diff --git a/.github/scripts/bot-next-issue-recommendation.js b/.github/scripts/bot-next-issue-recommendation.js index 6cc8c8aa2..82214883a 100644 --- a/.github/scripts/bot-next-issue-recommendation.js +++ b/.github/scripts/bot-next-issue-recommendation.js @@ -1,280 +1,148 @@ -const { - GOOD_FIRST_ISSUE_LABEL, - BEGINNER_LABEL, - INTERMEDIATE_LABEL, - ADVANCED_LABEL, - isSafeLabel, -} = require('./shared/labels.js'); - -for (const label of [GOOD_FIRST_ISSUE_LABEL, BEGINNER_LABEL, INTERMEDIATE_LABEL, ADVANCED_LABEL]) { - if (!isSafeLabel(label)) { - throw new Error(`Invalid configured label: ${label}`); - } -} +// .github/scripts/bot-next-issue-recommendation.js +// +// Recommends issues to contributors after a PR is merged. +// +// Recommendation priority (per issue description): +// 1. One level above what they just completed, home repo +// 2. One level above, other repos +// 3. Same level, home repo +// 4. Same level, other repos +// 5. One level below (never below beginner, never GFI), home repo then other repos +// 6. Silent — nothing posted +// +// Eligibility history caps the ceiling: a contributor who just completed GFI +// cannot be recommended intermediate even if intermediate issues are available. +// The target level is driven by what they completed, bounded by eligibility. +// +// Each repo declares its own label strings since naming varies across repos. -const SUPPORTED_GFI_REPOS = [ - 'hiero-sdk-cpp', - 'hiero-sdk-swift', - 'hiero-sdk-python', - 'hiero-website', - 'hiero-sdk-js', -]; +const { + CONFIG, + getHighestSkillLevelKey, + getRecommendedIssues, + buildRecommendationComment, + postComment, + alreadyCommented, + extractLinkedIssueNumber, +} = require('./shared'); + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- module.exports = async ({ github, context, core }) => { const { payload } = context; + const prNumber = payload.pull_request?.number; + const prBody = payload.pull_request?.body ?? ''; + const username = payload.pull_request?.user?.login; + const homeRepo = CONFIG.repos.find(r => r.isHome); - // Get PR information from automatic pull_request_target trigger - let prNumber = payload.pull_request?.number; - let prBody = payload.pull_request?.body || ''; - - // Manual workflow_dispatch is no longer supported - inputs were removed - // Only automatic triggers from merged PRs will work - const repoOwner = context.repo.owner; - const repoName = context.repo.repo; - - if (!prNumber) { - core.info('No PR number found, skipping'); + if (!homeRepo) { + core.setFailed('No home repo configured (missing isHome: true)'); return; } - core.info(`Processing PR #${prNumber}`); - - // Parse PR body to find linked issues - const MAX_PR_BODY_LENGTH = 50000; // Reasonable limit for PR body - if (prBody.length > MAX_PR_BODY_LENGTH) { - core.warning(`PR body exceeds ${MAX_PR_BODY_LENGTH} characters, truncating for parsing`); - prBody = prBody.substring(0, MAX_PR_BODY_LENGTH); - } - const issueRegex = /(fixes|closes|resolves|fix|close|resolve)\s+(?:[\w-]+\/[\w-]+)?#(\d+)/gi; - const matches = [...prBody.matchAll(issueRegex)]; - - if (matches.length === 0) { - core.info('No linked issues found in PR body'); + if (!prNumber || !username) { + core.info('Missing PR number or username, skipping'); return; } - // Get the first linked issue number - const issueNumber = parseInt(matches[0][2]); - core.info(`Found linked issue #${issueNumber}`); + core.info(`Processing PR #${prNumber} by @${username}`); + const { owner, repo } = homeRepo; try { - // Fetch issue details - const { data: issue } = await github.rest.issues.get({ - owner: repoOwner, - repo: repoName, - issue_number: issueNumber, - }); - - // Normalize and check issue labels (case-insensitive) - const labelNames = issue.labels.map(label => label.name.toLowerCase()); - const labelSet = new Set(labelNames); - core.info(`Issue labels: ${labelNames.join(', ')}`); - - // Determine issue difficulty level - const difficultyLevels = { - beginner: labelSet.has(BEGINNER_LABEL.toLowerCase()), - goodFirstIssue: labelSet.has(GOOD_FIRST_ISSUE_LABEL.toLowerCase()), - intermediate: labelSet.has(INTERMEDIATE_LABEL.toLowerCase()), - advanced: labelSet.has(ADVANCED_LABEL.toLowerCase()), - }; - - // Skip if intermediate or advanced - if (difficultyLevels.intermediate || difficultyLevels.advanced) { - core.info('Issue is intermediate or advanced level, skipping recommendation'); - return; - } - - // Only proceed for Good First Issue or beginner issues - if (!difficultyLevels.goodFirstIssue && !difficultyLevels.beginner) { - core.info('Issue is not a Good First Issue or beginner issue, skipping'); + if (await alreadyCommented(github, owner, repo, prNumber)) { + core.info('Recommendation already posted, skipping'); return; } - - let recommendedIssues = []; - let recommendedLabel = null; - let isFallback = false; - - recommendedIssues = await searchIssues(github, core, repoOwner, repoName, BEGINNER_LABEL); - recommendedLabel = BEGINNER_LABEL; - - if (recommendedIssues.length === 0) { - isFallback = true; - recommendedIssues = await searchIssues(github, core, repoOwner, repoName, GOOD_FIRST_ISSUE_LABEL); - recommendedLabel = GOOD_FIRST_ISSUE_LABEL; - } - - - // Remove the issue they just solved - recommendedIssues = recommendedIssues.filter(i => i.number !== issueNumber); - - // Generate and post comment - const completedLabel = difficultyLevels.goodFirstIssue ? GOOD_FIRST_ISSUE_LABEL : BEGINNER_LABEL; - const completedLabelText = completedLabel === BEGINNER_LABEL ? `${BEGINNER_LABEL} issue` : completedLabel; - const recommendationMeta = { - completedLabelText, - recommendedLabel, - isFallback, - }; - await generateAndPostComment(github, context, core, prNumber, recommendedIssues, recommendationMeta); - } catch (error) { - core.setFailed(`Error processing issue #${issueNumber}: ${error.message}`); + core.warning( + `Could not verify existing recommendation comment on PR #${prNumber}: ${error.message}; skipping to avoid duplicate post`, + ); + return; } -}; - -async function searchIssues(github, core, owner, repo, label) { - try { - const query = `repo:${owner}/${repo} type:issue state:open label:"${label}" no:assignee`; - core.info(`Searching for issues with query: ${query}`); - const { data: searchResult } = await github.rest.search.issuesAndPullRequests({ - q: query, - per_page: 6, - }); - - core.info(`Found ${searchResult.items.length} issues with label "${label}"`); - return searchResult.items; - } catch (error) { - core.warning(`Error searching for issues with label "${label}": ${error.message}`); - return []; + const linkedIssueNumber = extractLinkedIssueNumber(prBody); + if (!linkedIssueNumber) { + core.info('No linked issue found in PR body, skipping'); + return; } -} -async function generateAndPostComment(github, context, core, prNumber, recommendedIssues, { completedLabelText, recommendedLabel, isFallback }) { - const marker = ''; + core.info(`Linked issue: #${linkedIssueNumber}`); - // Build comment content - let comment = `${marker}\n\nšŸŽ‰ **Nice work completing a ${completedLabelText}!**\n\n`; - comment += `Thank you for your contribution to the Hiero Python SDK! We're excited to have you as part of our community.\n\n`; - - if (recommendedIssues.length > 0) { - - if (isFallback) { - comment += `Here are some **${recommendedLabel}** issues at a similar level you might be interested in working on next:\n\n`; - } else { - comment += `Here are some issues labeled **${recommendedLabel}** you might be interested in working on next:\n\n`; - } - - // Sanitize title: escape markdown link syntax and special characters - const sanitizeTitle = (title) => title - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]') - .replace(/\(/g, '\\(') - .replace(/\)/g, '\\)'); - - recommendedIssues.slice(0, 5).forEach((issue, index) => { - comment += `${index + 1}. [${sanitizeTitle(issue.title)}](${issue.html_url})\n`; - if (issue.body && issue.body.length > 0) { - const description = extractIssueDescription(issue.body); - comment += ` ${description}\n\n`; - } else { - comment += ` *No description available*\n\n`; - } + let issue; + try { + const { data } = await github.rest.issues.get({ + owner, repo, issue_number: linkedIssueNumber, }); - } else { - comment += `There are currently no open issues available at or near the ${completedLabelText} level in this repository.\n\n`; - comment += `You can check out **Good First Issues** in other Hiero repositories:\n\n`; - const repoQuery = SUPPORTED_GFI_REPOS - .map(repo => `repo:${context.repo.owner}/${repo}`) - .join(' OR '); - - const gfiSearchQuery = [ - 'is:open', - 'is:issue', - `org:${context.repo.owner}`, - 'archived:false', - 'no:assignee', - `label:"${GOOD_FIRST_ISSUE_LABEL}"`, - `(${repoQuery})`, - ].join(' '); - - const gfiQuery = `https://github.com/issues?q=${encodeURIComponent(gfiSearchQuery)}`; + issue = data; + } catch (error) { + core.warning(`Could not fetch issue #${linkedIssueNumber}: ${error.message}`); + return; + } - comment += `[View Good First Issues across supported Hiero repositories](${gfiQuery})\n\n`; + const completedLevelKey = getHighestSkillLevelKey(issue, homeRepo); + if (!completedLevelKey) { + core.info(`Issue #${linkedIssueNumber} has no recognised skill level label, skipping`); + return; } - comment += `🌟 **Stay connected with the project:**\n`; - comment += `- ⭐ [Star this repository](https://github.com/${context.repo.owner}/${context.repo.repo}) to show your support\n`; - comment += `- šŸ‘€ [Watch this repository](https://github.com/${context.repo.owner}/${context.repo.repo}/watchers) to get notified of new issues and releases\n\n`; + core.info(`Completed level: ${completedLevelKey}`); - comment += `We look forward to seeing more contributions from you! If you have any questions, feel free to ask in our [Discord community](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md).\n\n`; - comment += `From the Hiero Python SDK Team šŸš€`; + const completedDisplayName = CONFIG.skillPrerequisites[completedLevelKey]?.displayName ?? completedLevelKey; - // Check for existing comment + let result; try { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const existingComment = comments.find(c => c.body.includes(marker)); - - if (existingComment) { - core.info('Comment already exists, skipping'); - return; - } + result = await getRecommendedIssues( + github, + homeRepo, + username, + completedLevelKey, + { + owner, + repo, + issueNumber: linkedIssueNumber + }, + core, + ); } catch (error) { - core.warning(`Error checking existing comments: ${error.message}`); + core.error(`Error generating recommendations: ${error.message}`); + return; } - // 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..96774f4e6 --- /dev/null +++ b/.github/scripts/shared/api/github-api.js @@ -0,0 +1,70 @@ +// --------------------------------------------------------------------------- +// 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 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 { + 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 (err) { + console.warn(`[github-api] countClosedIssuesByAssignee failed for ${username} in ${owner}/${repo}: ${err.message}`); + 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|null>} 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 (err) { + console.warn(`[github-api] fetchIssuesBatch failed for ${repoConfig.owner}/${repoConfig.repo}: ${err.message}`); + 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..98393a6f1 --- /dev/null +++ b/.github/scripts/shared/config.js @@ -0,0 +1,99 @@ +// CONFIG helper + +const { + GOOD_FIRST_ISSUE_LABEL, + BEGINNER_LABEL, + INTERMEDIATE_LABEL, + 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 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: [ + 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: { + [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. + // Home repo must be first — contributor history is resolved against it. + repos: [ + { + 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, + 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: '', +}; + +validateRepoLabels(CONFIG.repos); + +module.exports = { + CONFIG, + LEVEL_KEYS, +}; diff --git a/.github/scripts/shared/core/eligibility.js b/.github/scripts/shared/core/eligibility.js new file mode 100644 index 000000000..a0656ed57 --- /dev/null +++ b/.github/scripts/shared/core/eligibility.js @@ -0,0 +1,249 @@ +// --------------------------------------------------------------------------- +// Eligibility resolution +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); +const { repoLabelFor } = require('../helpers/utils'); +const { countClosedIssuesByAssignee } = require('../api/github-api'); + +/** + * 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; + const atOrAbove = CONFIG.skillHierarchy.slice(idx); + + for (const key of atOrAbove) { + const count = await countClosedIssuesByAssignee( + github, homeRepo.owner, homeRepo.repo, username, + repoLabelFor(homeRepo, key), 1, + ); + if (count !== null && count >= 1) return true; + } + return false; +} + +/** + * 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". + * + * @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( + 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. + * + * Checks are evaluated in order: + * 1. Floor level (no prerequisite) — always eligible. + * 2. Bypass — contributor already completed ≄1 issue at this level or higher. + * 3. Normal progression — contributor met prerequisite count requirements. + * + * API failures are treated conservatively as ineligible. + * + * @param {import('@actions/github').GitHub} github + * @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]; + + // Entry-level floor (gfi) is always eligible. + if (!prereq?.requiredLevel) return true; + + const bypass = await passesBypassCheck( + github, + homeRepo, + username, + candidate, + ); + + if (bypass) return true; + + // API failures are treated conservatively as false. + const normal = await passesNormalCheck( + github, + homeRepo, + username, + prereq, + ); + + return normal === true; +} + +/** + * Resolves the highest level the contributor is historically eligible for. + * + * Historical eligibility intentionally excludes the current PR being processed. + * The result acts as an eligibility ceiling for recommendations. + * + * The caller is responsible for projecting the current PR completion + * via adjustEligibilityForCurrentPR(). + * + * @param {import('@actions/github').GitHub} github + * @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) + if (eligible === true) return candidate; + } + + return CONFIG.skillHierarchy[0]; +} + +/** + * Projects the contributor's eligibility ceiling after including + * the current PR completion. + * + * resolveEligibleLevel() intentionally ignores the current PR. + * Without this adjustment, contributors completing their first GFI + * would never receive beginner recommendations. + * + * Logic: + * if eligible ceiling <= completed level + * => bump ceiling one level upward + * + * @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 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; +} + +/** + * Computes metadata about the next progression level. + * + * Ensures: + * - current level exists + * - next level exists + * - next level depends on current level + * + * @param {string} currentLevelKey + * @returns {{ nextKey: string, nextPrereq: object } | null} + */ +function getNextLevelInfo(currentLevelKey) { + const hierarchy = CONFIG.skillHierarchy; + + const currentIndex = hierarchy.indexOf(currentLevelKey); + if (currentIndex === -1) return null; + + const nextKey = hierarchy[currentIndex + 1]; + if (!nextKey) return null; + + const nextPrereq = CONFIG.skillPrerequisites[nextKey]; + + if ( + !nextPrereq || + nextPrereq.requiredLevel !== currentLevelKey + ) { + return null; + } + + return { nextKey, nextPrereq }; +} + +/** + * 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. + * + * 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 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 historicalCount = await countClosedIssuesByAssignee( + github, + homeRepo.owner, + homeRepo.repo, + username, + repoLabelFor(homeRepo, currentLevelKey), + nextPrereq.requiredCount, + ); + + if (historicalCount === null) return null; + + const projectedCount = historicalCount + 1; + + return projectedCount === 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..86d7fb0f7 --- /dev/null +++ b/.github/scripts/shared/core/recommendation.js @@ -0,0 +1,128 @@ +// --------------------------------------------------------------------------- +// Core recommendation logic +// --------------------------------------------------------------------------- + +const { CONFIG, LEVEL_KEYS } = 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(LEVEL_KEYS.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 === LEVEL_KEYS.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. + * @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, linkedIssue, 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, linkedIssue); + 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..e6456ea6a --- /dev/null +++ b/.github/scripts/shared/helpers/comment.js @@ -0,0 +1,111 @@ +// --------------------------------------------------------------------------- +// Comment builder +// --------------------------------------------------------------------------- + +const { CONFIG } = require('../config'); + +const MARKDOWN_ESCAPE_REGEX = /[[\]()`*_\\]/g; + +/** + * 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(MARKDOWN_ESCAPE_REGEX, '\\$&') + .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 rawTitle = i?.title ?? 'Untitled issue'; + const title = escapeMarkdownText(rawTitle); + 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 + : 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) { + const homeRepo = CONFIG.repos.find(r => r.isHome) || {}; + + const { + repositoryUrl: repoUrl = '', + communityLinks = {}, + botSignature = 'Hiero Team', + } = homeRepo; + + const hasRepoUrl = Boolean(repoUrl); + + return [ + CONFIG.commentMarker, + '', + `šŸ‘‹ Hi @${username}! Great work completing a **${completedDisplayName}** issue! šŸŽ‰`, + '', + 'Thanks for your contribution! šŸš€', + '', + ...buildMilestoneBlock(unlockedLevelKey), + ...buildIssueListBlock(issues), + ...(hasRepoUrl ? [ + '🌟 **Stay connected:**', + `- ⭐ [Star this repository](${repoUrl})`, + `- šŸ‘€ [Watch for new issues](${repoUrl}/watchers)`, + ...(communityLinks?.discord + ? [`- šŸ’¬ [Join us on Discord](${communityLinks.discord})`] + : []), + ] : []), + + '', + 'Happy coding! šŸš€', + `_— ${botSignature}_`, + ].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..731802398 --- /dev/null +++ b/.github/scripts/shared/helpers/pr-helpers.js @@ -0,0 +1,87 @@ +// --------------------------------------------------------------------------- +// 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) { + if (typeof prBody !== 'string') { + return null; + } + const safe = prBody.length > 50000 ? prBody.substring(0, 50000) : prBody; + 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; +} + +/** + * 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 { + 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; + } +} + +/** + * 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..148b003c2 --- /dev/null +++ b/.github/scripts/shared/helpers/utils.js @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// Label helpers +// --------------------------------------------------------------------------- + +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] ?? null; +} + +function hasLabel(issue, labelName) { + if (!labelName) return false; + + const target = labelName.toLowerCase(); + 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, excludeIssue = null) { + const labelString = repoLabelFor(repoConfig, levelKey); + if (!labelString) return []; + + return issues + .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); +} + +/** + * 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 => { + const label = repoLabelFor(repoConfig, key); + return label && hasLabel(issue, label); + }) ?? null; +} + +module.exports = { + repoLabelFor, + hasLabel, + filterIssuesByLevel, + getHighestSkillLevelKey, +}; 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, +}; diff --git a/.github/scripts/shared/index.js b/.github/scripts/shared/index.js new file mode 100644 index 000000000..51bc24c2b --- /dev/null +++ b/.github/scripts/shared/index.js @@ -0,0 +1,24 @@ +// 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 = { + getRecommendedIssues, + getHighestSkillLevelKey, + buildRecommendationComment, + postComment, + alreadyCommented, + extractLinkedIssueNumber, + CONFIG, +}; diff --git a/.github/scripts/tests/recommendation.test.js b/.github/scripts/tests/recommendation.test.js new file mode 100644 index 000000000..9edbdea2d --- /dev/null +++ b/.github/scripts/tests/recommendation.test.js @@ -0,0 +1,102 @@ +// 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'); + 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'); + 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'); + + assert.ok(chain.length > 0, 'expected non-empty fallback chain'); + + 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); + } +}); diff --git a/.github/workflows/archive/bot-next-issue-recommendation.yml b/.github/workflows/bot-next-issue-recommendation.yml similarity index 58% rename from .github/workflows/archive/bot-next-issue-recommendation.yml rename to .github/workflows/bot-next-issue-recommendation.yml index bf509e820..9aa0581ec 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,15 +21,20 @@ 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 - - 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # 8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: |