diff --git a/.github/LABELS.md b/.github/LABELS.md new file mode 100644 index 0000000..f30148c --- /dev/null +++ b/.github/LABELS.md @@ -0,0 +1,114 @@ +# PR Label System + +This document describes all labels automatically applied to pull requests by the +[pr-labeler](scripts/pr-labeler.cjs) script. + +- **Type labels** derived from the PR title +- **Size labels** derived from total line changes +- **Module labels** derived from changed file paths +- **Complexity labels** derived from a heuristic score + +--- + +## Type Labels + +Applied by parsing the PR title. Supports KDM-style titles (`[KDM-123-FIX-...]`), +conventional commits (`fix:`, `feat:`, `refactor:`), and plain keywords. + +| Label | Triggered When | +|---|---| +| `type: bug-fix` | Title contains FIX / fix | +| `type: feature` | Title contains FEAT / feat / feature | +| `type: refactor` | Title contains REFACTOR / refactor | + +**Suggested colors:** `type: bug-fix` (red), `type: feature` (green), `type: refactor` (blue) + +--- + +## Size Labels + +Applied based on the total number of lines changed (additions + deletions). + +| Label | Threshold | Suggested Color | +|---|---|---| +| `size: XS` | < 10 lines | Lightest gray | +| `size: S` | < 50 lines | Light gray | +| `size: M` | < 200 lines | Medium gray | +| `size: L` | < 500 lines | Dark gray | +| `size: XL` | >= 500 lines | Darkest gray | + +Exactly one size label is applied per PR. Thresholds are defined in +[`kdm-automation.json`](kdm-automation.json) under `prLabels.size`. + +--- + +## Module Labels + +Applied by matching changed file paths against the pattern map defined in +[`kdm-automation.json`](kdm-automation.json) under `prLabels.modulePaths`. + +| Label | Path Pattern | +|---|---| +| `module: cli` | `src/commands/**`, `src/utils/version-check.ts`, `src/**` (fallback) | +| `module: ui` | `src/ui/**` | +| `module: config` | `src/utils/config.ts` | +| `module: logger` | `src/utils/logger.ts` | +| `module: auth` | _(not currently mapped)_ | +| `module: test` | `src/__tests__/**` | +| `module: docs` | `docs/**` | +| `module: docker` | `src/docker/**` | +| `module: k8s` | `src/kubernetes/**` | +| `module: minikube` | `src/minikube/**` | +| `module: monitor` | `src/monitor/**` | + +A PR may receive multiple module labels if it touches files in several areas. +If more than 2 modules are touched, a `multi-module` indicator label is also +added. + +**Suggested color for all module labels:** Consistent color family (e.g. purples) + +--- + +## Complexity Labels + +Applied based on a heuristic score that considers file count, line changes, +and module spread: + +``` +score = (files × 2) + (lines / 50) + (modules × 5) +``` + +| Label | Score Range | Suggested Color | +|---|---|---| +| `review: easy` | < 15 | Green | +| `review: medium` | < 40 | Yellow | +| `review: complex` | >= 40 | Red | + +Exactly one complexity label is applied per PR. The heuristic helps reviewers +gauge the cognitive load of a review: more files, more lines, and more modules +touched all increase the score. + +--- + +## Multi-Module Indicator + +When a PR touches more than 2 distinct modules, the label `multi-module` is +added. This signals that the PR crosses subsystem boundaries and may benefit +from additional reviewer attention. + +--- + +## Configuration Reference + +All label definitions and thresholds are centralized in +[`kdm-automation.json`](kdm-automation.json) under the `prLabels` key: + +- `prLabels.type` — type label name mappings +- `prLabels.size` — size label entries with `maxChanges` thresholds +- `prLabels.module` — module label name definitions +- `prLabels.complexity` — complexity label entries with `maxScore` thresholds +- `prLabels.modulePaths` — file path glob patterns → module key mappings + +Modify thresholds or add new labels by editing this file. The +[pr-labeler](scripts/pr-labeler.cjs) script reads the configuration +at runtime via the shared config loader. diff --git a/.github/kdm-automation.json b/.github/kdm-automation.json index 249c913..6026975 100644 --- a/.github/kdm-automation.json +++ b/.github/kdm-automation.json @@ -79,5 +79,52 @@ "community": { "discordChannel": "https://discord.com/channels/905194001349627914/1337424839761465364" + }, + + "prLabels": { + "type": { + "bugFix": "type: bug-fix", + "feature": "type: feature", + "refactor": "type: refactor" + }, + "size": { + "xs": { "label": "size: XS", "maxChanges": 9 }, + "s": { "label": "size: S", "maxChanges": 49 }, + "m": { "label": "size: M", "maxChanges": 199 }, + "l": { "label": "size: L", "maxChanges": 499 }, + "xl": { "label": "size: XL", "maxChanges": null } + }, + "module": { + "config": "module: config", + "cli": "module: cli", + "ui": "module: ui", + "auth": "module: auth", + "logger": "module: logger", + "test": "module: test", + "docs": "module: docs", + "docker": "module: docker", + "k8s": "module: k8s", + "minikube": "module: minikube", + "monitor": "module: monitor" + }, + "complexity": { + "easy": { "label": "review: easy", "maxScore": 14 }, + "medium": { "label": "review: medium", "maxScore": 39 }, + "complex": { "label": "review: complex", "maxScore": null } + }, + "modulePaths": { + "src/commands/**": "cli", + "src/ui/**": "ui", + "src/utils/config.ts": "config", + "src/utils/logger.ts": "logger", + "src/__tests__/**": "test", + "docs/**": "docs", + "src/docker/**": "docker", + "src/kubernetes/**": "k8s", + "src/minikube/**": "minikube", + "src/monitor/**": "monitor", + "src/utils/version-check.ts": "cli", + "src/**": "cli" + } } } diff --git a/.github/scripts/helpers/api.cjs b/.github/scripts/helpers/api.cjs index 0cdd259..2f926db 100644 --- a/.github/scripts/helpers/api.cjs +++ b/.github/scripts/helpers/api.cjs @@ -1,33 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// -// helpers/api.cjs -// -// 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'); +const { getLogger } = require('./logger.cjs'); +const { isSafeSearchToken, requireObject, requireNonEmptyString, requirePositiveInt, requireSafeUsername } = require('./validation.cjs'); +const { LABELS, SKILL_HIERARCHY, ISSUE_STATE } = require('./constants.cjs'); +const { checkDCO, checkGPG, checkMergeConflict, checkIssueLink } = require('./checks.cjs'); +const { buildBotComment } = require('./comments.cjs'); -/** - * 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'); @@ -43,12 +21,11 @@ function buildBotContext({ github, context }) { } 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': @@ -56,18 +33,15 @@ function buildBotContext({ github, context }) { 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; @@ -80,9 +54,6 @@ function buildBotContext({ github, context }) { 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'); @@ -90,7 +61,6 @@ function buildBotContext({ github, context }) { if (typeof comment.body !== 'string') { throw new Error('Bot context invalid: comment.body must be a string'); } - payloadPart = { ...payloadPart, comment, isBot }; } break; @@ -101,29 +71,11 @@ function buildBotContext({ github, context }) { 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' }; - } - + 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, - }); - + 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) { @@ -132,23 +84,10 @@ async function addLabels(botContext, labels) { } } -/** - * 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, - }); - + 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) { @@ -157,29 +96,11 @@ async function removeLabel(botContext, labelName) { } } -/** - * 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' }; - } - + 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, - }); - + 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) { @@ -188,29 +109,11 @@ async function addAssignees(botContext, assignees) { } } -/** - * 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' }; - } - + 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, - }); - + 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) { @@ -219,22 +122,10 @@ async function removeAssignees(botContext, assignees) { } } -/** - * 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, - }); + 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) { @@ -243,129 +134,55 @@ async function postComment(botContext, body) { } } -/** - * 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; - } - + 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}`); - } - + 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}`); - } - + 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; - } - } - + 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()) { + const existing = await getBotComment(botContext, marker); + if (existing) { + if (existing.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, - }); + await botContext.github.rest.issues.updateComment({ owner: botContext.owner, repo: botContext.repo, comment_id: existing.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, - }); + 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 }; @@ -375,100 +192,47 @@ async function postOrUpdateComment(botContext, marker, body) { } } -/** - * 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, - }); - + const response = await botContext.github.rest.pulls.listCommits({ owner: botContext.owner, repo: botContext.repo, pull_number: botContext.number, per_page: 100, page }); commits.push(...response.data); - - if (response.data.length < perPage) break; + if (response.data.length < 100) 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, - }); - + const response = await botContext.github.rest.pulls.list({ owner: botContext.owner, repo: botContext.repo, state: 'open', per_page: 100, page }); prs.push(...response.data); - - if (response.data.length < perPage) break; + if (response.data.length < 100) 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, - }); + 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 } - } + closingIssuesReferences(first:10){ nodes { number } } } } }`; - const result = await botContext.github.graphql(query, { - owner: botContext.owner, - repo: botContext.repo, - number: botContext.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) { @@ -477,74 +241,30 @@ async function fetchClosingIssueNumbers(botContext) { } } -/** - * 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, - }); - + const { data } = await botContext.github.rest.issues.listMilestones({ owner: botContext.owner, repo: botContext.repo, state: 'open', sort: 'due_on', direction: 'desc', per_page: 100, page }); milestones.push(...data); - - if (data.length < perPage) break; + if (data.length < 100) 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]; + if (!milestones.length) { getLogger().log('No open milestones found'); return null; } + const withDue = milestones.filter(m => m.due_on); + return withDue.length ? withDue.sort((a, b) => new Date(b.due_on) - new Date(a.due_on))[0] : [...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, - }); - + 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) { @@ -553,59 +273,25 @@ async function setMilestone(botContext, issueOrPrNumber, milestoneNumber) { } } -/** - * 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 toAdd = allPassed ? LABELS.NEEDS_REVIEW : LABELS.NEEDS_REVISION; + const toRemove = 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 (force || hasLabel(pr, toRemove)) { + const r = await removeLabel(botContext, toRemove); + if (!r.success) errors.push(`Failed to remove '${toRemove}': ${r.error}`); } - - if (shouldAdd) { - const addResult = await addLabels(botContext, [labelToAdd]); - if (!addResult.success) { - errors.push(`Failed to add '${labelToAdd}': ${addResult.error}`); - } + if (force || hasLabel(pr, toRemove)) { + const a = await addLabels(botContext, [toAdd]); + if (!a.success) errors.push(`Failed to add '${toAdd}': ${a.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', - }); + 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) { @@ -614,106 +300,45 @@ async function acknowledgeComment(botContext, commentId) { } } -/** - * 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 }; } - } - + 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, - }); - + const { data } = await botContext.github.rest.issues.listEvents({ owner: botContext.owner, repo: botContext.repo, issue_number: botContext.number, per_page: 100, page }); events.push(...data); - - if (data.length < perPage) break; + if (data.length < 100) 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, - }); - + const { data } = await botContext.github.rest.issues.listComments({ owner: botContext.owner, repo: botContext.repo, issue_number: botContext.number, per_page: 100, page }); comments.push(...data); - - if (data.length < perPage) break; + if (data.length < 100) 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', - }); - + 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) { @@ -722,391 +347,128 @@ async function closeItem(botContext) { } } -/** - * 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; + 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)))).filter(Boolean); + if (!issues.length) { getLogger().log('All linked issue fetches returned empty', { issueNumbers }); return null; } + const selected = issues.reduce((best, issue) => { + const bestIdx = SKILL_HIERARCHY.findIndex(level => hasLabel(best, level)); + const curIdx = SKILL_HIERARCHY.findIndex(level => hasLabel(issue, level)); + return curIdx > bestIdx ? issue : best; + }); + if (SKILL_HIERARCHY.findIndex(level => hasLabel(selected, level)) === -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; - } + 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; +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 ( - label && - (typeof label !== "string" || !label.trim() || label.includes('"')) - ) { - getLogger().log("[assign] Invalid label parameter:", { 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; - + let page = 1, matchingIssuesCount = 0; getLogger().log(`[assign] Fetching ${state} assigned issues via REST...`); while (true) { - const params = { - owner, - repo, - state: state.toLowerCase(), - assignee: username, - per_page: perPage, - page, - }; + const params = { owner, repo, state: state.toLowerCase(), assignee: username, per_page: 100, 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); - + 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; - } - + 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 + getLogger().log(`[assign] Reached threshold (${threshold}), short-circuiting fetch.`); + matchingIssuesCount = threshold; break; } - - // Pagination must evaluate the raw result size, not the filtered size. - if (result.data.length < perPage) break; + if (result.data.length < 100) 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}`, - ); - } + const suffix = state === ISSUE_STATE.OPEN && !label ? ' (excluding blocked)' : ''; + getLogger().log(`[assign] ${state} assigned issues for ${username}${label ? ` with label ${label}` : suffix}: ${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}`, - ); + getLogger().log(`[assign] Failed to count ${state} issues for ${username}: ${error instanceof Error ? error.message : String(error)}`); 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; + 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)..."); + 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), - ); - + const result = await github.rest.issues.listForRepo({ owner, repo, state: ISSUE_STATE.OPEN, assignee: username, per_page: 100, page }); + const actualIssues = result.data.filter(item => !item.pull_request); + 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; + if (result.data.length < 100) break; page++; } - - getLogger().log( - `[assign] Open non-blocked assigned issues for ${username}: ${issues.length}`, - ); + 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}`, - ); + getLogger().log(`[assign] Failed to list assigned issues for ${username}: ${error instanceof Error ? error.message : String(error)}`); 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. - */ +// closedByPullRequestsReferences only catches closing-keyword PRs +// (Fixes/Closes #N). Sidebar-linked PRs are invisible here. 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 (!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; - } - + 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 } - } - } + nodes { state, author { login }, labels(first:50) { nodes { name } } } } } } }`; - const result = await github.graphql(query, { - owner, - repo, - number: issueNumber, - }); - + 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; + const match = nodes.some(pr => pr.author?.login === username && pr.state === 'OPEN' && pr.labels?.nodes?.some(l => l.name === LABELS.NEEDS_REVIEW)); + getLogger().log(`[assign] Needs-review PR search for issue #${issueNumber}: ${match ? 1 : 0} match(es)`); + return match; } 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}`, - ); + getLogger().log(`[assign] Failed to search for needs-review PRs for issue #${issueNumber}: ${error instanceof Error ? error.message : String(error)}`); 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, + 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.cjs b/.github/scripts/helpers/checks.cjs index 02e4d3d..1a448e3 100644 --- a/.github/scripts/helpers/checks.cjs +++ b/.github/scripts/helpers/checks.cjs @@ -1,248 +1,110 @@ // SPDX-License-Identifier: Apache-2.0 -// -// helpers/checks.cjs -// -// 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'); +const { getLogger } = require('./logger.cjs'); -/** - * 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 }); - } + if (isMergeCommit(c)) { skipped++; continue; } + const msg = c.commit?.message || ''; + const sha = (c.sha || '').slice(0, 7); + if (!hasDCOSignoff(msg)) failures.push({ sha, message: msg.split('\n')[0] || '(no message)' }); } 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 }); - } + const sha = (c.sha || '').slice(0, 7); + const msg = c.commit?.message || ''; + if (!hasVerifiedGPGSignature(c)) failures.push({ sha, message: msg.split('\n')[0] || '(no message)' }); } 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, - }); - + let conflicts = false, resolved = false; + for (let attempt = 1; attempt <= 5; 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; + resolved = 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'); + if (attempt < 5) { logger.log(`Mergeable state not ready, waiting 2000ms (attempt ${attempt}/5)`); await new Promise(r => setTimeout(r, 2000)); } } + if (!resolved) 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)); - } + for (const re of [/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi, /related\s+to\s+#(\d+)/gi]) { + let m; while ((m = re.exec(body)) !== null) numbers.add(parseInt(m[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)); - } + let m; while ((m = /#(\d+)/g.exec(value)) !== null) numbers.add(parseInt(m[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() - ); + 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}`); - } + } catch (err) { getLogger().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: [] }; + if (!issueNumbers.size) { + const titleIssues = extractNumbersFromTitle(botContext.pr?.title || ''); + (await fetchClosingIssueNumbers(botContext)).filter(n => !titleIssues.has(n)).forEach(n => issueNumbers.add(n)); } - - 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 (!issueNumbers.size) { logger.log('Issue link check: no linked issues found'); return { passed: false, reason: 'no_issue_linked', issues: [] }; } + const linked = await fetchAndCheckAssignees(botContext, fetchIssue, issueNumbers, prAuthor); + if (!linked.length) { logger.log('Issue link check: all linked issues returned errors'); return { passed: false, reason: 'no_issue_linked', issues: [] }; } + const allAssigned = linked.every(i => i.isAssigned); if (!allAssigned) { - const missing = linkedIssues.filter(i => !i.isAssigned).map(i => `#${i.number}`).join(', '); + const missing = linked.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 }; + return { passed: false, reason: 'not_assigned', issues: linked }; } - - logger.log(`Issue link check: passed (author assigned to all linked issues)`); - return { passed: true, reason: null, issues: linkedIssues }; + logger.log('Issue link check: passed (author assigned to all linked issues)'); + return { passed: true, reason: null, issues: linked }; } -module.exports = { - hasDCOSignoff, - hasVerifiedGPGSignature, - isMergeCommit, - checkDCO, - checkGPG, - checkMergeConflict, - parseIssueNumbers, - checkIssueLink, - extractNumbersFromTitle, -}; +module.exports = { hasDCOSignoff, hasVerifiedGPGSignature, isMergeCommit, checkDCO, checkGPG, checkMergeConflict, parseIssueNumbers, checkIssueLink, extractNumbersFromTitle }; diff --git a/.github/scripts/helpers/comments.cjs b/.github/scripts/helpers/comments.cjs index 1339355..e932c7e 100644 --- a/.github/scripts/helpers/comments.cjs +++ b/.github/scripts/helpers/comments.cjs @@ -1,200 +1,73 @@ // SPDX-License-Identifier: Apache-2.0 -// -// 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 -// checks without changing the overall shape. - -const { MAINTAINER_TEAM, DOCUMENTATION } = require('./constants'); +const { MAINTAINER_TEAM, DOCUMENTATION } = require('./constants.cjs'); 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 `:warning: **${title}** -- This check encountered an internal error. ${MAINTAINER_TEAM} please review manually.\n\nError: ${result.errorMessage || 'Unknown error'}`; } + 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'); + const list = (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:\n${list}\n\nNo 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}).`; } -/** - * @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'); + const list = (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:\n${list}\n\nYou'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.`; } -/** - * @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'); + return `:x: **Merge Conflicts** -- Oh no, this PR has merge conflicts with the base branch.\n\nLet'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.`; } -/** - * 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(' '); + 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!`; } -/** - * @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 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** -- Almost there! You are not assigned to the following linked issues: ${unassigned}.\n\nPlease ensure you are assigned to all linked issues before opening a PR. You can comment \`/assign\` on the issue to grab it!`; } - 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'); + return `:x: **Issue Link** -- This PR is not linked to any issue.\n\nPlease reference an issue using a closing keyword (e.g. \`Fixes #123\`) and ensure the issue is assigned to you. Every PR needs a home!`; } -/** - * 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'); + return `### PR Checks\n\n${buildMergeSection(merge)}\n\n---\n\n${buildIssueLinkSection(issueLink)}`; } -/** - * 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 - ); + 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 greeting = `Hey @${prAuthor} :wave: thanks for the PR!\nI'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.\n\nThis comment updates automatically as you push changes -- think of it as your PR's live scoreboard!\nHere's the latest:`; 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 }; + 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!*'; + return { marker: MARKER, body: [MARKER, greeting, '', '---', '', checksSection, '', '---', '', footer].join('\n'), allPassed: passed }; } -module.exports = { - MARKER, - buildBotComment, - buildChecksSection, - allChecksPassed, - buildMergeConflictNotificationComment, -}; +module.exports = { MARKER, buildBotComment, buildChecksSection, allChecksPassed, buildMergeConflictNotificationComment }; diff --git a/.github/scripts/helpers/config-loader.cjs b/.github/scripts/helpers/config-loader.cjs index f7e1c98..da90e28 100644 --- a/.github/scripts/helpers/config-loader.cjs +++ b/.github/scripts/helpers/config-loader.cjs @@ -1,259 +1,114 @@ // SPDX-License-Identifier: Apache-2.0 -// -// helpers/config-loader.cjs -// -// 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. - */ +const isNonEmptyString = v => typeof v === 'string' && v.trim().length > 0; +const isPositiveInteger = v => Number.isInteger(v) && v > 0; + 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'); - } + 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`); - } - } - } + if (!config.labels || typeof config.labels !== 'object') { errors.push('labels must be an object'); return; } + const map = { status: REQUIRED_STATUS_KEYS, skill: REQUIRED_SKILL_KEYS, priority: REQUIRED_PRIORITY_KEYS }; + for (const [group, keys] of Object.entries(map)) { + if (!config.labels[group] || typeof config.labels[group] !== 'object') { errors.push(`labels.${group} must be an object`); continue; } + for (const key of keys) { 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; - } - + if (!Array.isArray(hierarchy) || !hierarchy.length) { 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`); - } + 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`); - } - } + if (config.labels?.[labelGroup]) { + const vals = Object.values(config.labels[labelGroup]); + for (const entry of hierarchy) { if (!vals.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`); - } + 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 (!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 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`); - } + 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'); - } + 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; + 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`); } +} + +function validatePrLabels(config, errors) { + const pr = config.prLabels; + if (!pr || typeof pr !== 'object') return; + if (pr.type && typeof pr.type === 'object') { + for (const key of Object.keys(pr.type)) { if (typeof pr.type[key] !== 'string' || !pr.type[key].startsWith('type: ')) errors.push(`prLabels.type["${key}"] must be a string starting with "type: "`); } + } + if (pr.size && typeof pr.size === 'object') { + for (const key of ['xs', 's', 'm', 'l', 'xl']) { + const e = pr.size[key]; + if (!e || typeof e !== 'object') { errors.push(`prLabels.size["${key}"] must be an object with "label" and "maxChanges"`); continue; } + if (typeof e.label !== 'string' || !e.label.startsWith('size: ')) errors.push(`prLabels.size["${key}"].label must be a string starting with "size: "`); + } + } + if (pr.module && typeof pr.module === 'object') { + for (const key of Object.keys(pr.module)) { if (typeof pr.module[key] !== 'string' || !pr.module[key].startsWith('module: ')) errors.push(`prLabels.module["${key}"] must be a string starting with "module: "`); } } - for (const key of requiredKeys) { - if (!isNonEmptyString(config[section][key])) { - errors.push(`${section}.${key} is required and must be a non-empty string`); + if (pr.complexity && typeof pr.complexity === 'object') { + for (const key of ['easy', 'medium', 'complex']) { + const e = pr.complexity[key]; + if (!e || typeof e !== 'object') { errors.push(`prLabels.complexity["${key}"] must be an object with "label" and "maxScore"`); continue; } + if (typeof e.label !== 'string' || !e.label.startsWith('review: ')) errors.push(`prLabels.complexity["${key}"].label must be a string starting with "review: "`); + } + } + if (pr.modulePaths && typeof pr.modulePaths === 'object') { + for (const [pattern, mod] of Object.entries(pr.modulePaths)) { + if (typeof pattern !== 'string' || typeof mod !== 'string') errors.push('prLabels.modulePaths entries must have string keys and string values'); + if (typeof mod === 'string' && pr.module && !pr.module[mod]) errors.push(`prLabels.modulePaths["${pattern}"] references unknown module "${mod}"`); } } } -/** - * 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); @@ -261,108 +116,48 @@ function validateConfig(config) { 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')}`, - ); - } + validatePrLabels(config, errors); + if (errors.length) 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}`, - ); - } - + 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}`, - ); - } - + 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.cjs, - * assign-comments.cjs, finalize-comments.cjs, and comments.cjs. - * - * @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); - + const SKILL_PREREQUISITES = Object.freeze( + Object.fromEntries(Object.entries(config.skillPrerequisites).map(([k, v]) => [k, Object.freeze({ ...v })])) + ); return { MAINTAINER_TEAM: config.maintainerTeam, GFI_SUPPORT_TEAM: config.goodFirstIssueSupportTeam, - LABELS, - SKILL_HIERARCHY, - PRIORITY_HIERARCHY, - SKILL_PREREQUISITES, + LABELS, SKILL_HIERARCHY, PRIORITY_HIERARCHY, SKILL_PREREQUISITES, DOCUMENTATION: Object.freeze({ ...config.documentation }), COMMUNITY: Object.freeze({ ...config.community }), }; } -module.exports = { - DEFAULT_CONFIG_PATH, - loadAutomationConfig, - buildConstants, -}; +module.exports = { DEFAULT_CONFIG_PATH, loadAutomationConfig, buildConstants }; diff --git a/.github/scripts/helpers/constants.cjs b/.github/scripts/helpers/constants.cjs index a17abbe..6ef6e90 100644 --- a/.github/scripts/helpers/constants.cjs +++ b/.github/scripts/helpers/constants.cjs @@ -1,90 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -// -// 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. - * Exposed for modules that need access to nested config values (e.g. assignment limits). - */ +const { loadAutomationConfig, buildConstants } = require('./config-loader.cjs'); 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, -}; +const ISSUE_STATE = Object.freeze({ OPEN: 'open', CLOSED: 'closed' }); + +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.cjs b/.github/scripts/helpers/index.cjs index c1a4629..ba1f362 100644 --- a/.github/scripts/helpers/index.cjs +++ b/.github/scripts/helpers/index.cjs @@ -1,22 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -// -// helpers/index.cjs -// -// 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, + ...require('./constants.cjs'), + ...require('./logger.cjs'), + ...require('./validation.cjs'), + ...require('./api.cjs'), + ...require('./checks.cjs'), + ...require('./comments.cjs'), }; diff --git a/.github/scripts/pr-labeler.cjs b/.github/scripts/pr-labeler.cjs index dfa36d9..3a5978f 100644 --- a/.github/scripts/pr-labeler.cjs +++ b/.github/scripts/pr-labeler.cjs @@ -1,55 +1,174 @@ -module.exports = async ({ github, context }) => { - const pull_number = context.payload.pull_request?.number; - if (!pull_number) { - console.log("No pull request number found in context. Skipping labeler."); - return; - } - - const { owner, repo } = context.repo; - - console.log(`Fetching files for PR #${pull_number}`); - - // Fetch list of files changed in the PR - // For PRs with more than 100 files, we just use the first 100 for simplicity - const { data: files } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number, - per_page: 100 - }); - - const labelsToAdd = new Set(); - - files.forEach(file => { - const filename = file.filename; - - // Rule: documentation - if (filename.startsWith('docs/') || filename.endsWith('.md')) { - labelsToAdd.add('documentation'); +// SPDX-License-Identifier: Apache-2.0 + +const { buildBotContext, addLabels } = require('./helpers/api.cjs'); +const { loadAutomationConfig } = require('./helpers/config-loader.cjs'); + +function detectType(title) { + if (!title || typeof title !== 'string') return null; + const upper = title.toUpperCase(); + + const kdm = upper.match(/\[KDM-\d+-(FIX|FEAT|REFACTOR)/); + if (kdm) { + const map = { FIX: 'bugFix', FEAT: 'feature', REFACTOR: 'refactor' }; + return map[kdm[1]] || null; + } + + const cc = title.match(/^(fix|feat|refactor)(\(|:)/i); + if (cc) { + const map = { fix: 'bugFix', feat: 'feature', refactor: 'refactor' }; + return map[cc[1].toLowerCase()] || null; + } + + const plain = title.match(/^(fix|feature|refactor)\b/i); + if (plain) { + const map = { fix: 'bugFix', feature: 'feature', refactor: 'refactor' }; + return map[plain[1].toLowerCase()] || null; + } + + return null; +} + +function determineSize(totalChanges, sizeConfig) { + for (const key of ['xs', 's', 'm', 'l', 'xl']) { + const max = sizeConfig[key]?.maxChanges; + if (max === null) return key; + if (totalChanges <= max) return key; + } + return 'xl'; +} + +function matchGlobPattern(filepath, pattern) { + const normPath = filepath.replace(/\\/g, '/'); + const normPat = pattern.replace(/\\/g, '/'); + + let re = ''; + for (let i = 0; i < normPat.length; i++) { + const ch = normPat[i]; + if (ch === '*' && normPat[i + 1] === '*') { + re += '.*'; + i += normPat[i + 2] === '/' ? 2 : 1; + } else if (ch === '*') { + re += '[^/]*'; + } else if (ch === '?') { + re += '[^/]'; + } else { + re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp('^' + re + '$').test(normPath); +} + +function detectModules(files, modulePaths) { + const matched = new Set(); + for (const file of files) { + for (const [pattern, mod] of Object.entries(modulePaths)) { + if (matchGlobPattern(file.filename, pattern)) matched.add(mod); + } + } + return Array.from(matched); +} + +function calculateComplexity(fileCount, totalChanges, moduleCount) { + return Math.round(fileCount * 2 + totalChanges / 50 + moduleCount * 5); +} + +function determineComplexity(score, complexityConfig) { + for (const key of ['easy', 'medium', 'complex']) { + const max = complexityConfig[key]?.maxScore; + if (max === null) return key; + if (score <= max) return key; + } + return 'complex'; +} + +async function labelPR({ github, context }) { + let botContext; + try { + botContext = buildBotContext({ github, context }); + } catch (err) { + return void console.log(`[pr-labeler] Failed to build bot context: ${err.message}`); + } + + const { owner, repo, number } = botContext; + const title = context.payload.pull_request?.title || ''; + console.log(`[pr-labeler] Labeling PR #${number}: "${title}"`); + + let config; + try { + config = loadAutomationConfig(); + } catch (err) { + return void console.log(`[pr-labeler] Failed to load automation config: ${err.message}`); + } + + const prLabels = config.prLabels; + if (!prLabels) return void console.log('[pr-labeler] No prLabels in kdm-automation.json. Skipping.'); + + let prData; + try { + prData = (await github.rest.pulls.get({ owner, repo, pull_number: number })).data; + } catch (err) { + return void console.log(`[pr-labeler] Failed to fetch PR data: ${err.message}`); + } + + const totalChanges = (prData.additions || 0) + (prData.deletions || 0); + + let files; + try { + files = (await github.rest.pulls.listFiles({ owner, repo, pull_number: number, per_page: 100 })).data; + } catch (err) { + return void console.log(`[pr-labeler] Failed to list PR files: ${err.message}`); + } + + const labelsToAdd = []; + + const typeKey = detectType(title); + if (typeKey && prLabels.type?.[typeKey]) { + labelsToAdd.push(prLabels.type[typeKey]); + console.log(`[pr-labeler] Type: ${typeKey} → ${prLabels.type[typeKey]}`); + } + + if (prLabels.size) { + const key = determineSize(totalChanges, prLabels.size); + const label = prLabels.size[key]?.label; + if (label) { + labelsToAdd.push(label); + console.log(`[pr-labeler] Size: ${key} (${totalChanges} changes) → ${label}`); } - - // Rule: frontend - if (filename.startsWith('src/') || filename.startsWith('public/')) { - labelsToAdd.add('frontend'); + } + + let matchedModules = []; + if (prLabels.modulePaths) { + matchedModules = detectModules(files, prLabels.modulePaths); + for (const mod of matchedModules) { + const label = prLabels.module?.[mod]; + if (label) { + labelsToAdd.push(label); + console.log(`[pr-labeler] Module: ${mod} → ${label}`); + } } - - // Rule: ci/cd - if (filename.startsWith('.github/workflows/')) { - labelsToAdd.add('ci/cd'); + if (matchedModules.length > 2) { + labelsToAdd.push('multi-module'); + console.log('[pr-labeler] Multi-module indicator added'); } - }); - - if (labelsToAdd.size > 0) { - const labelsArray = Array.from(labelsToAdd); - console.log(`Adding labels: ${labelsArray.join(', ')}`); - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pull_number, - labels: labelsArray - }); + } + + if (prLabels.complexity) { + const score = calculateComplexity(files.length, totalChanges, matchedModules.length); + const key = determineComplexity(score, prLabels.complexity); + const label = prLabels.complexity[key]?.label; + if (label) { + labelsToAdd.push(label); + console.log(`[pr-labeler] Complexity: ${key} (score ${score}) → ${label}`); + } + } + + if (labelsToAdd.length) { + console.log(`[pr-labeler] Adding labels: ${labelsToAdd.join(', ')}`); + const result = await addLabels(botContext, labelsToAdd); + if (!result.success) console.log(`[pr-labeler] Failed to add labels: ${result.error}`); } else { - console.log("No matching rules found for the changed files. No labels added."); + console.log('[pr-labeler] No labels to add.'); } -}; +} + +module.exports = labelPR;