-
Notifications
You must be signed in to change notification settings - Fork 287
refactor: Expand and refactor next-issue-recommendation bot #2224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
aceppaluni
merged 22 commits into
hiero-ledger:main
from
parvninama:refactor/bot-next-issue-recommendation
May 11, 2026
Merged
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 730100f
ci: harden recommendation bot workflow and fix checkout safety
parvninama c17da0d
chore: refactor comments and jsdocs
parvninama f9cfae5
refactor: reduce cyclomatic complexity of detectUnlockedLevel
parvninama efa92d9
feat: add test files for next issue recommendation
parvninama 2a05d57
refactor: extract hardcode document links into config
parvninama 268115c
chore: remove trailing whitespace
parvninama a644f08
fix: exclude the assignedIssue from recommended issues pool
parvninama c9602cc
chore: update JSdocs for detectUnlockedLevel
parvninama a503585
ci:harden workflow checkout for pull_request_target safety
parvninama 70dbab8
fix: delayed milestone unlock detection
parvninama 5b6999b
fix: add lable validation in config.js
parvninama 272f9f5
fix: escape issue titles in recommendation comments
parvninama b4b85c0
fix: scope linked-issue exclusion to repository identity
parvninama 3699d62
refactor: add Logs for better Error handling
parvninama c363f86
refactor: centralize canonical skill level keys
parvninama 8acf6a2
fix: paginate PR comments when checking existing bot replies
parvninama 42c6f13
fix: minor suggestions suggested by coderabbit and copilot
parvninama 46b582b
refactor: simplify markdown escaping logic
parvninama 49b6f33
fix: Wrap alreadyCommented in try/catch
parvninama 38d8cb0
chore: strengthen fallback chain priority assertions
parvninama b8fc59f
Merge branch 'main' into refactor/bot-next-issue-recommendation
parvninama File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| // 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; | ||
| } | ||
|
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}`); | ||
| } | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.