diff --git a/.github/scripts/bot-on-pr-close.js b/.github/scripts/bot-on-pr-close.js new file mode 100644 index 000000000..1b5cd34f7 --- /dev/null +++ b/.github/scripts/bot-on-pr-close.js @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-close.js +// +// Handles pull_request close events and triggers post-merge automation. +// +// Purpose: +// When a PR is closed (and merged), trigger issue recommendation +// to guide contributors to their next task. +// +// Security: +// - Only runs on merged PRs +// - Ignores bot users to prevent loops + +const { createLogger, buildBotContext, resolveLinkedIssue } = require('./helpers'); +const { handleRecommendIssues } = require('./commands/recommend-issues'); + +let logger = createLogger('on-pr-close'); + +// ============================================================================= +// ENTRY POINT +// ============================================================================= + +/** + * Entry point for PR close event. + * + * Validates: + * - PR is merged + * - Actor is not a bot + * + * Then triggers issue recommendation flow. + */ +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + const pr = botContext.pr; + + if (!pr) { + logger.log('Exit: no pull_request payload'); + return; + } + + if (!pr.merged) { + logger.log('Exit: PR closed but not merged'); + return; + } + + const username = pr.user?.login; + + if (!username) { + logger.log('Exit: missing PR author'); + return; + } + + if (pr.user?.type === 'Bot') { + logger.log('Exit: PR authored by bot'); + return; + } + + logger.log('Recommendation context:', { + username, + prNumber: pr.number, + }); + + const linkedIssue = await resolveLinkedIssue(botContext); + + if (!linkedIssue) { + logger.log('Skipping recommendation (no resolvable issue)', { + prNumber: pr.number, + username, + }); + return; + } + + await handleRecommendIssues({ + ...botContext, + issue: linkedIssue, + number: pr.number, + sender: pr.user, + }); + + } catch (error) { + logger.error('Error:', { + message: error.message, + status: error.status, + pr: context.payload.pull_request?.number, + }); + throw error; + } +}; diff --git a/.github/scripts/commands/recommend-issues.js b/.github/scripts/commands/recommend-issues.js new file mode 100644 index 000000000..65cbf4e22 --- /dev/null +++ b/.github/scripts/commands/recommend-issues.js @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/recommend-issues.js +// +// Issue recommendation command: suggests relevant issues to contributors +// after a PR is closed. Uses skill progression logic to recommend the next +// suitable issues based on difficulty level. + +const { + MAINTAINER_TEAM, + LABELS, + SKILL_HIERARCHY, + hasLabel, + postComment, + getLogger, +} = require('../helpers'); + +// Logger delegation +const logger = { + log: (...args) => getLogger().log(...args), + error: (...args) => getLogger().error(...args), +}; + +/** + * Returns the highest difficulty level of an issue based on its labels. + * + * Checks labels against SKILL_HIERARCHY in descending order and returns the first match. + * + * @param {{ labels: Array }} issue + * @returns {string|null} Matching level or null if none found. + */ +function getIssueSkillLevel(issue) { + for (const level of [...SKILL_HIERARCHY].reverse()) { + if (hasLabel(issue, level)) return level; + } + return null; +} + +/** + * Returns the next difficulty level in the hierarchy. + * + * @param {string} currentLevel + * @returns {string|null} Next level, same level if max, or null if invalid. + */ +function getNextLevel(currentLevel) { + const index = SKILL_HIERARCHY.indexOf(currentLevel); + if (index === -1) return null; + + return SKILL_HIERARCHY[index + 1] || currentLevel; +} + +/** + * Returns the previous (fallback) difficulty level. + * + * @param {string} currentLevel + * @returns {string|null} Lower level or null if none. + */ +function getFallbackLevel(currentLevel) { + const index = SKILL_HIERARCHY.indexOf(currentLevel); + if (index <= 0) return null; + + return SKILL_HIERARCHY[index - 1]; +} + +/** + * Groups issues by their matching difficulty level. + * + * Each issue is assigned to the first matching level in levelsPriority. + * + * @param {Array} issues + * @param {string[]} levelsPriority + * @returns {Object>} Issues grouped by level. + */ +function groupIssuesByLevel(issues, levelsPriority) { + const grouped = Object.fromEntries( + levelsPriority.map(level => [level, []]) + ); + + for (const issue of issues) { + const level = levelsPriority.find(l => hasLabel(issue, l)); + if (level) grouped[level].push(issue); + } + + return grouped; +} + +/** + * Returns issues from the highest-priority level with results. + * + * Limits output to 5 issues. + * + * @param {Object>} grouped + * @param {string[]} levelsPriority + * @returns {Array} Selected issues or empty array. + */ +function pickFirstAvailableLevel(grouped, levelsPriority) { + for (const level of levelsPriority) { + if (grouped[level].length > 0) { + return grouped[level].slice(0, 5); + } + } + return []; +} + +/** + * Fetches issues for multiple levels in a single query. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @returns {Promise | null>} + */ +async function fetchIssuesBatch(github, owner, repo) { + try { + + const query = [ + `repo:${owner}/${repo}`, + 'is:issue', + 'is:open', + 'no:assignee', + `label:"${LABELS.READY_FOR_DEV}"` + ].join(' '); + + const result = await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: 50, + }); + + return result.data.items || []; + } catch (error) { + logger.error('Failed to fetch issues:', { + message: error.message, + status: error.status, + }); + return null; + } +} + +/** + * Builds the success comment listing recommended issues. + * + * @param {string} username + * @param {Array<{ title: string, html_url: string }>} issues + * @returns {string} + */ +function buildRecommendationComment(username, issues) { + const list = issues.map( + (issue) => `- [${issue.title}](${issue.html_url})` + ); + return [ + `👋 Hi @${username}! Great work on your recent contribution! 🎉`, + '', + `Here are some issues you might want to explore next:`, + '', + ...list, + '', + `Happy coding! 🚀`, + ].join('\n'); +} + +/** + * Builds an error comment when recommendations fail. + * + * @param {string} username + * @returns {string} + */ +function buildRecommendationErrorComment(username) { + return [ + `👋 Hi @${username}!`, + '', + `I ran into an issue while generating recommendations for you.`, + '', + `${MAINTAINER_TEAM} — could you please take a look?`, + '', + `Sorry for the inconvenience — feel free to explore open issues in the meantime!`, + ].join('\n'); +} + +/** + * Returns recommended issues based on priority: + * next → same → fallback. + * + * Uses a single API call and filters results locally. + * + * @param {object} botContext + * @param {string} username + * @param {string} skillLevel + * @returns {Promise|null>} + */ +async function getRecommendedIssues(botContext, username, skillLevel) { + const fallback = getFallbackLevel(skillLevel); + const nextLevel = getNextLevel(skillLevel); + + const levelsPriority = [ + nextLevel !== skillLevel ? nextLevel : null, + skillLevel, + skillLevel !== LABELS.BEGINNER ? fallback : null, + ].filter(Boolean); + + const issues = await fetchIssuesBatch( + botContext.github, + botContext.owner, + botContext.repo, + ); + + if (issues === null) { + await postComment( + botContext, + buildRecommendationErrorComment(username) + ); + return null; + } + + const grouped = groupIssuesByLevel(issues, levelsPriority); + return pickFirstAvailableLevel(grouped, levelsPriority); +} + +/** + * Main handler for issue recommendations after a PR is merged. + * + * - Determines skill level + * - Fetches recommended issues + * - Posts a comment if results exist + * + * Skips silently if context is incomplete or no results found. + * Returns early on API failure . + * + * @param {{ + * github: object, + * owner: string, + * repo: string, + * issue: object, + * sender: { login: string } + * }} botContext + * @returns {Promise} + */ +async function handleRecommendIssues(botContext) { + const username = botContext.sender?.login; + if (!username) { + logger.log('Missing sender login, skipping recommendation'); + return; + } + + if (!botContext.issue) { + logger.log('Missing issue in context, skipping recommendation'); + return; + } + + const skillLevel = getIssueSkillLevel(botContext.issue); + if (!skillLevel) { + logger.log('No skill level found, skipping recommendation', { + issueNumber: botContext.issue?.number, + }); + return; + } + + logger.log('recommendation.context', { + user: username, + level: skillLevel, + issue: botContext.issue?.number, + }); + + const issues = await getRecommendedIssues( + botContext, + username, + skillLevel, + ); + + if (issues === null) return; + + if (issues.length === 0) { + logger.log('recommendation.empty', { user: username }); + return; + } + + const comment = buildRecommendationComment(username, issues); + logger.log('recommendation.postComment', { + target: botContext.number, + issueSource: botContext.issue?.number, + recommendations: issues.length, + }); + const result = await postComment(botContext, comment); + + if (!result.success) { + logger.error('recommendation.postCommentFailed', { + error: result.error, + }); + return; + } + + logger.log('recommendation.posted'); +} + +module.exports = { handleRecommendIssues }; diff --git a/.github/scripts/helpers/api.js b/.github/scripts/helpers/api.js index 84b14adaf..a41ef32e5 100644 --- a/.github/scripts/helpers/api.js +++ b/.github/scripts/helpers/api.js @@ -13,7 +13,7 @@ const { requirePositiveInt, requireSafeUsername, } = require('./validation'); -const { LABELS } = require('./constants'); +const { LABELS, SKILL_HIERARCHY } = require('./constants'); const { checkDCO, checkGPG, checkMergeConflict, checkIssueLink } = require('./checks'); const { buildBotComment } = require('./comments'); @@ -486,6 +486,67 @@ async function runAllChecksAndComment(botContext) { return { allPassed }; } +/** + * Resolves the primary issue linked to a PR. + * + * Strategy: + * - Fetch closing issue references via GraphQL + * - If multiple issues, return the one with the highest skill level + * - Return null if no linked issues found + * + * Notes: + * - Logs informational messages for traceability + * - Does NOT throw — failures are handled gracefully + * + * @param {object} botContext + * @returns {Promise} + */ +async function resolveLinkedIssue(botContext) { + try { + const issueNumbers = await fetchClosingIssueNumbers(botContext); + + if (!issueNumbers.length) { + getLogger().log('No linked issue found', { + prNumber: botContext.number, + }); + return null; + } + + if (issueNumbers.length === 1) { + return await fetchIssue(botContext, issueNumbers[0]) || null; + } + + const issues = await Promise.all( + issueNumbers.map(n => fetchIssue(botContext, n)) + ); + const valid = issues.filter(Boolean); + + if (!valid.length) { + getLogger().log('All linked issue fetches returned empty', { issueNumbers }); + return null; + } + + const selected = valid.reduce((best, issue) => { + const bestIndex = SKILL_HIERARCHY.findIndex(level => hasLabel(best, level)); + const currIndex = SKILL_HIERARCHY.findIndex(level => hasLabel(issue, level)); + return currIndex > bestIndex ? issue : best; + }); + + getLogger().log('Multiple linked issues found (using highest level)', { + issueNumbers, + selected: selected.number, + }); + + return selected; + + } catch (error) { + getLogger().error('Failed to resolve linked issue:', { + message: error.message, + }); + return null; + } +} + module.exports = { buildBotContext, addLabels, @@ -500,5 +561,6 @@ module.exports = { fetchClosingIssueNumbers, swapStatusLabel, runAllChecksAndComment, + resolveLinkedIssue, acknowledgeComment, }; diff --git a/.github/scripts/helpers/constants.js b/.github/scripts/helpers/constants.js index 7c4ad481f..36416f38c 100644 --- a/.github/scripts/helpers/constants.js +++ b/.github/scripts/helpers/constants.js @@ -27,6 +27,16 @@ const LABELS = Object.freeze({ ADVANCED: 'skill: advanced', }); +/** + * Skill hierarchy used to determine progression for recommendations. + */ +const SKILL_HIERARCHY = Object.freeze([ + LABELS.GOOD_FIRST_ISSUE, + LABELS.BEGINNER, + LABELS.INTERMEDIATE, + LABELS.ADVANCED, +]); + /** * Issue state values for GitHub search queries. */ @@ -39,4 +49,5 @@ module.exports = { MAINTAINER_TEAM, LABELS, ISSUE_STATE, + SKILL_HIERARCHY, }; diff --git a/.github/workflows/on-pr-close.yaml b/.github/workflows/on-pr-close.yaml new file mode 100644 index 000000000..630b49306 --- /dev/null +++ b/.github/workflows/on-pr-close.yaml @@ -0,0 +1,40 @@ +name: Bot - On PR Close + +# Runs on PR close (merged). Executes issue recommendation workflow: +# determines completed issue difficulty, finds next/same/fallback issues, +# and posts a recommendation comment via bot-on-pr-close.js. +on: + pull_request_target: + types: [closed] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + on-pr-close: + runs-on: hiero-client-sdk-linux-large + if: github.event.pull_request.merged == true + + concurrency: + group: on-pr-close-${{ github.event.pull_request.number }} + cancel-in-progress: true + + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Run PR Close Handler + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-close.js'); + await script({ github, context }); \ No newline at end of file