Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ed9db1b
refactor: restructure next-issue recommendation bot into modular arch…
parvninama May 2, 2026
730100f
ci: harden recommendation bot workflow and fix checkout safety
parvninama May 2, 2026
c17da0d
chore: refactor comments and jsdocs
parvninama May 3, 2026
f9cfae5
refactor: reduce cyclomatic complexity of detectUnlockedLevel
parvninama May 3, 2026
efa92d9
feat: add test files for next issue recommendation
parvninama May 7, 2026
2a05d57
refactor: extract hardcode document links into config
parvninama May 7, 2026
268115c
chore: remove trailing whitespace
parvninama May 7, 2026
a644f08
fix: exclude the assignedIssue from recommended issues pool
parvninama May 8, 2026
c9602cc
chore: update JSdocs for detectUnlockedLevel
parvninama May 8, 2026
a503585
ci:harden workflow checkout for pull_request_target safety
parvninama May 8, 2026
70dbab8
fix: delayed milestone unlock detection
parvninama May 8, 2026
5b6999b
fix: add lable validation in config.js
parvninama May 8, 2026
272f9f5
fix: escape issue titles in recommendation comments
parvninama May 8, 2026
b4b85c0
fix: scope linked-issue exclusion to repository identity
parvninama May 8, 2026
3699d62
refactor: add Logs for better Error handling
parvninama May 8, 2026
c363f86
refactor: centralize canonical skill level keys
parvninama May 8, 2026
8acf6a2
fix: paginate PR comments when checking existing bot replies
parvninama May 8, 2026
42c6f13
fix: minor suggestions suggested by coderabbit and copilot
parvninama May 8, 2026
46b582b
refactor: simplify markdown escaping logic
parvninama May 8, 2026
49b6f33
fix: Wrap alreadyCommented in try/catch
parvninama May 8, 2026
38d8cb0
chore: strengthen fallback chain priority assertions
parvninama May 8, 2026
b8fc59f
Merge branch 'main' into refactor/bot-next-issue-recommendation
parvninama May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 108 additions & 240 deletions .github/scripts/bot-next-issue-recommendation.js
Original file line number Diff line number Diff line change
@@ -1,280 +1,148 @@
const {
GOOD_FIRST_ISSUE_LABEL,
BEGINNER_LABEL,
INTERMEDIATE_LABEL,
ADVANCED_LABEL,
isSafeLabel,
} = require('./shared/labels.js');

for (const label of [GOOD_FIRST_ISSUE_LABEL, BEGINNER_LABEL, INTERMEDIATE_LABEL, ADVANCED_LABEL]) {
if (!isSafeLabel(label)) {
throw new Error(`Invalid configured label: ${label}`);
}
}
// .github/scripts/bot-next-issue-recommendation.js
//
// Recommends issues to contributors after a PR is merged.
//
// Recommendation priority (per issue description):
// 1. One level above what they just completed, home repo
// 2. One level above, other repos
// 3. Same level, home repo
// 4. Same level, other repos
// 5. One level below (never below beginner, never GFI), home repo then other repos
// 6. Silent — nothing posted
//
// Eligibility history caps the ceiling: a contributor who just completed GFI
// cannot be recommended intermediate even if intermediate issues are available.
// The target level is driven by what they completed, bounded by eligibility.
//
// Each repo declares its own label strings since naming varies across repos.

const SUPPORTED_GFI_REPOS = [
'hiero-sdk-cpp',
'hiero-sdk-swift',
'hiero-sdk-python',
'hiero-website',
'hiero-sdk-js',
];
const {
CONFIG,
getHighestSkillLevelKey,
getRecommendedIssues,
buildRecommendationComment,
postComment,
alreadyCommented,
extractLinkedIssueNumber,
} = require('./shared');

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

module.exports = async ({ github, context, core }) => {
const { payload } = context;
const prNumber = payload.pull_request?.number;
const prBody = payload.pull_request?.body ?? '';
const username = payload.pull_request?.user?.login;
const homeRepo = CONFIG.repos.find(r => r.isHome);

// Get PR information from automatic pull_request_target trigger
let prNumber = payload.pull_request?.number;
let prBody = payload.pull_request?.body || '';

// Manual workflow_dispatch is no longer supported - inputs were removed
// Only automatic triggers from merged PRs will work
const repoOwner = context.repo.owner;
const repoName = context.repo.repo;

if (!prNumber) {
core.info('No PR number found, skipping');
if (!homeRepo) {
core.setFailed('No home repo configured (missing isHome: true)');
return;
}

core.info(`Processing PR #${prNumber}`);

// Parse PR body to find linked issues
const MAX_PR_BODY_LENGTH = 50000; // Reasonable limit for PR body
if (prBody.length > MAX_PR_BODY_LENGTH) {
core.warning(`PR body exceeds ${MAX_PR_BODY_LENGTH} characters, truncating for parsing`);
prBody = prBody.substring(0, MAX_PR_BODY_LENGTH);
}
const issueRegex = /(fixes|closes|resolves|fix|close|resolve)\s+(?:[\w-]+\/[\w-]+)?#(\d+)/gi;
const matches = [...prBody.matchAll(issueRegex)];

if (matches.length === 0) {
core.info('No linked issues found in PR body');
if (!prNumber || !username) {
core.info('Missing PR number or username, skipping');
return;
}
Comment thread
parvninama marked this conversation as resolved.

// Get the first linked issue number
const issueNumber = parseInt(matches[0][2]);
core.info(`Found linked issue #${issueNumber}`);
core.info(`Processing PR #${prNumber} by @${username}`);

const { owner, repo } = homeRepo;
try {
// Fetch issue details
const { data: issue } = await github.rest.issues.get({
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
});

// Normalize and check issue labels (case-insensitive)
const labelNames = issue.labels.map(label => label.name.toLowerCase());
const labelSet = new Set(labelNames);
core.info(`Issue labels: ${labelNames.join(', ')}`);

// Determine issue difficulty level
const difficultyLevels = {
beginner: labelSet.has(BEGINNER_LABEL.toLowerCase()),
goodFirstIssue: labelSet.has(GOOD_FIRST_ISSUE_LABEL.toLowerCase()),
intermediate: labelSet.has(INTERMEDIATE_LABEL.toLowerCase()),
advanced: labelSet.has(ADVANCED_LABEL.toLowerCase()),
};

// Skip if intermediate or advanced
if (difficultyLevels.intermediate || difficultyLevels.advanced) {
core.info('Issue is intermediate or advanced level, skipping recommendation');
return;
}

// Only proceed for Good First Issue or beginner issues
if (!difficultyLevels.goodFirstIssue && !difficultyLevels.beginner) {
core.info('Issue is not a Good First Issue or beginner issue, skipping');
if (await alreadyCommented(github, owner, repo, prNumber)) {
core.info('Recommendation already posted, skipping');
return;
}

let recommendedIssues = [];
let recommendedLabel = null;
let isFallback = false;

recommendedIssues = await searchIssues(github, core, repoOwner, repoName, BEGINNER_LABEL);
recommendedLabel = BEGINNER_LABEL;

if (recommendedIssues.length === 0) {
isFallback = true;
recommendedIssues = await searchIssues(github, core, repoOwner, repoName, GOOD_FIRST_ISSUE_LABEL);
recommendedLabel = GOOD_FIRST_ISSUE_LABEL;
}


// Remove the issue they just solved
recommendedIssues = recommendedIssues.filter(i => i.number !== issueNumber);

// Generate and post comment
const completedLabel = difficultyLevels.goodFirstIssue ? GOOD_FIRST_ISSUE_LABEL : BEGINNER_LABEL;
const completedLabelText = completedLabel === BEGINNER_LABEL ? `${BEGINNER_LABEL} issue` : completedLabel;
const recommendationMeta = {
completedLabelText,
recommendedLabel,
isFallback,
};
await generateAndPostComment(github, context, core, prNumber, recommendedIssues, recommendationMeta);

} catch (error) {
core.setFailed(`Error processing issue #${issueNumber}: ${error.message}`);
core.warning(
`Could not verify existing recommendation comment on PR #${prNumber}: ${error.message}; skipping to avoid duplicate post`,
);
return;
}
};

async function searchIssues(github, core, owner, repo, label) {
try {
const query = `repo:${owner}/${repo} type:issue state:open label:"${label}" no:assignee`;
core.info(`Searching for issues with query: ${query}`);

const { data: searchResult } = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 6,
});

core.info(`Found ${searchResult.items.length} issues with label "${label}"`);
return searchResult.items;
} catch (error) {
core.warning(`Error searching for issues with label "${label}": ${error.message}`);
return [];
const linkedIssueNumber = extractLinkedIssueNumber(prBody);
if (!linkedIssueNumber) {
core.info('No linked issue found in PR body, skipping');
return;
}
}

async function generateAndPostComment(github, context, core, prNumber, recommendedIssues, { completedLabelText, recommendedLabel, isFallback }) {
const marker = '<!-- next-issue-bot-marker -->';
core.info(`Linked issue: #${linkedIssueNumber}`);

// Build comment content
let comment = `${marker}\n\n🎉 **Nice work completing a ${completedLabelText}!**\n\n`;
comment += `Thank you for your contribution to the Hiero Python SDK! We're excited to have you as part of our community.\n\n`;

if (recommendedIssues.length > 0) {

if (isFallback) {
comment += `Here are some **${recommendedLabel}** issues at a similar level you might be interested in working on next:\n\n`;
} else {
comment += `Here are some issues labeled **${recommendedLabel}** you might be interested in working on next:\n\n`;
}

// Sanitize title: escape markdown link syntax and special characters
const sanitizeTitle = (title) => title
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');

recommendedIssues.slice(0, 5).forEach((issue, index) => {
comment += `${index + 1}. [${sanitizeTitle(issue.title)}](${issue.html_url})\n`;
if (issue.body && issue.body.length > 0) {
const description = extractIssueDescription(issue.body);
comment += ` ${description}\n\n`;
} else {
comment += ` *No description available*\n\n`;
}
let issue;
try {
const { data } = await github.rest.issues.get({
owner, repo, issue_number: linkedIssueNumber,
});
} else {
comment += `There are currently no open issues available at or near the ${completedLabelText} level in this repository.\n\n`;
comment += `You can check out **Good First Issues** in other Hiero repositories:\n\n`;
const repoQuery = SUPPORTED_GFI_REPOS
.map(repo => `repo:${context.repo.owner}/${repo}`)
.join(' OR ');

const gfiSearchQuery = [
'is:open',
'is:issue',
`org:${context.repo.owner}`,
'archived:false',
'no:assignee',
`label:"${GOOD_FIRST_ISSUE_LABEL}"`,
`(${repoQuery})`,
].join(' ');

const gfiQuery = `https://github.com/issues?q=${encodeURIComponent(gfiSearchQuery)}`;
issue = data;
} catch (error) {
core.warning(`Could not fetch issue #${linkedIssueNumber}: ${error.message}`);
return;
}

comment += `[View Good First Issues across supported Hiero repositories](${gfiQuery})\n\n`;
const completedLevelKey = getHighestSkillLevelKey(issue, homeRepo);
if (!completedLevelKey) {
core.info(`Issue #${linkedIssueNumber} has no recognised skill level label, skipping`);
return;
}

comment += `🌟 **Stay connected with the project:**\n`;
comment += `- ⭐ [Star this repository](https://github.com/${context.repo.owner}/${context.repo.repo}) to show your support\n`;
comment += `- 👀 [Watch this repository](https://github.com/${context.repo.owner}/${context.repo.repo}/watchers) to get notified of new issues and releases\n\n`;
core.info(`Completed level: ${completedLevelKey}`);

comment += `We look forward to seeing more contributions from you! If you have any questions, feel free to ask in our [Discord community](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md).\n\n`;
comment += `From the Hiero Python SDK Team 🚀`;
const completedDisplayName = CONFIG.skillPrerequisites[completedLevelKey]?.displayName ?? completedLevelKey;

// Check for existing comment
let result;
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});

const existingComment = comments.find(c => c.body.includes(marker));

if (existingComment) {
core.info('Comment already exists, skipping');
return;
}
result = await getRecommendedIssues(
github,
homeRepo,
username,
completedLevelKey,
{
owner,
repo,
issueNumber: linkedIssueNumber
},
core,
);
} catch (error) {
core.warning(`Error checking existing comments: ${error.message}`);
core.error(`Error generating recommendations: ${error.message}`);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Post the comment
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: comment,
});

core.info(`Successfully posted comment to PR #${prNumber}`);
} catch (error) {
core.setFailed(`Error posting comment: ${error.message}`);
if (!result) {
core.warning('Recommendation engine returned no result');
return;
}
}
function extractIssueDescription(body) {
if (!body) return '*No description available*';

// Guard against massive inputs blocking the regex event loop
const safeBody = body.length > 50000 ? body.substring(0, 50000) : body;

// Try to find the specific description section first
const sectionRegex = /^##*\s*👾\s*(Description of the issue|Issue description)[^\r\n]*\r?\n([\s\S]*?)(?=^#|$(?![\s\S]))/im;
const match = safeBody.match(sectionRegex);

let targetText = safeBody;
if (match) {
if (match[2] && match[2].trim()) {
targetText = match[2];
const { issues, fromRepo, unlockedLevelKey } = result;

if (issues.length === 0) {
if (unlockedLevelKey) {
// Milestone crossed but no issues available — post the unlock banner on its own.
const posted = await postComment(
github, owner, repo, prNumber,
buildRecommendationComment(username, [], completedDisplayName, unlockedLevelKey),
core,
);
if (!posted) core.setFailed(`Failed to post milestone comment on PR #${prNumber}`);
} else {
return '*No description available*';
core.info('No eligible issues found, staying silent');
}
return;
}

// Sanitize: strip HTML, normalize whitespace, escape markdown formatting (links, emphasis, bold)
const sanitized = targetText
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Remove markdown links, keep text
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Remove bold
.replace(/(\*|_)(.*?)\1/g, '$2') // Remove italics
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code and codeblocks
.replace(/#{1,6}\s?/g, '') // Remove headings
.replace(/>/g, '') // Remove blockquotes
.replace(/\|/g, '') // Remove table pipes
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();

// If we couldn't find a meaningful description, return a default
if (!sanitized) {
return '*No description available*';
}

// Truncate to 150 characters
if (sanitized.length > 150) {
return sanitized.substring(0, 150) + '...';
if (fromRepo && fromRepo !== `${owner}/${repo}`) {
core.info(`Recommending from cross-repo: ${fromRepo}`);
}

return sanitized;
}
const posted = await postComment(
github, owner, repo, prNumber,
buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey),
core,
);

module.exports.extractIssueDescription = extractIssueDescription;
if (!posted) {
core.setFailed(`Failed to post recommendation comment on PR #${prNumber}`);
}
};
Loading
Loading