From ed9db1b3187f741ba9e6f11603129b95ef9765b0 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Sun, 3 May 2026 04:15:51 +0530 Subject: [PATCH 01/21] refactor: restructure next-issue recommendation bot into modular architecture Signed-off-by: Parv Ninama --- .../scripts/bot-next-issue-recommendation.js | 334 +++++------------- .github/scripts/shared/api/github-api.js | 68 ++++ .github/scripts/shared/config.js | 57 +++ .github/scripts/shared/core/eligibility.js | 157 ++++++++ .github/scripts/shared/core/recommendation.js | 127 +++++++ .github/scripts/shared/helpers/comment.js | 69 ++++ .github/scripts/shared/helpers/pr-helpers.js | 71 ++++ .github/scripts/shared/helpers/utils.js | 34 ++ .github/scripts/shared/index.js | 27 ++ 9 files changed, 702 insertions(+), 242 deletions(-) create mode 100644 .github/scripts/shared/api/github-api.js create mode 100644 .github/scripts/shared/config.js create mode 100644 .github/scripts/shared/core/eligibility.js create mode 100644 .github/scripts/shared/core/recommendation.js create mode 100644 .github/scripts/shared/helpers/comment.js create mode 100644 .github/scripts/shared/helpers/pr-helpers.js create mode 100644 .github/scripts/shared/helpers/utils.js create mode 100644 .github/scripts/shared/index.js diff --git a/.github/scripts/bot-next-issue-recommendation.js b/.github/scripts/bot-next-issue-recommendation.js index 6cc8c8aa2..2b791f5d8 100644 --- a/.github/scripts/bot-next-issue-recommendation.js +++ b/.github/scripts/bot-next-issue-recommendation.js @@ -1,280 +1,130 @@ -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 (!homeRepo) { + core.setFailed('No home repo configured (missing isHome: true)'); + return; + } - if (!prNumber) { - core.info('No PR number found, skipping'); + if (!prNumber || !username) { + core.info('Missing PR number or username, skipping'); return; } - core.info(`Processing PR #${prNumber}`); + core.info(`Processing PR #${prNumber} by @${username}`); - // 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 { owner, repo } = homeRepo; + if (await alreadyCommented(github, owner, repo, prNumber)) { + core.info('Recommendation already posted, skipping'); + return; } - 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'); + const issueNumber = extractLinkedIssueNumber(prBody); + if (!issueNumber) { + core.info('No linked issue found in PR body, skipping'); return; } - // Get the first linked issue number - const issueNumber = parseInt(matches[0][2]); - core.info(`Found linked issue #${issueNumber}`); + core.info(`Linked issue: #${issueNumber}`); + let issue; try { - // Fetch issue details - const { data: issue } = await github.rest.issues.get({ - owner: repoOwner, - repo: repoName, - issue_number: issueNumber, + const { data } = await github.rest.issues.get({ + owner, repo, 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'); - 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); - + issue = data; } catch (error) { - core.setFailed(`Error processing issue #${issueNumber}: ${error.message}`); - } -}; - -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 []; + core.warning(`Could not fetch issue #${issueNumber}: ${error.message}`); + return; } -} -async function generateAndPostComment(github, context, core, prNumber, recommendedIssues, { completedLabelText, recommendedLabel, isFallback }) { - const marker = ''; - - // 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`; - } - }); - } 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)}`; - - comment += `[View Good First Issues across supported Hiero repositories](${gfiQuery})\n\n`; + const completedLevelKey = getHighestSkillLevelKey(issue, homeRepo); + if (!completedLevelKey) { + core.info(`Issue #${issueNumber} 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, core); } catch (error) { - core.warning(`Error checking existing comments: ${error.message}`); + core.error(`Error generating recommendations: ${error.message}`); + return; } - // 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}`); + } +}; diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js new file mode 100644 index 000000000..36f897636 --- /dev/null +++ b/.github/scripts/shared/api/github-api.js @@ -0,0 +1,68 @@ +// --------------------------------------------------------------------------- +// GitHub API helpers +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); + +/** + * Counts closed issues historically assigned to a contributor at a given label, + * capped at `cap` to limit API result size. + * + * Uses the search API rather than listForRepo because GitHub drops assignee + * metadata from closed issues in the standard list endpoint. + * + * @param {import('@actions/github').GitHub} github + * @param {string} owner - Repo owner. + * @param {string} repo - Repo name. + * @param {string} username - GitHub login of the contributor. + * @param {string} labelString - Repo-specific label string to filter by. + * @param {number} cap - Maximum results to request (capped at 100). + * @returns {Promise} Count of matching issues, or null on API failure. + */ +async function countClosedIssuesByAssignee(github, owner, repo, username, labelString, cap) { + try { + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue is:closed assignee:${username} label:"${labelString}"`, + per_page: Math.min(cap, 100), + }); + return data.items.length; + } catch { + return null; + } +} + +/** + * Fetches a batch of open, unassigned issues from a repo, sorted oldest-first. + * Intentionally broad — label filtering happens client-side in filterIssuesByLevel + * to avoid one search call per skill level per repo. + * + * @param {import('@actions/github').GitHub} github + * @param {object} repoConfig - Repo entry from CONFIG.repos. + * @returns {Promise} Issue array, or null on API failure. + */ +async function fetchIssuesBatch(github, repoConfig) { + try { + const query = [ + `repo:${repoConfig.owner}/${repoConfig.repo}`, + 'is:issue', + 'is:open', + 'no:assignee', + ].join(' '); + + const { data } = await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: CONFIG.fetchPerPage, + sort: 'created', + order: 'asc', + }); + + return data.items ?? []; + } catch { + return null; + } +} + +module.exports = { + fetchIssuesBatch, + countClosedIssuesByAssignee, +}; diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js new file mode 100644 index 000000000..2ed0f610e --- /dev/null +++ b/.github/scripts/shared/config.js @@ -0,0 +1,57 @@ +// CONFIG helper + +const { + GOOD_FIRST_ISSUE_LABEL, + BEGINNER_LABEL, + INTERMEDIATE_LABEL, + ADVANCED_LABEL, +} = require('./labels'); + +const CONFIG = { + // Internal canonical keys — never used as label strings directly. + // GFI is index 0 and is entry-only: never recommended after first completion. + skillHierarchy: ['gfi', 'beginner', 'intermediate', 'advanced'], + + // requiredLevel: canonical key the contributor must have completed N times + // requiredCount: completions needed (0 = no prerequisite) + skillPrerequisites: { + gfi: { requiredLevel: null, requiredCount: 0, displayName: 'Good First Issue' }, + beginner: { requiredLevel: 'gfi', requiredCount: 1, displayName: 'Beginner' }, + intermediate: { requiredLevel: 'beginner', requiredCount: 3, displayName: 'Intermediate' }, + advanced: { requiredLevel: 'intermediate', requiredCount: 3, displayName: 'Advanced' }, + }, + + // Repos tried in order for each fallback step. + // Home repo must be first — contributor history is resolved against it. + repos: [ + { + owner: 'hiero-ledger', + repo: 'hiero-sdk-python', + isHome: true, + labels: { + gfi: GOOD_FIRST_ISSUE_LABEL, + beginner: BEGINNER_LABEL, + intermediate: INTERMEDIATE_LABEL, + advanced: ADVANCED_LABEL, + }, + }, + { + owner: 'hiero-ledger', + repo: 'hiero-sdk-cpp', + labels: { + gfi: 'skill: good first issue', + beginner: 'skill: beginner', + intermediate: 'skill: intermediate', + advanced: 'skill: advanced', + }, + }, + ], + + maxRecommendations: 5, + fetchPerPage: 50, + commentMarker: '', +}; + +module.exports = { + CONFIG, +}; diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js new file mode 100644 index 000000000..b08432912 --- /dev/null +++ b/.github/scripts/shared/core/eligibility.js @@ -0,0 +1,157 @@ +// --------------------------------------------------------------------------- +// Eligibility resolution +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); +const { repoLabelFor } = require('../helpers/utils'); +const { countClosedIssuesByAssignee } = require('../api/github-api'); + +// Fast path: user has already completed ≄1 issue at this level or higher +async function passesBypassCheck(github, homeRepo, username, candidate) { + const atOrAbove = CONFIG.skillHierarchy.slice(CONFIG.skillHierarchy.indexOf(candidate)); + + const counts = await Promise.all( + atOrAbove.map(key => countClosedIssuesByAssignee( + github, homeRepo.owner, homeRepo.repo, username, + repoLabelFor(homeRepo, key), 1, + )) + ); + + return counts.some(count => count !== null && count >= 1); +} + +/** + * Checks whether a contributor meets the prerequisite count for a given level. + * + * Unlike the bypass check, this verifies that the contributor has completed + * enough issues at the *previous* level to formally qualify for the candidate. + */ +async function passesNormalCheck(github, homeRepo, username, prereq) { + const count = await countClosedIssuesByAssignee( + github, homeRepo.owner, homeRepo.repo, username, + repoLabelFor(homeRepo, prereq.requiredLevel), prereq.requiredCount, + ); + if (count === null) return null; + return count >= prereq.requiredCount; +} + +/** + * Determines whether a contributor is eligible for a single candidate level. + * + * Two checks are tried in order: + * 1. Floor level (no prerequisite) — always eligible. + * 2. Bypass — has already closed ≄1 issue at this level or higher. + * 3. Normal — has met the prerequisite count at the previous level. + * + * Returns null on API failure so the caller can conservatively skip the candidate. + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo - Home repo entry from CONFIG.repos. + * @param {string} username - GitHub login of the contributor. + * @param {string} candidate - Canonical level key being evaluated. + * @returns {Promise} True if eligible, false if not, null on API failure. + */ +async function isEligibleFor(github, homeRepo, username, candidate) { + const prereq = CONFIG.skillPrerequisites[candidate]; + + // Floor level (gfi) has no prerequisite — always eligible as a floor. + if (!prereq?.requiredLevel) return true; + + const bypass = await passesBypassCheck(github, homeRepo, username, candidate); + if (bypass) return true; + + // null (API failure) is treated as false — caller skips conservatively. + const normal = await passesNormalCheck(github, homeRepo, username, prereq); + return normal === true; +} + +/** + * Determines the highest skill level a contributor is eligible for, + * based solely on their *historical* closed issues (excludes this PR). + * + * Walks the hierarchy from highest to lowest and returns the first + * level for which the contributor is eligible. The result acts as a + * ceiling — recommendations will not exceed this level. + * + * The caller is responsible for adjusting this ceiling to account for + * the current PR's completion (see adjustEligibilityForCurrentPR). + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo - Home repo entry from CONFIG.repos. + * @param {string} username - GitHub login of the contributor. + * @returns {Promise} Canonical level key of the eligibility ceiling. + */ +async function resolveEligibleLevel(github, homeRepo, username) { + for (const candidate of [...CONFIG.skillHierarchy].reverse()) { + const eligible = await isEligibleFor(github, homeRepo, username, candidate); + // eligible === null means API failure — skip conservatively. + if (eligible === true) return candidate; + } + return 'gfi'; +} + +/** + * Adjusts the historical eligibility ceiling to include the current PR's completion. + * + * resolveEligibleLevel is intentionally blind to the PR being processed. + * Without this adjustment, a contributor completing their very first GFI + * would have an eligibility ceiling of 'gfi' and never see beginner issues. + * + * Logic: if the ceiling is at or below the completed level, bump it one step up. + * + * @param {string} completedKey - Canonical key of the level just completed. + * @param {string} eligibleKey - Historical ceiling from resolveEligibleLevel. + * @returns {string} Adjusted ceiling key. + */ +function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { + const h = CONFIG.skillHierarchy; + const completedIdx = h.indexOf(completedKey); + const eligibleIdx = h.indexOf(eligibleKey); + + return eligibleIdx <= completedIdx + ? h[Math.min(completedIdx + 1, h.length - 1)] + : eligibleKey; +} + +/** + * Detects whether the current PR completion crossed the threshold into the next level. + * + * Checks if the contributor has reached *exactly* the required count for the next + * level after this PR. Uses cap = requiredCount + 1 to avoid over-fetching. + * + * Example: beginner → intermediate requires 3 beginner completions. + * If the search returns count === 3, this PR was the one that crossed the line. + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo - Home repo entry from CONFIG.repos. + * @param {string} username - GitHub login of the contributor. + * @param {string} currentLevelKey - Canonical key of the level just completed. + * @returns {Promise} Canonical key of the unlocked level, or null. + */ +async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) { + const hierarchy = CONFIG.skillHierarchy; + const currentIndex = hierarchy.indexOf(currentLevelKey); + const nextKey = hierarchy[currentIndex + 1] ?? null; + + if (!nextKey) return null; + + const nextPrereq = CONFIG.skillPrerequisites[nextKey]; + if (!nextPrereq?.requiredLevel || nextPrereq.requiredLevel !== currentLevelKey) return null; + + const count = await countClosedIssuesByAssignee( + github, homeRepo.owner, homeRepo.repo, username, + repoLabelFor(homeRepo, currentLevelKey), nextPrereq.requiredCount + 1, + ); + + if (count === null) return null; + return count === nextPrereq.requiredCount ? nextKey : null; +} + +module.exports = { + passesBypassCheck, + passesNormalCheck, + isEligibleFor, + resolveEligibleLevel, + adjustEligibilityForCurrentPR, + detectUnlockedLevel, +}; diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js new file mode 100644 index 000000000..ed522c068 --- /dev/null +++ b/.github/scripts/shared/core/recommendation.js @@ -0,0 +1,127 @@ +// --------------------------------------------------------------------------- +// Core recommendation logic +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); +const { filterIssuesByLevel } = require('../helpers/utils'); +const { fetchIssuesBatch } = require('../api/github-api'); +const { + resolveEligibleLevel, + detectUnlockedLevel, + adjustEligibilityForCurrentPR +} = require('./eligibility'); + +/** + * Order: level up → same → level down (floored at beginner, capped by eligibility) + * + * @param {string} completedLevelKey - Canonical key of the level just completed. + * @param {string} eligibleLevelKey - Adjusted eligibility ceiling. + * @returns {number[]} Deduplicated, ordered array of hierarchy indices to try. + */ +function computeLevelStepIndices(completedLevelKey, eligibleLevelKey) { + const h = CONFIG.skillHierarchy; + const completedIdx = h.indexOf(completedLevelKey); + const eligibleIdx = h.indexOf(eligibleLevelKey); + const beginnerIdx = h.indexOf('beginner'); + + const levelUp = completedIdx + 1 <= eligibleIdx ? completedIdx + 1 : null; + const same = completedIdx; + const levelDown = Math.max(completedIdx - 1, beginnerIdx); + + return [...new Set([levelUp, same, levelDown].filter(i => i !== null))]; +} + +/** + * Expand levels across repos (home repo first). GFI is never recommended. + * + * @param {string} completedLevelKey - Canonical key of the level just completed. + * @param {string} eligibleLevelKey - Adjusted eligibility ceiling. + * @returns {Array<{ levelKey: string, repoConfig: object }>} Ordered search chain. + */ +function buildFallbackChain(completedLevelKey, eligibleLevelKey) { + return computeLevelStepIndices(completedLevelKey, eligibleLevelKey) + .flatMap(i => { + const levelKey = CONFIG.skillHierarchy[i]; + // Hard block: GFI is entry-only and must never appear in recommendations. + if (levelKey === 'gfi') return []; + return CONFIG.repos.map(repoConfig => ({ levelKey, repoConfig })); + }); +} + +/** + * Fetch all repos once (parallel) to avoid duplicate API calls during fallback + * + * @param {import('@actions/github').GitHub} github + * @param {object} core - @actions/core logger. + * @returns {Promise>} Map of "owner/repo" → issues or null. + */ +async function prefetchIssues(github, core) { + const cache = new Map(); + + await Promise.all(CONFIG.repos.map(async repoConfig => { + const key = `${repoConfig.owner}/${repoConfig.repo}`; + const issues = await fetchIssuesBatch(github, repoConfig); + if (issues === null) core.warning(`Failed to fetch from ${key}, skipping this repo in all steps`); + cache.set(key, issues); + })); + + return cache; +} + +/** + * Main recommendation engine. + * + * Steps: + * 1. Resolve the historical eligibility ceiling and detect any milestone unlock + * in parallel (both need separate search calls, neither depends on the other). + * 2. Adjust the eligibility ceiling to include the current PR (see adjustEligibilityForCurrentPR). + * 3. Pre-fetch issues from all repos concurrently. + * 4. Walk the fallback chain and return the first level+repo combination + * that yields at least one matching issue. + * + * Returns an empty issues array when no match is found — the caller decides + * whether to post a milestone-only comment or stay silent. + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo - Home repo entry (CONFIG.repos[0]). + * @param {string} username - GitHub login of the contributor. + * @param {string} completedLevelKey - Canonical key of the level just completed. + * @param {object} core - @actions/core logger. + * @returns {Promise<{ issues: Array, fromRepo: string|null, unlockedLevelKey: string|null }>} + */ +async function getRecommendedIssues(github, homeRepo, username, completedLevelKey, core) { + const [eligibleLevelKey, unlockedLevelKey] = await Promise.all([ + resolveEligibleLevel(github, homeRepo, username), + detectUnlockedLevel(github, homeRepo, username, completedLevelKey), + ]); + + const adjustedEligibleKey = adjustEligibilityForCurrentPR(completedLevelKey, eligibleLevelKey); + core.info(`Completed: ${completedLevelKey}, Eligible ceiling: ${adjustedEligibleKey}, Unlocked: ${unlockedLevelKey ?? 'none'}`); + + const issueCache = await prefetchIssues(github, core); + const chain = buildFallbackChain(completedLevelKey, adjustedEligibleKey); + + for (const { levelKey, repoConfig } of chain) { + const repoKey = `${repoConfig.owner}/${repoConfig.repo}`; + const issues = issueCache.get(repoKey); + + // Skip if the fetch failed (null) or the repo is missing from the cache (undefined). + if (issues == null) continue; + + const picked = filterIssuesByLevel(issues, levelKey, repoConfig); + if (picked.length > 0) { + core.info(`Recommending ${levelKey} issues from ${repoKey}`); + return { issues: picked, fromRepo: repoKey, unlockedLevelKey }; + } + } + + core.info(`No eligible issues found for @${username}`); + return { issues: [], fromRepo: null, unlockedLevelKey }; +} + +module.exports = { + computeLevelStepIndices, + buildFallbackChain, + prefetchIssues, + getRecommendedIssues, +}; diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js new file mode 100644 index 000000000..ad95621e4 --- /dev/null +++ b/.github/scripts/shared/helpers/comment.js @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// Comment builder +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); + +function buildIssueListBlock(issues) { + if (issues.length > 0) { + return [ + 'Here are some issues you might want to explore next:', + '', + ...issues.map(i => `- [${i.title}](${i.html_url})`), + '', + ]; + } + return ['No suitable issues are available right now — check back soon!', '']; +} + +function buildMilestoneBlock(unlockedLevelKey) { + const displayName = unlockedLevelKey + ? CONFIG.skillPrerequisites[unlockedLevelKey]?.displayName + : null; + + return displayName + ? [`šŸ† **Milestone unlocked: you've reached ${displayName} level!** šŸŽ‰`, ''] + : []; +} + +/** + * Builds the full GitHub PR comment for issue recommendations. + * + * Structure: + * - HTML marker (used by alreadyCommented to detect duplicates) + * - Greeting and completion acknowledgment + * - Optional milestone unlock banner + * - Issue list (or "no issues" fallback) + * - Stay-connected footer + * + * @param {string} username - GitHub login of the contributor. + * @param {Array} issues - Recommended issues (may be empty). + * @param {string} completedDisplayName - Human-readable name of the completed level. + * @param {string|null} unlockedLevelKey - Canonical key of newly unlocked level, or null. + * @returns {string} Fully rendered comment body. + */ +function buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey = null) { + return [ + CONFIG.commentMarker, + '', + `šŸ‘‹ Hi @${username}! Great work completing a **${completedDisplayName}** issue! šŸŽ‰`, + '', + 'Thank you for your contribution to the Hiero Python SDK!', + '', + ...buildMilestoneBlock(unlockedLevelKey), + ...buildIssueListBlock(issues), + '🌟 **Stay connected:**', + '- ⭐ [Star this repository](https://github.com/hiero-ledger/hiero-sdk-python)', + '- šŸ‘€ [Watch for new issues](https://github.com/hiero-ledger/hiero-sdk-python/watchers)', + '- šŸ’¬ [Join us on Discord](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md)', + '', + 'Happy coding! šŸš€', + '_— Hiero Python SDK Team_', + ].join('\n'); +} + +module.exports = { + buildIssueListBlock, + buildMilestoneBlock, + buildRecommendationComment, +}; diff --git a/.github/scripts/shared/helpers/pr-helpers.js b/.github/scripts/shared/helpers/pr-helpers.js new file mode 100644 index 000000000..8092a0344 --- /dev/null +++ b/.github/scripts/shared/helpers/pr-helpers.js @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// PR parsing and comment helpers +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); + +/** + * Extracts the first linked issue number from a PR body using closing keywords. + * Supports: fixes, closes, resolves, fix, close, resolve (with or without colon), + * and cross-repo references (owner/repo#123) as well as plain #123. + * Input is capped at 50 000 chars to guard against pathologically large PR bodies. + * + * @param {string} prBody - Raw PR description text. + * @returns {number|null} Linked issue number, or null if none found. + */ +function extractLinkedIssueNumber(prBody) { + const safe = prBody.length > 50000 ? prBody.substring(0, 50000) : prBody; + const regex = /(fixes|closes|resolves|fix|close|resolve):?\s*(?:[\w-]+\/[\w-]+)?#(\d+)/gi; + const match = regex.exec(safe); + return match ? parseInt(match[2], 10) : null; +} + +/** + * Returns true if the bot has already posted a recommendation comment on this PR. + * Matches by the HTML marker string embedded at the top of every comment we post. + * Returns false on API failure to avoid blocking the run. + * + * @param {import('@actions/github').GitHub} github + * @param {string} owner - Repo owner. + * @param {string} repo - Repo name. + * @param {number} prNumber - PR number. + * @returns {Promise} + */ +async function alreadyCommented(github, owner, repo, prNumber) { + try { + const { data } = await github.rest.issues.listComments({ + owner, repo, issue_number: prNumber, per_page: 100, + }); + return data.some(c => c.body?.includes(CONFIG.commentMarker)); + } catch { + return false; + } +} + +/** + * Posts a comment on a PR and logs the outcome. + * + * @param {import('@actions/github').GitHub} github + * @param {string} owner - Repo owner. + * @param {string} repo - Repo name. + * @param {number} prNumber - PR number. + * @param {string} body - Comment body. + * @param {object} core - @actions/core logger. + * @returns {Promise} True if posted successfully, false otherwise. + */ +async function postComment(github, owner, repo, prNumber, body, core) { + try { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + core.info(`Posted recommendation comment to PR #${prNumber}`); + return true; + } catch (error) { + core.error(`Failed to post comment: ${error.message}`); + return false; + } +} + +module.exports = { + extractLinkedIssueNumber, + alreadyCommented, + postComment, +}; diff --git a/.github/scripts/shared/helpers/utils.js b/.github/scripts/shared/helpers/utils.js new file mode 100644 index 000000000..93adcba8e --- /dev/null +++ b/.github/scripts/shared/helpers/utils.js @@ -0,0 +1,34 @@ +// --------------------------------------------------------------------------- +// Label helpers +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); + +function repoLabelFor(repoConfig, levelKey) { + return repoConfig.labels[levelKey]; +} + +function hasLabel(issue, labelName) { + const target = labelName.toLowerCase(); + return (issue.labels ?? []).some(l => l.name.toLowerCase() === target); +} + +function filterIssuesByLevel(issues, levelKey, repoConfig) { + const labelString = repoLabelFor(repoConfig, levelKey); + return issues + .filter(issue => hasLabel(issue, labelString)) + .slice(0, CONFIG.maxRecommendations); +} + +function getHighestSkillLevelKey(issue, repoConfig) { + return [...CONFIG.skillHierarchy] + .reverse() + .find(key => hasLabel(issue, repoLabelFor(repoConfig, key))) ?? null; +} + +module.exports = { + repoLabelFor, + hasLabel, + filterIssuesByLevel, + getHighestSkillLevelKey, +}; diff --git a/.github/scripts/shared/index.js b/.github/scripts/shared/index.js new file mode 100644 index 000000000..38be20eeb --- /dev/null +++ b/.github/scripts/shared/index.js @@ -0,0 +1,27 @@ +// core +const { getRecommendedIssues } = require('./core/recommendation'); + +// helpers +const { getHighestSkillLevelKey } = require('./helpers/utils'); +const { buildRecommendationComment } = require('./helpers/comment'); +const { + postComment, + alreadyCommented, + extractLinkedIssueNumber +} = require('./helpers/pr-helpers'); + +// config +const { CONFIG } = require('./config'); + +module.exports = { + // core + getRecommendedIssues, + // helpers + getHighestSkillLevelKey, + buildRecommendationComment, + postComment, + alreadyCommented, + extractLinkedIssueNumber, + // config + CONFIG, +}; From 730100ff5f31c7f4d01a61a783c751fbd86471f0 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Sun, 3 May 2026 04:29:42 +0530 Subject: [PATCH 02/21] ci: harden recommendation bot workflow and fix checkout safety Signed-off-by: Parv Ninama --- .../{archive => }/bot-next-issue-recommendation.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename .github/workflows/{archive => }/bot-next-issue-recommendation.yml (75%) diff --git a/.github/workflows/archive/bot-next-issue-recommendation.yml b/.github/workflows/bot-next-issue-recommendation.yml similarity index 75% rename from .github/workflows/archive/bot-next-issue-recommendation.yml rename to .github/workflows/bot-next-issue-recommendation.yml index bf509e820..0e37ab762 100644 --- a/.github/workflows/archive/bot-next-issue-recommendation.yml +++ b/.github/workflows/bot-next-issue-recommendation.yml @@ -3,6 +3,7 @@ name: Next Issue Recommendation Bot on: pull_request_target: types: [closed] + branches: [main] permissions: pull-requests: write @@ -10,7 +11,7 @@ permissions: contents: read concurrency: - group: next-issue-bot-${{ github.event.pull_request.number || github.run_id }} + group: next-issue-bot-${{ github.event.pull_request.number }} cancel-in-progress: false jobs: @@ -20,7 +21,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -28,7 +29,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Recommend next issue - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # 8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From c17da0dfa15fb427847503c7b0944cfbede06f5b Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Mon, 4 May 2026 01:10:17 +0530 Subject: [PATCH 03/21] chore: refactor comments and jsdocs Signed-off-by: Parv Ninama --- .github/scripts/shared/api/github-api.js | 6 ++-- .github/scripts/shared/config.js | 1 + .github/scripts/shared/core/eligibility.js | 27 +++++++++------- .github/scripts/shared/core/recommendation.js | 2 +- .github/scripts/shared/helpers/comment.js | 26 ++++++++++++---- .github/scripts/shared/helpers/utils.js | 31 +++++++++++++++++-- .github/scripts/shared/index.js | 3 -- 7 files changed, 69 insertions(+), 27 deletions(-) diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index 36f897636..5c3d700f8 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -16,8 +16,8 @@ const { CONFIG } = require('../config'); * @param {string} repo - Repo name. * @param {string} username - GitHub login of the contributor. * @param {string} labelString - Repo-specific label string to filter by. - * @param {number} cap - Maximum results to request (capped at 100). - * @returns {Promise} Count of matching issues, or null on API failure. + * @param {number} cap - Maximum number of results to fetch (first page only, capped at 100). + * @returns {Promise} Number of fetched matching issues (not total count), or null on API failure. */ async function countClosedIssuesByAssignee(github, owner, repo, username, labelString, cap) { try { @@ -38,7 +38,7 @@ async function countClosedIssuesByAssignee(github, owner, repo, username, labelS * * @param {import('@actions/github').GitHub} github * @param {object} repoConfig - Repo entry from CONFIG.repos. - * @returns {Promise} Issue array, or null on API failure. + * @returns {Promise|null>} Issue array, or null on API failure. */ async function fetchIssuesBatch(github, repoConfig) { try { diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js index 2ed0f610e..904306c43 100644 --- a/.github/scripts/shared/config.js +++ b/.github/scripts/shared/config.js @@ -47,6 +47,7 @@ const CONFIG = { }, ], + repositoryUrl: 'https://github.com/hiero-ledger/hiero-sdk-python', maxRecommendations: 5, fetchPerPage: 50, commentMarker: '', diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index b08432912..2151f3814 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -8,16 +8,18 @@ const { countClosedIssuesByAssignee } = require('../api/github-api'); // Fast path: user has already completed ≄1 issue at this level or higher async function passesBypassCheck(github, homeRepo, username, candidate) { - const atOrAbove = CONFIG.skillHierarchy.slice(CONFIG.skillHierarchy.indexOf(candidate)); + const idx = CONFIG.skillHierarchy.indexOf(candidate); + if (idx === -1) return false; + const atOrAbove = CONFIG.skillHierarchy.slice(idx); - const counts = await Promise.all( - atOrAbove.map(key => countClosedIssuesByAssignee( + for (const key of atOrAbove) { + const count = await countClosedIssuesByAssignee( github, homeRepo.owner, homeRepo.repo, username, repoLabelFor(homeRepo, key), 1, - )) - ); - - return counts.some(count => count !== null && count >= 1); + ); + if (count !== null && count >= 1) return true; + } + return false; } /** @@ -49,7 +51,7 @@ async function passesNormalCheck(github, homeRepo, username, prereq) { * @param {object} homeRepo - Home repo entry from CONFIG.repos. * @param {string} username - GitHub login of the contributor. * @param {string} candidate - Canonical level key being evaluated. - * @returns {Promise} True if eligible, false if not, null on API failure. + * @returns {Promise} True if eligible, false otherwise (API failures treated as false). */ async function isEligibleFor(github, homeRepo, username, candidate) { const prereq = CONFIG.skillPrerequisites[candidate]; @@ -84,10 +86,10 @@ async function isEligibleFor(github, homeRepo, username, candidate) { async function resolveEligibleLevel(github, homeRepo, username) { for (const candidate of [...CONFIG.skillHierarchy].reverse()) { const eligible = await isEligibleFor(github, homeRepo, username, candidate); - // eligible === null means API failure — skip conservatively. + // false includes API failures (treated conservatively as ineligible) if (eligible === true) return candidate; } - return 'gfi'; + return CONFIG.skillHierarchy[0]; } /** @@ -131,8 +133,10 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) { const hierarchy = CONFIG.skillHierarchy; const currentIndex = hierarchy.indexOf(currentLevelKey); - const nextKey = hierarchy[currentIndex + 1] ?? null; + if (currentIndex === -1) return null; + + const nextKey = hierarchy[currentIndex + 1] ?? null; if (!nextKey) return null; const nextPrereq = CONFIG.skillPrerequisites[nextKey]; @@ -144,6 +148,7 @@ async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) ); if (count === null) return null; + // NOTE: count is derived from first-page results only; safe because requiredCount is small. return count === nextPrereq.requiredCount ? nextKey : null; } diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js index ed522c068..945ad444c 100644 --- a/.github/scripts/shared/core/recommendation.js +++ b/.github/scripts/shared/core/recommendation.js @@ -13,7 +13,7 @@ const { /** * Order: level up → same → level down (floored at beginner, capped by eligibility) - * + * * @param {string} completedLevelKey - Canonical key of the level just completed. * @param {string} eligibleLevelKey - Adjusted eligibility ceiling. * @returns {number[]} Deduplicated, ordered array of hierarchy indices to try. diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index ad95621e4..2ae4aef5b 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -4,18 +4,28 @@ const { CONFIG } = require('../config'); -function buildIssueListBlock(issues) { +function buildIssueListBlock(issues = []) { if (issues.length > 0) { return [ 'Here are some issues you might want to explore next:', '', - ...issues.map(i => `- [${i.title}](${i.html_url})`), + ...issues.map(i => { + const title = i?.title ?? 'Untitled issue'; + const url = i?.html_url ?? '#'; + return `- [${title}](${url})`; + }), '', ]; } return ['No suitable issues are available right now — check back soon!', '']; } +/** + * Builds a milestone banner if a new level was unlocked. + * + * @param {string|null} unlockedLevelKey + * @returns {string[]} Lines to include in the comment. + */ function buildMilestoneBlock(unlockedLevelKey) { const displayName = unlockedLevelKey ? CONFIG.skillPrerequisites[unlockedLevelKey]?.displayName @@ -43,19 +53,23 @@ function buildMilestoneBlock(unlockedLevelKey) { * @returns {string} Fully rendered comment body. */ function buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey = null) { + const repoUrl = CONFIG.repositoryUrl ?? ''; + const hasRepoUrl = Boolean(repoUrl); return [ CONFIG.commentMarker, '', `šŸ‘‹ Hi @${username}! Great work completing a **${completedDisplayName}** issue! šŸŽ‰`, '', - 'Thank you for your contribution to the Hiero Python SDK!', + 'Thanks for your contribution! šŸš€', '', ...buildMilestoneBlock(unlockedLevelKey), ...buildIssueListBlock(issues), + ...(hasRepoUrl ? [ '🌟 **Stay connected:**', - '- ⭐ [Star this repository](https://github.com/hiero-ledger/hiero-sdk-python)', - '- šŸ‘€ [Watch for new issues](https://github.com/hiero-ledger/hiero-sdk-python/watchers)', - '- šŸ’¬ [Join us on Discord](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md)', + `- ⭐ [Star this repository](${repoUrl})`, + `- šŸ‘€ [Watch for new issues](${repoUrl}/watchers)`, + `- šŸ’¬ [Join us on Discord](${repoUrl}/blob/main/docs/discord.md)`, + ]:[]), '', 'Happy coding! šŸš€', '_— Hiero Python SDK Team_', diff --git a/.github/scripts/shared/helpers/utils.js b/.github/scripts/shared/helpers/utils.js index 93adcba8e..1b403c1f1 100644 --- a/.github/scripts/shared/helpers/utils.js +++ b/.github/scripts/shared/helpers/utils.js @@ -4,26 +4,51 @@ const { CONFIG } = require('../config'); +/** + * Returns the repo-specific label string for a given canonical level key. + * + * @param {object} repoConfig - Repo config containing label mappings. + * @param {string} levelKey - Canonical skill level (e.g., 'beginner'). + * @returns {string|null} Label string or null if not defined. + */ function repoLabelFor(repoConfig, levelKey) { - return repoConfig.labels[levelKey]; + return repoConfig.labels[levelKey] ?? null; } function hasLabel(issue, labelName) { + if (!labelName) return false; + const target = labelName.toLowerCase(); - return (issue.labels ?? []).some(l => l.name.toLowerCase() === target); + return (issue.labels ?? []).some(l => l.name?.toLowerCase() === target); } +/** + * Filters issues by a specific skill level and caps the result size. + */ function filterIssuesByLevel(issues, levelKey, repoConfig) { const labelString = repoLabelFor(repoConfig, levelKey); + if (!labelString) return []; + return issues .filter(issue => hasLabel(issue, labelString)) .slice(0, CONFIG.maxRecommendations); } +/** + * Determines the highest skill level label present on an issue. + * Iterates from highest → lowest so the first match represents the ceiling. + * + * @param {object} issue + * @param {object} repoConfig + * @returns {string|null} Canonical level key or null if none found. + */ function getHighestSkillLevelKey(issue, repoConfig) { return [...CONFIG.skillHierarchy] .reverse() - .find(key => hasLabel(issue, repoLabelFor(repoConfig, key))) ?? null; + .find(key => { + const label = repoLabelFor(repoConfig, key); + return label && hasLabel(issue, label); + }) ?? null; } module.exports = { diff --git a/.github/scripts/shared/index.js b/.github/scripts/shared/index.js index 38be20eeb..51bc24c2b 100644 --- a/.github/scripts/shared/index.js +++ b/.github/scripts/shared/index.js @@ -14,14 +14,11 @@ const { const { CONFIG } = require('./config'); module.exports = { - // core getRecommendedIssues, - // helpers getHighestSkillLevelKey, buildRecommendationComment, postComment, alreadyCommented, extractLinkedIssueNumber, - // config CONFIG, }; From f9cfae5648805eb95c990b88979d27a714d5869e Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Mon, 4 May 2026 01:23:40 +0530 Subject: [PATCH 04/21] refactor: reduce cyclomatic complexity of detectUnlockedLevel Signed-off-by: Parv Ninama --- .github/scripts/shared/core/eligibility.js | 87 ++++++++++++++++------ 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index 2151f3814..fab11d934 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -6,7 +6,20 @@ const { CONFIG } = require('../config'); const { repoLabelFor } = require('../helpers/utils'); const { countClosedIssuesByAssignee } = require('../api/github-api'); -// Fast path: user has already completed ≄1 issue at this level or higher +/** + * Fast-path eligibility check. + * + * A contributor is considered eligible for `candidate` if they have already + * completed at least one issue at that level OR any higher level. + * + * This avoids forcing strict progression when the user already has experience. + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo + * @param {string} username + * @param {string} candidate - Canonical level key + * @returns {Promise} + */ async function passesBypassCheck(github, homeRepo, username, candidate) { const idx = CONFIG.skillHierarchy.indexOf(candidate); if (idx === -1) return false; @@ -23,10 +36,16 @@ async function passesBypassCheck(github, homeRepo, username, candidate) { } /** - * Checks whether a contributor meets the prerequisite count for a given level. + * Checks whether a contributor meets the prerequisite count for a level. + * + * This enforces the normal progression rules: + * e.g. "3 beginner issues required before intermediate". * - * Unlike the bypass check, this verifies that the contributor has completed - * enough issues at the *previous* level to formally qualify for the candidate. + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo + * @param {string} username + * @param {{ requiredLevel: string, requiredCount: number }} prereq + * @returns {Promise} null = API failure */ async function passesNormalCheck(github, homeRepo, username, prereq) { const count = await countClosedIssuesByAssignee( @@ -116,39 +135,61 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { } /** - * Detects whether the current PR completion crossed the threshold into the next level. - * - * Checks if the contributor has reached *exactly* the required count for the next - * level after this PR. Uses cap = requiredCount + 1 to avoid over-fetching. + * Computes next level metadata for unlock detection. * - * Example: beginner → intermediate requires 3 beginner completions. - * If the search returns count === 3, this PR was the one that crossed the line. + * Ensures: + * - current level exists + * - next level exists + * - next level depends on current level * - * @param {import('@actions/github').GitHub} github - * @param {object} homeRepo - Home repo entry from CONFIG.repos. - * @param {string} username - GitHub login of the contributor. - * @param {string} currentLevelKey - Canonical key of the level just completed. - * @returns {Promise} Canonical key of the unlocked level, or null. + * @param {string} currentLevelKey + * @returns {{ nextKey: string, nextPrereq: object } | null} */ -async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) { - const hierarchy = CONFIG.skillHierarchy; +function getNextLevelInfo(currentLevelKey) { + const hierarchy = CONFIG.skillHierarchy; const currentIndex = hierarchy.indexOf(currentLevelKey); - if (currentIndex === -1) return null; - const nextKey = hierarchy[currentIndex + 1] ?? null; + const nextKey = hierarchy[currentIndex + 1]; if (!nextKey) return null; const nextPrereq = CONFIG.skillPrerequisites[nextKey]; - if (!nextPrereq?.requiredLevel || nextPrereq.requiredLevel !== currentLevelKey) return null; + if (!nextPrereq || nextPrereq.requiredLevel !== currentLevelKey) return null; + + return { nextKey, nextPrereq }; +} + +/** + * Detects if the contributor just unlocked the next level. + * + * Trigger condition: + * completed count === requiredCount + * + * Uses a capped query (requiredCount + 1) for efficiency. + * + * @param {import('@actions/github').GitHub} github + * @param {object} homeRepo + * @param {string} username + * @param {string} currentLevelKey + * @returns {Promise} unlocked level key + */ +async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) { + const next = getNextLevelInfo(currentLevelKey); + if (!next) return null; + + const { nextKey, nextPrereq } = next; const count = await countClosedIssuesByAssignee( - github, homeRepo.owner, homeRepo.repo, username, - repoLabelFor(homeRepo, currentLevelKey), nextPrereq.requiredCount + 1, + github, + homeRepo.owner, + homeRepo.repo, + username, + repoLabelFor(homeRepo, currentLevelKey), + nextPrereq.requiredCount + 1, ); if (count === null) return null; - // NOTE: count is derived from first-page results only; safe because requiredCount is small. + return count === nextPrereq.requiredCount ? nextKey : null; } From efa92d91d6eeea10a14e9a8daeef62c743ff2a5c Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Thu, 7 May 2026 18:08:56 +0530 Subject: [PATCH 05/21] feat: add test files for next issue recommendation Signed-off-by: Parv Ninama --- .github/scripts/tests/recommendation.test.js | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/scripts/tests/recommendation.test.js diff --git a/.github/scripts/tests/recommendation.test.js b/.github/scripts/tests/recommendation.test.js new file mode 100644 index 000000000..0d16ac044 --- /dev/null +++ b/.github/scripts/tests/recommendation.test.js @@ -0,0 +1,92 @@ +// node --test .github/scripts/tests/recommendation.test.js + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + computeLevelStepIndices, + buildFallbackChain, +} = require('../shared/core/recommendation'); + +const { + adjustEligibilityForCurrentPR, +} = require('../shared/core/eligibility'); + +const { CONFIG } = require('../shared/config'); + +// --------------------------------------------------------------------------- +// computeLevelStepIndices +// --------------------------------------------------------------------------- + +test('computeLevelStepIndices prefers level up, same, then level down', () => { + const result = computeLevelStepIndices('beginner', 'intermediate'); + + // beginner=1, intermediate=2 + assert.deepEqual(result, [2, 1]); +}); + +test('computeLevelStepIndices falls back to same and lower level', () => { + const result = computeLevelStepIndices('intermediate', 'intermediate'); + + // intermediate=2, beginner=1 + assert.deepEqual(result, [2, 1]); +}); + +test('computeLevelStepIndices never recommends gfi as fallback', () => { + const result = computeLevelStepIndices('beginner', 'beginner'); + + // beginner only (no gfi fallback) + assert.deepEqual(result, [1]); +}); + +// --------------------------------------------------------------------------- +// buildFallbackChain +// --------------------------------------------------------------------------- + +test('buildFallbackChain expands levels across repos in priority order', () => { + const chain = buildFallbackChain('beginner', 'intermediate'); + + assert.ok(chain.length > 0); + + // First recommendation should be intermediate in home repo + assert.equal(chain[0].levelKey, 'intermediate'); + + // Ensure no gfi recommendations exist + const hasGfi = chain.some(entry => entry.levelKey === 'gfi'); + assert.equal(hasGfi, false); +}); + +// --------------------------------------------------------------------------- +// adjustEligibilityForCurrentPR +// --------------------------------------------------------------------------- + +test('adjustEligibilityForCurrentPR bumps eligibility after completing current level', () => { + const adjusted = adjustEligibilityForCurrentPR('gfi', 'gfi'); + + assert.equal(adjusted, 'beginner'); +}); + +test('adjustEligibilityForCurrentPR does not downgrade higher eligibility', () => { + const adjusted = adjustEligibilityForCurrentPR( + 'beginner', + 'advanced', + ); + + assert.equal(adjusted, 'advanced'); +}); + +// --------------------------------------------------------------------------- +// basic integration-style recommendation flow +// --------------------------------------------------------------------------- + +test('fallback chain includes repos for eligible levels only', () => { + const chain = buildFallbackChain('intermediate', 'advanced'); + + const validLevels = new Set(CONFIG.skillHierarchy); + + for (const entry of chain) { + assert.ok(validLevels.has(entry.levelKey)); + assert.ok(entry.repoConfig.owner); + assert.ok(entry.repoConfig.repo); + } +}); From 2a05d57c3660b519053cfa5d584a353b0a43b959 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Thu, 7 May 2026 18:25:46 +0530 Subject: [PATCH 06/21] refactor: extract hardcode document links into config Signed-off-by: Parv Ninama --- .github/scripts/shared/config.js | 6 +++++- .github/scripts/shared/helpers/comment.js | 21 ++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js index 904306c43..7c66eee97 100644 --- a/.github/scripts/shared/config.js +++ b/.github/scripts/shared/config.js @@ -28,6 +28,11 @@ const CONFIG = { owner: 'hiero-ledger', repo: 'hiero-sdk-python', isHome: true, + repositoryUrl: 'https://github.com/hiero-ledger/hiero-sdk-python', + communityLinks: { + discord: 'https://discord.com/invite/hyperledger', + }, + botSignature: 'Hiero Python SDK Team', labels: { gfi: GOOD_FIRST_ISSUE_LABEL, beginner: BEGINNER_LABEL, @@ -47,7 +52,6 @@ const CONFIG = { }, ], - repositoryUrl: 'https://github.com/hiero-ledger/hiero-sdk-python', maxRecommendations: 5, fetchPerPage: 50, commentMarker: '', diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index 2ae4aef5b..d7319b0cf 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -53,8 +53,16 @@ function buildMilestoneBlock(unlockedLevelKey) { * @returns {string} Fully rendered comment body. */ function buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey = null) { - const repoUrl = CONFIG.repositoryUrl ?? ''; + const homeRepo = CONFIG.repos.find(r => r.isHome) || {}; + + const { + repositoryUrl: repoUrl = '', + communityLinks = {}, + botSignature = 'Hiero Team', + } = homeRepo; + const hasRepoUrl = Boolean(repoUrl); + return [ CONFIG.commentMarker, '', @@ -68,11 +76,14 @@ function buildRecommendationComment(username, issues, completedDisplayName, unlo '🌟 **Stay connected:**', `- ⭐ [Star this repository](${repoUrl})`, `- šŸ‘€ [Watch for new issues](${repoUrl}/watchers)`, - `- šŸ’¬ [Join us on Discord](${repoUrl}/blob/main/docs/discord.md)`, - ]:[]), + ...(communityLinks?.discord + ? [`- šŸ’¬ [Join us on Discord](${communityLinks.discord})`] + : []), + ] : []), + '', - 'Happy coding! šŸš€', - '_— Hiero Python SDK Team_', + 'Happy coding! šŸš€', + `_— ${botSignature}_`, ].join('\n'); } From 268115c5216120fb6ab98290786e8b0399311518 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Thu, 7 May 2026 18:32:14 +0530 Subject: [PATCH 07/21] chore: remove trailing whitespace Signed-off-by: Parv Ninama --- .github/scripts/shared/helpers/comment.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index d7319b0cf..3b1034eef 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -62,7 +62,7 @@ function buildRecommendationComment(username, issues, completedDisplayName, unlo } = homeRepo; const hasRepoUrl = Boolean(repoUrl); - + return [ CONFIG.commentMarker, '', @@ -82,7 +82,7 @@ function buildRecommendationComment(username, issues, completedDisplayName, unlo ] : []), '', - 'Happy coding! šŸš€', + 'Happy coding! šŸš€', `_— ${botSignature}_`, ].join('\n'); } From a644f0872a22fad6f946873b6890dee4c8c0bc53 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 16:04:22 +0530 Subject: [PATCH 08/21] fix: exclude the assignedIssue from recommended issues pool Signed-off-by: Parv Ninama --- .github/scripts/bot-next-issue-recommendation.js | 14 +++++++------- .github/scripts/shared/core/recommendation.js | 4 ++-- .github/scripts/shared/helpers/utils.js | 8 ++++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/scripts/bot-next-issue-recommendation.js b/.github/scripts/bot-next-issue-recommendation.js index 2b791f5d8..4eaa75daa 100644 --- a/.github/scripts/bot-next-issue-recommendation.js +++ b/.github/scripts/bot-next-issue-recommendation.js @@ -55,28 +55,28 @@ module.exports = async ({ github, context, core }) => { return; } - const issueNumber = extractLinkedIssueNumber(prBody); - if (!issueNumber) { + const linkedIssueNumber = extractLinkedIssueNumber(prBody); + if (!linkedIssueNumber) { core.info('No linked issue found in PR body, skipping'); return; } - core.info(`Linked issue: #${issueNumber}`); + core.info(`Linked issue: #${linkedIssueNumber}`); let issue; try { const { data } = await github.rest.issues.get({ - owner, repo, issue_number: issueNumber, + owner, repo, issue_number: linkedIssueNumber, }); issue = data; } catch (error) { - core.warning(`Could not fetch issue #${issueNumber}: ${error.message}`); + core.warning(`Could not fetch issue #${linkedIssueNumber}: ${error.message}`); return; } const completedLevelKey = getHighestSkillLevelKey(issue, homeRepo); if (!completedLevelKey) { - core.info(`Issue #${issueNumber} has no recognised skill level label, skipping`); + core.info(`Issue #${linkedIssueNumber} has no recognised skill level label, skipping`); return; } @@ -86,7 +86,7 @@ module.exports = async ({ github, context, core }) => { let result; try { - result = await getRecommendedIssues(github, homeRepo, username, completedLevelKey, core); + result = await getRecommendedIssues(github, homeRepo, username, completedLevelKey, linkedIssueNumber, core); } catch (error) { core.error(`Error generating recommendations: ${error.message}`); return; diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js index 945ad444c..cd9139dc7 100644 --- a/.github/scripts/shared/core/recommendation.js +++ b/.github/scripts/shared/core/recommendation.js @@ -89,7 +89,7 @@ async function prefetchIssues(github, core) { * @param {object} core - @actions/core logger. * @returns {Promise<{ issues: Array, fromRepo: string|null, unlockedLevelKey: string|null }>} */ -async function getRecommendedIssues(github, homeRepo, username, completedLevelKey, core) { +async function getRecommendedIssues(github, homeRepo, username, completedLevelKey, linkedIssueNumber, core) { const [eligibleLevelKey, unlockedLevelKey] = await Promise.all([ resolveEligibleLevel(github, homeRepo, username), detectUnlockedLevel(github, homeRepo, username, completedLevelKey), @@ -108,7 +108,7 @@ async function getRecommendedIssues(github, homeRepo, username, completedLevelKe // Skip if the fetch failed (null) or the repo is missing from the cache (undefined). if (issues == null) continue; - const picked = filterIssuesByLevel(issues, levelKey, repoConfig); + const picked = filterIssuesByLevel(issues, levelKey, repoConfig, linkedIssueNumber); if (picked.length > 0) { core.info(`Recommending ${levelKey} issues from ${repoKey}`); return { issues: picked, fromRepo: repoKey, unlockedLevelKey }; diff --git a/.github/scripts/shared/helpers/utils.js b/.github/scripts/shared/helpers/utils.js index 1b403c1f1..06d6b98d3 100644 --- a/.github/scripts/shared/helpers/utils.js +++ b/.github/scripts/shared/helpers/utils.js @@ -25,12 +25,16 @@ function hasLabel(issue, labelName) { /** * Filters issues by a specific skill level and caps the result size. */ -function filterIssuesByLevel(issues, levelKey, repoConfig) { +function filterIssuesByLevel(issues, levelKey, repoConfig, excludeIssueNumber = null) { const labelString = repoLabelFor(repoConfig, levelKey); if (!labelString) return []; return issues - .filter(issue => hasLabel(issue, labelString)) + .filter(issue => + hasLabel(issue, labelString) && + issue.pull_request == null && + issue.number !== excludeIssueNumber + ) .slice(0, CONFIG.maxRecommendations); } From c9602cc17dfaa537b593ac8e5ad91853ba28c4bb Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 16:12:14 +0530 Subject: [PATCH 09/21] chore: update JSdocs for detectUnlockedLevel Signed-off-by: Parv Ninama --- .github/scripts/shared/core/eligibility.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index fab11d934..60700af6b 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -160,13 +160,14 @@ function getNextLevelInfo(currentLevelKey) { } /** - * Detects if the contributor just unlocked the next level. + * Detects whether the contributor appears to have just unlocked + * the next level based on current GitHub issue search results. * * Trigger condition: - * completed count === requiredCount + * observed completed count === requiredCount * * Uses a capped query (requiredCount + 1) for efficiency. - * + * * @param {import('@actions/github').GitHub} github * @param {object} homeRepo * @param {string} username From a503585dc1813186632716bab177117ee18cf7a4 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 16:20:12 +0530 Subject: [PATCH 10/21] ci:harden workflow checkout for pull_request_target safety Signed-off-by: Parv Ninama --- .github/scripts/shared/core/eligibility.js | 2 +- .github/workflows/bot-next-issue-recommendation.yml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index 60700af6b..7889b8f07 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -167,7 +167,7 @@ function getNextLevelInfo(currentLevelKey) { * observed completed count === requiredCount * * Uses a capped query (requiredCount + 1) for efficiency. - * + * * @param {import('@actions/github').GitHub} github * @param {object} homeRepo * @param {string} username diff --git a/.github/workflows/bot-next-issue-recommendation.yml b/.github/workflows/bot-next-issue-recommendation.yml index 0e37ab762..9aa0581ec 100644 --- a/.github/workflows/bot-next-issue-recommendation.yml +++ b/.github/workflows/bot-next-issue-recommendation.yml @@ -25,8 +25,13 @@ jobs: with: egress-policy: audit - - name: Checkout repository + # pull_request_target runs with privileged context. + # Explicitly checkout trusted base repository code only + - name: Checkout trusted base repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} - name: Recommend next issue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 From 70dbab84bbbdc1c7c96d20efb8411722468c3955 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 16:55:03 +0530 Subject: [PATCH 11/21] fix: delayed milestone unlock detection Signed-off-by: Parv Ninama --- .github/scripts/shared/core/eligibility.js | 135 ++++++++++++++------- 1 file changed, 89 insertions(+), 46 deletions(-) diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index 7889b8f07..1bd42ba34 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -59,75 +59,96 @@ async function passesNormalCheck(github, homeRepo, username, prereq) { /** * Determines whether a contributor is eligible for a single candidate level. * - * Two checks are tried in order: + * Checks are evaluated in order: * 1. Floor level (no prerequisite) — always eligible. - * 2. Bypass — has already closed ≄1 issue at this level or higher. - * 3. Normal — has met the prerequisite count at the previous level. + * 2. Bypass — contributor already completed ≄1 issue at this level or higher. + * 3. Normal progression — contributor met prerequisite count requirements. * - * Returns null on API failure so the caller can conservatively skip the candidate. + * API failures are treated conservatively as ineligible. * * @param {import('@actions/github').GitHub} github - * @param {object} homeRepo - Home repo entry from CONFIG.repos. - * @param {string} username - GitHub login of the contributor. - * @param {string} candidate - Canonical level key being evaluated. - * @returns {Promise} True if eligible, false otherwise (API failures treated as false). + * @param {object} homeRepo + * @param {string} username + * @param {string} candidate - Canonical level key being evaluated + * @returns {Promise} */ async function isEligibleFor(github, homeRepo, username, candidate) { const prereq = CONFIG.skillPrerequisites[candidate]; - // Floor level (gfi) has no prerequisite — always eligible as a floor. + // Entry-level floor (gfi) is always eligible. if (!prereq?.requiredLevel) return true; - const bypass = await passesBypassCheck(github, homeRepo, username, candidate); + const bypass = await passesBypassCheck( + github, + homeRepo, + username, + candidate, + ); + if (bypass) return true; - // null (API failure) is treated as false — caller skips conservatively. - const normal = await passesNormalCheck(github, homeRepo, username, prereq); + // API failures are treated conservatively as false. + const normal = await passesNormalCheck( + github, + homeRepo, + username, + prereq, + ); + return normal === true; } /** - * Determines the highest skill level a contributor is eligible for, - * based solely on their *historical* closed issues (excludes this PR). + * Resolves the highest level the contributor is historically eligible for. * - * Walks the hierarchy from highest to lowest and returns the first - * level for which the contributor is eligible. The result acts as a - * ceiling — recommendations will not exceed this level. + * Historical eligibility intentionally excludes the current PR being processed. + * The result acts as an eligibility ceiling for recommendations. * - * The caller is responsible for adjusting this ceiling to account for - * the current PR's completion (see adjustEligibilityForCurrentPR). + * The caller is responsible for projecting the current PR completion + * via adjustEligibilityForCurrentPR(). * * @param {import('@actions/github').GitHub} github - * @param {object} homeRepo - Home repo entry from CONFIG.repos. - * @param {string} username - GitHub login of the contributor. - * @returns {Promise} Canonical level key of the eligibility ceiling. + * @param {object} homeRepo + * @param {string} username + * @returns {Promise} Canonical level key */ async function resolveEligibleLevel(github, homeRepo, username) { for (const candidate of [...CONFIG.skillHierarchy].reverse()) { - const eligible = await isEligibleFor(github, homeRepo, username, candidate); - // false includes API failures (treated conservatively as ineligible) + const eligible = await isEligibleFor( + github, + homeRepo, + username, + candidate, + ); + + // false includes API failures (treated conservatively) if (eligible === true) return candidate; } + return CONFIG.skillHierarchy[0]; } /** - * Adjusts the historical eligibility ceiling to include the current PR's completion. + * Projects the contributor's eligibility ceiling after including + * the current PR completion. * - * resolveEligibleLevel is intentionally blind to the PR being processed. - * Without this adjustment, a contributor completing their very first GFI - * would have an eligibility ceiling of 'gfi' and never see beginner issues. + * resolveEligibleLevel() intentionally ignores the current PR. + * Without this adjustment, contributors completing their first GFI + * would never receive beginner recommendations. * - * Logic: if the ceiling is at or below the completed level, bump it one step up. + * Logic: + * if eligible ceiling <= completed level + * => bump ceiling one level upward * - * @param {string} completedKey - Canonical key of the level just completed. - * @param {string} eligibleKey - Historical ceiling from resolveEligibleLevel. - * @returns {string} Adjusted ceiling key. + * @param {string} completedKey - Level completed by current PR + * @param {string} eligibleKey - Historical eligibility ceiling + * @returns {string} Adjusted eligibility ceiling */ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { - const h = CONFIG.skillHierarchy; + const h = CONFIG.skillHierarchy; + const completedIdx = h.indexOf(completedKey); - const eligibleIdx = h.indexOf(eligibleKey); + const eligibleIdx = h.indexOf(eligibleKey); return eligibleIdx <= completedIdx ? h[Math.min(completedIdx + 1, h.length - 1)] @@ -135,7 +156,7 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { } /** - * Computes next level metadata for unlock detection. + * Computes metadata about the next progression level. * * Ensures: * - current level exists @@ -147,6 +168,7 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { */ function getNextLevelInfo(currentLevelKey) { const hierarchy = CONFIG.skillHierarchy; + const currentIndex = hierarchy.indexOf(currentLevelKey); if (currentIndex === -1) return null; @@ -154,19 +176,31 @@ function getNextLevelInfo(currentLevelKey) { if (!nextKey) return null; const nextPrereq = CONFIG.skillPrerequisites[nextKey]; - if (!nextPrereq || nextPrereq.requiredLevel !== currentLevelKey) return null; + + if ( + !nextPrereq || + nextPrereq.requiredLevel !== currentLevelKey + ) { + return null; + } return { nextKey, nextPrereq }; } /** - * Detects whether the contributor appears to have just unlocked - * the next level based on current GitHub issue search results. + * Detects whether the current PR completion unlocks the next level. + * + * GitHub search indexing may lag behind workflow execution, so counts are + * treated as historical state and the current PR completion is projected + * locally via +1. * - * Trigger condition: - * observed completed count === requiredCount + * Example: + * intermediate requires 3 beginner completions + * historical beginner count = 2 + * current PR completes another beginner issue + * projected count = 3 => unlock intermediate * - * Uses a capped query (requiredCount + 1) for efficiency. + * Uses a capped query for efficiency. * * @param {import('@actions/github').GitHub} github * @param {object} homeRepo @@ -174,24 +208,33 @@ function getNextLevelInfo(currentLevelKey) { * @param {string} currentLevelKey * @returns {Promise} unlocked level key */ -async function detectUnlockedLevel(github, homeRepo, username, currentLevelKey) { +async function detectUnlockedLevel( + github, + homeRepo, + username, + currentLevelKey, +) { const next = getNextLevelInfo(currentLevelKey); if (!next) return null; const { nextKey, nextPrereq } = next; - const count = await countClosedIssuesByAssignee( + const historicalCount = await countClosedIssuesByAssignee( github, homeRepo.owner, homeRepo.repo, username, repoLabelFor(homeRepo, currentLevelKey), - nextPrereq.requiredCount + 1, + nextPrereq.requiredCount, ); - if (count === null) return null; + if (historicalCount === null) return null; + + const projectedCount = historicalCount + 1; - return count === nextPrereq.requiredCount ? nextKey : null; + return projectedCount === nextPrereq.requiredCount + ? nextKey + : null; } module.exports = { From 5b6999b0ef75c7c66646c303dd5d8b33c497eb55 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 17:18:27 +0530 Subject: [PATCH 12/21] fix: add lable validation in config.js Signed-off-by: Parv Ninama --- .github/scripts/shared/config.js | 25 ++++++++++++++++++++ .github/scripts/shared/helpers/validation.js | 20 ++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/scripts/shared/helpers/validation.js diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js index 7c66eee97..a4c04a0b0 100644 --- a/.github/scripts/shared/config.js +++ b/.github/scripts/shared/config.js @@ -7,6 +7,29 @@ const { ADVANCED_LABEL, } = require('./labels'); +const { isSafeLabel } = require('./helpers/validation'); + +/** + * Validates configured repository label strings. + * + * Labels are interpolated into GitHub search queries, so invalid or unsafe + * values should fail fast during module initialization. + * + * @param {Array} repos + */ + +function validateRepoLabels(repos) { + for (const repo of repos) { + for (const [level, label] of Object.entries(repo.labels ?? {})) { + if (!isSafeLabel(label)) { + throw new Error( + `Unsafe label configured for ${repo.owner}/${repo.repo} (${level}): "${label}"` + ); + } + } + } +} + const CONFIG = { // Internal canonical keys — never used as label strings directly. // GFI is index 0 and is entry-only: never recommended after first completion. @@ -57,6 +80,8 @@ const CONFIG = { commentMarker: '', }; +validateRepoLabels(CONFIG.repos); + module.exports = { CONFIG, }; diff --git a/.github/scripts/shared/helpers/validation.js b/.github/scripts/shared/helpers/validation.js new file mode 100644 index 000000000..9330b37a7 --- /dev/null +++ b/.github/scripts/shared/helpers/validation.js @@ -0,0 +1,20 @@ +/** + * Validates that a label string is safe to interpolate into + * GitHub search queries. + * + * Rejects empty values and characters that could break or + * alter query parsing semantics. + * + * @param {string} label + * @returns {boolean} + */ +function isSafeLabel(label) { + return typeof label === 'string' + && label.length > 0 + && label.length <= 100 + && !/[\\"]/u.test(label); +} + +module.exports = { + isSafeLabel, +}; From 272f9f54e91429f38bf92b16dc758c29c52fb94f Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 17:23:10 +0530 Subject: [PATCH 13/21] fix: escape issue titles in recommendation comments Signed-off-by: Parv Ninama --- .github/scripts/shared/helpers/comment.js | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index 3b1034eef..9d88ce907 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -4,13 +4,35 @@ const { CONFIG } = require('../config'); +/** + * Escapes user-controlled issue titles before inserting them into Markdown. + * + * Prevents broken formatting and accidental mentions in bot comments. + * + * @param {string} text + * @returns {string} + */ +function escapeMarkdownText(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/`/g, '\\`') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_') + .replace(/@/g, '@\u200B'); +} + function buildIssueListBlock(issues = []) { if (issues.length > 0) { return [ 'Here are some issues you might want to explore next:', '', ...issues.map(i => { - const title = i?.title ?? 'Untitled issue'; + const rawTitle = i?.title ?? 'Untitled issue'; + const title = escapeMarkdownText(rawTitle); const url = i?.html_url ?? '#'; return `- [${title}](${url})`; }), From b4b85c0aeb97a73b762f7c92c529db4384906069 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 18:02:40 +0530 Subject: [PATCH 14/21] fix: scope linked-issue exclusion to repository identity Signed-off-by: Parv Ninama --- .../scripts/bot-next-issue-recommendation.js | 13 +++++++++++- .github/scripts/shared/core/recommendation.js | 5 +++-- .github/scripts/shared/helpers/utils.js | 20 +++++++++++++------ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/scripts/bot-next-issue-recommendation.js b/.github/scripts/bot-next-issue-recommendation.js index 4eaa75daa..e38a6cc9a 100644 --- a/.github/scripts/bot-next-issue-recommendation.js +++ b/.github/scripts/bot-next-issue-recommendation.js @@ -86,7 +86,18 @@ module.exports = async ({ github, context, core }) => { let result; try { - result = await getRecommendedIssues(github, homeRepo, username, completedLevelKey, linkedIssueNumber, core); + result = await getRecommendedIssues( + github, + homeRepo, + username, + completedLevelKey, + { + owner, + repo, + issueNumber: linkedIssueNumber + }, + core, + ); } catch (error) { core.error(`Error generating recommendations: ${error.message}`); return; diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js index cd9139dc7..cc2111aca 100644 --- a/.github/scripts/shared/core/recommendation.js +++ b/.github/scripts/shared/core/recommendation.js @@ -87,9 +87,10 @@ async function prefetchIssues(github, core) { * @param {string} username - GitHub login of the contributor. * @param {string} completedLevelKey - Canonical key of the level just completed. * @param {object} core - @actions/core logger. + * @param {{ owner: string, repo: string, issueNumber: number } | null} linkedIssue - Linked issue identity to exclude from recommendations. * @returns {Promise<{ issues: Array, fromRepo: string|null, unlockedLevelKey: string|null }>} */ -async function getRecommendedIssues(github, homeRepo, username, completedLevelKey, linkedIssueNumber, core) { +async function getRecommendedIssues(github, homeRepo, username, completedLevelKey, linkedIssue, core) { const [eligibleLevelKey, unlockedLevelKey] = await Promise.all([ resolveEligibleLevel(github, homeRepo, username), detectUnlockedLevel(github, homeRepo, username, completedLevelKey), @@ -108,7 +109,7 @@ async function getRecommendedIssues(github, homeRepo, username, completedLevelKe // Skip if the fetch failed (null) or the repo is missing from the cache (undefined). if (issues == null) continue; - const picked = filterIssuesByLevel(issues, levelKey, repoConfig, linkedIssueNumber); + const picked = filterIssuesByLevel(issues, levelKey, repoConfig, linkedIssue); if (picked.length > 0) { core.info(`Recommending ${levelKey} issues from ${repoKey}`); return { issues: picked, fromRepo: repoKey, unlockedLevelKey }; diff --git a/.github/scripts/shared/helpers/utils.js b/.github/scripts/shared/helpers/utils.js index 06d6b98d3..28ded1185 100644 --- a/.github/scripts/shared/helpers/utils.js +++ b/.github/scripts/shared/helpers/utils.js @@ -25,16 +25,24 @@ function hasLabel(issue, labelName) { /** * Filters issues by a specific skill level and caps the result size. */ -function filterIssuesByLevel(issues, levelKey, repoConfig, excludeIssueNumber = null) { +function filterIssuesByLevel(issues, levelKey, repoConfig, excludeIssue = null) { const labelString = repoLabelFor(repoConfig, levelKey); if (!labelString) return []; return issues - .filter(issue => - hasLabel(issue, labelString) && - issue.pull_request == null && - issue.number !== excludeIssueNumber - ) + .filter(issue => { + const isExcluded = + excludeIssue && + repoConfig.owner === excludeIssue.owner && + repoConfig.repo === excludeIssue.repo && + issue.number === excludeIssue.issueNumber; + + return ( + hasLabel(issue, labelString) && + issue.pull_request == null && + !isExcluded + ); + }) .slice(0, CONFIG.maxRecommendations); } From 3699d62a2e75052f16374a1e37bb323e2eb42146 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 18:30:43 +0530 Subject: [PATCH 15/21] refactor: add Logs for better Error handling Signed-off-by: Parv Ninama --- .github/scripts/shared/api/github-api.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index 5c3d700f8..6def54c14 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -26,8 +26,8 @@ async function countClosedIssuesByAssignee(github, owner, repo, username, labelS per_page: Math.min(cap, 100), }); return data.items.length; - } catch { - return null; + } catch (err) { + console.warn(`[github-api] countClosedIssuesByAssignee failed for ${username} in ${owner}/${repo}: ${err.message}`); } } @@ -57,8 +57,8 @@ async function fetchIssuesBatch(github, repoConfig) { }); return data.items ?? []; - } catch { - return null; + } catch (err) { + console.warn(`[github-api] fetchIssuesBatch failed for ${repoConfig.owner}/${repoConfig.repo}: ${err.message}`); } } From c363f8643cb5d8b902822c08971a4bd059132d69 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 18:41:29 +0530 Subject: [PATCH 16/21] refactor: centralize canonical skill level keys Signed-off-by: Parv Ninama --- .github/scripts/shared/config.js | 22 ++++++++++++++----- .github/scripts/shared/core/recommendation.js | 4 ++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js index a4c04a0b0..f4243b922 100644 --- a/.github/scripts/shared/config.js +++ b/.github/scripts/shared/config.js @@ -29,19 +29,30 @@ function validateRepoLabels(repos) { } } } +const LEVEL_KEYS = { + GFI: 'gfi', + BEGINNER: 'beginner', + INTERMEDIATE: 'intermediate', + ADVANCED: 'advanced', +}; const CONFIG = { // Internal canonical keys — never used as label strings directly. // GFI is index 0 and is entry-only: never recommended after first completion. - skillHierarchy: ['gfi', 'beginner', 'intermediate', 'advanced'], + skillHierarchy: [ + LEVEL_KEYS.GFI, + LEVEL_KEYS.BEGINNER, + LEVEL_KEYS.INTERMEDIATE, + LEVEL_KEYS.ADVANCED, + ], // requiredLevel: canonical key the contributor must have completed N times // requiredCount: completions needed (0 = no prerequisite) skillPrerequisites: { - gfi: { requiredLevel: null, requiredCount: 0, displayName: 'Good First Issue' }, - beginner: { requiredLevel: 'gfi', requiredCount: 1, displayName: 'Beginner' }, - intermediate: { requiredLevel: 'beginner', requiredCount: 3, displayName: 'Intermediate' }, - advanced: { requiredLevel: 'intermediate', requiredCount: 3, displayName: 'Advanced' }, + [LEVEL_KEYS.GFI]: { requiredLevel: null, requiredCount: 0, displayName: 'Good First Issue' }, + [LEVEL_KEYS.BEGINNER]: { requiredLevel: 'LEVEL_KEYS.GFI', requiredCount: 1, displayName: 'Beginner' }, + [LEVEL_KEYS.INTERMEDIATE]: { requiredLevel: 'LEVEL_KEYS.BEGINNER', requiredCount: 3, displayName: 'Intermediate' }, + [LEVEL_KEYS.ADVANCED]: { requiredLevel: 'LEVEL_KEYS.INTERMEDIATE', requiredCount: 3, displayName: 'Advanced' }, }, // Repos tried in order for each fallback step. @@ -84,4 +95,5 @@ validateRepoLabels(CONFIG.repos); module.exports = { CONFIG, + LEVEL_KEYS, }; diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js index cc2111aca..76d56639e 100644 --- a/.github/scripts/shared/core/recommendation.js +++ b/.github/scripts/shared/core/recommendation.js @@ -2,7 +2,7 @@ // Core recommendation logic // --------------------------------------------------------------------------- -const { CONFIG } = require('../config'); +const { CONFIG, LEVEL_KEYS } = require('../config'); const { filterIssuesByLevel } = require('../helpers/utils'); const { fetchIssuesBatch } = require('../api/github-api'); const { @@ -22,7 +22,7 @@ function computeLevelStepIndices(completedLevelKey, eligibleLevelKey) { const h = CONFIG.skillHierarchy; const completedIdx = h.indexOf(completedLevelKey); const eligibleIdx = h.indexOf(eligibleLevelKey); - const beginnerIdx = h.indexOf('beginner'); + const beginnerIdx = h.indexOf(LEVEL_KEYS.BEGINNER); const levelUp = completedIdx + 1 <= eligibleIdx ? completedIdx + 1 : null; const same = completedIdx; From 8acf6a2630dde38cc32ba1bf22d9dce8c0224a84 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 18:45:20 +0530 Subject: [PATCH 17/21] fix: paginate PR comments when checking existing bot replies Signed-off-by: Parv Ninama --- .github/scripts/shared/helpers/pr-helpers.js | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/scripts/shared/helpers/pr-helpers.js b/.github/scripts/shared/helpers/pr-helpers.js index 8092a0344..019dfe899 100644 --- a/.github/scripts/shared/helpers/pr-helpers.js +++ b/.github/scripts/shared/helpers/pr-helpers.js @@ -33,10 +33,23 @@ function extractLinkedIssueNumber(prBody) { */ async function alreadyCommented(github, owner, repo, prNumber) { try { - const { data } = await github.rest.issues.listComments({ - owner, repo, issue_number: prNumber, per_page: 100, - }); - return data.some(c => c.body?.includes(CONFIG.commentMarker)); + for await (const page of github.paginate.iterator( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }, + )) { + const found = page.data.some(comment => + comment.body?.includes(CONFIG.commentMarker), + ); + + if (found) return true; + } + + return false; } catch { return false; } From 42c6f13e2eddadb6f184c0729bd5c14256fed49f Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 18:59:19 +0530 Subject: [PATCH 18/21] fix: minor suggestions suggested by coderabbit and copilot Signed-off-by: Parv Ninama --- .github/scripts/shared/config.js | 6 +++--- .github/scripts/shared/core/eligibility.js | 2 ++ .github/scripts/shared/core/recommendation.js | 2 +- .github/scripts/shared/helpers/pr-helpers.js | 5 ++++- .github/scripts/shared/helpers/utils.js | 2 +- .github/scripts/tests/recommendation.test.js | 2 ++ 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/scripts/shared/config.js b/.github/scripts/shared/config.js index f4243b922..98393a6f1 100644 --- a/.github/scripts/shared/config.js +++ b/.github/scripts/shared/config.js @@ -50,9 +50,9 @@ const CONFIG = { // requiredCount: completions needed (0 = no prerequisite) skillPrerequisites: { [LEVEL_KEYS.GFI]: { requiredLevel: null, requiredCount: 0, displayName: 'Good First Issue' }, - [LEVEL_KEYS.BEGINNER]: { requiredLevel: 'LEVEL_KEYS.GFI', requiredCount: 1, displayName: 'Beginner' }, - [LEVEL_KEYS.INTERMEDIATE]: { requiredLevel: 'LEVEL_KEYS.BEGINNER', requiredCount: 3, displayName: 'Intermediate' }, - [LEVEL_KEYS.ADVANCED]: { requiredLevel: 'LEVEL_KEYS.INTERMEDIATE', requiredCount: 3, displayName: 'Advanced' }, + [LEVEL_KEYS.BEGINNER]: { requiredLevel: LEVEL_KEYS.GFI, requiredCount: 1, displayName: 'Beginner' }, + [LEVEL_KEYS.INTERMEDIATE]: { requiredLevel: LEVEL_KEYS.BEGINNER, requiredCount: 3, displayName: 'Intermediate' }, + [LEVEL_KEYS.ADVANCED]: { requiredLevel: LEVEL_KEYS.INTERMEDIATE, requiredCount: 3, displayName: 'Advanced' }, }, // Repos tried in order for each fallback step. diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js index 1bd42ba34..a0656ed57 100644 --- a/.github/scripts/shared/core/eligibility.js +++ b/.github/scripts/shared/core/eligibility.js @@ -150,6 +150,8 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) { const completedIdx = h.indexOf(completedKey); const eligibleIdx = h.indexOf(eligibleKey); + if (completedIdx === -1 || eligibleIdx === -1) return eligibleKey; + return eligibleIdx <= completedIdx ? h[Math.min(completedIdx + 1, h.length - 1)] : eligibleKey; diff --git a/.github/scripts/shared/core/recommendation.js b/.github/scripts/shared/core/recommendation.js index 76d56639e..86d7fb0f7 100644 --- a/.github/scripts/shared/core/recommendation.js +++ b/.github/scripts/shared/core/recommendation.js @@ -43,7 +43,7 @@ function buildFallbackChain(completedLevelKey, eligibleLevelKey) { .flatMap(i => { const levelKey = CONFIG.skillHierarchy[i]; // Hard block: GFI is entry-only and must never appear in recommendations. - if (levelKey === 'gfi') return []; + if (levelKey === LEVEL_KEYS.GFI) return []; return CONFIG.repos.map(repoConfig => ({ levelKey, repoConfig })); }); } diff --git a/.github/scripts/shared/helpers/pr-helpers.js b/.github/scripts/shared/helpers/pr-helpers.js index 019dfe899..731802398 100644 --- a/.github/scripts/shared/helpers/pr-helpers.js +++ b/.github/scripts/shared/helpers/pr-helpers.js @@ -14,8 +14,11 @@ const { CONFIG } = require('../config'); * @returns {number|null} Linked issue number, or null if none found. */ function extractLinkedIssueNumber(prBody) { + if (typeof prBody !== 'string') { + return null; + } const safe = prBody.length > 50000 ? prBody.substring(0, 50000) : prBody; - const regex = /(fixes|closes|resolves|fix|close|resolve):?\s*(?:[\w-]+\/[\w-]+)?#(\d+)/gi; + const regex = /\b(fixes|closes|resolves|fix|close|resolve)\b:?\s*(?:[\w-]+\/[\w-]+)?#(\d+)/gi; const match = regex.exec(safe); return match ? parseInt(match[2], 10) : null; } diff --git a/.github/scripts/shared/helpers/utils.js b/.github/scripts/shared/helpers/utils.js index 28ded1185..148b003c2 100644 --- a/.github/scripts/shared/helpers/utils.js +++ b/.github/scripts/shared/helpers/utils.js @@ -12,7 +12,7 @@ const { CONFIG } = require('../config'); * @returns {string|null} Label string or null if not defined. */ function repoLabelFor(repoConfig, levelKey) { - return repoConfig.labels[levelKey] ?? null; + return repoConfig?.labels[levelKey] ?? null; } function hasLabel(issue, labelName) { diff --git a/.github/scripts/tests/recommendation.test.js b/.github/scripts/tests/recommendation.test.js index 0d16ac044..b4ef169e5 100644 --- a/.github/scripts/tests/recommendation.test.js +++ b/.github/scripts/tests/recommendation.test.js @@ -82,6 +82,8 @@ test('adjustEligibilityForCurrentPR does not downgrade higher eligibility', () = test('fallback chain includes repos for eligible levels only', () => { const chain = buildFallbackChain('intermediate', 'advanced'); + assert.ok(chain.length > 0, 'expected non-empty fallback chain'); + const validLevels = new Set(CONFIG.skillHierarchy); for (const entry of chain) { From 46b582b4d0a389e947d58a6f23dee870f2ca0904 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 19:06:06 +0530 Subject: [PATCH 19/21] refactor: simplify markdown escaping logic Signed-off-by: Parv Ninama --- .github/scripts/shared/helpers/comment.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index 9d88ce907..e6456ea6a 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -4,6 +4,8 @@ const { CONFIG } = require('../config'); +const MARKDOWN_ESCAPE_REGEX = /[[\]()`*_\\]/g; + /** * Escapes user-controlled issue titles before inserting them into Markdown. * @@ -14,14 +16,7 @@ const { CONFIG } = require('../config'); */ function escapeMarkdownText(text) { return String(text) - .replace(/\\/g, '\\\\') - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]') - .replace(/\(/g, '\\(') - .replace(/\)/g, '\\)') - .replace(/`/g, '\\`') - .replace(/\*/g, '\\*') - .replace(/_/g, '\\_') + .replace(MARKDOWN_ESCAPE_REGEX, '\\$&') .replace(/@/g, '@\u200B'); } From 49b6f3382a3c872f5e937879d222490595acf8f8 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 19:12:37 +0530 Subject: [PATCH 20/21] fix: Wrap alreadyCommented in try/catch Signed-off-by: Parv Ninama --- .github/scripts/bot-next-issue-recommendation.js | 11 +++++++++-- .github/scripts/shared/api/github-api.js | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/scripts/bot-next-issue-recommendation.js b/.github/scripts/bot-next-issue-recommendation.js index e38a6cc9a..82214883a 100644 --- a/.github/scripts/bot-next-issue-recommendation.js +++ b/.github/scripts/bot-next-issue-recommendation.js @@ -50,8 +50,15 @@ module.exports = async ({ github, context, core }) => { core.info(`Processing PR #${prNumber} by @${username}`); const { owner, repo } = homeRepo; - if (await alreadyCommented(github, owner, repo, prNumber)) { - core.info('Recommendation already posted, skipping'); + try { + if (await alreadyCommented(github, owner, repo, prNumber)) { + core.info('Recommendation already posted, skipping'); + return; + } + } catch (error) { + core.warning( + `Could not verify existing recommendation comment on PR #${prNumber}: ${error.message}; skipping to avoid duplicate post`, + ); return; } diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index 6def54c14..96774f4e6 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -28,6 +28,7 @@ async function countClosedIssuesByAssignee(github, owner, repo, username, labelS return data.items.length; } catch (err) { console.warn(`[github-api] countClosedIssuesByAssignee failed for ${username} in ${owner}/${repo}: ${err.message}`); + return null; } } @@ -59,6 +60,7 @@ async function fetchIssuesBatch(github, repoConfig) { return data.items ?? []; } catch (err) { console.warn(`[github-api] fetchIssuesBatch failed for ${repoConfig.owner}/${repoConfig.repo}: ${err.message}`); + return null; } } From 38d8cb0ead38fc2eaa62ee44683b548aa7dcf877 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Fri, 8 May 2026 19:16:19 +0530 Subject: [PATCH 21/21] chore: strengthen fallback chain priority assertions Signed-off-by: Parv Ninama --- .github/scripts/tests/recommendation.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/scripts/tests/recommendation.test.js b/.github/scripts/tests/recommendation.test.js index b4ef169e5..9edbdea2d 100644 --- a/.github/scripts/tests/recommendation.test.js +++ b/.github/scripts/tests/recommendation.test.js @@ -50,6 +50,14 @@ test('buildFallbackChain expands levels across repos in priority order', () => { // First recommendation should be intermediate in home repo assert.equal(chain[0].levelKey, 'intermediate'); + assert.equal(chain[0].repoConfig.isHome, true); + + // Level transitions should follow level-up -> same -> level-down ordering + // (without gfi). Collect distinct levels in chain order. + const distinctLevels = chain + .map(e => e.levelKey) + .filter((lvl, i, arr) => i === 0 || arr[i - 1] !== lvl); + assert.deepEqual(distinctLevels, ['intermediate', 'beginner']); // Ensure no gfi recommendations exist const hasGfi = chain.some(entry => entry.levelKey === 'gfi');