From d93d8baff4b94d65d60bd55051b6f181b098c3ef Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 17 May 2026 02:05:52 +0530 Subject: [PATCH 1/5] feat: migrate and adapt kdm bot automation scripts --- .github/kdm-automation.json | 83 + .github/scripts/bot-inactivity-comments.js | 116 + .github/scripts/bot-inactivity.js | 629 ++++++ .github/scripts/bot-on-comment.js | 128 ++ .github/scripts/bot-on-pr-close.js | 152 ++ .github/scripts/bot-on-pr-merged.js | 93 + .github/scripts/bot-on-pr-open.js | 74 + .github/scripts/bot-on-pr-review-labels.js | 40 + .github/scripts/bot-on-pr-review.js | 42 + .github/scripts/bot-on-pr-update.js | 48 + .github/scripts/bot/bot-recommend-issues.js | 505 +++++ .github/scripts/commands/assign-comments.js | 424 ++++ .github/scripts/commands/assign.js | 650 ++++++ .github/scripts/commands/finalize-comments.js | 447 ++++ .github/scripts/commands/finalize.js | 285 +++ .github/scripts/commands/unassign-comments.js | 84 + .github/scripts/commands/unassign.js | 98 + .github/scripts/eslint.config.mjs | 13 + .github/scripts/helpers/api.js | 1112 ++++++++++ .github/scripts/helpers/checks.js | 248 +++ .github/scripts/helpers/comments.js | 200 ++ .github/scripts/helpers/config-loader.js | 368 ++++ .github/scripts/helpers/constants.js | 90 + .github/scripts/helpers/index.js | 22 + .github/scripts/helpers/logger.js | 54 + .github/scripts/helpers/validation.js | 96 + .github/scripts/package-lock.json | 1086 ++++++++++ .github/scripts/package.json | 7 + .github/scripts/tests/test-api.js | 850 ++++++++ .github/scripts/tests/test-assign-bot.js | 1867 +++++++++++++++++ .github/scripts/tests/test-checks.js | 694 ++++++ .github/scripts/tests/test-comments.js | 466 ++++ .github/scripts/tests/test-config-loader.js | 575 +++++ .github/scripts/tests/test-finalize-bot.js | 710 +++++++ .github/scripts/tests/test-inactivity-bot.js | 1270 +++++++++++ .github/scripts/tests/test-on-comment-bot.js | 88 + .github/scripts/tests/test-on-pr-close-bot.js | 178 ++ .../scripts/tests/test-on-pr-merged-bot.js | 413 ++++ .github/scripts/tests/test-on-pr-open-bot.js | 548 +++++ .../scripts/tests/test-on-pr-review-bot.js | 128 ++ .../scripts/tests/test-on-pr-update-bot.js | 947 +++++++++ .../tests/test-recommend-issues-bot.js | 623 ++++++ .github/scripts/tests/test-unassign-bot.js | 529 +++++ .github/scripts/tests/test-utils.js | 280 +++ .github/workflows/on-comment.yaml | 72 + .github/workflows/on-pr-close.yaml | 61 + .github/workflows/on-pr-review-labels.yaml | 43 + .github/workflows/on-pr-review.yaml | 36 + .github/workflows/on-pr-update.yaml | 41 + .github/workflows/on-pr.yaml | 43 + .github/workflows/on-schedule-inactivity.yaml | 66 + 51 files changed, 17722 insertions(+) create mode 100644 .github/kdm-automation.json create mode 100644 .github/scripts/bot-inactivity-comments.js create mode 100644 .github/scripts/bot-inactivity.js create mode 100644 .github/scripts/bot-on-comment.js create mode 100644 .github/scripts/bot-on-pr-close.js create mode 100644 .github/scripts/bot-on-pr-merged.js create mode 100644 .github/scripts/bot-on-pr-open.js create mode 100644 .github/scripts/bot-on-pr-review-labels.js create mode 100644 .github/scripts/bot-on-pr-review.js create mode 100644 .github/scripts/bot-on-pr-update.js create mode 100644 .github/scripts/bot/bot-recommend-issues.js create mode 100644 .github/scripts/commands/assign-comments.js create mode 100644 .github/scripts/commands/assign.js create mode 100644 .github/scripts/commands/finalize-comments.js create mode 100644 .github/scripts/commands/finalize.js create mode 100644 .github/scripts/commands/unassign-comments.js create mode 100644 .github/scripts/commands/unassign.js create mode 100644 .github/scripts/eslint.config.mjs create mode 100644 .github/scripts/helpers/api.js create mode 100644 .github/scripts/helpers/checks.js create mode 100644 .github/scripts/helpers/comments.js create mode 100644 .github/scripts/helpers/config-loader.js create mode 100644 .github/scripts/helpers/constants.js create mode 100644 .github/scripts/helpers/index.js create mode 100644 .github/scripts/helpers/logger.js create mode 100644 .github/scripts/helpers/validation.js create mode 100644 .github/scripts/package-lock.json create mode 100644 .github/scripts/package.json create mode 100644 .github/scripts/tests/test-api.js create mode 100644 .github/scripts/tests/test-assign-bot.js create mode 100644 .github/scripts/tests/test-checks.js create mode 100644 .github/scripts/tests/test-comments.js create mode 100644 .github/scripts/tests/test-config-loader.js create mode 100644 .github/scripts/tests/test-finalize-bot.js create mode 100644 .github/scripts/tests/test-inactivity-bot.js create mode 100644 .github/scripts/tests/test-on-comment-bot.js create mode 100644 .github/scripts/tests/test-on-pr-close-bot.js create mode 100644 .github/scripts/tests/test-on-pr-merged-bot.js create mode 100644 .github/scripts/tests/test-on-pr-open-bot.js create mode 100644 .github/scripts/tests/test-on-pr-review-bot.js create mode 100644 .github/scripts/tests/test-on-pr-update-bot.js create mode 100644 .github/scripts/tests/test-recommend-issues-bot.js create mode 100644 .github/scripts/tests/test-unassign-bot.js create mode 100644 .github/scripts/tests/test-utils.js create mode 100644 .github/workflows/on-comment.yaml create mode 100644 .github/workflows/on-pr-close.yaml create mode 100644 .github/workflows/on-pr-review-labels.yaml create mode 100644 .github/workflows/on-pr-review.yaml create mode 100644 .github/workflows/on-pr-update.yaml create mode 100644 .github/workflows/on-pr.yaml create mode 100644 .github/workflows/on-schedule-inactivity.yaml diff --git a/.github/kdm-automation.json b/.github/kdm-automation.json new file mode 100644 index 0000000..249c913 --- /dev/null +++ b/.github/kdm-automation.json @@ -0,0 +1,83 @@ +{ + "maintainerTeam": "@utkarsh232005", + "goodFirstIssueSupportTeam": "@utkarsh232005", + + "labels": { + "status": { + "awaitingTriage": "status: awaiting triage", + "readyForDev": "status: ready for dev", + "inProgress": "status: in progress", + "blocked": "status: blocked", + "needsReview": "status: needs review", + "needsRevision": "status: needs revision" + }, + "skill": { + "goodFirstIssue": "skill: good first issue", + "beginner": "skill: beginner", + "intermediate": "skill: intermediate", + "advanced": "skill: advanced" + }, + "priority": { + "critical": "priority: critical", + "high": "priority: high", + "medium": "priority: medium", + "low": "priority: low" + } + }, + + "skillHierarchy": [ + "skill: good first issue", + "skill: beginner", + "skill: intermediate", + "skill: advanced" + ], + + "priorityHierarchy": [ + "priority: critical", + "priority: high", + "priority: medium", + "priority: low" + ], + + "skillPrerequisites": { + "skill: good first issue": { + "requiredLabel": null, + "requiredCount": 0, + "displayName": "Good First Issue" + }, + "skill: beginner": { + "requiredLabel": "skill: good first issue", + "requiredCount": 2, + "displayName": "Beginner", + "prerequisiteDisplayName": "Good First Issues" + }, + "skill: intermediate": { + "requiredLabel": "skill: beginner", + "requiredCount": 3, + "displayName": "Intermediate", + "prerequisiteDisplayName": "Beginner Issues" + }, + "skill: advanced": { + "requiredLabel": "skill: intermediate", + "requiredCount": 3, + "displayName": "Advanced", + "prerequisiteDisplayName": "Intermediate Issues" + } + }, + + "assignmentLimits": { + "maxOpenAssignments": 2, + "maxGfiCompletions": 5 + }, + + "documentation": { + "workflowGuide": "https://github.com/KDM-cli/kdm-cli/blob/main/docs/training/workflow.md", + "readme": "https://github.com/KDM-cli/kdm-cli/blob/main/README.md", + "signingGuide": "https://github.com/KDM-cli/kdm-cli/blob/main/docs/training/signing.md", + "mergeConflictsGuide": "https://github.com/KDM-cli/kdm-cli/blob/main/docs/training/merge-conflicts.md" + }, + + "community": { + "discordChannel": "https://discord.com/channels/905194001349627914/1337424839761465364" + } +} diff --git a/.github/scripts/bot-inactivity-comments.js b/.github/scripts/bot-inactivity-comments.js new file mode 100644 index 0000000..05b3951 --- /dev/null +++ b/.github/scripts/bot-inactivity-comments.js @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-inactivity-comments.js +// +// Comment builders for the inactivity bot. Pure formatting functions +// separated from the scheduled execution logic for readability. +// +// Duration values (WARN_AFTER_MS, CLOSE_AFTER_MS, BLOCKED_CHECKIN_AFTER_MS) +// remain in bot-inactivity.js and are passed in as parameters here so this +// file stays free of module-level constants that belong to the scheduler. + +const { LABELS } = require('./helpers/constants'); + +// ─── HTML markers ──────────────────────────────────────────────────────────── + +const WARN_MARKER = ''; +const BLOCKED_CHECKIN_MARKER = ''; + +// ─── Comment builders ───────────────────────────────────────────────────────── + +/** + * Builds the inactivity warning comment body. + * @param {string[]} assigneeLogins + * @param {string} itemType - 'issue' or 'PR' + * @param {number} warnAfterMs - Duration in ms after which a warning is issued. + * @param {number} closeAfterMs - Duration in ms after which the item is closed. + * @returns {string} + */ +function buildWarningComment(assigneeLogins, itemType, warnAfterMs, closeAfterMs) { + const mentions = assigneeLogins.length > 0 + ? assigneeLogins.map(l => `@${l}`).join(', ') + : 'there'; + + const activityHint = itemType === 'PR' + ? 'To stay active, leave a comment on this PR or the linked **issue**, or push a new commit.' + : "If you're still on it, leave a comment to let us know!"; + + const warnDays = warnAfterMs / (24 * 60 * 60 * 1000); + const remainingDays = (closeAfterMs - warnAfterMs) / (24 * 60 * 60 * 1000); + + return [ + WARN_MARKER, + `👋 Hey ${mentions}! This ${itemType} has been inactive for ${warnDays} days.`, + '', + `Are you still working on this? We will close this in ${remainingDays} days if we see no further activity.`, + '', + `${activityHint} If you'd like to step down, comment \`/unassign\`.`, + ].join('\n'); +} + +/** + * Builds the closure comment body. + * @param {string} itemType - 'issue' or 'PR' + * @param {number} closeAfterMs - Duration in ms after which the item is closed. + * @returns {string} + */ +function buildClosureComment(itemType, closeAfterMs) { + const closeDays = closeAfterMs / (24 * 60 * 60 * 1000); + + if (itemType === 'issue') { + return [ + `⏱️ This issue has been unassigned and reset to \`${LABELS.READY_FOR_DEV}\` due to ${closeDays} days of inactivity.`, + '', + "If you'd like to continue working on this, feel free to comment `/assign` to be reassigned.", + ].join('\n'); + } + return [ + `⏱️ This PR has been closed due to ${closeDays} days of inactivity.`, + '', + `It has been unassigned and reset to \`${LABELS.READY_FOR_DEV}\` so another contributor can pick it up.`, + '', + "If you'd like to continue working on this, feel free to comment `/assign` to be reassigned.", + ].join('\n'); +} + +/** + * Builds the comment posted on an issue when its linked PR was closed for inactivity. + * @returns {string} + */ +function buildLinkedPRClosedComment() { + return [ + '🔗 The pull request linked to this issue was closed due to inactivity.', + '', + `This issue has been unassigned and reset to \`${LABELS.READY_FOR_DEV}\`.`, + '', + "If you'd like to continue working on this, feel free to comment `/assign` to be reassigned.", + ].join('\n'); +} + +/** + * Builds the 30-day blocked check-in comment body. + * @param {string[]} assigneeLogins + * @param {string} itemType - 'issue' or 'PR' + * @returns {string} + */ +function buildBlockedCheckinComment(assigneeLogins, itemType) { + const mentions = assigneeLogins.length > 0 + ? assigneeLogins.map(l => `@${l}`).join(', ') + : 'there'; + + return [ + BLOCKED_CHECKIN_MARKER, + `👋 Hey ${mentions}, just checking in! Is this ${itemType} still blocked?`, + '', + `If it has been unblocked, please remove the \`${LABELS.BLOCKED}\` label so we can track progress.`, + ].join('\n'); +} + +module.exports = { + WARN_MARKER, + BLOCKED_CHECKIN_MARKER, + buildWarningComment, + buildClosureComment, + buildLinkedPRClosedComment, + buildBlockedCheckinComment, +}; diff --git a/.github/scripts/bot-inactivity.js b/.github/scripts/bot-inactivity.js new file mode 100644 index 0000000..a094298 --- /dev/null +++ b/.github/scripts/bot-inactivity.js @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-inactivity.js +// +// Scheduled inactivity bot. Runs daily to detect stale assigned issues and PRs. +// +// Timeline (for non-blocked items): +// 5 days of inactivity → warning comment (idempotent via HTML marker) +// 7 days of inactivity → close, remove assignees, reset to "status: ready for dev" +// +// Activity signals that reset the 5-day clock: +// - A non-bot comment on the item by the author or any assignee +// - A non-bot comment by PR participants on a linked issue +// - A commit pushed to a PR branch by the PR author +// - Removal of the "status: blocked" label (unblocking counts as activity) +// +// PR review state: +// - "status: needs review" → skipped entirely (waiting on maintainers) +// - "status: needs revision" → clock starts from when label was last applied +// +// Blocked items ("status: blocked" label): +// - Exempt from the inactivity close/warn flow +// - Receive a friendly check-in comment every 30 days instead +// +// Cross-referencing: +// - Issues linked to open PRs with recent activity are not flagged. +// - When a PR is closed for inactivity, its linked issues are also reset. + +const { createLogger } = require('./helpers/logger'); +const { LABELS } = require('./helpers/constants'); +const { + hasLabel, + removeLabel, + addLabels, + removeAssignees, + postComment, + postOrUpdateComment, + fetchComments, + fetchIssueEvents, + fetchPRCommits, + closeItem, +} = require('./helpers/api'); +const { parseIssueNumbers } = require('./helpers/checks'); + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const WARN_AFTER_MS = 5 * 24 * 60 * 60 * 1000; // 5 days +const CLOSE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const BLOCKED_CHECKIN_AFTER_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +const { + WARN_MARKER, + BLOCKED_CHECKIN_MARKER, + buildWarningComment, + buildClosureComment, + buildLinkedPRClosedComment, + buildBlockedCheckinComment, +} = require('./bot-inactivity-comments'); + +// ─── Context builder ───────────────────────────────────────────────────────── + +/** + * Builds a minimal context object compatible with shared API helpers. + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number + * @returns {{ github: object, owner: string, repo: string, number: number }} + */ +function buildCtx(github, owner, repo, number) { + return { github, owner, repo, number }; +} + +// ─── Activity helpers ───────────────────────────────────────────────────────── + +/** + * Returns the greater of two timestamps. If candidate is null or not later + * than current, returns current unchanged. + * @param {number} current + * @param {number|null} candidate + * @returns {number} + */ +function latestOf(current, candidate) { + if (candidate === null || candidate <= current) return current; + return candidate; +} + +/** + * Builds a Set of lowercased participant logins from an issue or PR object. + * Includes the item's user (author) and all assignees. + * @param {object} item + * @returns {Set} + */ +function collectParticipants(item) { + const logins = (item.assignees || []).map(a => a.login.toLowerCase()); + if (item.user?.login) logins.push(item.user.login.toLowerCase()); + return new Set(logins); +} + +/** + * Returns true if the commit was authored by the given (lowercased) login. + * @param {object} commit + * @param {string} login - Lowercased login to match. + * @returns {boolean} + */ +function isAuthorLogin(commit, login) { + return commit.author?.login?.toLowerCase() === login; +} + +/** + * Extracts the best available date string from a commit object. + * @param {object} commit + * @returns {string|null} + */ +function extractCommitDate(commit) { + return commit.commit?.committer?.date ?? commit.commit?.author?.date ?? null; +} + +/** + * Returns the timestamp (ms) of the most recent non-bot comment posted by any + * of the given usernames, or null if none found. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number - Issue or PR number. + * @param {Set} relevantLogins - Lowercased usernames to consider. + * @returns {Promise} + */ +async function getLastRelevantCommentDate(github, owner, repo, number, relevantLogins) { + const ctx = buildCtx(github, owner, repo, number); + const comments = await fetchComments(ctx); + + let latest = null; + for (const c of comments) { + if (!c.user || c.user.type === 'Bot') continue; + if (!relevantLogins.has(c.user.login.toLowerCase())) continue; + const t = new Date(c.created_at).getTime(); + latest = latestOf(latest === null ? -Infinity : latest, t); + } + return latest === -Infinity ? null : latest; +} + +/** + * Returns the timestamp (ms) of the most recent commit authored by the given + * login on the PR, or null if none found. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} prNumber + * @param {string} authorLogin + * @returns {Promise} + */ +async function getLastAuthorCommitDate(github, owner, repo, prNumber, authorLogin) { + const ctx = buildCtx(github, owner, repo, prNumber); + const commits = await fetchPRCommits(ctx); + const login = authorLogin.toLowerCase(); + let latest = null; + + for (const c of commits) { + if (!isAuthorLogin(c, login)) continue; + const dateStr = extractCommitDate(c); + latest = latestOf(latest === null ? -Infinity : latest, dateStr ? new Date(dateStr).getTime() : -Infinity); + } + return latest === null || latest === -Infinity ? null : latest; +} + +/** + * Returns the timestamp (ms) of the most recent event matching predicate, + * or null if no matching event is found. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number + * @param {function} predicate - Called with each event object; return true to include. + * @returns {Promise} + */ +async function getLastMatchingEventDate(github, owner, repo, number, predicate) { + const ctx = buildCtx(github, owner, repo, number); + const events = await fetchIssueEvents(ctx); + + let latest = null; + for (const e of events) { + if (!predicate(e)) continue; + const t = new Date(e.created_at).getTime(); + latest = latestOf(latest === null ? -Infinity : latest, t); + } + return latest === null || latest === -Infinity ? null : latest; +} + +/** + * Returns the timestamp (ms) of the most recent time "status: blocked" was + * removed from the item, or null if it has never been unblocked. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number + * @returns {Promise} + */ +async function getLastUnblockedDate(github, owner, repo, number) { + return getLastMatchingEventDate(github, owner, repo, number, + e => e.event === 'unlabeled' && e.label?.name === LABELS.BLOCKED); +} + +/** + * Returns the timestamp (ms) of the most recent time "status: needs revision" + * was applied to the item, or null if it has never been applied. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number + * @returns {Promise} + */ +async function getLastNeedsRevisionLabeledDate(github, owner, repo, number) { + return getLastMatchingEventDate(github, owner, repo, number, + e => e.event === 'labeled' && e.label?.name === LABELS.NEEDS_REVISION); +} + +/** + * Returns the timestamp (ms) of the most recent time the item was assigned, + * or null if no assigned event is found. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {number} number + * @returns {Promise} + */ +async function getLastAssignedDate(github, owner, repo, number) { + return getLastMatchingEventDate(github, owner, repo, number, + e => e.event === 'assigned'); +} + +// ─── Activity computation ──────────────────────────────────────────────────── + +/** + * Computes the last meaningful activity timestamp (ms) for a PR. + * Considers: relevant comments (by PR author/assignees) on the PR and its + * linked issues, commits by the PR author, and removal of the blocked label. + * + * Also used by computeIssueLastActivity when evaluating linked open PRs. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {object} pr - GitHub PR object. + * @returns {Promise} + */ +async function computePRLastActivity(github, owner, repo, pr) { + let latest = new Date(pr.created_at).getTime(); + const participants = collectParticipants(pr); + + if (participants.size > 0) { + const d = await getLastRelevantCommentDate(github, owner, repo, pr.number, participants); + latest = latestOf(latest, d); + } + + if (pr.user?.login) { + const d = await getLastAuthorCommitDate(github, owner, repo, pr.number, pr.user.login); + latest = latestOf(latest, d); + } + + const linkedIssueNums = parseIssueNumbers(pr.body || ''); + for (const issueNum of linkedIssueNums) { + const d = await getLastRelevantCommentDate(github, owner, repo, issueNum, participants); + latest = latestOf(latest, d); + } + + return latestOf(latest, await getLastUnblockedDate(github, owner, repo, pr.number)); +} + +/** + * Computes the last meaningful activity timestamp (ms) for an issue. + * Considers: the issue's own relevant comments, removal of the blocked label, + * and activity on any linked open PRs. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {object} issue - GitHub issue object. + * @param {object[]} linkedOpenPRs - Open PR objects whose bodies reference this issue. + * @returns {Promise} + */ +async function computeIssueLastActivity(github, owner, repo, issue, linkedOpenPRs) { + const assignedAt = await getLastAssignedDate(github, owner, repo, issue.number); + if (assignedAt === null) return null; + + let latest = assignedAt; + const assigneeLogins = new Set((issue.assignees || []).map(a => a.login.toLowerCase())); + + if (assigneeLogins.size > 0) { + const d = await getLastRelevantCommentDate(github, owner, repo, issue.number, assigneeLogins); + latest = latestOf(latest, d); + } + + latest = latestOf(latest, await getLastUnblockedDate(github, owner, repo, issue.number)); + + for (const pr of linkedOpenPRs) { + latest = latestOf(latest, await computePRLastActivity(github, owner, repo, pr)); + } + + return latest; +} + +// ─── (Comment builders moved to bot-inactivity-comments.js) ───────────────── + +// ─── State mutation ─────────────────────────────────────────────────────────── + +/** + * Resets an item back to "ready for dev": removes assignees, removes + * "status: in progress", adds "status: ready for dev". + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {object} item - Issue or PR object with .number, .assignees, .labels. + * @returns {Promise} + */ +async function resetItem(github, owner, repo, item, { addReadyForDev = true } = {}) { + const ctx = buildCtx(github, owner, repo, item.number); + const assigneeLogins = (item.assignees || []).map(a => a.login); + + if (assigneeLogins.length > 0) { + await removeAssignees(ctx, assigneeLogins); + } + const statusLabels = (item.labels || []) + .map(label => label.name) + .filter(name => name.startsWith("status:")); + for (const label of statusLabels) { + await removeLabel(ctx, label); + } + if (addReadyForDev) { + await addLabels(ctx, [LABELS.READY_FOR_DEV]); + } +} + +// ─── Stale and blocked handlers ─────────────────────────────────────────────── + +/** + * Formats a one-line per-item activity summary log. + * + * @param {object} item - Issue or PR object with .number and .assignees. + * @param {string} itemType - 'issue' or 'PR' + * @param {number} lastActivityMs - Timestamp of last meaningful activity. + * @param {number} nowMs - Current time in ms. + * @param {'closed'|'warned'|'none'} result + * @returns {string} + */ +function formatActivitySummary(item, itemType, lastActivityMs, nowMs, result) { + const days = Math.floor((nowMs - lastActivityMs) / (24 * 60 * 60 * 1000)); + const assigneeLogins = (item.assignees || []).map(a => a.login); + const assignees = assigneeLogins.length > 0 ? assigneeLogins.join(', ') : 'none'; + + let action = 'no action needed'; + if (result === 'warned') { + action = 'posting inactivity warning'; + } else if (result === 'closed') { + action = itemType === 'PR' ? 'closing PR' : 'unassigning and resetting issue'; + } + + return `#${item.number} (${itemType}): last activity ${days}d ago (assigned: ${assignees}), ${action}`; +} + +/** + * Applies the appropriate stale action to an item. + * - If >= 7 days inactive: close, post closure comment, reset. + * - If >= 5 days inactive: post or update warning comment. + * - Otherwise: no action. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {object} item - Issue or PR object. + * @param {number} lastActivityMs - Timestamp of last meaningful activity. + * @param {string} itemType - 'issue' or 'PR' + * @param {number} nowMs - Current time in ms (injectable for testing). + * @returns {Promise<'closed'|'warned'|'none'>} + */ +async function handleStaleItem(github, owner, repo, item, lastActivityMs, itemType, nowMs) { + const ctx = buildCtx(github, owner, repo, item.number); + const elapsed = nowMs - lastActivityMs; + const assigneeLogins = (item.assignees || []).map(a => a.login); + + if (elapsed >= CLOSE_AFTER_MS) { + if (itemType === 'PR') { + await closeItem(ctx); + } + await resetItem(github, owner, repo, item, { addReadyForDev: itemType !== 'PR' }); + await postComment(ctx, buildClosureComment(itemType, CLOSE_AFTER_MS)); + return 'closed'; + } + + if (elapsed >= WARN_AFTER_MS) { + await postOrUpdateComment(ctx, WARN_MARKER, buildWarningComment(assigneeLogins, itemType, WARN_AFTER_MS, CLOSE_AFTER_MS)); + return 'warned'; + } + + return 'none'; +} + +/** + * Handles a blocked item: posts or refreshes a check-in comment if 30 days + * have passed since the last one. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {object} item - Issue or PR object. + * @param {string} itemType - 'issue' or 'PR' + * @param {number} nowMs - Current time in ms (injectable for testing). + * @param {object} logger + * @returns {Promise} + */ +async function handleBlockedItem(github, owner, repo, item, itemType, nowMs, logger) { + const ctx = buildCtx(github, owner, repo, item.number); + const comments = await fetchComments(ctx); + + // Find the most recently updated check-in comment (there should be at most one). + const checkinComment = comments + .filter(c => c.body && c.body.startsWith(BLOCKED_CHECKIN_MARKER)) + .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at))[0]; + + const lastCheckinMs = checkinComment + ? new Date(checkinComment.updated_at || checkinComment.created_at).getTime() + : null; + + if (lastCheckinMs === null || (nowMs - lastCheckinMs) >= BLOCKED_CHECKIN_AFTER_MS) { + logger.log(`#${item.number} (${itemType}): posting blocked check-in`); + const assigneeLogins = (item.assignees || []).map(a => a.login); + await postOrUpdateComment(ctx, BLOCKED_CHECKIN_MARKER, buildBlockedCheckinComment(assigneeLogins, itemType)); + } else { + logger.log(`#${item.number} (${itemType}): blocked check-in not due yet`); + } +} + +// ─── Data fetching ──────────────────────────────────────────────────────────── + +/** + * Fetches all open assigned issues (paginated). Includes issues with any label + * (blocked or in-progress) so both are handled in the main loop. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @returns {Promise} + */ +async function fetchAssignedIssues(github, owner, repo) { + const items = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + assignee: '*', + per_page: perPage, + page, + }); + // issues.listForRepo returns both issues and PRs — filter to issues only + items.push(...data.filter(item => !item.pull_request)); + if (data.length < perPage) break; + page++; + } + + return items; +} + +/** + * Fetches all open PRs (paginated). + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @returns {Promise} + */ +async function fetchOpenPRs(github, owner, repo) { + const prs = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: perPage, + page, + }); + prs.push(...data); + if (data.length < perPage) break; + page++; + } + + return prs; +} + +/** + * Builds a map from issue number → array of open PR objects whose body + * references that issue via closing keywords or "related to". + * + * @param {object[]} openPRs + * @returns {Map} + */ +function buildIssueToPRsMap(openPRs) { + const map = new Map(); + for (const pr of openPRs) { + const issueNums = parseIssueNumbers(pr.body || ''); + for (const num of issueNums) { + if (!map.has(num)) map.set(num, []); + map.get(num).push(pr); + } + } + return map; +} + +// ─── Main entrypoint ────────────────────────────────────────────────────────── + +/** + * Main entrypoint for the inactivity bot. + * + * @param {{ github: object, context: object, getNow?: () => number }} args + * - getNow is injectable for testing; defaults to Date.now. + */ +module.exports = async function ({ github, context, getNow = () => Date.now() }) { + const logger = createLogger('[inactivity-bot]'); + const { owner, repo } = context.repo; + const nowMs = getNow(); + + logger.log(`Starting inactivity check (now=${new Date(nowMs).toISOString()})`); + + // ── Fetch data ──────────────────────────────────────────────────────────── + + const [assignedIssues, allOpenPRs] = await Promise.all([ + fetchAssignedIssues(github, owner, repo), + fetchOpenPRs(github, owner, repo), + ]); + + const processedIssueNumbers = new Set(); + + const assignedOpenPRs = allOpenPRs.filter(pr => (pr.assignees || []).length > 0); + const issueToOpenPRs = buildIssueToPRsMap(allOpenPRs); + + logger.log( + `Found ${assignedIssues.length} assigned issues, ` + + `${assignedOpenPRs.length} assigned open PRs` + ); + + // ── Process assigned PRs ────────────────────────────────────────────────── + + for (const pr of assignedOpenPRs) { + if (hasLabel(pr, LABELS.BLOCKED)) { + await handleBlockedItem(github, owner, repo, pr, 'PR', nowMs, logger); + continue; + } + + if (hasLabel(pr, LABELS.NEEDS_REVIEW)) { + logger.log(`#${pr.number} (PR): skipping (status: needs review)`); + continue; + } + + let lastActivity; + if (hasLabel(pr, LABELS.NEEDS_REVISION)) { + const revisionLabeledAt = await getLastNeedsRevisionLabeledDate(github, owner, repo, pr.number); + const prActivity = await computePRLastActivity(github, owner, repo, pr); + lastActivity = latestOf(prActivity, revisionLabeledAt); + } else { + lastActivity = await computePRLastActivity(github, owner, repo, pr); + } + + const result = await handleStaleItem(github, owner, repo, pr, lastActivity, 'PR', nowMs); + logger.log(formatActivitySummary(pr, 'PR', lastActivity, nowMs, result)); + + if (result === 'closed') { + // Clean up issues linked to this PR immediately + const linkedIssueNums = parseIssueNumbers(pr.body || ''); + for (const issueNum of linkedIssueNums) { + try { + const { data: linkedIssue } = await github.rest.issues.get({ + owner, repo, issue_number: issueNum, + }); + if (linkedIssue.state === 'open' && (linkedIssue.assignees || []).length > 0) { + logger.log(`Cleaning up linked issue #${issueNum} (PR #${pr.number} closed for inactivity)`); + await resetItem(github, owner, repo, linkedIssue); + const ctx = buildCtx(github, owner, repo, issueNum); + await postComment(ctx, buildLinkedPRClosedComment()); + processedIssueNumbers.add(issueNum); + } + } catch (err) { + logger.error(`Could not clean up linked issue #${issueNum}: ${err.message}`); + } + } + } + } + + // ── Process assigned issues ─────────────────────────────────────────────── + + for (const issue of assignedIssues) { + if (processedIssueNumbers.has(issue.number)) { + logger.log(`#${issue.number}: skipping (already handled via PR loop)`); + continue; + } + if (hasLabel(issue, LABELS.BLOCKED)) { + await handleBlockedItem(github, owner, repo, issue, 'issue', nowMs, logger); + continue; + } + + if (!hasLabel(issue, LABELS.IN_PROGRESS)) { + logger.log(`#${issue.number}: skipping (no in-progress or blocked label)`); + continue; + } + + const linkedPRs = issueToOpenPRs.get(issue.number) || []; + const lastActivity = await computeIssueLastActivity(github, owner, repo, issue, linkedPRs); + if (lastActivity === null) { + logger.log(`#${issue.number}: skipping (no assigned event found)`); + continue; + } + const result = await handleStaleItem(github, owner, repo, issue, lastActivity, 'issue', nowMs); + logger.log(formatActivitySummary(issue, 'issue', lastActivity, nowMs, result)); + } + + logger.log('Inactivity check complete'); +}; diff --git a/.github/scripts/bot-on-comment.js b/.github/scripts/bot-on-comment.js new file mode 100644 index 0000000..990b962 --- /dev/null +++ b/.github/scripts/bot-on-comment.js @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-comment.js +// +// Handles issue comment events: reads the comment body, parses commands, and dispatches +// to the appropriate handler. Implemented commands: /assign, /unassign, /finalize. +// +// /assign: see commands/assign.js (skill levels, assignment limits, required labels). +// /unassign: see commands/unassign.js (authorization, label reversion). +// /finalize: see commands/finalize.js (triage permission required; validates labels, +// updates issue title/body with skill-level format, swaps status labels). + +const { createLogger, buildBotContext } = require('./helpers'); +const { handleAssign } = require('./commands/assign'); +const { handleUnassign } = require('./commands/unassign'); +const { handleFinalize } = require('./commands/finalize'); + +const COMMAND_HANDLERS = { + assign: handleAssign, + unassign: handleUnassign, + finalize: handleFinalize, +}; + +const KNOWN_COMMANDS = Object.keys(COMMAND_HANDLERS); + +let logger = createLogger('on-comment'); + +// ============================================================================= +// COMMAND PARSING +// ============================================================================= + +/** + * Parses the comment body and returns the list of commands to run. + * Commands are recognized by exact match (with optional surrounding whitespace). + * + * @param {string} body - The comment body. + * @returns {{ commands?: string[], nearMiss?: string }} - List of command names (e.g. ['assign'] or []). + */ + +function parseComment(body) { + if (typeof body !== 'string') { + return { commands: [] }; + } + + const trimmed = body.trim(); + + for (const command of KNOWN_COMMANDS) { + // exact match + if (new RegExp(`^/${command}$`, 'i').test(trimmed)) { + logger.log(`parseComment: detected /${command}`); + return { commands: [command] }; + } + + // near miss + if (new RegExp(`^/${command}\\b`, 'i').test(trimmed)) { + logger.log(`parseComment: near miss /${command}`); + return { nearMiss: command }; + } + } + + logger.log('parseComment: no known command', { body: body.substring(0, 80) }); + return { commands: [] }; +} + +// ============================================================================= +// ENTRY POINT +// ============================================================================= + +/** + * Entry point: read comment, parse commands, dispatch to handlers. + * Validates that the event is a comment from a human; then runs each detected command. + */ +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + if (!botContext.comment?.user?.login) { + logger.log('Exit: missing comment user login'); + return; + } + + if (botContext.comment.user.type === 'Bot') { + logger.log('Exit: comment authored by bot'); + return; + } + + const parsed = parseComment(botContext.comment.body); + if (parsed.nearMiss) { + logger = createLogger(`on-${parsed.nearMiss}`); + + await botContext.postComment( + `⚠️ The command \`/${parsed.nearMiss}\` must be used alone.\n\nPlease comment exactly:\n\`/${parsed.nearMiss}\`` + ); + + return; + } + + if (!parsed.commands || parsed.commands.length === 0) { + logger.log('Exit: no known command'); + return; + } + + + for (const command of parsed.commands) { + // Update logger prefix to the command name so helper functions + // (postComment, addLabels, etc.) log with the correct tag. + logger = createLogger(`on-${command}`); + + const handler = COMMAND_HANDLERS[command]; + + if (handler) { + await handler(botContext); + } else { + logger.log('Unknown command:', command); + } + } + } catch (error) { + logger.error('Error:', { + message: error.message, + status: error.status, + number: context.payload.issue?.number, + commenter: context.payload.comment?.user?.login, + }); + throw error; + } +}; + +module.exports.parseComment = parseComment; diff --git a/.github/scripts/bot-on-pr-close.js b/.github/scripts/bot-on-pr-close.js new file mode 100644 index 0000000..a309fd6 --- /dev/null +++ b/.github/scripts/bot-on-pr-close.js @@ -0,0 +1,152 @@ +// 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 { + MAINTAINER_TEAM, + createLogger, + buildBotContext, + fetchClosingIssueNumbers, + fetchIssue, + fetchLatestMilestone, + getLabelsByPrefix, + postComment, + removeLabel, + resolveLinkedIssue, + setMilestone, +} = require('./helpers'); +const { handleRecommendIssues } = require('./bot/bot-recommend-issues'); + +let logger = createLogger('on-pr-close'); + +function buildMissingMilestoneComment() { + return [ + `${MAINTAINER_TEAM} a PR was merged, but there are no open milestones available.`, + '', + 'Please create an open milestone so this merged work can be assigned appropriately.', + ].join('\n'); +} + +async function removeStatusLabels(botContext, item) { + const statusLabels = getLabelsByPrefix(item, 'status:'); + for (const label of statusLabels) { + const result = await removeLabel(botContext, label); + if (!result.success) { + logger.error(`Failed to remove status label "${label}" from #${botContext.number}: ${result.error}`); + } + } +} + +async function applyMergeMilestoneAutomation(botContext) { + await removeStatusLabels(botContext, botContext.pr); + + const milestone = await fetchLatestMilestone(botContext); + if (!milestone) { + await postComment(botContext, buildMissingMilestoneComment()); + return false; + } + + const issueNumbers = await fetchClosingIssueNumbers(botContext); + if (issueNumbers.length === 0) { + await setMilestone(botContext, botContext.number, milestone.number); + return true; + } + + for (const issueNumber of issueNumbers) { + const issue = await fetchIssue(botContext, issueNumber); + const issueContext = { ...botContext, number: issueNumber, issue }; + await removeStatusLabels(issueContext, issue); + await setMilestone(botContext, issueNumber, milestone.number); + } + + return true; +} + +// ============================================================================= +// 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 milestoneReady = await applyMergeMilestoneAutomation(botContext); + if (!milestoneReady) { + logger.log('Exit: no open milestone available'); + 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/bot-on-pr-merged.js b/.github/scripts/bot-on-pr-merged.js new file mode 100644 index 0000000..ae7479c --- /dev/null +++ b/.github/scripts/bot-on-pr-merged.js @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-merged.js +// +// Handles pull_request closed events where the PR was merged. +// Updates the dashboard comments of sibling PRs when a newly merged PR +// introduces or resolves merge conflicts in them. + +const { + createLogger, + buildBotContext, + fetchOpenPRs, + getBotComment, + runAllChecksAndComment, + swapStatusLabel, + postComment +} = require('./helpers'); +const { checkMergeConflict } = require('./helpers/checks'); +const { MARKER, buildMergeConflictNotificationComment } = require('./helpers/comments'); + +const logger = createLogger('on-pr-merged'); + +/** + * Iterates through open PRs, checking for merge conflicts. + * If a PR's conflict state has changed (compared to its dashboard comment), + * it runs the checks again and updates the PR dashboard. + * + * @param {object} globalBotContext - Base context scoped to the repo. + */ +async function checkSiblingConflictsOnMerge(globalBotContext) { + const openPRs = await fetchOpenPRs(globalBotContext); + const mergedPRNumber = globalBotContext.number; + + for (const pr of openPRs) { + if (pr.draft || pr.number === mergedPRNumber) { + continue; + } + + // Context specific to the sibling PR + const prContext = { ...globalBotContext, number: pr.number, pr }; + + // Check actual merge conflict status + const mergeResult = await checkMergeConflict(prContext); + const actuallyHasConflict = !mergeResult.passed; + + // Determine current dashboard status shown to the user + const existingComment = await getBotComment(prContext, MARKER); + const currentlyShowsConflict = existingComment + ? existingComment.body.includes(':x: **Merge Conflicts**') + : false; + + if (currentlyShowsConflict !== actuallyHasConflict) { + logger.log(`PR #${pr.number} conflict status changed to ${actuallyHasConflict ? 'conflicted' : 'clean'}, updating...`); + const { allPassed } = await runAllChecksAndComment(prContext, { merge: mergeResult }); + const swapResult = await swapStatusLabel(prContext, allPassed); + if (!swapResult.success) { + logger.error(`Failed to swap status label for PR #${pr.number}: ${swapResult.errorDetails}`); + } + + // Post a standalone notification when a conflict is newly introduced + if (!currentlyShowsConflict && actuallyHasConflict) { + const prAuthor = pr.user?.login; + if (prAuthor) { + const notification = buildMergeConflictNotificationComment(prAuthor, mergedPRNumber); + await postComment(prContext, notification); + } + } + } else { + logger.log(`PR #${pr.number} conflict status unchanged (${actuallyHasConflict ? 'conflicted' : 'clean'}), skipping...`); + } + } +} + +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + if (!botContext.pr || !botContext.pr.merged) { + logger.log('PR is not merged, exiting sibling conflict check.'); + return; + } + + // Decision: Sibling-conflict check is placed in a dedicated `bot-on-pr-merged.js` script + // rather than appending it to `bot-on-pr-close.js`. This separates the core PR status + // updates (conflicts/labels/dashboard) from the contributor workflow automation + // (issue recommendation) handled by `bot-on-pr-close.js`. + await checkSiblingConflictsOnMerge(botContext); + + } catch (error) { + logger.error('Error in sibling conflict check:', error); + throw error; + } +}; diff --git a/.github/scripts/bot-on-pr-open.js b/.github/scripts/bot-on-pr-open.js new file mode 100644 index 0000000..5fb3f18 --- /dev/null +++ b/.github/scripts/bot-on-pr-open.js @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-open.js +// +// Runs when a PR is opened, reopened, or converted from draft (ready_for_review). +// Performs all 4 checks (DCO, GPG, merge conflict, issue link), posts/updates +// the unified dashboard comment, auto-assigns the author, and applies the +// appropriate status label. + +const { + createLogger, + buildBotContext, + addAssignees, + requireSafeUsername, + runAllChecksAndComment, + swapStatusLabel, +} = require('./helpers'); + +const logger = createLogger('on-pr-open'); + +/** + * Auto-assigns the PR author if not already assigned. + * @param {object} botContext + */ +async function autoAssignAuthor(botContext) { + const prAuthor = botContext.pr?.user?.login; + if (!prAuthor) { + logger.log('Exit: missing pull request author'); + return; + } + try { + requireSafeUsername(prAuthor, 'pr.author'); + } catch (err) { + logger.log('Exit: invalid pr.author', err.message); + return; + } + + const currentAssignees = botContext.pr?.assignees || []; + const isAlreadyAssigned = currentAssignees.some( + (a) => (a?.login || '').toLowerCase() === prAuthor.toLowerCase() + ); + if (isAlreadyAssigned) { + logger.log(`Author ${prAuthor} is already assigned`); + return; + } + await addAssignees(botContext, [prAuthor]); +} + +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + await autoAssignAuthor(botContext); + + if (botContext.pr?.user?.type === 'Bot') { + logger.log('Skipping bot-authored PR'); + return; + } + + const { allPassed } = await runAllChecksAndComment(botContext); + const result = await swapStatusLabel(botContext, allPassed, { force: true }); + if (!result.success) { + logger.error(`Failed to swap status label: ${result.errorDetails}`); + } + + logger.log('On-PR-open bot completed'); + } catch (error) { + logger.error('Error:', { + message: error.message, + number: context?.payload?.pull_request?.number, + }); + throw error; + } +}; diff --git a/.github/scripts/bot-on-pr-review-labels.js b/.github/scripts/bot-on-pr-review-labels.js new file mode 100644 index 0000000..bd19ff6 --- /dev/null +++ b/.github/scripts/bot-on-pr-review-labels.js @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-review-labels.js +// +// Triggered by workflow_run after "Bot - On PR Review" completes. +// Downloads the recorder artifact, reconstructs context, and delegates to +// bot-on-pr-review.js to apply the correct status label. + +const fs = require('fs'); + +module.exports = async ({ github, context }) => { + const data = JSON.parse( + fs.readFileSync('review-event.json', 'utf8') + ); + + if (data.draft) { + return; + } + + const prNumber = data.pr_number; + const reviewState = data.review_state; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + context.eventName = 'pull_request_review'; + + context.payload = { + pull_request: pr, + review: { + state: reviewState, + }, + }; + + const bot = require('./bot-on-pr-review.js'); + await bot({ github, context }); +}; diff --git a/.github/scripts/bot-on-pr-review.js b/.github/scripts/bot-on-pr-review.js new file mode 100644 index 0000000..848e690 --- /dev/null +++ b/.github/scripts/bot-on-pr-review.js @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-review.js +// +// Triggers on pull_request_review: submitted. +// When a maintainer requests changes, automatically swaps the needs-review label +// to needs-revision. + +const { + createLogger, + buildBotContext, + swapStatusLabel, +} = require('./helpers'); + +const logger = createLogger('on-pr-review'); + +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + const state = context.payload.review?.state?.toLowerCase(); + + if (state !== 'changes_requested') { + logger.log(`Review state is '${state}', ignoring`); + return; + } + + // Force swap to needs-revision (allPassed = false) + const result = await swapStatusLabel(botContext, false, { force: true }); + if (!result.success) { + logger.error(`Failed to swap status to needs revision: ${result.errorDetails}`); + } else { + logger.log(`Successfully swapped status to needs revision`); + } + } catch (error) { + logger.error('Error:', { + message: error.message, + number: context?.payload?.pull_request?.number, + }); + throw error; + } +}; diff --git a/.github/scripts/bot-on-pr-update.js b/.github/scripts/bot-on-pr-update.js new file mode 100644 index 0000000..30dc44a --- /dev/null +++ b/.github/scripts/bot-on-pr-update.js @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bot-on-pr-update.js +// +// Runs on new commits (synchronize) and PR body edits (edited). Performs all +// 4 checks (DCO, GPG, merge conflict, issue link), posts/updates the unified +// dashboard comment, and conditionally swaps the status label. +// For edited events, exits early if only the title or base branch changed. + +const { + createLogger, + buildBotContext, + swapStatusLabel, + runAllChecksAndComment, +} = require('./helpers'); + +const logger = createLogger('on-pr-update'); + +module.exports = async ({ github, context }) => { + try { + const botContext = buildBotContext({ github, context }); + + if (botContext.pr?.user?.type === 'Bot') { + logger.log('Skipping bot-authored PR'); + return; + } + + // Edits can be triggered by title changes, but we only care about body changes. + if (context.payload.action === 'edited' && !context.payload.changes?.body) { + logger.log('Body not changed, skipping'); + return; + } + + const { allPassed } = await runAllChecksAndComment(botContext); + const result = await swapStatusLabel(botContext, allPassed); + if (!result.success) { + logger.error(`Failed to swap status label: ${result.errorDetails}`); + } + + logger.log('On-PR-update bot completed'); + } catch (error) { + logger.error('Error:', { + message: error.message, + number: context?.payload?.pull_request?.number, + }); + throw error; + } +}; diff --git a/.github/scripts/bot/bot-recommend-issues.js b/.github/scripts/bot/bot-recommend-issues.js new file mode 100644 index 0000000..58cea7d --- /dev/null +++ b/.github/scripts/bot/bot-recommend-issues.js @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/recommend-issues.js +// +// Issue recommendation command: suggests relevant issues to contributors +// after a PR is closed. Uses a history-based eligibility model to recommend issues strictly +// at the highest level the contributor is currently allowed to claim. + +const { + MAINTAINER_TEAM, + LABELS, + SKILL_HIERARCHY, + SKILL_PREREQUISITES, + ISSUE_STATE, + hasLabel, + postComment, + getHighestIssueSkillLevel, + countIssuesByAssignee, + PRIORITY_HIERARCHY, + createDelegatingLogger, +} = require('../helpers'); + +const logger = createDelegatingLogger(); + +/** + * 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, + sort: 'created', + order: 'asc', + }); + + 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. + * + * When `unlockedLevel` is provided, a congratulatory block is prepended to + * acknowledge that this contribution crossed the threshold into a new skill + * level. The display name is sourced from SKILL_PREREQUISITES so it stays + * consistent with the rest of the bot. + * + * @param {string} username + * @param {Array<{ title: string, html_url: string }>} issues + * @param {string|null} unlockedLevel - LABELS constant for the newly unlocked + * level, or null if no threshold was crossed. + * @returns {string} + */ +function buildRecommendationComment(username, issues, unlockedLevel = null) { + const list = issues.map( + (issue) => `- [${issue.title}](${issue.html_url})` + ); + + const congratsBlock = unlockedLevel + ? [ + `🏆 Milestone unlocked: you've just reached **${SKILL_PREREQUISITES[unlockedLevel].displayName}** level!`, + `That's a big step — well done. 🎊`, + '', + ] + : []; + + return [ + `👋 Hi @${username}! Great work on your recent contribution! 🎉`, + '', + ...congratsBlock, + `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'); +} + +/** + * Checks whether a contributor qualifies for a level via bypass. + * + * A contributor passes the bypass check if they have at least one closed + * issue at the candidate level or any higher level. This indicates prior + * experience at that difficulty, so prerequisite counts are not required. + * + * Iterates through all labels at or above the candidate level and stops + * as soon as a qualifying issue is found. + * + * API failures are treated as non-qualifying and skipped. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {string} username + * @param {string} candidate - Skill level being evaluated + * @returns {Promise} True if bypass condition is satisfied + */ +async function passesBypassCheck(github, owner, repo, username, candidate) { + const candidateIndex = SKILL_HIERARCHY.indexOf(candidate); + const atOrAboveLabels = SKILL_HIERARCHY.slice(candidateIndex); + + for (const label of atOrAboveLabels) { + const count = await countIssuesByAssignee( + github, owner, repo, username, + ISSUE_STATE.CLOSED, + label, + 1, + ); + // Skip on API failure + if (count === null) continue; + + if (count >= 1) return true; + } + return false; +} + +/** + * Checks whether a contributor qualifies for a level via prerequisite count. + * + * A contributor passes the normal check if they have completed at least + * `requiredCount` issues at the prerequisite level. + * + * The threshold is passed to countIssuesByAssignee so the query can stop + * early once the requirement is satisfied. + * + * @param {object} github + * @param {string} owner + * @param {string} repo + * @param {string} username + * @param {{ requiredLabel: string, requiredCount: number }} prereq + * @returns {Promise} + * - true → requirement met + * - false → requirement not met + * - null → API failure + */ +async function passesNormalCheck(github, owner, repo, username, prereq) { + const count = await countIssuesByAssignee( + github, owner, repo, username, + ISSUE_STATE.CLOSED, + prereq.requiredLabel, + prereq.requiredCount, + ); + + if (count === null) return null; + + return count >= prereq.requiredCount; +} + +/** + * Determines the highest skill level a contributor is eligible for + * based on their completed issues. + * + * The hierarchy is evaluated from highest to lowest level, returning + * the first level that satisfies either of the following: + * + * - Bypass check: + * The contributor has at least one closed issue at the candidate + * level or any higher level. This indicates prior experience, so + * prerequisite counts are not required. + * + * - Normal check: + * The contributor has completed at least `requiredCount` issues + * at the prerequisite level defined in SKILL_PREREQUISITES. + * + * The two checks are delegated to helper functions: + * - passesBypassCheck + * - passesNormalCheck + * + * Levels without a prerequisite label (e.g. Good First Issue) act as + * the floor and are always considered eligible. + * + * If an API call fails during evaluation, that candidate level is skipped + * and the next lower level is considered. + * + * @param {object} botContext + * @param {string} username + * @returns {Promise} The highest eligible skill level label + */ +async function resolveEligibleLevel(botContext, username) { + const { github, owner, repo } = botContext; + const topDown = [...SKILL_HIERARCHY].reverse(); + + for (const candidate of topDown) { + const prereq = SKILL_PREREQUISITES[candidate]; + + if (!prereq) continue; + + // Floor level + if (!prereq.requiredLabel) return candidate; + + // Bypass check + const bypass = await passesBypassCheck( + github, owner, repo, username, candidate + ); + if (bypass) return candidate; + + // Normal check + const normal = await passesNormalCheck( + github, owner, repo, username, prereq + ); + + // Skip candidate if API failed + if (normal === null) continue; + + if (normal) return candidate; + } + + return LABELS.GOOD_FIRST_ISSUE; +} + +/** + * Checks whether the just-completed issue caused the contributor to reach + * the threshold required to unlock the level directly above `currentLevel`. + * + * The count is evaluated using a capped query (threshold = requiredCount + 1), + * allowing the function to distinguish between: + * - reaching the threshold exactly for the first time + * - having already exceeded it earlier + * + * Only the immediate next level is considered. + * + * @param {object} botContext + * @param {string} username + * @param {string} currentLevel + * @returns {Promise} + */ +async function detectUnlockedLevel(botContext, username, currentLevel) { + const { github, owner, repo } = botContext; + const currentIndex = SKILL_HIERARCHY.indexOf(currentLevel); + const immediateNextLevel = SKILL_HIERARCHY[currentIndex + 1] ?? null; + + if (!immediateNextLevel) return null; + + const nextPrereq = SKILL_PREREQUISITES[immediateNextLevel]; + + // The next level's prerequisite must be the current level for this + // completion to be the relevant crossing event. + if (!nextPrereq?.requiredLabel || nextPrereq.requiredLabel !== currentLevel) return null; + + const count = await countIssuesByAssignee( + github, owner, repo, username, + ISSUE_STATE.CLOSED, + currentLevel, + nextPrereq.requiredCount + 1, + ); + + if (count === null) { + logger.log('detectUnlockedLevel: countIssuesByAssignee failed', { + user: username, currentLevel, + }); + return null; + } + + if (count === nextPrereq.requiredCount) { + logger.log('detectUnlockedLevel: threshold crossed', { + user: username, unlockedLevel: immediateNextLevel, count, + }); + return immediateNextLevel; + } + + return null; +} + +/** + * Sorts issues based on the PRIORITY_HIERARCHY. + * If two issues share the same priority, the oldest (earlier `created_at`) + * comes first; issues without a recognized priority label fall to the end. + * + * @param {Array} issues + * @returns {Array} Sorted copy of the issues. + */ +function sortByPriority(issues) { + return [...issues].sort((a, b) => { + const ai = PRIORITY_HIERARCHY.findIndex(p => hasLabel(a, p)); + const bi = PRIORITY_HIERARCHY.findIndex(p => hasLabel(b, p)); + const aRank = ai === -1 ? PRIORITY_HIERARCHY.length : ai; + const bRank = bi === -1 ? PRIORITY_HIERARCHY.length : bi; + if (aRank !== bRank) { + return aRank - bRank; + } + // Tiebreaker: Use the creation date (Oldest first) + return new Date(a.created_at) - new Date(b.created_at); + }); +} + +/** + * Returns recommended issues for the contributor based on their true + * eligibility level, determined by a history-based top-down walk. + * + * Issues are fetched in a single batch and filtered locally. + * Recommendations are restricted to the contributor's resolved eligible level, + * so all suggested issues are immediately assignable. + * + * Also runs a focused threshold check (detectUnlockedLevel) to determine + * whether this specific completion crossed a new level boundary, so the caller + * can include a congratulatory message when appropriate. + * + * @param {object} botContext + * @param {string} username + * @param {string} currentLevel - Skill label from the triggering PR/issue. + * @returns {Promise<{ issues: Array<{ title: string, html_url: string }>, unlockedLevel: string|null } | null>} + * Returns null on API failure (error comment already posted). + */ +async function getRecommendedIssues(botContext, username, currentLevel) { + // Run both async operations concurrently — they're independent. + const [eligibleLevel, unlockedLevel] = await Promise.all([ + resolveEligibleLevel(botContext, username), + detectUnlockedLevel(botContext, username, currentLevel), + ]); + + logger.log('getRecommendedIssues: resolved', { + user: username, + currentLevel, + eligibleLevel, + unlockedLevel, + }); + + const issues = await fetchIssuesBatch( + botContext.github, + botContext.owner, + botContext.repo, + ); + + if (issues === null) { + await postComment( + botContext, + buildRecommendationErrorComment(username) + ); + return null; + } + // Sort issues by priority. + const sorted = sortByPriority(issues); + // Filter issues to the resolved eligible level so all results are actionable. + const grouped = groupIssuesByLevel(sorted, [eligibleLevel]); + return { + issues: pickFirstAvailableLevel(grouped, [eligibleLevel]), + unlockedLevel, + }; +} + +/** + * Main handler for issue recommendations after a PR is merged. + * + * - Determines skill level + * - Fetches recommended issues + * - Posts a comment if results exist, with a congratulatory prefix when the + * just-completed issue crossed a new level threshold + * + * 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 = getHighestIssueSkillLevel(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 result = await getRecommendedIssues( + botContext, + username, + skillLevel, + ); + + if (result === null) return; + + const { issues, unlockedLevel } = result; + + if (issues.length === 0) { + logger.log('recommendation.empty', { user: username }); + return; + } + + const comment = buildRecommendationComment(username, issues, unlockedLevel); + logger.log('recommendation.postComment', { + target: botContext.number, + issueSource: botContext.issue?.number, + recommendations: issues.length, + unlockedLevel, + }); + const postResult = await postComment(botContext, comment); + + if (!postResult.success) { + logger.error('recommendation.postCommentFailed', { + error: postResult.error, + }); + return; + } + + logger.log('recommendation.posted'); +} + +module.exports = { + handleRecommendIssues, + getRecommendedIssues, + resolveEligibleLevel, + detectUnlockedLevel, +}; diff --git a/.github/scripts/commands/assign-comments.js b/.github/scripts/commands/assign-comments.js new file mode 100644 index 0000000..5cea22f --- /dev/null +++ b/.github/scripts/commands/assign-comments.js @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/assign-comments.js +// +// Comment builders for the /assign command. Pure formatting functions +// separated from assignment logic for readability. + +const { + MAINTAINER_TEAM, + GFI_SUPPORT_TEAM, + LABELS, + ISSUE_STATE, + SKILL_HIERARCHY, + SKILL_PREREQUISITES, + AUTOMATION_CONFIG, +} = require("../helpers"); + +/** + * Maximum number of open (non-blocked) issues a contributor can be assigned to + * at the same time. Enforced by handleAssign in assign.js. + * @type {number} + */ +const MAX_OPEN_ASSIGNMENTS = AUTOMATION_CONFIG.assignmentLimits.maxOpenAssignments; + +/** + * Maximum number of Good First Issues a contributor may complete before being + * redirected to Beginner and higher-level issues. Enforced by handleAssign in assign.js. + * @type {number} + */ +const MAX_GFI_COMPLETIONS = AUTOMATION_CONFIG.assignmentLimits.maxGfiCompletions; + +/** + * Builds the welcome comment posted after a successful assignment. Returns a + * special first-timer welcome for Good First Issues (mentioning the support team), + * or a shorter returning-contributor message for all other skill levels. + * + * @param {string} username - The GitHub username being assigned. + * @param {string} skillLevel - The skill-level label on the issue (a LABELS constant). + * @returns {string} The formatted Markdown comment body. + */ +function buildWelcomeComment(username, skillLevel) { + const isGoodFirstIssue = skillLevel === LABELS.GOOD_FIRST_ISSUE; + const skillDisplayName = + SKILL_PREREQUISITES[skillLevel]?.displayName || "issue"; + if (isGoodFirstIssue) { + return [ + `👋 Hi @${username}, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉`, + "", + `You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (${GFI_SUPPORT_TEAM}) is ready to help you succeed.`, + "", + "The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.", + "", + "If you realize you cannot complete this issue, simply comment `/unassign` to return it to the community pool.", + "", + "Good luck, and welcome aboard! 🚀", + ].join("\n"); + } + return [ + `👋 Hi @${username}, thanks for continuing to contribute to the KDM CLI! You've been assigned this **${skillDisplayName}** issue. 🙌`, + "", + "If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.", + "", + "If you realize you cannot complete this issue, simply comment `/unassign` to return it to the pool.", + "", + "Good luck! 🚀", + ].join("\n"); +} + +/** + * Builds the comment posted when an issue is already assigned. If the requester + * is the current assignee, confirms they're already set; otherwise names the + * current assignee and links to unassigned ready issues. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {{ assignees?: Array<{ login: string }> }} issue - The issue object from the payload. + * @param {string} owner - Repository owner (for the browse-issues URL). + * @param {string} repo - Repository name (for the browse-issues URL). + * @returns {string} The formatted Markdown comment body. + */ +function buildAlreadyAssignedComment(requesterUsername, issue, owner, repo) { + const isAssignedToSelf = issue?.assignees?.some( + (a) => + (a.login || "").toLowerCase() === (requesterUsername || "").toLowerCase(), + ); + if (isAssignedToSelf) { + const otherAssignees = (issue?.assignees || []) + .map((assignee) => assignee?.login) + .filter( + (login) => + login && + login.toLowerCase() !== (requesterUsername || "").toLowerCase(), + ); + const currentAssignmentLine = + otherAssignees.length > 0 + ? `You're already assigned to this issue, which is also assigned to ${otherAssignees.map((login) => `@${login}`).join(", ")}.` + : "You're already assigned to this issue. You're all set to start working on it!"; + return [ + `👋 Hi @${requesterUsername}! ${currentAssignmentLine}`, + "", + "If you have any questions, feel free to ask here or reach out to the team.", + ].join("\n"); + } + const assigneeLogins = (issue?.assignees || []) + .map((assignee) => assignee?.login) + .filter(Boolean); + const currentAssignee = + assigneeLogins.length === 0 + ? "someone" + : assigneeLogins.map((login) => `@${login}`).join(", "); + return [ + `👋 Hi @${requesterUsername}! This issue is already assigned to ${currentAssignee}.`, + "", + "👉 **Find another issue to work on:**", + `[Browse unassigned issues](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22)`, + "", + "Once you find one you like, comment `/assign` to get started!", + ].join("\n"); +} + +/** + * Builds the comment posted when the issue is missing the "status: ready for dev" + * label. Explains the requirement and links to issues that are ready. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string} owner - Repository owner (for the browse-issues URL). + * @param {string} repo - Repository name (for the browse-issues URL). + * @returns {string} The formatted Markdown comment body. + */ +function buildNotReadyComment(requesterUsername, owner, repo) { + return [ + `👋 Hi @${requesterUsername}! This issue is not ready for development yet.`, + "", + `Issues must have the \`${LABELS.READY_FOR_DEV}\` label before they can be assigned.`, + "", + "👉 **Find an issue that's ready:**", + `[Browse ready issues](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22)`, + "", + "Once you find one you like, comment `/assign` to get started!", + ].join("\n"); +} + +/** + * Builds the comment posted when a contributor hasn't completed enough prerequisite + * issues. Shows the required count, current progress, and a search link to find + * available prerequisite-level issues. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string} skillLevel - The skill-level label on the issue (a LABELS constant). + * @param {number} completedCount - How many prerequisite issues the user has completed so far. + * @param {string} owner - Repository owner (for the search URL). + * @param {string} repo - Repository name (for the search URL). + * @returns {string} The formatted Markdown comment body. + */ +function buildPrerequisiteNotMetComment( + requesterUsername, + skillLevel, + completedCount, + owner, + repo, +) { + const prereq = SKILL_PREREQUISITES[skillLevel]; + const { requiredLabel, requiredCount, prerequisiteDisplayName, displayName } = + prereq; + const searchQuery = `is:issue is:open no:assignee label:"${requiredLabel}" label:"${LABELS.READY_FOR_DEV}"`; + const searchUrl = `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent(searchQuery)}`; + return [ + `👋 Hi @${requesterUsername}! Thanks for your interest in contributing!`, + "", + `This is a **${displayName}** issue. Before taking it on, you need to complete at least **${requiredCount} ${prerequisiteDisplayName}** to build familiarity with the codebase.`, + "", + `📊 **Your Progress:** You've completed **${completedCount}** so far.`, + "", + `👉 **Find ${prerequisiteDisplayName} to work on:**`, + `[Browse available ${prerequisiteDisplayName}](${searchUrl})`, + "", + `Once you've completed ${requiredCount}, come back and we'll be happy to assign this to you! 🎯`, + ].join("\n"); +} + +/** + * Builds the comment posted when the issue has no skill-level label. Tags the + * maintainer team to add one of the four skill levels, and instructs the + * requester to try /assign again once the label is added. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @returns {string} The formatted Markdown comment body. + */ +function buildNoSkillLevelComment(requesterUsername) { + return [ + `👋 Hi @${requesterUsername}! This issue doesn't have a skill level label yet.`, + "", + `${MAINTAINER_TEAM} — could you please add one of the following labels?`, + `- \`${LABELS.GOOD_FIRST_ISSUE}\``, + `- \`${LABELS.BEGINNER}\``, + `- \`${LABELS.INTERMEDIATE}\``, + `- \`${LABELS.ADVANCED}\``, + "", + `@${requesterUsername}, once a maintainer adds the label, comment \`/assign\` again to request assignment.`, + ].join("\n"); +} + +/** + * Builds the comment posted when the issue's skill level changes while the + * assignment request is queued. Tells the requester to retry so eligibility + * checks run against the latest label state. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string} previousSkillLevel - The skill level from the event payload. + * @param {string} currentSkillLevel - The skill level from the fresh issue snapshot. + * @returns {string} The formatted Markdown comment body. + */ +function buildSkillLevelChangedComment( + requesterUsername, + previousSkillLevel, + currentSkillLevel, +) { + return [ + `👋 Hi @${requesterUsername}! The skill level for this issue changed while your assignment request was being processed.`, + "", + `**Current label:** \`${currentSkillLevel}\``, + `**Previous label:** \`${previousSkillLevel}\``, + "", + "Please comment `/assign` again to request assignment with the updated skill requirements.", + ].join("\n"); +} + +/** + * Builds a GitHub Issues search URL for the given repository and query string. + * + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {string} searchQuery - Raw GitHub search query (will be URI-encoded). + * @returns {string} The full HTTPS search URL. + */ +function buildIssuesSearchUrl(owner, repo, searchQuery) { + return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent(searchQuery)}`; +} + +/** + * Internal formatter for the assignment-limit-exceeded comment. Accepts pre-built + * URLs so it can be tested independently of URL construction logic. Explains the + * limit, shows the user's current count, and links to their assigned and + * (optionally) blocked issues. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string|number} openCount - User-facing open-assignment count display (for example 2 or "3+"). + * @param {string} assignedIssuesUrl - URL to the user's open assigned issues search. + * @param {string|null} blockedIssuesUrl - URL to the user's blocked issues search, or null if none. + * @returns {string} The formatted Markdown comment body. + */ +function formatAssignmentLimitExceededComment( + requesterUsername, + openCount, + assignedIssuesUrl, + blockedIssuesUrl, +) { + const lines = [ + `👋 Hi @${requesterUsername}! Thanks for your enthusiasm to contribute!`, + "", + `To help contributors stay focused and ensure issues remain available for others, we limit assignments to **${MAX_OPEN_ASSIGNMENTS} open issues** at a time. Issues labeled \`${LABELS.BLOCKED}\` are not counted toward this limit.`, + "", + `📊 **Your Current Assignments:** You're currently assigned to **${openCount}** open issues.`, + "", + "👉 **View your assigned issues:**", + `[Your open assignments](${assignedIssuesUrl})`, + ]; + if (blockedIssuesUrl) { + lines.push( + "", + "👉 **View your blocked issues:**", + `[Your blocked issues](${blockedIssuesUrl})`, + ); + } + lines.push( + "", + `💡 **Tip:** If all of your open assigned issues have a linked PR with \`${LABELS.NEEDS_REVIEW}\`, the limit is automatically bypassed — you can request a new assignment right away.`, + "", + "Once you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯", + ); + return lines.join("\n"); +} + +/** + * Builds the comment posted when a contributor is at or above the maximum number + * of open assignments (MAX_OPEN_ASSIGNMENTS). Constructs search URLs for the + * user's assigned and blocked issues, then delegates to formatAssignmentLimitExceededComment. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string|number} openCount - User-facing open-assignment count display (for example 2 or "3+"). + * @param {string} owner - Repository owner (for search URLs). + * @param {string} repo - Repository name (for search URLs). + * @param {number} [blockedCount=0] - Number of blocked issues assigned to the user (shows link if > 0). + * @returns {string} The formatted Markdown comment body. + */ +function buildAssignmentLimitExceededComment( + requesterUsername, + openCount, + owner, + repo, + blockedCount = 0, +) { + const assignedQuery = `is:issue is:${ISSUE_STATE.OPEN} assignee:${requesterUsername} -label:"${LABELS.BLOCKED}"`; + const assignedIssuesUrl = buildIssuesSearchUrl(owner, repo, assignedQuery); + const blockedIssuesUrl = + blockedCount > 0 + ? buildIssuesSearchUrl( + owner, + repo, + `is:issue is:${ISSUE_STATE.OPEN} assignee:${requesterUsername} label:"${LABELS.BLOCKED}"`, + ) + : null; + return formatAssignmentLimitExceededComment( + requesterUsername, + openCount, + assignedIssuesUrl, + blockedIssuesUrl, + ); +} + +/** + * Builds the comment posted when a GitHub API call fails during eligibility + * verification (e.g. counting open assignments or checking prerequisites). + * Tags the maintainer team for manual assistance. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @returns {string} The formatted Markdown comment body. + */ +function buildApiErrorComment(requesterUsername) { + return [ + `👋 Hi @${requesterUsername}! I encountered an error while trying to verify your eligibility for this issue.`, + "", + `${MAINTAINER_TEAM} — could you please help with this assignment request?`, + "", + `@${requesterUsername}, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ].join("\n"); +} + +/** + * Builds the comment posted when the user was successfully assigned but the + * label transition ("ready for dev" -> "in progress") failed. Tags the maintainer + * team with explicit manual steps and includes the error details. + * + * @param {string} username - The GitHub username who was assigned. + * @param {string} error - The error message(s) from the failed label operations. + * @returns {string} The formatted Markdown comment body. + */ +function buildLabelUpdateFailureComment(username, error) { + return [ + `⚠️ @${username} has been successfully assigned to this issue, but I encountered an error updating the labels.`, + "", + `${MAINTAINER_TEAM} — please manually:`, + `- Remove the \`${LABELS.READY_FOR_DEV}\` label`, + `- Add the \`${LABELS.IN_PROGRESS}\` label`, + "", + `Error details: ${error}`, + ].join("\n"); +} + +/** + * Builds the comment posted when a contributor has already completed the maximum + * number of Good First Issues (MAX_GFI_COMPLETIONS). Rejects the assignment + * warmly and redirects them toward Beginner and higher-level issues. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string|number} completedCount - User-facing completed-count display (for example 5 or "5+"). + * @param {string} owner - Repository owner (for the search URL). + * @param {string} repo - Repository name (for the search URL). + * @returns {string} The formatted Markdown comment body. + */ +function buildGfiLimitExceededComment( + requesterUsername, + completedCount, + owner, + repo, +) { + const searchQuery = `is:issue is:open no:assignee label:"${LABELS.BEGINNER}" label:"${LABELS.READY_FOR_DEV}"`; + const searchUrl = buildIssuesSearchUrl(owner, repo, searchQuery); + return [ + `👋 Hi @${requesterUsername}! You've completed **${completedCount} Good First Issues** — that's a fantastic achievement, and it shows you know the workflow inside and out. 🎉`, + "", + "Good First Issues are designed to help new contributors get comfortable with the process, and you've clearly mastered it. We believe you're more than ready to take on bigger challenges!", + "", + "👉 **Find Beginner and higher issues to work on:**", + `[Browse available Beginner issues](${searchUrl})`, + "", + "Come take on something more challenging — we're excited to see what you'll build next! 🚀", + ].join("\n"); +} + +/** + * Builds the comment posted when the addAssignees API call itself fails. + * Tags the maintainer team to manually assign the user and includes the error details. + * + * @param {string} requesterUsername - The GitHub username who commented /assign. + * @param {string} error - The error message from the failed assignment call. + * @returns {string} The formatted Markdown comment body. + */ +function buildAssignmentFailureComment(requesterUsername, error) { + return [ + `⚠️ Hi @${requesterUsername}! I tried to assign you to this issue, but encountered an error.`, + "", + `${MAINTAINER_TEAM} — could you please manually assign @${requesterUsername} to this issue?`, + "", + `Error details: ${error}`, + ].join("\n"); +} + +module.exports = { + MAX_OPEN_ASSIGNMENTS, + MAX_GFI_COMPLETIONS, + SKILL_HIERARCHY, + SKILL_PREREQUISITES, + buildWelcomeComment, + buildAlreadyAssignedComment, + buildNotReadyComment, + buildPrerequisiteNotMetComment, + buildNoSkillLevelComment, + buildSkillLevelChangedComment, + buildAssignmentLimitExceededComment, + buildGfiLimitExceededComment, + buildApiErrorComment, + buildLabelUpdateFailureComment, + buildAssignmentFailureComment, +}; diff --git a/.github/scripts/commands/assign.js b/.github/scripts/commands/assign.js new file mode 100644 index 0000000..b9a1d73 --- /dev/null +++ b/.github/scripts/commands/assign.js @@ -0,0 +1,650 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/assign.js +// +// /assign command: assigns the commenter to the issue. Enforces skill-level +// prerequisites, assignment limits, and status labels. See bot-on-comment.js +// for high-level docs (limits, skill levels, required labels). + +const { + LABELS, + ISSUE_STATE, + createDelegatingLogger, + isNonNegativeInteger, + hasLabel, + swapLabels, + addAssignees, + postComment, + acknowledgeComment, + getHighestIssueSkillLevel, + countIssuesByAssignee, + listAssignedIssues, + hasNeedsReviewPR, +} = require('../helpers'); + +const { + MAX_OPEN_ASSIGNMENTS, + MAX_GFI_COMPLETIONS, + SKILL_HIERARCHY, + SKILL_PREREQUISITES, + buildWelcomeComment, + buildAlreadyAssignedComment, + buildNotReadyComment, + buildPrerequisiteNotMetComment, + buildNoSkillLevelComment, + buildSkillLevelChangedComment, + buildAssignmentLimitExceededComment, + buildGfiLimitExceededComment, + buildApiErrorComment, + buildLabelUpdateFailureComment, + buildAssignmentFailureComment, +} = require("./assign-comments"); + +// Delegate to the active logger set by the dispatcher (bot-on-comment.js). +// This ensures the correct prefix is used after command parsing. +const logger = createDelegatingLogger(); + +/** + * Formats a count for user-facing text when a threshold short-circuit may have + * capped the value. If count reaches the short-circuit threshold, return an + * "at least" style display (e.g. "3+") rather than implying an exact value. + * + * @param {number} count - Count returned by countIssuesByAssignee. + * @param {number} threshold - Threshold used for short-circuiting. + * @returns {string} User-facing display string. + */ +function formatThresholdedCount(count, threshold) { + return count >= threshold ? `${threshold}+` : String(count); +} + +/** + * Returns the number of open issues assigned to the user that carry the + * "status: blocked" label. Used to provide context in the assignment-limit-exceeded + * comment (blocked issues don't count toward the limit). + * + * @param {object} github - Octokit GitHub API client. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {string} username - GitHub username to search for. + * @returns {Promise} The blocked-issue count (defaults to 0 on any error). + */ +async function getBlockedCount(github, owner, repo, username) { + const count = await countIssuesByAssignee( + github, + owner, + repo, + username, + ISSUE_STATE.OPEN, + LABELS.BLOCKED, + 1, + ); + if (count !== null && isNonNegativeInteger(count)) return Math.floor(count); + return 0; +} + +/** + * Checks skill-level prerequisites for the requesting user. Looks up the + * required number of completed issues at the prerequisite level (e.g. a + * Beginner issue requires 2 completed Good First Issues). Posts a comment + * and returns false when the check fails or an API error occurs; returns + * true when prerequisites are satisfied (or not required for the level). + * + * @param {object} botContext - Bot context from buildBotContext (github, owner, repo, number). + * @param {string} skillLevel - The skill-level label on the issue (a LABELS constant). + * @param {string} requesterUsername - The GitHub username requesting assignment. + * @returns {Promise} True if prerequisites are met or none required; false otherwise. + */ +async function checkPrerequisites(botContext, skillLevel, requesterUsername) { + const prereq = SKILL_PREREQUISITES[skillLevel]; + if (!prereq.requiredLabel || prereq.requiredCount <= 0) return true; + + // Bypass: If the user already has worked on any level or higher then bypass them. + const skillIndex = SKILL_HIERARCHY.indexOf(skillLevel); + if (skillIndex !== -1) { + for (let i = skillIndex; i < SKILL_HIERARCHY.length; i++) { + const checkCurrLevel = SKILL_HIERARCHY[i]; + const countAtLevel = await countIssuesByAssignee( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ISSUE_STATE.CLOSED, + checkCurrLevel, + 1, + ); + + if (countAtLevel !== null && countAtLevel > 0) { + logger.log( + `Bypassing prerequisites: user has completed ${countAtLevel} issues with label "${checkCurrLevel}"`, + ); + return true; + } + } + } + + // Normal validation + const completedCount = await countIssuesByAssignee( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ISSUE_STATE.CLOSED, + prereq.requiredLabel, + prereq.requiredCount, + ); + if (completedCount === null) { + logger.log("Exit: could not verify prerequisites due to API error"); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + logger.log("Posted API error comment, tagged maintainers"); + return false; + } + if (completedCount < prereq.requiredCount) { + logger.log("Exit: prerequisites not met", { + required: prereq.requiredCount, + completed: completedCount, + }); + await postComment( + botContext, + buildPrerequisiteNotMetComment( + requesterUsername, + skillLevel, + completedCount, + botContext.owner, + botContext.repo, + ), + ); + logger.log("Posted prerequisite-not-met comment"); + return false; + } + logger.log("Prerequisites met:", { + required: prereq.requiredCount, + completed: completedCount, + }); + return true; +} + +/** + * Transitions issue status labels after a successful assignment: removes + * "status: ready for dev" and adds "status: in progress". If either label + * operation fails, posts a comment tagging maintainers with manual instructions. + * + * @param {object} botContext - Bot context from buildBotContext (github, owner, repo, number). + * @param {string} requesterUsername - The username being assigned (for the failure comment). + * @returns {Promise} + */ +async function updateLabels(botContext, requesterUsername) { + const { success, errorDetails } = await swapLabels( + botContext, + LABELS.READY_FOR_DEV, + LABELS.IN_PROGRESS, + ); + if (!success) { + await postComment( + botContext, + buildLabelUpdateFailureComment(requesterUsername, errorDetails), + ); + logger.log("Posted label update failure comment, tagged maintainers"); + } +} + +/** + * Checks whether the requester has reached the GFI completion cap. Only applies + * when skillLevel is LABELS.GOOD_FIRST_ISSUE. Posts an encouraging comment and + * returns false when the cap is reached; returns true otherwise. + * + * @param {object} botContext - Bot context from buildBotContext. + * @param {string} skillLevel - The skill-level label on the issue (a LABELS constant). + * @param {string} requesterUsername - GitHub username requesting assignment. + * @returns {Promise} True if under the cap or not a GFI; false otherwise. + */ +async function enforceGfiCompletionLimit( + botContext, + skillLevel, + requesterUsername, +) { + if (skillLevel !== LABELS.GOOD_FIRST_ISSUE) return true; + + const completedCount = await countIssuesByAssignee( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ISSUE_STATE.CLOSED, + LABELS.GOOD_FIRST_ISSUE, + MAX_GFI_COMPLETIONS + 1, + ); + if (completedCount === null) { + logger.log("Exit: could not verify GFI completion count due to API error"); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + logger.log("Posted API error comment, tagged maintainers"); + return false; + } + if (completedCount >= MAX_GFI_COMPLETIONS) { + logger.log("Exit: contributor has reached GFI completion cap", { + maxAllowed: MAX_GFI_COMPLETIONS, + completedCount, + }); + await postComment( + botContext, + buildGfiLimitExceededComment( + requesterUsername, + formatThresholdedCount(completedCount, MAX_GFI_COMPLETIONS + 1), + botContext.owner, + botContext.repo, + ), + ); + logger.log("Posted GFI-limit-exceeded comment"); + return false; + } + logger.log("GFI completion count OK:", { + maxAllowed: MAX_GFI_COMPLETIONS, + completedCount, + }); + return true; +} + +/** + * Validates that the issue is in a state that allows assignment. Checks three + * gates in order: no existing assignees, "status: ready for dev" label present, + * and a skill-level label present. Posts an informative comment and returns null + * when any gate fails. + * + * @param {object} botContext - Bot context from buildBotContext. + * @param {string} requesterUsername - GitHub username requesting assignment. + * @returns {Promise} The skill-level label, or null if a gate failed. + */ +async function validateIssueState(botContext, requesterUsername) { + if (botContext.issue.assignees?.length > 0) { + logger.log( + "Exit: issue already assigned to", + botContext.issue.assignees.map((a) => a.login), + ); + await postComment( + botContext, + buildAlreadyAssignedComment( + requesterUsername, + botContext.issue, + botContext.owner, + botContext.repo, + ), + ); + logger.log("Posted already-assigned comment"); + return null; + } + + if (!hasLabel(botContext.issue, LABELS.READY_FOR_DEV)) { + logger.log("Exit: issue missing ready for dev label"); + await postComment( + botContext, + buildNotReadyComment( + requesterUsername, + botContext.owner, + botContext.repo, + ), + ); + logger.log("Posted not-ready comment"); + return null; + } + + const skillLevel = getHighestIssueSkillLevel(botContext.issue); + if (!skillLevel) { + logger.log("Exit: issue has no skill level label"); + await postComment(botContext, buildNoSkillLevelComment(requesterUsername)); + logger.log("Posted no-skill-level comment"); + return null; + } + logger.log("Issue skill level:", skillLevel); + return skillLevel; +} + +/** + * Verifies the requester has not reached the open-assignment limit. Posts an + * API-error or limit-exceeded comment and returns false when the check fails; + * returns true when the user is within limits. + * + * @param {object} botContext - Bot context from buildBotContext. + * @param {string} requesterUsername - GitHub username requesting assignment. + * @returns {Promise} True if within assignment limits; false otherwise. + */ +async function enforceAssignmentLimit(botContext, requesterUsername) { + const openAssignmentCount = await countIssuesByAssignee( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ISSUE_STATE.OPEN, + null, + MAX_OPEN_ASSIGNMENTS + 1, + ); + if (openAssignmentCount === null) { + logger.log("Exit: could not verify open assignments due to API error"); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + logger.log("Posted API error comment, tagged maintainers"); + return false; + } + + if (openAssignmentCount >= MAX_OPEN_ASSIGNMENTS) { + logger.log("Contributor at or above assignment cap", { + maxAllowed: MAX_OPEN_ASSIGNMENTS, + currentCount: openAssignmentCount, + }); + + // Suppress limit failures caused by a duplicate /assign for this issue. + let freshIssue; + try { + const response = await botContext.github.rest.issues.get({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + }); + freshIssue = response.data; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error("Failed to fetch fresh issue state:", errorMsg); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + return false; + } + + const requesterAlreadyAssigned = freshIssue.assignees?.some( + (assignee) => + (assignee.login || "").toLowerCase() === + requesterUsername.toLowerCase(), + ); + + if (requesterAlreadyAssigned) { + logger.log("Exit: user is already assigned (caught during limit check)"); + await postComment( + botContext, + buildAlreadyAssignedComment( + requesterUsername, + freshIssue, + botContext.owner, + botContext.repo, + ), + ); + return false; + } + + // --- Needs-review bypass check --- + // If every open (non-blocked) assigned issue has an open PR authored by + // the contributor with "status: needs review", allow the bypass. + const assignedIssues = await listAssignedIssues( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ); + + if (assignedIssues === null) { + logger.log("Exit: could not fetch assigned issues due to API error"); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + logger.log("Posted API error comment, tagged maintainers"); + return false; + } + + // Guard against vacuous truth: [].every(fn) === true in JavaScript. + // An empty array can occur from eventual consistency or race conditions; + // it must NOT silently trigger the bypass. + if (assignedIssues.length > 0) { + let allHaveNeedsReviewPR = true; + for (const issue of assignedIssues) { + const result = await hasNeedsReviewPR( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + issue.number, + ); + if (result === null) { + logger.log( + `Exit: API error checking needs-review PR for issue #${issue.number}`, + ); + await postComment( + botContext, + buildApiErrorComment(requesterUsername), + ); + logger.log("Posted API error comment, tagged maintainers"); + return false; + } + if (!result) { + allHaveNeedsReviewPR = false; + break; + } + } + + if (allHaveNeedsReviewPR) { + logger.log( + "Bypass: all assigned issues have linked needs-review PRs", + { issueCount: assignedIssues.length }, + ); + return true; + } + } else { + logger.log( + "Bypass skipped: listAssignedIssues returned 0 issues despite count >= limit", + ); + } + + // Fall through to the existing limit-exceeded path + logger.log("Exit: contributor has too many open assignments", { + maxAllowed: MAX_OPEN_ASSIGNMENTS, + currentCount: openAssignmentCount, + }); + const blockedCount = await getBlockedCount( + botContext.github, + botContext.owner, + botContext.repo, + requesterUsername, + ); + await postComment( + botContext, + buildAssignmentLimitExceededComment( + requesterUsername, + formatThresholdedCount(openAssignmentCount, MAX_OPEN_ASSIGNMENTS + 1), + botContext.owner, + botContext.repo, + blockedCount, + ), + ); + logger.log("Posted assignment-limit-exceeded comment"); + return false; + } + + logger.log("Open assignment count OK:", { + maxAllowed: MAX_OPEN_ASSIGNMENTS, + currentCount: openAssignmentCount, + }); + return true; +} + +/** + * Performs the actual issue assignment with a same-issue race defense: + * + * 1. Pre-flight check: fetches fresh issue state via issues.get() to detect + * same-issue collisions (another user assigned while this run was queued; + * the workflow concurrency key is serialized per issue). + * + * On success, posts a welcome comment and transitions status labels. + * Posts failure comments if any API call fails. + * + * @param {object} botContext - Bot context from buildBotContext. + * @param {string} requesterUsername - GitHub username being assigned. + * @param {string} skillLevel - The skill-level label on the issue. + * @returns {Promise} + */ +async function assignAndFinalize(botContext, requesterUsername, skillLevel) { + logger.log("Assigning issue to", requesterUsername); + + // Pre-flight same-issue collision check: fetch live issue state BEFORE writing. + // The per-issue concurrency key serializes runs on the same issue, so by the + // time this run executes, another user may already be assigned. + let freshIssue; + try { + const response = await botContext.github.rest.issues.get({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + }); + freshIssue = response.data; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error("Failed to pre-fetch fresh issue state:", errorMsg); + await postComment(botContext, buildApiErrorComment(requesterUsername)); + return; + } + + const alreadyAssigned = freshIssue.assignees?.some( + (a) => a.login === requesterUsername, + ); + + if (alreadyAssigned) { + logger.log("Exit: user is already assigned (caught during fresh fetch)"); + await postComment( + botContext, + buildAlreadyAssignedComment( + requesterUsername, + freshIssue, + botContext.owner, + botContext.repo, + ), + ); + return; + } + + if (freshIssue.assignees?.length > 0) { + logger.log("Exit: issue was assigned by another user while queued"); + await postComment( + botContext, + buildAlreadyAssignedComment( + requesterUsername, + freshIssue, + botContext.owner, + botContext.repo, + ), + ); + return; + } + + // Revalidate labels from the fresh issue snapshot to prevent stale-event + // payload eligibility checks when labels change while this run is queued. + if (!hasLabel(freshIssue, LABELS.READY_FOR_DEV)) { + logger.log("Exit: fresh issue state is missing ready for dev label"); + await postComment( + botContext, + buildNotReadyComment( + requesterUsername, + botContext.owner, + botContext.repo, + ), + ); + return; + } + + const freshSkillLevel = getHighestIssueSkillLevel(freshIssue); + if (!freshSkillLevel) { + logger.log("Exit: fresh issue state has no skill level label"); + await postComment(botContext, buildNoSkillLevelComment(requesterUsername)); + return; + } + + // If the skill level changed while this run was queued, the earlier prerequisite + // and GFI-cap gates were run against a stale label. Abort and ask user to retry. + if (freshSkillLevel !== skillLevel) { + logger.log("Exit: skill level changed while queued", { + stalePayloadLevel: skillLevel, + freshReadLevel: freshSkillLevel, + }); + await postComment( + botContext, + buildSkillLevelChangedComment( + requesterUsername, + skillLevel, + freshSkillLevel, + ), + ); + logger.log("Posted skill-level-changed comment"); + return; + } + + const assignResult = await addAssignees(botContext, [requesterUsername]); + if (!assignResult.success) { + await postComment( + botContext, + buildAssignmentFailureComment(requesterUsername, assignResult.error), + ); + logger.log("Posted assignment failure comment, tagged maintainers"); + return; + } + + await postComment( + botContext, + buildWelcomeComment(requesterUsername, freshSkillLevel), + ); + logger.log("Posted welcome comment"); + await updateLabels(botContext, requesterUsername); + logger.log("Assignment flow completed successfully"); +} + +/** + * Main handler for the /assign command. Runs the following gates in order, posting + * an informative comment and returning early if any gate fails: + * + * 1. Acknowledge the comment with a thumbs-up reaction. + * 2. Issue already assigned? -> already-assigned comment. + * 3. Missing "status: ready for dev" label? -> not-ready comment. + * 4. No skill-level label? -> no-skill-level comment (tags maintainers). + * 5. Open-assignment count API error? -> API-error comment (tags maintainers). + * 6. At or above MAX_OPEN_ASSIGNMENTS? -> limit-exceeded comment. + * 7. GFI cap reached (skill: good first issue only)? -> GFI-limit-exceeded comment. + * 8. Skill prerequisites not met? -> prerequisite-not-met comment. + * 9. Issue snatched while queued (fresh fetch)? -> already-assigned comment. + * 10. Fresh labels invalid while queued? -> not-ready/no-skill-level comment. + * 11. Skill level changed while queued? -> skill-level-changed comment (ask to retry). + * 12. Assignment API failure? -> assignment-failure comment (tags maintainers). + * + * If all checks pass, the bot assigns the issue, posts a welcome comment, + * and swaps the "status: ready for dev" label with "status: in progress". + * + * @param {{ github: object, owner: string, repo: string, number: number, + * issue: object, comment: { id: number, user: { login: string } } }} botContext + * Bot context from buildBotContext (issue_comment event). + * @returns {Promise} + */ +async function handleAssign(botContext) { + const requesterUsername = botContext.comment.user.login; + + const ack = await acknowledgeComment(botContext, botContext.comment.id); + if (!ack.success) { + logger.log( + "Aborting /assign: triggering comment was deleted or could not be acknowledged", + ); + return; + } + + const skillLevel = await validateIssueState(botContext, requesterUsername); + if (!skillLevel) return; + + const withinLimit = await enforceAssignmentLimit( + botContext, + requesterUsername, + ); + if (!withinLimit) return; + + const withinGfiCap = await enforceGfiCompletionLimit( + botContext, + skillLevel, + requesterUsername, + ); + if (!withinGfiCap) return; + + const prereqsPassed = await checkPrerequisites( + botContext, + skillLevel, + requesterUsername, + ); + if (!prereqsPassed) return; + + await assignAndFinalize(botContext, requesterUsername, skillLevel); +} + +module.exports = { handleAssign }; diff --git a/.github/scripts/commands/finalize-comments.js b/.github/scripts/commands/finalize-comments.js new file mode 100644 index 0000000..f74ece0 --- /dev/null +++ b/.github/scripts/commands/finalize-comments.js @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/finalize-comments.js +// +// Comment builders and per-skill-level boilerplate for the /finalize command. +// Pure formatting functions separated from finalize logic for readability and testability. + +const { MAINTAINER_TEAM, LABELS, DOCUMENTATION, COMMUNITY } = require('../helpers'); + +// ============================================================================= +// TITLE PREFIX MAP +// ============================================================================= + +/** + * Maps skill-level label constants to their title prefix strings. + * Used by /finalize to update the issue title. + * @type {Object} + */ +const SKILL_TITLE_PREFIXES = { + [LABELS.GOOD_FIRST_ISSUE]: '[Good First Issue]: ', + [LABELS.BEGINNER]: '[Beginner]: ', + [LABELS.INTERMEDIATE]: '[Intermediate]: ', + [LABELS.ADVANCED]: '[Advanced]: ', +}; + +// ============================================================================= +// SKILL-LEVEL BOILERPLATE BLOCKS +// ============================================================================= +// Each entry mirrors the intro textarea and [!IMPORTANT] markdown block from +// the original skill-level issue YAML templates. Used by /finalize to prepend +// the appropriate skill context to the issue body. + +/** + * Per-skill-level boilerplate for body reconstruction. + * - introLabel: the H3 header label for the intro section + * - introContent: the body of the intro textarea + * - importantBlock: the [!IMPORTANT] GitHub callout markdown + * + * @type {Object} + */ +const SKILL_BOILERPLATE = { + [LABELS.GOOD_FIRST_ISSUE]: { + introLabel: '🆕🐥 First-Time Friendly', + introContent: [ + 'This issue is especially welcoming for people who are new to contributing to the **KDM CLI**.', + '', + 'We know that opening your first pull request can feel like a big step. Issues labeled **Good First Issue** are designed to make that experience easier, clearer, and more comfortable.', + '', + 'No prior knowledge of KDM, Hedera, or distributed ledger technology is required - just a basic familiarity with C++ and Git is more than enough to get started.', + ].join('\n'), + importantBlock: [ + '> [!IMPORTANT]', + '> ### 📋 About Good First Issues', + '>', + '> Good First Issues are designed to make getting started as smooth and stress-free as possible.', + '>', + '> They usually focus on:', + '> - Small, clearly scoped changes ', + '> - Straightforward updates to existing code or docs ', + '> - Simple refactors or clarity improvements ', + '>', + '> Other kinds of contributions — like larger features, deeper technical changes, or design-focused work — are just as valuable and often use the beginner, intermediate, or advanced labels.', + ].join('\n'), + }, + + [LABELS.BEGINNER]: { + introLabel: '🐥 Beginner Friendly', + introContent: [ + 'This issue is a great fit for contributors who are ready to explore the KDM C++ codebase a little more and take on slightly more independent work.', + '', + 'Beginner Issues often involve reading existing C++ code, understanding how different parts of the SDK fit together, and making small, thoughtful updates that follow established patterns.', + '', + 'The goal is to support skill growth while keeping the experience approachable, well-scoped, and enjoyable.', + ].join('\n'), + importantBlock: [ + '> [!IMPORTANT]', + '> ### 🐥 About Beginner Issues', + '>', + '> Beginner Issues are a great next step for contributors who feel comfortable with the basic project workflow and want to explore the codebase a little more.', + '>', + '> These issues often involve:', + '> - Reading existing C++ code ', + '> - Understanding how different parts of the SDK fit together ', + '> - Making small, thoughtful updates that follow established patterns ', + '>', + '> You\'ll usually see Beginner Issues focused on things like:', + '> - Small, well-scoped improvements to existing tests ', + '> - Narrow updates to `src` functionality (e.g. refining helpers or improving readability) ', + '> - Documentation or comment clarity ', + '> - Enhancements to existing examples ', + '>', + '> Other types of contributions — such as brand-new features, broader system changes, or deeper technical work — are just as valuable and may use different labels.', + ].join('\n'), + }, + + [LABELS.INTERMEDIATE]: { + introLabel: '🧩 Intermediate Friendly', + introContent: [ + 'This issue is a good fit for contributors who are already familiar with the KDM CLI and feel comfortable navigating the codebase.', + '', + 'Intermediate Issues often involve:', + '- Exploring existing implementations ', + '- Understanding how different components work together ', + '- Making thoughtful changes that follow established patterns ', + '', + 'The goal is to support deeper problem-solving while keeping the task clear, focused, and enjoyable to work on.', + ].join('\n'), + importantBlock: [ + '> [!IMPORTANT]', + '> ### 🧭 About Intermediate Issues', + '>', + '> Intermediate Issues are a great next step for contributors who enjoy digging into the codebase and reasoning about how things work.', + '>', + '> These issues often:', + '> - Involve multiple related files or components ', + '> - Encourage investigation and understanding of existing behavior ', + '> - Leave room for thoughtful implementation choices ', + '> - Stay focused on a clearly defined goal ', + '>', + '> Other kinds of contributions — from beginner-friendly tasks to large system-level changes — are just as valuable and use different labels.', + ].join('\n'), + }, + + [LABELS.ADVANCED]: { + introLabel: '🧠 Advanced', + introContent: [ + 'This issue is well-suited for contributors who are very familiar with the KDM CLI and enjoy working with its core abstractions and design patterns.', + '', + 'Advanced Issues often involve:', + '- Exploring and shaping SDK architecture ', + '- Reasoning about trade-offs and long-term impact ', + '- Working across multiple modules or systems ', + '- Updating tests, examples, and documentation alongside code ', + '', + 'The goal is to support thoughtful, high-impact contributions in a clear and collaborative way.', + ].join('\n'), + importantBlock: [ + '> [!IMPORTANT]', + '> ### 🧭 About Advanced Issues', + '>', + '> Advanced Issues usually focus on larger changes that influence how the SDK works as a whole.', + '>', + '> These issues often:', + '> - Touch core abstractions or shared utilities ', + '> - Span multiple parts of the codebase ', + '> - Involve design decisions and trade-offs ', + '> - Consider long-term maintainability and compatibility ', + '>', + '> Smaller fixes, focused refactors, and onboarding-friendly tasks are just as valuable and often use different labels.', + ].join('\n'), + }, +}; + +// ============================================================================= +// CONTRIBUTION GUIDE BOILERPLATE +// ============================================================================= + +/** + * Standard contribution guide section appended to all finalized issue bodies. + * Mirrors the "Step-by-Step Contribution Guide" textarea from the skill-level templates. + */ +const CONTRIBUTION_GUIDE_LABEL = '📋 Step-by-Step Contribution Guide'; + +const CONTRIBUTION_GUIDE_CONTENT = [ + 'To help keep contributions consistent and easy to review, we recommend following these steps:', + '', + '- [ ] Comment `/assign` to request the issue', + '- [ ] Wait for assignment', + '- [ ] Fork the repository and create a branch', + '- [ ] Set up the project using the instructions in `README.md`', + '- [ ] Make the requested changes', + '- [ ] Sign each commit using `-s -S`', + '- [ ] Push your branch and open a pull request', + '', + `Read [Workflow Guide](${DOCUMENTATION.workflowGuide}) for step-by-step workflow guidance.`, + `Read [README.md](${DOCUMENTATION.readme}) for setup instructions.`, + '', + '❗ Pull requests **cannot be merged** without `S` and `s` signed commits.', + `See the [Signing Guide](${DOCUMENTATION.signingGuide}).`, +].join('\n'); + +// ============================================================================= +// DEFAULT ADDITIONAL INFORMATION CONTENT +// ============================================================================= + +/** + * Default additional information content used when the submitter left the field + * empty or with the default placeholder ("Optional."). + */ +const DEFAULT_ADDITIONAL_INFO_LABEL = '🤔 Additional Information'; + +const DEFAULT_ADDITIONAL_INFO_CONTENT = [ + 'If you have questions while working on this issue, feel free to ask!', + '', + `You can reach the community and maintainers here: [KDM-CLI Discord](${COMMUNITY.discordChannel})`, + '', + 'Whether you need help finding the right file, understanding existing code, or confirming your approach — we\'re happy to help.', +].join('\n'); + +// ============================================================================= +// BODY PARSING & RECONSTRUCTION +// ============================================================================= + +/** + * Parses an issue body into sections by splitting on H3 (###) markdown headers. + * GitHub form templates render each field's `label` as a `### ` header in the + * submitted issue body, making this a reliable split point. + * + * @param {string} body - The raw issue body string. + * @returns {Array<{ header: string|null, content: string }>} Ordered section list. + * The first entry may have `header: null` if content appears before the first header. + */ +function parseSections(body) { + if (!body || typeof body !== 'string') return []; + + const lines = body.split('\n'); + const sections = []; + let currentHeader = null; + let currentLines = []; + + function flush() { + if (currentHeader !== null || currentLines.some((l) => l.trim())) { + sections.push({ header: currentHeader, content: currentLines.join('\n').trim() }); + } + } + + for (const line of lines) { + const match = line.match(/^### (.+)$/); + if (match) { + flush(); + currentHeader = match[1].trim(); + currentLines = []; + } else { + currentLines.push(line); + } + } + + flush(); + return sections; +} + +/** + * Determines whether the content of a section is non-trivial (i.e., the user + * actually filled it in rather than leaving the default placeholder). + * + * @param {string|undefined} content - The section content string. + * @returns {boolean} True if the content is meaningful. + */ +function isMeaningfulContent(content) { + if (!content || typeof content !== 'string') return false; + const trimmed = content.trim(); + return trimmed.length > 0 && trimmed !== 'Optional.' && trimmed !== '_No response_'; +} + +/** + * Reconstructs the issue body in the skill-level template format. The existing + * body is parsed into sections; skill-level boilerplate is prepended; the + * Contribution Guide boilerplate is appended; Additional Information is moved + * to the very end. + * + * @param {string} existingBody - The current issue body (from the submitted template). + * @param {string} skillLevel - A LABELS skill-level constant (e.g. LABELS.BEGINNER). + * @returns {string} The fully reconstructed issue body. + */ +function reconstructBody(existingBody, skillLevel) { + const boilerplate = SKILL_BOILERPLATE[skillLevel]; + const sections = parseSections(existingBody); + + // Extract "Additional Information" section so it can be placed last + const additionalInfoIdx = sections.findIndex( + (s) => s.header && s.header.toLowerCase().replace(/[^\w\s]/g, '').trim().includes('additional information') + ); + let additionalInfoContent = null; + if (additionalInfoIdx !== -1) { + const [aiSection] = sections.splice(additionalInfoIdx, 1); + if (isMeaningfulContent(aiSection.content)) { + additionalInfoContent = aiSection.content; + } + } + + const parts = []; + + // 1. Skill-level intro block + parts.push(`### ${boilerplate.introLabel}`); + parts.push(''); + parts.push(boilerplate.introContent); + parts.push(''); + parts.push(boilerplate.importantBlock); + parts.push(''); + + // 2. User's sections in original order (skip null-header leading content) + for (const section of sections) { + if (!section.header) continue; + parts.push(`### ${section.header}`); + if (section.content) { + parts.push(''); + parts.push(section.content); + } + parts.push(''); + } + + // 3. Contribution guide boilerplate + parts.push('---'); + parts.push(''); + parts.push(`### ${CONTRIBUTION_GUIDE_LABEL}`); + parts.push(''); + parts.push(CONTRIBUTION_GUIDE_CONTENT); + parts.push(''); + + // 4. Additional information (user-provided or default) + parts.push(`### ${DEFAULT_ADDITIONAL_INFO_LABEL}`); + parts.push(''); + parts.push(additionalInfoContent !== null ? additionalInfoContent : DEFAULT_ADDITIONAL_INFO_CONTENT); + + return parts.join('\n').trimEnd(); +} + +// ============================================================================= +// COMMENT BUILDERS +// ============================================================================= + +/** + * Builds the comment posted when the commenter does not have triage-or-above + * permissions. Lists the required permission level. + * + * @param {string} username - The GitHub username who commented /finalize. + * @returns {string} The formatted Markdown comment body. + */ +function buildUnauthorizedComment(username) { + return [ + `👋 Hi @${username}! The \`/finalize\` command is reserved for maintainers and contributors with **triage** (or higher) repository permissions.`, + '', + 'If you believe you should have access, please reach out to a maintainer.', + ].join('\n'); +} + +/** + * Builds the comment posted when one or more label validation rules are violated. + * Lists every violation found so the reviewer can fix them all in one pass. + * + * @param {string} username - The GitHub username who commented /finalize. + * @param {string[]} errors - Array of human-readable error strings (one per violation). + * @returns {string} The formatted Markdown comment body. + */ +function buildValidationErrorComment(username, errors) { + const errorList = errors.map((e) => `- ${e}`).join('\n'); + return [ + `👋 Hi @${username}! The issue isn't quite ready to finalize yet. Please fix the following labeling issue(s) and then comment \`/finalize\` again:`, + '', + errorList, + '', + 'If you have questions about which labels to apply, see the maintainer documentation or ask in the team channel.', + ].join('\n'); +} + +/** + * Builds the comment posted when the GitHub API call to update the issue + * (title or body) fails. Tags the maintainer team for manual intervention. + * + * @param {string} username - The GitHub username who commented /finalize. + * @param {string} error - The error message from the failed API call. + * @returns {string} The formatted Markdown comment body. + */ +function buildUpdateFailureComment(username, error) { + return [ + `⚠️ Hi @${username}! I encountered an error while trying to update the issue title or body.`, + '', + `${MAINTAINER_TEAM} — could you please complete the finalization manually?`, + '', + `Error details: ${error}`, + ].join('\n'); +} + +/** + * Builds the comment posted when the label swap after a successful update + * fails. Tags the maintainer team with explicit manual steps. + * + * @param {string} username - The GitHub username who commented /finalize. + * @param {string} error - The error message(s) from the failed label operations. + * @returns {string} The formatted Markdown comment body. + */ +function buildLabelSwapFailureComment(username, error) { + return [ + `⚠️ The issue was updated successfully, but I encountered an error swapping the status labels.`, + '', + `${MAINTAINER_TEAM} — please manually:`, + `- Remove the \`${LABELS.AWAITING_TRIAGE}\` label`, + `- Add the \`${LABELS.READY_FOR_DEV}\` label`, + '', + `Error details: ${error}`, + ].join('\n'); +} + +/** + * Builds the comment posted when the permission check API call itself fails. + * Tags the maintainer team for manual assistance. + * + * @param {string} username - The GitHub username who commented /finalize. + * @returns {string} The formatted Markdown comment body. + */ +function buildPermissionCheckErrorComment(username) { + return [ + `👋 Hi @${username}! I encountered an error while trying to verify your permissions.`, + '', + `${MAINTAINER_TEAM} — could you please verify @${username}'s permissions and complete the finalization manually if appropriate?`, + '', + 'Sorry for the inconvenience!', + ].join('\n'); +} + +/** + * Builds the success comment posted after a successful /finalize run. + * + * @param {string} username - The GitHub username who ran /finalize. + * @param {string} skillLevel - The skill-level label that was applied (a LABELS constant). + * @param {string} priorityLabel - The priority label on the issue (e.g. 'priority: medium'). + * @returns {string} The formatted Markdown comment body. + */ +function buildSuccessComment(username, skillLevel, priorityLabel) { + const prefix = SKILL_TITLE_PREFIXES[skillLevel] || ''; + const displayLevel = prefix.replace(/[\][:]/g, '').trim(); + return [ + `✅ Issue finalized by @${username}!`, + '', + `**Skill level:** \`${displayLevel}\``, + `**Priority:** \`${priorityLabel}\``, + '', + 'The issue body has been updated with the appropriate skill-level context and contribution guide. This issue is now ready for contributors to pick up via `/assign`.', + ].join('\n'); +} + +module.exports = { + SKILL_TITLE_PREFIXES, + CONTRIBUTION_GUIDE_LABEL, + CONTRIBUTION_GUIDE_CONTENT, + DEFAULT_ADDITIONAL_INFO_LABEL, + DEFAULT_ADDITIONAL_INFO_CONTENT, + parseSections, + isMeaningfulContent, + reconstructBody, + buildUnauthorizedComment, + buildValidationErrorComment, + buildUpdateFailureComment, + buildLabelSwapFailureComment, + buildPermissionCheckErrorComment, + buildSuccessComment, +}; diff --git a/.github/scripts/commands/finalize.js b/.github/scripts/commands/finalize.js new file mode 100644 index 0000000..c5003bb --- /dev/null +++ b/.github/scripts/commands/finalize.js @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/finalize.js +// +// /finalize command: transitions a triaged issue from "awaiting triage" to +// "ready for dev". Validates all required labels, updates the issue title with +// the appropriate skill-level prefix, reconstructs the body in the skill-level +// template format, and swaps status labels. +// +// Only contributors with triage-or-above repository permissions may run this command. + +const { + LABELS, + createDelegatingLogger, + hasLabel, + getLabelsByPrefix, + swapLabels, + postComment, + acknowledgeComment, +} = require('../helpers'); + +const { + SKILL_TITLE_PREFIXES, + reconstructBody, + buildUnauthorizedComment, + buildValidationErrorComment, + buildUpdateFailureComment, + buildLabelSwapFailureComment, + buildPermissionCheckErrorComment, + buildSuccessComment, +} = require('./finalize-comments'); + +// Delegate to the active logger set by the dispatcher (bot-on-comment.js). +const logger = createDelegatingLogger(); + +// Permission levels that are allowed to run /finalize (triage and above). +const ALLOWED_ROLE_NAMES = new Set(['triage', 'write', 'maintain', 'admin']); + +// Regex that strips any existing skill-level title prefix (e.g. "[Beginner]: "). +// Built from SKILL_TITLE_PREFIXES so it stays in sync if new levels are added. +const EXISTING_PREFIX_RE = new RegExp( + `^\\[(${Object.values(SKILL_TITLE_PREFIXES) + .map((p) => p.match(/^\[(.+)\]:/)?.[1]) + .filter(Boolean) + .join('|')})\\]:\\s*`, + 'i' +); + +// Recognized GitHub issue type names set by our three templates. +const KNOWN_ISSUE_TYPES = new Set(['Bug', 'Feature', 'Task']); + +// ============================================================================= +// PERMISSION CHECK +// ============================================================================= + +/** + * Checks whether the commenter has triage-or-above repository permissions. + * Uses the GitHub REST API `getCollaboratorPermissionLevel` endpoint, which + * returns a `role_name` of 'read' | 'triage' | 'write' | 'maintain' | 'admin'. + * Non-collaborators (HTTP 404) are treated as unauthorized. + * + * @param {object} botContext - Bot context from buildBotContext. + * @param {string} username - GitHub username to check. + * @returns {Promise<'authorized'|'unauthorized'|'error'>} + */ +async function checkPermission(botContext, username) { + try { + const { data } = await botContext.github.rest.repos.getCollaboratorPermissionLevel({ + owner: botContext.owner, + repo: botContext.repo, + username, + }); + const roleName = data?.role_name; + logger.log(`Permission check for @${username}: role_name="${roleName}"`); + if (ALLOWED_ROLE_NAMES.has(roleName)) return 'authorized'; + return 'unauthorized'; + } catch (error) { + const status = error?.status ?? error?.response?.status; + if (status === 404) { + logger.log(`@${username} is not a collaborator (404) — unauthorized`); + return 'unauthorized'; + } + logger.error(`Permission check failed for @${username}:`, error.message); + return 'error'; + } +} + +// ============================================================================= +// LABEL VALIDATION +// ============================================================================= + +/** + * Returns the GitHub issue type name ('Bug', 'Feature', 'Task') from the issue + * payload, or null if not set or unrecognized. + * + * @param {object} issue - The issue object from the GitHub payload. + * @returns {string|null} + */ +function getIssueTypeName(issue) { + const name = issue?.type?.name; + if (typeof name === 'string' && KNOWN_ISSUE_TYPES.has(name)) return name; + return null; +} + +/** + * Formats an array of label names as a comma-separated inline code list. + * @param {string[]} labels + * @returns {string} e.g. "`skill: beginner`, `skill: intermediate`" + */ +function formatLabelList(labels) { + return labels.map((l) => `\`${l}\``).join(', '); +} + +/** + * Collects all label validation violations for /finalize. Returns an empty + * array when everything is valid. + * + * Rules: + * - status: awaiting triage must be present + * - exactly 1 skill: label + * - exactly 1 priority: label + * - issue type must be Bug, Feature, or Task + * + * @param {object} issue - The issue object from the GitHub payload. + * @returns {string[]} Array of human-readable error strings (one per violation). + */ +function collectLabelViolations(issue) { + const errors = []; + + const skillLabels = getLabelsByPrefix(issue, 'skill:'); + const priorityLabels = getLabelsByPrefix(issue, 'priority:'); + + const issueTypeName = getIssueTypeName(issue); + + // 1. status: awaiting triage must be present + if (!hasLabel(issue, LABELS.AWAITING_TRIAGE)) { + const statusLabels = getLabelsByPrefix(issue, 'status:'); + const currentStatus = statusLabels.length > 0 ? formatLabelList(statusLabels) : 'none'; + errors.push( + `The \`${LABELS.AWAITING_TRIAGE}\` label must be present to run \`/finalize\`. Current status label(s): ${currentStatus}.` + ); + } + + // 2. Exactly 1 skill: label + if (skillLabels.length === 0) { + errors.push( + `Exactly one \`skill:\` label is required (e.g. \`skill: beginner\`). None found. Choose from: \`${LABELS.GOOD_FIRST_ISSUE}\`, \`${LABELS.BEGINNER}\`, \`${LABELS.INTERMEDIATE}\`, \`${LABELS.ADVANCED}\`.` + ); + } else if (skillLabels.length > 1) { + errors.push( + `Exactly one \`skill:\` label is required. Found ${skillLabels.length}: ${formatLabelList(skillLabels)}. Please remove all but one.` + ); + } + + // 3. Exactly 1 priority: label + if (priorityLabels.length === 0) { + errors.push( + `Exactly one \`priority:\` label is required (e.g. \`priority: medium\`). None found.` + ); + } else if (priorityLabels.length > 1) { + errors.push( + `Exactly one \`priority:\` label is required. Found ${priorityLabels.length}: ${formatLabelList(priorityLabels)}. Please remove all but one.` + ); + } + + // 4. Issue type must be recognized + if (!issueTypeName) { + errors.push( + `The issue type (Bug, Feature, or Task) could not be determined. Ensure the issue was submitted using one of the official issue templates.` + ); + } + + return errors; +} + +// ============================================================================= +// TITLE UPDATE +// ============================================================================= + +/** + * Builds the new issue title by stripping any existing skill-level prefix and + * prepending the correct one for the given skill label. + * + * @param {string} currentTitle - The current issue title. + * @param {string} skillLevel - A LABELS skill-level constant. + * @returns {string} The updated title. + */ +function buildNewTitle(currentTitle, skillLevel) { + const strippedTitle = currentTitle.replace(EXISTING_PREFIX_RE, '').trim(); + const prefix = SKILL_TITLE_PREFIXES[skillLevel] || ''; + return `${prefix}${strippedTitle}`; +} + +// ============================================================================= +// MAIN HANDLER +// ============================================================================= + +/** + * Main handler for the /finalize command. Runs the following steps in order, + * posting an informative comment and returning early if any step fails: + * + * 1. Acknowledge the comment with a thumbs-up reaction. + * 2. Check commenter has triage+ permissions → unauthorized / API error comment. + * 3. Collect all label violations → validation error comment listing all issues. + * 4. Determine skill level and build updated title + body. + * 5. Update the issue via the GitHub API → update failure comment on error. + * 6. Swap status labels: awaiting triage → ready for dev. + * 7. Post success comment. + * + * @param {{ github: object, owner: string, repo: string, number: number, + * issue: object, comment: { id: number, user: { login: string } } }} botContext + * Bot context from buildBotContext (issue_comment event). + * @returns {Promise} + */ +async function handleFinalize(botContext) { + const finalizerUsername = botContext.comment.user.login; + + // STEP 1: Acknowledge + await acknowledgeComment(botContext, botContext.comment.id); + + // STEP 2: Permission check + const permResult = await checkPermission(botContext, finalizerUsername); + if (permResult === 'error') { + logger.log('Exit: permission check API error'); + await postComment(botContext, buildPermissionCheckErrorComment(finalizerUsername)); + return; + } + if (permResult === 'unauthorized') { + logger.log(`Exit: @${finalizerUsername} is not authorized`); + await postComment(botContext, buildUnauthorizedComment(finalizerUsername)); + return; + } + + // STEP 3: Label validation — collect ALL violations before posting + const violations = collectLabelViolations(botContext.issue); + if (violations.length > 0) { + logger.log(`Exit: ${violations.length} label violation(s) found`); + await postComment(botContext, buildValidationErrorComment(finalizerUsername, violations)); + return; + } + + // STEP 4: Determine skill level + build new title and body + const skillLabels = getLabelsByPrefix(botContext.issue, 'skill:'); + const skillLevel = skillLabels[0]; // validated above: exactly 1 exists + + const newTitle = buildNewTitle(botContext.issue.title, skillLevel); + const newBody = reconstructBody(botContext.issue.body || '', skillLevel); + + logger.log(`Updating issue #${botContext.number}: title="${newTitle}", skillLevel="${skillLevel}"`); + + // STEP 5: Update issue title and body + let updateError = null; + try { + await botContext.github.rest.issues.update({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + title: newTitle, + body: newBody, + }); + logger.log('Issue updated successfully'); + } catch (error) { + updateError = error instanceof Error ? error.message : String(error); + logger.error('Issue update failed:', updateError); + } + + if (updateError) { + await postComment(botContext, buildUpdateFailureComment(finalizerUsername, updateError)); + return; + } + + // STEP 6: Swap status labels: awaiting triage → ready for dev + const swapResult = await swapLabels(botContext, LABELS.AWAITING_TRIAGE, LABELS.READY_FOR_DEV); + if (!swapResult.success) { + await postComment(botContext, buildLabelSwapFailureComment(finalizerUsername, swapResult.errorDetails)); + logger.log('Posted label swap failure comment, tagged maintainers'); + } + + // STEP 7: Post success comment + const priorityLabel = getLabelsByPrefix(botContext.issue, 'priority:')[0]; + await postComment(botContext, buildSuccessComment(finalizerUsername, skillLevel, priorityLabel)); + logger.log('Finalize flow completed successfully'); +} + +module.exports = { handleFinalize }; diff --git a/.github/scripts/commands/unassign-comments.js b/.github/scripts/commands/unassign-comments.js new file mode 100644 index 0000000..ba3f5c6 --- /dev/null +++ b/.github/scripts/commands/unassign-comments.js @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/unassign-comments.js +// +// Comment builders for the /unassign command. Pure formatting functions +// separated from unassignment logic for readability. + +const { MAINTAINER_TEAM, LABELS } = require('../helpers'); + +/** + * Builds the comment posted after a successful unassignment. + * + * @param {string} username - The GitHub username being unassigned. + * @returns {string} The formatted Markdown comment body. + */ +function buildSuccessfulUnassignComment(username) { + return [ + `👋 Hi @${username}! You have been successfully unassigned from this issue.`, + '', + `The \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ].join('\n'); +} + +/** + * Builds the comment posted when a user tries to unassign an issue they don't own. + * + * @param {string} requesterUsername - The GitHub username who commented /unassign. + * @param {string} currentAssignee - The GitHub username of the actual assignee. + * @returns {string} The formatted Markdown comment body. + */ +function buildNotAssignedToUserComment(requesterUsername, currentAssignee) { + const assigneeText = currentAssignee ? `@${currentAssignee}` : 'someone else'; + return [ + `⚠️ Hi @${requesterUsername}! You cannot unassign this issue because it is currently assigned to ${assigneeText}.`, + '', + 'Only the current assignee can unassign themselves.', + ].join('\n'); +} + +/** + * Builds the comment posted when the issue has no assignees. + * + * @param {string} requesterUsername - The GitHub username who commented /unassign. + * @returns {string} The formatted Markdown comment body. + */ +function buildNoAssigneeComment(requesterUsername) { + return [ + `👋 Hi @${requesterUsername}! This issue doesn't currently have any assignees.`, + ].join('\n'); +} + +/** + * Builds the comment posted when the issue is already closed. + * + * @param {string} requesterUsername - The GitHub username who commented /unassign. + * @returns {string} The formatted Markdown comment body. + */ +function buildIssueClosedComment(requesterUsername) { + return [ + `👋 Hi @${requesterUsername}! This issue is already closed, so the \`/unassign\` command cannot be used here.`, + ].join('\n'); +} + +/** + * Builds the comment posted when the unassign API call fails. + * + * @param {string} requesterUsername - The GitHub username who commented /unassign. + * @returns {string} The formatted Markdown comment body. + */ +function buildUnassignFailureComment(requesterUsername) { + return [ + `⚠️ Hi @${requesterUsername}! I tried to unassign you, but encountered an unexpected error.`, + '', + `${MAINTAINER_TEAM} — could you please manually unassign @${requesterUsername}?`, + ].join('\n'); +} + +module.exports = { + buildSuccessfulUnassignComment, + buildNotAssignedToUserComment, + buildNoAssigneeComment, + buildIssueClosedComment, + buildUnassignFailureComment, +}; \ No newline at end of file diff --git a/.github/scripts/commands/unassign.js b/.github/scripts/commands/unassign.js new file mode 100644 index 0000000..eac65fa --- /dev/null +++ b/.github/scripts/commands/unassign.js @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// commands/unassign.js +// +// /unassign command: allows a currently assigned contributor to unassign themselves. +// Enforces authorization (only assignees can unassign themselves) and reverts +// status labels back to the community pool. + +const { + LABELS, + ISSUE_STATE, + createDelegatingLogger, + swapLabels, + removeAssignees, + postComment, + acknowledgeComment, +} = require('../helpers'); + +const { + buildSuccessfulUnassignComment, + buildNotAssignedToUserComment, + buildNoAssigneeComment, + buildIssueClosedComment, + buildUnassignFailureComment, +} = require('./unassign-comments'); + +// Delegate to the active logger set by the dispatcher. +const logger = createDelegatingLogger(); + +/** + * Main handler for the /unassign command. Runs the following gates in order: + * + * 1. Acknowledge the comment with a thumbs-up reaction. + * 2. Is the issue already closed? -> issue-closed comment. + * 3. Does the issue have no assignees? -> no-assignee comment. + * 4. Is the commenter not the current assignee? -> unauthorized comment. + * + * On success: removes the user as an assignee, reverts the "in progress" + * label to "ready for dev", and posts an acknowledgment comment. + * + * @param {{ github: object, owner: string, repo: string, number: number, + * issue: object, comment: { user: { login: string } } }} botContext + * @returns {Promise} + */ +async function handleUnassign(botContext) { + const requesterUsername = botContext.comment.user.login; + const issue = botContext.issue; + + await acknowledgeComment(botContext, botContext.comment.id); + + // GATE 1: Issue is closed + if (issue.state === ISSUE_STATE.CLOSED) { + logger.log('Exit: issue is closed'); + await postComment(botContext, buildIssueClosedComment(requesterUsername)); + return; + } + + const assignees = issue.assignees || []; + + // GATE 2: No one is assigned at all + if (assignees.length === 0) { + logger.log('Exit: issue has no assignees'); + await postComment(botContext, buildNoAssigneeComment(requesterUsername)); + return; + } + + // GATE 3: Authorization check (case-insensitive) + const isAssigned = assignees.some( + (a) => (a?.login || '').toLowerCase() === requesterUsername.toLowerCase() + ); + if (!isAssigned) { + logger.log(`Exit: @${requesterUsername} is not assigned to this issue`); + const currentAssignee = assignees[0]?.login; // Grab the actual assignee for the message + await postComment(botContext, buildNotAssignedToUserComment(requesterUsername, currentAssignee)); + return; + } + + // ACTION 1: Remove the assignee + logger.log(`Unassigning @${requesterUsername}`); + const removeResult = await removeAssignees(botContext, [requesterUsername]); + if (!removeResult.success) { + await postComment(botContext, buildUnassignFailureComment(requesterUsername)); + return; + } + + // ACTION 2: Label Swapping (Mirroring assign.js style - no stale checks) + logger.log(`Swapping labels: removing ${LABELS.IN_PROGRESS}, adding ${LABELS.READY_FOR_DEV}`); + const { success: swapSuccess, errorDetails: swapError } = await swapLabels(botContext, LABELS.IN_PROGRESS, LABELS.READY_FOR_DEV); + if (!swapSuccess) { + logger.error(`Label swap failed: ${swapError}`); + } + + // ACTION 3: Post success acknowledgment + await postComment(botContext, buildSuccessfulUnassignComment(requesterUsername)); + logger.log(`Successfully unassigned @${requesterUsername} and reverted labels`); +} + +module.exports = { handleUnassign }; \ No newline at end of file diff --git a/.github/scripts/eslint.config.mjs b/.github/scripts/eslint.config.mjs new file mode 100644 index 0000000..6f22a6d --- /dev/null +++ b/.github/scripts/eslint.config.mjs @@ -0,0 +1,13 @@ +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, +]; diff --git a/.github/scripts/helpers/api.js b/.github/scripts/helpers/api.js new file mode 100644 index 0000000..31a686b --- /dev/null +++ b/.github/scripts/helpers/api.js @@ -0,0 +1,1112 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/api.js +// +// Bot context builder and GitHub API wrappers (labels, assignees, comments, +// commit/issue fetching, and label swap helpers). + +const { getLogger } = require('./logger'); +const { + isSafeSearchToken, + requireObject, + requireNonEmptyString, + requirePositiveInt, + requireSafeUsername, +} = require('./validation'); +const { LABELS, SKILL_HIERARCHY, ISSUE_STATE } = require('./constants'); +const { checkDCO, checkGPG, checkMergeConflict, checkIssueLink } = require('./checks'); +const { buildBotComment } = require('./comments'); + +/** + * Builds the bot context for any bot. Validates github, context, and payload; throws if invalid. + * Returned object always includes eventType; then event-specific fields (number, pr/issue, and comment for issue_comment). + * + * @param {{ github: object, context: object }} args - The arguments from the workflow. + * @returns {{ github: object, owner: string, repo: string, eventType: string, ... }} + * - pull_request / pull_request_target / pull_request_review: also number, pr + * - issues: also number, issue + * - issue_comment: also number, issue, comment + * @throws {Error} If input is invalid or event type is unsupported. + */ +function buildBotContext({ github, context }) { + requireObject(github, 'github'); + requireObject(context, 'context'); + requireObject(context.repo, 'context.repo'); + requireObject(context.payload, 'context.payload'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + requireNonEmptyString(owner, 'context.repo.owner'); + requireNonEmptyString(repo, 'context.repo.repo'); + if (!isSafeSearchToken(owner) || !isSafeSearchToken(repo)) { + throw new Error('Bot context invalid: owner or repo contains invalid characters'); + } + + const base = { github, owner, repo }; + + requireNonEmptyString(context.eventName, 'context.eventName'); + const eventType = context.eventName; + + const { payload } = context; + let payloadPart; + switch (eventType) { + case 'pull_request': + case 'pull_request_target': + case 'pull_request_review': { + const pr = payload.pull_request; + requireObject(pr, 'context.payload.pull_request'); + requirePositiveInt(pr.number, 'pull_request.number'); + + if (pr.user) { + requireNonEmptyString(pr.user.login, 'pull_request.user.login'); + if (!isSafeSearchToken(pr.user.login)) { + throw new Error('Bot context invalid: pull_request.user.login contains invalid characters'); + } + } + + payloadPart = { number: pr.number, pr }; + break; + } + + case 'issues': + case 'issue_comment': { + const issue = payload.issue; + requireObject(issue, 'context.payload.issue'); + requirePositiveInt(issue.number, 'issue.number'); + payloadPart = { number: issue.number, issue }; + + if (eventType === 'issue_comment') { + const comment = payload.comment; + requireObject(comment, 'context.payload.comment'); + requireObject(comment.user, 'context.payload.comment.user'); + requireNonEmptyString(comment.user.login, 'context.payload.comment.user.login'); + + // Flag bot users early so callers can skip processing without + // hitting the stricter username validation below. + const isBot = comment.user.type === 'Bot'; + if (!isBot && !isSafeSearchToken(comment.user.login)) { + throw new Error('Bot context invalid: comment.user.login contains invalid characters'); + } + if (typeof comment.body !== 'string') { + throw new Error('Bot context invalid: comment.body must be a string'); + } + + payloadPart = { ...payloadPart, comment, isBot }; + } + break; + } + default: + throw new Error(`Bot context invalid: unsupported event type "${eventType}"`); + } + return { ...base, eventType, ...payloadPart }; +} + +/** + * Safely adds labels to an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string[]} labels - Array of label names to add. + * @returns {Promise<{success: boolean, error?: string}>} - Result object. + */ +async function addLabels(botContext, labels) { + if (!Array.isArray(labels)) { + return { success: false, error: 'labels must be an array' }; + } + + try { + for (let i = 0; i < labels.length; i++) { + requireNonEmptyString(labels[i], `labels[${i}]`); + } + + await botContext.github.rest.issues.addLabels({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + labels, + }); + + getLogger().log(`Added labels: ${labels.join(', ')}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not add labels "${labels.join(', ')}": ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Safely removes a label from an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string} labelName - The label name to remove. + * @returns {Promise<{success: boolean, error?: string}>} - Result object. + */ +async function removeLabel(botContext, labelName) { + try { + requireNonEmptyString(labelName, 'labelName'); + + await botContext.github.rest.issues.removeLabel({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + name: labelName, + }); + + getLogger().log(`Removed label: ${labelName}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not remove label "${labelName}": ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Safely adds assignees to an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string[]} assignees - Array of usernames to assign. + * @returns {Promise<{success: boolean, error?: string}>} - Result object. + */ +async function addAssignees(botContext, assignees) { + if (!Array.isArray(assignees)) { + return { success: false, error: 'assignees must be an array' }; + } + + try { + for (let i = 0; i < assignees.length; i++) { + requireSafeUsername(assignees[i], `assignees[${i}]`); + } + + await botContext.github.rest.issues.addAssignees({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + assignees, + }); + + getLogger().log(`Added assignees: ${assignees.join(', ')}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not add assignees "${assignees.join(', ')}": ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Safely removes assignees from an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string[]} assignees - Array of usernames to remove. + * @returns {Promise<{success: boolean, error?: string}>} - Result object. + */ +async function removeAssignees(botContext, assignees) { + if (!Array.isArray(assignees)) { + return { success: false, error: 'assignees must be an array' }; + } + + try { + for (let i = 0; i < assignees.length; i++) { + requireSafeUsername(assignees[i], `assignees[${i}]`); + } + + await botContext.github.rest.issues.removeAssignees({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + assignees, + }); + + getLogger().log(`Removed assignees: ${assignees.join(', ')}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not remove assignees "${assignees.join(', ')}": ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Safely posts a comment on an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string} body - The comment body. + * @returns {Promise<{success: boolean, error?: string}>} - Result object. + */ +async function postComment(botContext, body) { + try { + requireNonEmptyString(body, 'comment body'); + + await botContext.github.rest.issues.createComment({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + body, + }); + getLogger().log('Posted comment'); + return { success: true }; + } catch (error) { + getLogger().error(`Could not post comment: ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Checks if an issue or PR has a specific label. + * @param {object} issueOrPr - The issue or PR object. + * @param {string} labelName - The label name to check for. + * @returns {boolean} - True if the label is present. + */ +function hasLabel(issueOrPr, labelName) { + if (!issueOrPr?.labels?.length) { + return false; + } + + return issueOrPr.labels.some((label) => { + const name = typeof label === 'string' ? label : label?.name; + return typeof name === 'string' && name.toLowerCase() === labelName.toLowerCase(); + }); +} + +/** + * Returns all label names on an issue or PR that start with the given prefix. + * The comparison is case-insensitive. + * + * @param {object} issueOrPr - The issue or PR object. + * @param {string} prefix - Label group prefix (e.g. 'skill:'). + * @returns {string[]} + */ +function getLabelsByPrefix(issueOrPr, prefix) { + return (issueOrPr.labels || []) + .map((l) => (typeof l === 'string' ? l : l?.name || '')) + .filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase())); +} + +/** + * Removes `fromLabel` and adds `toLabel` on the issue/PR. Both operations are + * always attempted; errors are collected and returned rather than thrown. + * + * @param {object} botContext - Bot context (github, owner, repo, number). + * @param {string} fromLabel - Label to remove. + * @param {string} toLabel - Label to add. + * @returns {Promise<{ success: boolean, errorDetails: string }>} + */ +async function swapLabels(botContext, fromLabel, toLabel) { + const errors = []; + + const removeResult = await removeLabel(botContext, fromLabel); + if (!removeResult.success) { + errors.push(`Failed to remove '${fromLabel}': ${removeResult.error}`); + } + + const addResult = await addLabels(botContext, [toLabel]); + if (!addResult.success) { + errors.push(`Failed to add '${toLabel}': ${addResult.error}`); + } + + return { success: errors.length === 0, errorDetails: errors.join('; ') }; +} + +/** + * Fetches an existing comment identified by an HTML marker. + * Paginates through all comments to find a match. + * @param {object} botContext + * @param {string} marker - HTML comment marker (e.g. ''). + * @returns {Promise} + */ +async function getBotComment(botContext, marker) { + let page = 1; + const perPage = 100; + + while (true) { + const { data: comments } = await botContext.github.rest.issues.listComments({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + per_page: perPage, + page, + }); + + for (const c of comments) { + if (c.body && c.body.startsWith(marker)) { + return c; + } + } + + if (comments.length < perPage) break; + page++; + } + + return null; +} + +/** + * Posts a new comment or updates an existing one identified by an HTML marker. + * Prevents unnecessary updates if the comment body has not changed. + * @param {object} botContext + * @param {string} marker - HTML comment marker (e.g. ''). + * @param {string} body - Full comment body (must include the marker). + * @returns {Promise<{success: boolean, error?: string}>} + */ +async function postOrUpdateComment(botContext, marker, body) { + try { + requireNonEmptyString(marker, 'marker'); + requireNonEmptyString(body, 'comment body'); + + const existingComment = await getBotComment(botContext, marker); + + if (existingComment) { + if (existingComment.body.trim() === body.trim()) { + getLogger().log('Existing bot comment is up-to-date'); + } else { + await botContext.github.rest.issues.updateComment({ + owner: botContext.owner, + repo: botContext.repo, + comment_id: existingComment.id, + body, + }); + getLogger().log('Updated existing bot comment'); + } + } else { + await botContext.github.rest.issues.createComment({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + body, + }); + getLogger().log('Created new bot comment'); + } + return { success: true }; + } catch (error) { + getLogger().error(`Could not post/update comment: ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Fetches all commits for a pull request via the GitHub API (paginated). + * @param {object} botContext + * @returns {Promise} + */ +async function fetchPRCommits(botContext) { + const commits = []; + let page = 1; + const perPage = 100; + + while (true) { + const response = await botContext.github.rest.pulls.listCommits({ + owner: botContext.owner, + repo: botContext.repo, + pull_number: botContext.number, + per_page: perPage, + page, + }); + + commits.push(...response.data); + + if (response.data.length < perPage) break; + page++; + } + + getLogger().log(`Fetched ${commits.length} commits for PR #${botContext.number}`); + return commits; +} + +/** + * Fetches all open pull requests for the repository via the GitHub API (paginated). + * @param {object} botContext + * @returns {Promise} + */ +async function fetchOpenPRs(botContext) { + const prs = []; + let page = 1; + const perPage = 100; + + while (true) { + const response = await botContext.github.rest.pulls.list({ + owner: botContext.owner, + repo: botContext.repo, + state: 'open', + per_page: perPage, + page, + }); + + prs.push(...response.data); + + if (response.data.length < perPage) break; + page++; + } + + getLogger().log(`Fetched ${prs.length} open PRs`); + return prs; +} + +/** + * Fetches a single issue by number. + * @param {object} botContext + * @param {number} issueNumber + * @returns {Promise} + */ +async function fetchIssue(botContext, issueNumber) { + const { data: issue } = await botContext.github.rest.issues.get({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: issueNumber, + }); + return issue; +} + +/** + * Fetches issue numbers linked to a PR via GitHub's closingIssuesReferences GraphQL field. + * @param {object} botContext + * @returns {Promise} + */ +async function fetchClosingIssueNumbers(botContext) { + try { + const query = `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + pullRequest(number:$number){ + closingIssuesReferences(first:10){ + nodes { number } + } + } + } + }`; + const result = await botContext.github.graphql(query, { + owner: botContext.owner, + repo: botContext.repo, + number: botContext.number, + }); + const nodes = result.repository.pullRequest.closingIssuesReferences.nodes || []; + return nodes.map(n => n.number); + } catch (error) { + getLogger().error(`GraphQL closingIssuesReferences failed: ${error.message}`); + return []; + } +} + +/** + * Fetches the latest open milestone for the repository. + * Milestones with due dates are sorted by latest due_on. If none have due + * dates, falls back to the highest milestone number. + * + * @param {object} botContext + * @returns {Promise} + */ +async function fetchLatestMilestone(botContext) { + const milestones = []; + let page = 1; + const perPage = 100; + + try { + while (true) { + const { data } = await botContext.github.rest.issues.listMilestones({ + owner: botContext.owner, + repo: botContext.repo, + state: 'open', + sort: 'due_on', + direction: 'desc', + per_page: perPage, + page, + }); + + milestones.push(...data); + + if (data.length < perPage) break; + page++; + } + } catch (error) { + getLogger().error(`Could not fetch milestones: ${error.message}`); + return null; + } + + if (milestones.length === 0) { + getLogger().log('No open milestones found'); + return null; + } + + const withDueDates = milestones.filter(m => m.due_on); + if (withDueDates.length > 0) { + return withDueDates.sort((a, b) => new Date(b.due_on) - new Date(a.due_on))[0]; + } + + return [...milestones].sort((a, b) => b.number - a.number)[0]; +} + +/** + * Sets the milestone on an issue or PR. + * + * @param {object} botContext + * @param {number} issueOrPrNumber + * @param {number} milestoneNumber + * @returns {Promise<{success: boolean, error?: string}>} + */ +async function setMilestone(botContext, issueOrPrNumber, milestoneNumber) { + try { + requirePositiveInt(issueOrPrNumber, 'issueOrPrNumber'); + requirePositiveInt(milestoneNumber, 'milestoneNumber'); + + await botContext.github.rest.issues.update({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: issueOrPrNumber, + milestone: milestoneNumber, + }); + + getLogger().log(`Set milestone #${milestoneNumber} on #${issueOrPrNumber}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not set milestone on #${issueOrPrNumber}: ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * Swaps between needs-review and needs-revision labels based on check results. + * By default only changes the label if the opposite label is currently applied. + * When force is true, unconditionally applies the target label (used on PR open + * to guarantee a status label is always present). + * @param {object} botContext + * @param {boolean} allPassed + * @param {{ force?: boolean }} [options] + * @returns {Promise<{ success: boolean, errorDetails: string }>} + */ +async function swapStatusLabel(botContext, allPassed, { force = false } = {}) { + const pr = botContext.pr; + const labelToAdd = allPassed ? LABELS.NEEDS_REVIEW : LABELS.NEEDS_REVISION; + const labelToRemove = allPassed ? LABELS.NEEDS_REVISION : LABELS.NEEDS_REVIEW; + const errors = []; + + const shouldRemove = hasLabel(pr, labelToRemove); + const shouldAdd = force || shouldRemove; + + if (shouldRemove) { + const removeResult = await removeLabel(botContext, labelToRemove); + if (!removeResult.success) { + errors.push(`Failed to remove '${labelToRemove}': ${removeResult.error}`); + } + } + + if (shouldAdd) { + const addResult = await addLabels(botContext, [labelToAdd]); + if (!addResult.success) { + errors.push(`Failed to add '${labelToAdd}': ${addResult.error}`); + } + } + + return { success: errors.length === 0, errorDetails: errors.join('; ') }; +} + +/** + * Adds a thumbs-up (+1) reaction to a comment as visual acknowledgement that + * a bot command was received. Returns { success: false } when the reaction + * cannot be added (e.g. the comment was deleted before the bot ran). + * + * @param {object} botContext - Bot context from buildBotContext (github, owner, repo). + * @param {number} commentId - The ID of the comment to react to. + * @returns {Promise<{ success: boolean }>} + */ +async function acknowledgeComment(botContext, commentId) { + try { + await botContext.github.rest.reactions.createForIssueComment({ + owner: botContext.owner, + repo: botContext.repo, + comment_id: commentId, + content: '+1', + }); + getLogger().log('Added thumbs-up reaction to comment'); + return { success: true }; + } catch (error) { + getLogger().log('Could not add reaction:', error.message); + return { success: false }; + } +} + +/** + * Runs all 4 PR checks (DCO, GPG, merge conflict, issue link) with error + * resilience, builds the unified dashboard comment, and posts/updates it. + * Returns { allPassed } so callers can decide on label handling. + * @param {object} botContext + * @returns {Promise<{ allPassed: boolean }>} + */ +async function runAllChecksAndComment(botContext, precomputed = {}) { + let { merge, issueLink } = precomputed; + + if (!merge) { + try { merge = await checkMergeConflict(botContext); } + catch (e) { merge = { error: true, errorMessage: e.message }; } + } + + if (!issueLink) { + try { issueLink = await checkIssueLink(botContext, { fetchIssue, fetchClosingIssueNumbers }); } + catch (e) { issueLink = { error: true, errorMessage: e.message }; } + } + + const prAuthor = botContext.pr?.user?.login; + const { marker, body, allPassed } = buildBotComment({ prAuthor, merge, issueLink }); + await postOrUpdateComment(botContext, marker, body); + + return { allPassed }; +} + +/** + * Fetches all issue/PR events (paginated). Useful for detecting label changes + * (e.g. when "status: blocked" was removed). + * @param {object} botContext - Bot context (github, owner, repo, number). + * @returns {Promise} + */ +async function fetchIssueEvents(botContext) { + const events = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await botContext.github.rest.issues.listEvents({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + per_page: perPage, + page, + }); + + events.push(...data); + + if (data.length < perPage) break; + page++; + } + + getLogger().log(`Fetched ${events.length} events for #${botContext.number}`); + return events; +} + +/** + * Fetches all comments for an issue or PR (paginated). + * @param {object} botContext - Bot context (github, owner, repo, number). + * @returns {Promise} + */ +async function fetchComments(botContext) { + const comments = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await botContext.github.rest.issues.listComments({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + per_page: perPage, + page, + }); + + comments.push(...data); + + if (data.length < perPage) break; + page++; + } + + getLogger().log(`Fetched ${comments.length} comments for #${botContext.number}`); + return comments; +} + +/** + * Closes an issue or PR. + * @param {object} botContext - Bot context (github, owner, repo, number). + * @returns {Promise<{success: boolean, error?: string}>} + */ +async function closeItem(botContext) { + try { + await botContext.github.rest.issues.update({ + owner: botContext.owner, + repo: botContext.repo, + issue_number: botContext.number, + state: 'closed', + }); + + getLogger().log(`Closed #${botContext.number}`); + return { success: true }; + } catch (error) { + getLogger().error(`Could not close #${botContext.number}: ${error.message}`); + return { success: false, error: error.message }; + } +} + +/** + * 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) { + const issue = await fetchIssue(botContext, issueNumbers[0]); + if (!issue || SKILL_HIERARCHY.findIndex(level => hasLabel(issue, level)) === -1) { + getLogger().log('Single linked issue has no skill label', { issueNumber: issueNumbers[0] }); + return null; + } + return issue; + } + + 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; + }); + + const selectedIndex = SKILL_HIERARCHY.findIndex(level => hasLabel(selected, level)); + if (selectedIndex === -1) { + getLogger().log('No linked issues have a skill label', { issueNumbers }); + return null; + } + + 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; + } +} + +/** + * 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 getHighestIssueSkillLevel(issue) { + for (const level of [...SKILL_HIERARCHY].reverse()) { + if (hasLabel(issue, level)) return level; + } + return null; +} + +/** + * Fetches the number of issues assigned to a specific user that match a given + * state and optional label using the GitHub REST API. + * + * Note: When state is OPEN and no label filter is provided, issues with the + * "status: blocked" label are explicitly EXCLUDED from the count. + * The search is constrained to the repo specified in the context. + * + * @param {object} github - Octokit GitHub API client (must support github.rest). + * @param {string} owner - Repository owner (e.g. 'kdm-ledger'). + * @param {string} repo - Repository name (e.g. 'kdm-cli'). + * @param {string} username - GitHub username to search for. + * @param {string} state - Issue state filter: ISSUE_STATE.OPEN or ISSUE_STATE.CLOSED. + * @param {string|null} [label=null] - Optional label filter (e.g. 'skill: good first issue'). + * @param {number|null} [threshold=null] - Optional threshold to short-circuit pagination. + * When provided, the function returns a capped count (the threshold value) + * once that threshold is reached. + * @returns {Promise} Matching issue count, or null if inputs are invalid or the API call fails. + * When threshold is provided and reached, returns the threshold value (capped), + * not necessarily the exact total. + */ +async function countIssuesByAssignee( + github, + owner, + repo, + username, + state, + label = null, + threshold = null, +) { + if ( + !isSafeSearchToken(owner) || + !isSafeSearchToken(repo) || + !isSafeSearchToken(username) + ) { + getLogger().log("[assign] Invalid search inputs:", { + owner, + repo, + username, + label, + }); + return null; + } + if (state !== ISSUE_STATE.OPEN && state !== ISSUE_STATE.CLOSED) { + getLogger().log("[assign] Invalid state:", { state }); + return null; + } + if ( + label && + (typeof label !== "string" || !label.trim() || label.includes('"')) + ) { + getLogger().log("[assign] Invalid label parameter:", { label }); + return null; + } + + try { + let page = 1; + let matchingIssuesCount = 0; + const perPage = 100; + + getLogger().log(`[assign] Fetching ${state} assigned issues via REST...`); + while (true) { + const params = { + owner, + repo, + state: state.toLowerCase(), + assignee: username, + per_page: perPage, + page, + }; + if (label) params.labels = label; + + const result = await github.rest.issues.listForRepo(params); + // Filter out Pull Requests (which are returned by the issues endpoint) + const actualIssues = result.data.filter((item) => !item.pull_request); + + let pageMatchCount = 0; + if (state === ISSUE_STATE.OPEN && !label) { + pageMatchCount = actualIssues.filter( + (issue) => + !issue.labels?.some((l) => (l.name || l) === LABELS.BLOCKED), + ).length; + } else { + pageMatchCount = actualIssues.length; + } + + matchingIssuesCount += pageMatchCount; + + if (threshold !== null && matchingIssuesCount >= threshold) { + getLogger().log( + `[assign] Reached threshold (${threshold}), short-circuiting fetch.`, + ); + matchingIssuesCount = threshold; // Cap at threshold logically for callers + break; + } + + // Pagination must evaluate the raw result size, not the filtered size. + if (result.data.length < perPage) break; + page++; + } + + if (label) { + getLogger().log( + `[assign] ${state} assigned issues for ${username} with label ${label}: ${matchingIssuesCount}`, + ); + } else { + getLogger().log( + `[assign] ${state} assigned issues for ${username}${state === ISSUE_STATE.OPEN ? " (excluding blocked)" : ""}: ${matchingIssuesCount}`, + ); + } + return matchingIssuesCount; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + getLogger().log( + `[assign] Failed to count ${state} issues for ${username}: ${message}`, + ); + return null; + } +} + +/** + * Returns the actual open non-blocked issue objects assigned to the given user. + * Reuses the same listForRepo pagination pattern as countIssuesByAssignee, + * filtering out pull requests and issues with the "status: blocked" label. + * + * @param {object} github - Octokit GitHub API client (must support github.rest). + * @param {string} owner - Repository owner (e.g. 'kdm-ledger'). + * @param {string} repo - Repository name (e.g. 'kdm-cli'). + * @param {string} username - GitHub username to search for. + * @returns {Promise} Array of issue objects, or null if inputs are invalid or the API call fails. + */ +async function listAssignedIssues(github, owner, repo, username) { + if ( + !isSafeSearchToken(owner) || + !isSafeSearchToken(repo) || + !isSafeSearchToken(username) + ) { + getLogger().log("[assign] Invalid search inputs for listAssignedIssues:", { + owner, + repo, + username, + }); + return null; + } + + try { + let page = 1; + const perPage = 100; + const issues = []; + + getLogger().log("[assign] Fetching open assigned issues via REST (objects)..."); + while (true) { + const result = await github.rest.issues.listForRepo({ + owner, + repo, + state: ISSUE_STATE.OPEN, + assignee: username, + per_page: perPage, + page, + }); + + // Filter out Pull Requests (which are returned by the issues endpoint) + const actualIssues = result.data.filter((item) => !item.pull_request); + + // Exclude issues with the "status: blocked" label + const nonBlocked = actualIssues.filter( + (issue) => + !issue.labels?.some((l) => (l.name || l) === LABELS.BLOCKED), + ); + + issues.push(...nonBlocked); + + // Pagination must evaluate the raw result size, not the filtered size. + if (result.data.length < perPage) break; + page++; + } + + getLogger().log( + `[assign] Open non-blocked assigned issues for ${username}: ${issues.length}`, + ); + return issues; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + getLogger().log( + `[assign] Failed to list assigned issues for ${username}: ${message}`, + ); + return null; + } +} + +/** + * Checks whether the given issue has an open PR authored by the specified user + * that carries the "status: needs review" label and is linked to the issue. + * Uses the GitHub GraphQL API to check closedByPullRequestsReferences. + * + * @param {object} github - Octokit GitHub API client. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {string} username - GitHub username (PR author). + * @param {number} issueNumber - Issue number to check for linked PRs. + * @returns {Promise} true if a matching PR exists, false if none, null on API error. + */ +async function hasNeedsReviewPR(github, owner, repo, username, issueNumber) { + if ( + !isSafeSearchToken(owner) || + !isSafeSearchToken(repo) || + !isSafeSearchToken(username) + ) { + getLogger().log("[assign] Invalid search inputs for hasNeedsReviewPR:", { + owner, + repo, + username, + issueNumber, + }); + return null; + } + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + getLogger().log("[assign] Invalid issue number for hasNeedsReviewPR:", { + issueNumber, + }); + return null; + } + + try { + getLogger().log(`[assign] Querying linked PRs for issue #${issueNumber}`); + // closedByPullRequestsReferences only includes PRs linked via closing keywords + // (Fixes/Closes/Resolves #N). PRs linked through GitHub's sidebar "Development" panel + // or via a plain mention are invisible here. If a bypass fails for an active contributor, + // verify their PR uses a closing keyword to link the issue. + const query = `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + issue(number:$number){ + closedByPullRequestsReferences(first:50){ + nodes { + state + author { login } + labels(first:50) { + nodes { name } + } + } + } + } + } + }`; + const result = await github.graphql(query, { + owner, + repo, + number: issueNumber, + }); + + const nodes = result.repository?.issue?.closedByPullRequestsReferences?.nodes || []; + const hasMatch = nodes.some(pr => { + const isAuthor = pr.author && pr.author.login === username; + const isOpen = pr.state === 'OPEN'; + const hasLabel = pr.labels && pr.labels.nodes && pr.labels.nodes.some(l => l.name === LABELS.NEEDS_REVIEW); + return isAuthor && isOpen && hasLabel; + }); + + getLogger().log( + `[assign] Needs-review PR search for issue #${issueNumber}: ${hasMatch ? 1 : 0} match(es)`, + ); + return hasMatch; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + getLogger().log( + `[assign] Failed to search for needs-review PRs for issue #${issueNumber}: ${message}`, + ); + return null; + } +} + +module.exports = { + buildBotContext, + addLabels, + removeLabel, + addAssignees, + removeAssignees, + postComment, + hasLabel, + getLabelsByPrefix, + swapLabels, + getBotComment, + postOrUpdateComment, + fetchPRCommits, + fetchOpenPRs, + fetchIssue, + fetchClosingIssueNumbers, + fetchLatestMilestone, + setMilestone, + swapStatusLabel, + runAllChecksAndComment, + resolveLinkedIssue, + acknowledgeComment, + fetchComments, + fetchIssueEvents, + closeItem, + getHighestIssueSkillLevel, + countIssuesByAssignee, + listAssignedIssues, + hasNeedsReviewPR, +}; diff --git a/.github/scripts/helpers/checks.js b/.github/scripts/helpers/checks.js new file mode 100644 index 0000000..969f269 --- /dev/null +++ b/.github/scripts/helpers/checks.js @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/checks.js +// +// Pure check functions for PR validation: DCO sign-off, GPG signatures, +// merge conflicts, and issue link (with assignment verification). +// Each function returns a structured result object with no side effects. + +const { getLogger } = require('./logger'); + +/** + * Checks whether a commit message contains a valid DCO sign-off line. + * Expects a line matching "Signed-off-by: Name " (case-insensitive). + * @param {string} message - The commit message. + * @returns {boolean} + */ +function hasDCOSignoff(message) { + if (!message) return false; + return /^Signed-off-by:\s+.+\s+<.+>/mi.test(message); +} + +/** + * Checks whether a commit has a verified GPG signature. + * @param {{ commit?: { verification?: { verified?: boolean } } }} commit + * @returns {boolean} + */ +function hasVerifiedGPGSignature(commit) { + return commit?.commit?.verification?.verified === true; +} + +/** + * Returns true if the commit is a merge commit (has more than one parent). + * Merge commits are auto-generated by Git and should be exempt from DCO sign-off. + * @param {{ parents?: Array }} commit + * @returns {boolean} + */ +function isMergeCommit(commit) { + return Array.isArray(commit?.parents) && commit.parents.length > 1; +} + +/** + * Checks all commits for DCO sign-off. Merge commits are skipped. + * @param {Array<{ sha?: string, parents?: Array, commit?: { message?: string } }>} commits + * @returns {{ passed: boolean, failures: Array<{ sha: string, message: string }> }} + */ +function checkDCO(commits) { + const failures = []; + let skipped = 0; + for (const c of commits) { + if (isMergeCommit(c)) { + skipped++; + continue; + } + const message = c.commit?.message || ''; + const shortSha = (c.sha || '').slice(0, 7); + const firstLine = message.split('\n')[0] || '(no message)'; + if (!hasDCOSignoff(message)) { + failures.push({ sha: shortSha, message: firstLine }); + } + } + const checked = commits.length - skipped; + getLogger().log(`DCO check: ${checked - failures.length}/${checked} passed (${skipped} merge commit(s) skipped)`); + return { passed: failures.length === 0, failures }; +} + +/** + * Checks all commits for verified GPG signatures. + * @param {Array<{ sha?: string, commit?: { message?: string, verification?: { verified?: boolean } } }>} commits + * @returns {{ passed: boolean, failures: Array<{ sha: string, message: string }> }} + */ +function checkGPG(commits) { + const failures = []; + for (const c of commits) { + const shortSha = (c.sha || '').slice(0, 7); + const message = c.commit?.message || ''; + const firstLine = message.split('\n')[0] || '(no message)'; + if (!hasVerifiedGPGSignature(c)) { + failures.push({ sha: shortSha, message: firstLine }); + } + } + getLogger().log(`GPG check: ${commits.length - failures.length}/${commits.length} passed`); + return { passed: failures.length === 0, failures }; +} + +/** + * Checks whether the PR has merge conflicts with the base branch. + * Polls pulls.get for the mergeable state with retries. + * @param {object} botContext + * @returns {Promise<{ passed: boolean }>} + */ +async function checkMergeConflict(botContext) { + const logger = getLogger(); + const maxAttempts = 5; + const delayMs = 2000; + + let conflicts = false; + let mergeableResolved = false; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const { data: pr } = await botContext.github.rest.pulls.get({ + owner: botContext.owner, + repo: botContext.repo, + pull_number: botContext.number, + }); + + if (pr.mergeable !== null) { + logger.log(`Merge conflict check: mergeable=${pr.mergeable}, state=${pr.mergeable_state}`); + conflicts = !pr.mergeable; + mergeableResolved = true; + break; + } + + if (attempt < maxAttempts) { + logger.log(`Mergeable state not ready, waiting ${delayMs}ms (attempt ${attempt}/${maxAttempts})`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + if (!mergeableResolved) { + logger.log('Merge conflict check: mergeable never resolved after retries, assuming no conflicts'); + } + logger.log(`Merge conflict check: ${conflicts ? 'has conflicts' : 'no conflicts'}`); + return { passed: !conflicts }; +} + +/** + * Extracts issue numbers from a PR body using closing and "related to" keywords. + * @param {string} body + * @returns {Set} + */ +function parseIssueNumbers(body) { + const numbers = new Set(); + const patterns = [ + /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi, + /related\s+to\s+#(\d+)/gi, + ]; + for (const regex of patterns) { + let match; + while ((match = regex.exec(body)) !== null) { + numbers.add(parseInt(match[1], 10)); + } + } + return numbers; +} + +/** + * Identifies the issue numbers referenced in the PR title so they can be excluded from the GraphQL fallback results. + * @param {string} value + * @returns {Set} + */ +function extractNumbersFromTitle(value) { + const numbers = new Set(); + const pattern = /#(\d+)/g; + if (!value) return numbers; + + let match; + while ((match = pattern.exec(value)) !== null) { + numbers.add(parseInt(match[1], 10)); + } + return numbers; +} + +/** + * Fetches each issue by number and checks whether the given author is assigned. + * Issues that fail to fetch are silently skipped (logged only). + * @param {object} botContext + * @param {function} fetchIssue + * @param {Set} issueNumbers + * @param {string} prAuthor + * @returns {Promise>} + */ +async function fetchAndCheckAssignees(botContext, fetchIssue, issueNumbers, prAuthor) { + const logger = getLogger(); + const results = []; + for (const num of issueNumbers) { + try { + const issue = await fetchIssue(botContext, num); + const isAssigned = (issue.assignees || []).some( + a => a.login.toLowerCase() === prAuthor.toLowerCase() + ); + results.push({ number: num, title: issue.title, isAssigned }); + } catch (err) { + logger.log(`Issue link check: could not fetch issue #${num}: ${err.message}`); + } + } + return results; +} + +/** + * Checks whether the PR is linked to an issue and whether the PR author is + * assigned to that issue. + * + * Detection: regex on PR body for closing keywords, then GraphQL + * closingIssuesReferences as fallback. + * + * @param {object} botContext + * @param {{ fetchIssue: function, fetchClosingIssueNumbers: function }} api + * @returns {Promise<{ passed: boolean, reason: string|null, issues: Array<{ number: number, title: string, isAssigned: boolean }> }>} + */ +async function checkIssueLink(botContext, { fetchIssue, fetchClosingIssueNumbers }) { + const logger = getLogger(); + const body = botContext.pr?.body || ''; + const prAuthor = botContext.pr?.user?.login; + + const issueNumbers = parseIssueNumbers(body); + + if (issueNumbers.size === 0) { + const prTitle = botContext.pr?.title || ''; + const prTitleIssues = extractNumbersFromTitle(prTitle); + const graphqlIssues = await fetchClosingIssueNumbers(botContext); + graphqlIssues + .filter(n => !prTitleIssues.has(n)) + .forEach(n => issueNumbers.add(n)); + } + + if (issueNumbers.size === 0) { + logger.log('Issue link check: no linked issues found'); + return { passed: false, reason: 'no_issue_linked', issues: [] }; + } + + const linkedIssues = await fetchAndCheckAssignees(botContext, fetchIssue, issueNumbers, prAuthor); + + if (linkedIssues.length === 0) { + logger.log('Issue link check: all linked issues returned errors'); + return { passed: false, reason: 'no_issue_linked', issues: [] }; + } + + const allAssigned = linkedIssues.every(i => i.isAssigned); + if (!allAssigned) { + const missing = linkedIssues.filter(i => !i.isAssigned).map(i => `#${i.number}`).join(', '); + logger.log(`Issue link check: author ${prAuthor} not assigned to all linked issues (missing: ${missing})`); + return { passed: false, reason: 'not_assigned', issues: linkedIssues }; + } + + logger.log(`Issue link check: passed (author assigned to all linked issues)`); + return { passed: true, reason: null, issues: linkedIssues }; +} + +module.exports = { + hasDCOSignoff, + hasVerifiedGPGSignature, + isMergeCommit, + checkDCO, + checkGPG, + checkMergeConflict, + parseIssueNumbers, + checkIssueLink, + extractNumbersFromTitle, +}; diff --git a/.github/scripts/helpers/comments.js b/.github/scripts/helpers/comments.js new file mode 100644 index 0000000..d2b5739 --- /dev/null +++ b/.github/scripts/helpers/comments.js @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/comments.js +// +// Builds the unified PR Helper Bot dashboard comment. Designed with a layered +// structure so future sections (commands, instructions) can be added alongside +// checks without changing the overall shape. + +const { MAINTAINER_TEAM, DOCUMENTATION } = require('./constants'); + +const MARKER = ''; + +const SIGNING_GUIDE = DOCUMENTATION.signingGuide; +const MERGE_CONFLICTS_GUIDE = DOCUMENTATION.mergeConflictsGuide; + +/** + * Determines the display state of a check result. + * @param {{ passed?: boolean, error?: boolean }} result + * @returns {'pass'|'fail'|'error'} + */ +function checkState(result) { + if (result.error) return 'error'; + return result.passed ? 'pass' : 'fail'; +} + +/** + * Shared renderer for the error and pass states of a check section. + * Returns null for the fail state so callers can supply their own content. + * @param {{ title: string, result: object, passMessage: string }} opts + * @returns {string|null} + */ +function buildSection({ title, result, passMessage }) { + const state = checkState(result); + if (state === 'error') { + return [ + `:warning: **${title}** -- This check encountered an internal error. ${MAINTAINER_TEAM} please review manually.`, + '', + `Error: ${result.errorMessage || 'Unknown error'}`, + ].join('\n'); + } + if (state === 'pass') { + return `:white_check_mark: **${title}** -- ${passMessage}`; + } + return null; +} + +/** + * @param {{ passed: boolean, failures?: Array<{ sha: string, message: string }>, error?: boolean, errorMessage?: string }} dco + * @returns {string} + */ +function buildDCOSection(dco) { + const common = buildSection({ title: 'DCO Sign-off', result: dco, passMessage: 'All commits have valid sign-offs. Nice work!' }); + if (common) return common; + + const failList = (dco.failures || []).map(f => `- \`${f.sha}\` ${f.message}`).join('\n'); + return [ + ':x: **DCO Sign-off** -- Uh oh! The following commits are missing the required DCO sign-off:', + failList, + '', + `No worries, this is an easy fix! Add \`Signed-off-by: Your Name \` to each commit (e.g. \`git commit -s\`). See the [Signing Guide](${SIGNING_GUIDE}).`, + ].join('\n'); +} + +/** + * @param {{ passed: boolean, failures?: Array<{ sha: string, message: string }>, error?: boolean, errorMessage?: string }} gpg + * @returns {string} + */ +function buildGPGSection(gpg) { + const common = buildSection({ title: 'GPG Signature', result: gpg, passMessage: 'All commits have verified GPG signatures. Locked and loaded!' }); + if (common) return common; + + const failList = (gpg.failures || []).map(f => `- \`${f.sha}\` ${f.message}`).join('\n'); + return [ + ':x: **GPG Signature** -- Heads up! The following commits don\'t have a verified GPG signature:', + failList, + '', + `You'll need to sign your commits with GPG (e.g. \`git commit -S\`). See the [Signing Guide](${SIGNING_GUIDE}) for a step-by-step walkthrough.`, + ].join('\n'); +} + +/** + * @param {{ passed: boolean, error?: boolean, errorMessage?: string }} merge + * @returns {string} + */ +function buildMergeSection(merge) { + const common = buildSection({ title: 'Merge Conflicts', result: merge, passMessage: 'No merge conflicts detected. Smooth sailing!' }); + if (common) return common; + + return [ + ':x: **Merge Conflicts** -- Oh no, this PR has merge conflicts with the base branch.', + '', + `Let's get this sorted! Update your branch (e.g. rebase or merge from base) and push. See the [Merge Conflicts Guide](${MERGE_CONFLICTS_GUIDE}) if you need a hand.`, + ].join('\n'); +} + +/** + * Builds a standalone notification comment to alert a PR author that a + * recently merged PR has introduced a merge conflict in their PR. + * This is posted once when the conflict state changes from clean to + * conflicted — it does NOT replace the dashboard comment. + * + * @param {string} prAuthor - GitHub username of the PR author. + * @param {number} mergedPRNumber - The PR number whose merge caused the conflict. + * @returns {string} + */ +function buildMergeConflictNotificationComment(prAuthor, mergedPRNumber) { + return [ + `Hi @${prAuthor} :wave: — the recent merge of PR #${mergedPRNumber} has introduced a merge conflict in this PR.`, + `Please resolve the merge conflict so that this PR can be reviewed again. Thank you!`, + ].join(' '); +} + +/** + * @param {{ passed: boolean, reason?: string, issues?: Array<{ number: number, title: string, isAssigned: boolean }>, error?: boolean, errorMessage?: string }} issueLink + * @returns {string} + */ +function buildIssueLinkSection(issueLink) { + const linked = (issueLink.issues || []) + .filter(i => i.isAssigned) + .map(i => `#${i.number}`) + .join(', '); + const common = buildSection({ title: 'Issue Link', result: issueLink, passMessage: `Linked to ${linked} (assigned to you).` }); + if (common) return common; + + if (issueLink.reason === 'not_assigned') { + const unassigned = (issueLink.issues || []).filter(i => !i.isAssigned).map(i => `#${i.number}`).join(', '); + return [ + `:x: **Issue Link** -- Almost there! You are not assigned to the following linked issues: ${unassigned}.`, + '', + 'Please ensure you are assigned to all linked issues before opening a PR. You can comment `/assign` on the issue to grab it!', + ].join('\n'); + } + return [ + ':x: **Issue Link** -- This PR is not linked to any issue.', + '', + 'Please reference an issue using a closing keyword (e.g. `Fixes #123`) and ensure the issue is assigned to you. Every PR needs a home!', + ].join('\n'); +} + +/** + * Builds the ### PR Checks section of the dashboard comment. + * @param {{ dco: object, gpg: object, merge: object, issueLink: object }} results + * @returns {string} + */ +function buildChecksSection({ merge, issueLink }) { + return [ + '### PR Checks', + '', + buildMergeSection(merge), + '', + '---', + '', + buildIssueLinkSection(issueLink), + ].join('\n'); +} + +/** + * Determines whether all checks passed (errors count as not passed). + * @param {{ dco: object, gpg: object, merge: object, issueLink: object }} results + * @returns {boolean} + */ +function allChecksPassed({ merge, issueLink }) { + return ( + !merge.error && merge.passed && + !issueLink.error && issueLink.passed + ); +} + +/** + * Builds the full unified bot comment. + * @param {{ prAuthor: string, dco: object, gpg: object, merge: object, issueLink: object }} params + * @returns {{ marker: string, body: string, allPassed: boolean }} + */ +function buildBotComment({ prAuthor, merge, issueLink }) { + const greeting = [ + `Hey @${prAuthor} :wave: thanks for the PR!`, + "I'm your friendly **PR Helper Bot** :robot: and I'll be riding shotgun on this one, keeping track of your PR's status to help you get it approved and merged.", + '', + "This comment updates automatically as you push changes -- think of it as your PR's live scoreboard!", + "Here's the latest:", + ].join('\n'); + + const checksSection = buildChecksSection({ merge, issueLink }); + const passed = allChecksPassed({ merge, issueLink }); + + const footer = passed + ? ':tada: *All checks passed! Your PR is ready for review. Great job!*' + : ':hourglass_flowing_sand: *All checks must pass before this PR can be reviewed. You\'ve got this!*'; + + const body = [MARKER, greeting, '', '---', '', checksSection, '', '---', '', footer].join('\n'); + return { marker: MARKER, body, allPassed: passed }; +} + +module.exports = { + MARKER, + buildBotComment, + buildChecksSection, + allChecksPassed, + buildMergeConflictNotificationComment, +}; diff --git a/.github/scripts/helpers/config-loader.js b/.github/scripts/helpers/config-loader.js new file mode 100644 index 0000000..4a4b438 --- /dev/null +++ b/.github/scripts/helpers/config-loader.js @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/config-loader.js +// +// Loads and validates the repository automation configuration from +// .github/kdm-automation.json. Provides buildConstants() to map +// the nested config structure back into the flat constant shapes +// consumed by the rest of the bot scripts. + +const fs = require('fs'); +const path = require('path'); + +/** + * Default path to the repository automation config file. + * Resolves from helpers/ → scripts/ → .github/kdm-automation.json. + * @type {string} + */ +const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '../../kdm-automation.json'); + +/** + * Validates that a value is a non-empty string. + * @param {*} value + * @returns {boolean} + */ +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Validates that a value is a positive integer (> 0). + * @param {*} value + * @returns {boolean} + */ +function isPositiveInteger(value) { + return Number.isInteger(value) && value > 0; +} + +/** + * Required keys for each label group, documentation, and community. + * If a key is missing from the config, buildConstants() would produce + * undefined — so we fail early with a clear message. + */ +const REQUIRED_STATUS_KEYS = ['awaitingTriage', 'readyForDev', 'inProgress', 'blocked', 'needsReview', 'needsRevision']; +const REQUIRED_SKILL_KEYS = ['goodFirstIssue', 'beginner', 'intermediate', 'advanced']; +const REQUIRED_PRIORITY_KEYS = ['critical', 'high', 'medium', 'low']; +const REQUIRED_DOC_KEYS = ['workflowGuide', 'readme', 'signingGuide', 'mergeConflictsGuide']; +const REQUIRED_COMMUNITY_KEYS = ['discordChannel']; + +/** + * Validates that team references are non-empty strings. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateTeams(config, errors) { + if (!isNonEmptyString(config.maintainerTeam)) { + errors.push('maintainerTeam must be a non-empty string'); + } + if (!isNonEmptyString(config.goodFirstIssueSupportTeam)) { + errors.push('goodFirstIssueSupportTeam must be a non-empty string'); + } +} + +/** + * Validates that labels.status, labels.skill, and labels.priority each exist + * as objects containing all required keys with non-empty string values. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateLabels(config, errors) { + if (!config.labels || typeof config.labels !== 'object') { + errors.push('labels must be an object'); + return; + } + const requiredKeysMap = { + status: REQUIRED_STATUS_KEYS, + skill: REQUIRED_SKILL_KEYS, + priority: REQUIRED_PRIORITY_KEYS, + }; + for (const [group, requiredKeys] of Object.entries(requiredKeysMap)) { + if (!config.labels[group] || typeof config.labels[group] !== 'object') { + errors.push(`labels.${group} must be an object`); + } else { + for (const key of requiredKeys) { + if (!isNonEmptyString(config.labels[group][key])) { + errors.push(`labels.${group}.${key} is required and must be a non-empty string`); + } + } + } + } +} + +/** + * Validates a single hierarchy array: non-empty, unique entries, + * and all values exist in the corresponding label group. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + * @param {string} hierarchyKey - 'skillHierarchy' or 'priorityHierarchy'. + * @param {string} labelGroup - 'skill' or 'priority' (key in config.labels). + */ +function validateSingleHierarchy(config, errors, hierarchyKey, labelGroup) { + const hierarchy = config[hierarchyKey]; + + if (!Array.isArray(hierarchy) || hierarchy.length === 0) { + errors.push(`${hierarchyKey} must be a non-empty array`); + return; + } + + const seen = new Set(); + for (const entry of hierarchy) { + if (seen.has(entry)) { + errors.push(`${hierarchyKey} entry "${entry}" appears more than once`); + } + seen.add(entry); + } + + if (config.labels && config.labels[labelGroup]) { + const labelValues = Object.values(config.labels[labelGroup]); + for (const entry of hierarchy) { + if (!labelValues.includes(entry)) { + errors.push(`${hierarchyKey} entry "${entry}" not found in labels.${labelGroup} values`); + } + } + } +} + +/** + * Validates that skillHierarchy and priorityHierarchy are non-empty arrays + * whose entries are unique and exist in the corresponding label group values. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateHierarchies(config, errors) { + validateSingleHierarchy(config, errors, 'skillHierarchy', 'skill'); + validateSingleHierarchy(config, errors, 'priorityHierarchy', 'priority'); +} + +/** + * Validates a single prerequisite object shape: requiredLabel, requiredCount, + * displayName, and prerequisiteDisplayName (when requiredLabel is not null). + * @param {string} key - The skillPrerequisites key being validated. + * @param {object} prereq - The prerequisite object. + * @param {string[]} hierarchy - The skillHierarchy array for cross-reference. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validatePrerequisiteShape(key, prereq, hierarchy, errors) { + if (!prereq || typeof prereq !== 'object') { + errors.push(`skillPrerequisites["${key}"] must be an object`); + return; + } + if (!('requiredLabel' in prereq)) { + errors.push(`skillPrerequisites["${key}"].requiredLabel is required (use null for no prerequisite)`); + } + if (!Number.isInteger(prereq.requiredCount) || prereq.requiredCount < 0) { + errors.push(`skillPrerequisites["${key}"].requiredCount must be a non-negative integer`); + } + if (!isNonEmptyString(prereq.displayName)) { + errors.push(`skillPrerequisites["${key}"].displayName is required and must be a non-empty string`); + } + if (prereq.requiredLabel !== null && !isNonEmptyString(prereq.prerequisiteDisplayName)) { + errors.push(`skillPrerequisites["${key}"].prerequisiteDisplayName is required when requiredLabel is not null`); + } + if (prereq.requiredLabel !== null && prereq.requiredLabel !== undefined && !hierarchy.includes(prereq.requiredLabel)) { + errors.push(`skillPrerequisites["${key}"].requiredLabel "${prereq.requiredLabel}" not found in skillHierarchy`); + } +} + +/** + * Validates skillPrerequisites: coverage (every hierarchy entry has a + * prerequisites entry), membership (every key is in the hierarchy), and + * individual prerequisite object shape. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateSkillPrerequisites(config, errors) { + if (!config.skillPrerequisites || typeof config.skillPrerequisites !== 'object') { + errors.push('skillPrerequisites must be an object'); + return; + } + if (!Array.isArray(config.skillHierarchy)) return; + + for (const skill of config.skillHierarchy) { + if (!config.skillPrerequisites[skill]) { + errors.push(`skillPrerequisites is missing entry for skillHierarchy value "${skill}"`); + } + } + for (const [key, prereq] of Object.entries(config.skillPrerequisites)) { + if (!config.skillHierarchy.includes(key)) { + errors.push(`skillPrerequisites key "${key}" not found in skillHierarchy`); + } + validatePrerequisiteShape(key, prereq, config.skillHierarchy, errors); + } +} + +/** + * Validates that assignmentLimits contains positive integer values for + * maxOpenAssignments and maxGfiCompletions. + * @param {object} config - The parsed config object. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateAssignmentLimits(config, errors) { + if (!config.assignmentLimits || typeof config.assignmentLimits !== 'object') { + errors.push('assignmentLimits must be an object'); + return; + } + if (!isPositiveInteger(config.assignmentLimits.maxOpenAssignments)) { + errors.push('assignmentLimits.maxOpenAssignments must be a positive integer'); + } + if (!isPositiveInteger(config.assignmentLimits.maxGfiCompletions)) { + errors.push('assignmentLimits.maxGfiCompletions must be a positive integer'); + } +} + +/** + * Validates that a config section exists as an object and contains all + * required keys as non-empty strings. + * @param {object} config - The parsed config object. + * @param {string} section - The top-level config key (e.g. 'documentation'). + * @param {string[]} requiredKeys - Keys that must be present with non-empty string values. + * @param {string[]} errors - Mutable array to push error messages into. + */ +function validateRequiredKeys(config, section, requiredKeys, errors) { + if (!config[section] || typeof config[section] !== 'object') { + errors.push(`${section} must be an object`); + return; + } + for (const key of requiredKeys) { + if (!isNonEmptyString(config[section][key])) { + errors.push(`${section}.${key} is required and must be a non-empty string`); + } + } +} + +/** + * Validates the parsed automation config object. Collects all violations + * and throws a single error listing every problem found. + * + * Checks: + * - teams are non-empty strings + * - labels.status, labels.skill, labels.priority exist with all required keys as non-empty strings + * - every skillHierarchy entry exists in labels.skill values + * - every priorityHierarchy entry exists in labels.priority values + * - every skillHierarchy entry has a corresponding skillPrerequisites entry + * - every skillPrerequisites key exists in skillHierarchy + * - every prerequisite object has requiredLabel, requiredCount, displayName + * - prerequisiteDisplayName is required when requiredLabel is not null + * - every non-null requiredLabel exists in skillHierarchy + * - assignment limits are positive integers + * - documentation has all required keys as non-empty strings + * - community has all required keys as non-empty strings + * + * @param {object} config - The parsed config object. + * @throws {Error} If any validation rule is violated. + */ +function validateConfig(config) { + const errors = []; + + validateTeams(config, errors); + validateLabels(config, errors); + validateHierarchies(config, errors); + validateSkillPrerequisites(config, errors); + validateAssignmentLimits(config, errors); + validateRequiredKeys(config, 'documentation', REQUIRED_DOC_KEYS, errors); + validateRequiredKeys(config, 'community', REQUIRED_COMMUNITY_KEYS, errors); + + if (errors.length > 0) { + throw new Error( + `Invalid kdm-automation.json:\n${errors.map((e) => ` - ${e}`).join('\n')}`, + ); + } +} + +/** + * Reads, parses, and validates the repository automation config file. + * + * @param {string} [configPath=DEFAULT_CONFIG_PATH] - Absolute path to the JSON config file. + * @returns {object} The validated, frozen config object. + * @throws {Error} If the file cannot be read, parsed, or fails validation. + */ +function loadAutomationConfig(configPath = DEFAULT_CONFIG_PATH) { + let raw; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch (err) { + throw new Error( + `Failed to read automation config at ${configPath}: ${err.message}`, + ); + } + + let config; + try { + config = JSON.parse(raw); + } catch (err) { + throw new Error( + `Failed to parse automation config at ${configPath}: ${err.message}`, + ); + } + + validateConfig(config); + return Object.freeze(config); +} + +/** + * Maps the nested config structure back into the flat constant shapes + * consumed by the rest of the bot scripts. The returned object contains + * every derived constant that was previously hardcoded in constants.js, + * assign-comments.js, finalize-comments.js, and comments.js. + * + * @param {object} config - A validated config object from loadAutomationConfig. + * @returns {{ + * MAINTAINER_TEAM: string, + * GFI_SUPPORT_TEAM: string, + * LABELS: object, + * SKILL_HIERARCHY: string[], + * PRIORITY_HIERARCHY: string[], + * SKILL_PREREQUISITES: object, + * DOCUMENTATION: object, + * COMMUNITY: object, + * }} + */ +function buildConstants(config) { + const LABELS = Object.freeze({ + // Status labels + AWAITING_TRIAGE: config.labels.status.awaitingTriage, + READY_FOR_DEV: config.labels.status.readyForDev, + IN_PROGRESS: config.labels.status.inProgress, + BLOCKED: config.labels.status.blocked, + NEEDS_REVIEW: config.labels.status.needsReview, + NEEDS_REVISION: config.labels.status.needsRevision, + + // Skill level labels + GOOD_FIRST_ISSUE: config.labels.skill.goodFirstIssue, + BEGINNER: config.labels.skill.beginner, + INTERMEDIATE: config.labels.skill.intermediate, + ADVANCED: config.labels.skill.advanced, + + // Priority labels + PRIORITY_CRITICAL: config.labels.priority.critical, + PRIORITY_HIGH: config.labels.priority.high, + PRIORITY_MEDIUM: config.labels.priority.medium, + PRIORITY_LOW: config.labels.priority.low, + }); + + const SKILL_HIERARCHY = Object.freeze([...config.skillHierarchy]); + const PRIORITY_HIERARCHY = Object.freeze([...config.priorityHierarchy]); + + const SKILL_PREREQUISITES = {}; + for (const [key, value] of Object.entries(config.skillPrerequisites)) { + SKILL_PREREQUISITES[key] = Object.freeze({ ...value }); + } + Object.freeze(SKILL_PREREQUISITES); + + return { + MAINTAINER_TEAM: config.maintainerTeam, + GFI_SUPPORT_TEAM: config.goodFirstIssueSupportTeam, + LABELS, + SKILL_HIERARCHY, + PRIORITY_HIERARCHY, + SKILL_PREREQUISITES, + DOCUMENTATION: Object.freeze({ ...config.documentation }), + COMMUNITY: Object.freeze({ ...config.community }), + }; +} + +module.exports = { + DEFAULT_CONFIG_PATH, + loadAutomationConfig, + buildConstants, +}; diff --git a/.github/scripts/helpers/constants.js b/.github/scripts/helpers/constants.js new file mode 100644 index 0000000..11f3415 --- /dev/null +++ b/.github/scripts/helpers/constants.js @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/constants.js +// +// Shared constants for bot scripts: maintainer team, labels, issue state. + +const { loadAutomationConfig, buildConstants } = require('./config-loader'); + +/** + * Parsed and validated automation config loaded from .github/kdm-automation.json. + * Exposed for modules that need access to nested config values (e.g. assignment limits). + */ +const AUTOMATION_CONFIG = loadAutomationConfig(); + +/** + * Derived constants built from the automation config. Preserves the flat + * constant shapes (MAINTAINER_TEAM, LABELS, SKILL_HIERARCHY, etc.) that + * the rest of the bot scripts expect. + */ +const derived = buildConstants(AUTOMATION_CONFIG); + +/** + * Team to tag when manual intervention is needed. + */ +const MAINTAINER_TEAM = derived.MAINTAINER_TEAM; + +/** + * Team to tag in Good First Issue welcome comments. + */ +const GFI_SUPPORT_TEAM = derived.GFI_SUPPORT_TEAM; + +/** + * Common label constants used across bot scripts. + */ +const LABELS = derived.LABELS; + +/** + * Skill hierarchy used to determine progression for recommendations. + */ +const SKILL_HIERARCHY = derived.SKILL_HIERARCHY; + +/** + * Priority hierarchy for issue recommendations. + */ +const PRIORITY_HIERARCHY = derived.PRIORITY_HIERARCHY; + +/** + * Issue state values for GitHub search queries. + */ +const ISSUE_STATE = Object.freeze({ + OPEN: 'open', + CLOSED: 'closed', +}); + +/** + * Skill-level prerequisite map. Each key is a LABELS skill-level constant. + * - requiredLabel: the prerequisite skill label the user must have completed, or null if none. + * - requiredCount: how many closed issues with requiredLabel the user needs. + * - displayName: human-readable name for the current skill level. + * - prerequisiteDisplayName: human-readable plural name for the prerequisite level (used in comments). + * + * Progression: Good First Issue (no prereqs) -> Beginner (2 GFI) -> Intermediate (3 Beginner) -> Advanced (3 Intermediate). + * @type {Object} + */ +const SKILL_PREREQUISITES = derived.SKILL_PREREQUISITES; + +/** + * Documentation links loaded from the automation config. + * @type {{ workflowGuide: string, readme: string, signingGuide: string, mergeConflictsGuide: string }} + */ +const DOCUMENTATION = derived.DOCUMENTATION; + +/** + * Community links loaded from the automation config. + * @type {{ discordChannel: string }} + */ +const COMMUNITY = derived.COMMUNITY; + +module.exports = { + MAINTAINER_TEAM, + GFI_SUPPORT_TEAM, + LABELS, + ISSUE_STATE, + SKILL_HIERARCHY, + SKILL_PREREQUISITES, + PRIORITY_HIERARCHY, + DOCUMENTATION, + COMMUNITY, + AUTOMATION_CONFIG, +}; diff --git a/.github/scripts/helpers/index.js b/.github/scripts/helpers/index.js new file mode 100644 index 0000000..8674711 --- /dev/null +++ b/.github/scripts/helpers/index.js @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/index.js +// +// Single entry point for bot helpers. Re-exports constants, logger, validation, +// API, checks, and comments. + +const constants = require('./constants'); +const logger = require('./logger'); +const validation = require('./validation'); +const api = require('./api'); +const checks = require('./checks'); +const comments = require('./comments'); + +module.exports = { + ...constants, + ...logger, + ...validation, + ...api, + ...checks, + ...comments, +}; diff --git a/.github/scripts/helpers/logger.js b/.github/scripts/helpers/logger.js new file mode 100644 index 0000000..133c8ee --- /dev/null +++ b/.github/scripts/helpers/logger.js @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/logger.js +// +// Logger factory and getter for bot scripts. Helpers use getLogger() so they +// log with the current bot's prefix. + +/** @private Logger instance used by getLogger(); set when createLogger() is called. */ +let _logger = null; + +/** + * Creates a logger with a consistent prefix for bot scripts. + * Also sets the module logger so getLogger() returns this instance. + * @param {string} botName - The name of the bot (e.g., 'on-commit', 'on-pr', 'on-comment'). + * @returns {object} - Logger object with log and error methods. + */ +function createLogger(botName) { + const prefix = `[${botName}]`; + const logger = { + log: (...args) => console.log(prefix, ...args), + error: (...args) => console.error(prefix, ...args), + }; + _logger = logger; + return logger; +} + +/** + * Returns the logger used by helper functions (writeGithubOutput, postComment, etc.). + * Returns the logger last set by createLogger(), or a default logger if none set yet. + * @returns {{ log: function, error: function }} + */ +function getLogger() { + if (!_logger) _logger = createLogger('bot-helpers'); + return _logger; +} + +/** + * Returns a logger proxy that always delegates to whichever logger is currently + * active (i.e., the one last set by createLogger). Use this in command modules + * so log calls automatically pick up the prefix set by the dispatcher. + * @returns {{ log: function, error: function }} + */ +function createDelegatingLogger() { + return { + log: (...args) => getLogger().log(...args), + error: (...args) => getLogger().error(...args), + }; +} + +module.exports = { + createLogger, + getLogger, + createDelegatingLogger, +}; diff --git a/.github/scripts/helpers/validation.js b/.github/scripts/helpers/validation.js new file mode 100644 index 0000000..ef6e4b1 --- /dev/null +++ b/.github/scripts/helpers/validation.js @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// helpers/validation.js +// +// Validation helpers for bot scripts. Only functions that need two or more checks +// (e.g. not null and type, or type and format) are provided here. + +/** + * Returns true if value is a non-null object (including arrays). + * Two checks: not null and type object. + * @param {*} value + * @returns {boolean} + */ +function isObject(value) { + return value !== null && typeof value === 'object'; +} + +/** + * Returns true if value is an integer >= 0. + * Two checks: is integer and non-negative. + * @param {*} value + * @returns {boolean} + */ +function isNonNegativeInteger(value) { + return Number.isInteger(value) && value >= 0; +} + +/** + * Returns true if value is a string safe for GitHub search queries (type and format). + * Allows standard alphanumeric characters, `-`, `_`, `/`, `.`, and the optional + * GitHub bot suffix `[bot]` (e.g. `dependabot[bot]`). + * Two checks: is string and matches safe character set. + * @param {*} value + * @returns {boolean} + */ +function isSafeSearchToken(value) { + return typeof value === 'string' && /^[a-zA-Z0-9._/-]+(\[bot\])?$/.test(value); +} + +/** + * Throws if value is not a non-null object. + * @param {*} value - Value to check. + * @param {string} label - Label for error message. + * @throws {Error} + */ +function requireObject(value, label) { + if (!isObject(value)) { + throw new Error(`Bot context invalid: missing or invalid ${label}`); + } +} + +/** + * Throws if value is not a non-empty string (type and non-empty after trim). + * @param {*} value - Value to check. + * @param {string} label - Label for error message. + * @throws {Error} + */ +function requireNonEmptyString(value, label) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Bot context invalid: missing or invalid ${label}`); + } +} + +/** + * Throws if value is not a positive integer. + * @param {*} value - Value to check. + * @param {string} label - Label for error message. + * @throws {Error} + */ +function requirePositiveInt(value, label) { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`Bot context invalid: missing or invalid ${label}`); + } +} + +/** + * Throws if value is not a non-empty string safe for use as a GitHub username (e.g. in API calls or search). + * @param {*} value - Value to check. + * @param {string} label - Label for error message. + * @throws {Error} + */ +function requireSafeUsername(value, label) { + requireNonEmptyString(value, label); + if (!isSafeSearchToken(value)) { + throw new Error(`Bot context invalid: ${label} contains invalid characters`); + } +} + +module.exports = { + isNonNegativeInteger, + isSafeSearchToken, + requireObject, + requireNonEmptyString, + requirePositiveInt, + requireSafeUsername, +}; diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json new file mode 100644 index 0000000..6fdee11 --- /dev/null +++ b/.github/scripts/package-lock.json @@ -0,0 +1,1086 @@ +{ + "name": "kdm-cli-bot-scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kdm-cli-bot-scripts", + "devDependencies": { + "eslint": "^9.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 0000000..14c8f72 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,7 @@ +{ + "name": "kdm-cli-org-bot-scripts", + "private": true, + "devDependencies": { + "eslint": "^9.0.0" + } +} diff --git a/.github/scripts/tests/test-api.js b/.github/scripts/tests/test-api.js new file mode 100644 index 0000000..a45088d --- /dev/null +++ b/.github/scripts/tests/test-api.js @@ -0,0 +1,850 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-api.js +// +// Unit tests for helpers/api.js (postOrUpdateComment, fetchPRCommits, swapStatusLabel, etc.). +// Run with: node .github/scripts/tests/test-api.js + +const { runTestSuite } = require('./test-utils'); +const { + getBotComment, + postOrUpdateComment, + fetchPRCommits, + fetchOpenPRs, + fetchIssue, + fetchClosingIssueNumbers, + fetchLatestMilestone, + setMilestone, + swapStatusLabel, + hasLabel, + resolveLinkedIssue, + getHighestIssueSkillLevel, +} = require('../helpers/api'); +const { LABELS } = require('../helpers/constants'); +const { isSafeSearchToken } = require('../helpers/validation'); + +// ============================================================================= +// MOCK FACTORY +// ============================================================================= + +function createMockBotContext(overrides = {}) { + const calls = { + created: [], + updated: [], + labelsAdded: [], + labelsRemoved: [], + milestonesUpdated: [], + }; + const comments = overrides.comments || []; + return { + botContext: { + github: { + rest: { + issues: { + listComments: async ({ page, per_page }) => { + const start = (page - 1) * per_page; + const slice = comments.slice(start, start + per_page); + return { data: slice }; + }, + createComment: async (params) => { + calls.created.push(params); + }, + updateComment: async (params) => { + calls.updated.push(params); + }, + addLabels: async (params) => { + calls.labelsAdded.push(params.labels); + }, + removeLabel: async (params) => { + calls.labelsRemoved.push(params.name); + }, + listMilestones: async ({ page, per_page }) => { + const allMilestones = overrides.milestones || []; + const start = (page - 1) * per_page; + const slice = allMilestones.slice(start, start + per_page); + return { data: slice }; + }, + update: async (params) => { + calls.milestonesUpdated.push(params); + return {}; + }, + get: async ({ issue_number }) => { + const issue = (overrides.issues || {})[issue_number]; + if (!issue) throw new Error('Not Found'); + return { data: issue }; + }, + }, + pulls: { + list: async ({ page, per_page }) => { + const allPRs = overrides.openPRs || []; + const start = (page - 1) * per_page; + const slice = allPRs.slice(start, start + per_page); + return { data: slice }; + }, + listCommits: async ({ page, per_page }) => { + const allCommits = overrides.commits || []; + const start = (page - 1) * per_page; + const slice = allCommits.slice(start, start + per_page); + return { data: slice }; + }, + }, + }, + graphql: + overrides.graphql || + (async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { nodes: [] }, + }, + }, + })), + }, + owner: 'test', + repo: 'repo', + number: 1, + pr: overrides.pr || { labels: [] }, + }, + calls, + }; +} + +// ============================================================================= +// UNIT TESTS +// ============================================================================= + +const unitTests = [ + // --------------------------------------------------------------------------- + // hasLabel + // --------------------------------------------------------------------------- + { + name: 'hasLabel: PR with matching label object → true', + test: () => { + const pr = { + labels: [{ name: 'status: needs review' }], + }; + return hasLabel(pr, LABELS.NEEDS_REVIEW) === true; + }, + }, + { + name: 'hasLabel: PR with no matching label → false', + test: () => { + const pr = { + labels: [{ name: 'bug' }, { name: 'enhancement' }], + }; + return hasLabel(pr, LABELS.NEEDS_REVIEW) === false; + }, + }, + { + name: 'hasLabel: PR with no labels → false', + test: () => { + const pr = { labels: [] }; + return hasLabel(pr, LABELS.NEEDS_REVIEW) === false; + }, + }, + { + name: 'hasLabel: PR with null/undefined labels → false', + test: () => { + return ( + hasLabel({ labels: null }, LABELS.NEEDS_REVIEW) === false && + hasLabel({}, LABELS.NEEDS_REVIEW) === false + ); + }, + }, + { + name: 'hasLabel: case insensitive match → true', + test: () => { + const pr = { + labels: [{ name: 'STATUS: NEEDS REVIEW' }], + }; + return hasLabel(pr, 'status: needs review') === true; + }, + }, + { + name: 'hasLabel: string labels → true', + test: () => { + const pr = { + labels: ['status: needs review', 'bug'], + }; + return hasLabel(pr, LABELS.NEEDS_REVIEW) === true; + }, + }, + + // --------------------------------------------------------------------------- + // getBotComment + // --------------------------------------------------------------------------- + { + name: 'getBotComment: returns comment matched by marker', + test: async () => { + const marker = ''; + const { botContext } = createMockBotContext({ + comments: [ + { id: 1, body: 'User comment 1' }, + { id: 2, body: '\nBot comment' }, + ], + }); + const result = await getBotComment(botContext, marker); + return result !== null && result.id === 2; + }, + }, + { + name: 'getBotComment: returns null if no marker match', + test: async () => { + const marker = ''; + const { botContext } = createMockBotContext({ + comments: [ + { id: 1, body: 'User comment 1' }, + ], + }); + const result = await getBotComment(botContext, marker); + return result === null; + }, + }, + { + name: 'getBotComment: searches across pages', + test: async () => { + const marker = ''; + const page1 = Array(100).fill(null).map((_, i) => ({ id: i + 1, body: `Comment ${i}` })); + const page2 = [{ id: 101, body: '\nFound on page 2' }]; + const { botContext } = createMockBotContext({ + comments: [...page1, ...page2], + }); + const result = await getBotComment(botContext, marker); + return result !== null && result.id === 101; + }, + }, + + // --------------------------------------------------------------------------- + // postOrUpdateComment + // --------------------------------------------------------------------------- + { + name: 'postOrUpdateComment: no existing comment → creates new', + test: async () => { + const { botContext, calls } = createMockBotContext({ + comments: [], + }); + const marker = ''; + const body = '\nHello'; + const result = await postOrUpdateComment(botContext, marker, body); + return ( + result.success === true && + calls.created.length === 1 && + calls.updated.length === 0 && + calls.created[0].body === body + ); + }, + }, + { + name: 'postOrUpdateComment: existing comment with marker → updates', + test: async () => { + const marker = ''; + const { botContext, calls } = createMockBotContext({ + comments: [ + { id: 999, body: '\nOld content' }, + ], + }); + const body = '\nNew content'; + const result = await postOrUpdateComment(botContext, marker, body); + return ( + result.success === true && + calls.created.length === 0 && + calls.updated.length === 1 && + calls.updated[0].comment_id === 999 && + calls.updated[0].body === body + ); + }, + }, + { + name: 'postOrUpdateComment: multiple comments, one has marker → updates correct one', + test: async () => { + const marker = ''; + const { botContext, calls } = createMockBotContext({ + comments: [ + { id: 1, body: 'User comment 1' }, + { id: 2, body: '\nBot comment' }, + { id: 3, body: 'User comment 2' }, + ], + }); + const body = '\nUpdated bot'; + const result = await postOrUpdateComment(botContext, marker, body); + return ( + result.success === true && + calls.updated.length === 1 && + calls.updated[0].comment_id === 2 + ); + }, + }, + { + name: 'postOrUpdateComment: empty comment list → creates new', + test: async () => { + const { botContext, calls } = createMockBotContext({ + comments: [], + }); + const result = await postOrUpdateComment( + botContext, + '', + '\nEmpty' + ); + return result.success === true && calls.created.length === 1 && calls.updated.length === 0; + }, + }, + { + name: 'postOrUpdateComment: comment on second page → finds and updates', + test: async () => { + const marker = ''; + const page1 = Array(100) + .fill(null) + .map((_, i) => ({ id: i + 1, body: `Comment ${i}` })); + const page2 = [ + { id: 101, body: '\nFound on page 2' }, + ]; + const { botContext, calls } = createMockBotContext({ + comments: [...page1, ...page2], + }); + const body = '\nUpdated'; + const result = await postOrUpdateComment(botContext, marker, body); + return ( + result.success === true && + calls.updated.length === 1 && + calls.updated[0].comment_id === 101 + ); + }, + }, + + // --------------------------------------------------------------------------- + // fetchPRCommits + // --------------------------------------------------------------------------- + { + name: 'fetchPRCommits: single page (< 100 commits) → returns all', + test: async () => { + const commits = [ + { sha: 'a1', commit: { message: 'First' } }, + { sha: 'b2', commit: { message: 'Second' } }, + ]; + const { botContext } = createMockBotContext({ commits }); + const result = await fetchPRCommits(botContext); + return ( + Array.isArray(result) && + result.length === 2 && + result[0].sha === 'a1' && + result[1].sha === 'b2' + ); + }, + }, + { + name: 'fetchPRCommits: multiple pages → paginates and returns all', + test: async () => { + const commits = Array(150) + .fill(null) + .map((_, i) => ({ sha: `c${i}`, commit: { message: `Commit ${i}` } })); + const { botContext } = createMockBotContext({ commits }); + const result = await fetchPRCommits(botContext); + return result.length === 150; + }, + }, + { + name: 'fetchPRCommits: empty PR → returns []', + test: async () => { + const { botContext } = createMockBotContext({ commits: [] }); + const result = await fetchPRCommits(botContext); + return Array.isArray(result) && result.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // fetchOpenPRs + // --------------------------------------------------------------------------- + { + name: 'fetchOpenPRs: single page (< 100 PRs) → returns all', + test: async () => { + const openPRs = [ + { number: 1, title: 'First PR' }, + { number: 2, title: 'Second PR' }, + ]; + const { botContext } = createMockBotContext({ openPRs }); + const result = await fetchOpenPRs(botContext); + return ( + Array.isArray(result) && + result.length === 2 && + result[0].number === 1 && + result[1].number === 2 + ); + }, + }, + { + name: 'fetchOpenPRs: multiple pages → paginates and returns all', + test: async () => { + const openPRs = Array(150) + .fill(null) + .map((_, i) => ({ number: i + 1, title: `PR ${i + 1}` })); + const { botContext } = createMockBotContext({ openPRs }); + const result = await fetchOpenPRs(botContext); + return result.length === 150; + }, + }, + { + name: 'fetchOpenPRs: zero PRs → returns []', + test: async () => { + const { botContext } = createMockBotContext({ openPRs: [] }); + const result = await fetchOpenPRs(botContext); + return Array.isArray(result) && result.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // fetchIssue + // --------------------------------------------------------------------------- + { + name: 'fetchIssue: valid issue → returns issue data', + test: async () => { + const issueData = { number: 5, title: 'Bug report', state: 'open' }; + const { botContext } = createMockBotContext({ + issues: { 5: issueData }, + }); + const result = await fetchIssue(botContext, 5); + return result.number === 5 && result.title === 'Bug report'; + }, + }, + { + name: 'fetchIssue: missing issue (API throws) → throws', + test: async () => { + const { botContext } = createMockBotContext({ issues: {} }); + try { + await fetchIssue(botContext, 999); + return false; + } catch (err) { + return err.message === 'Not Found'; + } + }, + }, + + // --------------------------------------------------------------------------- + // fetchClosingIssueNumbers + // --------------------------------------------------------------------------- + { + name: 'fetchClosingIssueNumbers: 1 closing reference → returns [number]', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 42 }], + }, + }, + }, + }), + }); + const result = await fetchClosingIssueNumbers(botContext); + return result.length === 1 && result[0] === 42; + }, + }, + { + name: 'fetchClosingIssueNumbers: 0 references → returns []', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { nodes: [] }, + }, + }, + }), + }); + const result = await fetchClosingIssueNumbers(botContext); + return Array.isArray(result) && result.length === 0; + }, + }, + { + name: 'fetchClosingIssueNumbers: multiple references → returns all', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 1 }, { number: 2 }, { number: 3 }], + }, + }, + }, + }), + }); + const result = await fetchClosingIssueNumbers(botContext); + return ( + result.length === 3 && + result[0] === 1 && + result[1] === 2 && + result[2] === 3 + ); + }, + }, + { + name: 'fetchClosingIssueNumbers: GraphQL fails → returns [] (graceful)', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => { + throw new Error('GraphQL error'); + }, + }); + const result = await fetchClosingIssueNumbers(botContext); + return Array.isArray(result) && result.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // fetchLatestMilestone + // --------------------------------------------------------------------------- + { + name: 'fetchLatestMilestone: milestones with due dates → returns latest due date', + test: async () => { + const { botContext } = createMockBotContext({ + milestones: [ + { number: 1, title: 'Earlier', due_on: '2026-05-01T00:00:00Z' }, + { number: 2, title: 'Later', due_on: '2026-06-01T00:00:00Z' }, + { number: 3, title: 'No due date', due_on: null }, + ], + }); + const result = await fetchLatestMilestone(botContext); + return result !== null && result.number === 2; + }, + }, + { + name: 'fetchLatestMilestone: no due dates → returns highest milestone number', + test: async () => { + const { botContext } = createMockBotContext({ + milestones: [ + { number: 4, title: 'Older', due_on: null }, + { number: 7, title: 'Newest', due_on: null }, + { number: 5, title: 'Middle', due_on: null }, + ], + }); + const result = await fetchLatestMilestone(botContext); + return result !== null && result.number === 7; + }, + }, + { + name: 'fetchLatestMilestone: no open milestones → returns null', + test: async () => { + const { botContext } = createMockBotContext({ milestones: [] }); + const result = await fetchLatestMilestone(botContext); + return result === null; + }, + }, + + // --------------------------------------------------------------------------- + // setMilestone + // --------------------------------------------------------------------------- + { + name: 'setMilestone: updates issue milestone', + test: async () => { + const { botContext, calls } = createMockBotContext(); + const result = await setMilestone(botContext, 42, 9); + return ( + result.success === true && + calls.milestonesUpdated.length === 1 && + calls.milestonesUpdated[0].issue_number === 42 && + calls.milestonesUpdated[0].milestone === 9 + ); + }, + }, + + // --------------------------------------------------------------------------- + // resolveLinkedIssue + // --------------------------------------------------------------------------- + { + name: 'resolveLinkedIssue: no linked issues → returns null', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { nodes: [] }, + }, + }, + }), + }); + const result = await resolveLinkedIssue(botContext); + return result === null; + }, + }, + { + name: 'resolveLinkedIssue: single linked issue with skill label → returns it', + test: async () => { + const issueData = { number: 10, title: 'Fix bug', labels: [{ name: LABELS.BEGINNER }] }; + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { nodes: [{ number: 10 }] }, + }, + }, + }), + issues: { 10: issueData }, + }); + const result = await resolveLinkedIssue(botContext); + return result !== null && result.number === 10; + }, + }, + { + name: 'resolveLinkedIssue: single linked issue with no skill label → returns null', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 7 }], + }, + }, + }, + }), + issues: { + 7: { number: 7, title: 'Issue 7', labels: [{ name: 'bug' }] }, + }, + }); + const result = await resolveLinkedIssue(botContext); + return result === null; + }, + }, + { + name: 'resolveLinkedIssue: multiple linked issues with skill label → returns highest skill level', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 1 }, { number: 2 }, { number: 3 }], + }, + }, + }, + }), + issues: { + 1: { number: 1, title: 'GFI issue', labels: [{ name: LABELS.GOOD_FIRST_ISSUE }] }, + 2: { number: 2, title: 'Intermediate issue', labels: [{ name: LABELS.INTERMEDIATE }] }, + 3: { number: 3, title: 'Beginner issue', labels: [{ name: LABELS.BEGINNER }] }, + }, + }); + const result = await resolveLinkedIssue(botContext); + return result !== null && result.number === 2; // INTERMEDIATE is highest + }, + }, + { + name: 'resolveLinkedIssue: multiple linked issues with no skill label → returns null', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 4 }, { number: 5 }], + }, + }, + }, + }), + issues: { + 4: { number: 4, title: 'Issue 4', labels: [{ name: 'bug' }] }, + 5: { number: 5, title: 'Issue 5', labels: [{ name: 'enhancement' }] }, + }, + }); + const result = await resolveLinkedIssue(botContext); + return result === null; + }, + }, + { + name: 'resolveLinkedIssue: GraphQL fails → returns null gracefully', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => { throw new Error('GraphQL error'); }, + }); + const result = await resolveLinkedIssue(botContext); + return result === null; + }, + }, + { + name: 'resolveLinkedIssue: issue fetch fails for all linked issues → returns null', + test: async () => { + const { botContext } = createMockBotContext({ + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [{ number: 999 }, { number: 998 }], + }, + }, + }, + }), + issues: {}, // no issues → fetchIssue throws for all + }); + const result = await resolveLinkedIssue(botContext); + return result === null; + }, + }, + + // --------------------------------------------------------------------------- + // swapStatusLabel + // --------------------------------------------------------------------------- + { + name: 'swapStatusLabel: allPassed true, has NEEDS_REVISION → removes revision, adds review', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [{ name: LABELS.NEEDS_REVISION }] }, + }); + await swapStatusLabel(botContext, true); + return ( + calls.labelsRemoved.length === 1 && + calls.labelsRemoved[0] === LABELS.NEEDS_REVISION && + calls.labelsAdded.length === 1 && + Array.isArray(calls.labelsAdded[0]) && + calls.labelsAdded[0][0] === LABELS.NEEDS_REVIEW + ); + }, + }, + { + name: 'swapStatusLabel: allPassed true, has NEEDS_REVIEW → no-op', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [{ name: LABELS.NEEDS_REVIEW }] }, + }); + await swapStatusLabel(botContext, true); + return calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0; + }, + }, + { + name: 'swapStatusLabel: allPassed true, no status label → no-op', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [{ name: 'bug' }] }, + }); + await swapStatusLabel(botContext, true); + return calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0; + }, + }, + { + name: 'swapStatusLabel: allPassed false, has NEEDS_REVIEW → removes review, adds revision', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [{ name: LABELS.NEEDS_REVIEW }] }, + }); + await swapStatusLabel(botContext, false); + return ( + calls.labelsRemoved.length === 1 && + calls.labelsRemoved[0] === LABELS.NEEDS_REVIEW && + calls.labelsAdded.length === 1 && + Array.isArray(calls.labelsAdded[0]) && + calls.labelsAdded[0][0] === LABELS.NEEDS_REVISION + ); + }, + }, + { + name: 'swapStatusLabel: allPassed false, has NEEDS_REVISION → no-op', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [{ name: LABELS.NEEDS_REVISION }] }, + }); + await swapStatusLabel(botContext, false); + return calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0; + }, + }, + { + name: 'swapStatusLabel: allPassed false, no status label → no-op', + test: async () => { + const { botContext, calls } = createMockBotContext({ + pr: { labels: [] }, + }); + await swapStatusLabel(botContext, false); + return calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // SafeSearchToken + // --------------------------------------------------------------------------- + { + name: 'isSafeSearchToken: dependabot[bot] → true', + test: () => isSafeSearchToken('dependabot[bot]') === true, + }, + { + name: 'isSafeSearchToken: string with spaces → false', + test: () => isSafeSearchToken('bad username') === false, + }, + { + name: 'isSafeSearchToken: string with angle brackets → false', + test: () => isSafeSearchToken('bad') === false, + }, + { + name: 'isSafeSearchToken: string with semicolon → false', + test: () => isSafeSearchToken('bad;username') === false, + }, + { + name: 'isSafeSearchToken: string with brackets but not bot inside → false', + test: () => isSafeSearchToken('bad[admin]') === false, + }, + { + name: 'isSafeSearchToken: string with multiple brackets → false', + test: () => isSafeSearchToken('bad[[admin]') === false, + }, + + // --------------------------------------------------------------------------- + // getHighestIssueSkillLevel + // --------------------------------------------------------------------------- + { + name: 'getHighestIssueSkillLevel: issue with one skill label → returns that level', + test: () => { + const issue = { number: 1, title: 'Test', labels: [{ name: LABELS.BEGINNER }, { name: LABELS.READY_FOR_DEV }] }; + return getHighestIssueSkillLevel(issue) === LABELS.BEGINNER; + }, + }, + { + name: 'getHighestIssueSkillLevel: issue with no skill labels → returns null', + test: () => { + const issue = { number: 1, title: 'Test', labels: [{ name: 'bug' }, { name: 'enhancement' }] }; + return getHighestIssueSkillLevel(issue) === null; + }, + }, + { + name: 'getHighestIssueSkillLevel: issue with multiple skill labels → returns highest', + test: () => { + const issue = { number: 1, title: 'Test', labels: [{ name: LABELS.GOOD_FIRST_ISSUE }, { name: LABELS.BEGINNER }, { name: LABELS.INTERMEDIATE }] }; + return getHighestIssueSkillLevel(issue) === LABELS.INTERMEDIATE; + }, + }, + { + name: 'getHighestIssueSkillLevel: issue with empty labels → returns null', + test: () => { + const issue = { number: 1, title: 'Test', labels: [] }; + return getHighestIssueSkillLevel(issue) === null; + }, + }, +]; +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runUnitTests() { + console.log('🔬 UNIT TESTS (api)'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (const test of unitTests) { + try { + const result = await Promise.resolve(test.test()); + if (result) { + console.log(`✅ ${test.name}`); + passed++; + } else { + console.log(`❌ ${test.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${test.name} - Error: ${error.message}`); + failed++; + } + } + console.log('\n' + '-'.repeat(70)); + console.log(`Unit Tests: ${passed} passed, ${failed} failed`); + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('API HELPERS TEST SUITE', [], async () => true, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-assign-bot.js b/.github/scripts/tests/test-assign-bot.js new file mode 100644 index 0000000..20c5186 --- /dev/null +++ b/.github/scripts/tests/test-assign-bot.js @@ -0,0 +1,1867 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-assign-bot.js +// +// Local test script for bot-on-comment.js +// Run with: node .github/scripts/tests/test-assign-bot.js +// +// This script mocks the GitHub API and runs various test scenarios +// to verify the on-comment (assign) bot behaves correctly without making real API calls. + +const { LABELS } = require("../helpers"); +const script = require("../bot-on-comment.js"); + +// ============================================================================= +// MOCK GITHUB API +// ============================================================================= + +function createMockGithub(options = {}, issueFromPayload = null) { + const { + completedIssueCount = 0, + completedIssueCounts = {}, + openAssignmentCount = 0, + openAssignmentCountExcludingBlocked = openAssignmentCount, + blockedIssueCount = 0, + restListClosedShouldFail = false, + restListOpenShouldFail = false, + assignShouldFail = false, + removeLabelShouldFail = false, + addLabelShouldFail = false, + reactionShouldFail = false, + issueGetShouldFail = false, + issueAssignees = null, + issueLabels = null, + openAssignedIssues = null, + searchResults = null, + searchShouldFail = false, + restListOpenFailOnCall = null, + } = options; + + let openListForRepoCallCount = 0; + + const calls = { + comments: [], + assignees: [], + labelsAdded: [], + labelsRemoved: [], + restCalls: [], + reactions: [], + }; + + return { + calls, + rest: { + reactions: { + createForIssueComment: async (params) => { + if (reactionShouldFail) { + throw new Error( + "Simulated reaction failure: comment not found (404)", + ); + } + calls.reactions.push({ + commentId: params.comment_id, + content: params.content, + }); + console.log(`\n👍 REACTION ADDED: ${params.content}`); + }, + }, + issues: { + createComment: async (params) => { + calls.comments.push(params.body); + console.log("\n📝 COMMENT POSTED:"); + console.log("─".repeat(60)); + console.log(params.body); + console.log("─".repeat(60)); + }, + addAssignees: async (params) => { + if (assignShouldFail) { + throw new Error("Simulated assignment failure"); + } + calls.assignees.push(...params.assignees); + console.log(`\n✅ ASSIGNED: ${params.assignees.join(", ")}`); + }, + addLabels: async (params) => { + if (addLabelShouldFail) { + throw new Error("Simulated add label failure"); + } + calls.labelsAdded.push(...params.labels); + console.log(`\n🏷️ LABEL ADDED: ${params.labels.join(", ")}`); + }, + removeLabel: async (params) => { + if (removeLabelShouldFail) { + throw new Error("Simulated remove label failure"); + } + calls.labelsRemoved.push(params.name); + console.log(`\n🏷️ LABEL REMOVED: ${params.name}`); + }, + get: async (params) => { + console.log( + `\n🔍 REST API CALL: get issue_number=${params.issue_number}`, + ); + if (issueGetShouldFail) { + throw new Error("Simulated issues.get failure"); + } + const freshLabels = Array.isArray(issueLabels) + ? issueLabels + : Array.isArray(issueFromPayload?.labels) + ? issueFromPayload.labels + : []; + if (Array.isArray(issueAssignees)) { + return { + data: { + assignees: issueAssignees.map((login) => ({ login })), + labels: freshLabels, + }, + }; + } + if (options.issueAlreadyAssignedTo) { + return { + data: { + assignees: [{ login: options.issueAlreadyAssignedTo }], + labels: freshLabels, + }, + }; + } + return { data: { assignees: [], labels: freshLabels } }; + }, + listForRepo: async (params) => { + calls.restCalls.push( + `REST listForRepo: state=${params.state} assignee=${params.assignee}`, + ); + console.log( + `\n🔍 REST API CALL: listForRepo state=${params.state} assignee=${params.assignee}`, + ); + + if (params.state === "open") { + openListForRepoCallCount++; + if (restListOpenShouldFail) { + throw new Error( + "Simulated REST API failure for open assignments", + ); + } + if (restListOpenFailOnCall === openListForRepoCallCount) { + throw new Error( + "Simulated REST API failure for open assignments", + ); + } + if (openAssignedIssues) { + if (params.labels) { + return { + data: openAssignedIssues.filter((issue) => + issue.labels?.some((l) => l.name === params.labels), + ), + }; + } + return { data: openAssignedIssues }; + } + const effectiveCount = openAssignmentCountExcludingBlocked; + const issues = []; + for (let i = 0; i < effectiveCount; i++) { + issues.push({ + number: 9000 + i, + labels: [{ name: "status: ready for dev" }], + }); + } + const blockedToGenerate = Math.max( + blockedIssueCount, + openAssignmentCount - openAssignmentCountExcludingBlocked, + ); + for (let i = 0; i < blockedToGenerate; i++) { + issues.push({ + number: 9100 + i, + labels: [{ name: LABELS.BLOCKED }], + }); + } + const difference = + openAssignmentCount - + (openAssignmentCountExcludingBlocked + blockedToGenerate); + if (difference > 0) { + for (let i = 0; i < difference; i++) { + issues.push({ number: 9200 + i, labels: [] }); + } + } + if (params.labels) { + return { + data: issues.filter((issue) => + issue.labels?.some((l) => l.name === params.labels), + ), + }; + } + return { data: issues }; + } + + if (params.state === "closed") { + if (restListClosedShouldFail) { + throw new Error("Simulated REST API failure"); + } + const issues = []; + for (const [labelName, count] of Object.entries( + completedIssueCounts, + )) { + for (let i = 0; i < count; i++) { + issues.push({ labels: [{ name: labelName }] }); + } + } + for (let i = 0; i < completedIssueCount; i++) { + issues.push({ labels: [] }); + } + if (params.labels) { + return { + data: issues.filter((issue) => + issue.labels?.some((l) => l.name === params.labels), + ), + }; + } + return { data: issues }; + } + + return { data: [] }; + }, + }, + search: { + issuesAndPullRequests: async (params) => { + console.log(`\n🔍 SEARCH API CALL: q=${params.q}`); + if (searchShouldFail) { + throw new Error("Simulated search API failure"); + } + const linkedMatch = params.q.match(/linked:(\d+)/); + const issueNumber = linkedMatch + ? parseInt(linkedMatch[1], 10) + : null; + if ( + searchResults && + issueNumber !== null && + issueNumber in searchResults + ) { + return { data: searchResults[issueNumber] }; + } + return { data: { total_count: 0 } }; + }, + }, + }, + graphql: async (query, vars) => { + // Stubbed just in case other things call it, though we rely on REST now + if (searchShouldFail) { + throw new Error("Simulated GraphQL failure"); + } + + if (query.includes("closedByPullRequestsReferences")) { + const issueNumber = vars.number; + const totalCount = (searchResults && issueNumber !== null && issueNumber in searchResults) + ? searchResults[issueNumber].total_count + : 0; + + let nodes = []; + if (totalCount > 0) { + // In tests, if totalCount > 0, we just return a matching PR + // The hasNeedsReviewPR function checks author matching `username`, + // but we don't have the explicit username passed into the mock. + // So we use a special placeholder, or infer from context. + // Since the test just wants a match, we can return the author as options.mockUsername + // or fallback to 'bypass-review-user-1'. + const authorLogin = options.mockUsername || "bypass-review-user-1"; + nodes.push({ + state: "OPEN", + author: { login: authorLogin }, + labels: { nodes: [{ name: "status: needs review" }] } + }); + } + + return { + repository: { + issue: { + closedByPullRequestsReferences: { + nodes + } + } + } + }; + } + + return { search: { issueCount: 0 } }; + }, + }; +} + +// ============================================================================= +// TEST SCENARIOS +// ============================================================================= + +const scenarios = [ + { + name: "Race Condition - Issue Snatched While Queued (Case 2)", + description: + "Fresh fetch shows another user was assigned between queue and execution", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 120, + assignees: [], // stale payload: appears unassigned + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1021, + body: "/assign", + user: { login: "second-requester", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueAlreadyAssignedTo: "first-requester" }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @second-requester! This issue is already assigned to @first-requester.\n\n👉 **Find another issue to work on:**\n[Browse unassigned issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22)\n\nOnce you find one you like, comment \`/assign\` to get started!`, + ], + }, + { + name: "Race Condition - Requester Already Assigned In Fresh State", + description: + "Fresh fetch shows the requester is already assigned; handler must exit without addAssignees", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 122, + assignees: [], // stale payload: appears unassigned + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1023, + body: "/assign", + user: { login: "already-assigned-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueAlreadyAssignedTo: "already-assigned-user" }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @already-assigned-user! You're already assigned to this issue. You're all set to start working on it!\n\nIf you have any questions, feel free to ask here or reach out to the team.`, + ], + }, + { + name: "Race Condition - Duplicate Assignment Trigger At Limit", + description: + "A duplicate queued /assign sees the current issue in the live assignment count and must not post a limit-exceeded comment", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 127, + assignees: [], // stale payload: appears unassigned + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1028, + body: "/assign", + user: { login: "duplicate-requester", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + issueAlreadyAssignedTo: "duplicate-requester", + openAssignmentCount: 2, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @duplicate-requester! You're already assigned to this issue. You're all set to start working on it!\n\nIf you have any questions, feel free to ask here or reach out to the team.`, + ], + }, + { + name: "Race Condition - Fresh Issue Has Multiple Assignees", + description: + "Fresh fetch reveals a corrupted multi-assignee issue; bot must reject and list all assignees", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 123, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1024, + body: "/assign", + user: { login: "third-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueAssignees: ["first-user", "second-user"] }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @third-user! This issue is already assigned to @first-user, @second-user.\n\n👉 **Find another issue to work on:**\n[Browse unassigned issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22)\n\nOnce you find one you like, comment \`/assign\` to get started!`, + ], + }, + { + name: "Race Condition - Ready Label Removed While Queued", + description: + "Fresh fetch no longer has ready-for-dev label; bot must abort assignment", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 124, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1025, + body: "/assign", + user: { login: "stale-ready-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueLabels: [{ name: LABELS.GOOD_FIRST_ISSUE }] }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @stale-ready-user! This issue is not ready for development yet.\n\nIssues must have the \`status: ready for dev\` label before they can be assigned.\n\n👉 **Find an issue that's ready:**\n[Browse ready issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22)\n\nOnce you find one you like, comment \`/assign\` to get started!`, + ], + }, + { + name: "Race Condition - Skill Label Removed While Queued", + description: + "Fresh fetch no longer has any skill label; bot must abort and tag maintainers", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 125, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1026, + body: "/assign", + user: { login: "stale-skill-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueLabels: [{ name: LABELS.READY_FOR_DEV }] }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @stale-skill-user! This issue doesn't have a skill level label yet.\n\n@utkarsh232005 — could you please add one of the following labels?\n- \`skill: good first issue\`\n- \`skill: beginner\`\n- \`skill: intermediate\`\n- \`skill: advanced\`\n\n@stale-skill-user, once a maintainer adds the label, comment \`/assign\` again to request assignment.`, + ], + }, + { + name: "Race Condition - Skill Label Changed to Different Level While Queued", + description: + "Fresh fetch shows a different skill level; bot must abort since prerequisite gates were run against stale level", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 126, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1027, + body: "/assign", + user: { login: "skill-changed-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + issueLabels: [{ name: LABELS.READY_FOR_DEV }, { name: LABELS.BEGINNER }], + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @skill-changed-user! The skill level for this issue changed while your assignment request was being processed.\n\n**Current label:** \`${LABELS.BEGINNER}\`\n**Previous label:** \`${LABELS.GOOD_FIRST_ISSUE}\`\n\nPlease comment \`/assign\` again to request assignment with the updated skill requirements.`, + ], + }, + { + name: "Error - Fresh Issue Fetch API Failure", + // Note: This failure fires from inside assignAndFinalize(), unlike the + // other API failure tests which catch errors during precondition checks. + description: + "Tags maintainers when issues.get fails inside assignAndFinalize", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 121, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1022, + body: "/assign", + user: { login: "unlucky-user-4", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { issueGetShouldFail: true }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @unlucky-user-4! I encountered an error while trying to verify your eligibility for this issue.\n\n@utkarsh232005 — could you please help with this assignment request?\n\n@unlucky-user-4, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ], + }, + // --------------------------------------------------------------------------- + // HAPPY PATHS (4 tests) + // Successful assignment for each skill level + // --------------------------------------------------------------------------- + + { + name: "Happy Path - Good First Issue", + description: "New contributor successfully assigned to GFI", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 100, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1001, + body: "/assign", + user: { login: "new-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: "new-contributor", + expectedComments: [ + `👋 Hi @new-contributor, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉 + +You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed. + +The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool. + +Good luck, and welcome aboard! 🚀`, + ], + }, + + { + name: "Happy Path - Beginner Issue", + description: "Contributor with 2 completed GFIs assigned to Beginner", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 101, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 1002, + body: "/assign", + user: { login: "experienced-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 2 } }, + expectedAssignee: "experienced-contributor", + expectedComments: [ + `👋 Hi @experienced-contributor, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Beginner** issue. 🙌 + +If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool. + +Good luck! 🚀`, + ], + }, + + { + name: "Happy Path - Intermediate Issue", + description: + "Contributor with 3 completed Beginners assigned to Intermediate", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 102, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: intermediate" }, + ], + }, + comment: { + id: 1003, + body: "/assign", + user: { login: "growing-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.BEGINNER]: 3 } }, + expectedAssignee: "growing-contributor", + expectedComments: [ + `👋 Hi @growing-contributor, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Intermediate** issue. 🙌 + +If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool. + +Good luck! 🚀`, + ], + }, + + { + name: "Happy Path - Advanced Issue", + description: + "Contributor with 3 completed Intermediates assigned to Advanced", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 103, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: advanced" }, + ], + }, + comment: { + id: 1004, + body: "/assign", + user: { login: "senior-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.INTERMEDIATE]: 3 } }, + expectedAssignee: "senior-contributor", + expectedComments: [ + `👋 Hi @senior-contributor, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Advanced** issue. 🙌 + +If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool. + +Good luck! 🚀`, + ], + }, + + // --------------------------------------------------------------------------- + // BYPASS LOGIC TESTS + // --------------------------------------------------------------------------- + + { + name: "Bypass - Same Level Completed", + description: + "User with 1 Beginner can take another Beginner (bypasses 2 GFI prereq)", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 200, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 2001, + body: "/assign", + user: { login: "bypass-user-1", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + completedIssueCounts: { + [LABELS.BEGINNER]: 1, + [LABELS.GOOD_FIRST_ISSUE]: 1, // Only 1 (Not enough to pass normal prereq) + }, + }, + expectedAssignee: "bypass-user-1", + expectedComments: [ + `👋 Hi @bypass-user-1, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.\n\nGood luck! 🚀`, + ], + }, + + { + name: "Bypass - Higher Level Completed", + description: + "User with 1 Intermediate can take a Beginner (bypasses 2 GFI prereq)", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 201, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 2002, + body: "/assign", + user: { login: "bypass-user-2", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + completedIssueCounts: { + [LABELS.INTERMEDIATE]: 1, + }, + }, + expectedAssignee: "bypass-user-2", + expectedComments: [ + `👋 Hi @bypass-user-2, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.\n\nGood luck! 🚀`, + ], + }, + + // --------------------------------------------------------------------------- + // GFI COMPLETION CAP TESTS (3 tests) + // Gate added in enforceGfiCompletionLimit + // --------------------------------------------------------------------------- + + { + name: "GFI Cap - Exactly At Limit (5 Completed)", + description: + "Contributor with 5 completed GFIs is rejected with encouraging redirect", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 300, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 3001, + body: "/assign", + user: { login: "veteran-gfi-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 5 } }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @veteran-gfi-user! You've completed **5 Good First Issues** — that's a fantastic achievement, and it shows you know the workflow inside and out. 🎉 + +Good First Issues are designed to help new contributors get comfortable with the process, and you've clearly mastered it. We believe you're more than ready to take on bigger challenges! + +👉 **Find Beginner and higher issues to work on:** +[Browse available Beginner issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20no%3Aassignee%20label%3A%22skill%3A%20beginner%22%20label%3A%22status%3A%20ready%20for%20dev%22) + +Come take on something more challenging — we're excited to see what you'll build next! 🚀`, + ], + }, + + { + name: "GFI Cap - Below Limit (4 Completed)", + description: + "Contributor with 4 completed GFIs is still allowed to take another GFI", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 301, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 3002, + body: "/assign", + user: { login: "almost-capped-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 4 } }, + expectedAssignee: "almost-capped-user", + expectedComments: [ + `👋 Hi @almost-capped-user, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉 + +You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed. + +The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool. + +Good luck, and welcome aboard! 🚀`, + ], + }, + + { + name: "GFI Cap - Does Not Apply to Beginner Issues", + description: + "Contributor with 5 completed GFIs can still take a Beginner issue", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 302, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 3003, + body: "/assign", + user: { login: "gfi-graduated-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 5 } }, + expectedAssignee: "gfi-graduated-user", + expectedComments: [ + `👋 Hi @gfi-graduated-user, thanks for continuing to contribute to the KDM CLI! You've been assigned this **Beginner** issue. 🙌 + +If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool. + +Good luck! 🚀`, + ], + }, + + // --------------------------------------------------------------------------- + // VALIDATION FAILURES (9 tests) + // Bot rejects assignment with helpful message + // --------------------------------------------------------------------------- + + { + name: "Validation - Already Assigned to Someone Else", + description: "Issue is taken by another contributor", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 104, + assignees: [{ login: "other-user" }], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1005, + body: "/assign", + user: { login: "late-arrival", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @late-arrival! This issue is already assigned to @other-user. + +👉 **Find another issue to work on:** +[Browse unassigned issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22) + +Once you find one you like, comment \`/assign\` to get started!`, + ], + }, + + { + name: "Validation - Already Assigned to Self", + description: "Contributor already owns the issue", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 105, + assignees: [{ login: "forgetful-user" }], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1006, + body: "/assign", + user: { login: "forgetful-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @forgetful-user! You're already assigned to this issue. You're all set to start working on it! + +If you have any questions, feel free to ask here or reach out to the team.`, + ], + }, + + { + name: "Validation - Not Ready for Dev", + description: "Issue missing status: ready for dev label", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 106, + assignees: [], + labels: [{ name: "skill: good first issue" }], + }, + comment: { + id: 1007, + body: "/assign", + user: { login: "eager-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @eager-user! This issue is not ready for development yet. + +Issues must have the \`status: ready for dev\` label before they can be assigned. + +👉 **Find an issue that's ready:** +[Browse ready issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22) + +Once you find one you like, comment \`/assign\` to get started!`, + ], + }, + + { + name: "Validation - No Labels At All", + description: "Issue has no labels", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 107, + assignees: [], + labels: [], + }, + comment: { + id: 1007, + body: "/assign", + user: { login: "eager-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @eager-user! This issue is not ready for development yet. + +Issues must have the \`status: ready for dev\` label before they can be assigned. + +👉 **Find an issue that's ready:** +[Browse ready issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22status%3A+ready+for+dev%22) + +Once you find one you like, comment \`/assign\` to get started!`, + ], + }, + + { + name: "Validation - No Skill Level Label", + description: "Issue missing skill level label", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 107, + assignees: [], + labels: [{ name: "status: ready for dev" }], + }, + comment: { + id: 1008, + body: "/assign", + user: { login: "confused-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @confused-user! This issue doesn't have a skill level label yet. + +@utkarsh232005 — could you please add one of the following labels? +- \`skill: good first issue\` +- \`skill: beginner\` +- \`skill: intermediate\` +- \`skill: advanced\` + +@confused-user, once a maintainer adds the label, comment \`/assign\` again to request assignment.`, + ], + }, + + { + name: "Validation - Prerequisites Not Met", + description: "Contributor lacks required experience", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 108, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 1009, + body: "/assign", + user: { login: "eager-newbie", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { completedIssueCount: 0, openAssignmentCount: 0 }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @eager-newbie! Thanks for your interest in contributing! + +This is a **Beginner** issue. Before taking it on, you need to complete at least **2 Good First Issues** to build familiarity with the codebase. + +📊 **Your Progress:** You've completed **0** so far. + +👉 **Find Good First Issues to work on:** +[Browse available Good First Issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20no%3Aassignee%20label%3A%22skill%3A%20good%20first%20issue%22%20label%3A%22status%3A%20ready%20for%20dev%22) + +Once you've completed 2, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Validation - Too Many Open Assignments (at limit)", + description: "Contributor already has 2 open issues assigned", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 114, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1015, + body: "/assign", + user: { login: "busy-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { openAssignmentCount: 2 }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @busy-contributor! Thanks for your enthusiasm to contribute! + +To help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit. + +📊 **Your Current Assignments:** You're currently assigned to **2** open issues. + +👉 **View your assigned issues:** +[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Abusy-contributor%20-label%3A%22status%3A%20blocked%22) + +💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away. + +Once you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Validation - Too Many Open Assignments (over limit)", + description: "Contributor has more than 2 open issues assigned", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 115, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1016, + body: "/assign", + user: { login: "very-busy-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { openAssignmentCount: 5 }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @very-busy-contributor! Thanks for your enthusiasm to contribute! + +To help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit. + +📊 **Your Current Assignments:** You're currently assigned to **3+** open issues. + +👉 **View your assigned issues:** +[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Avery-busy-contributor%20-label%3A%22status%3A%20blocked%22) + +💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away. + +Once you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Validation - Over Limit After Issues Unblocked", + description: + "User has 3 open issues; some were blocked when they got the third. Now 3 count (excluding blocked), so over limit and cannot be assigned", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 118, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1019, + body: "/assign", + user: { login: "now-over-limit-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + openAssignmentCount: 3, + openAssignmentCountExcludingBlocked: 3, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @now-over-limit-user! Thanks for your enthusiasm to contribute! + +To help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit. + +📊 **Your Current Assignments:** You're currently assigned to **3+** open issues. + +👉 **View your assigned issues:** +[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Anow-over-limit-user%20-label%3A%22status%3A%20blocked%22) + +💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away. + +Once you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Validation - At Limit With Blocked Issues (shows blocked link)", + description: + "User at 2 open (excluding blocked) and has 1 blocked issue; comment includes link to blocked issues", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 119, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1020, + body: "/assign", + user: { login: "at-limit-with-blocked", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + openAssignmentCount: 2, + openAssignmentCountExcludingBlocked: 2, + blockedIssueCount: 1, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @at-limit-with-blocked! Thanks for your enthusiasm to contribute! + +To help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit. + +📊 **Your Current Assignments:** You're currently assigned to **2** open issues. + +👉 **View your assigned issues:** +[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Aat-limit-with-blocked%20-label%3A%22status%3A%20blocked%22) + +👉 **View your blocked issues:** +[Your blocked issues](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Aat-limit-with-blocked%20label%3A%22status%3A%20blocked%22) + +💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away. + +Once you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Validation - Under Assignment Limit (1 open issue)", + description: "Contributor with 1 open issue can take another", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 116, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1017, + body: "/assign", + user: { login: "active-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { openAssignmentCount: 1 }, + expectedAssignee: "active-contributor", + expectedComments: [ + `👋 Hi @active-contributor, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉 + +You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed. + +The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool. + +Good luck, and welcome aboard! 🚀`, + ], + }, + + { + name: "Validation - Open Assignments Exclude Blocked", + description: + "Contributor with 2 open issues both status: blocked can be assigned (blocked not counted)", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 117, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1018, + body: "/assign", + user: { login: "blocked-contributor", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + openAssignmentCount: 2, + openAssignmentCountExcludingBlocked: 0, + }, + expectedAssignee: "blocked-contributor", + expectedComments: [ + `👋 Hi @blocked-contributor, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉 + +You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed. + +The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool. + +Good luck, and welcome aboard! 🚀`, + ], + }, + + // --------------------------------------------------------------------------- + // ERROR HANDLING (4 tests) + // API failures + // --------------------------------------------------------------------------- + + { + name: "Error - Open Assignments API Failure", + description: "Tags maintainers when open assignments check fails", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 117, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1018, + body: "/assign", + user: { login: "unlucky-user-3", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { restListOpenShouldFail: true }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @unlucky-user-3! I encountered an error while trying to verify your eligibility for this issue. + +@utkarsh232005 — could you please help with this assignment request? + +@unlucky-user-3, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ], + }, + + { + name: "Error - Prerequisite Check API Failure", + description: "Tags maintainers when prerequisite check fails", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 109, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: beginner" }, + ], + }, + comment: { + id: 1010, + body: "/assign", + user: { login: "unlucky-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { restListClosedShouldFail: true }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @unlucky-user! I encountered an error while trying to verify your eligibility for this issue. + +@utkarsh232005 — could you please help with this assignment request? + +@unlucky-user, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ], + }, + + { + name: "Error - Assignment API Failure", + description: "Tags maintainers when assignment fails", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 110, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1011, + body: "/assign", + user: { login: "unlucky-user-2", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { assignShouldFail: true }, + expectedAssignee: null, + expectedComments: [ + `⚠️ Hi @unlucky-user-2! I tried to assign you to this issue, but encountered an error. + +@utkarsh232005 — could you please manually assign @unlucky-user-2 to this issue? + +Error details: Simulated assignment failure`, + ], + }, + + { + name: "Error - Label Update Failure", + description: "Tags maintainers when labels cannot be updated", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 111, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1012, + body: "/assign", + user: { login: "partially-lucky", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { removeLabelShouldFail: true, addLabelShouldFail: true }, + expectedAssignee: "partially-lucky", + expectedComments: [ + `👋 Hi @partially-lucky, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉 + +You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed. + +The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help. + +If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool. + +Good luck, and welcome aboard! 🚀`, + `⚠️ @partially-lucky has been successfully assigned to this issue, but I encountered an error updating the labels. + +@utkarsh232005 — please manually: +- Remove the \`status: ready for dev\` label +- Add the \`status: in progress\` label + +Error details: Failed to remove 'status: ready for dev': Simulated remove label failure; Failed to add 'status: in progress': Simulated add label failure`, + ], + }, + + // --------------------------------------------------------------------------- + // DELETED COMMENT ABORT (1 test) + // Bot aborts /assign when the triggering comment has been deleted + // --------------------------------------------------------------------------- + + { + name: "Abort - Triggering Comment Deleted", + description: + "Bot aborts /assign flow when acknowledgeComment fails (comment deleted)", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 400, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 4001, + body: "/assign", + user: { login: "deleted-comment-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { reactionShouldFail: true }, + expectedAssignee: null, + expectedComments: [], + expectedNoSideEffects: true, + }, + + // --------------------------------------------------------------------------- + // NO ACTION (2 tests) + // Bot stays silent and takes no action + // --------------------------------------------------------------------------- + + { + name: "No Action - Comment Without /assign", + description: "Regular comment ignored", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 112, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1013, + body: "This looks interesting, can someone help me understand it?", + user: { login: "curious-user", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [], + }, + + { + name: "No Action - Bot Comment", + description: "Bot users ignored to prevent loops", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 113, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 1014, + body: "/assign", + user: { login: "github-actions[bot]", type: "Bot" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: {}, + expectedAssignee: null, + expectedComments: [], + }, + + // --------------------------------------------------------------------------- + // NEEDS-REVIEW BYPASS TESTS (6 tests) + // Assignment cap bypass when all assigned issues have linked needs-review PRs + // --------------------------------------------------------------------------- + + { + name: "Bypass Cap - All Issues Have Needs-Review PRs (over limit)", + description: + "Contributor over the cap but all 3 assigned issues have linked needs-review PRs — bypass allows assignment", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 600, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6001, + body: "/assign", + user: { login: "bypass-review-user-1", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + mockUsername: "bypass-review-user-1", + openAssignedIssues: [ + { number: 500, labels: [{ name: "status: in progress" }] }, + { number: 501, labels: [{ name: "status: in progress" }] }, + { number: 502, labels: [{ name: "status: in progress" }] }, + ], + searchResults: { + 500: { total_count: 1 }, + 501: { total_count: 1 }, + 502: { total_count: 1 }, + }, + }, + expectedAssignee: "bypass-review-user-1", + expectedComments: [ + `👋 Hi @bypass-review-user-1, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉\n\nYou've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed.\n\nThe issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.\n\nGood luck, and welcome aboard! 🚀`, + ], + }, + + { + name: "Bypass Cap - One Issue Missing Needs-Review PR", + description: + "One assigned issue has a needs-review PR but the other does not — bypass fails, limit-exceeded comment posted", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 601, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6002, + body: "/assign", + user: { login: "bypass-review-user-2", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + mockUsername: "bypass-review-user-2", + openAssignedIssues: [ + { number: 503, labels: [{ name: "status: in progress" }] }, + { number: 504, labels: [{ name: "status: in progress" }] }, + ], + searchResults: { + 503: { total_count: 1 }, + 504: { total_count: 0 }, + }, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @bypass-review-user-2! Thanks for your enthusiasm to contribute!\n\nTo help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit.\n\n📊 **Your Current Assignments:** You're currently assigned to **2** open issues.\n\n👉 **View your assigned issues:**\n[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Abypass-review-user-2%20-label%3A%22status%3A%20blocked%22)\n\n💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away.\n\nOnce you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Bypass Cap - No Issues Have Needs-Review PRs", + description: + "Neither assigned issue has a needs-review PR — bypass fails, limit-exceeded comment posted", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 602, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6003, + body: "/assign", + user: { login: "bypass-review-user-3", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + mockUsername: "bypass-review-user-3", + openAssignedIssues: [ + { number: 505, labels: [{ name: "status: in progress" }] }, + { number: 506, labels: [{ name: "status: in progress" }] }, + ], + searchResults: { + 505: { total_count: 0 }, + 506: { total_count: 0 }, + }, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @bypass-review-user-3! Thanks for your enthusiasm to contribute!\n\nTo help contributors stay focused and ensure issues remain available for others, we limit assignments to **2 open issues** at a time. Issues labeled \`status: blocked\` are not counted toward this limit.\n\n📊 **Your Current Assignments:** You're currently assigned to **2** open issues.\n\n👉 **View your assigned issues:**\n[Your open assignments](https://github.com/KDM-cli/kdm-cli/issues?q=is%3Aissue%20is%3Aopen%20assignee%3Abypass-review-user-3%20-label%3A%22status%3A%20blocked%22)\n\n💡 **Tip:** If all of your open assigned issues have a linked PR with \`status: needs review\`, the limit is automatically bypassed — you can request a new assignment right away.\n\nOnce you complete or unassign from one of your current issues, come back and we'll be happy to assign this to you! 🎯`, + ], + }, + + { + name: "Bypass Cap - Search API Error", + description: + "Search API fails when checking for needs-review PRs — API error comment posted, no bypass", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 603, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6004, + body: "/assign", + user: { login: "bypass-review-user-4", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + openAssignedIssues: [ + { number: 507, labels: [{ name: "status: in progress" }] }, + { number: 508, labels: [{ name: "status: in progress" }] }, + ], + searchShouldFail: true, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @bypass-review-user-4! I encountered an error while trying to verify your eligibility for this issue.\n\n@utkarsh232005 — could you please help with this assignment request?\n\n@bypass-review-user-4, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ], + }, + + { + name: "Bypass Cap - Exactly At Limit With Needs-Review PRs", + description: + "Contributor at exactly MAX_OPEN_ASSIGNMENTS with all issues having needs-review PRs — bypass succeeds", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 604, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6005, + body: "/assign", + user: { login: "bypass-review-user-5", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + mockUsername: "bypass-review-user-5", + openAssignedIssues: [ + { number: 509, labels: [{ name: "status: in progress" }] }, + { number: 510, labels: [{ name: "status: in progress" }] }, + ], + searchResults: { + 509: { total_count: 1 }, + 510: { total_count: 1 }, + }, + }, + expectedAssignee: "bypass-review-user-5", + expectedComments: [ + `👋 Hi @bypass-review-user-5, welcome to the KDM CLI community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉\n\nYou've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@utkarsh232005) is ready to help you succeed.\n\nThe issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.\n\nGood luck, and welcome aboard! 🚀`, + ], + }, + + { + name: "Bypass Cap - listAssignedIssues API Error", + description: + "listForRepo fails on the second call (listAssignedIssues) — API error comment posted, no bypass", + context: { + eventName: "issue_comment", + payload: { + issue: { + number: 605, + assignees: [], + labels: [ + { name: "status: ready for dev" }, + { name: "skill: good first issue" }, + ], + }, + comment: { + id: 6006, + body: "/assign", + user: { login: "bypass-review-user-6", type: "User" }, + }, + }, + repo: { owner: "kdm-ledger", repo: "kdm-cli" }, + }, + githubOptions: { + openAssignmentCount: 2, + restListOpenFailOnCall: 2, + }, + expectedAssignee: null, + expectedComments: [ + `👋 Hi @bypass-review-user-6! I encountered an error while trying to verify your eligibility for this issue.\n\n@utkarsh232005 — could you please help with this assignment request?\n\n@bypass-review-user-6, a maintainer will review your request and assign you manually if appropriate. Sorry for the inconvenience!`, + ], + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +const { verifyComments, runTestSuite } = require("./test-utils"); + +async function runTest(scenario, index) { + console.log("\n" + "=".repeat(70)); + console.log(`TEST ${index + 1}: ${scenario.name}`); + console.log(`Description: ${scenario.description}`); + console.log("=".repeat(70)); + + const mockGithub = createMockGithub( + scenario.githubOptions, + scenario.context?.payload?.issue, + ); + + try { + await script({ github: mockGithub, context: scenario.context }); + } catch (error) { + console.log(`\n❌ SCRIPT THREW ERROR: ${error.message}`); + } + + const results = { + passed: true, + details: [], + }; + + if (scenario.expectedAssignee) { + if (mockGithub.calls.assignees.includes(scenario.expectedAssignee)) { + results.details.push( + `✅ Correctly assigned to ${scenario.expectedAssignee}`, + ); + } else { + results.passed = false; + results.details.push( + `❌ Expected assignee ${scenario.expectedAssignee}, got: ${mockGithub.calls.assignees.join(", ") || "none"}`, + ); + } + } else { + if (mockGithub.calls.assignees.length === 0) { + results.details.push("✅ Correctly did not assign anyone"); + } else { + results.passed = false; + results.details.push( + `❌ Should not have assigned, but assigned: ${mockGithub.calls.assignees.join(", ")}`, + ); + } + } + + const commentResult = verifyComments( + scenario.expectedComments || [], + mockGithub.calls.comments, + ); + if (!commentResult.passed) results.passed = false; + results.details.push(...commentResult.details); + + console.log("\n📊 RESULT:"); + results.details.forEach((d) => console.log(` ${d}`)); + + return results.passed; +} + +runTestSuite("BOT-ASSIGN-ON-COMMENT TEST SUITE", scenarios, runTest); diff --git a/.github/scripts/tests/test-checks.js b/.github/scripts/tests/test-checks.js new file mode 100644 index 0000000..da8dc40 --- /dev/null +++ b/.github/scripts/tests/test-checks.js @@ -0,0 +1,694 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-checks.js +// +// Unit tests for helpers/checks.js (DCO, GPG, merge conflict, issue link). +// Run with: node .github/scripts/tests/test-checks.js + +const { runTestSuite } = require('./test-utils'); +const { + hasDCOSignoff, + hasVerifiedGPGSignature, + isMergeCommit, + checkDCO, + checkGPG, + checkMergeConflict, + checkIssueLink, +} = require('../helpers/checks'); + +// ============================================================================= +// UNIT TESTS +// ============================================================================= + +const unitTests = [ + // --------------------------------------------------------------------------- + // hasDCOSignoff + // --------------------------------------------------------------------------- + { + name: 'hasDCOSignoff: valid sign-off Signed-off-by: Name ', + test: () => hasDCOSignoff('Signed-off-by: Jane Doe ') === true, + }, + { + name: 'hasDCOSignoff: missing sign-off', + test: () => hasDCOSignoff('Just a commit message') === false, + }, + { + name: 'hasDCOSignoff: sign-off without email', + test: () => hasDCOSignoff('Signed-off-by: Jane Doe') === false, + }, + { + name: 'hasDCOSignoff: case insensitive signed-off-by', + test: () => hasDCOSignoff('signed-off-by: Jane Doe ') === true, + }, + { + name: 'hasDCOSignoff: null message', + test: () => hasDCOSignoff(null) === false, + }, + { + name: 'hasDCOSignoff: empty string', + test: () => hasDCOSignoff('') === false, + }, + { + name: 'hasDCOSignoff: multiline with sign-off in trailer', + test: () => + hasDCOSignoff( + 'Fix bug\n\nSome description\n\nSigned-off-by: Jane Doe ' + ) === true, + }, + + // --------------------------------------------------------------------------- + // hasVerifiedGPGSignature + // --------------------------------------------------------------------------- + { + name: 'hasVerifiedGPGSignature: verified true', + test: () => + hasVerifiedGPGSignature({ + commit: { verification: { verified: true } }, + }) === true, + }, + { + name: 'hasVerifiedGPGSignature: verified false', + test: () => + hasVerifiedGPGSignature({ + commit: { verification: { verified: false } }, + }) === false, + }, + { + name: 'hasVerifiedGPGSignature: no verification object', + test: () => hasVerifiedGPGSignature({ commit: {} }) === false, + }, + { + name: 'hasVerifiedGPGSignature: missing commit object', + test: () => hasVerifiedGPGSignature({}) === false, + }, + { + name: 'hasVerifiedGPGSignature: null commit', + test: () => hasVerifiedGPGSignature(null) === false, + }, + + // --------------------------------------------------------------------------- + // isMergeCommit + // --------------------------------------------------------------------------- + { + name: 'isMergeCommit: commit with two parents returns true', + test: () => isMergeCommit({ parents: [{}, {}] }) === true, + }, + { + name: 'isMergeCommit: commit with one parent returns false', + test: () => isMergeCommit({ parents: [{}] }) === false, + }, + { + name: 'isMergeCommit: commit with no parents field returns false', + test: () => isMergeCommit({}) === false, + }, + { + name: 'isMergeCommit: null commit returns false', + test: () => isMergeCommit(null) === false, + }, + { + name: 'isMergeCommit: undefined commit returns false', + test: () => isMergeCommit(undefined) === false, + }, + { + name: 'isMergeCommit: commit with empty parents array returns false', + test: () => isMergeCommit({ parents: [] }) === false, + }, + + // --------------------------------------------------------------------------- + // checkDCO + // --------------------------------------------------------------------------- + { + name: 'checkDCO: all pass', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix\n\nSigned-off-by: A ' } }, + { sha: 'def5678', commit: { message: 'Fix2\n\nSigned-off-by: B ' } }, + ]; + const r = checkDCO(commits); + return r.passed === true && r.failures.length === 0; + }, + }, + { + name: 'checkDCO: some fail', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix\n\nSigned-off-by: A ' } }, + { sha: 'def5678', commit: { message: 'No sign-off here' } }, + ]; + const r = checkDCO(commits); + return ( + r.passed === false && + r.failures.length === 1 && + r.failures[0].sha === 'def5678' && + r.failures[0].message === 'No sign-off here' + ); + }, + }, + { + name: 'checkDCO: all fail', + test: () => { + const commits = [ + { sha: 'aaa1111', commit: { message: 'No sign-off' } }, + { sha: 'bbb2222', commit: { message: 'Also no sign-off' } }, + ]; + const r = checkDCO(commits); + return r.passed === false && r.failures.length === 2; + }, + }, + { + name: 'checkDCO: empty commits', + test: () => { + const r = checkDCO([]); + return r.passed === true && r.failures.length === 0; + }, + }, + { + name: 'checkDCO: merge commit without sign-off is skipped (passes)', + test: () => { + const commits = [ + { sha: 'merge123', parents: [{}, {}], commit: { message: 'Merge branch main into feat' } }, + ]; + const r = checkDCO(commits); + return r.passed === true && r.failures.length === 0; + }, + }, + { + name: 'checkDCO: merge commit skipped, regular commits still checked', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix\n\nSigned-off-by: A ' } }, + { sha: 'merge56', parents: [{}, {}], commit: { message: 'Merge branch main into feat' } }, + { sha: 'def5678', commit: { message: 'No sign-off here' } }, + ]; + const r = checkDCO(commits); + return ( + r.passed === false && + r.failures.length === 1 && + r.failures[0].sha === 'def5678' + ); + }, + }, + { + name: 'checkDCO: all regular commits pass with merge commit present', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix\n\nSigned-off-by: A ' } }, + { sha: 'merge56', parents: [{}, {}], commit: { message: 'Merge branch main into feat' } }, + ]; + const r = checkDCO(commits); + return r.passed === true && r.failures.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // checkGPG + // --------------------------------------------------------------------------- + { + name: 'checkGPG: all pass', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix', verification: { verified: true } } }, + { sha: 'def5678', commit: { message: 'Fix2', verification: { verified: true } } }, + ]; + const r = checkGPG(commits); + return r.passed === true && r.failures.length === 0; + }, + }, + { + name: 'checkGPG: some fail', + test: () => { + const commits = [ + { sha: 'abc1234', commit: { message: 'Fix', verification: { verified: true } } }, + { sha: 'def5678', commit: { message: 'Fix2', verification: { verified: false } } }, + ]; + const r = checkGPG(commits); + return ( + r.passed === false && + r.failures.length === 1 && + r.failures[0].sha === 'def5678' + ); + }, + }, + { + name: 'checkGPG: all fail', + test: () => { + const commits = [ + { sha: 'aaa1111', commit: { message: 'Fix', verification: { verified: false } } }, + { sha: 'bbb2222', commit: { message: 'Fix2' } }, + ]; + const r = checkGPG(commits); + return r.passed === false && r.failures.length === 2; + }, + }, + { + name: 'checkGPG: empty commits', + test: () => { + const r = checkGPG([]); + return r.passed === true && r.failures.length === 0; + }, + }, + + // --------------------------------------------------------------------------- + // checkMergeConflict + // --------------------------------------------------------------------------- + { + name: 'checkMergeConflict: mergeable true', + test: async () => { + const botContext = { + github: { + rest: { + pulls: { + get: async () => ({ data: { mergeable: true, mergeable_state: 'clean' } }), + }, + }, + }, + owner: 'o', + repo: 'r', + number: 1, + }; + const r = await checkMergeConflict(botContext); + return r.passed === true; + }, + }, + { + name: 'checkMergeConflict: mergeable false', + test: async () => { + const botContext = { + github: { + rest: { + pulls: { + get: async () => ({ data: { mergeable: false, mergeable_state: 'dirty' } }), + }, + }, + }, + owner: 'o', + repo: 'r', + number: 1, + }; + const r = await checkMergeConflict(botContext); + return r.passed === false; + }, + }, + { + name: 'checkMergeConflict: mergeable null then true on retry', + test: async () => { + let callCount = 0; + const botContext = { + github: { + rest: { + pulls: { + get: async () => { + callCount++; + return { + data: { + mergeable: callCount === 1 ? null : true, + mergeable_state: callCount === 1 ? 'unknown' : 'clean', + }, + }; + }, + }, + }, + }, + owner: 'o', + repo: 'r', + number: 1, + }; + const r = await checkMergeConflict(botContext); + return r.passed === true && callCount === 2; + }, + }, + + // --------------------------------------------------------------------------- + // checkIssueLink + // --------------------------------------------------------------------------- + { + name: 'checkIssueLink: Fixes #123 in body, author assigned', + test: async () => { + const ctx = { + pr: { body: 'Fixes #123', user: { login: 'alice' } }, + }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'alice' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true && r.reason === null; + }, + }, + { + name: 'checkIssueLink: fixes #123 lowercase', + test: async () => { + const ctx = { pr: { body: 'fixes #123', user: { login: 'bob' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'bob' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Fixed #123', + test: async () => { + const ctx = { pr: { body: 'Fixed #123', user: { login: 'carol' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'carol' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Closes #456', + test: async () => { + const ctx = { pr: { body: 'Closes #456', user: { login: 'dave' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'dave' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: close #789', + test: async () => { + const ctx = { pr: { body: 'close #789', user: { login: 'eve' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'eve' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Closed #789', + test: async () => { + const ctx = { pr: { body: 'Closed #789', user: { login: 'frank' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'frank' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Resolves #100', + test: async () => { + const ctx = { pr: { body: 'Resolves #100', user: { login: 'grace' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'grace' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: resolve #100', + test: async () => { + const ctx = { pr: { body: 'resolve #100', user: { login: 'henry' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'henry' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Resolved #100', + test: async () => { + const ctx = { pr: { body: 'Resolved #100', user: { login: 'ivy' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'ivy' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: Related to #200', + test: async () => { + const ctx = { pr: { body: 'Related to #200', user: { login: 'jack' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'jack' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: related to #200 lowercase', + test: async () => { + const ctx = { pr: { body: 'related to #200', user: { login: 'kate' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'kate' }] }); + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: multiple keywords Fixes #1 and Closes #2', + test: async () => { + const ctx = { pr: { body: 'Fixes #1\nCloses #2', user: { login: 'lea' } } }; + let fetched = []; + const fetchIssue = async (_, num) => { + fetched.push(num); + return { title: `Issue ${num}`, assignees: [{ login: 'lea' }] }; + }; + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true && fetched.includes(1) && fetched.includes(2); + }, + }, + { + name: 'checkIssueLink: body with #123 but no keyword, GraphQL fallback called', + test: async () => { + let graphqlCalled = false; + const ctx = { pr: { body: 'Addresses issue #123', user: { login: 'mike' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'mike' }] }); + const fetchClosing = async () => { + graphqlCalled = true; + return [123]; + }; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return graphqlCalled === true && r.passed === true; + }, + }, + { + name: 'checkIssueLink: empty body', + test: async () => { + const ctx = { pr: { body: '', user: { login: 'nancy' } } }; + let graphqlCalled = false; + const fetchIssue = async () => ({}); + const fetchClosing = async () => { + graphqlCalled = true; + return []; + }; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === false && r.reason === 'no_issue_linked' && graphqlCalled === true; + }, + }, + { + name: 'checkIssueLink: null body', + test: async () => { + const ctx = { pr: { body: null, user: { login: 'oscar' } } }; + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { + fetchIssue: async () => ({}), + fetchClosingIssueNumbers: fetchClosing, + }); + return r.passed === false && r.reason === 'no_issue_linked'; + }, + }, + { + name: 'checkIssueLink: GraphQL fallback returns issue', + test: async () => { + const ctx = { pr: { body: 'No keyword here', user: { login: 'paul' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'paul' }] }); + const fetchClosing = async () => [999]; + const r = await checkIssueLink(ctx, { fetchIssue, fetchClosingIssueNumbers: fetchClosing }); + return r.passed === true && r.issues.some(i => i.number === 999); + }, + }, + { + name: 'checkIssueLink: GraphQL returns 0', + test: async () => { + const ctx = { pr: { body: 'No keywords', user: { login: 'quinn' } } }; + const fetchClosing = async () => []; + const r = await checkIssueLink(ctx, { + fetchIssue: async () => ({}), + fetchClosingIssueNumbers: fetchClosing, + }); + return r.passed === false && r.reason === 'no_issue_linked'; + }, + }, + { + name: 'checkIssueLink: 1 linked issue, author assigned', + test: async () => { + const ctx = { pr: { body: 'Fixes #1', user: { login: 'rachel' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'rachel' }] }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: 1 linked issue, different user', + test: async () => { + const ctx = { pr: { body: 'Fixes #1', user: { login: 'sam' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'other-user' }] }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'not_assigned'; + }, + }, + { + name: 'checkIssueLink: 1 linked issue, no assignees', + test: async () => { + const ctx = { pr: { body: 'Fixes #1', user: { login: 'tina' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [] }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'not_assigned'; + }, + }, + { + name: 'checkIssueLink: multiple issues, author assigned to first only', + test: async () => { + const ctx = { pr: { body: 'Fixes #1\nCloses #2', user: { login: 'uma' } } }; + const fetchIssue = async (_, num) => ({ + title: `Issue ${num}`, + assignees: num === 1 ? [{ login: 'uma' }] : [{ login: 'other' }], + }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'not_assigned'; + }, + }, + { + name: 'checkIssueLink: multiple issues, author assigned to second only', + test: async () => { + const ctx = { pr: { body: 'Fixes #1\nCloses #2', user: { login: 'vic' } } }; + const fetchIssue = async (_, num) => ({ + title: `Issue ${num}`, + assignees: num === 2 ? [{ login: 'vic' }] : [{ login: 'other' }], + }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'not_assigned'; + }, + }, + { + name: 'checkIssueLink: multiple issues, author assigned to all', + test: async () => { + const ctx = { pr: { body: 'Fixes #1\nCloses #2', user: { login: 'zara' } } }; + const fetchIssue = async () => ({ + title: 'Bug', + assignees: [{ login: 'zara' }], + }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === true && r.issues.length === 2; + }, + }, + { + name: 'checkIssueLink: multiple issues, author assigned to none', + test: async () => { + const ctx = { pr: { body: 'Fixes #1\nCloses #2', user: { login: 'wade' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'other' }] }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'not_assigned'; + }, + }, + { + name: 'checkIssueLink: case-insensitive author check', + test: async () => { + const ctx = { pr: { body: 'Fixes #1', user: { login: 'Alice' } } }; + const fetchIssue = async () => ({ title: 'Bug', assignees: [{ login: 'alice' }] }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === true; + }, + }, + { + name: 'checkIssueLink: fetchIssue throws 404, graceful no_issue_linked', + test: async () => { + const ctx = { pr: { body: 'Fixes #1', user: { login: 'xavier' } } }; + const fetchIssue = async () => { + throw new Error('Not Found'); + }; + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [], + }); + return r.passed === false && r.reason === 'no_issue_linked' && r.issues.length === 0; + }, + }, + { + name: 'checkIssueLink: PR with a title containing an issue number and a body with none', + test: async () => { + const ctx = { pr: { title: 'Feature relates to #314', body: 'Fixes #', user: { login: 'monte' } } }; + const fetchIssue = async () => ({ + title: 'Feature request', + assignees: [{ login: 'monte' }], + }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [314], + }); + return r.passed === false && r.reason === 'no_issue_linked' && r.issues.length === 0; + }, + }, + { + name: 'checkIssueLink: PR with a title that contains no issue references and an empty body', + test: async () => { + const ctx = { pr: { title: 'Fix', body: 'Fixing something', user: { login: 'mark' } } }; + const fetchIssue = async () => ({ + title: 'Bug', + assignees: [{ login: 'mark' }], + }); + const r = await checkIssueLink(ctx, { + fetchIssue, + fetchClosingIssueNumbers: async () => [12], + }); + return r.passed === true && r.issues.length === 1 && r.issues[0].number === 12; + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runUnitTests() { + console.log('🔬 UNIT TESTS (checks)'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (const test of unitTests) { + try { + const result = await Promise.resolve(test.test()); + if (result) { + console.log(`✅ ${test.name}`); + passed++; + } else { + console.log(`❌ ${test.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${test.name} - Error: ${error.message}`); + failed++; + } + } + console.log('\n' + '-'.repeat(70)); + console.log(`Unit Tests: ${passed} passed, ${failed} failed`); + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('CHECK HELPERS TEST SUITE', [], async () => true, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-comments.js b/.github/scripts/tests/test-comments.js new file mode 100644 index 0000000..45a7b32 --- /dev/null +++ b/.github/scripts/tests/test-comments.js @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-comments.js +// +// Unit tests for helpers/comments.js (unified bot comment builder). +// Run with: node .github/scripts/tests/test-comments.js + +const { runTestSuite } = require('./test-utils'); +const { MARKER, buildBotComment, buildChecksSection, allChecksPassed, buildMergeConflictNotificationComment } = require('../helpers/comments'); + +// ============================================================================= +// TEST DATA HELPERS +// ============================================================================= + +function allPassing() { + return { + dco: { passed: true, failures: [] }, + gpg: { passed: true, failures: [] }, + merge: { passed: true }, + issueLink: { + passed: true, + reason: null, + issues: [{ number: 42, title: 'test', isAssigned: true }], + }, + }; +} + +function withDCOFailed(data = allPassing()) { + return { + ...data, + dco: { passed: false, failures: [{ sha: 'abc123', message: 'Fix bug' }] }, + }; +} + +function withDCOError(data = allPassing()) { + return { + ...data, + dco: { passed: false, error: true, errorMessage: 'API timeout' }, + }; +} + +function withGPGFailed(data = allPassing()) { + return { + ...data, + gpg: { passed: false, failures: [{ sha: 'def456', message: 'Another fix' }] }, + }; +} + +function withGPGError(data = allPassing()) { + return { + ...data, + gpg: { passed: false, error: true, errorMessage: 'Network error' }, + }; +} + +function withMergeFailed(data = allPassing()) { + return { + ...data, + merge: { passed: false }, + }; +} + + +function withIssueLinkNoIssue(data = allPassing()) { + return { + ...data, + issueLink: { passed: false, reason: 'no_issue_linked', issues: [] }, + }; +} + +function withIssueLinkNotAssigned(data = allPassing()) { + return { + ...data, + issueLink: { + passed: false, + reason: 'not_assigned', + issues: [{ number: 99, title: 'Bug', isAssigned: false }], + }, + }; +} + + +// ============================================================================= +// UNIT TESTS +// ============================================================================= + +const unitTests = [ + // --------------------------------------------------------------------------- + // Structure and greeting + // --------------------------------------------------------------------------- + { + name: 'Comment starts with marker', + test: () => { + const { body } = buildBotComment({ prAuthor: 'alice', ...allPassing() }); + return body.startsWith(''); + }, + }, + { + name: 'Greeting contains @prAuthor mention', + test: () => { + const { body } = buildBotComment({ prAuthor: 'bob-smith', ...allPassing() }); + return body.includes('@bob-smith'); + }, + }, + { + name: 'Greeting contains "PR Helper Bot"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'carol', ...allPassing() }); + return body.includes('PR Helper Bot'); + }, + }, + { + name: 'marker field equals MARKER constant', + test: () => { + const { marker } = buildBotComment({ prAuthor: 'dave', ...allPassing() }); + return marker === MARKER; + }, + }, + { + name: 'allPassed: true when all checks pass', + test: () => { + const { allPassed } = buildBotComment({ prAuthor: 'eve', ...allPassing() }); + return allPassed === true; + }, + }, + { + name: 'allPassed: false when any check fails', + test: () => { + const data = withDCOFailed(); + const { allPassed } = buildBotComment({ prAuthor: 'frank', ...data }); + return allPassed === false; + }, + }, + + // --------------------------------------------------------------------------- + // DCO section + // --------------------------------------------------------------------------- + { + name: 'DCO all pass: body contains :white_check_mark: and "All commits have valid sign-offs"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'gina', ...allPassing() }); + return body.includes(':white_check_mark:') && body.includes('All commits have valid sign-offs'); + }, + }, + { + name: 'DCO failures: body contains :x: and "DCO Sign-off" and lists failing commits', + test: () => { + const data = withDCOFailed(); + const { body } = buildBotComment({ prAuthor: 'henry', ...data }); + return ( + body.includes(':x:') && + body.includes('DCO Sign-off') && + body.includes('abc123') && + body.includes('Fix bug') + ); + }, + }, + { + name: 'DCO failures include link to Signing Guide', + test: () => { + const data = withDCOFailed(); + const { body } = buildBotComment({ prAuthor: 'ivy', ...data }); + return body.includes('signing.md'); + }, + }, + + // --------------------------------------------------------------------------- + // GPG section + // --------------------------------------------------------------------------- + { + name: 'GPG all pass: body contains "All commits have verified GPG signatures"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'jack', ...allPassing() }); + return body.includes('All commits have verified GPG signatures'); + }, + }, + { + name: 'GPG failures: body contains :x: and "GPG Signature" and lists failing commits', + test: () => { + const data = withGPGFailed(); + const { body } = buildBotComment({ prAuthor: 'kate', ...data }); + return ( + body.includes(':x:') && + body.includes('GPG Signature') && + body.includes('def456') && + body.includes('Another fix') + ); + }, + }, + { + name: 'GPG failures include link to Signing Guide', + test: () => { + const data = withGPGFailed(); + const { body } = buildBotComment({ prAuthor: 'lea', ...data }); + return body.includes('signing.md'); + }, + }, + + // --------------------------------------------------------------------------- + // Merge conflict section + // --------------------------------------------------------------------------- + { + name: 'Merge no conflicts: body contains "No merge conflicts detected"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'mike', ...allPassing() }); + return body.includes('No merge conflicts detected'); + }, + }, + { + name: 'Merge conflicts: body contains :x: and "merge conflicts" and guide link', + test: () => { + const data = withMergeFailed(); + const { body } = buildBotComment({ prAuthor: 'nancy', ...data }); + return ( + body.includes(':x:') && + body.includes('merge conflicts') && + body.includes('merge-conflicts.md') + ); + }, + }, + + // --------------------------------------------------------------------------- + // Issue link section + // --------------------------------------------------------------------------- + { + name: 'Issue link passed: body contains "Linked to #42 (assigned to you)"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'oscar', ...allPassing() }); + return body.includes('Linked to #42') && body.includes('assigned to you'); + }, + }, + { + name: 'Issue link reason no_issue_linked: body contains "not linked to any issue"', + test: () => { + const data = withIssueLinkNoIssue(); + const { body } = buildBotComment({ prAuthor: 'paul', ...data }); + return body.includes('not linked to any issue'); + }, + }, + { + name: 'Issue link reason not_assigned: body shows unassigned issues', + test: () => { + const data = withIssueLinkNotAssigned(); + const { body } = buildBotComment({ prAuthor: 'quinn', ...data }); + return body.includes('not assigned to the following linked issues') && body.includes('#99'); + }, + }, + + // --------------------------------------------------------------------------- + // Footer + // --------------------------------------------------------------------------- + { + name: 'Footer all pass: body contains :tada: and "All checks passed"', + test: () => { + const { body } = buildBotComment({ prAuthor: 'rachel', ...allPassing() }); + return body.includes(':tada:') && body.includes('All checks passed'); + }, + }, + { + name: 'Footer any fail: body contains "All checks must pass"', + test: () => { + const data = withDCOFailed(); + const { body } = buildBotComment({ prAuthor: 'sam', ...data }); + return body.includes('All checks must pass'); + }, + }, + + // --------------------------------------------------------------------------- + // Error state + // --------------------------------------------------------------------------- + { + name: 'DCO errored: body contains :warning: and maintainer team tag', + test: () => { + const data = withDCOError(); + const { body } = buildBotComment({ prAuthor: 'tina', ...data }); + return ( + body.includes(':warning:') && + body.includes('@utkarsh232005') && + body.includes('internal error') + ); + }, + }, + { + name: 'Multiple errors: each shows warning', + test: () => { + const data = withDCOError(withGPGError()); + const { body } = buildBotComment({ prAuthor: 'uma', ...data }); + const dcoWarning = body.includes(':warning: **DCO Sign-off**'); + const gpgWarning = body.includes(':warning: **GPG Signature**'); + return dcoWarning && gpgWarning; + }, + }, + { + name: 'Mix of error + fail + pass: correct icons for each', + test: () => { + const data = withDCOError(withGPGFailed(withMergeFailed(allPassing()))); + const { body } = buildBotComment({ prAuthor: 'vic', ...data }); + const hasDCOWarning = body.includes(':warning: **DCO Sign-off**'); + const hasGPGX = body.includes(':x: **GPG Signature**'); + const hasMergeX = body.includes(':x: **Merge Conflicts**'); + const hasIssueCheck = body.includes(':white_check_mark: **Issue Link**'); + return hasDCOWarning && hasGPGX && hasMergeX && hasIssueCheck; + }, + }, + { + name: 'allPassed false when any check errored', + test: () => { + const data = withDCOError(); + const { allPassed } = buildBotComment({ prAuthor: 'wade', ...data }); + return allPassed === false; + }, + }, + + // --------------------------------------------------------------------------- + // Combinations + // --------------------------------------------------------------------------- + { + name: 'Single failure (DCO only)', + test: () => { + const data = withDCOFailed(); + const { allPassed, body } = buildBotComment({ prAuthor: 'xander', ...data }); + return allPassed === false && body.includes(':x: **DCO Sign-off**'); + }, + }, + { + name: 'Two failures (DCO + GPG)', + test: () => { + const data = withGPGFailed(withDCOFailed()); + const { allPassed, body } = buildBotComment({ prAuthor: 'yara', ...data }); + return ( + allPassed === false && + body.includes(':x: **DCO Sign-off**') && + body.includes(':x: **GPG Signature**') + ); + }, + }, + { + name: 'All four fail', + test: () => { + const data = withIssueLinkNoIssue( + withMergeFailed(withGPGFailed(withDCOFailed())) + ); + const { allPassed, body } = buildBotComment({ prAuthor: 'zara', ...data }); + return ( + allPassed === false && + body.includes(':x: **DCO Sign-off**') && + body.includes(':x: **GPG Signature**') && + body.includes(':x: **Merge Conflicts**') && + body.includes(':x: **Issue Link**') + ); + }, + }, + { + name: 'Issue link fail + all others pass', + test: () => { + const data = withIssueLinkNotAssigned(); + const { allPassed, body } = buildBotComment({ prAuthor: 'adam', ...data }); + const othersPass = + body.includes(':white_check_mark: **DCO Sign-off**') && + body.includes(':white_check_mark: **GPG Signature**') && + body.includes(':white_check_mark: **Merge Conflicts**'); + const issueFails = body.includes(':x: **Issue Link**'); + return allPassed === false && othersPass && issueFails; + }, + }, + + // --------------------------------------------------------------------------- + // buildChecksSection and allChecksPassed (direct) + // --------------------------------------------------------------------------- + { + name: 'buildChecksSection: contains PR Checks heading', + test: () => { + const section = buildChecksSection(allPassing()); + return section.includes('### PR Checks'); + }, + }, + { + name: 'allChecksPassed: true when all pass', + test: () => allChecksPassed(allPassing()) === true, + }, + { + name: 'allChecksPassed: false when DCO fails', + test: () => allChecksPassed(withDCOFailed()) === false, + }, + { + name: 'allChecksPassed: false when DCO errors', + test: () => allChecksPassed(withDCOError()) === false, + }, + { + name: 'Issue link assigned: multiple issues format', + test: () => { + const data = allPassing(); + data.issueLink.issues = [ + { number: 1, title: 'Bug 1', isAssigned: true }, + { number: 2, title: 'Bug 2', isAssigned: true }, + ]; + const { body } = buildBotComment({ prAuthor: 'multi', ...data }); + return body.includes('#1') && body.includes('#2') && body.includes('assigned to you'); + }, + }, + + // --------------------------------------------------------------------------- + // buildMergeConflictNotificationComment + // --------------------------------------------------------------------------- + { + name: 'Notification comment contains @prAuthor mention', + test: () => { + const comment = buildMergeConflictNotificationComment('alice', 42); + return comment.includes('@alice'); + }, + }, + { + name: 'Notification comment references merged PR number', + test: () => { + const comment = buildMergeConflictNotificationComment('bob', 123); + return comment.includes('#123'); + }, + }, + { + name: 'Notification comment includes wave emoji', + test: () => { + const comment = buildMergeConflictNotificationComment('carol', 7); + return comment.includes(':wave:'); + }, + }, + { + name: 'Notification comment asks to resolve merge conflict', + test: () => { + const comment = buildMergeConflictNotificationComment('dave', 99); + return comment.includes('resolve the merge conflict'); + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runUnitTests() { + console.log('🔬 UNIT TESTS (comments)'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (const test of unitTests) { + try { + const result = await Promise.resolve(test.test()); + if (result) { + console.log(`✅ ${test.name}`); + passed++; + } else { + console.log(`❌ ${test.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${test.name} - Error: ${error.message}`); + failed++; + } + } + console.log('\n' + '-'.repeat(70)); + console.log(`Unit Tests: ${passed} passed, ${failed} failed`); + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('COMMENT HELPERS TEST SUITE', [], async () => true, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-config-loader.js b/.github/scripts/tests/test-config-loader.js new file mode 100644 index 0000000..6fe8259 --- /dev/null +++ b/.github/scripts/tests/test-config-loader.js @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-config-loader.js +// +// Unit tests for helpers/config-loader.js +// Run with: node .github/scripts/tests/test-config-loader.js +// +// Tests cover: +// - Valid config loading and constant building +// - Missing config file error +// - Malformed JSON error +// - Validation errors for every required field and cross-reference + +const fs = require('fs'); +const path = require('path'); +const { runTestSuite } = require('./test-utils'); +const { loadAutomationConfig, buildConstants, DEFAULT_CONFIG_PATH } = require('../helpers/config-loader'); + +// ============================================================================= +// HELPERS +// ============================================================================= + +const SCRATCH_DIR = path.resolve(__dirname, '.config-loader-scratch'); + +/** + * Returns a deep clone of the production config for use as a valid baseline. + * Tests mutate this clone to create invalid configs without touching the real file. + */ +function getValidConfig() { + const raw = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8'); + return JSON.parse(raw); +} + +/** + * Writes a config object to a temporary file and returns the path. + * @param {string} name - Filename (no directory). + * @param {object|string} content - Object (JSON-serialized) or raw string. + * @returns {string} Absolute path to the temp file. + */ +function writeTempConfig(name, content) { + if (!fs.existsSync(SCRATCH_DIR)) { + fs.mkdirSync(SCRATCH_DIR, { recursive: true }); + } + const filePath = path.join(SCRATCH_DIR, name); + const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + fs.writeFileSync(filePath, data, 'utf8'); + return filePath; +} + +/** + * Removes the scratch directory and all temp files. + */ +function cleanupScratch() { + if (fs.existsSync(SCRATCH_DIR)) { + for (const file of fs.readdirSync(SCRATCH_DIR)) { + fs.unlinkSync(path.join(SCRATCH_DIR, file)); + } + fs.rmdirSync(SCRATCH_DIR); + } +} + +/** + * Asserts that loadAutomationConfig throws an error containing the expected substring. + * @param {string} configPath - Path to the temp config file. + * @param {string} expectedSubstring - Expected substring in the error message. + * @returns {boolean} + */ +function expectLoadError(configPath, expectedSubstring) { + try { + loadAutomationConfig(configPath); + return false; + } catch (err) { + if (!err.message.includes(expectedSubstring)) { + console.log(` Expected error containing: "${expectedSubstring}"`); + console.log(` Got: "${err.message}"`); + return false; + } + return true; + } +} + +// ============================================================================= +// UNIT TESTS +// ============================================================================= + +const unitTests = [ + + // --------------------------------------------------------------------------- + // Valid config loading + // --------------------------------------------------------------------------- + { + name: 'loadAutomationConfig: loads production config without error', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + return config !== null && typeof config === 'object'; + }, + }, + { + name: 'loadAutomationConfig: returns a frozen object', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + return Object.isFrozen(config); + }, + }, + { + name: 'buildConstants: produces MAINTAINER_TEAM string', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const derived = buildConstants(config); + return derived.MAINTAINER_TEAM === '@utkarsh232005'; + }, + }, + { + name: 'buildConstants: produces GFI_SUPPORT_TEAM string', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const derived = buildConstants(config); + return derived.GFI_SUPPORT_TEAM === '@utkarsh232005'; + }, + }, + { + name: 'buildConstants: LABELS has all expected keys', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { LABELS } = buildConstants(config); + const expectedKeys = [ + 'AWAITING_TRIAGE', 'READY_FOR_DEV', 'IN_PROGRESS', 'BLOCKED', + 'NEEDS_REVIEW', 'NEEDS_REVISION', + 'GOOD_FIRST_ISSUE', 'BEGINNER', 'INTERMEDIATE', 'ADVANCED', + 'PRIORITY_CRITICAL', 'PRIORITY_HIGH', 'PRIORITY_MEDIUM', 'PRIORITY_LOW', + ]; + return expectedKeys.every(k => typeof LABELS[k] === 'string' && LABELS[k].length > 0); + }, + }, + { + name: 'buildConstants: LABELS is frozen', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { LABELS } = buildConstants(config); + return Object.isFrozen(LABELS); + }, + }, + { + name: 'buildConstants: SKILL_HIERARCHY matches config order', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { SKILL_HIERARCHY, LABELS } = buildConstants(config); + return ( + SKILL_HIERARCHY.length === 4 && + SKILL_HIERARCHY[0] === LABELS.GOOD_FIRST_ISSUE && + SKILL_HIERARCHY[3] === LABELS.ADVANCED + ); + }, + }, + { + name: 'buildConstants: PRIORITY_HIERARCHY matches config order', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { PRIORITY_HIERARCHY, LABELS } = buildConstants(config); + return ( + PRIORITY_HIERARCHY.length === 4 && + PRIORITY_HIERARCHY[0] === LABELS.PRIORITY_CRITICAL && + PRIORITY_HIERARCHY[3] === LABELS.PRIORITY_LOW + ); + }, + }, + { + name: 'buildConstants: SKILL_PREREQUISITES has correct progression', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { SKILL_PREREQUISITES, LABELS } = buildConstants(config); + return ( + SKILL_PREREQUISITES[LABELS.GOOD_FIRST_ISSUE].requiredLabel === null && + SKILL_PREREQUISITES[LABELS.BEGINNER].requiredLabel === LABELS.GOOD_FIRST_ISSUE && + SKILL_PREREQUISITES[LABELS.BEGINNER].requiredCount === 2 && + SKILL_PREREQUISITES[LABELS.INTERMEDIATE].requiredLabel === LABELS.BEGINNER && + SKILL_PREREQUISITES[LABELS.INTERMEDIATE].requiredCount === 3 && + SKILL_PREREQUISITES[LABELS.ADVANCED].requiredLabel === LABELS.INTERMEDIATE && + SKILL_PREREQUISITES[LABELS.ADVANCED].requiredCount === 3 + ); + }, + }, + { + name: 'buildConstants: DOCUMENTATION has all expected keys', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { DOCUMENTATION } = buildConstants(config); + return ( + typeof DOCUMENTATION.workflowGuide === 'string' && + typeof DOCUMENTATION.readme === 'string' && + typeof DOCUMENTATION.signingGuide === 'string' && + typeof DOCUMENTATION.mergeConflictsGuide === 'string' + ); + }, + }, + { + name: 'buildConstants: COMMUNITY has discordChannel', + test: () => { + const config = loadAutomationConfig(DEFAULT_CONFIG_PATH); + const { COMMUNITY } = buildConstants(config); + return typeof COMMUNITY.discordChannel === 'string' && COMMUNITY.discordChannel.length > 0; + }, + }, + + // --------------------------------------------------------------------------- + // File-level errors + // --------------------------------------------------------------------------- + { + name: 'loadAutomationConfig: missing file → clear error', + test: () => { + return expectLoadError('/nonexistent/path/config.json', 'Failed to read automation config'); + }, + }, + { + name: 'loadAutomationConfig: malformed JSON → clear error', + test: () => { + const p = writeTempConfig('malformed.json', '{ broken json!!!'); + return expectLoadError(p, 'Failed to parse automation config'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: teams + // --------------------------------------------------------------------------- + { + name: 'validation: missing maintainerTeam → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.maintainerTeam; + const p = writeTempConfig('no-team.json', cfg); + return expectLoadError(p, 'maintainerTeam must be a non-empty string'); + }, + }, + { + name: 'validation: empty goodFirstIssueSupportTeam → error', + test: () => { + const cfg = getValidConfig(); + cfg.goodFirstIssueSupportTeam = ''; + const p = writeTempConfig('empty-gfi-team.json', cfg); + return expectLoadError(p, 'goodFirstIssueSupportTeam must be a non-empty string'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: labels + // --------------------------------------------------------------------------- + { + name: 'validation: missing labels → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.labels; + const p = writeTempConfig('no-labels.json', cfg); + return expectLoadError(p, 'labels must be an object'); + }, + }, + { + name: 'validation: missing labels.skill → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.labels.skill; + const p = writeTempConfig('no-skill-labels.json', cfg); + return expectLoadError(p, 'labels.skill must be an object'); + }, + }, + { + name: 'validation: empty label value → error', + test: () => { + const cfg = getValidConfig(); + cfg.labels.status.awaitingTriage = ''; + const p = writeTempConfig('empty-label.json', cfg); + return expectLoadError(p, 'labels.status.awaitingTriage is required and must be a non-empty string'); + }, + }, + { + name: 'validation: missing required status key (blocked) → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.labels.status.blocked; + const p = writeTempConfig('no-blocked.json', cfg); + return expectLoadError(p, 'labels.status.blocked is required and must be a non-empty string'); + }, + }, + { + name: 'validation: missing required skill key (beginner) → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.labels.skill.beginner; + const p = writeTempConfig('no-beginner.json', cfg); + return expectLoadError(p, 'labels.skill.beginner is required and must be a non-empty string'); + }, + }, + { + name: 'validation: missing required priority key (high) → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.labels.priority.high; + const p = writeTempConfig('no-high.json', cfg); + return expectLoadError(p, 'labels.priority.high is required and must be a non-empty string'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: hierarchies + // --------------------------------------------------------------------------- + { + name: 'validation: empty skillHierarchy → error', + test: () => { + const cfg = getValidConfig(); + cfg.skillHierarchy = []; + const p = writeTempConfig('empty-skill-hier.json', cfg); + return expectLoadError(p, 'skillHierarchy must be a non-empty array'); + }, + }, + { + name: 'validation: skillHierarchy entry not in labels.skill → error', + test: () => { + const cfg = getValidConfig(); + cfg.skillHierarchy.push('skill: nonexistent'); + const p = writeTempConfig('bad-skill-hier.json', cfg); + return expectLoadError(p, 'skillHierarchy entry "skill: nonexistent" not found in labels.skill values'); + }, + }, + { + name: 'validation: priorityHierarchy entry not in labels.priority → error', + test: () => { + const cfg = getValidConfig(); + cfg.priorityHierarchy.push('priority: ultra'); + const p = writeTempConfig('bad-prio-hier.json', cfg); + return expectLoadError(p, 'priorityHierarchy entry "priority: ultra" not found in labels.priority values'); + }, + }, + { + name: 'validation: duplicate skillHierarchy entry → error', + test: () => { + const cfg = getValidConfig(); + cfg.skillHierarchy.push(cfg.skillHierarchy[0]); + const p = writeTempConfig('dup-skill-hier.json', cfg); + return expectLoadError(p, 'appears more than once'); + }, + }, + { + name: 'validation: duplicate priorityHierarchy entry → error', + test: () => { + const cfg = getValidConfig(); + cfg.priorityHierarchy.push(cfg.priorityHierarchy[0]); + const p = writeTempConfig('dup-prio-hier.json', cfg); + return expectLoadError(p, 'appears more than once'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: skill prerequisites + // --------------------------------------------------------------------------- + { + name: 'validation: skillPrerequisites key not in skillHierarchy → error', + test: () => { + const cfg = getValidConfig(); + cfg.skillPrerequisites['skill: phantom'] = { requiredLabel: null, requiredCount: 0, displayName: 'Phantom' }; + const p = writeTempConfig('bad-prereq-key.json', cfg); + return expectLoadError(p, 'skillPrerequisites key "skill: phantom" not found in skillHierarchy'); + }, + }, + { + name: 'validation: non-null requiredLabel not in skillHierarchy → error', + test: () => { + const cfg = getValidConfig(); + cfg.skillPrerequisites['skill: beginner'].requiredLabel = 'skill: imaginary'; + const p = writeTempConfig('bad-prereq-label.json', cfg); + return expectLoadError(p, 'requiredLabel "skill: imaginary" not found in skillHierarchy'); + }, + }, + { + name: 'validation: skillHierarchy entry missing from skillPrerequisites → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.skillPrerequisites['skill: intermediate']; + const p = writeTempConfig('missing-prereq-entry.json', cfg); + return expectLoadError(p, 'skillPrerequisites is missing entry for skillHierarchy value "skill: intermediate"'); + }, + }, + { + name: 'validation: prerequisite missing requiredLabel → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.skillPrerequisites['skill: good first issue'].requiredLabel; + const p = writeTempConfig('no-req-label.json', cfg); + return expectLoadError(p, 'requiredLabel is required'); + }, + }, + { + name: 'validation: prerequisite missing requiredCount → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.skillPrerequisites['skill: beginner'].requiredCount; + const p = writeTempConfig('no-req-count.json', cfg); + return expectLoadError(p, 'requiredCount must be a non-negative integer'); + }, + }, + { + name: 'validation: prerequisite missing displayName → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.skillPrerequisites['skill: advanced'].displayName; + const p = writeTempConfig('no-display-name.json', cfg); + return expectLoadError(p, 'displayName is required and must be a non-empty string'); + }, + }, + { + name: 'validation: prerequisite missing prerequisiteDisplayName when requiredLabel is not null → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.skillPrerequisites['skill: intermediate'].prerequisiteDisplayName; + const p = writeTempConfig('no-prereq-display.json', cfg); + return expectLoadError(p, 'prerequisiteDisplayName is required when requiredLabel is not null'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: assignment limits + // --------------------------------------------------------------------------- + { + name: 'validation: missing assignmentLimits → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.assignmentLimits; + const p = writeTempConfig('no-limits.json', cfg); + return expectLoadError(p, 'assignmentLimits must be an object'); + }, + }, + { + name: 'validation: maxOpenAssignments = 0 → error (must be positive)', + test: () => { + const cfg = getValidConfig(); + cfg.assignmentLimits.maxOpenAssignments = 0; + const p = writeTempConfig('zero-limit.json', cfg); + return expectLoadError(p, 'maxOpenAssignments must be a positive integer'); + }, + }, + { + name: 'validation: maxGfiCompletions = -1 → error (must be positive)', + test: () => { + const cfg = getValidConfig(); + cfg.assignmentLimits.maxGfiCompletions = -1; + const p = writeTempConfig('neg-gfi.json', cfg); + return expectLoadError(p, 'maxGfiCompletions must be a positive integer'); + }, + }, + { + name: 'validation: maxOpenAssignments = 1.5 → error (must be integer)', + test: () => { + const cfg = getValidConfig(); + cfg.assignmentLimits.maxOpenAssignments = 1.5; + const p = writeTempConfig('float-limit.json', cfg); + return expectLoadError(p, 'maxOpenAssignments must be a positive integer'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: documentation + // --------------------------------------------------------------------------- + { + name: 'validation: missing documentation → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.documentation; + const p = writeTempConfig('no-docs.json', cfg); + return expectLoadError(p, 'documentation must be an object'); + }, + }, + { + name: 'validation: empty documentation value → error', + test: () => { + const cfg = getValidConfig(); + cfg.documentation.signingGuide = ''; + const p = writeTempConfig('empty-doc.json', cfg); + return expectLoadError(p, 'documentation.signingGuide is required and must be a non-empty string'); + }, + }, + { + name: 'validation: missing required documentation key (readme) → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.documentation.readme; + const p = writeTempConfig('no-readme-doc.json', cfg); + return expectLoadError(p, 'documentation.readme is required and must be a non-empty string'); + }, + }, + + // --------------------------------------------------------------------------- + // Validation: community + // --------------------------------------------------------------------------- + { + name: 'validation: missing community → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.community; + const p = writeTempConfig('no-community.json', cfg); + return expectLoadError(p, 'community must be an object'); + }, + }, + { + name: 'validation: empty community value → error', + test: () => { + const cfg = getValidConfig(); + cfg.community.discordChannel = ' '; + const p = writeTempConfig('empty-discord.json', cfg); + return expectLoadError(p, 'community.discordChannel is required and must be a non-empty string'); + }, + }, + { + name: 'validation: missing required community key (discordChannel) → error', + test: () => { + const cfg = getValidConfig(); + delete cfg.community.discordChannel; + const p = writeTempConfig('no-discord.json', cfg); + return expectLoadError(p, 'community.discordChannel is required and must be a non-empty string'); + }, + }, + + // --------------------------------------------------------------------------- + // Custom config with different values + // --------------------------------------------------------------------------- + { + name: 'loadAutomationConfig: loads a custom config with alternate values', + test: () => { + const cfg = getValidConfig(); + cfg.maintainerTeam = '@my-org/my-team'; + cfg.assignmentLimits.maxOpenAssignments = 5; + const p = writeTempConfig('custom.json', cfg); + const config = loadAutomationConfig(p); + const derived = buildConstants(config); + return ( + derived.MAINTAINER_TEAM === '@my-org/my-team' && + config.assignmentLimits.maxOpenAssignments === 5 + ); + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runUnitTests() { + console.log('🔬 UNIT TESTS (config-loader)'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (const test of unitTests) { + try { + const result = await Promise.resolve(test.test()); + if (result) { + console.log(`✅ ${test.name}`); + passed++; + } else { + console.log(`❌ ${test.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${test.name} - Error: ${error.message}`); + failed++; + } + } + console.log('\n' + '-'.repeat(70)); + console.log(`Unit Tests: ${passed} passed, ${failed} failed`); + + // Cleanup scratch files + cleanupScratch(); + + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('CONFIG LOADER TEST SUITE', [], async () => true, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-finalize-bot.js b/.github/scripts/tests/test-finalize-bot.js new file mode 100644 index 0000000..6446e05 --- /dev/null +++ b/.github/scripts/tests/test-finalize-bot.js @@ -0,0 +1,710 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-finalize-bot.js +// +// Local test script for the /finalize command (bot-on-comment.js → commands/finalize.js). +// Run with: node .github/scripts/tests/test-finalize-bot.js +// +// Mocks the GitHub API and runs scenarios to verify /finalize behaves correctly +// without making real API calls. + +const { LABELS } = require('../helpers'); +const script = require('../bot-on-comment.js'); +const { runTestSuite, verifyComments } = require('./test-utils'); +const { + parseSections, + isMeaningfulContent, +} = require('../commands/finalize-comments'); + +// ============================================================================= +// MOCK GITHUB API +// ============================================================================= + +function createMockGithub(options = {}) { + const { + roleName = 'triage', // role_name returned by getCollaboratorPermissionLevel + permissionShouldFail = false, // throw HTTP 500 on permission check + permissionNotFound = false, // throw HTTP 404 (non-collaborator) + updateShouldFail = false, // throw on issues.update + removeLabelShouldFail = false, + addLabelShouldFail = false, + } = options; + + const calls = { + comments: [], + reactions: [], + labelsAdded: [], + labelsRemoved: [], + issueUpdates: [], + permissionChecks: [], + }; + + return { + calls, + rest: { + reactions: { + createForIssueComment: async (params) => { + calls.reactions.push({ commentId: params.comment_id, content: params.content }); + console.log(`\n👍 REACTION ADDED: ${params.content}`); + }, + }, + repos: { + getCollaboratorPermissionLevel: async (params) => { + calls.permissionChecks.push(params.username); + console.log(`\n🔐 PERMISSION CHECK: @${params.username}`); + if (permissionNotFound) { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + if (permissionShouldFail) { + const err = new Error('Simulated permission check failure'); + err.status = 500; + throw err; + } + console.log(` → role_name: ${roleName}`); + return { data: { role_name: roleName, permission: roleName } }; + }, + }, + issues: { + createComment: async (params) => { + calls.comments.push(params.body); + console.log('\n📝 COMMENT POSTED:'); + console.log('─'.repeat(60)); + console.log(params.body); + console.log('─'.repeat(60)); + }, + update: async (params) => { + if (updateShouldFail) { + throw new Error('Simulated issue update failure'); + } + calls.issueUpdates.push({ title: params.title, body: params.body }); + console.log(`\n✏️ ISSUE UPDATED: title="${params.title}"`); + }, + addLabels: async (params) => { + if (addLabelShouldFail) { + throw new Error('Simulated add label failure'); + } + calls.labelsAdded.push(...params.labels); + console.log(`\n🏷️ LABEL ADDED: ${params.labels.join(', ')}`); + }, + removeLabel: async (params) => { + if (removeLabelShouldFail) { + throw new Error('Simulated remove label failure'); + } + calls.labelsRemoved.push(params.name); + console.log(`\n🏷️ LABEL REMOVED: ${params.name}`); + }, + }, + }, + }; +} + +// ============================================================================= +// HELPERS +// ============================================================================= + +function makeIssue(overrides = {}) { + return { + number: 42, + title: 'Fix something', + state: 'open', + body: '### 👾 Description of the Issue\n\nThis thing is broken.\n\n### ✔️ Acceptance Criteria\n\n- [ ] Fixed', + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.BEGINNER }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + assignees: [], + ...overrides, + }; +} + +function makeContext(issue, commentBody = '/finalize', commenter = 'maintainer') { + return { + eventName: 'issue_comment', + payload: { + issue, + comment: { + id: 9001, + body: commentBody, + user: { login: commenter, type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }; +} + +async function runScenario(scenario, index) { + console.log('\n' + '='.repeat(70)); + console.log(`TEST ${index + 1}: ${scenario.name}`); + console.log(`DESC: ${scenario.description}`); + console.log('='.repeat(70)); + + const github = createMockGithub(scenario.githubOptions || {}); + const context = scenario.context; + + let threw = null; + try { + await script({ github, context }); + } catch (e) { + threw = e; + } + + let passed = true; + const failures = []; + + // Snapshot verification for comment text + if (scenario.expectedComments !== undefined) { + const commentResult = verifyComments(scenario.expectedComments, github.calls.comments); + if (!commentResult.passed) { + passed = false; + failures.push(...commentResult.details.filter((d) => d.startsWith('❌'))); + } + } + + // Other behavioural assertions (labels, reactions, issue updates, body content) + for (const assertion of scenario.assertions || []) { + const result = assertion(github.calls, threw); + if (result !== true) { + passed = false; + failures.push(result); + } + } + + if (passed) { + console.log('\n✅ PASSED\n'); + } else { + console.log('\n❌ FAILED'); + for (const f of failures) console.log(' -', f); + console.log(); + } + + return passed; +} + +// ============================================================================= +// ASSERTION HELPERS +// ============================================================================= + +const assert = { + commentContains: (text) => (calls) => { + const found = calls.comments.some((c) => c.includes(text)); + return found || `Expected a comment containing: "${text}"`; + }, + noComments: () => (calls) => calls.comments.length === 0 || `Expected no comments, got ${calls.comments.length}`, + labelAdded: (label) => (calls) => calls.labelsAdded.includes(label) || `Expected label added: "${label}"`, + labelRemoved: (label) => (calls) => calls.labelsRemoved.includes(label) || `Expected label removed: "${label}"`, + noLabelsAdded: () => (calls) => calls.labelsAdded.length === 0 || `Expected no labels added, got: ${calls.labelsAdded}`, + noLabelsRemoved: () => (calls) => calls.labelsRemoved.length === 0 || `Expected no labels removed, got: ${calls.labelsRemoved}`, + noIssueUpdate: () => (calls) => calls.issueUpdates.length === 0 || `Expected no issue update, got ${calls.issueUpdates.length}`, + issueUpdated: () => (calls) => calls.issueUpdates.length > 0 || 'Expected issue to be updated', + titleContains: (text) => (calls) => { + const found = calls.issueUpdates.some((u) => u.title && u.title.includes(text)); + return found || `Expected updated title to contain: "${text}"`; + }, + bodyContains: (text) => (calls) => { + const found = calls.issueUpdates.some((u) => u.body && u.body.includes(text)); + return found || `Expected updated body to contain: "${text}"`; + }, + bodyNotContains: (text) => (calls) => { + const found = calls.issueUpdates.some((u) => u.body && u.body.includes(text)); + return !found || `Expected updated body NOT to contain: "${text}"`; + }, + reactionAdded: () => (calls) => calls.reactions.length > 0 || 'Expected thumbs-up reaction to be added', +}; + +// ============================================================================= +// SCENARIOS +// ============================================================================= + +// ============================================================================= +// EXPECTED COMMENT SNAPSHOTS +// ============================================================================= +// Defined as constants so the same text can be reused across scenarios that +// produce the same comment (e.g. both unauthorized scenarios). + +const COMMENT_UNAUTHORIZED = `👋 Hi @maintainer! The \`/finalize\` command is reserved for maintainers and contributors with **triage** (or higher) repository permissions. + +If you believe you should have access, please reach out to a maintainer.`; + +const COMMENT_PERMISSION_ERROR = `👋 Hi @maintainer! I encountered an error while trying to verify your permissions. + +@utkarsh232005 — could you please verify @maintainer's permissions and complete the finalization manually if appropriate? + +Sorry for the inconvenience!`; + +const COMMENT_UPDATE_FAILURE = `⚠️ Hi @maintainer! I encountered an error while trying to update the issue title or body. + +@utkarsh232005 — could you please complete the finalization manually? + +Error details: Simulated issue update failure`; + +const COMMENT_SWAP_FAILURE_REMOVE = `⚠️ The issue was updated successfully, but I encountered an error swapping the status labels. + +@utkarsh232005 — please manually: +- Remove the \`status: awaiting triage\` label +- Add the \`status: ready for dev\` label + +Error details: Failed to remove 'status: awaiting triage': Simulated remove label failure`; + +const COMMENT_SWAP_FAILURE_ADD = `⚠️ The issue was updated successfully, but I encountered an error swapping the status labels. + +@utkarsh232005 — please manually: +- Remove the \`status: awaiting triage\` label +- Add the \`status: ready for dev\` label + +Error details: Failed to add 'status: ready for dev': Simulated add label failure`; + +const COMMENT_SUCCESS_GFI_LOW = `✅ Issue finalized by @maintainer! + +**Skill level:** \`Good First Issue\` +**Priority:** \`priority: low\` + +The issue body has been updated with the appropriate skill-level context and contribution guide. This issue is now ready for contributors to pick up via \`/assign\`.`; + +const COMMENT_SUCCESS_BEGINNER_MEDIUM = `✅ Issue finalized by @maintainer! + +**Skill level:** \`Beginner\` +**Priority:** \`priority: medium\` + +The issue body has been updated with the appropriate skill-level context and contribution guide. This issue is now ready for contributors to pick up via \`/assign\`.`; + +const COMMENT_SUCCESS_INTERMEDIATE_HIGH = `✅ Issue finalized by @maintainer! + +**Skill level:** \`Intermediate\` +**Priority:** \`priority: high\` + +The issue body has been updated with the appropriate skill-level context and contribution guide. This issue is now ready for contributors to pick up via \`/assign\`.`; + +const COMMENT_SUCCESS_ADVANCED_MEDIUM = `✅ Issue finalized by @maintainer! + +**Skill level:** \`Advanced\` +**Priority:** \`priority: medium\` + +The issue body has been updated with the appropriate skill-level context and contribution guide. This issue is now ready for contributors to pick up via \`/assign\`.`; + +/** Builds the standard validation-error comment for one or more violations. */ +function validationComment(...errors) { + const errorList = errors.map((e) => `- ${e}`).join('\n'); + return `👋 Hi @maintainer! The issue isn't quite ready to finalize yet. Please fix the following labeling issue(s) and then comment \`/finalize\` again:\n\n${errorList}\n\nIf you have questions about which labels to apply, see the maintainer documentation or ask in the team channel.`; +} + +// Pre-built validation error strings matching the exact output of collectLabelViolations. +const ERR_MISSING_TRIAGE = `The \`status: awaiting triage\` label must be present to run \`/finalize\`. Current status label(s): \`status: ready for dev\`.`; +const ERR_NO_SKILL = `Exactly one \`skill:\` label is required (e.g. \`skill: beginner\`). None found. Choose from: \`skill: good first issue\`, \`skill: beginner\`, \`skill: intermediate\`, \`skill: advanced\`.`; +const ERR_MULTIPLE_SKILLS = `Exactly one \`skill:\` label is required. Found 2: \`skill: beginner\`, \`skill: intermediate\`. Please remove all but one.`; +const ERR_NO_PRIORITY = `Exactly one \`priority:\` label is required (e.g. \`priority: medium\`). None found.`; + +const ERR_UNKNOWN_TYPE = `The issue type (Bug, Feature, or Task) could not be determined. Ensure the issue was submitted using one of the official issue templates.`; + +// ============================================================================= +// SCENARIOS +// ============================================================================= + +const scenarios = [ + // --------------------------------------------------------------------------- + // AUTHORIZATION + // --------------------------------------------------------------------------- + + { + name: 'Unauthorized — read-only collaborator', + description: 'A collaborator with "read" role is rejected', + context: makeContext(makeIssue()), + githubOptions: { roleName: 'read' }, + expectedComments: [COMMENT_UNAUTHORIZED], + assertions: [ + assert.reactionAdded(), + assert.noIssueUpdate(), + assert.noLabelsAdded(), + ], + }, + + { + name: 'Unauthorized — non-collaborator (404)', + description: 'A user who is not a repo collaborator is rejected', + context: makeContext(makeIssue()), + githubOptions: { permissionNotFound: true }, + expectedComments: [COMMENT_UNAUTHORIZED], + assertions: [ + assert.reactionAdded(), + assert.noIssueUpdate(), + ], + }, + + { + name: 'Permission check API error', + description: 'When the permission API fails, posts an error comment and tags maintainers', + context: makeContext(makeIssue()), + githubOptions: { permissionShouldFail: true }, + expectedComments: [COMMENT_PERMISSION_ERROR], + assertions: [ + assert.reactionAdded(), + assert.noIssueUpdate(), + ], + }, + + // --------------------------------------------------------------------------- + // LABEL VALIDATION + // --------------------------------------------------------------------------- + + { + name: 'Validation — missing status: awaiting triage', + description: 'Issue has a different status label — should fail validation', + context: makeContext(makeIssue({ + labels: [ + { name: LABELS.READY_FOR_DEV }, + { name: LABELS.BEGINNER }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [validationComment(ERR_MISSING_TRIAGE)], + assertions: [ + assert.noIssueUpdate(), + assert.noLabelsAdded(), + ], + }, + + { + name: 'Validation — no skill label', + description: 'Issue is missing a skill: label', + context: makeContext(makeIssue({ + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [validationComment(ERR_NO_SKILL)], + assertions: [ + assert.noIssueUpdate(), + ], + }, + + { + name: 'Validation — multiple skill labels', + description: 'Issue has two skill: labels — exactly one is required', + context: makeContext(makeIssue({ + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.BEGINNER }, + { name: LABELS.INTERMEDIATE }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [validationComment(ERR_MULTIPLE_SKILLS)], + assertions: [ + assert.noIssueUpdate(), + ], + }, + + { + name: 'Validation — no priority label', + description: 'Issue is missing a priority: label', + context: makeContext(makeIssue({ + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.BEGINNER }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [validationComment(ERR_NO_PRIORITY)], + assertions: [ + assert.noIssueUpdate(), + ], + }, + + { + name: 'Validation — Multiple violations listed in one comment', + description: 'All violations (no skill, no priority) reported together', + context: makeContext(makeIssue({ + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + ], + type: { name: 'Feature' }, + })), + githubOptions: { roleName: 'admin' }, + expectedComments: [validationComment(ERR_NO_SKILL, ERR_NO_PRIORITY)], + assertions: [ + assert.noIssueUpdate(), + ], + }, + + { + name: 'Validation — Unknown issue type', + description: 'Issue created without a recognized type triggers a validation error', + context: makeContext(makeIssue({ type: null })), + githubOptions: { roleName: 'triage' }, + expectedComments: [validationComment(ERR_UNKNOWN_TYPE)], + assertions: [ + assert.noIssueUpdate(), + ], + }, + + // --------------------------------------------------------------------------- + // HAPPY PATHS + // --------------------------------------------------------------------------- + + { + name: 'Happy Path — Good First Issue (Feature)', + description: 'Valid GFI feature issue is finalized successfully', + context: makeContext(makeIssue({ + title: 'Add batch query support', + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.GOOD_FIRST_ISSUE }, + { name: 'priority: low' }, + ], + type: { name: 'Feature' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [COMMENT_SUCCESS_GFI_LOW], + assertions: [ + assert.reactionAdded(), + assert.issueUpdated(), + assert.titleContains('[Good First Issue]:'), + assert.bodyContains('First-Time Friendly'), + assert.bodyContains('About Good First Issues'), + assert.bodyContains('Step-by-Step Contribution Guide'), + assert.labelAdded(LABELS.READY_FOR_DEV), + assert.labelRemoved(LABELS.AWAITING_TRIAGE), + ], + }, + + { + name: 'Happy Path — Beginner Task', + description: 'Valid beginner task is finalized; title prefix added, body reconstructed', + context: makeContext(makeIssue()), + githubOptions: { roleName: 'triage' }, + expectedComments: [COMMENT_SUCCESS_BEGINNER_MEDIUM], + assertions: [ + assert.reactionAdded(), + assert.issueUpdated(), + assert.titleContains('[Beginner]: Fix something'), + assert.bodyContains('Beginner Friendly'), + assert.bodyContains('About Beginner Issues'), + assert.bodyContains('Step-by-Step Contribution Guide'), + assert.labelAdded(LABELS.READY_FOR_DEV), + assert.labelRemoved(LABELS.AWAITING_TRIAGE), + ], + }, + + { + name: 'Happy Path — Intermediate Bug', + description: 'Valid intermediate bug report is finalized', + context: makeContext(makeIssue({ + title: 'Client fee cap silently ignored', + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.INTERMEDIATE }, + { name: 'priority: high' }, + ], + type: { name: 'Bug' }, + })), + githubOptions: { roleName: 'write' }, + expectedComments: [COMMENT_SUCCESS_INTERMEDIATE_HIGH], + assertions: [ + assert.issueUpdated(), + assert.titleContains('[Intermediate]:'), + assert.bodyContains('Intermediate Friendly'), + assert.bodyContains('About Intermediate Issues'), + assert.bodyContains('Step-by-Step Contribution Guide'), + assert.labelAdded(LABELS.READY_FOR_DEV), + ], + }, + + { + name: 'Happy Path — Advanced Task', + description: 'Valid advanced task is finalized', + context: makeContext(makeIssue({ + title: 'Improve issue triage workflow', + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.ADVANCED }, + { name: 'priority: medium' }, + { name: 'scope: ci' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'admin' }, + expectedComments: [COMMENT_SUCCESS_ADVANCED_MEDIUM], + assertions: [ + assert.issueUpdated(), + assert.titleContains('[Advanced]:'), + assert.bodyContains('🧠 Advanced'), + assert.bodyContains('About Advanced Issues'), + assert.bodyContains('Step-by-Step Contribution Guide'), + assert.labelAdded(LABELS.READY_FOR_DEV), + ], + }, + + { + name: 'Happy Path — existing prefix is replaced', + description: 'An issue that was already finalized once gets the correct prefix when re-finalized', + context: makeContext(makeIssue({ + title: '[Beginner]: Fix something', + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.ADVANCED }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'maintain' }, + expectedComments: [COMMENT_SUCCESS_ADVANCED_MEDIUM], + assertions: [ + assert.issueUpdated(), + assert.titleContains('[Advanced]: Fix something'), + ], + }, + + // --------------------------------------------------------------------------- + // API FAILURE PATHS + // --------------------------------------------------------------------------- + + { + name: 'API failure — issue update fails', + description: 'When issues.update throws, a failure comment is posted and labels are NOT swapped', + context: makeContext(makeIssue()), + githubOptions: { roleName: 'triage', updateShouldFail: true }, + expectedComments: [COMMENT_UPDATE_FAILURE], + assertions: [ + assert.noLabelsAdded(), + assert.noLabelsRemoved(), + ], + }, + + { + name: 'API failure — remove label fails after successful update', + description: 'When removeLabel throws, the swap failure comment and success comment are both posted', + context: makeContext(makeIssue()), + githubOptions: { roleName: 'triage', removeLabelShouldFail: true }, + expectedComments: [COMMENT_SWAP_FAILURE_REMOVE, COMMENT_SUCCESS_BEGINNER_MEDIUM], + assertions: [ + assert.issueUpdated(), + assert.labelAdded(LABELS.READY_FOR_DEV), // add still runs and succeeds + assert.noLabelsRemoved(), // remove failed + ], + }, + + { + name: 'API failure — add label fails after successful update and remove', + description: 'When addLabels throws, the swap failure comment and success comment are both posted', + context: makeContext(makeIssue()), + githubOptions: { roleName: 'triage', addLabelShouldFail: true }, + expectedComments: [COMMENT_SWAP_FAILURE_ADD, COMMENT_SUCCESS_BEGINNER_MEDIUM], + assertions: [ + assert.issueUpdated(), + assert.labelRemoved(LABELS.AWAITING_TRIAGE), // remove succeeded + assert.noLabelsAdded(), // add failed + ], + }, + + { + name: 'Body reconstruction — user-provided Additional Information is preserved', + description: 'When the Additional Information section has real user content it should not be replaced with the default Discord link text', + context: makeContext(makeIssue({ + body: [ + '### 👾 Description of the Issue\n\nSome bug.\n\n', + '### ✔️ Acceptance Criteria\n\n- [ ] Fixed\n\n', + '### 🤔 Additional Information\n\nSee the internal bug tracker for full repro steps.', + ].join(''), + labels: [ + { name: LABELS.AWAITING_TRIAGE }, + { name: LABELS.BEGINNER }, + { name: 'priority: medium' }, + ], + type: { name: 'Task' }, + })), + githubOptions: { roleName: 'triage' }, + expectedComments: [COMMENT_SUCCESS_BEGINNER_MEDIUM], + assertions: [ + assert.issueUpdated(), + assert.bodyContains('See the internal bug tracker for full repro steps.'), + assert.bodyNotContains('If you have questions while working on this issue'), + ], + }, +]; + +// ============================================================================= +// UNIT TESTS — pure functions from finalize-comments.js +// ============================================================================= + +async function runUnitTests() { + let total = 0; + let passed = 0; + let failed = 0; + + function check(name, actual, expected) { + total++; + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + passed++; + console.log(` ✅ ${name}`); + } else { + failed++; + console.log(` ❌ ${name}`); + console.log(` expected: ${JSON.stringify(expected)}`); + console.log(` actual: ${JSON.stringify(actual)}`); + } + } + + console.log('\n📐 UNIT TESTS — parseSections / isMeaningfulContent'); + console.log('─'.repeat(60)); + + // parseSections + check('parseSections: null → []', parseSections(null), []); + check('parseSections: empty string → []', parseSections(''), []); + check('parseSections: no headers → single null-header entry', + parseSections('just some text'), + [{ header: null, content: 'just some text' }] + ); + check('parseSections: single section', + parseSections('### My Header\n\nsome content'), + [{ header: 'My Header', content: 'some content' }] + ); + check('parseSections: two sections', + parseSections('### First\n\ncontent one\n\n### Second\n\ncontent two'), + [ + { header: 'First', content: 'content one' }, + { header: 'Second', content: 'content two' }, + ] + ); + check('parseSections: section with no content', + parseSections('### Header\n'), + [{ header: 'Header', content: '' }] + ); + check('parseSections: leading content then a header', + parseSections('preamble\n### Section\n\nbody'), + [ + { header: null, content: 'preamble' }, + { header: 'Section', content: 'body' }, + ] + ); + + // isMeaningfulContent + check('isMeaningfulContent: null → false', isMeaningfulContent(null), false); + check('isMeaningfulContent: empty string → false', isMeaningfulContent(''), false); + check('isMeaningfulContent: whitespace only → false', isMeaningfulContent(' '), false); + check('isMeaningfulContent: "Optional." → false', isMeaningfulContent('Optional.'), false); + check('isMeaningfulContent: "_No response_" → false', isMeaningfulContent('_No response_'), false); + check('isMeaningfulContent: real content → true', isMeaningfulContent('Some real text here.'), true); + check('isMeaningfulContent: whitespace-padded real content → true', isMeaningfulContent(' Real content '), true); + + return { total, passed, failed }; +} + +// ============================================================================= +// RUNNER +// ============================================================================= + +runTestSuite('FINALIZE COMMAND TEST SUITE', scenarios, runScenario, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-inactivity-bot.js b/.github/scripts/tests/test-inactivity-bot.js new file mode 100644 index 0000000..3f7a710 --- /dev/null +++ b/.github/scripts/tests/test-inactivity-bot.js @@ -0,0 +1,1270 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-inactivity-bot.js +// +// Integration tests for bot-inactivity.js. +// Run with: node .github/scripts/tests/test-inactivity-bot.js + +const { runTestSuite } = require('./test-utils'); +const script = require('../bot-inactivity.js'); +const { LABELS } = require('../helpers/constants'); + +// ============================================================================= +// TIME HELPERS +// ============================================================================= + +// Fixed "now" for all tests +const NOW = new Date('2024-01-15T12:00:00Z').getTime(); + +function daysAgo(n) { + return new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString(); +} + +// ============================================================================= +// MOCK GITHUB FACTORY +// ============================================================================= + +/** + * Creates a mock GitHub API for bot-inactivity.js tests. + * + * @param {object} opts + * @param {object[]} opts.assignedIssues - Items returned by issues.listForRepo (assignee:*) + * @param {object[]} opts.openPRs - Items returned by pulls.list + * @param {object} opts.commentsByNumber - number -> comment[] (issues.listComments) + * @param {object} opts.commitsByPRNumber - prNumber -> commit[] (pulls.listCommits) + * @param {object} opts.issuesByNumber - number -> issue data (issues.get) + * @param {object} opts.eventsByNumber - number -> event[] (issues.listEvents) + */ +function createMockGithub(opts = {}) { + const { + assignedIssues = [], + openPRs = [], + commentsByNumber = {}, + commitsByPRNumber = {}, + issuesByNumber = {}, + eventsByNumber = {}, + } = opts; + + const calls = { + itemsClosed: [], // issue_numbers that were closed + commentsCreated: [], // { issue_number, body } + commentsUpdated: [], // { comment_id, body } + labelsAdded: [], // { issue_number, labels } + labelsRemoved: [], // { issue_number, name } + assigneesRemoved: [], // { issue_number, assignees } + commentsList: [], // { issue_number } — tracked for verification + }; + + const perPage = 100; + + // All comments keyed by issue/PR number (includes any pre-seeded ones used + // by postOrUpdateComment to detect existing marker comments). + const allComments = { ...commentsByNumber }; + + const mock = { + calls, + rest: { + issues: { + listForRepo: async (params) => { + const page = params.page || 1; + const start = (page - 1) * perPage; + const slice = assignedIssues.slice(start, start + perPage); + return { data: slice }; + }, + + listEvents: async (params) => { + const num = params.issue_number; + const all = eventsByNumber[num] || []; + const page = params.page || 1; + const start = (page - 1) * perPage; + return { data: all.slice(start, start + perPage) }; + }, + + listComments: async (params) => { + const num = params.issue_number; + calls.commentsList.push({ issue_number: num }); + const all = allComments[num] || []; + const page = params.page || 1; + const start = (page - 1) * perPage; + return { data: all.slice(start, start + perPage) }; + }, + + createComment: async (params) => { + calls.commentsCreated.push({ issue_number: params.issue_number, body: params.body }); + console.log(`\n📝 COMMENT CREATED on #${params.issue_number}:\n${'─'.repeat(50)}\n${params.body}\n${'─'.repeat(50)}`); + }, + + updateComment: async (params) => { + calls.commentsUpdated.push({ comment_id: params.comment_id, body: params.body }); + console.log(`\n✏️ COMMENT UPDATED (id=${params.comment_id}):\n${'─'.repeat(50)}\n${params.body}\n${'─'.repeat(50)}`); + }, + + update: async (params) => { + if (params.state === 'closed') { + calls.itemsClosed.push(params.issue_number); + console.log(`\n🔒 CLOSED #${params.issue_number}`); + } + }, + + addLabels: async (params) => { + calls.labelsAdded.push({ issue_number: params.issue_number, labels: params.labels }); + console.log(`\n🏷️ LABELS ADDED on #${params.issue_number}: ${params.labels.join(', ')}`); + }, + + removeLabel: async (params) => { + calls.labelsRemoved.push({ issue_number: params.issue_number, name: params.name }); + console.log(`\n🏷️ LABEL REMOVED on #${params.issue_number}: ${params.name}`); + }, + + removeAssignees: async (params) => { + calls.assigneesRemoved.push({ issue_number: params.issue_number, assignees: params.assignees }); + console.log(`\n👤 ASSIGNEES REMOVED on #${params.issue_number}: ${params.assignees.join(', ')}`); + }, + + get: async (params) => { + const data = issuesByNumber[params.issue_number] || { + number: params.issue_number, + state: 'open', + assignees: [], + labels: [], + created_at: daysAgo(1), + }; + return { data }; + }, + }, + + pulls: { + list: async (params) => { + const page = params.page || 1; + const start = (page - 1) * perPage; + const slice = openPRs.slice(start, start + perPage); + return { data: slice }; + }, + + listCommits: async (params) => { + const all = commitsByPRNumber[params.pull_number] || []; + const page = params.page || 1; + const start = (page - 1) * perPage; + return { data: all.slice(start, start + perPage) }; + }, + }, + }, + }; + + return mock; +} + +// ============================================================================= +// HELPERS — ITEM BUILDERS +// ============================================================================= + +function makeIssue(number, { createdAt = daysAgo(1), assignees = [], labels = [] } = {}) { + return { + number, + state: 'open', + created_at: createdAt, + assignees: assignees.map(l => ({ login: l })), + labels: labels.map(l => ({ name: l })), + pull_request: undefined, + }; +} + +function makePR(number, { createdAt = daysAgo(1), assignees = [], labels = [], body = '', authorLogin = 'contributor' } = {}) { + return { + number, + state: 'open', + created_at: createdAt, + user: { login: authorLogin, type: 'User' }, + assignees: assignees.map(l => ({ login: l })), + labels: labels.map(l => ({ name: l })), + body, + }; +} + +function makeUnlabeledEvent(labelName, createdAt) { + return { event: 'unlabeled', created_at: createdAt, label: { name: labelName } }; +} + +function makeLabeledEvent(labelName, createdAt) { + return { event: 'labeled', created_at: createdAt, label: { name: labelName } }; +} + +function makeAssignedEvent(createdAt) { + return { event: 'assigned', created_at: createdAt }; +} + +function makeComment(userLogin, createdAt, { isBot = false, body = null } = {}) { + return { + id: Math.floor(Math.random() * 100000), + user: { login: userLogin, type: isBot ? 'Bot' : 'User' }, + body: body || `Comment from ${userLogin}`, + created_at: createdAt, + }; +} + +function makeCommit(authorLogin, date) { + return { + author: { login: authorLogin }, + commit: { + author: { date }, + committer: { date }, + message: 'chore: some work', + verification: { verified: true }, + }, + }; +} + +const defaultContext = { + repo: { owner: 'test-org', repo: 'test-repo' }, +}; + +// ============================================================================= +// SCENARIOS +// ============================================================================= + +const scenarios = [ + // ── 1 ────────────────────────────────────────────────────────────────────── + { + name: 'No in-progress items — no action taken', + description: 'When there are no assigned issues or PRs, the bot should be silent.', + github: createMockGithub(), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 2 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 3 days inactive — no action', + description: 'An issue created 3 days ago with no comments should not be flagged.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(10, { createdAt: daysAgo(3), assignees: ['alice'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 10: [makeAssignedEvent(daysAgo(3))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + summaryLogs: ['#10 (issue): last activity 3d ago (assigned: alice), no action needed'], + }, + }, + + // ── 3 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 6 days inactive — warning posted', + description: 'An issue with no activity for 6 days should receive a warning comment.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(20, { createdAt: daysAgo(6), assignees: ['alice'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 20: [makeAssignedEvent(daysAgo(6))], + }, + }), + expect: { + itemsClosed: [], + commentsCreatedCount: 1, + warningPostedOn: [20], + labelsAdded: 0, + assigneesRemoved: 0, + summaryLogs: ['#20 (issue): last activity 6d ago (assigned: alice), posting inactivity warning'], + }, + }, + + // ── 4 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 8 days inactive — reset (not closed)', + description: 'An issue with no activity for 8 days should be reset but remain open.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(30, { createdAt: daysAgo(8), assignees: ['alice'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 30: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + resetCommentOn: [30], + labelsAdded: [{ issue_number: 30, labels: [LABELS.READY_FOR_DEV] }], + labelsRemoved: [{ issue_number: 30, name: LABELS.IN_PROGRESS }], + assigneesRemoved: [{ issue_number: 30, assignees: ['alice'] }], + summaryLogs: ['#30 (issue): last activity 8d ago (assigned: alice), unassigning and resetting issue'], + }, + }, + + // ── 5 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 8 days old but assignee commented 2 days ago — no action', + description: 'An assignee comment resets the inactivity clock.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(40, { createdAt: daysAgo(8), assignees: ['bob'], labels: [LABELS.IN_PROGRESS] }), + ], + commentsByNumber: { + 40: [makeComment('bob', daysAgo(2))], + }, + eventsByNumber: { + 40: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 6 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 8 days old, non-assignee commented — clock not reset, reset (not closed)', + description: 'A comment by a non-assignee (e.g. maintainer) should not reset the clock.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(50, { createdAt: daysAgo(8), assignees: ['alice'], labels: [LABELS.IN_PROGRESS] }), + ], + commentsByNumber: { + 50: [makeComment('maintainer', daysAgo(1))], + }, + eventsByNumber: { + 50: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + resetCommentOn: [50], + assigneesRemoved: [{ issue_number: 50, assignees: ['alice'] }], + }, + }, + + // ── 7 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: blocked label — skipped for inactivity, receives check-in', + description: 'Blocked issues are exempt from close/warn, but get a 30-day check-in comment.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(60, { + createdAt: daysAgo(8), + assignees: ['alice'], + labels: [LABELS.IN_PROGRESS, LABELS.BLOCKED], + }), + ], + }), + expect: { + itemsClosed: [], + checkinPostedOn: [60], + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 8 ────────────────────────────────────────────────────────────────────── + { + name: 'Issue: 8 days inactive but linked open PR has recent author commit — no action', + description: 'Activity on a linked PR (author commit 1 day ago) should protect the issue.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(70, { createdAt: daysAgo(8), assignees: ['carol'], labels: [LABELS.IN_PROGRESS] }), + ], + openPRs: [ + makePR(71, { + createdAt: daysAgo(8), + assignees: ['carol'], + authorLogin: 'carol', + body: 'Fixes #70', + }), + ], + commitsByPRNumber: { + 71: [makeCommit('carol', daysAgo(1))], + }, + eventsByNumber: { + 70: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 9 ────────────────────────────────────────────────────────────────────── + { + name: 'PR: 6 days inactive — warning posted', + description: 'An assigned PR with no activity for 6 days should receive a warning.', + github: createMockGithub({ + openPRs: [ + makePR(80, { createdAt: daysAgo(6), assignees: ['dave'], authorLogin: 'dave' }), + ], + }), + expect: { + itemsClosed: [], + commentsCreatedCount: 1, + warningPostedOn: [80], + labelsAdded: 0, + assigneesRemoved: 0, + summaryLogs: ['#80 (PR): last activity 6d ago (assigned: dave), posting inactivity warning'], + }, + }, + + // ── 10 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: 8 days inactive — closed, reset, linked issue cleaned up', + description: 'A stale PR should be closed and its linked issue unassigned and reset.', + github: createMockGithub({ + openPRs: [ + makePR(90, { + createdAt: daysAgo(8), + assignees: ['eve'], + authorLogin: 'eve', + body: 'Fixes #91', + }), + ], + issuesByNumber: { + 91: { + number: 91, + state: 'open', + assignees: [{ login: 'eve' }], + labels: [{ name: LABELS.IN_PROGRESS }], + created_at: daysAgo(8), + }, + }, + }), + expect: { + itemsClosed: [90], + closureCommentOn: [90], + linkedIssueCleaned: [91], + assigneesRemovedOn: [90, 91], + summaryLogs: ['#90 (PR): last activity 8d ago (assigned: eve), closing PR'], + }, + }, + + // ── 11 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: blocked label — skipped for inactivity, receives check-in', + description: 'Blocked PRs are exempt from close/warn, but get a 30-day check-in comment.', + github: createMockGithub({ + openPRs: [ + makePR(100, { + createdAt: daysAgo(8), + assignees: ['frank'], + authorLogin: 'frank', + labels: [LABELS.BLOCKED], + }), + ], + }), + expect: { + itemsClosed: [], + checkinPostedOn: [100], + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 12 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: 8 days old but author committed 1 day ago — no action', + description: 'A recent commit by the PR author resets the inactivity clock.', + github: createMockGithub({ + openPRs: [ + makePR(110, { createdAt: daysAgo(8), assignees: ['grace'], authorLogin: 'grace' }), + ], + commitsByPRNumber: { + 110: [makeCommit('grace', daysAgo(1))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 13 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: 8 days old, commit by different author — clock not reset, closed', + description: 'A commit by someone other than the PR author must not reset the clock.', + github: createMockGithub({ + openPRs: [ + makePR(120, { createdAt: daysAgo(8), assignees: ['henry'], authorLogin: 'henry' }), + ], + commitsByPRNumber: { + // Commit is by a maintainer, not the PR author + 120: [makeCommit('maintainer', daysAgo(1))], + }, + }), + expect: { + itemsClosed: [120], + closureCommentOn: [120], + assigneesRemoved: [{ issue_number: 120, assignees: ['henry'] }], + }, + }, + + // ── 14 ───────────────────────────────────────────────────────────────────── + { + name: 'Unassigned PR — not tracked for inactivity', + description: 'Open PRs without assignees should not be processed.', + github: createMockGithub({ + openPRs: [ + makePR(130, { createdAt: daysAgo(8), assignees: [], authorLogin: 'ivan' }), + ], + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 15 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: unblocked 3 days ago (was 8 days old) — no action', + description: 'Removing the blocked label resets the 5-day clock.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(140, { createdAt: daysAgo(8), assignees: ['judy'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 140: [makeAssignedEvent(daysAgo(8)), makeUnlabeledEvent(LABELS.BLOCKED, daysAgo(3))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 16 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: unblocked 8 days ago — reset (not closed)', + description: 'If the unblocked date is still more than 7 days ago, the issue should be reset but remain open.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(150, { createdAt: daysAgo(10), assignees: ['kate'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 150: [makeAssignedEvent(daysAgo(10)), makeUnlabeledEvent(LABELS.BLOCKED, daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + resetCommentOn: [150], + assigneesRemoved: [{ issue_number: 150, assignees: ['kate'] }], + }, + }, + + // ── 17 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: blocked, no prior check-in — check-in comment posted', + description: 'First time the bot sees a blocked item, it should post a check-in.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(160, { + createdAt: daysAgo(35), + assignees: ['liam'], + labels: [LABELS.IN_PROGRESS, LABELS.BLOCKED], + }), + ], + }), + expect: { + itemsClosed: [], + checkinPostedOn: [160], + assigneesRemoved: 0, + }, + }, + + // ── 18 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: blocked, check-in posted 35 days ago — new check-in posted', + description: 'After 30 days the check-in comment should be refreshed.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(170, { + createdAt: daysAgo(40), + assignees: ['mia'], + labels: [LABELS.IN_PROGRESS, LABELS.BLOCKED], + }), + ], + commentsByNumber: { + 170: [{ + id: 9001, + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: '\n👋 Hey @mia, just checking in!...', + created_at: daysAgo(35), + updated_at: daysAgo(35), + }], + }, + }), + expect: { + itemsClosed: [], + checkinPostedOn: [170], + assigneesRemoved: 0, + }, + }, + + // ── 19 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: blocked, check-in posted 10 days ago — no action', + description: 'If a check-in was posted within 30 days, the bot should stay quiet.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(180, { + createdAt: daysAgo(40), + assignees: ['noah'], + labels: [LABELS.IN_PROGRESS, LABELS.BLOCKED], + }), + ], + commentsByNumber: { + 180: [{ + id: 9002, + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: '\n👋 Hey @noah, just checking in!...', + created_at: daysAgo(10), + updated_at: daysAgo(10), + }], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + commentsUpdated: 0, + assigneesRemoved: 0, + }, + }, + + // ── 20 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: blocked, no prior check-in — check-in posted', + description: 'Blocked PRs also receive the 30-day check-in.', + github: createMockGithub({ + openPRs: [ + makePR(190, { + createdAt: daysAgo(35), + assignees: ['olivia'], + authorLogin: 'olivia', + labels: [LABELS.BLOCKED], + }), + ], + }), + expect: { + itemsClosed: [], + checkinPostedOn: [190], + assigneesRemoved: 0, + }, + }, + + // ── 21 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: created 30 days ago but assigned 2 days ago — no action', + description: 'The inactivity clock starts from the assignment date, not creation date.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(200, { createdAt: daysAgo(30), assignees: ['pat'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 200: [makeAssignedEvent(daysAgo(2))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 22 ───────────────────────────────────────────────────────────────────── + { + name: 'Issue: no assigned event — skipped without error', + description: 'If the events API returns no assigned event, the issue is skipped entirely.', + github: createMockGithub({ + assignedIssues: [ + makeIssue(210, { createdAt: daysAgo(10), assignees: ['quinn'], labels: [LABELS.IN_PROGRESS] }), + ], + eventsByNumber: { + 210: [], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 23 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs review — skipped entirely', + description: 'PRs waiting on maintainer review are exempt from inactivity tracking.', + github: createMockGithub({ + openPRs: [ + makePR(220, { + createdAt: daysAgo(8), + assignees: ['rose'], + authorLogin: 'rose', + labels: [LABELS.NEEDS_REVIEW], + }), + ], + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 24 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs revision labeled 2 days ago — no action', + description: 'Inactivity clock starts from when needs-revision was last applied; 2 days is under threshold.', + github: createMockGithub({ + openPRs: [ + makePR(230, { + createdAt: daysAgo(10), + assignees: ['sam'], + authorLogin: 'sam', + labels: [LABELS.NEEDS_REVISION], + }), + ], + eventsByNumber: { + 230: [makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(2))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 25 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs revision labeled 6 days ago — warning posted', + description: 'Six days since needs-revision was applied triggers the 5-day warning.', + github: createMockGithub({ + openPRs: [ + makePR(240, { + createdAt: daysAgo(10), + assignees: ['taylor'], + authorLogin: 'taylor', + labels: [LABELS.NEEDS_REVISION], + }), + ], + eventsByNumber: { + 240: [makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(6))], + }, + }), + expect: { + itemsClosed: [], + commentsCreatedCount: 1, + warningPostedOn: [240], + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 26 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs revision labeled 8 days ago — closed and reset', + description: 'Eight days since needs-revision was applied exceeds the 7-day close threshold.', + github: createMockGithub({ + openPRs: [ + makePR(250, { + createdAt: daysAgo(10), + assignees: ['uri'], + authorLogin: 'uri', + labels: [LABELS.NEEDS_REVISION], + }), + ], + eventsByNumber: { + 250: [makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(8))], + }, + }), + expect: { + itemsClosed: [250], + closureCommentOn: [250], + assigneesRemoved: [{ issue_number: 250, assignees: ['uri'] }], + }, + }, + + // ── 27 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs revision labeled 8 days ago, author commented 2 days ago — no action', + description: 'Author activity after the label is applied resets the clock; the bot should not close an actively-engaged PR.', + github: createMockGithub({ + openPRs: [ + makePR(261, { + createdAt: daysAgo(10), + assignees: ['wren'], + authorLogin: 'wren', + labels: [LABELS.NEEDS_REVISION], + }), + ], + commentsByNumber: { + 261: [makeComment('wren', daysAgo(2))], + }, + eventsByNumber: { + 261: [makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 28 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: status: needs revision applied twice — clock uses most recent application', + description: 'Back-and-forth review cycles must not penalize contributors; the clock resets on each new needs-revision application.', + github: createMockGithub({ + openPRs: [ + makePR(260, { + createdAt: daysAgo(12), + assignees: ['vera'], + authorLogin: 'vera', + labels: [LABELS.NEEDS_REVISION], + }), + ], + eventsByNumber: { + 260: [ + makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(10)), + makeUnlabeledEvent(LABELS.NEEDS_REVISION, daysAgo(8)), + makeLabeledEvent(LABELS.NEEDS_REVIEW, daysAgo(8)), + makeUnlabeledEvent(LABELS.NEEDS_REVIEW, daysAgo(2)), + makeLabeledEvent(LABELS.NEEDS_REVISION, daysAgo(2)), + ], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 29 ───────────────────────────────────────────────────────────────────── + { + name: 'PR + issue both stale — issue not double-commented after PR loop resets it', + description: 'When the PR loop resets a linked issue, the issues loop should skip it — no duplicate comment.', + github: createMockGithub({ + openPRs: [ + makePR(90, { + createdAt: daysAgo(8), + assignees: ['eve'], + authorLogin: 'eve', + body: 'Fixes #91', + }), + ], + assignedIssues: [ + makeIssue(91, { + createdAt: daysAgo(8), + assignees: ['eve'], + labels: [LABELS.IN_PROGRESS], + }), + ], + issuesByNumber: { + 91: { + number: 91, + state: 'open', + assignees: [{ login: 'eve' }], + labels: [{ name: LABELS.IN_PROGRESS }], + created_at: daysAgo(8), + }, + }, + }), + expect: { + itemsClosed: [90], + closureCommentOn: [90], + linkedIssueCleaned: [91], + assigneesRemovedOn: [90, 91], + commentsCreatedCount: 2, + }, + }, + + // ── 30 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: comment on linked issue — no action (not stale)', + description: 'A participant comment on a linked issue should reset PR inactivity.', + github: createMockGithub({ + openPRs: [ + makePR(500, { + createdAt: daysAgo(8), + assignees: ['alice'], + authorLogin: 'alice', + body: 'Fixes #501', + }), + ], + assignedIssues: [ + makeIssue(501, { + createdAt: daysAgo(8), + assignees: ['alice'], + labels: [LABELS.IN_PROGRESS], + }), + ], + commentsByNumber: { + 501: [makeComment('alice', daysAgo(1), { body: '/working' })], + }, + eventsByNumber: { + 501: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 31 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: regular comment on linked issue — no action (not stale)', + description: 'Any participant comment on linked issue should reset the clock.', + github: createMockGithub({ + openPRs: [ + makePR(510, { + createdAt: daysAgo(8), + assignees: ['bob'], + authorLogin: 'bob', + body: 'Fixes #511', + }), + ], + assignedIssues: [ + makeIssue(511, { + createdAt: daysAgo(8), + assignees: ['bob'], + labels: [LABELS.IN_PROGRESS], + }), + ], + commentsByNumber: { + 511: [makeComment('bob', daysAgo(1), { body: 'Still working on this, will update soon!' })], + }, + eventsByNumber: { + 511: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [], + commentsCreated: 0, + labelsAdded: 0, + assigneesRemoved: 0, + }, + }, + + // ── 32 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: no linked issues — stale behavior unchanged', + description: 'A PR with no linked issues should behave exactly as before.', + github: createMockGithub({ + openPRs: [ + makePR(520, { + createdAt: daysAgo(8), + assignees: ['charlie'], + authorLogin: 'charlie', + body: 'Add new feature', + }), + ], + }), + expect: { + itemsClosed: [520], + closureCommentOn: [520], + assigneesRemoved: [{ issue_number: 520, assignees: ['charlie'] }], + }, + }, + + // ── 33 ───────────────────────────────────────────────────────────────────── + { + name: 'PR: non-participant comment on linked issue — still stale, closed', + description: 'A comment from someone who is not the PR author/assignee must not reset inactivity.', + github: createMockGithub({ + openPRs: [ + makePR(530, { + createdAt: daysAgo(8), + assignees: ['diana'], + authorLogin: 'diana', + body: 'Fixes #531', + }), + ], + assignedIssues: [ + makeIssue(531, { + createdAt: daysAgo(8), + assignees: ['diana'], + labels: [LABELS.IN_PROGRESS], + }), + ], + commentsByNumber: { + 531: [makeComment('outsider', daysAgo(1), { body: 'Any update here?' })], + }, + eventsByNumber: { + 531: [makeAssignedEvent(daysAgo(8))], + }, + }), + expect: { + itemsClosed: [530], + closureCommentOn: [530], + assigneesRemoved: [{ issue_number: 530, assignees: ['diana'] }], + }, + }, + + // ── 34 ───────────────────────────────────────────────────────────────────── + { + name: 'PR closed for inactivity should not get ready-for-dev and should remove status labels', + description: 'Closed PR should have all status labels removed and not receive ready-for-dev label', + github: createMockGithub({ + openPRs: [ + makePR(30, { + createdAt: daysAgo(10), + assignees: ['alice'], + labels: [LABELS.IN_PROGRESS, LABELS.NEEDS_REVISION] + }) + ], + eventsByNumber: { + 30: [makeAssignedEvent(daysAgo(10))] + } + }), + expect: { + itemsClosed: [30], + commentsCreated: 1, + labelsAdded: 0, + labelsRemoved: [ + { issue_number: 30, name: LABELS.IN_PROGRESS }, + { issue_number: 30, name: LABELS.NEEDS_REVISION } + ], + assigneesRemoved: 1, + summaryLogs: ['#30 (PR): last activity 10d ago (assigned: alice), closing PR'], + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runScenario(scenario, index) { + const { name, description, github, expect: expected } = scenario; + + console.log(`\n${'─'.repeat(70)}`); + console.log(`[${index}] ${name}`); + if (description) console.log(` ${description}`); + + const capturedLogs = []; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + console.log = (...args) => { + capturedLogs.push(args.map(a => String(a)).join(' ')); + originalConsoleLog(...args); + }; + console.error = (...args) => { + capturedLogs.push(args.map(a => String(a)).join(' ')); + originalConsoleError(...args); + }; + + try { + await script({ github, context: defaultContext, getNow: () => NOW }); + } catch (err) { + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.error(`❌ Script threw: ${err.message}`); + console.error(err.stack); + return false; + } + console.log = originalConsoleLog; + console.error = originalConsoleError; + + const { calls } = github; + const failures = []; + + // itemsClosed + if (expected.itemsClosed !== undefined) { + const closed = calls.itemsClosed; + if (JSON.stringify(closed.sort()) !== JSON.stringify(expected.itemsClosed.sort())) { + failures.push(`itemsClosed: expected ${JSON.stringify(expected.itemsClosed)}, got ${JSON.stringify(closed)}`); + } + } + + // commentsCreated count + if (expected.commentsCreated !== undefined) { + const count = calls.commentsCreated.length + calls.commentsUpdated.length; + if (count !== expected.commentsCreated) { + failures.push(`comments posted: expected ${expected.commentsCreated}, got ${count}`); + } + } + + if (expected.commentsCreatedCount !== undefined) { + const count = calls.commentsCreated.length; + if (count !== expected.commentsCreatedCount) { + failures.push(`commentsCreated count: expected ${expected.commentsCreatedCount}, got ${count}`); + } + } + + // warningPostedOn — check that warning marker appears in comments for these items + if (expected.warningPostedOn) { + for (const num of expected.warningPostedOn) { + const MARKER = ''; + const found = calls.commentsCreated.some(c => c.issue_number === num && c.body.startsWith(MARKER)) + || calls.commentsUpdated.some(c => c.body.startsWith(MARKER)); + if (!found) { + failures.push(`Expected warning comment on #${num}`); + } + } + } + + // closureCommentOn — check that PR closure comment appears for these items + if (expected.closureCommentOn) { + for (const num of expected.closureCommentOn) { + const found = calls.commentsCreated.some(c => c.issue_number === num && c.body.includes('closed due to')); + if (!found) { + failures.push(`Expected closure comment on #${num}`); + } + } + } + + // resetCommentOn — check that issue reset comment appears for these items (not closed) + if (expected.resetCommentOn) { + for (const num of expected.resetCommentOn) { + const found = calls.commentsCreated.some(c => c.issue_number === num && c.body.includes('unassigned and reset')); + if (!found) { + failures.push(`Expected reset comment on #${num}`); + } + } + } + + // labelsAdded + if (expected.labelsAdded !== undefined) { + if (typeof expected.labelsAdded === 'number') { + if (calls.labelsAdded.length !== expected.labelsAdded) { + failures.push(`labelsAdded count: expected ${expected.labelsAdded}, got ${calls.labelsAdded.length}`); + } + } else if (Array.isArray(expected.labelsAdded)) { + for (const exp of expected.labelsAdded) { + const found = calls.labelsAdded.some( + a => a.issue_number === exp.issue_number && + JSON.stringify(a.labels) === JSON.stringify(exp.labels) + ); + if (!found) { + failures.push(`Expected labels ${JSON.stringify(exp.labels)} added on #${exp.issue_number}`); + } + } + } + } + + // labelsRemoved + if (expected.labelsRemoved !== undefined) { + if (typeof expected.labelsRemoved === 'number') { + if (calls.labelsRemoved.length !== expected.labelsRemoved) { + failures.push(`labelsRemoved count: expected ${expected.labelsRemoved}, got ${calls.labelsRemoved.length}`); + } + } else if (Array.isArray(expected.labelsRemoved)) { + for (const exp of expected.labelsRemoved) { + const found = calls.labelsRemoved.some( + r => r.issue_number === exp.issue_number && r.name === exp.name + ); + if (!found) { + failures.push(`Expected label "${exp.name}" removed on #${exp.issue_number}`); + } + } + } + } + + // assigneesRemoved + if (expected.assigneesRemoved !== undefined) { + if (typeof expected.assigneesRemoved === 'number') { + if (calls.assigneesRemoved.length !== expected.assigneesRemoved) { + failures.push(`assigneesRemoved count: expected ${expected.assigneesRemoved}, got ${calls.assigneesRemoved.length}`); + } + } else if (Array.isArray(expected.assigneesRemoved)) { + for (const exp of expected.assigneesRemoved) { + const found = calls.assigneesRemoved.some( + r => r.issue_number === exp.issue_number && + JSON.stringify(r.assignees.sort()) === JSON.stringify(exp.assignees.sort()) + ); + if (!found) { + failures.push(`Expected assignees ${JSON.stringify(exp.assignees)} removed on #${exp.issue_number}`); + } + } + } + } + + // assigneesRemovedOn — just check items (not specific logins) + if (expected.assigneesRemovedOn) { + for (const num of expected.assigneesRemovedOn) { + const found = calls.assigneesRemoved.some(r => r.issue_number === num); + if (!found) { + failures.push(`Expected assignees removed on #${num}`); + } + } + } + + // linkedIssueCleaned — issue was reset (assignees removed AND ready-for-dev label added) + if (expected.linkedIssueCleaned) { + for (const num of expected.linkedIssueCleaned) { + const unassigned = calls.assigneesRemoved.some(r => r.issue_number === num); + const relabeled = calls.labelsAdded.some(r => r.issue_number === num && r.labels.includes(LABELS.READY_FOR_DEV)); + if (!unassigned) failures.push(`Expected assignees removed on linked issue #${num}`); + if (!relabeled) failures.push(`Expected "${LABELS.READY_FOR_DEV}" added on linked issue #${num}`); + } + } + + // checkinPostedOn — blocked check-in marker appears in created or updated comments + if (expected.checkinPostedOn) { + const CHECKIN_MARKER = ''; + for (const num of expected.checkinPostedOn) { + const found = calls.commentsCreated.some(c => c.issue_number === num && c.body.startsWith(CHECKIN_MARKER)) + || calls.commentsUpdated.some(c => c.body.startsWith(CHECKIN_MARKER)); + if (!found) { + failures.push(`Expected blocked check-in comment on #${num}`); + } + } + } + + // commentsUpdated count + if (expected.commentsUpdated !== undefined) { + if (calls.commentsUpdated.length !== expected.commentsUpdated) { + failures.push(`commentsUpdated count: expected ${expected.commentsUpdated}, got ${calls.commentsUpdated.length}`); + } + } + + if (expected.summaryLogs) { + for (const expectedLine of expected.summaryLogs) { + const found = capturedLogs.some(line => line.includes(expectedLine)); + if (!found) { + failures.push(`Expected summary log line: ${expectedLine}`); + } + } + } + + if (failures.length > 0) { + for (const f of failures) console.error(` ❌ ${f}`); + return false; + } + + console.log(' ✅ All assertions passed'); + return true; +} + +// ============================================================================= +// ENTRY POINT +// ============================================================================= + +runTestSuite('INACTIVITY BOT TEST SUITE', scenarios, runScenario); diff --git a/.github/scripts/tests/test-on-comment-bot.js b/.github/scripts/tests/test-on-comment-bot.js new file mode 100644 index 0000000..82f1224 --- /dev/null +++ b/.github/scripts/tests/test-on-comment-bot.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-comment-bot.js +// +// Unit tests for parseComment in bot-on-comment.js. +// Run with: node .github/scripts/tests/test-on-comment-bot.js + +const { runTestSuite } = require('./test-utils'); +const { parseComment } = require('../bot-on-comment'); + +function deepEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +const unitTests = [ + { + name: 'exact assign', + test: () => deepEqual(parseComment('/assign'), { commands: ['assign'] }), + }, + { + name: 'near miss assign', + test: () => deepEqual(parseComment('/assign hi'), { nearMiss: 'assign' }), + }, + { + name: 'exact unassign', + test: () => deepEqual(parseComment('/unassign'), { commands: ['unassign'] }), + }, + { + name: 'near miss unassign', + test: () => deepEqual(parseComment('/unassign please'), { nearMiss: 'unassign' }), + }, + { + name: 'exact finalize', + test: () => deepEqual(parseComment('/finalize'), { commands: ['finalize'] }), + }, + { + name: 'near miss finalize', + test: () => deepEqual(parseComment('/finalize now'), { nearMiss: 'finalize' }), + }, + { + name: 'near miss assign non-whitespace separator', + test: () => deepEqual(parseComment('/assign!'), { nearMiss: 'assign' }), + }, + { + name: 'unrelated comment', + test: () => deepEqual(parseComment('hello'), { commands: [] }), + }, + { + name: 'empty string', + test: () => deepEqual(parseComment(''), { commands: [] }), + }, +]; + +async function runUnitTests() { + console.log('🧪 UNIT TESTS (parseComment)'); + console.log('-'.repeat(50)); + + let passed = 0; + let failed = 0; + + for (const t of unitTests) { + try { + const result = await Promise.resolve(t.test()); + if (result) { + console.log(`✅ ${t.name}`); + passed++; + } else { + console.log(`❌ ${t.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${t.name} - Error: ${error.message}`); + failed++; + } + } + + console.log('\n' + '-'.repeat(50)); + console.log(`Unit tests: ${passed} passed, ${failed} failed`); + + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('ON-COMMENT BOT TEST SUITE', [], async () => true, [ + { + label: 'Unit Tests', + run: runUnitTests, + }, +]); \ No newline at end of file diff --git a/.github/scripts/tests/test-on-pr-close-bot.js b/.github/scripts/tests/test-on-pr-close-bot.js new file mode 100644 index 0000000..c3c71af --- /dev/null +++ b/.github/scripts/tests/test-on-pr-close-bot.js @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-pr-close-bot.js +// +// Integration tests for bot-on-pr-close.js post-merge automation. +// Run with: node .github/scripts/tests/test-on-pr-close-bot.js + +const { runTestSuite } = require('./test-utils'); +const script = require('../bot-on-pr-close.js'); +const { LABELS, MAINTAINER_TEAM } = require('../helpers/constants'); + +function createMockGithub({ + closingIssueNumbers = [], + issues = {}, + milestones = [{ number: 9, title: 'Release', due_on: '2026-06-01T00:00:00Z' }], +} = {}) { + const calls = { + commentsCreated: [], + labelsRemoved: [], + milestonesUpdated: [], + }; + + return { + calls, + rest: { + issues: { + listMilestones: async () => ({ data: milestones }), + get: async ({ issue_number }) => { + const issue = issues[issue_number]; + if (!issue) throw new Error(`Issue #${issue_number} not found`); + return { data: issue }; + }, + removeLabel: async ({ issue_number, name }) => { + calls.labelsRemoved.push({ issue_number, name }); + return {}; + }, + update: async ({ issue_number, milestone }) => { + calls.milestonesUpdated.push({ issue_number, milestone }); + return {}; + }, + createComment: async ({ issue_number, body }) => { + calls.commentsCreated.push({ issue_number, body }); + return {}; + }, + }, + }, + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: closingIssueNumbers.map(number => ({ number })), + }, + }, + }, + }), + }; +} + +function defaultContext(overrides = {}) { + return { + eventName: 'pull_request_target', + repo: { owner: 'test-owner', repo: 'test-repo' }, + payload: { + pull_request: { + number: 100, + merged: true, + user: { login: 'contributor', type: 'User' }, + labels: [{ name: LABELS.NEEDS_REVIEW }], + }, + }, + ...overrides, + }; +} + +const scenarios = [ + { + name: 'Merged PR with linked issue removes status labels and milestones linked issue', + run: async () => { + const github = createMockGithub({ + closingIssueNumbers: [42], + issues: { + 42: { + number: 42, + title: 'Linked issue', + labels: [{ name: LABELS.IN_PROGRESS }, { name: 'area: ci' }], + }, + }, + }); + + await script({ github, context: defaultContext() }); + + const removed = github.calls.labelsRemoved; + const milestones = github.calls.milestonesUpdated; + + return ( + removed.some(call => call.issue_number === 100 && call.name === LABELS.NEEDS_REVIEW) && + removed.some(call => call.issue_number === 42 && call.name === LABELS.IN_PROGRESS) && + milestones.length === 1 && + milestones[0].issue_number === 42 && + milestones[0].milestone === 9 + ); + }, + }, + { + name: 'Merged PR without linked issues milestones the PR itself', + run: async () => { + const github = createMockGithub({ closingIssueNumbers: [] }); + + await script({ github, context: defaultContext() }); + + return ( + github.calls.milestonesUpdated.length === 1 && + github.calls.milestonesUpdated[0].issue_number === 100 && + github.calls.milestonesUpdated[0].milestone === 9 + ); + }, + }, + { + name: 'Merged PR without open milestone comments maintainers and stops', + run: async () => { + const github = createMockGithub({ + closingIssueNumbers: [42], + issues: { + 42: { number: 42, title: 'Linked issue', labels: [{ name: LABELS.IN_PROGRESS }] }, + }, + milestones: [], + }); + + await script({ github, context: defaultContext() }); + + return ( + github.calls.milestonesUpdated.length === 0 && + github.calls.commentsCreated.length === 1 && + github.calls.commentsCreated[0].issue_number === 100 && + github.calls.commentsCreated[0].body.includes(MAINTAINER_TEAM) + ); + }, + }, + { + name: 'Bot-authored merged PR still receives milestone automation', + run: async () => { + const github = createMockGithub({ closingIssueNumbers: [] }); + const context = defaultContext({ + payload: { + pull_request: { + number: 101, + merged: true, + user: { login: 'dependabot[bot]', type: 'Bot' }, + labels: [{ name: LABELS.NEEDS_REVIEW }], + }, + }, + }); + + await script({ github, context }); + + return ( + github.calls.labelsRemoved.some(call => call.issue_number === 101 && call.name === LABELS.NEEDS_REVIEW) && + github.calls.milestonesUpdated.length === 1 && + github.calls.milestonesUpdated[0].issue_number === 101 + ); + }, + }, +]; + +async function runScenario(scenario, index) { + console.log(`\nScenario ${index}: ${scenario.name}`); + try { + const passed = await scenario.run(); + console.log(passed ? 'Passed' : 'Failed'); + return passed; + } catch (error) { + console.log(`Failed with error: ${error.message}`); + console.log(error.stack); + return false; + } +} + +runTestSuite('ON-PR-CLOSE BOT TEST SUITE', scenarios, runScenario); diff --git a/.github/scripts/tests/test-on-pr-merged-bot.js b/.github/scripts/tests/test-on-pr-merged-bot.js new file mode 100644 index 0000000..3df660e --- /dev/null +++ b/.github/scripts/tests/test-on-pr-merged-bot.js @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-pr-merged-bot.js +// +// Integration tests for the bot-on-pr-merged.js script. +// Verifies that when a PR is merged, sibling conflicts are properly evaluated +// and their components (dashboard comments and labels) updated seamlessly. + +const { runTestSuite, createMockGithub } = require('./test-utils'); +const onPrMergedBot = require('../bot-on-pr-merged'); +const { MARKER } = require('../helpers/comments'); + +// ============================================================================= +// SCENARIOS +// ============================================================================= + +const scenarios = [ + { + name: 'Merged PR triggers conflict check on siblings: one clean, one with new conflict', + run: async () => { + // Mock GitHub: + // - PR 10: Just merged + // - PR 20: Sibling PR, clean + // - PR 30: Sibling PR, now dirty (shows conflict) + + const pullsState = { + 20: { mergeable: true, mergeable_state: 'clean' }, + 30: { mergeable: false, mergeable_state: 'dirty' } + }; + + const mockGithub = createMockGithub({ + existingComments: [ + // PR 20 currently clean in its comment + { id: 201, body: `${MARKER}\nAll good` } + // PR 30 currently clean in its comment, but API says dirty! + ], + }); + + // Override pulls.list to return PR 20 and 30 + mockGithub.rest.pulls.list = async () => ({ + data: [ + { number: 20, draft: false, user: { login: 'contributor-20' } }, + { number: 30, draft: false, user: { login: 'contributor-30' } }, + { number: 40, draft: true } // skipped + ] + }); + + // Override pulls.get to return mergeable state per PR + mockGithub.rest.pulls.get = async ({ pull_number }) => { + const state = pullsState[pull_number]; + return { data: { mergeable: state.mergeable, mergeable_state: state.mergeable_state } }; + }; + + const context = { + eventName: 'pull_request_target', + repo: { owner: 'test', repo: 'repo' }, + payload: { + action: 'closed', + pull_request: { + number: 10, + merged: true, + user: { login: 'contributor' } + } + } + }; + + // Also need to stub listComments to return comments per PR + mockGithub.rest.issues.listComments = async ({ issue_number }) => { + if (issue_number === 20) { + return { data: [{ id: 201, body: `${MARKER}\nNo conflicts previously` }] }; + } + if (issue_number === 30) { + return { data: [{ id: 301, body: `${MARKER}\nNo conflicts previously` }] }; + } + return { data: [] }; + }; + + await onPrMergedBot({ github: mockGithub, context }); + + // So commentsUpdated should have length 1, for PR 30 + // And commentsCreated should have length 1 (the notification for PR 30) + if (mockGithub.calls.commentsCreated.length !== 1) { + console.log('Expected 1 notification comment created, got:', mockGithub.calls.commentsCreated.length); + return false; + } + + // Verify the notification mentions the PR author and merged PR number + const notification = mockGithub.calls.commentsCreated[0]; + if (!notification.includes('@contributor-30') || !notification.includes('#10')) { + console.log('Notification should tag PR author and reference merged PR. Got:', notification); + return false; + } + + if (mockGithub.calls.commentsUpdated.length !== 1) { + console.log('Expected 1 comment updated, got:', mockGithub.calls.commentsUpdated.length); + return false; + } + + const update = mockGithub.calls.commentsUpdated[0]; + if (update.comment_id !== 301) { + console.log('Expected PR 30 comment (301) to be updated, got:', update.comment_id); + return false; + } + + if (!update.body.includes(':x: **Merge Conflicts**')) { + console.log('Update body should contain Merge Conflicts error'); + return false; + } + + return true; + } + }, + { + name: 'Merged PR triggers conflict check: PR already showed conflict and still has it (no churn)', + run: async () => { + const mockGithub = createMockGithub(); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 50, draft: false }] + }); + + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: false, mergeable_state: 'dirty' } + }); + + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 501, body: `${MARKER}\n:x: **Merge Conflicts**` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'contributor' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + // No creates or updates expected because status didn't change + if (mockGithub.calls.commentsUpdated.length !== 0 || mockGithub.calls.commentsCreated.length !== 0) { + console.log('Expected no comments updated/created due to no churn'); + return false; + } + + return true; + } + }, + { + name: 'Zero open non-draft sibling PRs -> exits cleanly', + run: async () => { + const mockGithub = createMockGithub(); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 10, draft: false }, { number: 40, draft: true }] + }); + + const context = { + eventName: 'pull_request_target', + repo: { owner: 'test', repo: 'repo' }, + payload: { + pull_request: { number: 10, merged: true, user: { login: 'contributor' } } + } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + if (mockGithub.calls.commentsUpdated.length !== 0 || mockGithub.calls.commentsCreated.length !== 0) { + console.log('Expected no comments updated/created'); + return false; + } + + return true; + } + }, + { + name: 'mergeable returns null across all retries -> treated as no conflict', + run: async () => { + const mockGithub = createMockGithub({ + existingComments: [ + { id: 601, body: `${MARKER}\nAll good` } + ], + }); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 60, draft: false }] + }); + + // API always returns null + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: null, mergeable_state: 'unknown' } + }); + + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 601, body: `${MARKER}\nAll good` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'contributor' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + if (mockGithub.calls.commentsUpdated.length !== 0 || mockGithub.calls.commentsCreated.length !== 0) { + console.log('Expected no comments updated/created'); + return false; + } + + return true; + } + }, + { + name: 'dirty -> clean: comment is updated to show no conflict', + run: async () => { + const mockGithub = createMockGithub({ + existingComments: [ + { id: 701, body: `${MARKER}\n:x: **Merge Conflicts**` } + ], + }); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 70, draft: false }] + }); + + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: true, mergeable_state: 'clean' } + }); + + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 701, body: `${MARKER}\n:x: **Merge Conflicts**` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'contributor' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + if (mockGithub.calls.commentsUpdated.length !== 1) { + console.log('Expected 1 comment updated'); + return false; + } + + const update = mockGithub.calls.commentsUpdated[0]; + if (update.body.includes(':x: **Merge Conflicts**')) { + console.log('Update body should NOT contain Merge Conflicts error. Got:', update.body); + return false; + } + + return true; + } + }, + { + name: 'New conflict detected → notification comment posted tagging PR author', + run: async () => { + const mockGithub = createMockGithub(); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 80, draft: false, user: { login: 'alice' } }] + }); + + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: false, mergeable_state: 'dirty' } + }); + + // Dashboard currently shows clean (no conflict marker) + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 801, body: `${MARKER}\nAll good` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'merger' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + // Dashboard should be updated + if (mockGithub.calls.commentsUpdated.length !== 1) { + console.log('Expected 1 comment updated, got:', mockGithub.calls.commentsUpdated.length); + return false; + } + + // Notification comment should be posted + if (mockGithub.calls.commentsCreated.length !== 1) { + console.log('Expected 1 notification comment, got:', mockGithub.calls.commentsCreated.length); + return false; + } + + const notification = mockGithub.calls.commentsCreated[0]; + if (!notification.includes('@alice')) { + console.log('Notification should tag PR author @alice. Got:', notification); + return false; + } + if (!notification.includes('#10')) { + console.log('Notification should reference merged PR #10. Got:', notification); + return false; + } + + return true; + } + }, + { + name: 'Conflict resolving (dirty → clean) → no notification comment posted', + run: async () => { + const mockGithub = createMockGithub({ + existingComments: [ + { id: 901, body: `${MARKER}\n:x: **Merge Conflicts**` } + ], + }); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 90, draft: false, user: { login: 'bob' } }] + }); + + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: true, mergeable_state: 'clean' } + }); + + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 901, body: `${MARKER}\n:x: **Merge Conflicts**` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'merger' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + // Dashboard should be updated (conflict resolved) + if (mockGithub.calls.commentsUpdated.length !== 1) { + console.log('Expected 1 comment updated, got:', mockGithub.calls.commentsUpdated.length); + return false; + } + + // NO notification comment should be posted for conflict resolution + if (mockGithub.calls.commentsCreated.length !== 0) { + console.log('Expected 0 notification comments for conflict resolution, got:', mockGithub.calls.commentsCreated.length); + return false; + } + + return true; + } + }, + { + name: 'Conflict status unchanged (still conflicted) → no notification comment', + run: async () => { + const mockGithub = createMockGithub(); + + mockGithub.rest.pulls.list = async () => ({ + data: [{ number: 100, draft: false, user: { login: 'charlie' } }] + }); + + mockGithub.rest.pulls.get = async () => ({ + data: { mergeable: false, mergeable_state: 'dirty' } + }); + + // Dashboard already shows conflict + mockGithub.rest.issues.listComments = async () => { + return { data: [{ id: 1001, body: `${MARKER}\n:x: **Merge Conflicts**` }] }; + }; + + const context = { + eventName: 'pull_request', + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 10, merged: true, user: { login: 'merger' } } } + }; + + await onPrMergedBot({ github: mockGithub, context }); + + // No updates or notifications expected + if (mockGithub.calls.commentsUpdated.length !== 0) { + console.log('Expected 0 comment updates, got:', mockGithub.calls.commentsUpdated.length); + return false; + } + if (mockGithub.calls.commentsCreated.length !== 0) { + console.log('Expected 0 notification comments, got:', mockGithub.calls.commentsCreated.length); + return false; + } + + return true; + } + } +]; + +async function runScenario(scenario, index) { + console.log(`\n▶️ Scenario ${index}: ${scenario.name}`); + try { + const passed = await scenario.run(); + if (passed) { + console.log('✅ Passed'); + return true; + } else { + console.log('❌ Failed'); + return false; + } + } catch (err) { + console.log(`❌ Failed with error: ${err.message}`); + console.log(err.stack); + return false; + } +} + +// ============================================================================= +// RUN +// ============================================================================= + +runTestSuite('ON-PR-MERGED BOT TEST SUITE', scenarios, runScenario); diff --git a/.github/scripts/tests/test-on-pr-open-bot.js b/.github/scripts/tests/test-on-pr-open-bot.js new file mode 100644 index 0000000..239317a --- /dev/null +++ b/.github/scripts/tests/test-on-pr-open-bot.js @@ -0,0 +1,548 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-pr-open-bot.js +// +// Integration tests for bot-on-pr-open.js (opened/reopened/ready_for_review). +// Run with: node .github/scripts/tests/test-on-pr-open-bot.js + +const { + runTestSuite, + commitDCOAndGPG, + commitDCOFail, + commitGPGFail, + createMockGithub, +} = require('./test-utils'); +const script = require('../bot-on-pr-open.js'); +const { LABELS } = require('../helpers/constants'); +const { MARKER } = require('../helpers/comments'); + +// ============================================================================= +// DEFAULT CONTEXT +// ============================================================================= + +function defaultContext(overrides = {}) { + return { + eventName: 'pull_request_target', + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #42', + labels: [], + assignees: [], + }, + }, + repo: { owner: 'test', repo: 'repo' }, + ...overrides, + }; +} + +// ============================================================================= +// SCENARIOS +// ============================================================================= + +const scenarios = [ + // --------------------------------------------------------------------------- + // 1. Happy path - all pass + // --------------------------------------------------------------------------- + { + name: 'Happy path - all pass', + description: 'All commits have DCO+GPG, no conflicts, issue linked and assigned.', + context: defaultContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'Add feature')], + mergeable: true, + issues: { + 42: { title: 'Bug fix', assignees: [{ login: 'contributor' }] }, + }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVIEW], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentUpdated: false, + commentIncludes: [':white_check_mark:', 'All checks passed', '@contributor'], + commentExcludes: [':x:'], + }, + }, + + // --------------------------------------------------------------------------- + // 2. DCO fail only + // --------------------------------------------------------------------------- + { + name: 'DCO fail only', + description: 'One commit missing DCO.', + context: defaultContext(), + githubOptions: { + commits: [ + commitDCOAndGPG('abc1234', 'OK'), + commitDCOFail('def5678', 'No sign-off here'), + ], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **DCO Sign-off**', 'def5678', 'No sign-off here'], + }, + }, + + // --------------------------------------------------------------------------- + // 3. GPG fail only + // --------------------------------------------------------------------------- + { + name: 'GPG fail only', + description: 'One commit missing GPG.', + context: defaultContext(), + githubOptions: { + commits: [ + commitDCOAndGPG('abc1234', 'OK'), + commitGPGFail('def5678', 'Fix bug'), + ], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **GPG Signature**', 'def5678'], + }, + }, + + // --------------------------------------------------------------------------- + // 4. Merge conflict only + // --------------------------------------------------------------------------- + { + name: 'Merge conflict only', + description: 'mergeable=false.', + context: defaultContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: false, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **Merge Conflicts**', 'merge conflicts'], + }, + }, + + // --------------------------------------------------------------------------- + // 5. Issue link not linked + // --------------------------------------------------------------------------- + { + name: 'Issue link not linked', + description: 'No issue in body, no GraphQL results.', + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Just some changes', + labels: [], + assignees: [], + }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: {}, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **Issue Link**', 'not linked to any issue'], + }, + }, + + // --------------------------------------------------------------------------- + // 6. Issue link not assigned + // --------------------------------------------------------------------------- + { + name: 'Issue link not assigned', + description: 'Issue linked but author not assigned.', + context: defaultContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { + 42: { title: 'Bug', assignees: [{ login: 'other-user' }] }, + }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **Issue Link**', 'not assigned to the following linked issues'], + }, + }, + + // --------------------------------------------------------------------------- + // 7. Multiple failures (DCO + GPG) + // --------------------------------------------------------------------------- + { + name: 'Multiple failures (DCO + GPG)', + description: 'Both DCO and GPG fail.', + context: defaultContext(), + githubOptions: { + commits: [ + commitDCOFail('abc1234', 'No DCO'), + commitGPGFail('def5678', 'No GPG'), + ], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **DCO Sign-off**', ':x: **GPG Signature**'], + }, + }, + + // --------------------------------------------------------------------------- + // 8. All 4 fail + // --------------------------------------------------------------------------- + { + name: 'All 4 fail', + description: 'DCO, GPG, merge, issue link all fail.', + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: '', + labels: [], + assignees: [], + }, + }, + }), + githubOptions: { + commits: [ + commitDCOFail('abc1234', 'No sign-off'), + commitGPGFail('def5678', 'No GPG'), + ], + mergeable: false, + issues: {}, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [ + ':x: **DCO Sign-off**', + ':x: **GPG Signature**', + ':x: **Merge Conflicts**', + ':x: **Issue Link**', + ], + }, + }, + + // --------------------------------------------------------------------------- + // 9. Bot user skip + // --------------------------------------------------------------------------- + { + name: 'Bot user skip', + description: "PR author type='Bot'. Auto-assigned, but no checks, comments, or labels.", + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'dependabot', type: 'Bot' }, + body: 'Fixes #42', + labels: [], + assignees: [], + }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'dependabot' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: ['dependabot'], + commentCreated: false, + commentUpdated: false, + }, + }, + + // --------------------------------------------------------------------------- + // 10. Label cleanup on reopen - was needs-revision, now all pass + // --------------------------------------------------------------------------- + { + name: 'Label cleanup on reopen - was needs-revision, now all pass', + description: 'PR has needs-revision label, all checks pass.', + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #42', + labels: [{ name: LABELS.NEEDS_REVISION }], + assignees: [], + }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVIEW], + labelsRemoved: [LABELS.NEEDS_REVISION], + assignees: ['contributor'], + commentCreated: true, + }, + }, + + // --------------------------------------------------------------------------- + // 11. Label cleanup on reopen - was needs-review, now fails + // --------------------------------------------------------------------------- + { + name: 'Label cleanup on reopen - was needs-review, now fails', + description: 'PR has needs-review label, DCO fails.', + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #42', + labels: [{ name: LABELS.NEEDS_REVIEW }], + assignees: [], + }, + }, + }), + githubOptions: { + commits: [commitDCOFail('abc1234', 'No sign-off')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [LABELS.NEEDS_REVIEW], + assignees: ['contributor'], + commentCreated: true, + commentIncludes: [':x: **DCO Sign-off**'], + }, + }, + + // --------------------------------------------------------------------------- + // 12. Author already assigned + // --------------------------------------------------------------------------- + { + name: 'Author already assigned', + description: 'Author in assignees list. No addAssignees call.', + context: defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #42', + labels: [], + assignees: [{ login: 'contributor' }], + }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVIEW], + labelsRemoved: [], + assignees: [], + commentCreated: true, + }, + }, + + // --------------------------------------------------------------------------- + // 13. Comment already exists + // --------------------------------------------------------------------------- + { + name: 'Comment already exists', + description: 'Existing bot comment. Updated, not duplicated.', + context: defaultContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + existingComments: [ + { + id: 999, + body: `${MARKER}\n\nOld comment content`, + }, + ], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVIEW], + labelsRemoved: [], + assignees: ['contributor'], + commentCreated: false, + commentUpdated: true, + commentIncludes: [':white_check_mark:', 'All checks passed'], + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runTest(scenario, index) { + console.log('\n' + '='.repeat(70)); + console.log(`TEST ${index + 1}: ${scenario.name}`); + console.log(`Description: ${scenario.description}`); + console.log('='.repeat(70)); + + const mock = createMockGithub(scenario.githubOptions); + + // Wrap for buildBotContext: github.rest.issues, etc. + const github = { + rest: mock.rest, + graphql: mock.graphql.bind(mock), + }; + + const context = scenario.context; + + try { + await script({ github, context }); + } catch (error) { + if (!scenario.expectError) { + console.log(`\n❌ SCRIPT THREW ERROR: ${error.message}`); + if (error.stack) console.log(error.stack); + return false; + } + } + + const expect = scenario.expect || {}; + let passed = true; + + // Check labels added + const expectedLabelsAdded = expect.labelsAdded || []; + const actualLabelsAdded = mock.calls.labelsAdded; + if ( + expectedLabelsAdded.length !== actualLabelsAdded.length || + expectedLabelsAdded.some((l, i) => l !== actualLabelsAdded[i]) + ) { + console.log( + `\n❌ labelsAdded: expected [${expectedLabelsAdded.join(', ')}], got [${actualLabelsAdded.join(', ')}]` + ); + passed = false; + } else if (expectedLabelsAdded.length > 0) { + console.log(`\n✅ labelsAdded: [${actualLabelsAdded.join(', ')}]`); + } + + // Check labels removed + const expectedLabelsRemoved = expect.labelsRemoved || []; + const actualLabelsRemoved = mock.calls.labelsRemoved; + if ( + expectedLabelsRemoved.length !== actualLabelsRemoved.length || + expectedLabelsRemoved.some((l, i) => l !== actualLabelsRemoved[i]) + ) { + console.log( + `\n❌ labelsRemoved: expected [${expectedLabelsRemoved.join(', ')}], got [${actualLabelsRemoved.join(', ')}]` + ); + passed = false; + } else if (expectedLabelsRemoved.length > 0) { + console.log(`\n✅ labelsRemoved: [${actualLabelsRemoved.join(', ')}]`); + } + + // Check assignees + const expectedAssignees = expect.assignees || []; + const actualAssignees = mock.calls.assignees; + if ( + expectedAssignees.length !== actualAssignees.length || + expectedAssignees.some((a, i) => a !== actualAssignees[i]) + ) { + console.log( + `\n❌ assignees: expected [${expectedAssignees.join(', ')}], got [${actualAssignees.join(', ')}]` + ); + passed = false; + } else if (expectedAssignees.length > 0) { + console.log(`\n✅ assignees: [${actualAssignees.join(', ')}]`); + } + + // Check comment created + if (expect.commentCreated === true && mock.calls.commentsCreated.length === 0) { + console.log('\n❌ Expected comment to be created'); + passed = false; + } + if (expect.commentCreated === false && mock.calls.commentsCreated.length > 0) { + console.log(`\n❌ Expected no comment created, got ${mock.calls.commentsCreated.length}`); + passed = false; + } + + // Check comment updated + if (expect.commentUpdated === true && mock.calls.commentsUpdated.length === 0) { + console.log('\n❌ Expected comment to be updated'); + passed = false; + } + if (expect.commentUpdated === false && mock.calls.commentsUpdated.length > 0) { + console.log(`\n❌ Expected no comment updated, got ${mock.calls.commentsUpdated.length}`); + passed = false; + } + + // Check comment body content (commentsCreated stores body strings; commentsUpdated stores { body } objects) + const commentBody = + mock.calls.commentsCreated[0] || mock.calls.commentsUpdated[0]?.body || ''; + if (expect.commentIncludes) { + for (const str of expect.commentIncludes) { + if (!commentBody.includes(str)) { + console.log(`\n❌ Comment body missing expected string: "${str}"`); + passed = false; + } + } + if (passed && expect.commentIncludes.length > 0) { + console.log(`\n✅ Comment includes expected strings`); + } + } + if (expect.commentExcludes) { + for (const str of expect.commentExcludes) { + if (commentBody.includes(str)) { + console.log(`\n❌ Comment body should not contain: "${str}"`); + passed = false; + } + } + } + + if (passed) { + console.log('\n✅ PASSED'); + } + + return passed; +} + +runTestSuite('ON-PR-OPEN BOT TEST SUITE', scenarios, runTest); diff --git a/.github/scripts/tests/test-on-pr-review-bot.js b/.github/scripts/tests/test-on-pr-review-bot.js new file mode 100644 index 0000000..553c981 --- /dev/null +++ b/.github/scripts/tests/test-on-pr-review-bot.js @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-pr-review-bot.js +// +// Integration tests for bot-on-pr-review.js (pull_request_review trigger). +// Run with: node .github/scripts/tests/test-on-pr-review-bot.js + +const { runTestSuite, createMockGithub } = require('./test-utils'); +const script = require('../bot-on-pr-review.js'); +const { LABELS } = require('../helpers/constants'); + +function defaultContext(overrides = {}) { + return { + eventName: 'pull_request_review', + repo: { owner: 'test-owner', repo: 'test-repo' }, + payload: { + pull_request: { + number: 1, + user: { login: 'contributor' }, + labels: [], + }, + review: { + state: 'changes_requested', + }, + }, + ...overrides, + }; +} + +const scenarios = [ + { + name: 'Changes requested, PR has needs-review label', + setup: { + state: 'changes_requested', + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + }, + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVIEW) && + calls.labelsAdded.includes(LABELS.NEEDS_REVISION), + }, + { + name: 'Changes requested, PR does not have needs-review label', + setup: { + state: 'changes_requested', + prLabels: [], + }, + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && + calls.labelsAdded.includes(LABELS.NEEDS_REVISION), + }, + { + name: 'Review approved (ignored)', + setup: { + state: 'approved', + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + }, + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && + calls.labelsAdded.length === 0, + }, + { + name: 'Review commented (ignored)', + setup: { + state: 'commented', + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + }, + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && + calls.labelsAdded.length === 0, + }, + { + name: 'Uppercase state CHANGES_REQUESTED is handled', + setup: { + state: 'CHANGES_REQUESTED', + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + }, + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVIEW) && + calls.labelsAdded.includes(LABELS.NEEDS_REVISION), + }, +]; + +async function runScenario(scenario, index) { + const opts = scenario.setup; + + const mock = createMockGithub(); + const github = { + rest: mock.rest, + graphql: mock.graphql.bind(mock), + }; + + const context = defaultContext({ + payload: { + pull_request: { + number: 1, + user: { login: 'contributor' }, + labels: opts.prLabels || [], + }, + review: { + state: opts.state, + }, + }, + }); + + try { + await script({ github, context }); + } catch (error) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(` Script threw: ${error.message}`); + return false; + } + + if (!scenario.verify({ calls: mock.calls })) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(' Verification failed'); + console.log(' labelsAdded:', JSON.stringify(mock.calls.labelsAdded)); + console.log(' labelsRemoved:', JSON.stringify(mock.calls.labelsRemoved)); + return false; + } + + return true; +} + +if (require.main === module) { + runTestSuite('ON-PR-REVIEW BOT TEST SUITE', scenarios, runScenario); +} else { + module.exports = { runTestSuite: () => runTestSuite('ON-PR-REVIEW BOT TEST SUITE', scenarios, runScenario) }; +} diff --git a/.github/scripts/tests/test-on-pr-update-bot.js b/.github/scripts/tests/test-on-pr-update-bot.js new file mode 100644 index 0000000..b31e87b --- /dev/null +++ b/.github/scripts/tests/test-on-pr-update-bot.js @@ -0,0 +1,947 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-on-pr-update-bot.js +// +// Integration tests for bot-on-pr-update.js (synchronize + edited triggers). +// Run with: node .github/scripts/tests/test-on-pr-update-bot.js + +const { + runTestSuite, + commitDCOAndGPG, + commitDCOFail, + commitMerge, + createMockGithub, +} = require('./test-utils'); +const script = require('../bot-on-pr-update.js'); +const { LABELS } = require('../helpers/constants'); +const { MARKER } = require('../helpers/comments'); + +// ============================================================================= +// SYNCHRONIZE TRIGGER TESTS +// ============================================================================= + +/** + * Full mock factory for synchronize tests. Builds both the GitHub API mock and + * the context object (event = pull_request, no action/changes fields). + */ +function createSyncMock(options = {}) { + const commits = options.commits || []; + const mergeable = options.mergeable ?? true; + const comments = options.comments || []; + const prLabels = options.prLabels || []; + const prUser = options.prUser || { login: 'alice', type: 'User' }; + const prBody = options.prBody || ''; + const assignees = options.assignees || []; + const closingIssues = options.closingIssues || []; + const issuesData = options.issues || {}; + + const calls = { + labelsAdded: [], + labelsRemoved: [], + createComment: [], + updateComment: [], + addAssignees: [], + }; + + const owner = 'test-owner'; + const repo = 'test-repo'; + const prNumber = options.prNumber ?? 1; + + const mockGithub = { + rest: { + pulls: { + listCommits: async ({ owner: o, repo: r, pull_number, page = 1, per_page = 100 }) => { + if (o !== owner || r !== repo || pull_number !== prNumber) { + throw new Error('Invalid pulls.listCommits params'); + } + const start = (page - 1) * per_page; + return { data: commits.slice(start, start + per_page) }; + }, + get: async ({ owner: o, repo: r, pull_number }) => { + if (o !== owner || r !== repo || pull_number !== prNumber) { + throw new Error('Invalid pulls.get params'); + } + return { + data: { mergeable, mergeable_state: mergeable ? 'clean' : 'dirty' }, + }; + }, + }, + issues: { + listComments: async ({ owner: o, repo: r, issue_number, page = 1, per_page = 100 }) => { + if (o !== owner || r !== repo || issue_number !== prNumber) { + throw new Error('Invalid issues.listComments params'); + } + const start = (page - 1) * per_page; + const slice = comments.slice(start, start + per_page); + return { data: slice }; + }, + createComment: async (params) => { + calls.createComment.push(params); + return {}; + }, + updateComment: async (params) => { + calls.updateComment.push(params); + return {}; + }, + addLabels: async (params) => { + calls.labelsAdded.push(params.labels); + return {}; + }, + removeLabel: async (params) => { + calls.labelsRemoved.push(params.name); + return {}; + }, + addAssignees: async (params) => { + calls.addAssignees.push(params.assignees); + return {}; + }, + get: async ({ owner: o, repo: r, issue_number }) => { + if (o !== owner || r !== repo) { + throw new Error('Invalid issues.get params'); + } + const issue = issuesData[issue_number]; + if (!issue) throw new Error('Not Found'); + return { data: issue }; + }, + }, + }, + graphql: + options.graphql || + (async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: closingIssues.map((n) => ({ number: n })), + }, + }, + }, + })), + }; + + const context = { + eventName: options.eventName || 'pull_request', + repo: { owner, repo }, + payload: { + pull_request: { + number: prNumber, + user: prUser, + body: prBody, + labels: prLabels, + assignees, + }, + }, + }; + + return { github: mockGithub, context, calls }; +} + +function passingCommits(count = 1) { + return Array.from({ length: count }, (_, i) => ({ + sha: `abc${i}234567890`, + commit: { + message: `feat: commit ${i}\n\nSigned-off-by: Test `, + verification: { verified: true }, + }, + })); +} + +function dcoFailingCommits() { + return [ + { + sha: 'bad1234567890', + commit: { + message: 'feat: forgot to sign off', + verification: { verified: true }, + }, + }, + ]; +} + +function gpgFailingCommits() { + return [ + { + sha: 'nogpg1234567', + commit: { + message: 'feat: no gpg\n\nSigned-off-by: Test ', + verification: { verified: false }, + }, + }, + ]; +} + +function issueWithAssignee(num, title, assigneeLogin) { + return { + number: num, + title: title || 'Bug report', + assignees: [{ login: assigneeLogin }], + }; +} + +const syncScenarios = [ + { + name: '[sync] Label swap: revision → review (all pass)', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVISION }], + prUser: { login: 'alice', type: 'User' }, + prBody: 'Fixes #42', + closingIssues: [42], + issues: { 42: issueWithAssignee(42, 'Bug', 'alice') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVISION) && + calls.labelsAdded.some((arr) => arr.includes(LABELS.NEEDS_REVIEW)), + commentIncludes: ['All checks passed', ':white_check_mark:'], + }, + { + name: '[sync] Label swap: review → revision (DCO fails)', + setup: () => ({ + commits: dcoFailingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + prUser: { login: 'bob', type: 'User' }, + prBody: 'Fixes #1', + closingIssues: [1], + issues: { 1: issueWithAssignee(1, 'Fix', 'bob') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVIEW) && + calls.labelsAdded.some((arr) => arr.includes(LABELS.NEEDS_REVISION)), + commentIncludes: [':x:', 'DCO Sign-off'], + }, + { + name: '[sync] No-op: all pass, already has needs-review', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + prUser: { login: 'carol', type: 'User' }, + prBody: 'Fixes #10', + closingIssues: [10], + issues: { 10: issueWithAssignee(10, 'Feature', 'carol') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0, + commentIncludes: ['All checks passed'], + }, + { + name: '[sync] No-op: fail, already has needs-revision', + setup: () => ({ + commits: dcoFailingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVISION }], + prUser: { login: 'dave', type: 'User' }, + prBody: 'Fixes #20', + closingIssues: [20], + issues: { 20: issueWithAssignee(20, 'Fix', 'dave') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0, + commentIncludes: [':x:', 'DCO Sign-off'], + }, + { + name: '[sync] No-op: all pass, no status labels', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: 'bug' }], + prUser: { login: 'eve', type: 'User' }, + prBody: 'Fixes #30', + closingIssues: [30], + issues: { 30: issueWithAssignee(30, 'Bug', 'eve') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0, + commentIncludes: ['All checks passed'], + }, + { + name: '[sync] No-op: fail, no status labels', + setup: () => ({ + commits: gpgFailingCommits(), + mergeable: true, + comments: [], + prLabels: [], + prUser: { login: 'frank', type: 'User' }, + prBody: 'Fixes #40', + closingIssues: [40], + issues: { 40: issueWithAssignee(40, 'Fix', 'frank') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.length === 0 && calls.labelsAdded.length === 0, + commentIncludes: [':x:', 'GPG Signature'], + }, + { + name: '[sync] Cross-check: DCO/GPG/merge pass but issue not linked', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + prUser: { login: 'grace', type: 'User' }, + prBody: 'No issue linked', + closingIssues: [], + issues: {}, + }), + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVIEW) && + calls.labelsAdded.some((arr) => arr.includes(LABELS.NEEDS_REVISION)), + commentIncludes: [':x:', 'Issue Link', 'not linked to any issue'], + }, + { + name: '[sync] Comment updated after new commits fix issue', + setup: () => ({ + commits: passingCommits(2), + mergeable: true, + comments: [ + { id: 999, body: `${MARKER}\nOld content with DCO fail` }, + ], + prLabels: [{ name: LABELS.NEEDS_REVISION }], + prUser: { login: 'henry', type: 'User' }, + prBody: 'Fixes #50', + closingIssues: [50], + issues: { 50: issueWithAssignee(50, 'Fix', 'henry') }, + }), + verify: ({ calls }) => + calls.updateComment.length === 1 && + calls.createComment.length === 0 && + calls.labelsRemoved.includes(LABELS.NEEDS_REVISION) && + calls.labelsAdded.some((arr) => arr.includes(LABELS.NEEDS_REVIEW)), + commentIncludes: ['All checks passed', ':white_check_mark:'], + expectUpdate: true, + }, + { + name: '[sync] Bot user skip', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVIEW }], + prUser: { login: 'dependabot', type: 'Bot' }, + prBody: 'Fixes #60', + closingIssues: [60], + issues: { 60: issueWithAssignee(60, 'Dep', 'dependabot') }, + }), + verify: ({ calls }) => + calls.createComment.length === 0 && + calls.updateComment.length === 0 && + calls.labelsAdded.length === 0 && + calls.labelsRemoved.length === 0, + commentIncludes: null, + skipComment: true, + }, + { + name: '[sync] No auto-assign', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVISION }], + prUser: { login: 'ivan', type: 'User' }, + prBody: 'Fixes #70', + closingIssues: [70], + issues: { 70: issueWithAssignee(70, 'Fix', 'ivan') }, + }), + verify: ({ calls }) => calls.addAssignees.length === 0, + commentIncludes: ['All checks passed'], + }, + { + name: '[sync] Comment already exists', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [ + { id: 111, body: `${MARKER}\nPrevious bot comment` }, + ], + prLabels: [], + prUser: { login: 'jane', type: 'User' }, + prBody: 'Fixes #80', + closingIssues: [80], + issues: { 80: issueWithAssignee(80, 'Fix', 'jane') }, + }), + verify: ({ calls }) => + calls.updateComment.length === 1 && + calls.createComment.length === 0 && + calls.updateComment[0].comment_id === 111, + commentIncludes: ['All checks passed'], + expectUpdate: true, + }, + { + name: '[sync] New comment if none exists', + setup: () => ({ + commits: passingCommits(), + mergeable: true, + comments: [], + prLabels: [], + prUser: { login: 'kate', type: 'User' }, + prBody: 'Fixes #90', + closingIssues: [90], + issues: { 90: issueWithAssignee(90, 'Fix', 'kate') }, + }), + verify: ({ calls }) => + calls.createComment.length === 1 && + calls.updateComment.length === 0, + commentIncludes: ['All checks passed'], + expectCreate: true, + }, + { + name: '[sync] Merge commit without DCO sign-off is skipped', + setup: () => ({ + commits: [ + ...passingCommits(), + { + sha: 'merge1234567890', + parents: [{}, {}], + commit: { + message: 'Merge branch \'main\' into feat/my-feature', + verification: { verified: true }, + }, + }, + ], + mergeable: true, + comments: [], + prLabels: [{ name: LABELS.NEEDS_REVISION }], + prUser: { login: 'larry', type: 'User' }, + prBody: 'Fixes #100', + closingIssues: [100], + issues: { 100: issueWithAssignee(100, 'Fix', 'larry') }, + }), + verify: ({ calls }) => + calls.labelsRemoved.includes(LABELS.NEEDS_REVISION) && + calls.labelsAdded.some((arr) => arr.includes(LABELS.NEEDS_REVIEW)), + commentIncludes: ['All checks passed', ':white_check_mark:'], + }, +]; + +async function runSyncTest(scenario, index) { + const opts = typeof scenario.setup === 'function' ? scenario.setup() : scenario.setup; + const { github, context, calls } = createSyncMock(opts); + + try { + await script({ github, context }); + } catch (error) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(` Script threw: ${error.message}`); + return false; + } + + let passed = true; + + if (scenario.verify && !scenario.verify({ calls })) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(' Verification failed'); + console.log(' labelsAdded:', JSON.stringify(calls.labelsAdded)); + console.log(' labelsRemoved:', JSON.stringify(calls.labelsRemoved)); + console.log(' createComment:', calls.createComment.length); + console.log(' updateComment:', calls.updateComment.length); + console.log(' addAssignees:', calls.addAssignees.length); + passed = false; + } + + if (!scenario.skipComment && scenario.commentIncludes) { + const body = calls.updateComment[0]?.body ?? calls.createComment[0]?.body; + if (!body) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(' No comment body to verify'); + passed = false; + } else { + for (const needle of scenario.commentIncludes) { + if (!body.includes(needle)) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(` Comment should include "${needle}"`); + passed = false; + break; + } + } + } + } + + if (scenario.expectUpdate && calls.updateComment.length === 0) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(' Expected comment update, got create'); + passed = false; + } + + if (scenario.expectCreate && calls.createComment.length === 0) { + console.log(`\n❌ Scenario ${index + 1}: ${scenario.name}`); + console.log(' Expected new comment, got update or none'); + passed = false; + } + + return passed; +} + +async function runSyncTests() { + console.log('🔬 SYNCHRONIZE TRIGGER TESTS'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (let i = 0; i < syncScenarios.length; i++) { + const ok = await runSyncTest(syncScenarios[i], i); + if (ok) passed++; + else failed++; + } + console.log('\n' + '-'.repeat(70)); + console.log(`Synchronize Tests: ${passed} passed, ${failed} failed`); + return { total: syncScenarios.length, passed, failed }; +} + +// ============================================================================= +// EDITED TRIGGER TESTS +// ============================================================================= + +function defaultEditContext(overrides = {}) { + return { + eventName: 'pull_request_target', + payload: { + action: 'edited', + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #42', + labels: [], + assignees: [], + }, + changes: { body: { from: 'old body' } }, + }, + repo: { owner: 'test', repo: 'repo' }, + ...overrides, + }; +} + +function mergePayload(base, extras) { + const merged = JSON.parse(JSON.stringify(base)); + const payload = merged.payload || {}; + const pr = { ...payload.pull_request, ...(extras.pull_request || {}), ...(extras.payload?.pull_request || {}) }; + const payloadOverrides = extras.payload || extras; + const keys = Object.keys(payloadOverrides).filter((k) => k !== 'pull_request'); + keys.forEach((k) => { payload[k] = payloadOverrides[k]; }); + merged.payload = { ...payload, pull_request: pr }; + return merged; +} + +const editScenarios = [ + { + name: '[edit] Title-edit optimization - only title changed', + description: 'changes: { title: { from: "old" } } only. No body change. Early exit.', + context: mergePayload(defaultEditContext(), { + changes: { title: { from: 'old' } }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'Add feature')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: false, + commentUpdated: false, + }, + }, + { + name: '[edit] Body changed - all checks run', + description: 'changes: { body: { from: "old" } }. All checks run normally.', + context: defaultEditContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'Add feature')], + mergeable: true, + issues: { 42: { title: 'Bug fix', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + commentUpdated: false, + commentIncludes: [':white_check_mark:', 'All checks passed'], + }, + }, + { + name: '[edit] Label swap: revision → review', + description: 'Body edited to add Fixes #42 (assigned). All pass. PR has needs-revision → swap to review.', + context: mergePayload(defaultEditContext(), { + pull_request: { + labels: [{ name: LABELS.NEEDS_REVISION }], + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'Add feature')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVIEW], + labelsRemoved: [LABELS.NEEDS_REVISION], + assignees: [], + commentCreated: true, + }, + }, + { + name: '[edit] Label swap: review → revision', + description: 'Body edited to remove issue link. PR has needs-review → swap to revision.', + context: mergePayload(defaultEditContext(), { + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Just some changes', + labels: [{ name: LABELS.NEEDS_REVIEW }], + assignees: [], + }, + changes: { body: { from: 'Fixes #42' } }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: {}, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [LABELS.NEEDS_REVIEW], + assignees: [], + commentCreated: true, + commentIncludes: [':x: **Issue Link**', 'not linked to any issue'], + }, + }, + { + name: '[edit] No-op: already correct label', + description: 'All pass. Already has needs-review → no label change.', + context: mergePayload(defaultEditContext(), { + pull_request: { + labels: [{ name: LABELS.NEEDS_REVIEW }], + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + }, + }, + { + name: '[edit] No-op: fail, already has needs-revision', + description: 'DCO fails. PR already has needs-revision → no label change.', + context: mergePayload(defaultEditContext(), { + pull_request: { + labels: [{ name: LABELS.NEEDS_REVISION }], + }, + }), + githubOptions: { + commits: [commitDCOFail('abc1234', 'No sign-off')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + commentIncludes: [':x: **DCO Sign-off**'], + }, + }, + { + name: '[edit] Cross-check: issue link passes but DCO fails', + description: 'Body adds issue link. DCO fails → stays needs-revision.', + context: mergePayload(defaultEditContext(), { + pull_request: { + labels: [{ name: LABELS.NEEDS_REVISION }], + }, + }), + githubOptions: { + commits: [commitDCOFail('abc1234', 'No sign-off')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + commentIncludes: [':x: **DCO Sign-off**', ':white_check_mark: **Issue Link**'], + }, + }, + { + name: '[edit] Bot user skip', + description: "PR author type='Bot'. No checks, no comment, no labels.", + context: mergePayload(defaultEditContext(), { + pull_request: { + user: { login: 'dependabot', type: 'Bot' }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'dependabot' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: false, + commentUpdated: false, + }, + }, + { + name: '[edit] No auto-assign', + description: 'Verify no addAssignees calls on pr-edit. Issue link fails (not assigned).', + context: mergePayload(defaultEditContext(), { + pull_request: { labels: [{ name: LABELS.NEEDS_REVIEW }] }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [LABELS.NEEDS_REVIEW], + assignees: [], + commentCreated: true, + commentIncludes: [':x: **Issue Link**', 'not assigned to the following linked issues'], + }, + }, + { + name: '[edit] Comment updated', + description: 'Existing bot comment updated, not duplicated.', + context: defaultEditContext(), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + existingComments: [ + { id: 999, body: `${MARKER}\n\nOld comment content` }, + ], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: false, + commentUpdated: true, + commentIncludes: [':white_check_mark:', 'All checks passed'], + }, + }, + { + name: '[edit] Body changed from Fixes #1 to Fixes #2 (still assigned)', + description: 'Body has Fixes #2. Author assigned to #2. Passed.', + context: mergePayload(defaultEditContext(), { + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #2', + labels: [], + assignees: [], + }, + changes: { body: { from: 'Fixes #1' } }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 2: { title: 'Issue 2', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + commentIncludes: [':white_check_mark:', 'All checks passed'], + }, + }, + { + name: '[edit] Body changed to empty', + description: 'Body emptied. Issue link fails (no_issue_linked). PR has needs-review → swap to revision.', + context: mergePayload(defaultEditContext(), { + payload: { + pull_request: { + number: 1, + user: { login: 'contributor', type: 'User' }, + body: '', + labels: [{ name: LABELS.NEEDS_REVIEW }], + assignees: [], + }, + changes: { body: { from: 'Fixes #42' } }, + }, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: {}, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [LABELS.NEEDS_REVISION], + labelsRemoved: [LABELS.NEEDS_REVIEW], + assignees: [], + commentCreated: true, + commentIncludes: [':x: **Issue Link**', 'not linked to any issue'], + }, + }, + { + name: '[edit] Merge commit without DCO sign-off is skipped', + description: 'Mix of passing commit and merge commit (no sign-off). DCO still passes.', + context: defaultEditContext(), + githubOptions: { + commits: [ + commitDCOAndGPG('abc1234', 'Add feature'), + commitMerge('merge567', 'Merge branch \'main\' into feat/my-feature'), + ], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: true, + commentIncludes: [':white_check_mark:', 'All checks passed'], + }, + }, + { + name: '[edit] Empty changes - early exit', + description: 'changes: {} or no body. Early exit.', + context: mergePayload(defaultEditContext(), { + changes: {}, + }), + githubOptions: { + commits: [commitDCOAndGPG('abc1234', 'OK')], + mergeable: true, + issues: { 42: { title: 'Bug', assignees: [{ login: 'contributor' }] } }, + graphqlClosingIssues: [], + }, + expect: { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentCreated: false, + commentUpdated: false, + }, + }, +]; + +async function runEditTest(scenario) { + const mock = createMockGithub(scenario.githubOptions); + + const github = { + rest: mock.rest, + graphql: mock.graphql.bind(mock), + }; + + try { + await script({ github, context: scenario.context }); + } catch (error) { + if (!scenario.expectError) { + console.log(`\n❌ SCRIPT THREW ERROR: ${error.message}`); + if (error.stack) console.log(error.stack); + return false; + } + } + + const expect = scenario.expect || {}; + let passed = true; + + const expectedLabelsAdded = expect.labelsAdded || []; + const actualLabelsAdded = mock.calls.labelsAdded; + if ( + expectedLabelsAdded.length !== actualLabelsAdded.length || + expectedLabelsAdded.some((l, i) => l !== actualLabelsAdded[i]) + ) { + console.log( + `\n❌ labelsAdded: expected [${expectedLabelsAdded.join(', ')}], got [${actualLabelsAdded.join(', ')}]` + ); + passed = false; + } + + const expectedLabelsRemoved = expect.labelsRemoved || []; + const actualLabelsRemoved = mock.calls.labelsRemoved; + if ( + expectedLabelsRemoved.length !== actualLabelsRemoved.length || + expectedLabelsRemoved.some((l, i) => l !== actualLabelsRemoved[i]) + ) { + console.log( + `\n❌ labelsRemoved: expected [${expectedLabelsRemoved.join(', ')}], got [${actualLabelsRemoved.join(', ')}]` + ); + passed = false; + } + + const expectedAssignees = expect.assignees || []; + const actualAssignees = mock.calls.assignees; + if ( + expectedAssignees.length !== actualAssignees.length || + expectedAssignees.some((a, i) => a !== actualAssignees[i]) + ) { + console.log( + `\n❌ assignees: expected [${expectedAssignees.join(', ')}], got [${actualAssignees.join(', ')}]` + ); + passed = false; + } + + if (expect.commentCreated === true && mock.calls.commentsCreated.length === 0) { + console.log('\n❌ Expected comment to be created'); + passed = false; + } + if (expect.commentCreated === false && mock.calls.commentsCreated.length > 0) { + console.log(`\n❌ Expected no comment created, got ${mock.calls.commentsCreated.length}`); + passed = false; + } + + if (expect.commentUpdated === true && mock.calls.commentsUpdated.length === 0) { + console.log('\n❌ Expected comment to be updated'); + passed = false; + } + if (expect.commentUpdated === false && mock.calls.commentsUpdated.length > 0) { + console.log(`\n❌ Expected no comment updated, got ${mock.calls.commentsUpdated.length}`); + passed = false; + } + + const commentBody = + mock.calls.commentsCreated[0] || mock.calls.commentsUpdated[0]?.body || ''; + if (expect.commentIncludes) { + for (const str of expect.commentIncludes) { + if (!commentBody.includes(str)) { + console.log(`\n❌ Comment body missing expected string: "${str}"`); + passed = false; + } + } + } + + if (passed) { + console.log(`\n✅ PASSED`); + } + + return passed; +} + +// ============================================================================= +// COMBINED RUNNER +// ============================================================================= + +runTestSuite('ON-PR-UPDATE BOT TEST SUITE', editScenarios, runEditTest, [ + { label: 'Synchronize Tests', run: runSyncTests }, +]); diff --git a/.github/scripts/tests/test-recommend-issues-bot.js b/.github/scripts/tests/test-recommend-issues-bot.js new file mode 100644 index 0000000..a6873ae --- /dev/null +++ b/.github/scripts/tests/test-recommend-issues-bot.js @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-recommend-issues-bot.js +// +// Unit tests for bot/bot-recommend-issues.js +// Run with: node .github/scripts/tests/test-recommend-issues-bot.js + +const { runTestSuite } = require('./test-utils'); +const { LABELS, SKILL_HIERARCHY, SKILL_PREREQUISITES, MAINTAINER_TEAM, PRIORITY_HIERARCHY } = require('../helpers/constants'); +const { + handleRecommendIssues, + getRecommendedIssues, + resolveEligibleLevel, + detectUnlockedLevel, +} = require('../bot/bot-recommend-issues'); + +// ============================================================================= +// MOCK FACTORY +// ============================================================================= + +function createMockBotContext(overrides = {}) { + const calls = { comments: [] }; + + // searchItems: null → simulate API failure for issue search + // searchItems: [] → API succeeds, no results + // searchItems: [...] → API succeeds with items + const searchItems = overrides.searchItems !== undefined ? overrides.searchItems : []; + + // closedCounts controls how many issues exist for each (label, user). + // null simulates API failure for all calls. + const closedCounts = overrides.closedCounts !== undefined ? overrides.closedCounts : {}; + + return { + botContext: { + github: { + rest: { + issues: { + createComment: async (params) => { + calls.comments.push(params.body); + }, + listForRepo: buildListForRepo(closedCounts), + }, + search: { + issuesAndPullRequests: async () => { + if (searchItems === null) throw new Error('Search API error'); + return { data: { items: searchItems } }; + }, + }, + }, + }, + owner: 'test', + repo: 'repo', + number: 99, + sender: overrides.sender !== undefined ? overrides.sender : { login: 'contributor' }, + issue: overrides.issue !== undefined ? overrides.issue : null, + }, + calls, + }; +} + +/** + + * Builds a mock of `github.rest.issues.listForRepo` used by + * countIssuesByAssignee. + * + * For a given (assignee, label), the mock returns a list of synthetic + * issues whose size is determined by `closedCounts["label:username"]`. + * + * Behavior: + * - `closedCounts === null` → all calls throw (simulates API failure) + * - `closedCounts[key] === null` → specific call throws + * - otherwise → returns an array of matching issues + * + * The returned items mimic real API responses: + * - include labels + * - exclude pull_request field (so they count as issues) + * + * The number of returned items is capped by `per_page`, matching how the real + * function respects pagination limits. + */ +function buildListForRepo(closedCounts) { + return async ({ assignee, labels, per_page }) => { + // Simulate full API outage. + if (closedCounts === null) { + throw new Error('listForRepo API error'); + } + + // Only the closed + label path is modeled, which is what the eligibility + // and unlock logic rely on. + const label = labels ?? null; + const key = label ? `${label}:${assignee}` : null; + + // Allow individual keys to signal failure. + if (key !== null && closedCounts[key] === null) { + throw new Error(`listForRepo API error for key ${key}`); + } + + const count = (key !== null ? closedCounts[key] : 0) ?? 0; + + // Build synthetic issue objects that pass the `!item.pull_request` filter + // and carry the requested label. + const items = Array.from({ length: count }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1}`, + html_url: `https://github.com/test/repo/issues/${i + 1}`, + pull_request: undefined, // marks it as a real issue, not a PR + labels: label ? [{ name: label }] : [], + })); + + // The real implementation paginates but the mock returns everything at + // once since counts are small. Mimic the `{ data }` envelope. + return { data: items.slice(0, per_page) }; + }; +} + +/** + * Creates a minimal issue object with the given labels. + * Matches the structure expected by recommendation logic. + */ +function makeIssue(labels, number = 1, createdAt = '2026-03-01T00:00:00Z') { + return { + number, + title: `Issue ${number}`, + html_url: `https://github.com/test/repo/issues/${number}`, + labels: labels.map((name) => ({ name })), + created_at: createdAt, + }; +} + +// Skill levels derived from hierarchy order +const GFI = SKILL_HIERARCHY[0]; // good first issue (or equivalent) +const BEGINNER = SKILL_HIERARCHY[1]; +const MID = SKILL_HIERARCHY[2]; +const TOP = SKILL_HIERARCHY[SKILL_HIERARCHY.length - 1]; + +// Convenience: priority levels +const CRITICAL = PRIORITY_HIERARCHY[0]; +const HIGH = PRIORITY_HIERARCHY[1]; +const MEDIUM = PRIORITY_HIERARCHY[2]; +const LOW = PRIORITY_HIERARCHY[3]; + +// ============================================================================= +// UNIT TESTS +// ============================================================================= + +const unitTests = [ + + // --------------------------------------------------------------------------- + // resolveEligibleLevel + // --------------------------------------------------------------------------- + { + name: 'resolveEligibleLevel: no closed issues → defaults to GFI (floor level)', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: {}, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === GFI; + }, + }, + { + name: 'resolveEligibleLevel: meets normal prereq for BEGINNER → returns BEGINNER', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + // Enough closed GFIs to satisfy the normal check for BEGINNER. + const { botContext } = createMockBotContext({ + closedCounts: { + [`${prereq.requiredLabel}:contributor`]: prereq.requiredCount, + }, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === BEGINNER; + }, + }, + { + name: 'resolveEligibleLevel: has 1 closed BEGINNER issue → bypass check passes for BEGINNER', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: { + [`${BEGINNER}:contributor`]: 1, + }, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === BEGINNER; + }, + }, + { + name: 'resolveEligibleLevel: has 1 closed MID issue → bypass check resolves to MID', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: { + [`${MID}:contributor`]: 1, + }, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === MID; + }, + }, + { + name: 'resolveEligibleLevel: countIssuesByAssignee API failure → degrades to GFI', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: null, // signal all API calls fail + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === GFI; + }, + }, + { + name: 'resolveEligibleLevel: API failure at top level → falls through to next eligible level', + test: async () => { + const midPrereq = SKILL_PREREQUISITES[MID]; + const { botContext } = createMockBotContext({ + closedCounts: { + // Simulate failure for the TOP-level check, pass for MID. + [`${TOP}:contributor`]: null, + [`${midPrereq.requiredLabel}:contributor`]: midPrereq.requiredCount, + }, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === MID; + }, + }, + { + name: 'resolveEligibleLevel: meets prereq for MID but not TOP → returns MID', + test: async () => { + const midPrereq = SKILL_PREREQUISITES[MID]; + const { botContext } = createMockBotContext({ + closedCounts: { + [`${midPrereq.requiredLabel}:contributor`]: midPrereq.requiredCount, + }, + }); + const level = await resolveEligibleLevel(botContext, 'contributor'); + return level === MID; + }, + }, + + // --------------------------------------------------------------------------- + // detectUnlockedLevel + // --------------------------------------------------------------------------- + { + name: 'detectUnlockedLevel: count exactly equals threshold → returns next level', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + [`${GFI}:contributor`]: prereq.requiredCount, + }, + }); + const unlocked = await detectUnlockedLevel(botContext, 'contributor', GFI); + return unlocked === BEGINNER; + }, + }, + { + name: 'detectUnlockedLevel: count below threshold → returns null', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + [`${GFI}:contributor`]: prereq.requiredCount - 1, + }, + }); + const unlocked = await detectUnlockedLevel(botContext, 'contributor', GFI); + return unlocked === null; + }, + }, + { + name: 'detectUnlockedLevel: count above threshold → returns null (already crossed before)', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + // The mock returns raw counts, so values greater than requiredCount + // represent cases where the threshold was already exceeded. + [`${GFI}:contributor`]: prereq.requiredCount + 1, + }, + }); + const unlocked = await detectUnlockedLevel(botContext, 'contributor', GFI); + return unlocked === null; + }, + }, + { + name: 'detectUnlockedLevel: currentLevel is TOP → returns null (no higher level)', + test: async () => { + const { botContext } = createMockBotContext(); + const unlocked = await detectUnlockedLevel(botContext, 'contributor', TOP); + return unlocked === null; + }, + }, + { + name: 'detectUnlockedLevel: API failure → returns null', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: null, + }); + const unlocked = await detectUnlockedLevel(botContext, 'contributor', GFI); + return unlocked === null; + }, + }, + + // --------------------------------------------------------------------------- + // getRecommendedIssues — returns { issues, unlockedLevel } + // --------------------------------------------------------------------------- + { + name: 'getRecommendedIssues: eligible for BEGINNER, BEGINNER issues exist → returns them', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + [`${prereq.requiredLabel}:contributor`]: prereq.requiredCount, + }, + searchItems: [ + makeIssue([BEGINNER, LABELS.READY_FOR_DEV], 1), + makeIssue([MID, LABELS.READY_FOR_DEV], 2), + ], + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return ( + result !== null && + result.issues.length === 1 && + result.issues[0].number === 1 + ); + }, + }, + { + name: 'getRecommendedIssues: eligible for GFI → only GFI issues returned', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: {}, + searchItems: [ + makeIssue([GFI, LABELS.READY_FOR_DEV], 3), + makeIssue([BEGINNER, LABELS.READY_FOR_DEV], 4), + ], + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return ( + result !== null && + result.issues.length === 1 && + result.issues[0].number === 3 + ); + }, + }, + { + name: 'getRecommendedIssues: no issues at eligible level → returns empty array', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: {}, + searchItems: [ + makeIssue([BEGINNER, LABELS.READY_FOR_DEV], 5), + ], + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return result !== null && result.issues.length === 0; + }, + }, + { + name: 'getRecommendedIssues: API failure on issue search → posts error comment, returns null', + test: async () => { + const { botContext, calls } = createMockBotContext({ + sender: { login: 'user' }, + issue: makeIssue([BEGINNER]), + closedCounts: {}, + searchItems: null, + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + const expected = [ + '👋 Hi @user!', + '', + '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'); + return ( + result === null && + calls.comments.length === 1 && + calls.comments[0] === expected + ); + }, + }, + { + name: 'getRecommendedIssues: caps results at 5', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + [`${prereq.requiredLabel}:contributor`]: prereq.requiredCount, + }, + searchItems: Array.from({ length: 10 }, (_, i) => + makeIssue([BEGINNER, LABELS.READY_FOR_DEV], i + 1) + ), + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return result !== null && result.issues.length <= 5; + }, + }, + { + name: 'getRecommendedIssues: threshold crossed → unlockedLevel is set', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext } = createMockBotContext({ + closedCounts: { + // Exactly meets normal prereq for BEGINNER (resolveEligibleLevel) AND + // exactly equals threshold for detectUnlockedLevel. + [`${GFI}:contributor`]: prereq.requiredCount, + }, + searchItems: [makeIssue([BEGINNER, LABELS.READY_FOR_DEV], 1)], + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return result !== null && result.unlockedLevel === BEGINNER; + }, + }, + { + name: 'getRecommendedIssues: threshold not crossed → unlockedLevel is null', + test: async () => { + const { botContext } = createMockBotContext({ + closedCounts: {}, + searchItems: [makeIssue([GFI, LABELS.READY_FOR_DEV], 1)], + }); + const result = await getRecommendedIssues(botContext, 'contributor', GFI); + return result !== null && result.unlockedLevel === null; + }, + }, + + // --------------------------------------------------------------------------- + // getRecommendedIssues — Priority and Tiebreaker logic + // --------------------------------------------------------------------------- + { + name: 'getRecommendedIssues: Critical > High priority at same level', + test: async () => { + const { botContext } = createMockBotContext({ + sender: { login: 'user' }, + closedCounts: { [`${MID}:user`]: 1 }, + searchItems: [ + makeIssue([MID, LABELS.READY_FOR_DEV, HIGH], 1), + makeIssue([MID, LABELS.READY_FOR_DEV, CRITICAL], 2), + ], + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + // Even though Issue 1 was "found" first by search, Issue 2 should be recommended first + return result.issues[0].number === 2 && result.issues[1].number === 1; + }, + }, + { + name: 'getRecommendedIssues: High > Medium priority at same level', + test: async () => { + const { botContext } = createMockBotContext({ + sender: { login: 'user' }, + closedCounts: { [`${MID}:user`]: 1 }, + searchItems: [ + makeIssue([MID, LABELS.READY_FOR_DEV, MEDIUM], 1), + makeIssue([MID, LABELS.READY_FOR_DEV, HIGH], 2), + ], + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + return result.issues[0].number === 2 && result.issues[1].number === 1; + }, + }, + { + name: 'getRecommendedIssues: Medium > Low priority at same level', + test: async () => { + const { botContext } = createMockBotContext({ + sender: { login: 'user' }, + closedCounts: { [`${MID}:user`]: 1 }, + searchItems: [ + makeIssue([MID, LABELS.READY_FOR_DEV, LOW], 1), + makeIssue([MID, LABELS.READY_FOR_DEV, MEDIUM], 2), + ], + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + return result.issues[0].number === 2 && result.issues[1].number === 1; + }, + }, + { + name: 'getRecommendedIssues: Unlabeled issues appear after all priority labels', + test: async () => { + const { botContext } = createMockBotContext({ + sender: { login: 'user' }, + closedCounts: { [`${MID}:user`]: 1 }, + searchItems: [ + makeIssue([MID, LABELS.READY_FOR_DEV], 1), // No priority label + makeIssue([MID, LABELS.READY_FOR_DEV, LOW], 2), + ], + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + return result.issues[0].number === 2 && result.issues[1].number === 1; + }, + }, + { + name: 'getRecommendedIssues: Same priority tiebreaker → older issue first', + test: async () => { + const { botContext } = createMockBotContext({ + sender: { login: 'user' }, + closedCounts: { [`${MID}:user`]: 1 }, + searchItems: [ + makeIssue([MID, LABELS.READY_FOR_DEV, HIGH], 1, '2026-04-01T00:00:00Z'), // Newer + makeIssue([MID, LABELS.READY_FOR_DEV, HIGH], 2, '2026-01-01T00:00:00Z'), // Older + ], + }); + const result = await getRecommendedIssues(botContext, 'user', BEGINNER); + // Issue 2 is older, so it should be the top recommendation + return result.issues[0].number === 2 && result.issues[1].number === 1; + }, + }, + + // --------------------------------------------------------------------------- + // handleRecommendIssues — short-circuit cases + // --------------------------------------------------------------------------- + { + name: 'handleRecommendIssues: missing sender → skips silently', + test: async () => { + const { botContext, calls } = createMockBotContext({ sender: null }); + await handleRecommendIssues(botContext); + return calls.comments.length === 0; + }, + }, + { + name: 'handleRecommendIssues: missing issue → skips silently', + test: async () => { + const { botContext, calls } = createMockBotContext({ issue: null }); + await handleRecommendIssues(botContext); + return calls.comments.length === 0; + }, + }, + { + name: 'handleRecommendIssues: issue has no skill label → skips silently', + test: async () => { + const { botContext, calls } = createMockBotContext({ + issue: makeIssue(['bug', 'enhancement']), + }); + await handleRecommendIssues(botContext); + return calls.comments.length === 0; + }, + }, + { + name: 'handleRecommendIssues: no matching issues at eligible level → no comment', + test: async () => { + const { botContext, calls } = createMockBotContext({ + issue: makeIssue([BEGINNER, LABELS.READY_FOR_DEV]), + closedCounts: {}, + searchItems: [], + }); + await handleRecommendIssues(botContext); + return calls.comments.length === 0; + }, + }, + { + name: 'handleRecommendIssues: valid context, no unlock → posts standard recommendation comment', + test: async () => { + const { botContext, calls } = createMockBotContext({ + sender: { login: 'user' }, + issue: makeIssue([GFI, LABELS.READY_FOR_DEV]), + closedCounts: {}, + searchItems: [makeIssue([GFI, LABELS.READY_FOR_DEV], 1)], + }); + await handleRecommendIssues(botContext); + const expected = [ + '👋 Hi @user! Great work on your recent contribution! 🎉', + '', + 'Here are some issues you might want to explore next:', + '', + '- [Issue 1](https://github.com/test/repo/issues/1)', + '', + 'Happy coding! 🚀', + ].join('\n'); + return calls.comments.length === 1 && calls.comments[0] === expected; + }, + }, + { + name: 'handleRecommendIssues: level unlocked → comment includes congratulatory block', + test: async () => { + const prereq = SKILL_PREREQUISITES[BEGINNER]; + const { botContext, calls } = createMockBotContext({ + sender: { login: 'user' }, + issue: makeIssue([GFI, LABELS.READY_FOR_DEV]), + closedCounts: { + [`${GFI}:user`]: prereq.requiredCount, + }, + searchItems: [makeIssue([BEGINNER, LABELS.READY_FOR_DEV], 2)], + }); + await handleRecommendIssues(botContext); + return ( + calls.comments.length === 1 && + calls.comments[0].includes('Milestone unlocked') && + calls.comments[0].includes(SKILL_PREREQUISITES[BEGINNER].displayName) + ); + }, + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +async function runUnitTests() { + console.log('🔬 UNIT TESTS (recommend-issues)'); + console.log('='.repeat(70)); + let passed = 0; + let failed = 0; + for (const test of unitTests) { + try { + const result = await Promise.resolve(test.test()); + if (result) { + console.log(`✅ ${test.name}`); + passed++; + } else { + console.log(`❌ ${test.name}`); + failed++; + } + } catch (error) { + console.log(`❌ ${test.name} - Error: ${error.message}`); + failed++; + } + } + console.log('\n' + '-'.repeat(70)); + console.log(`Unit Tests: ${passed} passed, ${failed} failed`); + return { total: unitTests.length, passed, failed }; +} + +runTestSuite('RECOMMEND ISSUES TEST SUITE', [], async () => true, [ + { label: 'Unit Tests', run: runUnitTests }, +]); diff --git a/.github/scripts/tests/test-unassign-bot.js b/.github/scripts/tests/test-unassign-bot.js new file mode 100644 index 0000000..d33185f --- /dev/null +++ b/.github/scripts/tests/test-unassign-bot.js @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-unassign-bot.js +// +// Local test script for the /unassign command in bot-on-comment.js. +// Run with: node .github/scripts/tests/test-unassign-bot.js +// +// This script mocks the GitHub API and runs various test scenarios +// to verify the /unassign command behaves correctly. + +const { LABELS, ISSUE_STATE } = require('../helpers'); +const script = require('../bot-on-comment.js'); +const { verifyComments, runTestSuite } = require('./test-utils'); + +// ============================================================================= +// MOCK GITHUB API +// ============================================================================= + +/** + * Creates a mock GitHub API client for testing. + * Tracks all calls made and optionally simulates API failures. + * + * @param {object} options + * @param {boolean} [options.removeAssigneesShouldFail=false] + * @param {boolean} [options.removeLabelShouldFail=false] + * @param {boolean} [options.addLabelShouldFail=false] + * @returns {{ calls: object, rest: object }} + */ +function createMockGithub(options = {}) { + const { + removeAssigneesShouldFail = false, + removeLabelShouldFail = false, + addLabelShouldFail = false, + } = options; + + const calls = { + comments: [], + assigneesRemoved: [], + labelsAdded: [], + labelsRemoved: [], + reactions: [], + }; + + return { + calls, + rest: { + reactions: { + createForIssueComment: async (params) => { + calls.reactions.push({ commentId: params.comment_id, content: params.content }); + console.log(`\n👍 REACTION ADDED: ${params.content}`); + }, + }, + issues: { + createComment: async (params) => { + calls.comments.push(params.body); + console.log('\n📝 COMMENT POSTED:'); + console.log('─'.repeat(60)); + console.log(params.body); + console.log('─'.repeat(60)); + }, + removeAssignees: async (params) => { + if (removeAssigneesShouldFail) throw new Error('Simulated remove assignees failure'); + calls.assigneesRemoved.push(...params.assignees); + console.log(`\n✅ UNASSIGNED: ${params.assignees.join(', ')}`); + }, + addLabels: async (params) => { + if (addLabelShouldFail) throw new Error('Simulated add label failure'); + calls.labelsAdded.push(...params.labels); + console.log(`\n🏷️ LABEL ADDED: ${params.labels.join(', ')}`); + }, + removeLabel: async (params) => { + if (removeLabelShouldFail) throw new Error('Simulated remove label failure'); + calls.labelsRemoved.push(params.name); + console.log(`\n🏷️ LABEL REMOVED: ${params.name}`); + }, + }, + }, + }; +} + +// ============================================================================= +// TEST SCENARIOS +// ============================================================================= + +const scenarios = [ + // --------------------------------------------------------------------------- + // HAPPY PATHS + // --------------------------------------------------------------------------- + { + name: 'Happy Path - Unassign Success', + description: 'Current assignee successfully unassigns themselves; labels swap correctly', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 100, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1001, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: 'active-contributor', + expectedLabelRemoved: LABELS.IN_PROGRESS, + expectedLabelAdded: LABELS.READY_FOR_DEV, + expectedComments: [ + `👋 Hi @active-contributor! You have been successfully unassigned from this issue.\n\nThe \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ], + }, + { + name: 'Happy Path - Case-Insensitive Username', + description: 'Unassign succeeds even when stored login casing differs from commenter casing', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 106, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'Active-Contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1007, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: 'active-contributor', + expectedLabelRemoved: LABELS.IN_PROGRESS, + expectedLabelAdded: LABELS.READY_FOR_DEV, + expectedComments: [ + `👋 Hi @active-contributor! You have been successfully unassigned from this issue.\n\nThe \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ], + }, + { + name: 'Happy Path - Command Casing and Whitespace', + description: 'Command successfully routes even with trailing spaces and uppercase letters', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 110, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1011, + body: ' /UNASSIGN ', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: 'active-contributor', + expectedLabelRemoved: LABELS.IN_PROGRESS, + expectedLabelAdded: LABELS.READY_FOR_DEV, + expectedComments: [ + `👋 Hi @active-contributor! You have been successfully unassigned from this issue.\n\nThe \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ], + }, + { + name: 'Happy Path - IN_PROGRESS Label Already Missing', + description: 'Unassign succeeds if a maintainer manually removed IN_PROGRESS; READY_FOR_DEV is still added', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 107, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [], + }, + comment: { + id: 1008, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: 'active-contributor', + expectedLabelRemoved: LABELS.IN_PROGRESS, + expectedLabelAdded: LABELS.READY_FOR_DEV, + expectedComments: [ + `👋 Hi @active-contributor! You have been successfully unassigned from this issue.\n\nThe \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ], + }, + + // --------------------------------------------------------------------------- + // VALIDATION FAILURES + // --------------------------------------------------------------------------- + { + name: 'Validation - Not Assigned to Requester', + description: 'User tries to unassign an issue assigned to someone else; gets auth error', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 101, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'other-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1002, + body: '/unassign', + user: { login: 'sneaky-user', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [ + `⚠️ Hi @sneaky-user! You cannot unassign this issue because it is currently assigned to @other-contributor.\n\nOnly the current assignee can unassign themselves.`, + ], + }, + { + name: 'Validation - No Assignees on Issue', + description: 'User tries to unassign an issue that has no assignees at all', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 102, + state: ISSUE_STATE.OPEN, + assignees: [], + labels: [{ name: LABELS.READY_FOR_DEV }], + }, + comment: { + id: 1003, + body: '/unassign', + user: { login: 'confused-user', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [ + `👋 Hi @confused-user! This issue doesn't currently have any assignees.`, + ], + }, + { + name: 'Validation - Issue Already Closed', + description: 'User tries to unassign from a closed issue; command is rejected', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 103, + state: ISSUE_STATE.CLOSED, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1004, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: true, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [ + `👋 Hi @active-contributor! This issue is already closed, so the \`/unassign\` command cannot be used here.`, + ], + }, + + // --------------------------------------------------------------------------- + // NO ACTION + // --------------------------------------------------------------------------- + { + name: 'No Action - Regular Comment Ignored', + description: 'Non-command comments on assigned issues produce zero API calls', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 109, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1010, + body: 'Looking good, will review tomorrow!', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: false, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [], + }, + { + name: 'No Action - Bot Comment Ignored', + description: 'Bot-authored comments are silently ignored to prevent feedback loops', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 108, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'github-actions[bot]' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1009, + body: '/unassign', + user: { login: 'github-actions[bot]', type: 'Bot' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: {}, + expectReaction: false, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [], + }, + + // --------------------------------------------------------------------------- + // ERROR HANDLING + // --------------------------------------------------------------------------- + { + name: 'Error - removeAssignees API Failure', + description: 'GitHub API throws on removeAssignees; failure comment posted, no label changes made', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 104, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1005, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: { removeAssigneesShouldFail: true }, + expectReaction: true, + expectedAssigneeRemoved: null, + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [ + `⚠️ Hi @active-contributor! I tried to unassign you, but encountered an unexpected error.\n\n@utkarsh232005 — could you please manually unassign @active-contributor?`, + ], + }, + { + name: 'Error - Label Swap API Failure', + description: 'Label swap fails after successful unassignment; error logged, success comment still posted', + context: { + eventName: 'issue_comment', + payload: { + issue: { + number: 105, + state: ISSUE_STATE.OPEN, + assignees: [{ login: 'active-contributor' }], + labels: [{ name: LABELS.IN_PROGRESS }], + }, + comment: { + id: 1006, + body: '/unassign', + user: { login: 'active-contributor', type: 'User' }, + }, + }, + repo: { owner: 'kdm-ledger', repo: 'kdm-cli' }, + }, + githubOptions: { removeLabelShouldFail: true, addLabelShouldFail: true }, + expectReaction: true, + expectedAssigneeRemoved: 'active-contributor', + expectedLabelRemoved: null, + expectedLabelAdded: null, + expectedComments: [ + `👋 Hi @active-contributor! You have been successfully unassigned from this issue.\n\nThe \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`, + ], + }, +]; + +// ============================================================================= +// TEST RUNNER +// ============================================================================= + +/** + * Runs a single test scenario and returns whether it passed. + * + * @param {object} scenario - The scenario definition. + * @param {number} index - Zero-based scenario index. + * @returns {Promise} + */ +async function runTest(scenario, index) { + console.log('\n' + '='.repeat(70)); + console.log(`TEST ${index + 1}: ${scenario.name}`); + console.log(`Description: ${scenario.description}`); + console.log('='.repeat(70)); + + const mockGithub = createMockGithub(scenario.githubOptions); + + try { + await script({ github: mockGithub, context: scenario.context }); + } catch (error) { + console.log(`\n❌ SCRIPT THREW ERROR: ${error.message}`); + } + + const results = { passed: true, details: [] }; + + // Verify: assignee removed + if (scenario.expectedAssigneeRemoved) { + if (mockGithub.calls.assigneesRemoved.includes(scenario.expectedAssigneeRemoved)) { + results.details.push(`✅ Correctly removed assignee: ${scenario.expectedAssigneeRemoved}`); + } else { + results.passed = false; + results.details.push(`❌ Expected to remove assignee ${scenario.expectedAssigneeRemoved}, got: ${mockGithub.calls.assigneesRemoved.join(', ') || 'none'}`); + } + } else { + if (mockGithub.calls.assigneesRemoved.length === 0) { + results.details.push('✅ Correctly did not remove any assignees'); + } else { + results.passed = false; + results.details.push(`❌ Should not have removed assignees, but removed: ${mockGithub.calls.assigneesRemoved.join(', ')}`); + } + } + + // Verify: label removed + if ('expectedLabelRemoved' in scenario) { + if (scenario.expectedLabelRemoved === null) { + if (mockGithub.calls.labelsRemoved.length === 0) { + results.details.push('✅ Correctly did not remove any labels'); + } else { + results.passed = false; + results.details.push(`❌ Expected NO labels removed, got: ${mockGithub.calls.labelsRemoved.join(', ')}`); + } + } else { + if (mockGithub.calls.labelsRemoved.includes(scenario.expectedLabelRemoved)) { + results.details.push(`✅ Correctly removed label: ${scenario.expectedLabelRemoved}`); + } else { + results.passed = false; + results.details.push(`❌ Expected to remove label "${scenario.expectedLabelRemoved}", got: ${mockGithub.calls.labelsRemoved.join(', ') || 'none'}`); + } + } + } + + // Verify: label added + if ('expectedLabelAdded' in scenario) { + if (scenario.expectedLabelAdded === null) { + if (mockGithub.calls.labelsAdded.length === 0) { + results.details.push('✅ Correctly did not add any labels'); + } else { + results.passed = false; + results.details.push(`❌ Expected NO labels added, got: ${mockGithub.calls.labelsAdded.join(', ')}`); + } + } else { + if (mockGithub.calls.labelsAdded.includes(scenario.expectedLabelAdded)) { + results.details.push(`✅ Correctly added label: ${scenario.expectedLabelAdded}`); + } else { + results.passed = false; + results.details.push(`❌ Expected to add label "${scenario.expectedLabelAdded}", got: ${mockGithub.calls.labelsAdded.join(', ') || 'none'}`); + } + } + } + + // Verify: comments + const commentResult = verifyComments(scenario.expectedComments || [], mockGithub.calls.comments); + if (!commentResult.passed) results.passed = false; + results.details.push(...commentResult.details); + + // Verify: reaction + if (scenario.expectReaction) { + const reacted = mockGithub.calls.reactions.some(r => r.content === '+1'); + if (reacted) { + results.details.push('✅ Correctly added thumbs-up reaction'); + } else { + results.passed = false; + results.details.push('❌ Expected thumbs-up reaction but none was added'); + } + } else { + if (mockGithub.calls.reactions.length === 0) { + results.details.push('✅ Correctly did not add any reactions'); + } else { + results.passed = false; + results.details.push(`❌ Expected NO reactions, but added: ${mockGithub.calls.reactions.map(r => r.content).join(', ')}`); + } + } + + console.log('\n📊 RESULT:'); + results.details.forEach((d) => console.log(` ${d}`)); + + return results.passed; +} + +runTestSuite('BOT-UNASSIGN-ON-COMMENT TEST SUITE', scenarios, runTest); \ No newline at end of file diff --git a/.github/scripts/tests/test-utils.js b/.github/scripts/tests/test-utils.js new file mode 100644 index 0000000..c71eef2 --- /dev/null +++ b/.github/scripts/tests/test-utils.js @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// tests/test-utils.js +// +// Shared test utilities for bot script test suites. Provides commit helpers, +// mock GitHub factory, comment snapshot verification, result checking, summary +// printing, and CLI argument handling. + +/** + * Compares actual comments against expected snapshots. Returns an array of + * { passed: boolean, detail: string } results and logs mismatches. + * + * @param {string[]} expectedComments - Expected comment bodies. + * @param {string[]} actualComments - Actual comment bodies captured during the test. + * @returns {{ passed: boolean, details: string[] }} + */ +function verifyComments(expectedComments, actualComments) { + const results = { passed: true, details: [] }; + + if (expectedComments.length === 0 && actualComments.length === 0) { + results.details.push('✅ Correctly posted no comments'); + return results; + } + + if (expectedComments.length !== actualComments.length) { + results.passed = false; + results.details.push(`❌ Expected ${expectedComments.length} comment(s), got ${actualComments.length}`); + return results; + } + + for (let i = 0; i < expectedComments.length; i++) { + if (actualComments[i] === expectedComments[i]) { + results.details.push(`✅ Comment ${i + 1} matches snapshot`); + } else { + results.passed = false; + results.details.push(`❌ Comment ${i + 1} does not match snapshot`); + console.log('\n📋 EXPECTED:'); + console.log('─'.repeat(60)); + console.log(expectedComments[i]); + console.log('─'.repeat(60)); + console.log('\n📋 ACTUAL:'); + console.log('─'.repeat(60)); + console.log(actualComments[i]); + console.log('─'.repeat(60)); + } + } + + return results; +} + +/** + * Prints a summary table and exits with the appropriate code. + * + * @param {{ label: string, total: number, passed: number, failed: number }[]} sections + * One entry per test section (e.g. unit tests, integration tests). + */ +function printSummaryAndExit(sections) { + console.log('\n' + '='.repeat(70)); + console.log('📈 SUMMARY'); + console.log('='.repeat(70)); + + let anyFailed = false; + for (const { label, total, passed, failed } of sections) { + if (failed > 0) anyFailed = true; + console.log(` ${label}: ${total} total, ${passed} passed${failed > 0 ? `, ${failed} failed ❌` : ' ✅'}`); + } + + console.log('='.repeat(70)); + process.exit(anyFailed ? 1 : 0); +} + +/** + * Parses the optional test-index CLI argument and either runs a single + * scenario or all scenarios, then prints a summary and exits. + * + * @param {string} suiteName - Display name (e.g. "ON-COMMIT BOT TEST SUITE"). + * @param {object[]} scenarios - Array of scenario objects. + * @param {function(object, number): Promise} runScenario + * Async function that runs one scenario and returns true if it passed. + * @param {{ label: string, run: () => Promise<{ total: number, passed: number, failed: number }> }[]} [extraSections] + * Optional extra sections (e.g. unit tests) to run before scenarios. + */ +async function runTestSuite(suiteName, scenarios, runScenario, extraSections = []) { + const testIndex = process.argv[2]; + + if (testIndex !== undefined) { + const index = parseInt(testIndex, 10); + if (index >= 0 && index < scenarios.length) { + const ok = await runScenario(scenarios[index], index); + printSummaryAndExit([{ label: 'Single Test', total: 1, passed: ok ? 1 : 0, failed: ok ? 0 : 1 }]); + } else { + console.log(`Invalid test index. Available: 0-${scenarios.length - 1}`); + console.log('\nAvailable scenarios:'); + scenarios.forEach((s, i) => console.log(` ${i}: ${s.name}`)); + process.exit(1); + } + return; + } + + console.log(`🧪 ${suiteName}`); + console.log('='.repeat(suiteName.length + 3) + '\n'); + + const summaries = []; + + for (const section of extraSections) { + const result = await section.run(); + summaries.push({ label: section.label, ...result }); + } + + console.log('\n\n🔗 INTEGRATION TESTS'); + console.log('='.repeat(70)); + + let passed = 0; + let failed = 0; + for (let i = 0; i < scenarios.length; i++) { + const ok = await runScenario(scenarios[i], i); + if (ok) passed++; + else failed++; + } + summaries.push({ label: 'Integration Tests', total: scenarios.length, passed, failed }); + + printSummaryAndExit(summaries); +} + +// ============================================================================= +// COMMIT HELPERS +// ============================================================================= + +function commitDCOAndGPG(sha, message) { + return { + sha, + commit: { + message: `${message}\n\nSigned-off-by: Contributor `, + verification: { verified: true }, + }, + }; +} + +function commitDCOFail(sha, message) { + return { + sha, + commit: { + message, + verification: { verified: true }, + }, + }; +} + +function commitGPGFail(sha, message) { + return { + sha, + commit: { + message: `${message}\n\nSigned-off-by: Contributor `, + verification: { verified: false }, + }, + }; +} + +function commitMerge(sha, message) { + return { + sha, + parents: [{}, {}], + commit: { + message, + verification: { verified: true }, + }, + }; +} + +// ============================================================================= +// MOCK GITHUB FACTORY +// ============================================================================= + +/** + * Creates a mock GitHub API object for integration tests. + * Tracks labels, assignees, and comments via the returned calls object. + * + * @param {object} options + * @param {Array} options.commits - Commits for pulls.listCommits + * @param {boolean} options.mergeable - Merge state for pulls.get + * @param {Record} options.issues - issue_number -> issue data + * @param {number[]} options.graphqlClosingIssues - Issue numbers for graphql + * @param {Array<{id: number, body: string}>} options.existingComments - Pre-existing PR comments + * @returns {{ calls: object, rest: object, graphql: function }} + */ +function createMockGithub(options = {}) { + const { + commits = [], + mergeable = true, + issues = {}, + graphqlClosingIssues = [], + existingComments = [], + } = options; + + const calls = { + labelsAdded: [], + labelsRemoved: [], + assignees: [], + commentsCreated: [], + commentsUpdated: [], + }; + + const perPage = 100; + const listComments = async (params) => { + const page = params.page || 1; + const start = (page - 1) * perPage; + const slice = existingComments.slice(start, start + perPage); + return { data: slice }; + }; + + const mock = { + calls, + rest: { + pulls: { + listCommits: async (params) => { + const page = params.page || 1; + const start = (page - 1) * perPage; + const slice = commits.slice(start, start + perPage); + return { data: slice }; + }, + get: async () => ({ + data: { + mergeable, + mergeable_state: mergeable ? 'clean' : 'dirty', + }, + }), + }, + issues: { + listComments: async (params) => listComments(params), + createComment: async (params) => { + calls.commentsCreated.push(params.body); + return {}; + }, + updateComment: async (params) => { + calls.commentsUpdated.push({ comment_id: params.comment_id, body: params.body }); + return {}; + }, + addLabels: async (params) => { + const labels = Array.isArray(params.labels) ? params.labels : [params.labels]; + calls.labelsAdded.push(...labels); + return {}; + }, + removeLabel: async (params) => { + calls.labelsRemoved.push(params.name); + return {}; + }, + addAssignees: async (params) => { + calls.assignees.push(...(params.assignees || [])); + return {}; + }, + get: async (params) => { + const num = params.issue_number; + const issue = issues[num] || { title: 'Issue', assignees: [] }; + return { data: issue }; + }, + }, + }, + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: graphqlClosingIssues.map((n) => ({ number: n })), + }, + }, + }, + }), + }; + + return mock; +} + +module.exports = { + verifyComments, + runTestSuite, + commitDCOAndGPG, + commitDCOFail, + commitGPGFail, + commitMerge, + createMockGithub, +}; diff --git a/.github/workflows/on-comment.yaml b/.github/workflows/on-comment.yaml new file mode 100644 index 0000000..941643e --- /dev/null +++ b/.github/workflows/on-comment.yaml @@ -0,0 +1,72 @@ +name: Bot - On Comment + +# ────────────────────────────────────────────────────────────────────── +# Workflow: Bot - On Comment +# +# Purpose: +# Runs when a NEW comment is created on an issue. Dispatches to +# bot-on-comment.js which parses slash commands (e.g. /assign) from +# the comment body and runs the appropriate handler. +# +# Currently supported commands: +# /assign — Assign the commenter to the issue (see commands/assign.js +# for eligibility checks: skill prerequisites, assignment +# limits, required status labels). +# /unassign — Unassign the commenter from the issue (see commands/unassign.js +# for authorization and label reversion details). +# /finalize — Finalize the issue (see commands/finalize.js for triage +# permission requirements, label validation, and status updates). +# +# Security: +# - Checks out the default branch (never the PR branch) to prevent +# running untrusted code with the write token. +# - The if-guard ensures this only fires on issue comments, not on +# PR review comments (which have a different payload shape). +# +# Concurrency: +# Serialized per issue number (cancel-in-progress: false) to prevent +# same-issue races where two different users both see assignees=[] and +# both get assigned. Same-issue collisions are caught by the pre-write +# fresh issues.get() in assignAndFinalize(). Same-user multi-issue limits +# are enforced via REST API counting in enforceAssignmentLimit(). +# ────────────────────────────────────────────────────────────────────── +on: + issue_comment: + types: + - created + +permissions: + issues: write # Required to add assignees, labels, reactions, and post comments + contents: read # Required to checkout the default branch for bot scripts + +jobs: + on-comment: + # Only run on issue comments (not PR review comments which also trigger issue_comment) + if: github.event.issue.pull_request == null + + runs-on: ubuntu-latest + + # Serialize per issue to prevent same-issue races without blocking other issues. + # IMPORTANT: keep this keyed by issue.number (not github.actor), otherwise + # two different users can run /assign on the same issue concurrently. + concurrency: + group: on-comment-${{ github.event.issue.number }} + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Run On-Comment Handler + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-comment.js'); + await script({ github, context }); diff --git a/.github/workflows/on-pr-close.yaml b/.github/workflows/on-pr-close.yaml new file mode 100644 index 0000000..d8c656a --- /dev/null +++ b/.github/workflows/on-pr-close.yaml @@ -0,0 +1,61 @@ +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: ubuntu-latest + 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@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-close.js'); + await script({ github, context }); + + on-pr-merged-conflict-check: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Run Sibling Conflict Check + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-merged.js'); + await script({ github, context }); \ No newline at end of file diff --git a/.github/workflows/on-pr-review-labels.yaml b/.github/workflows/on-pr-review-labels.yaml new file mode 100644 index 0000000..6e9381f --- /dev/null +++ b/.github/workflows/on-pr-review-labels.yaml @@ -0,0 +1,43 @@ +name: Bot - On PR Review (Labels) + +on: + workflow_run: + workflows: ["Bot - On PR Review"] + types: + - completed + +permissions: + contents: read + pull-requests: write + issues: write + actions: read + +jobs: + run-bot: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout default branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: review-event-${{ github.event.workflow_run.id }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Run bot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-review-labels.js'); + await script({ github, context }); diff --git a/.github/workflows/on-pr-review.yaml b/.github/workflows/on-pr-review.yaml new file mode 100644 index 0000000..403782a --- /dev/null +++ b/.github/workflows/on-pr-review.yaml @@ -0,0 +1,36 @@ +name: Bot - On PR Review + +on: + pull_request_review: + types: + - submitted + +permissions: + contents: read + +jobs: + on-pr-review: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + + concurrency: + group: pr-bot-${{ github.event.pull_request.number }} + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Save review event + run: | + echo '{"pr_number":${{ github.event.pull_request.number }}, "review_state":"${{ github.event.review.state }}", "draft":${{ github.event.pull_request.draft }}}' > review-event.json + + - name: Upload review event + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: review-event-${{ github.run_id }} + path: review-event.json + retention-days: 1 + \ No newline at end of file diff --git a/.github/workflows/on-pr-update.yaml b/.github/workflows/on-pr-update.yaml new file mode 100644 index 0000000..50228c8 --- /dev/null +++ b/.github/workflows/on-pr-update.yaml @@ -0,0 +1,41 @@ +name: Bot - On PR Update + +# Runs on new commits (synchronize) and PR body edits (edited). Performs all +# 4 checks (DCO, GPG, merge conflict, issue link), posts/updates unified +# comment, and conditionally swaps the status label via bot-on-pr-update.js. +# For edited events, exits early if only title/base changed. +on: + pull_request_target: + types: + - synchronize + - edited + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + on-pr-update: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + + concurrency: + group: pr-bot-${{ github.event.pull_request.number }} + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Bot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-update.js'); + await script({ github, context }); diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml new file mode 100644 index 0000000..411e092 --- /dev/null +++ b/.github/workflows/on-pr.yaml @@ -0,0 +1,43 @@ +name: Bot - On PR Open + +# Runs on opened, reopened, and ready_for_review. Single job: run all 4 checks +# (DCO, GPG, merge conflict, issue link), post unified comment, auto-assign, +# and apply status label via bot-on-pr-open.js. +on: + # Uses pull_request_target so fork PRs get write token without repo setting; we + # checkout default branch only so we never run PR branch code. + pull_request_target: + types: + - opened + - reopened + - ready_for_review + +permissions: + pull-requests: write + contents: read + checks: write + +jobs: + on-pr-open: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + + concurrency: + group: pr-bot-${{ github.event.pull_request.number }} + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Bot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-on-pr-open.js'); + await script({ github, context }); diff --git a/.github/workflows/on-schedule-inactivity.yaml b/.github/workflows/on-schedule-inactivity.yaml new file mode 100644 index 0000000..507c729 --- /dev/null +++ b/.github/workflows/on-schedule-inactivity.yaml @@ -0,0 +1,66 @@ +name: Bot - Inactivity Check + +# ────────────────────────────────────────────────────────────────────── +# Workflow: Bot - Inactivity Check +# +# Purpose: +# Runs daily to detect assigned issues and PRs that have gone inactive. +# Uses bot-inactivity.js to: +# - Warn after 5 days of inactivity (idempotent comment via HTML marker) +# - Close, unassign, and reset to "status: ready for dev" after 7 days +# +# Activity signals (reset the 5-day clock): +# - A non-bot comment by the author or any assignee +# - A commit pushed to a PR branch by the PR author +# +# Cross-referencing: +# - Issues linked to open PRs with recent activity are not flagged +# - When a PR is closed for inactivity, its linked issues are also reset +# +# Exemptions: +# - Items with the "status: blocked" label are skipped +# +# Security: +# - Always checks out the default branch (never a PR branch) +# - workflow_dispatch allows manual triggering for testing +# +# Concurrency: +# Serialized globally — only one inactivity check runs at a time to +# avoid double-warning or double-closing the same items. +# ────────────────────────────────────────────────────────────────────── +on: + schedule: + - cron: '0 0 * * *' # Daily at midnight UTC + workflow_dispatch: # Manual trigger for testing + +permissions: + issues: write # Add/remove labels, assignees, comments, close issues + pull-requests: write # Close PRs + contents: read # Checkout repository for bot scripts + +jobs: + inactivity-check: + name: Inactivity Check + runs-on: ubuntu-latest + + concurrency: + group: inactivity-check + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Run Inactivity Check + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-inactivity.js'); + await script({ github, context }); From 2fef7e3871771af9b1bd06e442389e124a9576c1 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 17 May 2026 02:10:13 +0530 Subject: [PATCH 2/5] fix: set package type to commonjs for bot scripts --- .github/scripts/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 14c8f72..0a82187 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -1,6 +1,7 @@ { "name": "kdm-cli-org-bot-scripts", "private": true, + "type": "commonjs", "devDependencies": { "eslint": "^9.0.0" } From 0772204e1efe26df2ad1f1f5612937397bd693fe Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 17 May 2026 02:15:49 +0530 Subject: [PATCH 3/5] fix: rename all bot scripts to .cjs to avoid ES module scope errors --- ...omments.js => bot-inactivity-comments.cjs} | 4 +- .../{bot-inactivity.js => bot-inactivity.cjs} | 4 +- .../{bot-on-comment.js => bot-on-comment.cjs} | 8 +-- ...bot-on-pr-close.js => bot-on-pr-close.cjs} | 2 +- ...t-on-pr-merged.js => bot-on-pr-merged.cjs} | 8 +-- .../{bot-on-pr-open.js => bot-on-pr-open.cjs} | 2 +- ...-labels.js => bot-on-pr-review-labels.cjs} | 8 +-- ...t-on-pr-review.js => bot-on-pr-review.cjs} | 2 +- ...t-on-pr-update.js => bot-on-pr-update.cjs} | 2 +- ...end-issues.js => bot-recommend-issues.cjs} | 2 +- ...assign-comments.js => assign-comments.cjs} | 6 +- .../commands/{assign.js => assign.cjs} | 6 +- ...lize-comments.js => finalize-comments.cjs} | 2 +- .../commands/{finalize.js => finalize.cjs} | 4 +- ...sign-comments.js => unassign-comments.cjs} | 2 +- .../commands/{unassign.js => unassign.cjs} | 4 +- .github/scripts/helpers/{api.js => api.cjs} | 2 +- .../scripts/helpers/{checks.js => checks.cjs} | 2 +- .../helpers/{comments.js => comments.cjs} | 2 +- .../{config-loader.js => config-loader.cjs} | 14 ++-- .../helpers/{constants.js => constants.cjs} | 4 +- .../scripts/helpers/{index.js => index.cjs} | 2 +- .../scripts/helpers/{logger.js => logger.cjs} | 2 +- .../helpers/{validation.js => validation.cjs} | 2 +- .../scripts/{pr-labeler.js => pr-labeler.cjs} | 0 .../tests/{test-api.js => test-api.cjs} | 6 +- ...test-assign-bot.js => test-assign-bot.cjs} | 8 +-- .../tests/{test-checks.js => test-checks.cjs} | 6 +- .../{test-comments.js => test-comments.cjs} | 6 +- ...onfig-loader.js => test-config-loader.cjs} | 72 +++++++++---------- ...-finalize-bot.js => test-finalize-bot.cjs} | 10 +-- ...ctivity-bot.js => test-inactivity-bot.cjs} | 10 +-- ...comment-bot.js => test-on-comment-bot.cjs} | 6 +- ...-close-bot.js => test-on-pr-close-bot.cjs} | 8 +-- ...erged-bot.js => test-on-pr-merged-bot.cjs} | 4 +- ...pr-open-bot.js => test-on-pr-open-bot.cjs} | 8 +-- ...eview-bot.js => test-on-pr-review-bot.cjs} | 8 +-- ...pdate-bot.js => test-on-pr-update-bot.cjs} | 8 +-- ...s-bot.js => test-recommend-issues-bot.cjs} | 6 +- ...-unassign-bot.js => test-unassign-bot.cjs} | 8 +-- .../tests/{test-utils.js => test-utils.cjs} | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/on-comment.yaml | 10 +-- .github/workflows/on-pr-close.yaml | 6 +- .github/workflows/on-pr-review-labels.yaml | 2 +- .github/workflows/on-pr-review.yaml | 4 +- .github/workflows/on-pr-update.yaml | 4 +- .github/workflows/on-pr.yaml | 4 +- .github/workflows/on-schedule-inactivity.yaml | 4 +- .github/workflows/publish.yml | 8 +-- .github/workflows/test.yml | 4 +- 51 files changed, 160 insertions(+), 160 deletions(-) rename .github/scripts/{bot-inactivity-comments.js => bot-inactivity-comments.cjs} (97%) rename .github/scripts/{bot-inactivity.js => bot-inactivity.cjs} (99%) rename .github/scripts/{bot-on-comment.js => bot-on-comment.cjs} (93%) rename .github/scripts/{bot-on-pr-close.js => bot-on-pr-close.cjs} (99%) rename .github/scripts/{bot-on-pr-merged.js => bot-on-pr-merged.cjs} (93%) rename .github/scripts/{bot-on-pr-open.js => bot-on-pr-open.cjs} (98%) rename .github/scripts/{bot-on-pr-review-labels.js => bot-on-pr-review-labels.cjs} (79%) rename .github/scripts/{bot-on-pr-review.js => bot-on-pr-review.cjs} (97%) rename .github/scripts/{bot-on-pr-update.js => bot-on-pr-update.cjs} (98%) rename .github/scripts/bot/{bot-recommend-issues.js => bot-recommend-issues.cjs} (99%) rename .github/scripts/commands/{assign-comments.js => assign-comments.cjs} (99%) rename .github/scripts/commands/{assign.js => assign.cjs} (99%) rename .github/scripts/commands/{finalize-comments.js => finalize-comments.cjs} (99%) rename .github/scripts/commands/{finalize.js => finalize.cjs} (99%) rename .github/scripts/commands/{unassign-comments.js => unassign-comments.cjs} (98%) rename .github/scripts/commands/{unassign.js => unassign.cjs} (97%) rename .github/scripts/helpers/{api.js => api.cjs} (99%) rename .github/scripts/helpers/{checks.js => checks.cjs} (99%) rename .github/scripts/helpers/{comments.js => comments.cjs} (99%) rename .github/scripts/helpers/{config-loader.js => config-loader.cjs} (97%) rename .github/scripts/helpers/{constants.js => constants.cjs} (98%) rename .github/scripts/helpers/{index.js => index.cjs} (95%) rename .github/scripts/helpers/{logger.js => logger.cjs} (98%) rename .github/scripts/helpers/{validation.js => validation.cjs} (99%) rename .github/scripts/{pr-labeler.js => pr-labeler.cjs} (100%) rename .github/scripts/tests/{test-api.js => test-api.cjs} (99%) rename .github/scripts/tests/{test-assign-bot.js => test-assign-bot.cjs} (99%) rename .github/scripts/tests/{test-checks.js => test-checks.cjs} (99%) rename .github/scripts/tests/{test-comments.js => test-comments.cjs} (98%) rename .github/scripts/tests/{test-config-loader.js => test-config-loader.cjs} (89%) rename .github/scripts/tests/{test-finalize-bot.js => test-finalize-bot.cjs} (98%) rename .github/scripts/tests/{test-inactivity-bot.js => test-inactivity-bot.cjs} (99%) rename .github/scripts/tests/{test-on-comment-bot.js => test-on-comment-bot.cjs} (93%) rename .github/scripts/tests/{test-on-pr-close-bot.js => test-on-pr-close-bot.cjs} (95%) rename .github/scripts/tests/{test-on-pr-merged-bot.js => test-on-pr-merged-bot.cjs} (99%) rename .github/scripts/tests/{test-on-pr-open-bot.js => test-on-pr-open-bot.cjs} (98%) rename .github/scripts/tests/{test-on-pr-review-bot.js => test-on-pr-review-bot.cjs} (93%) rename .github/scripts/tests/{test-on-pr-update-bot.js => test-on-pr-update-bot.cjs} (99%) rename .github/scripts/tests/{test-recommend-issues-bot.js => test-recommend-issues-bot.cjs} (99%) rename .github/scripts/tests/{test-unassign-bot.js => test-unassign-bot.cjs} (99%) rename .github/scripts/tests/{test-utils.js => test-utils.cjs} (99%) diff --git a/.github/scripts/bot-inactivity-comments.js b/.github/scripts/bot-inactivity-comments.cjs similarity index 97% rename from .github/scripts/bot-inactivity-comments.js rename to .github/scripts/bot-inactivity-comments.cjs index 05b3951..36ce087 100644 --- a/.github/scripts/bot-inactivity-comments.js +++ b/.github/scripts/bot-inactivity-comments.cjs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-inactivity-comments.js +// bot-inactivity-comments.cjs // // Comment builders for the inactivity bot. Pure formatting functions // separated from the scheduled execution logic for readability. // // Duration values (WARN_AFTER_MS, CLOSE_AFTER_MS, BLOCKED_CHECKIN_AFTER_MS) -// remain in bot-inactivity.js and are passed in as parameters here so this +// remain in bot-inactivity.cjs and are passed in as parameters here so this // file stays free of module-level constants that belong to the scheduler. const { LABELS } = require('./helpers/constants'); diff --git a/.github/scripts/bot-inactivity.js b/.github/scripts/bot-inactivity.cjs similarity index 99% rename from .github/scripts/bot-inactivity.js rename to .github/scripts/bot-inactivity.cjs index a094298..9072f7b 100644 --- a/.github/scripts/bot-inactivity.js +++ b/.github/scripts/bot-inactivity.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-inactivity.js +// bot-inactivity.cjs // // Scheduled inactivity bot. Runs daily to detect stale assigned issues and PRs. // @@ -306,7 +306,7 @@ async function computeIssueLastActivity(github, owner, repo, issue, linkedOpenPR return latest; } -// ─── (Comment builders moved to bot-inactivity-comments.js) ───────────────── +// ─── (Comment builders moved to bot-inactivity-comments.cjs) ───────────────── // ─── State mutation ─────────────────────────────────────────────────────────── diff --git a/.github/scripts/bot-on-comment.js b/.github/scripts/bot-on-comment.cjs similarity index 93% rename from .github/scripts/bot-on-comment.js rename to .github/scripts/bot-on-comment.cjs index 990b962..1bec917 100644 --- a/.github/scripts/bot-on-comment.js +++ b/.github/scripts/bot-on-comment.cjs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-comment.js +// bot-on-comment.cjs // // Handles issue comment events: reads the comment body, parses commands, and dispatches // to the appropriate handler. Implemented commands: /assign, /unassign, /finalize. // -// /assign: see commands/assign.js (skill levels, assignment limits, required labels). -// /unassign: see commands/unassign.js (authorization, label reversion). -// /finalize: see commands/finalize.js (triage permission required; validates labels, +// /assign: see commands/assign.cjs (skill levels, assignment limits, required labels). +// /unassign: see commands/unassign.cjs (authorization, label reversion). +// /finalize: see commands/finalize.cjs (triage permission required; validates labels, // updates issue title/body with skill-level format, swaps status labels). const { createLogger, buildBotContext } = require('./helpers'); diff --git a/.github/scripts/bot-on-pr-close.js b/.github/scripts/bot-on-pr-close.cjs similarity index 99% rename from .github/scripts/bot-on-pr-close.js rename to .github/scripts/bot-on-pr-close.cjs index a309fd6..33fe69d 100644 --- a/.github/scripts/bot-on-pr-close.js +++ b/.github/scripts/bot-on-pr-close.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-close.js +// bot-on-pr-close.cjs // // Handles pull_request close events and triggers post-merge automation. // diff --git a/.github/scripts/bot-on-pr-merged.js b/.github/scripts/bot-on-pr-merged.cjs similarity index 93% rename from .github/scripts/bot-on-pr-merged.js rename to .github/scripts/bot-on-pr-merged.cjs index ae7479c..90fedb1 100644 --- a/.github/scripts/bot-on-pr-merged.js +++ b/.github/scripts/bot-on-pr-merged.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-merged.js +// bot-on-pr-merged.cjs // // Handles pull_request closed events where the PR was merged. // Updates the dashboard comments of sibling PRs when a newly merged PR @@ -80,10 +80,10 @@ module.exports = async ({ github, context }) => { return; } - // Decision: Sibling-conflict check is placed in a dedicated `bot-on-pr-merged.js` script - // rather than appending it to `bot-on-pr-close.js`. This separates the core PR status + // Decision: Sibling-conflict check is placed in a dedicated `bot-on-pr-merged.cjs` script + // rather than appending it to `bot-on-pr-close.cjs`. This separates the core PR status // updates (conflicts/labels/dashboard) from the contributor workflow automation - // (issue recommendation) handled by `bot-on-pr-close.js`. + // (issue recommendation) handled by `bot-on-pr-close.cjs`. await checkSiblingConflictsOnMerge(botContext); } catch (error) { diff --git a/.github/scripts/bot-on-pr-open.js b/.github/scripts/bot-on-pr-open.cjs similarity index 98% rename from .github/scripts/bot-on-pr-open.js rename to .github/scripts/bot-on-pr-open.cjs index 5fb3f18..614996a 100644 --- a/.github/scripts/bot-on-pr-open.js +++ b/.github/scripts/bot-on-pr-open.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-open.js +// bot-on-pr-open.cjs // // Runs when a PR is opened, reopened, or converted from draft (ready_for_review). // Performs all 4 checks (DCO, GPG, merge conflict, issue link), posts/updates diff --git a/.github/scripts/bot-on-pr-review-labels.js b/.github/scripts/bot-on-pr-review-labels.cjs similarity index 79% rename from .github/scripts/bot-on-pr-review-labels.js rename to .github/scripts/bot-on-pr-review-labels.cjs index bd19ff6..0bba040 100644 --- a/.github/scripts/bot-on-pr-review-labels.js +++ b/.github/scripts/bot-on-pr-review-labels.cjs @@ -1,16 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-review-labels.js +// bot-on-pr-review-labels.cjs // // Triggered by workflow_run after "Bot - On PR Review" completes. // Downloads the recorder artifact, reconstructs context, and delegates to -// bot-on-pr-review.js to apply the correct status label. +// bot-on-pr-review.cjs to apply the correct status label. const fs = require('fs'); module.exports = async ({ github, context }) => { const data = JSON.parse( - fs.readFileSync('review-event.json', 'utf8') + fs.readFileSync('review-event.cjson', 'utf8') ); if (data.draft) { @@ -35,6 +35,6 @@ module.exports = async ({ github, context }) => { }, }; - const bot = require('./bot-on-pr-review.js'); + const bot = require('./bot-on-pr-review.cjs'); await bot({ github, context }); }; diff --git a/.github/scripts/bot-on-pr-review.js b/.github/scripts/bot-on-pr-review.cjs similarity index 97% rename from .github/scripts/bot-on-pr-review.js rename to .github/scripts/bot-on-pr-review.cjs index 848e690..903d727 100644 --- a/.github/scripts/bot-on-pr-review.js +++ b/.github/scripts/bot-on-pr-review.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-review.js +// bot-on-pr-review.cjs // // Triggers on pull_request_review: submitted. // When a maintainer requests changes, automatically swaps the needs-review label diff --git a/.github/scripts/bot-on-pr-update.js b/.github/scripts/bot-on-pr-update.cjs similarity index 98% rename from .github/scripts/bot-on-pr-update.js rename to .github/scripts/bot-on-pr-update.cjs index 30dc44a..4cccddc 100644 --- a/.github/scripts/bot-on-pr-update.js +++ b/.github/scripts/bot-on-pr-update.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// bot-on-pr-update.js +// bot-on-pr-update.cjs // // Runs on new commits (synchronize) and PR body edits (edited). Performs all // 4 checks (DCO, GPG, merge conflict, issue link), posts/updates the unified diff --git a/.github/scripts/bot/bot-recommend-issues.js b/.github/scripts/bot/bot-recommend-issues.cjs similarity index 99% rename from .github/scripts/bot/bot-recommend-issues.js rename to .github/scripts/bot/bot-recommend-issues.cjs index 58cea7d..d44a7ec 100644 --- a/.github/scripts/bot/bot-recommend-issues.js +++ b/.github/scripts/bot/bot-recommend-issues.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/recommend-issues.js +// commands/recommend-issues.cjs // // Issue recommendation command: suggests relevant issues to contributors // after a PR is closed. Uses a history-based eligibility model to recommend issues strictly diff --git a/.github/scripts/commands/assign-comments.js b/.github/scripts/commands/assign-comments.cjs similarity index 99% rename from .github/scripts/commands/assign-comments.js rename to .github/scripts/commands/assign-comments.cjs index 5cea22f..3ea890a 100644 --- a/.github/scripts/commands/assign-comments.js +++ b/.github/scripts/commands/assign-comments.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/assign-comments.js +// commands/assign-comments.cjs // // Comment builders for the /assign command. Pure formatting functions // separated from assignment logic for readability. @@ -17,14 +17,14 @@ const { /** * Maximum number of open (non-blocked) issues a contributor can be assigned to - * at the same time. Enforced by handleAssign in assign.js. + * at the same time. Enforced by handleAssign in assign.cjs. * @type {number} */ const MAX_OPEN_ASSIGNMENTS = AUTOMATION_CONFIG.assignmentLimits.maxOpenAssignments; /** * Maximum number of Good First Issues a contributor may complete before being - * redirected to Beginner and higher-level issues. Enforced by handleAssign in assign.js. + * redirected to Beginner and higher-level issues. Enforced by handleAssign in assign.cjs. * @type {number} */ const MAX_GFI_COMPLETIONS = AUTOMATION_CONFIG.assignmentLimits.maxGfiCompletions; diff --git a/.github/scripts/commands/assign.js b/.github/scripts/commands/assign.cjs similarity index 99% rename from .github/scripts/commands/assign.js rename to .github/scripts/commands/assign.cjs index b9a1d73..b703841 100644 --- a/.github/scripts/commands/assign.js +++ b/.github/scripts/commands/assign.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/assign.js +// commands/assign.cjs // // /assign command: assigns the commenter to the issue. Enforces skill-level -// prerequisites, assignment limits, and status labels. See bot-on-comment.js +// prerequisites, assignment limits, and status labels. See bot-on-comment.cjs // for high-level docs (limits, skill levels, required labels). const { @@ -40,7 +40,7 @@ const { buildAssignmentFailureComment, } = require("./assign-comments"); -// Delegate to the active logger set by the dispatcher (bot-on-comment.js). +// Delegate to the active logger set by the dispatcher (bot-on-comment.cjs). // This ensures the correct prefix is used after command parsing. const logger = createDelegatingLogger(); diff --git a/.github/scripts/commands/finalize-comments.js b/.github/scripts/commands/finalize-comments.cjs similarity index 99% rename from .github/scripts/commands/finalize-comments.js rename to .github/scripts/commands/finalize-comments.cjs index f74ece0..7b69c69 100644 --- a/.github/scripts/commands/finalize-comments.js +++ b/.github/scripts/commands/finalize-comments.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/finalize-comments.js +// commands/finalize-comments.cjs // // Comment builders and per-skill-level boilerplate for the /finalize command. // Pure formatting functions separated from finalize logic for readability and testability. diff --git a/.github/scripts/commands/finalize.js b/.github/scripts/commands/finalize.cjs similarity index 99% rename from .github/scripts/commands/finalize.js rename to .github/scripts/commands/finalize.cjs index c5003bb..1e8152b 100644 --- a/.github/scripts/commands/finalize.js +++ b/.github/scripts/commands/finalize.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/finalize.js +// commands/finalize.cjs // // /finalize command: transitions a triaged issue from "awaiting triage" to // "ready for dev". Validates all required labels, updates the issue title with @@ -30,7 +30,7 @@ const { buildSuccessComment, } = require('./finalize-comments'); -// Delegate to the active logger set by the dispatcher (bot-on-comment.js). +// Delegate to the active logger set by the dispatcher (bot-on-comment.cjs). const logger = createDelegatingLogger(); // Permission levels that are allowed to run /finalize (triage and above). diff --git a/.github/scripts/commands/unassign-comments.js b/.github/scripts/commands/unassign-comments.cjs similarity index 98% rename from .github/scripts/commands/unassign-comments.js rename to .github/scripts/commands/unassign-comments.cjs index ba3f5c6..f88ba95 100644 --- a/.github/scripts/commands/unassign-comments.js +++ b/.github/scripts/commands/unassign-comments.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/unassign-comments.js +// commands/unassign-comments.cjs // // Comment builders for the /unassign command. Pure formatting functions // separated from unassignment logic for readability. diff --git a/.github/scripts/commands/unassign.js b/.github/scripts/commands/unassign.cjs similarity index 97% rename from .github/scripts/commands/unassign.js rename to .github/scripts/commands/unassign.cjs index eac65fa..dc4ed11 100644 --- a/.github/scripts/commands/unassign.js +++ b/.github/scripts/commands/unassign.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// commands/unassign.js +// commands/unassign.cjs // // /unassign command: allows a currently assigned contributor to unassign themselves. // Enforces authorization (only assignees can unassign themselves) and reverts @@ -83,7 +83,7 @@ async function handleUnassign(botContext) { return; } - // ACTION 2: Label Swapping (Mirroring assign.js style - no stale checks) + // ACTION 2: Label Swapping (Mirroring assign.cjs style - no stale checks) logger.log(`Swapping labels: removing ${LABELS.IN_PROGRESS}, adding ${LABELS.READY_FOR_DEV}`); const { success: swapSuccess, errorDetails: swapError } = await swapLabels(botContext, LABELS.IN_PROGRESS, LABELS.READY_FOR_DEV); if (!swapSuccess) { diff --git a/.github/scripts/helpers/api.js b/.github/scripts/helpers/api.cjs similarity index 99% rename from .github/scripts/helpers/api.js rename to .github/scripts/helpers/api.cjs index 31a686b..0cdd259 100644 --- a/.github/scripts/helpers/api.js +++ b/.github/scripts/helpers/api.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/api.js +// helpers/api.cjs // // Bot context builder and GitHub API wrappers (labels, assignees, comments, // commit/issue fetching, and label swap helpers). diff --git a/.github/scripts/helpers/checks.js b/.github/scripts/helpers/checks.cjs similarity index 99% rename from .github/scripts/helpers/checks.js rename to .github/scripts/helpers/checks.cjs index 969f269..02e4d3d 100644 --- a/.github/scripts/helpers/checks.js +++ b/.github/scripts/helpers/checks.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/checks.js +// helpers/checks.cjs // // Pure check functions for PR validation: DCO sign-off, GPG signatures, // merge conflicts, and issue link (with assignment verification). diff --git a/.github/scripts/helpers/comments.js b/.github/scripts/helpers/comments.cjs similarity index 99% rename from .github/scripts/helpers/comments.js rename to .github/scripts/helpers/comments.cjs index d2b5739..1339355 100644 --- a/.github/scripts/helpers/comments.js +++ b/.github/scripts/helpers/comments.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/comments.js +// helpers/comments.cjs // // Builds the unified PR Helper Bot dashboard comment. Designed with a layered // structure so future sections (commands, instructions) can be added alongside diff --git a/.github/scripts/helpers/config-loader.js b/.github/scripts/helpers/config-loader.cjs similarity index 97% rename from .github/scripts/helpers/config-loader.js rename to .github/scripts/helpers/config-loader.cjs index 4a4b438..b5830ce 100644 --- a/.github/scripts/helpers/config-loader.js +++ b/.github/scripts/helpers/config-loader.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/config-loader.js +// helpers/config-loader.cjs // // Loads and validates the repository automation configuration from -// .github/kdm-automation.json. Provides buildConstants() to map +// .github/kdm-automation.cjson. Provides buildConstants() to map // the nested config structure back into the flat constant shapes // consumed by the rest of the bot scripts. @@ -12,10 +12,10 @@ const path = require('path'); /** * Default path to the repository automation config file. - * Resolves from helpers/ → scripts/ → .github/kdm-automation.json. + * Resolves from helpers/ → scripts/ → .github/kdm-automation.cjson. * @type {string} */ -const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '../../kdm-automation.json'); +const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '../../kdm-automation.cjson'); /** * Validates that a value is a non-empty string. @@ -264,7 +264,7 @@ function validateConfig(config) { if (errors.length > 0) { throw new Error( - `Invalid kdm-automation.json:\n${errors.map((e) => ` - ${e}`).join('\n')}`, + `Invalid kdm-automation.cjson:\n${errors.map((e) => ` - ${e}`).join('\n')}`, ); } } @@ -302,8 +302,8 @@ function loadAutomationConfig(configPath = DEFAULT_CONFIG_PATH) { /** * Maps the nested config structure back into the flat constant shapes * consumed by the rest of the bot scripts. The returned object contains - * every derived constant that was previously hardcoded in constants.js, - * assign-comments.js, finalize-comments.js, and comments.js. + * every derived constant that was previously hardcoded in constants.cjs, + * assign-comments.cjs, finalize-comments.cjs, and comments.cjs. * * @param {object} config - A validated config object from loadAutomationConfig. * @returns {{ diff --git a/.github/scripts/helpers/constants.js b/.github/scripts/helpers/constants.cjs similarity index 98% rename from .github/scripts/helpers/constants.js rename to .github/scripts/helpers/constants.cjs index 11f3415..53f66a9 100644 --- a/.github/scripts/helpers/constants.js +++ b/.github/scripts/helpers/constants.cjs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/constants.js +// helpers/constants.cjs // // Shared constants for bot scripts: maintainer team, labels, issue state. const { loadAutomationConfig, buildConstants } = require('./config-loader'); /** - * Parsed and validated automation config loaded from .github/kdm-automation.json. + * Parsed and validated automation config loaded from .github/kdm-automation.cjson. * Exposed for modules that need access to nested config values (e.g. assignment limits). */ const AUTOMATION_CONFIG = loadAutomationConfig(); diff --git a/.github/scripts/helpers/index.js b/.github/scripts/helpers/index.cjs similarity index 95% rename from .github/scripts/helpers/index.js rename to .github/scripts/helpers/index.cjs index 8674711..c1a4629 100644 --- a/.github/scripts/helpers/index.js +++ b/.github/scripts/helpers/index.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/index.js +// helpers/index.cjs // // Single entry point for bot helpers. Re-exports constants, logger, validation, // API, checks, and comments. diff --git a/.github/scripts/helpers/logger.js b/.github/scripts/helpers/logger.cjs similarity index 98% rename from .github/scripts/helpers/logger.js rename to .github/scripts/helpers/logger.cjs index 133c8ee..9009cd3 100644 --- a/.github/scripts/helpers/logger.js +++ b/.github/scripts/helpers/logger.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/logger.js +// helpers/logger.cjs // // Logger factory and getter for bot scripts. Helpers use getLogger() so they // log with the current bot's prefix. diff --git a/.github/scripts/helpers/validation.js b/.github/scripts/helpers/validation.cjs similarity index 99% rename from .github/scripts/helpers/validation.js rename to .github/scripts/helpers/validation.cjs index ef6e4b1..96f6880 100644 --- a/.github/scripts/helpers/validation.js +++ b/.github/scripts/helpers/validation.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// helpers/validation.js +// helpers/validation.cjs // // Validation helpers for bot scripts. Only functions that need two or more checks // (e.g. not null and type, or type and format) are provided here. diff --git a/.github/scripts/pr-labeler.js b/.github/scripts/pr-labeler.cjs similarity index 100% rename from .github/scripts/pr-labeler.js rename to .github/scripts/pr-labeler.cjs diff --git a/.github/scripts/tests/test-api.js b/.github/scripts/tests/test-api.cjs similarity index 99% rename from .github/scripts/tests/test-api.js rename to .github/scripts/tests/test-api.cjs index a45088d..4ac86d4 100644 --- a/.github/scripts/tests/test-api.js +++ b/.github/scripts/tests/test-api.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-api.js +// tests/test-api.cjs // -// Unit tests for helpers/api.js (postOrUpdateComment, fetchPRCommits, swapStatusLabel, etc.). -// Run with: node .github/scripts/tests/test-api.js +// Unit tests for helpers/api.cjs (postOrUpdateComment, fetchPRCommits, swapStatusLabel, etc.). +// Run with: node .github/scripts/tests/test-api.cjs const { runTestSuite } = require('./test-utils'); const { diff --git a/.github/scripts/tests/test-assign-bot.js b/.github/scripts/tests/test-assign-bot.cjs similarity index 99% rename from .github/scripts/tests/test-assign-bot.js rename to .github/scripts/tests/test-assign-bot.cjs index 20c5186..f4ab3f1 100644 --- a/.github/scripts/tests/test-assign-bot.js +++ b/.github/scripts/tests/test-assign-bot.cjs @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-assign-bot.js +// tests/test-assign-bot.cjs // -// Local test script for bot-on-comment.js -// Run with: node .github/scripts/tests/test-assign-bot.js +// Local test script for bot-on-comment.cjs +// Run with: node .github/scripts/tests/test-assign-bot.cjs // // This script mocks the GitHub API and runs various test scenarios // to verify the on-comment (assign) bot behaves correctly without making real API calls. const { LABELS } = require("../helpers"); -const script = require("../bot-on-comment.js"); +const script = require("../bot-on-comment.cjs"); // ============================================================================= // MOCK GITHUB API diff --git a/.github/scripts/tests/test-checks.js b/.github/scripts/tests/test-checks.cjs similarity index 99% rename from .github/scripts/tests/test-checks.js rename to .github/scripts/tests/test-checks.cjs index da8dc40..e0e1786 100644 --- a/.github/scripts/tests/test-checks.js +++ b/.github/scripts/tests/test-checks.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-checks.js +// tests/test-checks.cjs // -// Unit tests for helpers/checks.js (DCO, GPG, merge conflict, issue link). -// Run with: node .github/scripts/tests/test-checks.js +// Unit tests for helpers/checks.cjs (DCO, GPG, merge conflict, issue link). +// Run with: node .github/scripts/tests/test-checks.cjs const { runTestSuite } = require('./test-utils'); const { diff --git a/.github/scripts/tests/test-comments.js b/.github/scripts/tests/test-comments.cjs similarity index 98% rename from .github/scripts/tests/test-comments.js rename to .github/scripts/tests/test-comments.cjs index 45a7b32..586c755 100644 --- a/.github/scripts/tests/test-comments.js +++ b/.github/scripts/tests/test-comments.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-comments.js +// tests/test-comments.cjs // -// Unit tests for helpers/comments.js (unified bot comment builder). -// Run with: node .github/scripts/tests/test-comments.js +// Unit tests for helpers/comments.cjs (unified bot comment builder). +// Run with: node .github/scripts/tests/test-comments.cjs const { runTestSuite } = require('./test-utils'); const { MARKER, buildBotComment, buildChecksSection, allChecksPassed, buildMergeConflictNotificationComment } = require('../helpers/comments'); diff --git a/.github/scripts/tests/test-config-loader.js b/.github/scripts/tests/test-config-loader.cjs similarity index 89% rename from .github/scripts/tests/test-config-loader.js rename to .github/scripts/tests/test-config-loader.cjs index 6fe8259..1869f6f 100644 --- a/.github/scripts/tests/test-config-loader.js +++ b/.github/scripts/tests/test-config-loader.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-config-loader.js +// tests/test-config-loader.cjs // -// Unit tests for helpers/config-loader.js -// Run with: node .github/scripts/tests/test-config-loader.js +// Unit tests for helpers/config-loader.cjs +// Run with: node .github/scripts/tests/test-config-loader.cjs // // Tests cover: // - Valid config loading and constant building @@ -208,13 +208,13 @@ const unitTests = [ { name: 'loadAutomationConfig: missing file → clear error', test: () => { - return expectLoadError('/nonexistent/path/config.json', 'Failed to read automation config'); + return expectLoadError('/nonexistent/path/config.cjson', 'Failed to read automation config'); }, }, { name: 'loadAutomationConfig: malformed JSON → clear error', test: () => { - const p = writeTempConfig('malformed.json', '{ broken json!!!'); + const p = writeTempConfig('malformed.cjson', '{ broken json!!!'); return expectLoadError(p, 'Failed to parse automation config'); }, }, @@ -227,7 +227,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.maintainerTeam; - const p = writeTempConfig('no-team.json', cfg); + const p = writeTempConfig('no-team.cjson', cfg); return expectLoadError(p, 'maintainerTeam must be a non-empty string'); }, }, @@ -236,7 +236,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.goodFirstIssueSupportTeam = ''; - const p = writeTempConfig('empty-gfi-team.json', cfg); + const p = writeTempConfig('empty-gfi-team.cjson', cfg); return expectLoadError(p, 'goodFirstIssueSupportTeam must be a non-empty string'); }, }, @@ -249,7 +249,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.labels; - const p = writeTempConfig('no-labels.json', cfg); + const p = writeTempConfig('no-labels.cjson', cfg); return expectLoadError(p, 'labels must be an object'); }, }, @@ -258,7 +258,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.labels.skill; - const p = writeTempConfig('no-skill-labels.json', cfg); + const p = writeTempConfig('no-skill-labels.cjson', cfg); return expectLoadError(p, 'labels.skill must be an object'); }, }, @@ -267,7 +267,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.labels.status.awaitingTriage = ''; - const p = writeTempConfig('empty-label.json', cfg); + const p = writeTempConfig('empty-label.cjson', cfg); return expectLoadError(p, 'labels.status.awaitingTriage is required and must be a non-empty string'); }, }, @@ -276,7 +276,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.labels.status.blocked; - const p = writeTempConfig('no-blocked.json', cfg); + const p = writeTempConfig('no-blocked.cjson', cfg); return expectLoadError(p, 'labels.status.blocked is required and must be a non-empty string'); }, }, @@ -285,7 +285,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.labels.skill.beginner; - const p = writeTempConfig('no-beginner.json', cfg); + const p = writeTempConfig('no-beginner.cjson', cfg); return expectLoadError(p, 'labels.skill.beginner is required and must be a non-empty string'); }, }, @@ -294,7 +294,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.labels.priority.high; - const p = writeTempConfig('no-high.json', cfg); + const p = writeTempConfig('no-high.cjson', cfg); return expectLoadError(p, 'labels.priority.high is required and must be a non-empty string'); }, }, @@ -307,7 +307,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.skillHierarchy = []; - const p = writeTempConfig('empty-skill-hier.json', cfg); + const p = writeTempConfig('empty-skill-hier.cjson', cfg); return expectLoadError(p, 'skillHierarchy must be a non-empty array'); }, }, @@ -316,7 +316,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.skillHierarchy.push('skill: nonexistent'); - const p = writeTempConfig('bad-skill-hier.json', cfg); + const p = writeTempConfig('bad-skill-hier.cjson', cfg); return expectLoadError(p, 'skillHierarchy entry "skill: nonexistent" not found in labels.skill values'); }, }, @@ -325,7 +325,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.priorityHierarchy.push('priority: ultra'); - const p = writeTempConfig('bad-prio-hier.json', cfg); + const p = writeTempConfig('bad-prio-hier.cjson', cfg); return expectLoadError(p, 'priorityHierarchy entry "priority: ultra" not found in labels.priority values'); }, }, @@ -334,7 +334,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.skillHierarchy.push(cfg.skillHierarchy[0]); - const p = writeTempConfig('dup-skill-hier.json', cfg); + const p = writeTempConfig('dup-skill-hier.cjson', cfg); return expectLoadError(p, 'appears more than once'); }, }, @@ -343,7 +343,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.priorityHierarchy.push(cfg.priorityHierarchy[0]); - const p = writeTempConfig('dup-prio-hier.json', cfg); + const p = writeTempConfig('dup-prio-hier.cjson', cfg); return expectLoadError(p, 'appears more than once'); }, }, @@ -356,7 +356,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.skillPrerequisites['skill: phantom'] = { requiredLabel: null, requiredCount: 0, displayName: 'Phantom' }; - const p = writeTempConfig('bad-prereq-key.json', cfg); + const p = writeTempConfig('bad-prereq-key.cjson', cfg); return expectLoadError(p, 'skillPrerequisites key "skill: phantom" not found in skillHierarchy'); }, }, @@ -365,7 +365,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.skillPrerequisites['skill: beginner'].requiredLabel = 'skill: imaginary'; - const p = writeTempConfig('bad-prereq-label.json', cfg); + const p = writeTempConfig('bad-prereq-label.cjson', cfg); return expectLoadError(p, 'requiredLabel "skill: imaginary" not found in skillHierarchy'); }, }, @@ -374,7 +374,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.skillPrerequisites['skill: intermediate']; - const p = writeTempConfig('missing-prereq-entry.json', cfg); + const p = writeTempConfig('missing-prereq-entry.cjson', cfg); return expectLoadError(p, 'skillPrerequisites is missing entry for skillHierarchy value "skill: intermediate"'); }, }, @@ -383,7 +383,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.skillPrerequisites['skill: good first issue'].requiredLabel; - const p = writeTempConfig('no-req-label.json', cfg); + const p = writeTempConfig('no-req-label.cjson', cfg); return expectLoadError(p, 'requiredLabel is required'); }, }, @@ -392,7 +392,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.skillPrerequisites['skill: beginner'].requiredCount; - const p = writeTempConfig('no-req-count.json', cfg); + const p = writeTempConfig('no-req-count.cjson', cfg); return expectLoadError(p, 'requiredCount must be a non-negative integer'); }, }, @@ -401,7 +401,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.skillPrerequisites['skill: advanced'].displayName; - const p = writeTempConfig('no-display-name.json', cfg); + const p = writeTempConfig('no-display-name.cjson', cfg); return expectLoadError(p, 'displayName is required and must be a non-empty string'); }, }, @@ -410,7 +410,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.skillPrerequisites['skill: intermediate'].prerequisiteDisplayName; - const p = writeTempConfig('no-prereq-display.json', cfg); + const p = writeTempConfig('no-prereq-display.cjson', cfg); return expectLoadError(p, 'prerequisiteDisplayName is required when requiredLabel is not null'); }, }, @@ -423,7 +423,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.assignmentLimits; - const p = writeTempConfig('no-limits.json', cfg); + const p = writeTempConfig('no-limits.cjson', cfg); return expectLoadError(p, 'assignmentLimits must be an object'); }, }, @@ -432,7 +432,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.assignmentLimits.maxOpenAssignments = 0; - const p = writeTempConfig('zero-limit.json', cfg); + const p = writeTempConfig('zero-limit.cjson', cfg); return expectLoadError(p, 'maxOpenAssignments must be a positive integer'); }, }, @@ -441,7 +441,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.assignmentLimits.maxGfiCompletions = -1; - const p = writeTempConfig('neg-gfi.json', cfg); + const p = writeTempConfig('neg-gfi.cjson', cfg); return expectLoadError(p, 'maxGfiCompletions must be a positive integer'); }, }, @@ -450,7 +450,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.assignmentLimits.maxOpenAssignments = 1.5; - const p = writeTempConfig('float-limit.json', cfg); + const p = writeTempConfig('float-limit.cjson', cfg); return expectLoadError(p, 'maxOpenAssignments must be a positive integer'); }, }, @@ -463,7 +463,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.documentation; - const p = writeTempConfig('no-docs.json', cfg); + const p = writeTempConfig('no-docs.cjson', cfg); return expectLoadError(p, 'documentation must be an object'); }, }, @@ -472,7 +472,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.documentation.signingGuide = ''; - const p = writeTempConfig('empty-doc.json', cfg); + const p = writeTempConfig('empty-doc.cjson', cfg); return expectLoadError(p, 'documentation.signingGuide is required and must be a non-empty string'); }, }, @@ -481,7 +481,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.documentation.readme; - const p = writeTempConfig('no-readme-doc.json', cfg); + const p = writeTempConfig('no-readme-doc.cjson', cfg); return expectLoadError(p, 'documentation.readme is required and must be a non-empty string'); }, }, @@ -494,7 +494,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.community; - const p = writeTempConfig('no-community.json', cfg); + const p = writeTempConfig('no-community.cjson', cfg); return expectLoadError(p, 'community must be an object'); }, }, @@ -503,7 +503,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); cfg.community.discordChannel = ' '; - const p = writeTempConfig('empty-discord.json', cfg); + const p = writeTempConfig('empty-discord.cjson', cfg); return expectLoadError(p, 'community.discordChannel is required and must be a non-empty string'); }, }, @@ -512,7 +512,7 @@ const unitTests = [ test: () => { const cfg = getValidConfig(); delete cfg.community.discordChannel; - const p = writeTempConfig('no-discord.json', cfg); + const p = writeTempConfig('no-discord.cjson', cfg); return expectLoadError(p, 'community.discordChannel is required and must be a non-empty string'); }, }, @@ -526,7 +526,7 @@ const unitTests = [ const cfg = getValidConfig(); cfg.maintainerTeam = '@my-org/my-team'; cfg.assignmentLimits.maxOpenAssignments = 5; - const p = writeTempConfig('custom.json', cfg); + const p = writeTempConfig('custom.cjson', cfg); const config = loadAutomationConfig(p); const derived = buildConstants(config); return ( diff --git a/.github/scripts/tests/test-finalize-bot.js b/.github/scripts/tests/test-finalize-bot.cjs similarity index 98% rename from .github/scripts/tests/test-finalize-bot.js rename to .github/scripts/tests/test-finalize-bot.cjs index 6446e05..0198afc 100644 --- a/.github/scripts/tests/test-finalize-bot.js +++ b/.github/scripts/tests/test-finalize-bot.cjs @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-finalize-bot.js +// tests/test-finalize-bot.cjs // -// Local test script for the /finalize command (bot-on-comment.js → commands/finalize.js). -// Run with: node .github/scripts/tests/test-finalize-bot.js +// Local test script for the /finalize command (bot-on-comment.cjs → commands/finalize.cjs). +// Run with: node .github/scripts/tests/test-finalize-bot.cjs // // Mocks the GitHub API and runs scenarios to verify /finalize behaves correctly // without making real API calls. const { LABELS } = require('../helpers'); -const script = require('../bot-on-comment.js'); +const script = require('../bot-on-comment.cjs'); const { runTestSuite, verifyComments } = require('./test-utils'); const { parseSections, @@ -634,7 +634,7 @@ const scenarios = [ ]; // ============================================================================= -// UNIT TESTS — pure functions from finalize-comments.js +// UNIT TESTS — pure functions from finalize-comments.cjs // ============================================================================= async function runUnitTests() { diff --git a/.github/scripts/tests/test-inactivity-bot.js b/.github/scripts/tests/test-inactivity-bot.cjs similarity index 99% rename from .github/scripts/tests/test-inactivity-bot.js rename to .github/scripts/tests/test-inactivity-bot.cjs index 3f7a710..a190d58 100644 --- a/.github/scripts/tests/test-inactivity-bot.js +++ b/.github/scripts/tests/test-inactivity-bot.cjs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-inactivity-bot.js +// tests/test-inactivity-bot.cjs // -// Integration tests for bot-inactivity.js. -// Run with: node .github/scripts/tests/test-inactivity-bot.js +// Integration tests for bot-inactivity.cjs. +// Run with: node .github/scripts/tests/test-inactivity-bot.cjs const { runTestSuite } = require('./test-utils'); -const script = require('../bot-inactivity.js'); +const script = require('../bot-inactivity.cjs'); const { LABELS } = require('../helpers/constants'); // ============================================================================= @@ -25,7 +25,7 @@ function daysAgo(n) { // ============================================================================= /** - * Creates a mock GitHub API for bot-inactivity.js tests. + * Creates a mock GitHub API for bot-inactivity.cjs tests. * * @param {object} opts * @param {object[]} opts.assignedIssues - Items returned by issues.listForRepo (assignee:*) diff --git a/.github/scripts/tests/test-on-comment-bot.js b/.github/scripts/tests/test-on-comment-bot.cjs similarity index 93% rename from .github/scripts/tests/test-on-comment-bot.js rename to .github/scripts/tests/test-on-comment-bot.cjs index 82f1224..52203fc 100644 --- a/.github/scripts/tests/test-on-comment-bot.js +++ b/.github/scripts/tests/test-on-comment-bot.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-comment-bot.js +// tests/test-on-comment-bot.cjs // -// Unit tests for parseComment in bot-on-comment.js. -// Run with: node .github/scripts/tests/test-on-comment-bot.js +// Unit tests for parseComment in bot-on-comment.cjs. +// Run with: node .github/scripts/tests/test-on-comment-bot.cjs const { runTestSuite } = require('./test-utils'); const { parseComment } = require('../bot-on-comment'); diff --git a/.github/scripts/tests/test-on-pr-close-bot.js b/.github/scripts/tests/test-on-pr-close-bot.cjs similarity index 95% rename from .github/scripts/tests/test-on-pr-close-bot.js rename to .github/scripts/tests/test-on-pr-close-bot.cjs index c3c71af..118911c 100644 --- a/.github/scripts/tests/test-on-pr-close-bot.js +++ b/.github/scripts/tests/test-on-pr-close-bot.cjs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-pr-close-bot.js +// tests/test-on-pr-close-bot.cjs // -// Integration tests for bot-on-pr-close.js post-merge automation. -// Run with: node .github/scripts/tests/test-on-pr-close-bot.js +// Integration tests for bot-on-pr-close.cjs post-merge automation. +// Run with: node .github/scripts/tests/test-on-pr-close-bot.cjs const { runTestSuite } = require('./test-utils'); -const script = require('../bot-on-pr-close.js'); +const script = require('../bot-on-pr-close.cjs'); const { LABELS, MAINTAINER_TEAM } = require('../helpers/constants'); function createMockGithub({ diff --git a/.github/scripts/tests/test-on-pr-merged-bot.js b/.github/scripts/tests/test-on-pr-merged-bot.cjs similarity index 99% rename from .github/scripts/tests/test-on-pr-merged-bot.js rename to .github/scripts/tests/test-on-pr-merged-bot.cjs index 3df660e..c76e883 100644 --- a/.github/scripts/tests/test-on-pr-merged-bot.js +++ b/.github/scripts/tests/test-on-pr-merged-bot.cjs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-pr-merged-bot.js +// tests/test-on-pr-merged-bot.cjs // -// Integration tests for the bot-on-pr-merged.js script. +// Integration tests for the bot-on-pr-merged.cjs script. // Verifies that when a PR is merged, sibling conflicts are properly evaluated // and their components (dashboard comments and labels) updated seamlessly. diff --git a/.github/scripts/tests/test-on-pr-open-bot.js b/.github/scripts/tests/test-on-pr-open-bot.cjs similarity index 98% rename from .github/scripts/tests/test-on-pr-open-bot.js rename to .github/scripts/tests/test-on-pr-open-bot.cjs index 239317a..6fe1f0d 100644 --- a/.github/scripts/tests/test-on-pr-open-bot.js +++ b/.github/scripts/tests/test-on-pr-open-bot.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-pr-open-bot.js +// tests/test-on-pr-open-bot.cjs // -// Integration tests for bot-on-pr-open.js (opened/reopened/ready_for_review). -// Run with: node .github/scripts/tests/test-on-pr-open-bot.js +// Integration tests for bot-on-pr-open.cjs (opened/reopened/ready_for_review). +// Run with: node .github/scripts/tests/test-on-pr-open-bot.cjs const { runTestSuite, @@ -12,7 +12,7 @@ const { commitGPGFail, createMockGithub, } = require('./test-utils'); -const script = require('../bot-on-pr-open.js'); +const script = require('../bot-on-pr-open.cjs'); const { LABELS } = require('../helpers/constants'); const { MARKER } = require('../helpers/comments'); diff --git a/.github/scripts/tests/test-on-pr-review-bot.js b/.github/scripts/tests/test-on-pr-review-bot.cjs similarity index 93% rename from .github/scripts/tests/test-on-pr-review-bot.js rename to .github/scripts/tests/test-on-pr-review-bot.cjs index 553c981..8cec0ad 100644 --- a/.github/scripts/tests/test-on-pr-review-bot.js +++ b/.github/scripts/tests/test-on-pr-review-bot.cjs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-pr-review-bot.js +// tests/test-on-pr-review-bot.cjs // -// Integration tests for bot-on-pr-review.js (pull_request_review trigger). -// Run with: node .github/scripts/tests/test-on-pr-review-bot.js +// Integration tests for bot-on-pr-review.cjs (pull_request_review trigger). +// Run with: node .github/scripts/tests/test-on-pr-review-bot.cjs const { runTestSuite, createMockGithub } = require('./test-utils'); -const script = require('../bot-on-pr-review.js'); +const script = require('../bot-on-pr-review.cjs'); const { LABELS } = require('../helpers/constants'); function defaultContext(overrides = {}) { diff --git a/.github/scripts/tests/test-on-pr-update-bot.js b/.github/scripts/tests/test-on-pr-update-bot.cjs similarity index 99% rename from .github/scripts/tests/test-on-pr-update-bot.js rename to .github/scripts/tests/test-on-pr-update-bot.cjs index b31e87b..617eeef 100644 --- a/.github/scripts/tests/test-on-pr-update-bot.js +++ b/.github/scripts/tests/test-on-pr-update-bot.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-on-pr-update-bot.js +// tests/test-on-pr-update-bot.cjs // -// Integration tests for bot-on-pr-update.js (synchronize + edited triggers). -// Run with: node .github/scripts/tests/test-on-pr-update-bot.js +// Integration tests for bot-on-pr-update.cjs (synchronize + edited triggers). +// Run with: node .github/scripts/tests/test-on-pr-update-bot.cjs const { runTestSuite, @@ -12,7 +12,7 @@ const { commitMerge, createMockGithub, } = require('./test-utils'); -const script = require('../bot-on-pr-update.js'); +const script = require('../bot-on-pr-update.cjs'); const { LABELS } = require('../helpers/constants'); const { MARKER } = require('../helpers/comments'); diff --git a/.github/scripts/tests/test-recommend-issues-bot.js b/.github/scripts/tests/test-recommend-issues-bot.cjs similarity index 99% rename from .github/scripts/tests/test-recommend-issues-bot.js rename to .github/scripts/tests/test-recommend-issues-bot.cjs index a6873ae..92f73a1 100644 --- a/.github/scripts/tests/test-recommend-issues-bot.js +++ b/.github/scripts/tests/test-recommend-issues-bot.cjs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-recommend-issues-bot.js +// tests/test-recommend-issues-bot.cjs // -// Unit tests for bot/bot-recommend-issues.js -// Run with: node .github/scripts/tests/test-recommend-issues-bot.js +// Unit tests for bot/bot-recommend-issues.cjs +// Run with: node .github/scripts/tests/test-recommend-issues-bot.cjs const { runTestSuite } = require('./test-utils'); const { LABELS, SKILL_HIERARCHY, SKILL_PREREQUISITES, MAINTAINER_TEAM, PRIORITY_HIERARCHY } = require('../helpers/constants'); diff --git a/.github/scripts/tests/test-unassign-bot.js b/.github/scripts/tests/test-unassign-bot.cjs similarity index 99% rename from .github/scripts/tests/test-unassign-bot.js rename to .github/scripts/tests/test-unassign-bot.cjs index d33185f..27c9c11 100644 --- a/.github/scripts/tests/test-unassign-bot.js +++ b/.github/scripts/tests/test-unassign-bot.cjs @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-unassign-bot.js +// tests/test-unassign-bot.cjs // -// Local test script for the /unassign command in bot-on-comment.js. -// Run with: node .github/scripts/tests/test-unassign-bot.js +// Local test script for the /unassign command in bot-on-comment.cjs. +// Run with: node .github/scripts/tests/test-unassign-bot.cjs // // This script mocks the GitHub API and runs various test scenarios // to verify the /unassign command behaves correctly. const { LABELS, ISSUE_STATE } = require('../helpers'); -const script = require('../bot-on-comment.js'); +const script = require('../bot-on-comment.cjs'); const { verifyComments, runTestSuite } = require('./test-utils'); // ============================================================================= diff --git a/.github/scripts/tests/test-utils.js b/.github/scripts/tests/test-utils.cjs similarity index 99% rename from .github/scripts/tests/test-utils.js rename to .github/scripts/tests/test-utils.cjs index c71eef2..55ff752 100644 --- a/.github/scripts/tests/test-utils.js +++ b/.github/scripts/tests/test-utils.cjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// tests/test-utils.js +// tests/test-utils.cjs // // Shared test utilities for bot script test suites. Provides commit helpers, // mock GitHub factory, comment snapshot verification, result checking, summary diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 68326f0..7f9b7ce 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -19,5 +19,5 @@ jobs: uses: actions/github-script@v7 with: script: | - const script = require('./.github/scripts/pr-labeler.js'); + const script = require('./.github/scripts/pr-labeler.cjs'); await script({ github, context }); diff --git a/.github/workflows/on-comment.yaml b/.github/workflows/on-comment.yaml index 941643e..519ea29 100644 --- a/.github/workflows/on-comment.yaml +++ b/.github/workflows/on-comment.yaml @@ -5,16 +5,16 @@ name: Bot - On Comment # # Purpose: # Runs when a NEW comment is created on an issue. Dispatches to -# bot-on-comment.js which parses slash commands (e.g. /assign) from +# bot-on-comment.cjs which parses slash commands (e.g. /assign) from # the comment body and runs the appropriate handler. # # Currently supported commands: -# /assign — Assign the commenter to the issue (see commands/assign.js +# /assign — Assign the commenter to the issue (see commands/assign.cjs # for eligibility checks: skill prerequisites, assignment # limits, required status labels). -# /unassign — Unassign the commenter from the issue (see commands/unassign.js +# /unassign — Unassign the commenter from the issue (see commands/unassign.cjs # for authorization and label reversion details). -# /finalize — Finalize the issue (see commands/finalize.js for triage +# /finalize — Finalize the issue (see commands/finalize.cjs for triage # permission requirements, label validation, and status updates). # # Security: @@ -68,5 +68,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-comment.js'); + const script = require('./.github/scripts/bot-on-comment.cjs'); await script({ github, context }); diff --git a/.github/workflows/on-pr-close.yaml b/.github/workflows/on-pr-close.yaml index d8c656a..d5d72f4 100644 --- a/.github/workflows/on-pr-close.yaml +++ b/.github/workflows/on-pr-close.yaml @@ -2,7 +2,7 @@ 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. +# and posts a recommendation comment via bot-on-pr-close.cjs. on: pull_request_target: types: [closed] @@ -36,7 +36,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-pr-close.js'); + const script = require('./.github/scripts/bot-on-pr-close.cjs'); await script({ github, context }); on-pr-merged-conflict-check: @@ -57,5 +57,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-pr-merged.js'); + const script = require('./.github/scripts/bot-on-pr-merged.cjs'); await script({ github, context }); \ No newline at end of file diff --git a/.github/workflows/on-pr-review-labels.yaml b/.github/workflows/on-pr-review-labels.yaml index 6e9381f..3bbaa99 100644 --- a/.github/workflows/on-pr-review-labels.yaml +++ b/.github/workflows/on-pr-review-labels.yaml @@ -39,5 +39,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-pr-review-labels.js'); + const script = require('./.github/scripts/bot-on-pr-review-labels.cjs'); await script({ github, context }); diff --git a/.github/workflows/on-pr-review.yaml b/.github/workflows/on-pr-review.yaml index 403782a..89628ff 100644 --- a/.github/workflows/on-pr-review.yaml +++ b/.github/workflows/on-pr-review.yaml @@ -25,12 +25,12 @@ jobs: - name: Save review event run: | - echo '{"pr_number":${{ github.event.pull_request.number }}, "review_state":"${{ github.event.review.state }}", "draft":${{ github.event.pull_request.draft }}}' > review-event.json + echo '{"pr_number":${{ github.event.pull_request.number }}, "review_state":"${{ github.event.review.state }}", "draft":${{ github.event.pull_request.draft }}}' > review-event.cjson - name: Upload review event uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: review-event-${{ github.run_id }} - path: review-event.json + path: review-event.cjson retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/on-pr-update.yaml b/.github/workflows/on-pr-update.yaml index 50228c8..0710381 100644 --- a/.github/workflows/on-pr-update.yaml +++ b/.github/workflows/on-pr-update.yaml @@ -2,7 +2,7 @@ name: Bot - On PR Update # Runs on new commits (synchronize) and PR body edits (edited). Performs all # 4 checks (DCO, GPG, merge conflict, issue link), posts/updates unified -# comment, and conditionally swaps the status label via bot-on-pr-update.js. +# comment, and conditionally swaps the status label via bot-on-pr-update.cjs. # For edited events, exits early if only title/base changed. on: pull_request_target: @@ -37,5 +37,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-pr-update.js'); + const script = require('./.github/scripts/bot-on-pr-update.cjs'); await script({ github, context }); diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 411e092..219ba76 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -2,7 +2,7 @@ name: Bot - On PR Open # Runs on opened, reopened, and ready_for_review. Single job: run all 4 checks # (DCO, GPG, merge conflict, issue link), post unified comment, auto-assign, -# and apply status label via bot-on-pr-open.js. +# and apply status label via bot-on-pr-open.cjs. on: # Uses pull_request_target so fork PRs get write token without repo setting; we # checkout default branch only so we never run PR branch code. @@ -39,5 +39,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-on-pr-open.js'); + const script = require('./.github/scripts/bot-on-pr-open.cjs'); await script({ github, context }); diff --git a/.github/workflows/on-schedule-inactivity.yaml b/.github/workflows/on-schedule-inactivity.yaml index 507c729..21c91df 100644 --- a/.github/workflows/on-schedule-inactivity.yaml +++ b/.github/workflows/on-schedule-inactivity.yaml @@ -5,7 +5,7 @@ name: Bot - Inactivity Check # # Purpose: # Runs daily to detect assigned issues and PRs that have gone inactive. -# Uses bot-inactivity.js to: +# Uses bot-inactivity.cjs to: # - Warn after 5 days of inactivity (idempotent comment via HTML marker) # - Close, unassign, and reset to "status: ready for dev" after 7 days # @@ -62,5 +62,5 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const script = require('./.github/scripts/bot-inactivity.js'); + const script = require('./.github/scripts/bot-inactivity.cjs'); await script({ github, context }); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2023a03..b737744 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: token: ${{ secrets.GH_PAT }} fetch-depth: 0 - - name: Setup Node.js + - name: Setup Node.cjs uses: actions/setup-node@v4 with: node-version: '20' @@ -46,8 +46,8 @@ jobs: - name: Commit lockfile if changed run: | - git add package-lock.json || true - git diff --cached --quiet || git commit -m 'chore: update package-lock.json' + git add package-lock.cjson || true + git diff --cached --quiet || git commit -m 'chore: update package-lock.cjson' - name: Push changes and tags run: | @@ -62,7 +62,7 @@ jobs: - name: Get Version id: get_version run: | - VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('./package.json', 'utf8')).version)") + VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('./package.cjson', 'utf8')).version)") echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Create GitHub Release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27b1d4a..c701d47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,10 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js + - name: Setup Node.cjs uses: actions/setup-node@v4 with: - node-version: '20' # the project uses Node.js, version 20 is a good default + node-version: '20' # the project uses Node.cjs, version 20 is a good default cache: 'npm' - name: Install dependencies From f439862d695b6a584ad3d63e34e0729b70a46fa1 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 17 May 2026 02:25:17 +0530 Subject: [PATCH 4/5] ci: fix CodeRabbit review findings in workflows --- .github/workflows/on-comment.yaml | 1 + .github/workflows/on-pr-close.yaml | 2 ++ .github/workflows/on-pr-review-labels.yaml | 3 +++ .github/workflows/on-pr-review.yaml | 11 +++++++++-- .github/workflows/on-pr-update.yaml | 1 + .github/workflows/on-pr.yaml | 1 + .github/workflows/on-schedule-inactivity.yaml | 1 + 7 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on-comment.yaml b/.github/workflows/on-comment.yaml index 519ea29..f2043e1 100644 --- a/.github/workflows/on-comment.yaml +++ b/.github/workflows/on-comment.yaml @@ -45,6 +45,7 @@ jobs: if: github.event.issue.pull_request == null runs-on: ubuntu-latest + timeout-minutes: 30 # Serialize per issue to prevent same-issue races without blocking other issues. # IMPORTANT: keep this keyed by issue.number (not github.actor), otherwise diff --git a/.github/workflows/on-pr-close.yaml b/.github/workflows/on-pr-close.yaml index d5d72f4..ad02f75 100644 --- a/.github/workflows/on-pr-close.yaml +++ b/.github/workflows/on-pr-close.yaml @@ -15,6 +15,7 @@ permissions: jobs: on-pr-close: runs-on: ubuntu-latest + timeout-minutes: 60 if: github.event.pull_request.merged == true concurrency: @@ -41,6 +42,7 @@ jobs: on-pr-merged-conflict-check: runs-on: ubuntu-latest + timeout-minutes: 60 if: github.event.pull_request.merged == true steps: - name: Harden Runner diff --git a/.github/workflows/on-pr-review-labels.yaml b/.github/workflows/on-pr-review-labels.yaml index 3bbaa99..a21756b 100644 --- a/.github/workflows/on-pr-review-labels.yaml +++ b/.github/workflows/on-pr-review-labels.yaml @@ -16,6 +16,9 @@ jobs: run-bot: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest + + concurrency: + group: 'review-label-${{ github.event.workflow_run.id }}' steps: - name: Harden Runner diff --git a/.github/workflows/on-pr-review.yaml b/.github/workflows/on-pr-review.yaml index 89628ff..622311a 100644 --- a/.github/workflows/on-pr-review.yaml +++ b/.github/workflows/on-pr-review.yaml @@ -11,7 +11,7 @@ permissions: jobs: on-pr-review: runs-on: ubuntu-latest - if: github.event.pull_request.draft == false + if: !github.event.pull_request.draft concurrency: group: pr-bot-${{ github.event.pull_request.number }} @@ -24,8 +24,15 @@ jobs: egress-policy: audit - name: Save review event + env: + REVIEW_PAYLOAD: | + { + "pr_number": ${{ toJSON(github.event.pull_request.number) }}, + "review_state": ${{ toJSON(github.event.review.state) }}, + "draft": ${{ toJSON(github.event.pull_request.draft) }} + } run: | - echo '{"pr_number":${{ github.event.pull_request.number }}, "review_state":"${{ github.event.review.state }}", "draft":${{ github.event.pull_request.draft }}}' > review-event.cjson + echo "$REVIEW_PAYLOAD" > review-event.cjson - name: Upload review event uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/on-pr-update.yaml b/.github/workflows/on-pr-update.yaml index 0710381..993fd9f 100644 --- a/.github/workflows/on-pr-update.yaml +++ b/.github/workflows/on-pr-update.yaml @@ -18,6 +18,7 @@ permissions: jobs: on-pr-update: runs-on: ubuntu-latest + timeout-minutes: 10 if: github.event.pull_request.draft == false concurrency: diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 219ba76..ba491be 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -20,6 +20,7 @@ permissions: jobs: on-pr-open: runs-on: ubuntu-latest + timeout-minutes: 10 if: github.event.pull_request.draft == false concurrency: diff --git a/.github/workflows/on-schedule-inactivity.yaml b/.github/workflows/on-schedule-inactivity.yaml index 21c91df..331443e 100644 --- a/.github/workflows/on-schedule-inactivity.yaml +++ b/.github/workflows/on-schedule-inactivity.yaml @@ -42,6 +42,7 @@ jobs: inactivity-check: name: Inactivity Check runs-on: ubuntu-latest + timeout-minutes: 60 concurrency: group: inactivity-check From 9b8fc4f262e08a2fdc58e4825851b9b0f2741d71 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 17 May 2026 02:30:54 +0530 Subject: [PATCH 5/5] ci: fix duplicate CodeRabbit review findings --- .github/workflows/on-pr-review-labels.yaml | 2 +- .github/workflows/on-pr-review.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on-pr-review-labels.yaml b/.github/workflows/on-pr-review-labels.yaml index a21756b..f900dd9 100644 --- a/.github/workflows/on-pr-review-labels.yaml +++ b/.github/workflows/on-pr-review-labels.yaml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest concurrency: - group: 'review-label-${{ github.event.workflow_run.id }}' + group: 'review-label-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }}' steps: - name: Harden Runner diff --git a/.github/workflows/on-pr-review.yaml b/.github/workflows/on-pr-review.yaml index 622311a..81b71ad 100644 --- a/.github/workflows/on-pr-review.yaml +++ b/.github/workflows/on-pr-review.yaml @@ -11,7 +11,7 @@ permissions: jobs: on-pr-review: runs-on: ubuntu-latest - if: !github.event.pull_request.draft + if: ${{ !github.event.pull_request.draft }} concurrency: group: pr-bot-${{ github.event.pull_request.number }}