From 79da4ffbd3f06a8a6a4dc6f5842ac2d8c6efcf43 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 03:49:37 +0200 Subject: [PATCH 1/6] codex-pr-enrichment-ux: streamline enrichment UX --- .github/pull_request_template.md | 144 +------ .github/workflows/copilot_pull_request.yml | 9 +- build/api/index.js | 25 +- build/cli/index.js | 364 +++++++++++++++--- build/github_action/index.js | 364 +++++++++++++++--- docs/bugbot/configuration.mdx | 2 +- docs/bugbot/how-it-works.mdx | 2 +- docs/features.mdx | 11 +- docs/how-to-use.mdx | 9 +- docs/pull-requests/ai-description.mdx | 27 +- docs/pull-requests/capabilities.mdx | 18 +- docs/pull-requests/examples.mdx | 9 +- docs/pull-requests/index.mdx | 8 +- docs/pull-requests/workflow-setup.mdx | 44 ++- .../operations/troubleshooting.mdx | 2 +- scripts/validate-workflow-contract.cjs | 25 +- setup/pull_request_template.md | 144 +------ setup/workflows/copilot_pull_request.yml | 9 +- specs/CATALOG.md | 26 +- specs/bugbot-review-state-reconciliation.md | 63 +-- specs/catalog.json | 34 +- ...ecution-admission-queue-and-publication.md | 11 +- ...ssue-and-pull-request-context-hardening.md | 90 ++--- .../pull-request-lifecycle-and-enrichment.md | 218 ++++++++--- src/actions/__tests__/common_action.test.ts | 16 + src/actions/common_action.ts | 2 +- .../__tests__/agent_response_schemas.test.ts | 16 + ...request_description_content_policy.test.ts | 104 +++++ .../policies/agent_response_schemas.ts | 43 ++- ...pull_request_description_content_policy.ts | 209 ++++++++++ .../execution_issue_number_policy.test.ts | 36 ++ .../execution_issue_number_policy.ts | 6 + .../execution/setup_execution_workflow.ts | 26 +- ...close_issue_after_merging_use_case.test.ts | 4 +- .../close_issue_after_merging_use_case.ts | 16 +- .../link_pull_request_issue_use_case.test.ts | 23 +- ...s_labels_from_issue_to_pr_use_case.test.ts | 4 +- ..._pull_request_description_use_case.test.ts | 73 +++- .../link_pull_request_issue_workflow.ts | 24 +- ...ogress_labels_from_issue_to_pr_use_case.ts | 10 +- ...pdate_pull_request_description_workflow.ts | 50 +-- src/data/model/__tests__/execution.test.ts | 20 + .../update_pull_request_description.test.ts | 19 +- .../update_pull_request_description.ts | 25 +- .../validate_workflow_contract.test.ts | 31 +- 45 files changed, 1703 insertions(+), 712 deletions(-) create mode 100644 src/application/policies/__tests__/pull_request_description_content_policy.test.ts create mode 100644 src/application/policies/pull_request_description_content_policy.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3d7b7e60f..522d0bbb7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,144 +1,18 @@ -# ๐Ÿ“Œ Summary - - ---- - -## ๐ŸŽฏ Related Issues / Tickets - -- Closes # -- Related to # - ---- +## What changed -## ๐Ÿงฉ Scope of Changes - -- Added: -- Updated: -- Removed: -- Refactored: - ---- - -## ๐Ÿ› ๏ธ Technical Details - - ---- - -## ๐Ÿ” How to Test - -1. -2. -3. - ---- - -## ๐Ÿงช Test Coverage - -- [ ] Unit tests -- [ ] Integration tests -- [ ] End-to-end (E2E) tests -- [ ] Manual testing only (explain why) - ---- - -## ๐Ÿ“ธ Screenshots / Recordings (UI changes only) - +- ---- - -## โš ๏ธ Breaking Changes - -- None - ---- - -## ๐Ÿš€ Deployment Notes - -- [ ] Requires database migration -- [ ] Requires environment variable changes -- [ ] Requires feature flag toggle -- [ ] No special deployment steps - -Details: - ---- - -## ๐Ÿ”’ Security Considerations - -- [ ] No security impact -- [ ] Input validation changes -- [ ] Authentication / authorization changes -- [ ] Sensitive data handling changes - ---- - -## ๐Ÿ“ˆ Performance Impact - -- [ ] No performance impact -- [ ] Improves performance -- [ ] Potential performance regression (explain) - ---- - -## ๐Ÿ“ Notes for Reviewers - - ---- - -## โœ… Checklist - -- [ ] I have self-reviewed my code -- [ ] Code follows project standards and conventions -- [ ] Tests have been added or updated -- [ ] Documentation has been updated (if applicable) -- [ ] No new warnings or lint errors -- [ ] Breaking contract changes and required consumer updates are documented +## Validation ---- +- -## ๐Ÿ“š Additional Context diff --git a/.github/workflows/copilot_pull_request.yml b/.github/workflows/copilot_pull_request.yml index f4b08879b..f8d52c4a5 100644 --- a/.github/workflows/copilot_pull_request.yml +++ b/.github/workflows/copilot_pull_request.yml @@ -1,8 +1,9 @@ name: Copilot - Pull Request +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: - types: [opened, reopened, edited, closed, synchronize] + types: [opened, reopened, closed, synchronize] pull_request_review: types: [submitted, edited, dismissed] merge_group: @@ -11,6 +12,8 @@ on: jobs: copilot-merge-group: if: ${{ github.event_name == 'merge_group' }} + # Keep the same required-check context as normal PR analysis. The run name + # above distinguishes merge_group:checks_requested in the Actions UI. name: Copilot - Pull Request runs-on: [self-hosted, codex] timeout-minutes: 10 @@ -23,12 +26,12 @@ jobs: copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} - name: Copilot - Pull Request + name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: [self-hosted, codex] timeout-minutes: 120 concurrency: group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' }} + cancel-in-progress: true permissions: checks: write contents: read diff --git a/build/api/index.js b/build/api/index.js index 5b1bf6db2..94c99cd35 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -6134,10 +6134,10 @@ function getThinkPrompt(params) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionPrompt; /** - * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + * Prompt for generating a concise PR description from an optional issue and the diff. */ const fill_1 = __nccwpck_require__(2559); -const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +const TEMPLATE = `You are in the repository workspace. Your task is to write a concise, review-ready pull request description from the branch diff and any linked issue. Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. @@ -6148,20 +6148,15 @@ Write every human-readable sentence in {{targetLocale}}. Preserve code identifie - **Head (source) branch:** \`{{headBranch}}\` **Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +1. Read \`.github/pull_request_template.md\` as content guidance and repository-specific constraints. Do not reproduce empty placeholder sections or treat every heading as mandatory. +2. Get the full merge-base diff with \`git diff {{baseBranch}}...{{headBranch}}\`. Use it to understand the behavior and contracts that changed. 3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** {{relatedIssueInstruction}} - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. +4. Provide \`overview\` as one to three sentences that state the outcome and why it matters. +5. Provide \`whatChangedHeading\` as the plain-text {{targetLocale}} equivalent of "What changed" and \`changes\` as two to six short, outcome-oriented items. Do not inventory files, use-case names, internal categories, or every implementation step. +6. Provide \`validationHeading\` as the plain-text {{targetLocale}} equivalent of "Validation" and \`validation\` with only commands, automated checks, or manual scenarios supported by available evidence. Never claim a check passed unless the evidence says it did, and never infer that result from the presence of test files or commands. When no execution evidence is available, say concisely in {{targetLocale}} that validation was not run or was not available. +7. Set \`reviewNotesHeading\` and \`reviewNotes\` to \`null\` unless reviewers need material migration, security, performance, compatibility, rollout, manual-verification, risk, or follow-up context. Otherwise use the localized plain-text heading and one to four concise items. {{relatedIssueInstruction}} +8. Keep the description practical and normally under 4,000 characters. It must never exceed 12,000 characters. Do not use emoji, horizontal separators, generic checklists, empty headings, repeated statements, placeholder text, or unsupported "no impact" claims. +9. Return one JSON object with exactly \`outputLocale\`, \`overview\`, \`whatChangedHeading\`, \`changes\`, \`validationHeading\`, \`validation\`, \`reviewNotesHeading\`, \`reviewNotes\`, and \`closesLinkedIssue\`. Every content field is plain text except Markdown links, code spans, refs, and commands inside content values. The application renders the Markdown structure; do not include headings, bullet prefixes, a preamble, meta-commentary, or code fence in the values. **Issue description:** {{issueDescription}} diff --git a/build/cli/index.js b/build/cli/index.js index 53a3577f2..8b972ac98 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -38922,7 +38922,7 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c if (execution.runnedByToken) { return runTrackedRoute(execution, 'single-action', () => (0, main_run_lifecycle_1.runTokenExecution)(execution, routeHandlers), undefined, agentActivityUseCase); } - if (execution.issueNumber === -1) { + if (execution.issueNumber === -1 && !execution.isPullRequest) { return runTrackedRoute(execution, 'single-action', () => (0, main_run_lifecycle_1.runNoIssueExecution)(execution, routeHandlers), undefined, agentActivityUseCase); } (0, main_run_lifecycle_1.logWelcomeMessage)(execution); @@ -41444,14 +41444,49 @@ exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { type: 'object', properties: { outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, - description: { + overview: { type: 'string', minLength: 1, - maxLength: 60000, - description: 'The complete Markdown pull-request description body.', + maxLength: 1500, + description: 'One to three sentences describing the outcome and why it matters.', + }, + whatChangedHeading: { type: 'string', minLength: 1, maxLength: 100 }, + changes: { + type: 'array', + minItems: 2, + maxItems: 6, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + validationHeading: { type: 'string', minLength: 1, maxLength: 100 }, + validation: { + type: 'array', + minItems: 1, + maxItems: 8, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + reviewNotesHeading: { type: ['string', 'null'], minLength: 1, maxLength: 100 }, + reviewNotes: { + type: ['array', 'null'], + minItems: 1, + maxItems: 4, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + closesLinkedIssue: { + type: 'boolean', + description: 'Whether this PR fully resolves the separate linked issue supplied by the application.', }, }, - required: ['outputLocale', 'description'], + required: [ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', + ], additionalProperties: false, }; /** @deprecated Retained for API compatibility; runtime adaptation uses one combined schema. */ @@ -45135,6 +45170,189 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ })); +/***/ }), + +/***/ 43268: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH = void 0; +exports.renderPullRequestDescriptionContent = renderPullRequestDescriptionContent; +const github_comment_publication_policy_1 = __nccwpck_require__(72712); +exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH = 12000; +const CONTENT_KEYS = Object.freeze([ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', +]); +const SORTED_CONTENT_KEYS = Object.freeze([...CONTENT_KEYS].sort()); +/** Turns structured, untrusted agent content into one predictable review surface. */ +function renderPullRequestDescriptionContent(payload, targetLocale, linkedIssueNumber) { + const parsed = parseContent(payload); + if (!parsed) + return { kind: 'invalid', reason: 'shape' }; + const safeLinkedIssueNumber = typeof linkedIssueNumber === 'number' + && Number.isSafeInteger(linkedIssueNumber) + && linkedIssueNumber > 0 + ? linkedIssueNumber + : undefined; + if (parsed.closesLinkedIssue && safeLinkedIssueNumber === undefined) { + return { kind: 'invalid', reason: 'shape' }; + } + const rawContent = [ + parsed.overview, + parsed.whatChangedHeading, + parsed.validationHeading, + ...parsed.changes, + ...parsed.validation, + ...(parsed.reviewNotesHeading ? [parsed.reviewNotesHeading] : []), + ...(parsed.reviewNotes ?? []), + ]; + if (rawContent.some(hasForbiddenMarkdown)) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + const overview = sanitizeBlock(parsed.overview); + const whatChangedHeading = sanitizeInline(parsed.whatChangedHeading); + const validationHeading = sanitizeInline(parsed.validationHeading); + const changes = parsed.changes.map(sanitizeInline); + const validation = parsed.validation.map(sanitizeInline); + const reviewNotesHeading = parsed.reviewNotesHeading === null + ? null + : sanitizeInline(parsed.reviewNotesHeading); + const reviewNotes = parsed.reviewNotes?.map(sanitizeInline) ?? null; + const allContent = [ + overview, + whatChangedHeading, + validationHeading, + ...changes, + ...validation, + ...(reviewNotesHeading ? [reviewNotesHeading] : []), + ...(reviewNotes ?? []), + ]; + if (allContent.some(value => !value || hasForbiddenMarkdown(value))) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + if (sentenceCount(overview, targetLocale) > 3) { + return { kind: 'invalid', reason: 'sentence-count' }; + } + if (hasDuplicates(changes, targetLocale) + || hasDuplicates(validation, targetLocale) + || (reviewNotes && hasDuplicates(reviewNotes, targetLocale))) { + return { kind: 'invalid', reason: 'duplicate-item' }; + } + const sections = [ + overview, + `## ${whatChangedHeading}\n\n${renderList(changes)}`, + `## ${validationHeading}\n\n${renderList(validation)}`, + ]; + if (reviewNotesHeading && reviewNotes) { + sections.push(`## ${reviewNotesHeading}\n\n${renderList(reviewNotes)}`); + } + if (parsed.closesLinkedIssue && safeLinkedIssueNumber !== undefined) { + sections.push(`Closes #${safeLinkedIssueNumber}`); + } + const markdown = sections.join('\n\n'); + return markdown.length <= exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH + ? { kind: 'valid', markdown } + : { kind: 'invalid', reason: 'body-too-long' }; +} +function parseContent(payload) { + const keys = Object.keys(payload).sort(); + if (keys.length !== CONTENT_KEYS.length + || keys.some((key, index) => key !== SORTED_CONTENT_KEYS[index])) { + return undefined; + } + const changes = stringArray(payload.changes, 2, 6); + const validation = stringArray(payload.validation, 1, 8); + if (typeof payload.overview !== 'string' + || payload.overview.length > 1500 + || typeof payload.whatChangedHeading !== 'string' + || payload.whatChangedHeading.length > 100 + || typeof payload.validationHeading !== 'string' + || payload.validationHeading.length > 100 + || !changes + || !validation + || typeof payload.closesLinkedIssue !== 'boolean') { + return undefined; + } + let reviewNotes; + let reviewNotesHeading; + if (payload.reviewNotes === null) { + if (payload.reviewNotesHeading !== null) + return undefined; + reviewNotes = null; + reviewNotesHeading = null; + } + else { + const parsedReviewNotes = stringArray(payload.reviewNotes, 1, 4); + if (!parsedReviewNotes + || typeof payload.reviewNotesHeading !== 'string' + || payload.reviewNotesHeading.length > 100) + return undefined; + reviewNotes = parsedReviewNotes; + reviewNotesHeading = payload.reviewNotesHeading; + } + return { + overview: payload.overview, + whatChangedHeading: payload.whatChangedHeading, + changes, + validationHeading: payload.validationHeading, + validation, + reviewNotesHeading, + reviewNotes, + closesLinkedIssue: payload.closesLinkedIssue, + }; +} +function stringArray(value, minimum, maximum) { + return Array.isArray(value) + && value.length >= minimum + && value.length <= maximum + && value.every(item => typeof item === 'string' && item.length <= 1000) + ? value + : undefined; +} +function sanitizeBlock(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 1500).trim().replace(/\s*\n\s*/gu, ' '); +} +function sanitizeInline(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 1000) + .trim() + .replace(/^[-*+]\s+/u, '') + .replace(/\s+/gu, ' '); +} +function hasForbiddenMarkdown(value) { + return /\p{Extended_Pictographic}/u.test(value) + || /(^|\n)\s*#{1,6}\s/u.test(value) + || /(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*($|\n)/u.test(value) + || /(^|\n)\s*(?:[-*+]\s+)?\[[ xX]\]\s/u.test(value); +} +function sentenceCount(value, locale) { + const Segmenter = Intl.Segmenter; + if (Segmenter) { + return Array.from(new Segmenter(locale, { granularity: 'sentence' }).segment(value)) + .filter(part => part.segment.trim().length > 0) + .length; + } + const terminalGroups = value.match(/[.!?ใ€‚๏ผ๏ผŸ]+(?=\s|$)/gu)?.length ?? 0; + return Math.max(1, terminalGroups); +} +function hasDuplicates(values, locale) { + const normalized = values.map(value => value.toLocaleLowerCase(locale).trim()); + return new Set(normalized).size !== normalized.length; +} +function renderList(values) { + return values.map(value => `- ${value}`).join('\n'); +} + + /***/ }), /***/ 39410: @@ -51324,6 +51542,11 @@ function resolveEventIssueNumber(context) { if (['check_suite', 'workflow_run'].includes(context.eventName)) { issueNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); } + else if (['pull_request', 'pull_request_review'].includes(context.eventName)) { + const pullRequestNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); + const branchIssueNumber = positiveIssueNumberOrUndefined((0, title_utils_1.extractIssueNumberFromBranch)(context.pullRequest.head)); + issueNumber = branchIssueNumber === pullRequestNumber ? undefined : branchIssueNumber; + } else { issueNumber = positiveIssueNumberOrUndefined((0, title_utils_1.extractIssueNumberFromBranch)(context.pullRequest.head)) ?? positiveIssueNumberOrUndefined(context.pullRequest.number); @@ -51468,11 +51691,15 @@ async function runSetupExecution(context, dependencies) { (0, logging_ports_1.setGlobalLoggerDebug)(context.debug, context.local); const tokenUser = await loadTokenUser(context, dependencies.organizationSetupPort); const issueResolution = await (0, resolve_execution_issue_number_1.resolveExecutionIssueNumber)(context, dependencies.issueSetupPort); - if (issueResolution.issueNumber === undefined) { + const canConfigureUnlinkedPullRequest = context.isPullRequest + && positiveIssueNumberOrUndefined(context.pullRequest.number) !== undefined; + if (issueResolution.issueNumber === undefined && !canConfigureUnlinkedPullRequest) { return { status: 'issue-unresolved', tokenUser, issueResolution }; } const previousConfiguration = await loadPreviousConfiguration(context, issueResolution.issueNumber, dependencies.configurationPort); - const currentIssueLabels = await loadIssueLabels(context, issueResolution.issueNumber, dependencies.issueSetupPort); + const currentIssueLabels = issueResolution.issueNumber === undefined + ? [] + : await loadIssueLabels(context, issueResolution.issueNumber, dependencies.issueSetupPort); let release = { ...context.release, active: currentIssueLabels.includes(context.labelNames.release), @@ -51506,7 +51733,7 @@ async function runSetupExecution(context, dependencies) { hotfixBranch: restored.hotfixBranch, }; let currentPullRequestLabels = [...context.currentPullRequestLabels]; - if (context.isIssue && !context.isSingleAction) { + if (context.isIssue && !context.isSingleAction && issueResolution.issueNumber !== undefined) { const resolution = await dependencies.branchVersionResolver.resolve({ issueNumber: issueResolution.issueNumber, release, @@ -51599,7 +51826,7 @@ function setupState(previousConfiguration, currentIssueLabels, currentPullReques }; } function positiveIssueNumberOrUndefined(value) { - return value > 0 && Number.isSafeInteger(value) ? value : undefined; + return typeof value === 'number' && value > 0 && Number.isSafeInteger(value) ? value : undefined; } @@ -59787,6 +60014,7 @@ const result_1 = __nccwpck_require__(73817); const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); class CloseIssueAfterMergingUseCase { constructor(issueRepository) { this.issueRepository = issueRepository; @@ -59795,7 +60023,8 @@ class CloseIssueAfterMergingUseCase { async invoke(param) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Executing ${this.taskId}.`); const result = []; - if (param.issueNumber <= 0) { + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { (0, logging_ports_1.logDebugInfo)('CloseIssueAfterMerging: no issue was inferred from the pull-request branch; skipping issue closure.'); return [new result_1.Result({ id: this.taskId, @@ -59805,20 +60034,20 @@ class CloseIssueAfterMergingUseCase { })]; } try { - const closed = await this.issueRepository.closeIssue(param.issueNumber); + const closed = await this.issueRepository.closeIssue(linkedIssueNumber); if (closed) { - (0, logging_ports_1.logInfo)(`Issue #${param.issueNumber} closed after merging PR #${param.pullRequestNumber}.`); + (0, logging_ports_1.logInfo)(`Issue #${linkedIssueNumber} closed after merging PR #${param.pullRequestNumber}.`); result.push(new result_1.Result({ id: this.taskId, success: true, executed: true, steps: [ - `#${param.issueNumber} was automatically closed after merging this pull request.` + `#${linkedIssueNumber} was automatically closed after merging this pull request.` ] })); } else { - (0, logging_ports_1.logDebugInfo)(`Issue #${param.issueNumber} was already closed or close failed after merge.`); + (0, logging_ports_1.logDebugInfo)(`Issue #${linkedIssueNumber} was already closed or close failed after merge.`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -59827,14 +60056,14 @@ class CloseIssueAfterMergingUseCase { } } catch (error) { - const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to close issue #${param.issueNumber}.`); + const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to close issue #${linkedIssueNumber}.`); (0, logging_ports_1.logError)(semanticError); result.push(new result_1.Result({ id: this.taskId, success: false, executed: true, steps: [ - `Tried to close issue #${param.issueNumber}, but there was a problem.`, + `Tried to close issue #${linkedIssueNumber}, but there was a problem.`, ], errors: [semanticError], })); @@ -60887,15 +61116,31 @@ exports.PullRequestIssueLinkOperationError = PullRequestIssueLinkOperationError; * from the same immutable webhook payload without trusting an event URL. */ async function runLinkPullRequestIssue(param, taskId, port, delay) { - if (!(0, positive_integer_policy_1.parsePositiveSafeInteger)(param.pullRequestNumber) - || !(0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber) - || !(0, deployment_configuration_1.isSafeBranchTree)(param.originalBaseBranch) + const pullRequestNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.pullRequestNumber); + const issueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!pullRequestNumber) { + return [new result_1.Result({ + id: taskId, + success: false, + executed: false, + steps: ['Pull-request linkage requires a positive pull-request number.'], + })]; + } + if (!issueNumber || issueNumber === pullRequestNumber) { + return [new result_1.Result({ + id: taskId, + success: true, + executed: false, + steps: ['No separate linked issue was inferred; pull-request linkage was skipped.'], + })]; + } + if (!(0, deployment_configuration_1.isSafeBranchTree)(param.originalBaseBranch) || !(0, deployment_configuration_1.isSafeBranchTree)(param.defaultBranch)) { return [new result_1.Result({ id: taskId, success: false, executed: false, - steps: ['Pull-request linkage requires positive issue/PR numbers and safe non-empty base branches.'], + steps: ['Pull-request linkage requires a positive issue number and safe non-empty base branches.'], })]; } const pendingMarker = buildPendingMarker(param); @@ -61064,6 +61309,7 @@ const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const sync_size_and_progress_labels_policy_1 = __nccwpck_require__(65676); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); /** * Copies size and progress labels from the linked issue to the PR. * Used when a PR is opened so it gets the same size/progress as the issue (corner case: @@ -61078,7 +61324,8 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Executing ${this.taskId}.`); const result = []; try { - if (param.issueNumber === -1) { + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { (0, logging_ports_1.logDebugInfo)('No issue linked to this PR. Skipping sync of size/progress labels.'); result.push(new result_1.Result({ id: this.taskId, @@ -61088,10 +61335,10 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { })); return result; } - const issueLabels = await this.issueLabelsPort.getLabels(param.issueNumber); + const issueLabels = await this.issueLabelsPort.getLabels(linkedIssueNumber); const sizeAndProgressFromIssue = (0, sync_size_and_progress_labels_policy_1.selectSizeAndProgressLabels)(issueLabels, param.sizeLabels); if (sizeAndProgressFromIssue.length === 0) { - (0, logging_ports_1.logDebugInfo)(`Issue #${param.issueNumber} has no size or progress labels. Nothing to sync.`); + (0, logging_ports_1.logDebugInfo)(`Issue #${linkedIssueNumber} has no size or progress labels. Nothing to sync.`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -61104,7 +61351,7 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { const prLabels = await this.issueLabelsPort.getLabels(prNumber); const nextPrLabels = (0, sync_size_and_progress_labels_policy_1.mergeSizeAndProgressLabels)(prLabels, sizeAndProgressFromIssue, param.sizeLabels); await this.issueLabelsPort.setLabels(prNumber, nextPrLabels); - (0, logging_ports_1.logDebugInfo)(`Synced size/progress labels from issue #${param.issueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); + (0, logging_ports_1.logDebugInfo)(`Synced size/progress labels from issue #${linkedIssueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -61196,12 +61443,12 @@ const prompts_1 = __nccwpck_require__(69518); const logging_ports_1 = __nccwpck_require__(6152); const project_context_instruction_1 = __nccwpck_require__(63907); const task_emoji_1 = __nccwpck_require__(46103); -const github_comment_publication_policy_1 = __nccwpck_require__(72712); const pull_request_description_1 = __nccwpck_require__(45315); const application_error_1 = __nccwpck_require__(75999); const positive_integer_policy_1 = __nccwpck_require__(19879); const agent_response_schemas_1 = __nccwpck_require__(25603); const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const pull_request_description_content_policy_1 = __nccwpck_require__(43268); /** Generates and publishes a PR description from an immutable, capability-scoped request. */ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId} (AI PR description).`); @@ -61224,12 +61471,13 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen })]; } (0, logging_ports_1.logDebugInfo)(`PR description will be generated from workspace diff: base "${branches.baseBranch}", head "${branches.headBranch}" (configured agent will run git diff).`); - const issueDescription = context.issueNumber > 0 - ? (await dependencies.issueDescriptionQueryPort.getDescription(context.issueNumber)) ?? '' + const inferredIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(context.issueNumber); + const linkedIssueNumber = inferredIssueNumber !== context.pullRequest.number + ? inferredIssueNumber + : undefined; + const issueDescription = linkedIssueNumber + ? (await dependencies.issueDescriptionQueryPort.getDescription(linkedIssueNumber)) ?? '' : ''; - if (context.issueNumber > 0 && issueDescription.length === 0) { - return skipped(taskId, 'No issue description found. Skipping update pull request description.'); - } const currentProjectMembers = await dependencies.organizationMembersPort.getAllMembers(); const creatorIsTeamMember = context.pullRequest.creator.length > 0 && currentProjectMembers.includes(context.pullRequest.creator); @@ -61240,11 +61488,11 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen projectContextInstruction: project_context_instruction_1.PROJECT_CONTEXT_INSTRUCTION, baseBranch: branches.baseBranch, headBranch: branches.headBranch, - issueNumber: context.issueNumber > 0 ? String(context.issueNumber) : 'not linked', + issueNumber: linkedIssueNumber ? String(linkedIssueNumber) : 'not linked', issueDescription: issueDescription || 'No linked issue description is available. Infer intent from the pull request title, body, and diff.', - relatedIssueInstruction: context.issueNumber > 0 - ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` - : 'Do not add a Closes line because this pull request has no linked issue.', + relatedIssueInstruction: linkedIssueNumber + ? `Set \`closesLinkedIssue\` to true only when this PR fully resolves issue #${linkedIssueNumber}; otherwise set it to false. Do not put the closing reference in another field.` + : 'Set `closesLinkedIssue` to false because this pull request has no separate linked issue.', targetLocale: context.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`UpdatePullRequestDescription: prompt length=${prompt.length}, issue description length=${issueDescription.length}. Calling configured agent.`); @@ -61254,15 +61502,7 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen prompt, options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('pull-request-description', agent_response_schemas_1.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA), }); - const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response, context.targetLocale)); - if (!generatedDescription.trim()) { - return [new result_1.Result({ - id: taskId, - success: false, - executed: true, - steps: ['Configured agent did not return a PR description.'], - })]; - } + const generatedDescription = extractDescription(response, context.targetLocale, linkedIssueNumber); const currentBody = details?.body ?? context.pullRequest.body; const pullRequestBody = context.mode === 'replace' ? generatedDescription @@ -61302,14 +61542,19 @@ async function loadPullRequestDetails(context, dependencies, trigger) { ? dependencies.pullRequestDescriptionCommandPort.getDetails(context.pullRequest.number) : undefined; } -function extractDescription(response, targetLocale) { - if (response == null) - return ''; +function extractDescription(response, targetLocale, linkedIssueNumber) { + if (response == null) { + throw new application_error_1.ApplicationError('agent.failed', 'Configured agent did not return PR description content. Existing body retained.'); + } const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); if (validation.kind === 'invalid') { throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); } - return typeof validation.payload.description === 'string' ? validation.payload.description : ''; + const rendered = (0, pull_request_description_content_policy_1.renderPullRequestDescriptionContent)(validation.payload, targetLocale, linkedIssueNumber); + if (rendered.kind === 'invalid') { + throw new application_error_1.ApplicationError('agent.failed', `Configured agent returned PR content that failed the concise description contract (${rendered.reason}). Existing body retained.`); + } + return rendered.markdown; } function skipped(taskId, step) { return [new result_1.Result({ id: taskId, success: false, executed: false, steps: [step] })]; @@ -79032,10 +79277,10 @@ function getThinkPrompt(params) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionPrompt; /** - * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + * Prompt for generating a concise PR description from an optional issue and the diff. */ const fill_1 = __nccwpck_require__(2559); -const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +const TEMPLATE = `You are in the repository workspace. Your task is to write a concise, review-ready pull request description from the branch diff and any linked issue. Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. @@ -79046,20 +79291,15 @@ Write every human-readable sentence in {{targetLocale}}. Preserve code identifie - **Head (source) branch:** \`{{headBranch}}\` **Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +1. Read \`.github/pull_request_template.md\` as content guidance and repository-specific constraints. Do not reproduce empty placeholder sections or treat every heading as mandatory. +2. Get the full merge-base diff with \`git diff {{baseBranch}}...{{headBranch}}\`. Use it to understand the behavior and contracts that changed. 3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** {{relatedIssueInstruction}} - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. +4. Provide \`overview\` as one to three sentences that state the outcome and why it matters. +5. Provide \`whatChangedHeading\` as the plain-text {{targetLocale}} equivalent of "What changed" and \`changes\` as two to six short, outcome-oriented items. Do not inventory files, use-case names, internal categories, or every implementation step. +6. Provide \`validationHeading\` as the plain-text {{targetLocale}} equivalent of "Validation" and \`validation\` with only commands, automated checks, or manual scenarios supported by available evidence. Never claim a check passed unless the evidence says it did, and never infer that result from the presence of test files or commands. When no execution evidence is available, say concisely in {{targetLocale}} that validation was not run or was not available. +7. Set \`reviewNotesHeading\` and \`reviewNotes\` to \`null\` unless reviewers need material migration, security, performance, compatibility, rollout, manual-verification, risk, or follow-up context. Otherwise use the localized plain-text heading and one to four concise items. {{relatedIssueInstruction}} +8. Keep the description practical and normally under 4,000 characters. It must never exceed 12,000 characters. Do not use emoji, horizontal separators, generic checklists, empty headings, repeated statements, placeholder text, or unsupported "no impact" claims. +9. Return one JSON object with exactly \`outputLocale\`, \`overview\`, \`whatChangedHeading\`, \`changes\`, \`validationHeading\`, \`validation\`, \`reviewNotesHeading\`, \`reviewNotes\`, and \`closesLinkedIssue\`. Every content field is plain text except Markdown links, code spans, refs, and commands inside content values. The application renders the Markdown structure; do not include headings, bullet prefixes, a preamble, meta-commentary, or code fence in the values. **Issue description:** {{issueDescription}} diff --git a/build/github_action/index.js b/build/github_action/index.js index 324067622..448062f22 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -38921,7 +38921,7 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c if (execution.runnedByToken) { return runTrackedRoute(execution, 'single-action', () => (0, main_run_lifecycle_1.runTokenExecution)(execution, routeHandlers), undefined, agentActivityUseCase); } - if (execution.issueNumber === -1) { + if (execution.issueNumber === -1 && !execution.isPullRequest) { return runTrackedRoute(execution, 'single-action', () => (0, main_run_lifecycle_1.runNoIssueExecution)(execution, routeHandlers), undefined, agentActivityUseCase); } (0, main_run_lifecycle_1.logWelcomeMessage)(execution); @@ -42268,14 +42268,49 @@ exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { type: 'object', properties: { outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, - description: { + overview: { type: 'string', minLength: 1, - maxLength: 60000, - description: 'The complete Markdown pull-request description body.', + maxLength: 1500, + description: 'One to three sentences describing the outcome and why it matters.', + }, + whatChangedHeading: { type: 'string', minLength: 1, maxLength: 100 }, + changes: { + type: 'array', + minItems: 2, + maxItems: 6, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + validationHeading: { type: 'string', minLength: 1, maxLength: 100 }, + validation: { + type: 'array', + minItems: 1, + maxItems: 8, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + reviewNotesHeading: { type: ['string', 'null'], minLength: 1, maxLength: 100 }, + reviewNotes: { + type: ['array', 'null'], + minItems: 1, + maxItems: 4, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + closesLinkedIssue: { + type: 'boolean', + description: 'Whether this PR fully resolves the separate linked issue supplied by the application.', }, }, - required: ['outputLocale', 'description'], + required: [ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', + ], additionalProperties: false, }; /** @deprecated Retained for API compatibility; runtime adaptation uses one combined schema. */ @@ -46351,6 +46386,189 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ })); +/***/ }), + +/***/ 43268: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH = void 0; +exports.renderPullRequestDescriptionContent = renderPullRequestDescriptionContent; +const github_comment_publication_policy_1 = __nccwpck_require__(72712); +exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH = 12000; +const CONTENT_KEYS = Object.freeze([ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', +]); +const SORTED_CONTENT_KEYS = Object.freeze([...CONTENT_KEYS].sort()); +/** Turns structured, untrusted agent content into one predictable review surface. */ +function renderPullRequestDescriptionContent(payload, targetLocale, linkedIssueNumber) { + const parsed = parseContent(payload); + if (!parsed) + return { kind: 'invalid', reason: 'shape' }; + const safeLinkedIssueNumber = typeof linkedIssueNumber === 'number' + && Number.isSafeInteger(linkedIssueNumber) + && linkedIssueNumber > 0 + ? linkedIssueNumber + : undefined; + if (parsed.closesLinkedIssue && safeLinkedIssueNumber === undefined) { + return { kind: 'invalid', reason: 'shape' }; + } + const rawContent = [ + parsed.overview, + parsed.whatChangedHeading, + parsed.validationHeading, + ...parsed.changes, + ...parsed.validation, + ...(parsed.reviewNotesHeading ? [parsed.reviewNotesHeading] : []), + ...(parsed.reviewNotes ?? []), + ]; + if (rawContent.some(hasForbiddenMarkdown)) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + const overview = sanitizeBlock(parsed.overview); + const whatChangedHeading = sanitizeInline(parsed.whatChangedHeading); + const validationHeading = sanitizeInline(parsed.validationHeading); + const changes = parsed.changes.map(sanitizeInline); + const validation = parsed.validation.map(sanitizeInline); + const reviewNotesHeading = parsed.reviewNotesHeading === null + ? null + : sanitizeInline(parsed.reviewNotesHeading); + const reviewNotes = parsed.reviewNotes?.map(sanitizeInline) ?? null; + const allContent = [ + overview, + whatChangedHeading, + validationHeading, + ...changes, + ...validation, + ...(reviewNotesHeading ? [reviewNotesHeading] : []), + ...(reviewNotes ?? []), + ]; + if (allContent.some(value => !value || hasForbiddenMarkdown(value))) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + if (sentenceCount(overview, targetLocale) > 3) { + return { kind: 'invalid', reason: 'sentence-count' }; + } + if (hasDuplicates(changes, targetLocale) + || hasDuplicates(validation, targetLocale) + || (reviewNotes && hasDuplicates(reviewNotes, targetLocale))) { + return { kind: 'invalid', reason: 'duplicate-item' }; + } + const sections = [ + overview, + `## ${whatChangedHeading}\n\n${renderList(changes)}`, + `## ${validationHeading}\n\n${renderList(validation)}`, + ]; + if (reviewNotesHeading && reviewNotes) { + sections.push(`## ${reviewNotesHeading}\n\n${renderList(reviewNotes)}`); + } + if (parsed.closesLinkedIssue && safeLinkedIssueNumber !== undefined) { + sections.push(`Closes #${safeLinkedIssueNumber}`); + } + const markdown = sections.join('\n\n'); + return markdown.length <= exports.MAX_PULL_REQUEST_DESCRIPTION_LENGTH + ? { kind: 'valid', markdown } + : { kind: 'invalid', reason: 'body-too-long' }; +} +function parseContent(payload) { + const keys = Object.keys(payload).sort(); + if (keys.length !== CONTENT_KEYS.length + || keys.some((key, index) => key !== SORTED_CONTENT_KEYS[index])) { + return undefined; + } + const changes = stringArray(payload.changes, 2, 6); + const validation = stringArray(payload.validation, 1, 8); + if (typeof payload.overview !== 'string' + || payload.overview.length > 1500 + || typeof payload.whatChangedHeading !== 'string' + || payload.whatChangedHeading.length > 100 + || typeof payload.validationHeading !== 'string' + || payload.validationHeading.length > 100 + || !changes + || !validation + || typeof payload.closesLinkedIssue !== 'boolean') { + return undefined; + } + let reviewNotes; + let reviewNotesHeading; + if (payload.reviewNotes === null) { + if (payload.reviewNotesHeading !== null) + return undefined; + reviewNotes = null; + reviewNotesHeading = null; + } + else { + const parsedReviewNotes = stringArray(payload.reviewNotes, 1, 4); + if (!parsedReviewNotes + || typeof payload.reviewNotesHeading !== 'string' + || payload.reviewNotesHeading.length > 100) + return undefined; + reviewNotes = parsedReviewNotes; + reviewNotesHeading = payload.reviewNotesHeading; + } + return { + overview: payload.overview, + whatChangedHeading: payload.whatChangedHeading, + changes, + validationHeading: payload.validationHeading, + validation, + reviewNotesHeading, + reviewNotes, + closesLinkedIssue: payload.closesLinkedIssue, + }; +} +function stringArray(value, minimum, maximum) { + return Array.isArray(value) + && value.length >= minimum + && value.length <= maximum + && value.every(item => typeof item === 'string' && item.length <= 1000) + ? value + : undefined; +} +function sanitizeBlock(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 1500).trim().replace(/\s*\n\s*/gu, ' '); +} +function sanitizeInline(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 1000) + .trim() + .replace(/^[-*+]\s+/u, '') + .replace(/\s+/gu, ' '); +} +function hasForbiddenMarkdown(value) { + return /\p{Extended_Pictographic}/u.test(value) + || /(^|\n)\s*#{1,6}\s/u.test(value) + || /(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*($|\n)/u.test(value) + || /(^|\n)\s*(?:[-*+]\s+)?\[[ xX]\]\s/u.test(value); +} +function sentenceCount(value, locale) { + const Segmenter = Intl.Segmenter; + if (Segmenter) { + return Array.from(new Segmenter(locale, { granularity: 'sentence' }).segment(value)) + .filter(part => part.segment.trim().length > 0) + .length; + } + const terminalGroups = value.match(/[.!?ใ€‚๏ผ๏ผŸ]+(?=\s|$)/gu)?.length ?? 0; + return Math.max(1, terminalGroups); +} +function hasDuplicates(values, locale) { + const normalized = values.map(value => value.toLocaleLowerCase(locale).trim()); + return new Set(normalized).size !== normalized.length; +} +function renderList(values) { + return values.map(value => `- ${value}`).join('\n'); +} + + /***/ }), /***/ 39410: @@ -51814,6 +52032,11 @@ function resolveEventIssueNumber(context) { if (['check_suite', 'workflow_run'].includes(context.eventName)) { issueNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); } + else if (['pull_request', 'pull_request_review'].includes(context.eventName)) { + const pullRequestNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); + const branchIssueNumber = positiveIssueNumberOrUndefined((0, title_utils_1.extractIssueNumberFromBranch)(context.pullRequest.head)); + issueNumber = branchIssueNumber === pullRequestNumber ? undefined : branchIssueNumber; + } else { issueNumber = positiveIssueNumberOrUndefined((0, title_utils_1.extractIssueNumberFromBranch)(context.pullRequest.head)) ?? positiveIssueNumberOrUndefined(context.pullRequest.number); @@ -51993,11 +52216,15 @@ async function runSetupExecution(context, dependencies) { (0, logging_ports_1.setGlobalLoggerDebug)(context.debug, context.local); const tokenUser = await loadTokenUser(context, dependencies.organizationSetupPort); const issueResolution = await (0, resolve_execution_issue_number_1.resolveExecutionIssueNumber)(context, dependencies.issueSetupPort); - if (issueResolution.issueNumber === undefined) { + const canConfigureUnlinkedPullRequest = context.isPullRequest + && positiveIssueNumberOrUndefined(context.pullRequest.number) !== undefined; + if (issueResolution.issueNumber === undefined && !canConfigureUnlinkedPullRequest) { return { status: 'issue-unresolved', tokenUser, issueResolution }; } const previousConfiguration = await loadPreviousConfiguration(context, issueResolution.issueNumber, dependencies.configurationPort); - const currentIssueLabels = await loadIssueLabels(context, issueResolution.issueNumber, dependencies.issueSetupPort); + const currentIssueLabels = issueResolution.issueNumber === undefined + ? [] + : await loadIssueLabels(context, issueResolution.issueNumber, dependencies.issueSetupPort); let release = { ...context.release, active: currentIssueLabels.includes(context.labelNames.release), @@ -52031,7 +52258,7 @@ async function runSetupExecution(context, dependencies) { hotfixBranch: restored.hotfixBranch, }; let currentPullRequestLabels = [...context.currentPullRequestLabels]; - if (context.isIssue && !context.isSingleAction) { + if (context.isIssue && !context.isSingleAction && issueResolution.issueNumber !== undefined) { const resolution = await dependencies.branchVersionResolver.resolve({ issueNumber: issueResolution.issueNumber, release, @@ -52124,7 +52351,7 @@ function setupState(previousConfiguration, currentIssueLabels, currentPullReques }; } function positiveIssueNumberOrUndefined(value) { - return value > 0 && Number.isSafeInteger(value) ? value : undefined; + return typeof value === 'number' && value > 0 && Number.isSafeInteger(value) ? value : undefined; } @@ -59874,6 +60101,7 @@ const result_1 = __nccwpck_require__(73817); const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); class CloseIssueAfterMergingUseCase { constructor(issueRepository) { this.issueRepository = issueRepository; @@ -59882,7 +60110,8 @@ class CloseIssueAfterMergingUseCase { async invoke(param) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Executing ${this.taskId}.`); const result = []; - if (param.issueNumber <= 0) { + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { (0, logging_ports_1.logDebugInfo)('CloseIssueAfterMerging: no issue was inferred from the pull-request branch; skipping issue closure.'); return [new result_1.Result({ id: this.taskId, @@ -59892,20 +60121,20 @@ class CloseIssueAfterMergingUseCase { })]; } try { - const closed = await this.issueRepository.closeIssue(param.issueNumber); + const closed = await this.issueRepository.closeIssue(linkedIssueNumber); if (closed) { - (0, logging_ports_1.logInfo)(`Issue #${param.issueNumber} closed after merging PR #${param.pullRequestNumber}.`); + (0, logging_ports_1.logInfo)(`Issue #${linkedIssueNumber} closed after merging PR #${param.pullRequestNumber}.`); result.push(new result_1.Result({ id: this.taskId, success: true, executed: true, steps: [ - `#${param.issueNumber} was automatically closed after merging this pull request.` + `#${linkedIssueNumber} was automatically closed after merging this pull request.` ] })); } else { - (0, logging_ports_1.logDebugInfo)(`Issue #${param.issueNumber} was already closed or close failed after merge.`); + (0, logging_ports_1.logDebugInfo)(`Issue #${linkedIssueNumber} was already closed or close failed after merge.`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -59914,14 +60143,14 @@ class CloseIssueAfterMergingUseCase { } } catch (error) { - const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to close issue #${param.issueNumber}.`); + const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to close issue #${linkedIssueNumber}.`); (0, logging_ports_1.logError)(semanticError); result.push(new result_1.Result({ id: this.taskId, success: false, executed: true, steps: [ - `Tried to close issue #${param.issueNumber}, but there was a problem.`, + `Tried to close issue #${linkedIssueNumber}, but there was a problem.`, ], errors: [semanticError], })); @@ -60974,15 +61203,31 @@ exports.PullRequestIssueLinkOperationError = PullRequestIssueLinkOperationError; * from the same immutable webhook payload without trusting an event URL. */ async function runLinkPullRequestIssue(param, taskId, port, delay) { - if (!(0, positive_integer_policy_1.parsePositiveSafeInteger)(param.pullRequestNumber) - || !(0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber) - || !(0, deployment_configuration_1.isSafeBranchTree)(param.originalBaseBranch) + const pullRequestNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.pullRequestNumber); + const issueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!pullRequestNumber) { + return [new result_1.Result({ + id: taskId, + success: false, + executed: false, + steps: ['Pull-request linkage requires a positive pull-request number.'], + })]; + } + if (!issueNumber || issueNumber === pullRequestNumber) { + return [new result_1.Result({ + id: taskId, + success: true, + executed: false, + steps: ['No separate linked issue was inferred; pull-request linkage was skipped.'], + })]; + } + if (!(0, deployment_configuration_1.isSafeBranchTree)(param.originalBaseBranch) || !(0, deployment_configuration_1.isSafeBranchTree)(param.defaultBranch)) { return [new result_1.Result({ id: taskId, success: false, executed: false, - steps: ['Pull-request linkage requires positive issue/PR numbers and safe non-empty base branches.'], + steps: ['Pull-request linkage requires a positive issue number and safe non-empty base branches.'], })]; } const pendingMarker = buildPendingMarker(param); @@ -61151,6 +61396,7 @@ const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const sync_size_and_progress_labels_policy_1 = __nccwpck_require__(65676); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); /** * Copies size and progress labels from the linked issue to the PR. * Used when a PR is opened so it gets the same size/progress as the issue (corner case: @@ -61165,7 +61411,8 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Executing ${this.taskId}.`); const result = []; try { - if (param.issueNumber === -1) { + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { (0, logging_ports_1.logDebugInfo)('No issue linked to this PR. Skipping sync of size/progress labels.'); result.push(new result_1.Result({ id: this.taskId, @@ -61175,10 +61422,10 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { })); return result; } - const issueLabels = await this.issueLabelsPort.getLabels(param.issueNumber); + const issueLabels = await this.issueLabelsPort.getLabels(linkedIssueNumber); const sizeAndProgressFromIssue = (0, sync_size_and_progress_labels_policy_1.selectSizeAndProgressLabels)(issueLabels, param.sizeLabels); if (sizeAndProgressFromIssue.length === 0) { - (0, logging_ports_1.logDebugInfo)(`Issue #${param.issueNumber} has no size or progress labels. Nothing to sync.`); + (0, logging_ports_1.logDebugInfo)(`Issue #${linkedIssueNumber} has no size or progress labels. Nothing to sync.`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -61191,7 +61438,7 @@ class SyncSizeAndProgressLabelsFromIssueToPrUseCase { const prLabels = await this.issueLabelsPort.getLabels(prNumber); const nextPrLabels = (0, sync_size_and_progress_labels_policy_1.mergeSizeAndProgressLabels)(prLabels, sizeAndProgressFromIssue, param.sizeLabels); await this.issueLabelsPort.setLabels(prNumber, nextPrLabels); - (0, logging_ports_1.logDebugInfo)(`Synced size/progress labels from issue #${param.issueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); + (0, logging_ports_1.logDebugInfo)(`Synced size/progress labels from issue #${linkedIssueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); result.push(new result_1.Result({ id: this.taskId, success: true, @@ -61283,12 +61530,12 @@ const prompts_1 = __nccwpck_require__(69518); const logging_ports_1 = __nccwpck_require__(6152); const project_context_instruction_1 = __nccwpck_require__(63907); const task_emoji_1 = __nccwpck_require__(46103); -const github_comment_publication_policy_1 = __nccwpck_require__(72712); const pull_request_description_1 = __nccwpck_require__(45315); const application_error_1 = __nccwpck_require__(75999); const positive_integer_policy_1 = __nccwpck_require__(19879); const agent_response_schemas_1 = __nccwpck_require__(25603); const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const pull_request_description_content_policy_1 = __nccwpck_require__(43268); /** Generates and publishes a PR description from an immutable, capability-scoped request. */ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId} (AI PR description).`); @@ -61311,12 +61558,13 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen })]; } (0, logging_ports_1.logDebugInfo)(`PR description will be generated from workspace diff: base "${branches.baseBranch}", head "${branches.headBranch}" (configured agent will run git diff).`); - const issueDescription = context.issueNumber > 0 - ? (await dependencies.issueDescriptionQueryPort.getDescription(context.issueNumber)) ?? '' + const inferredIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(context.issueNumber); + const linkedIssueNumber = inferredIssueNumber !== context.pullRequest.number + ? inferredIssueNumber + : undefined; + const issueDescription = linkedIssueNumber + ? (await dependencies.issueDescriptionQueryPort.getDescription(linkedIssueNumber)) ?? '' : ''; - if (context.issueNumber > 0 && issueDescription.length === 0) { - return skipped(taskId, 'No issue description found. Skipping update pull request description.'); - } const currentProjectMembers = await dependencies.organizationMembersPort.getAllMembers(); const creatorIsTeamMember = context.pullRequest.creator.length > 0 && currentProjectMembers.includes(context.pullRequest.creator); @@ -61327,11 +61575,11 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen projectContextInstruction: project_context_instruction_1.PROJECT_CONTEXT_INSTRUCTION, baseBranch: branches.baseBranch, headBranch: branches.headBranch, - issueNumber: context.issueNumber > 0 ? String(context.issueNumber) : 'not linked', + issueNumber: linkedIssueNumber ? String(linkedIssueNumber) : 'not linked', issueDescription: issueDescription || 'No linked issue description is available. Infer intent from the pull request title, body, and diff.', - relatedIssueInstruction: context.issueNumber > 0 - ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` - : 'Do not add a Closes line because this pull request has no linked issue.', + relatedIssueInstruction: linkedIssueNumber + ? `Set \`closesLinkedIssue\` to true only when this PR fully resolves issue #${linkedIssueNumber}; otherwise set it to false. Do not put the closing reference in another field.` + : 'Set `closesLinkedIssue` to false because this pull request has no separate linked issue.', targetLocale: context.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`UpdatePullRequestDescription: prompt length=${prompt.length}, issue description length=${issueDescription.length}. Calling configured agent.`); @@ -61341,15 +61589,7 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen prompt, options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('pull-request-description', agent_response_schemas_1.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA), }); - const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response, context.targetLocale)); - if (!generatedDescription.trim()) { - return [new result_1.Result({ - id: taskId, - success: false, - executed: true, - steps: ['Configured agent did not return a PR description.'], - })]; - } + const generatedDescription = extractDescription(response, context.targetLocale, linkedIssueNumber); const currentBody = details?.body ?? context.pullRequest.body; const pullRequestBody = context.mode === 'replace' ? generatedDescription @@ -61389,14 +61629,19 @@ async function loadPullRequestDetails(context, dependencies, trigger) { ? dependencies.pullRequestDescriptionCommandPort.getDetails(context.pullRequest.number) : undefined; } -function extractDescription(response, targetLocale) { - if (response == null) - return ''; +function extractDescription(response, targetLocale, linkedIssueNumber) { + if (response == null) { + throw new application_error_1.ApplicationError('agent.failed', 'Configured agent did not return PR description content. Existing body retained.'); + } const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); if (validation.kind === 'invalid') { throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); } - return typeof validation.payload.description === 'string' ? validation.payload.description : ''; + const rendered = (0, pull_request_description_content_policy_1.renderPullRequestDescriptionContent)(validation.payload, targetLocale, linkedIssueNumber); + if (rendered.kind === 'invalid') { + throw new application_error_1.ApplicationError('agent.failed', `Configured agent returned PR content that failed the concise description contract (${rendered.reason}). Existing body retained.`); + } + return rendered.markdown; } function skipped(taskId, step) { return [new result_1.Result({ id: taskId, success: false, executed: false, steps: [step] })]; @@ -76371,10 +76616,10 @@ function getThinkPrompt(params) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionPrompt; /** - * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + * Prompt for generating a concise PR description from an optional issue and the diff. */ const fill_1 = __nccwpck_require__(2559); -const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +const TEMPLATE = `You are in the repository workspace. Your task is to write a concise, review-ready pull request description from the branch diff and any linked issue. Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. @@ -76385,20 +76630,15 @@ Write every human-readable sentence in {{targetLocale}}. Preserve code identifie - **Head (source) branch:** \`{{headBranch}}\` **Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +1. Read \`.github/pull_request_template.md\` as content guidance and repository-specific constraints. Do not reproduce empty placeholder sections or treat every heading as mandatory. +2. Get the full merge-base diff with \`git diff {{baseBranch}}...{{headBranch}}\`. Use it to understand the behavior and contracts that changed. 3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** {{relatedIssueInstruction}} - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. +4. Provide \`overview\` as one to three sentences that state the outcome and why it matters. +5. Provide \`whatChangedHeading\` as the plain-text {{targetLocale}} equivalent of "What changed" and \`changes\` as two to six short, outcome-oriented items. Do not inventory files, use-case names, internal categories, or every implementation step. +6. Provide \`validationHeading\` as the plain-text {{targetLocale}} equivalent of "Validation" and \`validation\` with only commands, automated checks, or manual scenarios supported by available evidence. Never claim a check passed unless the evidence says it did, and never infer that result from the presence of test files or commands. When no execution evidence is available, say concisely in {{targetLocale}} that validation was not run or was not available. +7. Set \`reviewNotesHeading\` and \`reviewNotes\` to \`null\` unless reviewers need material migration, security, performance, compatibility, rollout, manual-verification, risk, or follow-up context. Otherwise use the localized plain-text heading and one to four concise items. {{relatedIssueInstruction}} +8. Keep the description practical and normally under 4,000 characters. It must never exceed 12,000 characters. Do not use emoji, horizontal separators, generic checklists, empty headings, repeated statements, placeholder text, or unsupported "no impact" claims. +9. Return one JSON object with exactly \`outputLocale\`, \`overview\`, \`whatChangedHeading\`, \`changes\`, \`validationHeading\`, \`validation\`, \`reviewNotesHeading\`, \`reviewNotes\`, and \`closesLinkedIssue\`. Every content field is plain text except Markdown links, code spans, refs, and commands inside content values. The application renders the Markdown structure; do not include headings, bullet prefixes, a preamble, meta-commentary, or code fence in the values. **Issue description:** {{issueDescription}} diff --git a/docs/bugbot/configuration.mdx b/docs/bugbot/configuration.mdx index 6302e5664..74cad1736 100644 --- a/docs/bugbot/configuration.mdx +++ b/docs/bugbot/configuration.mdx @@ -121,7 +121,7 @@ Use repository-specific review and protected-branch controls in addition to igno Detection is read-oriented, but publication requires the GitHub permissions needed to create issue comments or pull-request review comments. Autofix and user-request workflows additionally require write permission and must be restricted to trusted actors and trusted code. -PR reconciliation also reads submitted reviews and updates their generated status blocks, then upserts one issue-comment status card. Keep `pull-requests: write` and `issues: write` available to the configured PAT. No additional Bugbot toggle or secret is required. The shipped Commit and Pull Request workflows use distinct repository/branch native concurrency keys, so they cannot cancel one another. On push, Bugbot uses an exact-head provider lookup to detect an open same-repository PR before loading review context or invoking the agent; a match yields review ownership to `pull_request:synchronize`. Branches without a PR retain push-time review. A metadata-only `pull_request: edited` run waits instead of preempting an active PR review. Copy both templates together so this ownership contract remains intact. +PR reconciliation also reads submitted reviews and updates their generated status blocks, then upserts one issue-comment status card. Keep `pull-requests: write` and `issues: write` available to the configured PAT. No additional Bugbot toggle or secret is required. The shipped Commit and Pull Request workflows use distinct repository/branch native concurrency keys, so they cannot cancel one another. On push, Bugbot uses an exact-head provider lookup to detect an open same-repository PR before loading review context or invoking the agent; a match yields review ownership to `pull_request:synchronize`. Branches without a PR retain push-time review. Metadata-only `pull_request: edited` events are intentionally not subscribed, so Copilot's own body updates cannot create review-run cascades. Copy both templates together so this ownership contract remains intact. Do not expose provider credentials to fork code, untrusted pull requests, or `pull_request_target` workflows that execute attacker-controlled code. Prefer private repositories, protected branches, reviewed workflows, approved environments, and controlled runners. diff --git a/docs/bugbot/how-it-works.mdx b/docs/bugbot/how-it-works.mdx index e3523d98c..4fd3c77f2 100644 --- a/docs/bugbot/how-it-works.mdx +++ b/docs/bugbot/how-it-works.mdx @@ -81,7 +81,7 @@ This page describes the **internal flow** of Bugbot: how detection runs, how the 7. **Re-read and project:** After mutations, Bugbot verifies the PR head, re-reads linked-issue findings, reviews, child comments, native thread facts, and the PR conversation concurrently, and then verifies the head again. If the head changed while those reads were in flight, the whole snapshot is discarded as superseded. Each surface records whether its read was verified, failed, or not applicable, and each non-clean issue and PR destination must be observed independently; one visible or clean destination cannot hide another that is open, unverifiable, or missing. The human-discussion prompt includes only provider-classified human authors; comments and inline reviews authored by GitHub Apps or bot accounts do not consume its item or character budget. Pure lifecycle, provider-projection, and reconciliation-plan policies derive `open`, `reopened`, `fixed`, `obsolete`, `dismissed`, `verification-required`, or `unknown`. A dedicated presentation use case then updates at most 20 affected historical review blocks per run with bounded concurrency, upserts the oldest trusted **Bugbot status** card, and feeds the Result, lifecycle labels, Job Summary, Check Run, and telemetry. Missing or malformed owned evidence fails closed; it is never presented as clean. User-facing PR, commit, run, review, and finding links come only from authenticated provider adapters. Finding links are accepted only when they belong to the same HTTPS server and repository, including GitHub Enterprise installations. -Review and Commit workflow templates use distinct repository-and-branch concurrency keys. Each can cancel only a superseded run from the same event owner; a paired `push` and `pull_request:synchronize` therefore cannot cancel one another. Commit retains issue progress and native push work, then Bugbot resolves the branch with a read-only exact-head provider lookup. A validated open same-repository PR makes the push stop before loading review context or invoking the agent, because the PR synchronization event exclusively owns review for that head. This does not depend on PR identity being present in the `push` payload. Branches without an open PR still receive push-time, issue-targeted Bugbot review. Fork PR jobs remain excluded by the workflow's same-repository admission gate. A `pull_request: edited` event uses the PR key but cannot cancel a running PR review; it waits, and redundant pending edits collapse to the newest event. When it later runs, it keeps its native workflow result and Job Summary but does not create a `Copilot / Review` Check because it has no Bugbot telemetry. The Check name is reserved for exactly one structurally valid analysis snapshot for the exact head; duplicate telemetry or a valid snapshot beside malformed telemetry is rejected as ambiguous. Application head guards and idempotent provider writes still protect partial/canceled transitions. Other durable mutation workflows retain their workflow-local queue. +Review and Commit workflow templates use distinct repository-and-branch concurrency keys. Each can cancel only a superseded run from the same event owner; a paired `push` and `pull_request:synchronize` therefore cannot cancel one another. Commit retains issue progress and native push work, then Bugbot resolves the branch with a read-only exact-head provider lookup. A validated open same-repository PR makes the push stop before loading review context or invoking the agent, because the PR synchronization event exclusively owns review for that head. This does not depend on PR identity being present in the `push` payload. Branches without an open PR still receive push-time, issue-targeted Bugbot review. Fork PR jobs remain excluded by the workflow's same-repository admission gate. Metadata-only `pull_request: edited` events are not subscribed, preventing Copilot's description update from starting another PR run. The run name includes the event and action, review-state events have their own check identity, and normal PR plus merge-group jobs retain the same required-check context for branch-protection compatibility. The `Copilot / Review` Check remains reserved for exactly one structurally valid analysis snapshot for the exact head; duplicate telemetry or a valid snapshot beside malformed telemetry is rejected as ambiguous. Application head guards and idempotent provider writes still protect partial/canceled transitions. Other durable mutation workflows retain their workflow-local queue. Every exit path emits optional content-free telemetry, including canonical selection reason, logical and raw request counts, the fixed concurrency limit, diff --git a/docs/features.mdx b/docs/features.mdx index ec3a47317..7fabaa2e2 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -43,15 +43,16 @@ When the workflow runs on `issues` (opened, edited, labeled, unlabeled, etc.): ### 2. Pull request events (`on: pull_request`) -When the workflow runs on `pull_request` (opened, edited, etc.): +When the workflow runs on `pull_request` (`opened`, `reopened`, `synchronize`, +or `closed`): | Feature | Description | |--------|-------------| -| **PRโ€“issue linking** | Links the pull request to the issue associated with its branch and relies on GitHub's native linked-PR UI. | +| **PRโ€“issue linking** | Links the pull request to a distinct issue associated with its branch and relies on GitHub's native linked-PR UI. An unlinked PR still receives PR-native enrichment and is never linked to itself. | | **Project linking** | Adds the PR to the configured GitHub Projects and moves it to the configured column. | | **Reviewers** | Assigns up to `desired-reviewers-count` reviewers. | | **Priority & size** | Applies priority and size checks (labels and thresholds). | -| **AI PR description** | When `ai-pull-request-description-mode` is `replace`, `append`, or `preserve`, the selected agent CLI can generate a PR description from the issue and branch diff. `disabled` turns the feature off. See [Pull Requests โ†’ AI-generated PR description](/pull-requests/ai-description). | +| **AI PR description** | When `ai-pull-request-description-mode` is `replace`, `append`, or `preserve`, the selected agent CLI writes a concise outcome, material changes, and validation from the optional issue context and branch diff. `disabled` turns the feature off. See [Pull Requests โ†’ AI-generated PR description](/pull-requests/ai-description). | | **Conversation UX** | Publishes only purpose-specific replies and durable status cards. It does not add a generic action recap or decorative image. | | **Bugbot review** | Reviews the full PR on open/reopen and the new commit range on synchronize; publishes historical review snapshots and stable line findings, then reconciles native threads, review status blocks, one current PR card, and the Check from a provider-verified projection. | @@ -133,7 +134,7 @@ Codex is the default runtime for the repository's AI feature paths. OpenCode and | **Explicit Copilot commands** | Issue and PR comments | `/copilot help`, `/copilot plan`, `/copilot clarify`, `/copilot estimate`, `/copilot test-plan`, `/copilot explain`, `/copilot diagnose`, `/copilot analyze`, `/copilot status`, `/copilot review`, `/copilot findings`, `/copilot recheck`, `/copilot fix`, `/copilot dismiss`, `/copilot remember`, `/copilot implement`, and `/copilot sync-branch` provide a bounded, predictable interface. | | **Branch synchronization** | All-branch push observer; issue/PR command | Detection is agent-free. An authorized command merges parent into working branch, invokes the fixer only for eligible Git conflicts, validates the prepared merge, runs configured checks, revalidates remote heads, pushes, and reports the outcome. | | **Repository language and request adaptation** | Generated GitHub UI; addressed issue/PR comments | Uses `repository-locale` (`en-US` by default), with optional inheriting `issues-locale` and `pull-requests-locale` overrides. An addressed foreign-language request is safely interpreted once; its source comment is never edited, and translation context appears only with the useful bot response. Unaddressed human and automated comments are inert. | -| **AI PR description** | Pull request pipeline | Fills the repo's `.github/pull_request_template.md` from issue and branch diff (configured agent CLI). | +| **AI PR description** | Pull request pipeline | Uses `.github/pull_request_template.md` as guidance to write a concise body from optional issue context and the branch diff (configured agent CLI). | | **Copilot** | CLI `copilot do` | Code analysis and file edits via the configured agent runtime. | | **Recommend steps** | Single action / CLI | Suggests implementation steps from the issue description (configured agent CLI). | @@ -161,7 +162,7 @@ are deprecated. **Two coordination policies:** durable mutation workflows preserve every admitted event in a workflow-local queue. Commit and Pull Request each have a separate repository/branch latest-revision lane, so paired events cannot cancel one another. -GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}` and Pull Request uses `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}`. Each cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting one event cancel useful work from the other. Branches without an open PR retain push-time Bugbot. The PR workflow conditionally disables cancellation for `pull_request: edited`, so metadata normalization waits instead of preempting the active PR review. Application-level head guards prevent an older run from updating a newer PR, and the next run repairs any durable half-transition discovered after cancellation. +GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}` and Pull Request uses `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}`. Each cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting one event cancel useful work from the other. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check; normal PR and merge-group jobs deliberately share the exact required-check context for branch-protection compatibility. For non-replaceable issue and comment mutations, Copilot adds an application-level queue per workflow: every started run waits for earlier active runs of that same workflow, so intermediate events are not discarded by a native concurrency group. Runs triggered by the PAT owner that would only re-trigger the normal pipeline complete before entering this queue. diff --git a/docs/how-to-use.mdx b/docs/how-to-use.mdx index aead11c3a..242ff24c6 100644 --- a/docs/how-to-use.mdx +++ b/docs/how-to-use.mdx @@ -416,7 +416,14 @@ The **labels** in each template must match the label names configured in the act ### `setup/pull_request_template.md` -Copied to `.github/pull_request_template.md`. Used as the default body for new PRs. The AI PR description feature can fill this structure; you can edit the sections (Summary, Related Issues, Scope, Technical Details, How to Test, etc.) to fit your repo. No Copilot logic depends on specific headings; only the deploy/release/hotfix flows depend on **workflow filenames** and **label names**. +Copied to `.github/pull_request_template.md`. Used as the default body for new +PRs and as guidance for AI-generated descriptions. The shipped template asks +for a short outcome, material changes, and validation; reviewer notes are +conditional. You can add repository-specific instructions, but the agent omits +empty boilerplate, generic checklists, and unsupported claims rather than +reproducing every heading. No Copilot logic depends on specific headings; only +the deploy/release/hotfix flows depend on **workflow filenames** and **label +names**. --- diff --git a/docs/pull-requests/ai-description.mdx b/docs/pull-requests/ai-description.mdx index 11697fb53..bc601bb12 100644 --- a/docs/pull-requests/ai-description.mdx +++ b/docs/pull-requests/ai-description.mdx @@ -1,6 +1,6 @@ --- title: AI-Generated PR Description -description: How the configured agent fills your pull request template from the issue and branch diff +description: How the configured agent writes a concise pull request description from the diff and optional issue context --- # AI-Generated PR Description @@ -10,33 +10,38 @@ description: How the configured agent fills your pull request template from the 1. The action determines the PR's **base** and **head** branch (target and source branch). 2. The configured agent CLI runs in the repository workspace. It: - - Reads the repository's **pull request template** (see below). - - Computes the **diff** between base and head (e.g. `git diff base..head`) to understand what changed. + - Reads the repository's **pull request template** as content guidance and constraints. + - Computes the merge-base **diff** between base and head (`git diff base...head`) to understand what changed. - Uses the **issue description** (when the PR branch is linked to an issue) as context; otherwise it infers intent from the PR metadata and diff. -3. The agent **fills the template** with a structured description: summary, scope of changes, technical details, how to test, breaking changes, deployment notes, etc., following the same sections and format as your template. +3. The agent returns bounded semantic fields for one short outcome paragraph, two to six changes, validation evidence, and optional review notes. The action validates and sanitizes those fields, then renders the fixed Markdown hierarchy; the model does not control arbitrary sections or closing-reference syntax. 4. The request carries the effective PR locale (`pull-requests-locale`, otherwise `repository-locale`, otherwise `en-US`). The response must echo that exact canonical tag in `outputLocale`; a mismatch stops before the body changes. 5. The action writes the validated result to the PR body according to the configured ownership mode. No pre-computed file list or patches are sent from the action; the agent has access to the workspace and computes the diff itself, similar to the [check progress](/single-actions) flow. -## PR template as example for the AI +## PR template as guidance -The AI is instructed to use your repository's **pull request template** as the structure for the description. You should define: +The AI reads your repository's **pull request template** for team-specific guidance: -- **`.github/pull_request_template.md`** โ€” This file is read by the configured `planner` agent runtime and used as the **skeleton** to fill. The agent keeps the same headings, bullet lists, checkboxes (`- [ ]`, `- [x]`), and separators, and fills each section with content derived from the diff and the issue. +- **`.github/pull_request_template.md`** โ€” This file is read by the configured `planner` agent runtime. Its instructions and relevant headings influence the result, but empty placeholders and generic sections are omitted. -If you don't have a template, the agent will still produce a structured description, but defining a template ensures consistent, professional PR descriptions that match your team's expectations (e.g. Summary, Related Issues, Scope of Changes, Technical Details, How to Test, Breaking Changes, Deployment Notes, etc.). +The generated body is normally under 4,000 characters and can never exceed +12,000. It excludes emoji, separators, empty headings, generic checklists, +file-by-file inventories, repeated statements, and unsupported claims that a +test passed or a change has no impact. A template can add useful constraints, +but it cannot force the agent to reproduce empty boilerplate. - **Recommendation:** Add a `.github/pull_request_template.md` in your repo with the sections you want (summary, scope, testing, breaking changes, etc.). The AI will use it as a guide and fill it with the information from the issue and the branch diff. + **Recommendation:** Keep the template short. Ask for the outcome, material changes, validation evidence, and conditional reviewer notes. The setup template follows this contract. ## When the AI description runs - - A linked issue with a non-empty description enriches the result, but it is not required; PRs without an issue are supported. + - A linked issue description enriches the result when available. An empty or absent issue description does not block generation; the PR metadata and diff remain sufficient context. + - A PR is never treated as its own linked issue. A closing reference is added only for a distinct inferred issue and only when the change fully resolves it. - If `ai-members-only` is enabled (default: false), the PR author must be a **project/org member**; otherwise the step is skipped. - - The action runs on the same `pull_request` events as the rest of the PR pipeline (e.g. opened, edited). + - Automatic descriptions run on `opened`, `reopened`, and `synchronize`. Body/title-only `pull_request: edited` events are intentionally ignored so the action's own body update cannot trigger another Copilot PR run. ## Description policies diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index 27ba4d2a7..cc6d34997 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -9,7 +9,7 @@ This page describes each **capability** the action performs when it runs on a pu ## PRโ€“issue linking -The action **links the pull request to the issue** associated with its branch. The issue number is inferred from the **branch name** (e.g. `feature/123-add-login` โ†’ issue `123`). It then: +The action links the pull request to a **separate issue** inferred from its branch (for example, `feature/123-add-login` โ†’ issue `123`). It then: - Creates the **link** in GitHub (so the issue shows โ€œLinked pull requestsโ€ and the PR shows the issue). - Relies on GitHub's native linked-PR UI; routine linkage does not create a summary comment. @@ -30,7 +30,7 @@ compensated failure says that the original body and base were restored. If a newer event sees a different current base, Copilot stops without writing; restore the base named by the failed run or start again from the current PR state. -The branch name must follow the pattern that includes the issue number (e.g. `/-`). If the branch does not match, the action cannot link an issue. See [Issues โ†’ Branch management](/issues/branch-management) for naming conventions. +The branch name must follow the pattern that includes the issue number (e.g. `/-`). If it does not, the PR still receives PR-native enrichment such as title normalization on open/reopen, assignee and reviewer selection, project linking, description, and review where configured. Issue-derived priority, size, and progress synchronization and merge closure are skipped because those policies need a separate issue. Copilot never uses the PR number itself as fallback linkage and never creates `Closes #`. See [Issues โ†’ Branch management](/issues/branch-management) for naming conventions. ## Project linking @@ -68,8 +68,9 @@ Thresholds are defined in [Configuration](/configuration) (e.g. `size-m-threshol - Reads your repoโ€™s **pull request template** (`.github/pull_request_template.md`). -- Uses the **issue description** and the **branch diff** (base..head) as context. -- Fills the template with a structured description (summary, scope, technical details, how to test, etc.). +- Uses the optional **issue description** and the merge-base **branch diff** (`base...head`) as context. +- Writes a concise outcome, material changes, and validation evidence. Reviewer notes appear only when they carry useful risk, migration, rollout, or manual-verification context. +- Omits empty template sections, generic checklists, implementation inventories, and self-closing issue references. See [AI PR description](/pull-requests/ai-description) for details, optional issue context, and how to enable it in your workflow. @@ -107,6 +108,15 @@ defaults to English, and records one non-duplicated localization section. Stable event, lifecycle, finding-state, and error-code values remain suitable for operator diagnosis even when the surrounding copy is localized. +The supplied workflow names runs as `Copilot PR ยท :`, so normal +analysis, review-state observation, and merge-queue admission are recognizable +without opening logs. Review-state events use the distinct +`Copilot - Pull Request Review State` check. Normal analysis and merge-group +runs intentionally share `Copilot - Pull Request`, preserving the exact +required-check context that GitHub branch protection and merge queues expect. +Metadata-only body/title edits do not start the PR workflow; new commits and +lifecycle changes still do. + ## Next steps - **[Workflow setup](/pull-requests/workflow-setup)** โ€” Enable the action for pull_request. diff --git a/docs/pull-requests/examples.mdx b/docs/pull-requests/examples.mdx index 31588389e..b6bb5cce9 100644 --- a/docs/pull-requests/examples.mdx +++ b/docs/pull-requests/examples.mdx @@ -13,18 +13,21 @@ Example `.github/workflows/copilot_pull_request.yml` with common inputs: ```yaml name: Copilot - Pull Request +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: - types: [opened, reopened, edited, closed, synchronize] + types: [opened, reopened, closed, synchronize] + pull_request_review: + types: [submitted, edited, dismissed] jobs: copilot-pull-requests: - name: Copilot - Pull Request + name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: ubuntu-latest concurrency: group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' }} + cancel-in-progress: true steps: - name: Checkout Repository uses: actions/checkout@v5 diff --git a/docs/pull-requests/index.mdx b/docs/pull-requests/index.mdx index 94a3c3220..f0b8ed930 100644 --- a/docs/pull-requests/index.mdx +++ b/docs/pull-requests/index.mdx @@ -5,7 +5,7 @@ description: How Copilot handles pull requests: linking, projects, reviewers, si # Pull Request Management -When your workflow runs on **`pull_request`** events (e.g. opened, edited, labeled), Copilot performs a set of actions so that PRs stay linked to issues, projects, and team workflows. It can link the PR to the issue, add it to project boards, assign reviewers, apply size and priority labels, and optionally generate the PR description with AI. +When your workflow runs on **`pull_request`** code and lifecycle events, Copilot keeps PRs connected to projects and team workflows. It can link a distinct branch issue when one exists, add the PR to project boards, assign reviewers, apply size and priority policy, and optionally generate a concise PR description with AI. Unlinked PRs remain supported and are never linked to themselves. @@ -15,7 +15,7 @@ When your workflow runs on **`pull_request`** events (e.g. opened, edited, label PRโ€“issue linking, project linking, reviewers, size and priority, AI description, comments and images. - How the configured agent fills your PR template from the issue and branch diff. + How the configured agent writes a concise body from optional issue context and the branch diff. PR-specific inputs: project columns, reviewers, images, AI. @@ -29,9 +29,9 @@ When your workflow runs on **`pull_request`** events (e.g. opened, edited, label | What happens | What Copilot does | |--------------|-------------------| -| A **PR is opened or reopened** | Links the PR to its issue and projects, assigns reviewers, applies size/priority policy, optionally generates its description, and runs a full Bugbot review. | +| A **PR is opened or reopened** | Links a distinct branch issue when present, enriches the PR, optionally generates its description, and runs a full Bugbot review. | | New commits **synchronize** the PR | Optionally refreshes the AI description and reviews only the new commit range while reconciling every open finding. | -| PR metadata is **edited** | Normalizes the title without invoking an agent or replaying creation-time mutations. | +| PR metadata is **edited** | The supplied workflow does not run, preventing automatic body updates from creating workflow noise and leaving that human edit untouched. | | **Push** to the PR branch | Commit workflow updates issue progress while the paired PR `synchronize` event exclusively owns Bugbot review for that head. You can ask the bot to fix findings from a comment. See [Bugbot](/bugbot). | **Bugbot** (potential problems) runs on PR code-change events, on **push** to an issue-linked branch that has no open PR, or on demand. When a PR is available, its PR event publishes one review summary with new findings attached as inline comments; it does not duplicate the review from the paired push or publish findings as independent PR conversation comments. For full details, see [Bugbot](/bugbot). diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index 67285aa74..2784056fe 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -14,7 +14,7 @@ Use the `pull_request` trigger with the types you need. Common setup: ```yaml on: pull_request: - types: [opened, reopened, edited, closed, synchronize] + types: [opened, reopened, closed, synchronize] pull_request_review: types: [submitted, edited, dismissed] ``` @@ -23,10 +23,15 @@ on: |------------|--------------|-------------| | `opened` | A new PR is created | Link to issue, link to projects, assign reviewers, apply size/priority, generate AI description (if enabled). | | `reopened` | A closed PR is reopened | Re-apply linking and labels. | -| `edited` | PR title or body is edited | Normalize the title only; no agent is invoked. | | `synchronize` | New commits are pushed to the PR branch | Update the AI description when enabled and review the new commit range while reconciling open findings. | | `closed` | PR is closed or merged | Update project state. | +Do not subscribe the supplied PR workflow to `pull_request: edited`. Copilot +updates the PR body itself, so that metadata event would create redundant runs. +Human title/body edits do not trigger Copilot. Title normalization still runs +on open/reopen; subsequent synchronize events affect the body only according to +the configured AI description ownership mode. + For **first-time setup**, at least **`opened`** and **`synchronize`** are useful so that new PRs get full treatment and updates when the branch changes. ## Minimal workflow @@ -35,18 +40,21 @@ Create a file under `.github/workflows/` (e.g. `copilot_pull_request.yml`): ```yaml name: Copilot - Pull Request +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: - types: [opened, reopened, edited, closed, synchronize] + types: [opened, reopened, closed, synchronize] + pull_request_review: + types: [submitted, edited, dismissed] jobs: copilot-pull-requests: - name: Copilot - Pull Request + name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: ubuntu-latest concurrency: group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' }} + cancel-in-progress: true steps: - name: Checkout Repository uses: actions/checkout@v5 @@ -74,15 +82,19 @@ jobs: - **`token`** is required (use a fine-grained PAT with repo and project permissions; see [Authentication](/authentication)). - **`project-ids`** is optional but needed if you want PRs linked to GitHub Project boards and moved to columns (e.g. "In Progress"). - The conditional fetch downloads only the two commit objects needed by the incremental reviewer. Keep it when `synchronize` is enabled; a full-history checkout is not required. -- Keep the PR-specific concurrency group and conditional expression exactly as - shown. A newer PR review event cancels an obsolete PR analysis, while a - metadata-only `edited` event waits instead of canceling the active Bugbot - review. The Commit workflow uses its own `copilot-push-โ€ฆ` group, so a paired - `push` event cannot cancel this workflow (or be canceled by it). -- The queued metadata run keeps its own workflow result and Job Summary but does - not publish a generic PR comment or `Copilot / Review`. Only a result with exactly one validated, - current-schema Bugbot telemetry snapshot for the exact head owns that Check name; partial, skipped, and superseded - reviews are neutral rather than successful. +- Keep the PR-specific concurrency group and cancellation policy exactly as + shown. A newer PR/review-state event cancels an obsolete run. The Commit + workflow uses its own `copilot-push-โ€ฆ` group, so a paired `push` event cannot + cancel this workflow (or be canceled by it). +- Keep the event/action run name and distinct review-state job name. They make + the Actions and Checks views understandable without opening logs. Only a + result with exactly one validated, current-schema Bugbot telemetry snapshot + for the exact head owns the `Copilot / Review` Check; partial, skipped, and + superseded reviews are neutral rather than successful. +- If the repository uses a merge queue, also keep the shipped + `merge_group: checks_requested` job named `Copilot - Pull Request`. Its run + name identifies the merge-group event while its unchanged job name satisfies + the same required-check rule as normal PR analysis. ## What runs when @@ -91,13 +103,13 @@ jobs: Set the optional Repository Variable `COPILOT_BOT_LOGIN` to the PAT bot's login to skip bot-authored event jobs before a runner starts. The expression is generic and optional; the authenticated in-Action admission remains the fallback. This workflow is event-driven and does not require or install a `workflow_run` trigger. Consumers may add private, explicitly scoped workflow chaining in their own repositories. -2. **PRโ€“issue linking:** The action infers the **issue number** from the PR branch name (e.g. `feature/123-add-login` โ†’ issue `123`) and **links the PR to that issue**. Routine linkage uses GitHub's native linked-PR UI and does not post a recap comment. +2. **Optional PRโ€“issue linking:** The action infers a distinct **issue number** from the PR branch name (e.g. `feature/123-add-login` โ†’ issue `123`) and links the PR to that issue. Without a distinct match, PR-native enrichment continues and Copilot never treats the PR as its own issue. Routine linkage uses GitHub's native linked-PR UI and does not post a recap comment. 3. **Project linking:** If `project-ids` is set, the PR is added to those projects and moved to the configured column (e.g. "In Progress"). See [Capabilities](/pull-requests/capabilities). 4. **Reviewers:** If `desired-reviewers-count` is set, the action assigns up to that many reviewers. See [Configuration](/pull-requests/configuration). -5. **Size and priority:** The action computes **size** (XSโ€“XXL) and **progress** (if the selected agent CLI is configured) from the branch diff and applies the corresponding labels to the **issue** and to the **PR**. Same thresholds as in [Configuration](/configuration). +5. **Size and priority:** When a distinct issue is linked, the action computes **size** (XSโ€“XXL) and **progress** (if the selected agent CLI is configured) from the branch diff and synchronizes the corresponding labels from the **issue** to the **PR**. Without a distinct issue, this issue-derived step is skipped. Same thresholds as in [Configuration](/configuration). 6. **AI PR description:** When `ai-pull-request-description-mode` is not `disabled` and the selected agent CLI is configured, the action can generate or update the PR description from the issue and the branch diff according to the selected policy. See [AI PR description](/pull-requests/ai-description). diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index d29b1f174..abf33e767 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -77,7 +77,7 @@ This guide helps you resolve common issues you might encounter while using Copil external GitHub wait and has no release runner to extend. npm visibility has its own bounded polling interval and timeout. 2. **Network:** Check GitHub API status, verify network access, and monitor rate limits. - 3. **Triggers:** Verify event triggers, workflow conditions, and concurrent executions. A newer run may intentionally cancel an older run from the same workflow, but a Commit run and its paired Pull Request run must not cancel one another. When an open same-repository PR exists, Commit must skip Bugbot and `pull_request:synchronize` must own review for that head. A metadata-only `pull_request: edited` run must wait and must not cancel an active `synchronize` review; after it runs, it must not create a generic PR comment or `Copilot / Review`. If event owners cross-cancel, both run Bugbot for one head, append lifecycle/debug-only comments, or a newer same-name Check says `Bugbot review: โ€”`, update the shipped workflows and Action before trusting the result. Bounded partial, skipped, and superseded reviews should be neutral, not successful. + 3. **Triggers:** Verify event triggers, workflow conditions, and concurrent executions. A newer run may intentionally cancel an older run from the same workflow, but a Commit run and its paired Pull Request run must not cancel one another. When an open same-repository PR exists, Commit must skip Bugbot and `pull_request:synchronize` must own review for that head. The supplied PR workflow must not subscribe to metadata-only `pull_request: edited`; if an automatic description update starts another PR run, update the workflow template. Run names must expose event/action and review-state events must have their own check identity. Normal PR and merge-group jobs must retain the same `Copilot - Pull Request` context so required checks continue to resolve. If event owners cross-cancel, both run Bugbot for one head, append lifecycle/debug-only comments, or a newer ambiguous Check says `Bugbot review: โ€”`, update the shipped workflows and Action before trusting the result. Bounded partial, skipped, and superseded reviews should be neutral, not successful. diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index e1f9a7647..e26b56971 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -26,7 +26,6 @@ const CHECKOUT_ACTION = 'actions/checkout@v5'; const SETUP_NODE_ACTION = 'actions/setup-node@v7'; const PUSH_BRANCH_CONCURRENCY_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; const PULL_REQUEST_BRANCH_CONCURRENCY_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}'; -const BUGBOT_PULL_REQUEST_CANCEL_EXPRESSION = "${{ github.event_name != 'pull_request' || github.event.action != 'edited' }}"; const BUGBOT_CONCURRENCY_JOBS = Object.freeze({ 'copilot_commit.yml': Object.freeze({ jobId: 'copilot-commits', @@ -36,7 +35,7 @@ const BUGBOT_CONCURRENCY_JOBS = Object.freeze({ 'copilot_pull_request.yml': Object.freeze({ jobId: 'copilot-pull-requests', group: PULL_REQUEST_BRANCH_CONCURRENCY_GROUP, - cancelInProgress: BUGBOT_PULL_REQUEST_CANCEL_EXPRESSION, + cancelInProgress: true, }), }); @@ -374,7 +373,7 @@ function assertReviewConcurrency(relativeFile, workflow) { } if (job.concurrency?.group !== group || job.concurrency?.['cancel-in-progress'] !== cancelInProgress) { - throw new Error(`${relativeFile} job ${jobId} must use its workflow-specific branch group, avoid cross-canceling the other event owner, cancel superseded runs, and queue pull_request edited events without preempting an active review.`); + throw new Error(`${relativeFile} job ${jobId} must use its workflow-specific branch group, avoid cross-canceling the other event owner, and cancel superseded runs.`); } } } @@ -389,6 +388,26 @@ function assertDirectEventTriggers(file, workflow) { if (!triggers.pull_request || !triggers.pull_request_review) { throw new Error(`${relativeFile} must define direct pull_request and pull_request_review triggers.`); } + const pullRequestTypes = triggers.pull_request.types; + if (!Array.isArray(pullRequestTypes) + || pullRequestTypes.includes('edited') + || ['opened', 'reopened', 'closed', 'synchronize'].some(type => !pullRequestTypes.includes(type))) { + throw new Error(`${relativeFile} must handle code/lifecycle PR events without subscribing to metadata-only edited events.`); + } + if (typeof workflow['run-name'] !== 'string' + || !workflow['run-name'].includes('github.event_name') + || !workflow['run-name'].includes('github.event.action')) { + throw new Error(`${relativeFile} must expose the event kind and action in its run identity.`); + } + const pullRequestJobName = workflow.jobs?.['copilot-pull-requests']?.name; + const mergeQueueJobName = workflow.jobs?.['copilot-merge-group']?.name; + const expectedPullRequestJobName = "${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }}"; + if (pullRequestJobName !== expectedPullRequestJobName) { + throw new Error(`${relativeFile} must give review-state events their exact distinct check identity while preserving the normal PR analysis identity.`); + } + if (mergeQueueJobName !== 'Copilot - Pull Request') { + throw new Error(`${relativeFile} must preserve the Copilot - Pull Request required-check identity for merge-group runs; the run name provides event distinction.`); + } } function assertExactNeeds(relativeFile, jobId, job, expected) { diff --git a/setup/pull_request_template.md b/setup/pull_request_template.md index 3d7b7e60f..522d0bbb7 100644 --- a/setup/pull_request_template.md +++ b/setup/pull_request_template.md @@ -1,144 +1,18 @@ -# ๐Ÿ“Œ Summary - - ---- - -## ๐ŸŽฏ Related Issues / Tickets - -- Closes # -- Related to # - ---- +## What changed -## ๐Ÿงฉ Scope of Changes - -- Added: -- Updated: -- Removed: -- Refactored: - ---- - -## ๐Ÿ› ๏ธ Technical Details - - ---- - -## ๐Ÿ” How to Test - -1. -2. -3. - ---- - -## ๐Ÿงช Test Coverage - -- [ ] Unit tests -- [ ] Integration tests -- [ ] End-to-end (E2E) tests -- [ ] Manual testing only (explain why) - ---- - -## ๐Ÿ“ธ Screenshots / Recordings (UI changes only) - +- ---- - -## โš ๏ธ Breaking Changes - -- None - ---- - -## ๐Ÿš€ Deployment Notes - -- [ ] Requires database migration -- [ ] Requires environment variable changes -- [ ] Requires feature flag toggle -- [ ] No special deployment steps - -Details: - ---- - -## ๐Ÿ”’ Security Considerations - -- [ ] No security impact -- [ ] Input validation changes -- [ ] Authentication / authorization changes -- [ ] Sensitive data handling changes - ---- - -## ๐Ÿ“ˆ Performance Impact - -- [ ] No performance impact -- [ ] Improves performance -- [ ] Potential performance regression (explain) - ---- - -## ๐Ÿ“ Notes for Reviewers - - ---- - -## โœ… Checklist - -- [ ] I have self-reviewed my code -- [ ] Code follows project standards and conventions -- [ ] Tests have been added or updated -- [ ] Documentation has been updated (if applicable) -- [ ] No new warnings or lint errors -- [ ] Breaking contract changes and required consumer updates are documented +## Validation ---- +- -## ๐Ÿ“š Additional Context diff --git a/setup/workflows/copilot_pull_request.yml b/setup/workflows/copilot_pull_request.yml index 78873aba2..d78b7d5ad 100644 --- a/setup/workflows/copilot_pull_request.yml +++ b/setup/workflows/copilot_pull_request.yml @@ -1,8 +1,9 @@ name: Copilot - Pull Request +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: - types: [opened, reopened, edited, closed, synchronize] + types: [opened, reopened, closed, synchronize] pull_request_review: types: [submitted, edited, dismissed] merge_group: @@ -11,6 +12,8 @@ on: jobs: copilot-merge-group: if: ${{ github.event_name == 'merge_group' }} + # Keep the same required-check context as normal PR analysis. The run name + # above distinguishes merge_group:checks_requested in the Actions UI. name: Copilot - Pull Request runs-on: ubuntu-latest timeout-minutes: 10 @@ -23,12 +26,12 @@ jobs: copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} - name: Copilot - Pull Request + name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: ubuntu-latest timeout-minutes: 120 concurrency: group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' }} + cancel-in-progress: true permissions: checks: write contents: read diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 3a5ee6924..6b8ee74bc 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -13,15 +13,15 @@ debt or convert unknown historic intent into a design decision. | `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 148 paths ยท 2026-09-15 | | `release-orchestration` | Implemented | Release and hotfix promotion, publication, reconciliation, and durable recovery | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 52 paths ยท 2026-09-14 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 23 paths ยท 2026-09-14 | -| `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths ยท 2026-09-14 | -| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 82 paths ยท 2026-09-14 | -| `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 71 paths ยท 2026-09-14 | +| `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths ยท 2026-09-15 | +| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 82 paths ยท 2026-09-15 | +| `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 71 paths ยท 2026-09-15 | | `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 1 companion | 51 paths ยท 2026-09-14 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 21 paths ยท 2026-09-13 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 52 paths ยท 2026-09-15 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, analyze bounded evidence, publish stable findings, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 1 companion | 63 paths ยท 2026-09-13 | | `branch-synchronization` | As-built baseline | Observe parent drift and safely merge a parent branch into a linked working branch | [Branch synchronization and conflict recovery](./branch-synchronization-and-conflict-recovery.md) | 15 paths ยท 2026-09-11 | -| `pull-request-lifecycle` | As-built baseline | Link pull requests to issues and projects, synchronize metadata, reviewers, size, and descriptions | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 19 paths ยท 2026-09-13 | +| `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 35 paths ยท 2026-09-15 | | `agent-runtime` | Implemented | Resolve, provision, authenticate, authorize, and execute only the agent roles reachable by a run | [Agent runtime, provider, model, and role routing](./agent-runtime-provider-and-model-routing.md) + 1 companion | 51 paths ยท 2026-09-12 | | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 29 paths ยท 2026-09-15 | @@ -63,7 +63,7 @@ debt or convert unknown historic intent into a design decision. ### `bugbot-review-state-reconciliation` โ€” Bugbot review-state reconciliation - Owner: Copilot maintainers -- Last verified: 2026-09-14 +- Last verified: 2026-09-15 - Specifications: [`specs/bugbot-review-state-reconciliation.md`](./bugbot-review-state-reconciliation.md) - Workflows: [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) ยท [`src/application/usecases/steps/commit/bugbot/reconcile_bugbot_review_state_use_case.ts`](../src/application/usecases/steps/commit/bugbot/reconcile_bugbot_review_state_use_case.ts) ยท [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) ยท [`src/api.ts`](../src/api.ts) @@ -74,7 +74,7 @@ debt or convert unknown historic intent into a design decision. ### `execution-lifecycle` โ€” Execution admission, queueing, routing, and result publication - Owner: Copilot maintainers -- Last verified: 2026-09-14 +- Last verified: 2026-09-15 - Specifications: [`specs/execution-admission-queue-and-publication.md`](./execution-admission-queue-and-publication.md) ยท [`specs/execution-error-and-context-hardening.md`](./execution-error-and-context-hardening.md) ยท [`specs/execution-boundary-closure-audit.md`](./execution-boundary-closure-audit.md) ยท [`specs/push-and-single-action-context-hardening.md`](./push-and-single-action-context-hardening.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) ยท [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) ยท [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) ยท [`src/actions/common_action.ts`](../src/actions/common_action.ts) @@ -85,7 +85,7 @@ debt or convert unknown historic intent into a design decision. ### `architecture-quality-hardening` โ€” Architecture quality and scalability hardening - Owner: Copilot maintainers -- Last verified: 2026-09-14 +- Last verified: 2026-09-15 - Specifications: [`specs/architecture-quality-and-scalability-hardening.md`](./architecture-quality-and-scalability-hardening.md) ยท [`specs/issue-and-pull-request-context-hardening.md`](./issue-and-pull-request-context-hardening.md) - Workflows: [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/release_workflow.yml`](../.github/workflows/release_workflow.yml) ยท [`.github/workflows/hotfix_workflow.yml`](../.github/workflows/hotfix_workflow.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) ยท [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) ยท [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) @@ -151,13 +151,13 @@ debt or convert unknown historic intent into a design decision. ### `pull-request-lifecycle` โ€” Pull request lifecycle and enrichment - Owner: Copilot maintainers -- Last verified: 2026-09-13 +- Last verified: 2026-09-15 - Specifications: [`specs/pull-request-lifecycle-and-enrichment.md`](./pull-request-lifecycle-and-enrichment.md) -- Workflows: [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) -- Entrypoints: [`src/application/usecases/pull_request_use_case.ts`](../src/application/usecases/pull_request_use_case.ts) -- Core code: [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) ยท [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) ยท [`src/application/usecases/pull_request_workflow_steps.ts`](../src/application/usecases/pull_request_workflow_steps.ts) ยท [`src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts`](../src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts) ยท [`src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts) ยท [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) ยท [`src/domain/pull_request_description.ts`](../src/domain/pull_request_description.ts) ยท [`src/data/repository/pull_request/pull_request_lifecycle_repository.ts`](../src/data/repository/pull_request/pull_request_lifecycle_repository.ts) -- Tests: [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) ยท [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts) ยท [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) ยท [`src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts`](../src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts) -- User documentation: [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) ยท [`docs/pull-requests/ai-description.mdx`](../docs/pull-requests/ai-description.mdx) ยท [`docs/pull-requests/configuration.mdx`](../docs/pull-requests/configuration.mdx) +- Workflows: [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) +- Entrypoints: [`src/application/usecases/pull_request_use_case.ts`](../src/application/usecases/pull_request_use_case.ts) ยท [`src/actions/common_action.ts`](../src/actions/common_action.ts) +- Core code: [`src/application/usecases/execution/execution_issue_number_policy.ts`](../src/application/usecases/execution/execution_issue_number_policy.ts) ยท [`src/application/usecases/execution/setup_execution_workflow.ts`](../src/application/usecases/execution/setup_execution_workflow.ts) ยท [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) ยท [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) ยท [`src/application/usecases/pull_request_workflow_steps.ts`](../src/application/usecases/pull_request_workflow_steps.ts) ยท [`src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts`](../src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts) ยท [`src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts) ยท [`src/prompts/update_pull_request_description.ts`](../src/prompts/update_pull_request_description.ts) ยท [`src/application/policies/agent_response_schemas.ts`](../src/application/policies/agent_response_schemas.ts) ยท [`src/application/policies/pull_request_description_content_policy.ts`](../src/application/policies/pull_request_description_content_policy.ts) ยท [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) ยท [`src/domain/pull_request_description.ts`](../src/domain/pull_request_description.ts) ยท [`src/data/repository/pull_request/pull_request_lifecycle_repository.ts`](../src/data/repository/pull_request/pull_request_lifecycle_repository.ts) +- Tests: [`src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts`](../src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts) ยท [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) ยท [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) ยท [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) ยท [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts) ยท [`src/application/policies/__tests__/pull_request_description_content_policy.test.ts`](../src/application/policies/__tests__/pull_request_description_content_policy.test.ts) ยท [`src/prompts/__tests__/update_pull_request_description.test.ts`](../src/prompts/__tests__/update_pull_request_description.test.ts) ยท [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) ยท [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) ยท [`src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts`](../src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts) +- User documentation: [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) ยท [`docs/pull-requests/ai-description.mdx`](../docs/pull-requests/ai-description.mdx) ยท [`docs/pull-requests/configuration.mdx`](../docs/pull-requests/configuration.mdx) ยท [`docs/features.mdx`](../docs/features.mdx) ยท [`docs/bugbot/how-it-works.mdx`](../docs/bugbot/how-it-works.mdx) ยท [`docs/bugbot/configuration.mdx`](../docs/bugbot/configuration.mdx) ### `agent-runtime` โ€” Agent runtime, provider, model, and role routing diff --git a/specs/bugbot-review-state-reconciliation.md b/specs/bugbot-review-state-reconciliation.md index 1d70ff7e0..fc8d102ff 100644 --- a/specs/bugbot-review-state-reconciliation.md +++ b/specs/bugbot-review-state-reconciliation.md @@ -3,7 +3,7 @@ - Status: Implemented - Date: 2026-09-11 - Catalog capability ID: `bugbot-review-state-reconciliation` -- Last verified: 2026-09-14 on `develop`; generic localized presentation is verified by the catalog and publication contract tests +- Last verified: 2026-09-15 on `develop` plus PR workflow UX implementation branch - Owners: `vypdev/copilot` product and engineering maintainers - Scope: make every Bugbot pull-request surface present one coherent, current, recoverable finding state without erasing the historical review record. @@ -11,7 +11,8 @@ [original finding](https://github.com/vypdev/copilot/pull/358#discussion_r3972947434), [successful reconciliation run](https://github.com/vypdev/copilot/actions/runs/34537613448), [current Check Run](https://github.com/vypdev/copilot/runs/103074383528), - and [PR #363 concurrency evidence](https://github.com/vypdev/copilot/pull/363) + [PR #363 concurrency evidence](https://github.com/vypdev/copilot/pull/363), + and [PR #378 workflow-noise evidence](https://github.com/vypdev/copilot/pull/378) - Required review gates: product UX, architecture, testing, documentation, security/operations - Open decisions blocking readiness: none @@ -175,15 +176,15 @@ PR #367 exposed the remaining flaw: a paired `pull_request:synchronize` run canceled Commit run `34846885692` through the same native group after every job step had succeeded, leaving a misleading canceled conclusion. Running Bugbot from both event routes also made cancellation necessary only because ownership -was duplicated. The final contract therefore uses distinct `copilot-push-โ€ฆ` +was duplicated. The next correction therefore introduced distinct `copilot-push-โ€ฆ` and `copilot-pr-โ€ฆ` branch groups. The Commit route retains issue progress work, then Bugbot's read-only exact-head preflight validates whether an open same-repository PR owns the pushed branch. A match skips review-context loading and agent invocation; the PR synchronization event exclusively owns review for that head. This decision uses provider discovery rather than the absent -`pull_request` field on a push payload. PR metadata retains conditional -non-preemption within the PR-specific group, and fork PR execution remains -excluded by the same-repository workflow gate. +`pull_request` field on a push payload. At that stage PR metadata retained +conditional non-preemption within the PR-specific group, and fork PR execution +remained excluded by the same-repository workflow gate. The next head `0e039ae9` proved that ordering alone was insufficient. Review run `34756692307` published Check `103722773263` with truthful `partial` Bugbot @@ -231,6 +232,15 @@ reported through its native workflow and Job Summary. Generic publication now uses one explicit mode: `pull_request: edited` is `omit-metadata-only`, a real Bugbot result is `omit-feature-owned`, and other routes remain `publish`. +PR #378 exposed the final workflow-level noise: Copilot's own description update +still emitted redundant skipped `pull_request: edited` runs, and analysis, +review-state, and merge-queue jobs shared an ambiguous visible name. The current +contract excludes metadata-only edited events from the supplied PR workflow and +uses event/action run names plus a distinct review-state job identity. Normal PR +and merge-group jobs intentionally retain the same required-check context so +branch protection continues to resolve it. The quiet application publication +mode remains defense in depth for direct/API invocation. + ## 3. Actors, surfaces, and terminology | Actor | Goal | Entry point | Visible surfaces | @@ -346,9 +356,9 @@ Terms: 15. Final provider snapshot acquisition MUST verify the same pull-request head immediately before and after reading its surfaces. Missing or changed head evidence makes the run superseded and MUST produce no presentation writes. -16. Shipped PR workflows MUST serialize metadata edits on the review branch - key without letting those edits cancel an active `opened`, `reopened`, - `synchronize`, push, or review-triggered analysis. +16. Shipped PR workflows MUST exclude metadata-only edited events so they cannot + trigger or cancel `opened`, `reopened`, `synchronize`, push, or + review-triggered analysis. 17. Review evidence eligibility MUST be a pure application policy over semantic Result payloads; it MUST NOT query, copy, or merge a previous provider Check. @@ -357,7 +367,7 @@ Terms: | Stage | Current | Proposed | User/operator effect | |---|---|---|---| | Review starts | Native workflow is running | Native workflow remains the pending authority; last verified card remains explicitly historical | Cancellation cannot leave a custom current-state claim stuck in progress | -| Metadata edit during review | Edit can cancel or later hide analysis with a green metadata run | Edit waits; afterward its workflow/summary update without emitting `Copilot / Review` | Reviewed-head evidence remains latest by name | +| Metadata edit during review | Edit can cancel, hide, or duplicate analysis | No supplied PR workflow starts; the human/body edit remains visible | Reviewed-head evidence remains latest by name without run noise | | Findings detected | One review with โ€œactiveโ€ findings | One commit-scoped review snapshot plus current status block | History and current state are visually distinct | | Existing finding remains | Inline body refreshed | Inline body refreshed; origin review and status card use the same final projection | No counter drift | | Finding resolved | Inline marker and thread change | Marker changes first, thread follows, provider state is re-read, all projections update | Partial failures are retryable and visible | @@ -477,9 +487,9 @@ readiness. `unknown` is a system failure and fails the review regardless of replaceable revisions. On push, a read-only exact-head preflight MUST validate any open same-repository PR before Bugbot loads review context or invokes the agent; a validated match yields to the PR code-change event. The decision MUST - NOT read PR identity from the push payload. `pull_request: edited` uses the PR - group with cancellation disabled, so - it waits and cannot preempt an active review. Application freshness checks + NOT read PR identity from the push payload. The shipped PR workflow MUST NOT + subscribe to metadata-only `pull_request: edited`, so description/title + mutations cannot trigger or preempt review. Application freshness checks remain mandatory because API consumers and comment-triggered flows are not fully serialized by workflow YAML. - If cancellation happens after a durable mutation, the next run discovers the @@ -728,10 +738,10 @@ presentation pattern: - Existing bot-owned review bodies are adoptable only when ownership is proven by current bot author plus trusted child finding markers or trusted review-level finding markers. -- Shipped PR and commit workflows use the same normalized repository/branch - concurrency key. The PR workflow conditionally disables preemption only for - `pull_request: edited`; code/review events still cancel obsolete analysis. - The application still performs remote head checks. +- Shipped PR and commit workflows use distinct normalized repository/branch + concurrency keys. PR code/lifecycle and review-state events cancel obsolete + PR-lane work; metadata-only `pull_request: edited` is not subscribed. The + application still performs remote head checks. - The Review Check is single-purpose evidence. Metadata-only PR lifecycle runs publish no same-name Check and therefore cannot supersede the latest analyzed head in GitHub's latest-by-name rollup. Their native workflow check and Job @@ -1254,12 +1264,11 @@ examples should reuse the same fixtures as presentation tests where practical. superseded and does not update current-state surfaces. 16. Given cancellation after one durable mutation, then the last verified card remains truthful and the next run repairs the discovered drift. -17. Given duplicate same-head workflows, then shared workflow concurrency and +17. Given duplicate same-head workflows, then branch-scoped workflow concurrency and application idempotency prevent duplicate reviews/comments. -18. Given a maintainer or external automation emits `pull_request: edited` - while a `synchronize` review is active, then the edit waits on the same - branch group and the review completes for the current head before metadata - normalization continues. +18. Given a maintainer, external automation, or Copilot body update emits + `pull_request: edited`, then the supplied PR workflow starts no run and the + active `synchronize` review remains unaffected. 19. Given a response that omits a required nullable finding property or uses a removed resolution field, then strict native/local validation rejects the whole response and no finding lifecycle mutation runs. @@ -1512,11 +1521,11 @@ evidence. Action.** GitHub exposes the webhook but not a GitHub Actions trigger. The product documents native immediate feedback plus bounded convergence on the next supported event. -9. **Use unconditional cancellation for PR metadata edits โ€” rejected.** PR #363 - demonstrated that a maintainer-authored title/body update can cancel the - current-head review and leave a successful metadata-only run. Conditional - cancellation preserves one branch mutex without allowing that false-green - sequence. +9. **Queue PR metadata edits in the analysis workflow โ€” rejected.** PR #363 + demonstrated that unconditional cancellation can hide the current-head + review; conditional cancellation fixed correctness but PR #378 showed that + automated body updates still created redundant runs. The supplied workflow + now excludes `pull_request: edited` entirely. 10. **Read and merge the previous Review Check into metadata output โ€” rejected.** It adds provider reads and a stale read/write race while still allowing a non-review run to impersonate review evidence. Metadata runs publish no diff --git a/specs/catalog.json b/specs/catalog.json index 2560ddff0..10658c125 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -296,7 +296,7 @@ "status": "implemented", "scope": "Reconcile review snapshots, findings, threads, comments, and check conclusions", "owner": "Copilot maintainers", - "lastVerified": "2026-09-14", + "lastVerified": "2026-09-15", "specs": [ "specs/bugbot-review-state-reconciliation.md" ], @@ -373,7 +373,7 @@ "status": "implemented", "scope": "Shared GitHub Action lifecycle from event admission through durable user-facing results", "owner": "Copilot maintainers", - "lastVerified": "2026-09-14", + "lastVerified": "2026-09-15", "specs": [ "specs/execution-admission-queue-and-publication.md", "specs/execution-error-and-context-hardening.md", @@ -479,7 +479,7 @@ "status": "implemented", "scope": "Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order", "owner": "Copilot maintainers", - "lastVerified": "2026-09-14", + "lastVerified": "2026-09-15", "specs": [ "specs/architecture-quality-and-scalability-hardening.md", "specs/issue-and-pull-request-context-hardening.md" @@ -878,41 +878,57 @@ { "id": "pull-request-lifecycle", "title": "Pull request lifecycle and enrichment", - "status": "as-built-baseline", - "scope": "Link pull requests to issues and projects, synchronize metadata, reviewers, size, and descriptions", + "status": "implemented", + "scope": "Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence", "owner": "Copilot maintainers", - "lastVerified": "2026-09-13", + "lastVerified": "2026-09-15", "specs": [ "specs/pull-request-lifecycle-and-enrichment.md" ], "workflows": [ - ".github/workflows/copilot_pull_request.yml" + ".github/workflows/copilot_pull_request.yml", + "setup/workflows/copilot_pull_request.yml" ], "entrypoints": [ - "src/application/usecases/pull_request_use_case.ts" + "src/application/usecases/pull_request_use_case.ts", + "src/actions/common_action.ts" ], "code": [ + "src/application/usecases/execution/execution_issue_number_policy.ts", + "src/application/usecases/execution/setup_execution_workflow.ts", "src/application/usecases/pull_request_workflow_context.ts", "src/application/usecases/pull_request_workflow.ts", "src/application/usecases/pull_request_workflow_steps.ts", "src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts", "src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts", + "src/prompts/update_pull_request_description.ts", + "src/application/policies/agent_response_schemas.ts", + "src/application/policies/pull_request_description_content_policy.ts", "src/infrastructure/composition/lifecycle_capability_port_binding.ts", "src/domain/pull_request_description.ts", "src/data/repository/pull_request/pull_request_lifecycle_repository.ts" ], "tests": [ + "src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts", + "src/data/model/__tests__/execution.test.ts", + "src/actions/__tests__/common_action.test.ts", "src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts", "src/application/usecases/__tests__/pull_request_use_case.test.ts", "src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts", "src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts", + "src/application/policies/__tests__/pull_request_description_content_policy.test.ts", + "src/prompts/__tests__/update_pull_request_description.test.ts", + "src/tooling/__tests__/validate_workflow_contract.test.ts", "src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts", "src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts" ], "documentation": [ "docs/pull-requests/capabilities.mdx", "docs/pull-requests/ai-description.mdx", - "docs/pull-requests/configuration.mdx" + "docs/pull-requests/configuration.mdx", + "docs/features.mdx", + "docs/bugbot/how-it-works.mdx", + "docs/bugbot/configuration.mdx" ] }, { diff --git a/specs/execution-admission-queue-and-publication.md b/specs/execution-admission-queue-and-publication.md index cdd6bcb0b..89c5b7070 100644 --- a/specs/execution-admission-queue-and-publication.md +++ b/specs/execution-admission-queue-and-publication.md @@ -1,11 +1,13 @@ # Execution Admission, Queueing, Routing, and Result Publication - Status: As-built baseline with implemented review-evidence ownership hardening -- Date: 2026-09-11 +- Date: 2026-09-15 +- Last verified: 2026-09-15 on `develop` plus PR workflow UX implementation branch - Owners: Copilot maintainers - Scope: the shared GitHub Action lifecycle from an incoming event to visible results and persisted execution state - Related issues/PRs: architecture quality and scalability hardening SDD; - [PR #363](https://github.com/vypdev/copilot/pull/363); historic motivation + [PR #363](https://github.com/vypdev/copilot/pull/363); + [PR #378](https://github.com/vypdev/copilot/pull/378); historic motivation before that live evidence is not recoverable from repository evidence - Required review gates: product UX, architecture, testing, documentation, security/operations - Open decisions blocking readiness: none for the baseline; see known debt and limitations @@ -55,8 +57,9 @@ create inconsistent authorization and failure behavior. 6. Results reconcile lifecycle/activity labels and are published to the target, Job Summary, optional semantic Check Run, and configuration marker as applicable. A PR result without exact-head Bugbot telemetry publishes no - `Copilot / Review` Check. A `pull_request: edited` result uses the native - workflow and Job Summary only, never a generic conversation comment. + `Copilot / Review` Check. The supplied PR workflow does not subscribe to + metadata-only `pull_request: edited`, so an automated body update cannot + create another admission/publication cycle. 7. The first executed result error, malformed canonical finding-state evidence, an unknown Bugbot state, or configured unresolved findings marks the Action failed. diff --git a/specs/issue-and-pull-request-context-hardening.md b/specs/issue-and-pull-request-context-hardening.md index c4cc5ac41..30b12a2ce 100644 --- a/specs/issue-and-pull-request-context-hardening.md +++ b/specs/issue-and-pull-request-context-hardening.md @@ -3,13 +3,14 @@ - Status: Implemented; all local gates pass, final review-evidence correction pending controlled live verification - Date: 2026-09-13 - Catalog capability ID: `architecture-quality-hardening` -- Last verified: 2026-09-13 on `develop` (P2-E implementation validation) +- Last verified: 2026-09-15 on `develop` plus PR workflow UX implementation branch - Owners: Copilot maintainers - Scope: complete P2-E by replacing issue and pull-request leaf access to the shared `Execution` aggregate and repository credentials with immutable capability requests and bound semantic ports - Related issues/PRs: parent architecture program, the managed issue and - pull-request lifecycle SDDs, and [PR #363](https://github.com/vypdev/copilot/pull/363) + pull-request lifecycle SDDs, [PR #363](https://github.com/vypdev/copilot/pull/363), + and [PR #378](https://github.com/vypdev/copilot/pull/378) - Required review gates: product UX, architecture, testing, documentation, security/operations, patch coverage, generated bundles - Open decisions blocking readiness: none @@ -29,20 +30,23 @@ requests. That operation MUST use a repository-owned URL/query, a durable hidden marker, ordered compensation, and truthful partial-state results. Event-provided URLs MUST never be fetched. -PR metadata normalization MUST share the PR-specific branch serialization -boundary without preempting an active code review. Commit uses a distinct -push-specific boundary. Its Bugbot path MUST perform a provider-backed, +PR code/lifecycle and review-state events MUST share the PR-specific branch +serialization boundary. The supplied workflow MUST NOT subscribe to +metadata-only `pull_request: edited`, because Copilot's own description update +would create a redundant run; human metadata edits remain untouched. Commit +uses a distinct push-specific boundary. Its Bugbot path MUST perform a provider-backed, exact-head, same-repository preflight and stop before review-context loading or agent invocation when that selection proves an open PR exists. It MUST NOT infer PR ownership from the push payload. PR synchronization then exclusively owns review for that head. -A newer PR review event MAY cancel an obsolete PR review; -`pull_request: edited` MUST wait and MUST NOT replace a real `synchronize` -analysis with a green metadata-only run. After it waits, the -metadata-only run MUST publish its own workflow outcome and Job Summary without -creating a generic result comment or a newer same-name `Copilot / Review` Check. -That Check name is reserved for a result carrying exactly one current-schema -Bugbot review telemetry snapshot for the exact head. +A newer PR or review-state event MAY cancel an obsolete PR run. PR analysis, +review-state observation, and merge-queue admission MUST expose distinct run +identities; review state MUST also use its own job/check name. Normal PR and +merge-group jobs MUST share the configured required-check name so GitHub can +satisfy the same branch-protection rule in both contexts. The `Copilot / Review` +Check name is reserved for a result +carrying exactly one current-schema Bugbot review telemetry snapshot for the +exact head. ```text validated issue/PR event @@ -60,10 +64,9 @@ PR link request synchronize review starts -> maintainer or external automation edits PR metadata - -> edited event waits on the same branch key - -> review finishes for the current head - -> newest pending metadata event runs without agent analysis - -> metadata Job Summary updates; reviewed-head Check remains authoritative + -> supplied workflow admits no edited event + -> review finishes for the current head unaffected + -> reviewed-head Check remains authoritative; no metadata run is created ``` ## 2. Problem, current behavior, and evidence @@ -146,8 +149,11 @@ misleading intermediate state. and maps bounded partial, skipped, or superseded review outcomes to neutral. - Metadata runs in that sequence also created generic discussion comments `5653191018` and `5653243319` containing only lifecycle/debug output. The - explicit result-publication mode now keeps `pull_request: edited` completion - in the workflow and Job Summary instead of accumulating conversation noise. + first result-publication correction kept `pull_request: edited` completion in + the workflow and Job Summary instead of accumulating conversation noise. +- PR #378 then showed that Copilot's own body update still created redundant + skipped edited-event runs. The current supplied workflow excludes that event; + the quiet application route remains defense in depth for direct invocation. - Review `5191003573` then proved the single-snapshot and finding-state readers could disagree about a malformed telemetry sibling. One discriminated telemetry-set projection now owns cardinality/schema validity for Review @@ -353,17 +359,16 @@ treated as compensation-required, not ordinary failure. ### 6.5 Workflow concurrency and event ordering -The Commit and Pull Request workflows use the same normalized repository/branch -group. Commit pushes plus PR `opened`, `reopened`, `synchronize`, `closed`, and -review events use cancel-in-progress behavior so newer review evidence replaces -obsolete work. A `pull_request: edited` job evaluates cancellation to false. It -therefore waits behind an active review instead of canceling it. GitHub's -single-pending behavior intentionally collapses multiple waiting metadata edits -to the newest event. Application head guards remain mandatory. +The Commit and Pull Request workflows use distinct normalized +repository/branch groups. Commit pushes plus PR `opened`, `reopened`, +`synchronize`, `closed`, and review-state events use cancel-in-progress behavior +within their own lane so newer evidence replaces obsolete work. The supplied PR +workflow excludes `pull_request: edited`, preventing body/title-only mutations +from entering either lane. Application head guards remain mandatory. This rule is identical in the repository workflow and the shipped setup copy. -It is not configurable because allowing metadata normalization to preempt code -analysis creates a false-green review surface. +It is not configurable because admitting self-generated metadata events creates +noise and can obscure code analysis. Completion applies a second, independent guard. The pure evidence policy emits `Copilot / Review` only when the result set contains exactly one current-schema, @@ -649,14 +654,12 @@ SDDs, catalog metadata, generated catalog, and bundles are updated together. and `invokeExplicit`; no compatibility symbol remains in source or bundle. 16. Given full validation, specs, catalog, public docs, workflows, package, bundles, coverage, architecture metrics, and Graphify agree. -17. Given a maintainer or external automation edits PR metadata while a - `synchronize` analysis is running, the edit waits, the analysis completes - for the current head, and the newest metadata event runs afterward without - invoking Bugbot analysis. -18. Given the later metadata-only run completes, it publishes its own Job Summary - and workflow conclusion but no generic result comment or `Copilot / Review`, - so the telemetry-bearing review Check for the same head remains the latest - authority by that name without durable conversation noise. +17. Given a maintainer, external automation, or Copilot edits PR metadata while + `synchronize` analysis is running, the supplied workflow admits no edited + event and the current-head analysis completes unaffected. +18. Given that metadata edit completes, no PR workflow, Job Summary, generic + result comment, or `Copilot / Review` is created, so the telemetry-bearing + review Check remains the latest authority without run or conversation noise. 19. Given Bugbot reports bounded `partial`, `skipped`, or `superseded` telemetry, the Review Check is neutral and names that outcome; it never claims success or whole-PR cleanliness. @@ -745,19 +748,20 @@ SDDs, catalog metadata, generated catalog, and bundles are updated together. methods; rejected overloads and deprecated aliases. - Decision: use distinct push and PR branch concurrency keys, make PR synchronization the sole Bugbot owner after a provider-backed exact-head - preflight discovers an open same-repository PR, and conditionally disable - PR-key preemption for - `pull_request: edited`. PR #363 proved unconditional metadata cancellation can - hide a missed review; PR #367 proved the shared push/PR key can cancel useful - completed push work. Separate event lanes are safe because only the PR lane - mutates Bugbot PR surfaces once a PR exists. + preflight discovers an open same-repository PR, and exclude + `pull_request: edited` from the supplied PR workflow. PR #363 proved + unconditional metadata cancellation can hide a missed review; PR #367 proved + the shared push/PR key can cancel useful completed push work; PR #378 showed + that an automated body update still generated redundant skipped PR runs. + Separate event lanes are safe because only the PR lane mutates Bugbot PR + surfaces once a PR exists. - Decision: reserve `Copilot / Review` for structurally valid Bugbot telemetry and skip metadata-only evidence instead of reading/merging prior Checks; rejected a second metadata Check with the same name and a provider read/write merge because both retain latest-by-name ambiguity or introduce stale races. - Decision: represent generic comment publication as an explicit mode and omit - it for `pull_request: edited`; rejected a second durable notification because - its workflow conclusion and Job Summary already provide recovery evidence. + metadata-only publication. The shipped workflow now prevents those events + earlier; API consumers remain quiet if they invoke the legacy route directly. - Provider reference: [GitHub Actions workflow concurrency](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency). - Follow-up outside P2-E: P2-F removes the remaining push/single-action leaf aggregate inputs; P2-G performs the final exact 16-file audit. diff --git a/specs/pull-request-lifecycle-and-enrichment.md b/specs/pull-request-lifecycle-and-enrichment.md index c9f3fae91..381ae6ee8 100644 --- a/specs/pull-request-lifecycle-and-enrichment.md +++ b/specs/pull-request-lifecycle-and-enrichment.md @@ -1,29 +1,32 @@ # Pull Request Lifecycle and Enrichment -- Status: As-built baseline -- Date: 2026-09-13 -- Last verified: 2026-09-13 on `develop` (P2-E implementation validation) +- Status: Implemented +- Date: 2026-09-15 +- Last verified: 2026-09-15 on `develop` plus PR UX implementation branch - Owners: Copilot maintainers - Scope: PR-to-issue/project linkage, assignments, metadata, size/progress, description ownership, review integration, and merge closure -- Related issues/PRs: managed issue lifecycle and Bugbot SDDs +- Related issues/PRs: managed issue lifecycle and Bugbot SDDs; live UX evidence from [PR #378](https://github.com/vypdev/copilot/pull/378) - Required review gates: product UX, architecture, testing, documentation, security/operations -- Open decisions blocking readiness: none for the baseline +- Open decisions blocking readiness: none ## 1. Executive summary -On an opened PR, Copilot links its issue and projects, assigns people, syncs -size/progress/priority metadata, optionally owns all or a marked section of the -description, and starts review. Synchronize events refresh enabled generated -content and review; edits normalize title; a merged PR closes the linked issue. -Human-authored body content is preserved only in `append`, `preserve`, or -`disabled` modeโ€”the recommended existing default is explicit full ownership -with `replace`. +On an opened PR, Copilot enriches the PR even when no issue is linked. When the +branch identifies a separate issue, Copilot also links and synchronizes that +issue; it never falls back to treating the PR number as the issue number. +Generated descriptions prioritize reviewer decisions: a short outcome, +material changes, verified validation evidence, and conditional review notes. +Synchronize events refresh enabled generated content and review; metadata-only +edits are ignored so Copilot's own body update cannot retrigger the pipeline. A +merged PR closes only a distinct linked issue. Human-authored body content is +preserved only in `append`, `preserve`, or `disabled` modeโ€”the existing default +is explicit full ownership with `replace`. ```text -opened PR -> link issue/project -> assign -> sync labels/size -> description -> review -synchronize -> description policy -> review -edited -> title normalization -merged -> close linked issue +opened/reopened -> resolve optional distinct issue -> enrich PR -> concise description -> review +synchronize -> concise description policy -> review +edited metadata -> no Copilot PR workflow +merged -> close distinct linked issue only ``` ## 2. Problem, current behavior, and evidence @@ -36,28 +39,35 @@ descriptions can also overwrite human content unless ownership is explicit. ### 2.2 Current behavior -1. The route resolves PR state and branch-linked issue context. +1. The route resolves PR state and optional branch-linked issue context. A + missing or self-equal issue number is represented as unlinked. 2. On open/reopen, the route snapshots separate immutable step requests; the sequential workflow updates title, assigns assignee and reviewers, links projects/issue, syncs size/progress labels, and checks priority size through repository- and credential-bound ports. -3. `replace` or `append` automatically generates a sanitized description from - PR branches/workspace diff and optional linked issue context. +3. `replace` or `append` automatically generates a sanitized, bounded + description from the merge-base diff and optional distinct issue context. 4. `replace` owns the body; `append` upserts a marker-bounded Copilot section; `preserve` updates only on authorized `/copilot description`; `disabled` never updates. 5. Open/reopen/synchronize run read-only review when configured/authorized. -6. Edited PRs update title only. Merged PRs close the linked issue. -7. Result publication, lifecycle labels, Job Summary, and optional Check Run expose state. +6. Metadata-only edited events do not start the supplied workflow. Merged PRs + close only a distinct linked issue. +7. Runs expose event/action identity and review-state events use a distinct job + name. Normal PR and merge-group jobs intentionally share the required-check + name so branch protection resolves the same context in both event paths. + Result publication, lifecycle labels, Job Summary, and optional Check Run + expose state. ### 2.3 Evidence and contract classification - Observed behavior: PR workflow/steps, description domain/workflow, PR - repositories/composition, workflow, tests, and documentation. + repositories/composition, workflow, tests, documentation, and PR #378. - Intentional contract: event-specific sequencing, semantic linkage, explicit body ownership modes, marker idempotency, creator/member guard, and merge closure. -- Known debt and limitations: issue number inference depends on branch naming or - GitHub links; assignment candidate quality depends on accessible membership; - generated-description quality is probabilistic; live responsive UX evidence is absent. +- Known debt and limitations: ordinary PR issue inference depends on branch + naming; assignment candidate quality depends on accessible membership; + generated-description quality is probabilistic; live responsive UX evidence + remains required for every shipped workflow change. - Unknown rationale: `replace` is the current default, but historic selection evidence is unavailable. - Proposed improvements: changing the recommended default requires migration and user study. @@ -80,6 +90,10 @@ body ownership. โ€œEnrichmentโ€ is metadata mutation that does not merge code. 1. Opened PRs MUST converge to one linked/enriched state on replay. 2. Description ownership MUST be explicit and preserve content per mode. 3. Merge-driven issue closure MUST use resolved linkage, not title guessing alone. +4. Unlinked PRs MUST retain PR-native enrichment without provider calls against + a synthetic issue number. +5. Generated descriptions MUST optimize for reviewer decisions, not execution + inventories or template completeness. ### 4.2 Non-goals @@ -93,15 +107,17 @@ body ownership. โ€œEnrichmentโ€ is metadata mutation that does not merge code. 2. Agent-generated Markdown MUST be sanitized before body update. 3. Append mode MUST replace only the managed section. 4. Missing branches/context MUST skip safely rather than guess. +5. A PR number MUST NOT be used as that PR's linked issue number. +6. Copilot-authored body edits MUST NOT trigger another supplied PR workflow. ## 5. Current versus proposed product journey | Stage | Manual/ambiguous risk | As-built contract | Effect | |---|---|---|---| -| Linkage | branch/title guess by human | branch + provider linkage | traceable issue | +| Linkage | PR number could become issue fallback | distinct branch issue or explicit unlinked state | no self-link/close | | Metadata | independent labels/projects | ordered synchronization | consistency | -| Body | implicit AI ownership | four explicit modes | predictable edits | -| Review | unrelated event timing | open/reopen/sync route | current evidence | +| Body | exhaustive template dump | concise evidence-based body in four ownership modes | faster review | +| Review | ambiguous run/check names and body-edit churn | event-specific run identity, distinct review-state check, merge-compatible required check | legible current evidence | | Closure | manual issue close | merged linked PR | aligned lifecycle | P2-E preserves the normal enrichment journey while hardening its authority and @@ -112,16 +128,22 @@ link mutations are compensated on every edge, and partial cleanup is explicit. ### 6.1 Happy path -1. Same-repository PR opens from a managed branch containing issue identity. -2. Copilot enriches linkage, people, projects, labels, size, and description. +1. Same-repository PR opens; its managed branch may contain a distinct issue identity. +2. Copilot enriches PR-native title, people, projects, description, and review; + it adds issue linkage and issue-derived priority/size/progress synchronization + only when a distinct issue exists. 3. Review result updates current status/lifecycle. 4. Synchronize reruns only refreshable content and review. 5. Merge closes the linked issue and exposes completion. ### 6.2 Alternative paths -- No linked issue still permits a description inferred from PR metadata/diff, - without adding a false `Closes` line. +- No linked issue still permits title/assignee/reviewer/project/review enrichment + and a description inferred from PR metadata/diff, without an issue-provider + call, issue-derived label synchronization, or false `Closes` line. +- A distinct linked issue with an empty description remains valid optional + context; description generation continues from PR metadata and diff. +- A branch number equal to the PR number is unlinked rather than self-linked. - `preserve` accepts only explicit authorized description refresh. - `disabled` skips both automatic and explicit body generation. - Non-member creators are skipped for AI description when `ai-members-only=true`. @@ -159,10 +181,12 @@ than duplicate content. A stale review is governed by the Bugbot freshness contr | size/priority/progress labels and thresholds | documented defaults | bounded numeric/label inputs | repository/workflow | | `ai-members-only` | `false` | boolean | per run | -Recommended configuration uses a PR template, one reviewer, `replace`, and -same-repository workflow guards. Teams preserving human prose use `append`. +Recommended configuration uses a concise PR template, one reviewer, `replace`, +and same-repository workflow guards. Teams preserving human prose use `append`. Marker syntax, sanitization, safe branch resolution, fork boundary, and merged -issue-closure semantics are not configurable. +issue-closure semantics are not configurable. The 12,000-character hard body +limit, normal 4,000-character target, no-self-link rule, and omission of +metadata-only edited triggers are fixed product/safety boundaries. ## 8. Clean Architecture design @@ -173,14 +197,17 @@ issue-closure semantics are not configurable. | Ports | PR details/body/link/project/reviewer/issue close | Octokit types | | Adapters | GitHub REST/GraphQL operations | event policy | | Composition | ordered concrete steps | duplicated business rules | -| Presentation | generated/sanitized body and result | provider mutations | +| Presentation | validate structured agent fields; sanitize and render the fixed body hierarchy | provider mutations | ```mermaid flowchart LR - E[PR event] --> U[PR workflow] - U --> D[Description/event policies] - U --> P[PR/issue/project/reviewer ports] + E[PR code or lifecycle event] --> R[Optional distinct issue resolver] + R --> U[PR workflow] + U --> D[Concise description/event policies] + U --> P[PR-native ports] + R -->|distinct issue only| I[Issue linkage/sync/closure ports] A[GitHub and agent adapters] --> P + A --> I U --> V[PR body/status presentation] ``` @@ -191,28 +218,80 @@ P2-E makes this boundary executable: issue/PR leaves import no `Execution` aggregate, application requests contain facts rather than credentials, and composition exposes operation-only bound ports. Automatic and explicit PR description generation share one discriminated request instead of dual methods. +The agent returns bounded semantic fields, not arbitrary body Markdown. A pure +application presentation policy validates cardinality, sentence count, +duplicates, optional notes, trusted linkage, unsafe Markdown controls, and the +hard body budget before rendering and before any provider write. ## 9. UI/UX and content contract +The default generated body is English and uses this information hierarchy: + ```markdown -Pending: **Copilot is enriching PR #84.** Linkage and review are still running. -Action required: **No linked issue was found.** Rename/link the branch if issue tracking is required. -Blocked: **This fork cannot run the privileged PR workflow.** No repository content was changed. -Partial: **Issue and labels linked; AI description failed.** Existing PR body was retained; retry the run. -Complete: **PR metadata and review are current.** The linked issue will close when this PR merges. +This change keeps unlinked pull requests usable and removes workflow noise caused +by Copilot updating its own description. + +## What changed + +- Continue PR-native enrichment when no distinct issue can be inferred. +- Prevent self-linking, self-closing references, and self-triggered body-edit runs. +- Make every event recognizable while preserving the normal PR required-check + context for merge-group admission. + +## Validation + +- `pnpm run test:coverage` +- `pnpm run validate:workflows` + +## Review notes + +Metadata-only PR edits no longer normalize titles automatically; human edits are preserved. ``` +The opening outcome and the first two sections are required. `Review notes` and a +distinct `Closes #โ€ฆ` reference appear only when supported. The normal target is +4,000 characters and the schema rejects more than 12,000. Empty template +sections, emoji, separators, generic checklists, file/use-case inventories, +repeated copy, and unverified test/no-impact claims are forbidden. + +| Trigger | Visible behavior | Must not appear | +|---|---|---| +| open/reopen | enrichment plus concise body and review | self-link, generic recap comment | +| synchronize | refresh owned body and exact-head review | duplicate card or stale review | +| metadata edit | no supplied Copilot PR run | body-edit cascade or title rewrite | +| review submitted/edited/dismissed | review-state job with distinct identity | full analysis masquerading under same check name | +| merge queue | event-specific run name plus lightweight required check | a renamed check that cannot satisfy branch protection | +| merged with distinct issue | close that issue | closure of the PR's own number | + The PR body follows configured ownership; append uses one stable section. -Result/status surfaces link issue, project where possible, current commit/review, -and next action. One primary status and action precede technical detail. English -fallback, descriptive links, logical headings, and text equivalents are required. +Result/status surfaces link the issue only when distinct, and expose project, +current commit/review, and next action where useful. English fallback, +descriptive links, logical headings, and text equivalents are required. Untrusted body/title/template/agent output and mentions are sanitized. +Operational copy belongs in the Job Summary or the stable review Check/card, +not in a new conversation recap. The English default examples below show the +minimum useful state; configured repository locale changes the surrounding copy +without changing stable machine-readable event, state, finding, or error codes. + +| State | When it appears | Representative message | +|---|---|---| +| pending | PR analysis has started and no decision exists yet | `Review in progress for 8f3c2d1. No action is required yet.` | +| action required | current actionable findings need an author response | `2 findings need attention. Fix or discuss them in the review threads, then push an update.` | +| blocked | required context or provider capability prevents safe progress | `PR description was not updated: the configured agent returned invalid structured content. The existing body was retained.` | +| partial | bounded coverage or cleanup retained named state | `Review covered 100 of 137 changed files. Results are incomplete; inspect the Job Summary before merging.` | +| complete | current head has a trustworthy clean result | `Review complete for 8f3c2d1. No actionable findings remain; maintainer review is next.` | + +Each message states the affected object or head, the material outcome, and one +next action only when a person must act. It omits completed-step inventories, +generic `Feature Actions`/`Automatic Actions` headings, decorative media, and +success comments that merely repeat native GitHub metadata. + ## 10. Failure, recovery, and cleanup | Failure | Impact | Retained facts | Retry | Action | Cleanup | |---|---|---|---|---|---| -| linkage absent | issue automation unavailable | PR unchanged otherwise | yes | link/rename | none | +| linkage absent | issue-specific automation unavailable | PR-native enrichment remains | optional | link/rename only if issue tracking is required | none | | linkage propagation fails, compensation succeeds | link may be incomplete | exact original body/base restored | yes | rerun | none | | linkage compensation is partial | PR may retain default base and/or temporary reference | result names each retained mutation | recovery-first rerun/manual | restore named state, rerun | never claim full restoration | | metadata provider fail | partial enrichment | successful fields | yes | rerun | idempotent upsert | @@ -236,32 +315,42 @@ links, lifecycle/activity/waiting labels, Job Summary, and optional Check Run provide user/operator evidence. A synchronize run correlates the current head. Optional capability absence is a visible skip; provider failure is not hidden as successful enrichment. Replays should not increase comment/body-section count. +The Actions run name includes event and action. Review-state observation has a +distinct check name, while normal PR analysis and merge-queue admission share +the exact required-check context deliberately; their run names provide the +human-visible distinction. ## 13. Compatibility, migration, rollout, and rollback Existing unmarked bodies are preserved in append/preserve/disabled and replaced only in replace. Existing marked append sections are updated in place. Mode changes are prospective: moving away from replace cannot recover overwritten -historic prose. Rollback restores body through GitHub history/manual edit; issue -closure is reversible by reopening, while merged code is not altered. +historic prose. Existing large templates remain valid inputs, but generated +output omits empty boilerplate. Repositories relying on title normalization from +`pull_request: edited` must move that policy to a separate workflow before +rollout; this deliberate compatibility change prevents body-edit recursion. +Rollback restores body through GitHub history/manual edit and may restore the +edited trigger; issue closure is reversible by reopening, while merged code is +not altered. ## 14. Testing strategy and numeric budget | Area | Minimum cases | Risks | |---|---:|---| -| Event/description/label policy | 22 | action matrix, modes, markers, bounds | -| Orchestration/idempotency | 18 | order, replay, partial, merge closure | +| Event/description/label policy | 26 | action matrix, optional/self linkage, modes, markers, bounds | +| Orchestration/idempotency | 20 | order, unlinked route, replay, partial, merge closure | | Provider/agent adapters | 14 | link/project/reviewer/body/errors | -| Workflow/config contracts | 10 | events, forks, permissions, inputs | -| PR UX/localization/sanitization | 14 | body/status/links/template/output | +| Workflow/config contracts | 14 | event exclusion, identity, forks, permissions, inputs | +| PR UX/localization/sanitization | 14 | concise body/status/links/template/output | | Integration/security/migration | 12 | PRโ†’issue close, mode changes, stale head | -| **Total** | **90** | no double counting | +| **Total** | **100** | no double counting | Global thresholds remain; description/marker policy SHOULD reach 100% branch coverage. The P2-E context/orchestration path enforces 95% lines/statements and 90% branches/functions. Tests use fake provider/agent results and semantic Markdown assertions. Manual evidence covers open/sync/merge on desktop/mobile, light/dark, screen -reader, all four body modes, and fork-visible messaging. +reader, all four body modes, fork-visible messaging, and an observed automatic +body mutation that produces no follow-up PR workflow. ## 15. Documentation and discoverability @@ -278,7 +367,8 @@ reader, all four body modes, and fork-visible messaging. 2. Synchronize refreshes enabled description/review without duplicating markers. 3. Replace owns the full body; append only its section; preserve needs explicit command; disabled performs no description update. -4. A PR without linked issue never invents a closing reference. +4. A PR without a distinct linked issue still runs PR-native enrichment and + never queries, links, labels, closes, or references its own PR number as an issue. 5. A disallowed actor/fork cannot invoke privileged writes or agent execution. 6. Partial description/provider failure retains completed facts and existing body. 7. A stale head publishes no stale review evidence. @@ -288,15 +378,26 @@ reader, all four body modes, and fork-visible messaging. positive safe PR number determine every linkage read/write. 11. Linkage compensation reports whether the temporary base, description reference, both, or neither remain, and replay never layers another marker. +12. A generated body is rendered from a strict structured response, starts with + a one-to-three sentence outcome, has two to six + material-change bullets and evidence-based validation, stays within 12,000 + characters, and omits empty/generic sections. +13. Copilot's own PR body update creates zero follow-up PR workflow runs. +14. Actions distinguish PR analysis, review-state observation, and merge-queue + admission without requiring log inspection; review state uses a distinct + check and merge queue preserves the normal PR required-check context. ## 17. Requirements traceability | Requirement | Owner | Evidence | Documentation | |---|---|---|---| | event sequence | PR workflow | PR use-case tests | capabilities | +| optional distinct linkage | execution issue policy and PR workflows | unlinked/self-link setup, link, sync, and close tests | capabilities | | body ownership | description domain/workflow | description tests | AI description | +| concise content | description prompt/schema/template | prompt/schema semantic tests and live PR body | AI description | | enrichment ports | PR/project/reviewer adapters | repository tests | configuration | | review integration | Bugbot contracts | Bugbot E2E | Bugbot docs | +| run/check identity and no edit churn | workflow template/validator | distributed workflow contract tests and live PR runs | features/Bugbot docs | | fork safety | workflow guards | workflow tests | workflow setup/security | | safe/recoverable linkage | exact-target adapter and compensation workflow | URL, identifier, marker, replay, and restoration-edge tests | capabilities/troubleshooting | | immutable authority boundary | PR contexts and lifecycle port binding | projection, mutation-isolation, binding, and zero-leaf AST tests | architecture/dependency rules | @@ -311,10 +412,11 @@ reader, all four body modes, and fork-visible messaging. ## 19. Definition of Done -- [ ] The 90-case budget, coverage, architecture, and workflow gates pass. +- [ ] The 100-case budget, coverage, architecture, and workflow gates pass. - [ ] Event order, replay, partial state, stale head, and merge closure are proven. - [ ] All body modes preserve their documented ownership and migration behavior. - [ ] UI states, accessibility, localization, sanitization, and noise pass. +- [ ] An unlinked PR and Copilot-authored description edit pass live UX verification without self-linkage or a follow-up PR workflow. - [ ] PR docs, Bugbot links, and catalog are current. - [ ] Human four-mode and fork UX evidence is captured. diff --git a/src/actions/__tests__/common_action.test.ts b/src/actions/__tests__/common_action.test.ts index c6607c79e..a2a22f1f9 100644 --- a/src/actions/__tests__/common_action.test.ts +++ b/src/actions/__tests__/common_action.test.ts @@ -456,6 +456,22 @@ describe('mainRun', () => { expect(mockSingleActionInvoke).not.toHaveBeenCalled(); }); + it('dispatches an unlinked pull request instead of treating it as a targetless action', async () => { + const execution = mockExecution({ + eventName: 'pull_request', + issueNumber: -1, + isPullRequest: true, + pullRequest: { number: 84, isPullRequest: true }, + }); + mockPullRequestInvoke.mockResolvedValue([new Result({ id: 'pr', success: true })]); + + const results = await runMain(execution); + + expect(mockPullRequestInvoke).toHaveBeenCalledWith(execution); + expect(mockSingleActionInvoke).not.toHaveBeenCalled(); + expect(results).toHaveLength(1); + }); + it('runs IssueCommentUseCase when isIssue and issue comment', async () => { const execution = mockExecution({ isIssue: true, diff --git a/src/actions/common_action.ts b/src/actions/common_action.ts index 0e9d9a367..4af42c214 100644 --- a/src/actions/common_action.ts +++ b/src/actions/common_action.ts @@ -73,7 +73,7 @@ export async function mainRun( return runTrackedRoute(execution, 'single-action', () => runTokenExecution(execution, routeHandlers), undefined, agentActivityUseCase); } - if (execution.issueNumber === -1) { + if (execution.issueNumber === -1 && !execution.isPullRequest) { return runTrackedRoute(execution, 'single-action', () => runNoIssueExecution(execution, routeHandlers), undefined, agentActivityUseCase); } diff --git a/src/application/policies/__tests__/agent_response_schemas.test.ts b/src/application/policies/__tests__/agent_response_schemas.test.ts index 08bacbc52..b885e4368 100644 --- a/src/application/policies/__tests__/agent_response_schemas.test.ts +++ b/src/application/policies/__tests__/agent_response_schemas.test.ts @@ -32,4 +32,20 @@ describe('production agent response schemas', () => { expect(schema.required).toContain('outputLocale'); expect(schema.properties.outputLocale).toMatchObject({ type: 'string', maxLength: 255 }); }); + + it('bounds pull-request descriptions as structured reviewer content', () => { + expect(PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA.properties.overview).toMatchObject({ + minLength: 1, + maxLength: 1_500, + }); + expect(PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA.properties.changes).toMatchObject({ + minItems: 2, + maxItems: 6, + }); + expect(PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA.properties.validation).toMatchObject({ + minItems: 1, + maxItems: 8, + }); + expect(PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA.required).toContain('closesLinkedIssue'); + }); }); diff --git a/src/application/policies/__tests__/pull_request_description_content_policy.test.ts b/src/application/policies/__tests__/pull_request_description_content_policy.test.ts new file mode 100644 index 000000000..ba06eaed2 --- /dev/null +++ b/src/application/policies/__tests__/pull_request_description_content_policy.test.ts @@ -0,0 +1,104 @@ +import { + MAX_PULL_REQUEST_DESCRIPTION_LENGTH, + renderPullRequestDescriptionContent, +} from '../pull_request_description_content_policy'; + +const content = (overrides: Record = {}) => ({ + outputLocale: 'en-US', + overview: 'This keeps pull requests concise. Reviewers can now find the relevant evidence quickly.', + whatChangedHeading: 'What changed', + changes: ['Removed empty boilerplate.', 'Kept material reviewer context.'], + validationHeading: 'Validation', + validation: ['`pnpm test`'], + reviewNotesHeading: null, + reviewNotes: null, + closesLinkedIssue: false, + ...overrides, +}); + +describe('pull request description content policy', () => { + it('renders the fixed information hierarchy without an empty optional section', () => { + expect(renderPullRequestDescriptionContent(content(), 'en-US')).toEqual({ + kind: 'valid', + markdown: [ + 'This keeps pull requests concise. Reviewers can now find the relevant evidence quickly.', + '## What changed\n\n- Removed empty boilerplate.\n- Kept material reviewer context.', + '## Validation\n\n- `pnpm test`', + ].join('\n\n'), + }); + }); + + it('renders localized headings, optional notes, and one trusted closing reference', () => { + const result = renderPullRequestDescriptionContent(content({ + outputLocale: 'es-ES', + overview: 'Este cambio reduce el ruido.', + whatChangedHeading: 'Quรฉ cambiรณ', + validationHeading: 'Validaciรณn', + reviewNotesHeading: 'Notas para revisiรณn', + reviewNotes: ['La ediciรณn de metadatos ya no inicia el workflow.'], + closesLinkedIssue: true, + }), 'es-ES', 42); + + expect(result).toMatchObject({ kind: 'valid' }); + expect(result.kind === 'valid' && result.markdown).toContain('## Quรฉ cambiรณ'); + expect(result.kind === 'valid' && result.markdown).toContain('Closes #42'); + }); + + it.each([ + ['missing fields', { validation: undefined }, 'shape'], + ['additional fields', { untrusted: 'value' }, 'shape'], + ['too few changes', { changes: ['Only one.'] }, 'shape'], + ['blank content', { changes: [' ', 'Material change.'] }, 'unsafe-markdown'], + ['notes without heading', { reviewNotes: ['Risk.'], reviewNotesHeading: null }, 'shape'], + ['heading without notes', { reviewNotes: null, reviewNotesHeading: 'Review notes' }, 'shape'], + ['closing without issue', { closesLinkedIssue: true }, 'shape'], + ['more than three overview sentences', { overview: 'One. Two. Three. Four.' }, 'sentence-count'], + ['more than three Japanese overview sentences', { overview: 'ไธ€ใคใงใ™ใ€‚ไบŒใคใงใ™ใ€‚ไธ‰ใคใงใ™ใ€‚ๅ››ใคใงใ™ใ€‚' }, 'sentence-count'], + ['heading injection', { changes: ['## Hidden section', 'Material change.'] }, 'unsafe-markdown'], + ['multiline heading injection', { changes: ['Material change.\n## Hidden section', 'Another change.'] }, 'unsafe-markdown'], + ['checkbox injection', { validation: ['- [x] Trust me'] }, 'unsafe-markdown'], + ['decorative emoji', { overview: 'This is concise ๐Ÿš€.' }, 'unsafe-markdown'], + ['duplicate changes', { changes: ['Same change.', 'same change.'] }, 'duplicate-item'], + ])('rejects %s', (_label, overrides, reason) => { + expect(renderPullRequestDescriptionContent(content(overrides), 'en-US')).toEqual({ + kind: 'invalid', + reason, + }); + }); + + it('rejects a body that exceeds the hard publication budget', () => { + const repeated = 'a'.repeat(999); + const result = renderPullRequestDescriptionContent(content({ + overview: 'b'.repeat(1_500), + changes: Array.from({ length: 6 }, (_, index) => `${index}${repeated}`), + validation: Array.from({ length: 8 }, (_, index) => `${index}${repeated}`), + }), 'en-US'); + + expect(result).toEqual({ kind: 'invalid', reason: 'body-too-long' }); + expect(MAX_PULL_REQUEST_DESCRIPTION_LENGTH).toBe(12_000); + }); + + it('keeps a deterministic sentence-limit fallback when Intl.Segmenter is unavailable', () => { + const originalSegmenter = (Intl as unknown as { Segmenter?: unknown }).Segmenter; + Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: undefined }); + try { + expect(renderPullRequestDescriptionContent(content({ + overview: 'One. Two. Three. Four.', + }), 'en-US')).toEqual({ kind: 'invalid', reason: 'sentence-count' }); + expect(renderPullRequestDescriptionContent(content({ + overview: 'A concise outcome without terminal punctuation', + }), 'en-US')).toMatchObject({ kind: 'valid' }); + } finally { + Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: originalSegmenter }); + } + }); + + it('neutralizes mentions and slash commands before rendering', () => { + const result = renderPullRequestDescriptionContent(content({ + changes: ['Notify @team.', '/deploy only after review.'], + }), 'en-US'); + + expect(result.kind === 'valid' && result.markdown).toContain('@\u200bteam'); + expect(result.kind === 'valid' && result.markdown).toContain('- \u200b/deploy'); + }); +}); diff --git a/src/application/policies/agent_response_schemas.ts b/src/application/policies/agent_response_schemas.ts index 5fab4e7cb..bd3c4ee4d 100644 --- a/src/application/policies/agent_response_schemas.ts +++ b/src/application/policies/agent_response_schemas.ts @@ -77,14 +77,49 @@ export const PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { type: 'object', properties: { outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, - description: { + overview: { type: 'string', minLength: 1, - maxLength: 60_000, - description: 'The complete Markdown pull-request description body.', + maxLength: 1_500, + description: 'One to three sentences describing the outcome and why it matters.', + }, + whatChangedHeading: { type: 'string', minLength: 1, maxLength: 100 }, + changes: { + type: 'array', + minItems: 2, + maxItems: 6, + items: { type: 'string', minLength: 1, maxLength: 1_000 }, + }, + validationHeading: { type: 'string', minLength: 1, maxLength: 100 }, + validation: { + type: 'array', + minItems: 1, + maxItems: 8, + items: { type: 'string', minLength: 1, maxLength: 1_000 }, + }, + reviewNotesHeading: { type: ['string', 'null'], minLength: 1, maxLength: 100 }, + reviewNotes: { + type: ['array', 'null'], + minItems: 1, + maxItems: 4, + items: { type: 'string', minLength: 1, maxLength: 1_000 }, + }, + closesLinkedIssue: { + type: 'boolean', + description: 'Whether this PR fully resolves the separate linked issue supplied by the application.', }, }, - required: ['outputLocale', 'description'], + required: [ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', + ], additionalProperties: false, } as const; diff --git a/src/application/policies/pull_request_description_content_policy.ts b/src/application/policies/pull_request_description_content_policy.ts new file mode 100644 index 000000000..65c036451 --- /dev/null +++ b/src/application/policies/pull_request_description_content_policy.ts @@ -0,0 +1,209 @@ +import { sanitizeAgentMarkdown } from './github_comment_publication_policy'; + +export const MAX_PULL_REQUEST_DESCRIPTION_LENGTH = 12_000; + +const CONTENT_KEYS = Object.freeze([ + 'outputLocale', + 'overview', + 'whatChangedHeading', + 'changes', + 'validationHeading', + 'validation', + 'reviewNotesHeading', + 'reviewNotes', + 'closesLinkedIssue', +]); +const SORTED_CONTENT_KEYS = Object.freeze([...CONTENT_KEYS].sort()); + +export type PullRequestDescriptionContent = { + readonly overview: string; + readonly whatChangedHeading: string; + readonly changes: readonly string[]; + readonly validationHeading: string; + readonly validation: readonly string[]; + readonly reviewNotesHeading: string | null; + readonly reviewNotes: readonly string[] | null; + readonly closesLinkedIssue: boolean; +}; + +export type PullRequestDescriptionContentResult = + | { readonly kind: 'valid'; readonly markdown: string } + | { + readonly kind: 'invalid'; + readonly reason: 'shape' | 'sentence-count' | 'unsafe-markdown' | 'duplicate-item' | 'body-too-long'; + }; + +/** Turns structured, untrusted agent content into one predictable review surface. */ +export function renderPullRequestDescriptionContent( + payload: Readonly>, + targetLocale: string, + linkedIssueNumber?: number, +): PullRequestDescriptionContentResult { + const parsed = parseContent(payload); + if (!parsed) return { kind: 'invalid', reason: 'shape' }; + const safeLinkedIssueNumber = typeof linkedIssueNumber === 'number' + && Number.isSafeInteger(linkedIssueNumber) + && linkedIssueNumber > 0 + ? linkedIssueNumber + : undefined; + if (parsed.closesLinkedIssue && safeLinkedIssueNumber === undefined) { + return { kind: 'invalid', reason: 'shape' }; + } + const rawContent = [ + parsed.overview, + parsed.whatChangedHeading, + parsed.validationHeading, + ...parsed.changes, + ...parsed.validation, + ...(parsed.reviewNotesHeading ? [parsed.reviewNotesHeading] : []), + ...(parsed.reviewNotes ?? []), + ]; + if (rawContent.some(hasForbiddenMarkdown)) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + + const overview = sanitizeBlock(parsed.overview); + const whatChangedHeading = sanitizeInline(parsed.whatChangedHeading); + const validationHeading = sanitizeInline(parsed.validationHeading); + const changes = parsed.changes.map(sanitizeInline); + const validation = parsed.validation.map(sanitizeInline); + const reviewNotesHeading = parsed.reviewNotesHeading === null + ? null + : sanitizeInline(parsed.reviewNotesHeading); + const reviewNotes = parsed.reviewNotes?.map(sanitizeInline) ?? null; + + const allContent = [ + overview, + whatChangedHeading, + validationHeading, + ...changes, + ...validation, + ...(reviewNotesHeading ? [reviewNotesHeading] : []), + ...(reviewNotes ?? []), + ]; + if (allContent.some(value => !value || hasForbiddenMarkdown(value))) { + return { kind: 'invalid', reason: 'unsafe-markdown' }; + } + if (sentenceCount(overview, targetLocale) > 3) { + return { kind: 'invalid', reason: 'sentence-count' }; + } + if (hasDuplicates(changes, targetLocale) + || hasDuplicates(validation, targetLocale) + || (reviewNotes && hasDuplicates(reviewNotes, targetLocale))) { + return { kind: 'invalid', reason: 'duplicate-item' }; + } + + const sections = [ + overview, + `## ${whatChangedHeading}\n\n${renderList(changes)}`, + `## ${validationHeading}\n\n${renderList(validation)}`, + ]; + if (reviewNotesHeading && reviewNotes) { + sections.push(`## ${reviewNotesHeading}\n\n${renderList(reviewNotes)}`); + } + if (parsed.closesLinkedIssue && safeLinkedIssueNumber !== undefined) { + sections.push(`Closes #${safeLinkedIssueNumber}`); + } + + const markdown = sections.join('\n\n'); + return markdown.length <= MAX_PULL_REQUEST_DESCRIPTION_LENGTH + ? { kind: 'valid', markdown } + : { kind: 'invalid', reason: 'body-too-long' }; +} + +function parseContent(payload: Readonly>): PullRequestDescriptionContent | undefined { + const keys = Object.keys(payload).sort(); + if (keys.length !== CONTENT_KEYS.length + || keys.some((key, index) => key !== SORTED_CONTENT_KEYS[index])) { + return undefined; + } + const changes = stringArray(payload.changes, 2, 6); + const validation = stringArray(payload.validation, 1, 8); + if (typeof payload.overview !== 'string' + || payload.overview.length > 1_500 + || typeof payload.whatChangedHeading !== 'string' + || payload.whatChangedHeading.length > 100 + || typeof payload.validationHeading !== 'string' + || payload.validationHeading.length > 100 + || !changes + || !validation + || typeof payload.closesLinkedIssue !== 'boolean') { + return undefined; + } + let reviewNotes: readonly string[] | null; + let reviewNotesHeading: string | null; + if (payload.reviewNotes === null) { + if (payload.reviewNotesHeading !== null) return undefined; + reviewNotes = null; + reviewNotesHeading = null; + } else { + const parsedReviewNotes = stringArray(payload.reviewNotes, 1, 4); + if (!parsedReviewNotes + || typeof payload.reviewNotesHeading !== 'string' + || payload.reviewNotesHeading.length > 100) return undefined; + reviewNotes = parsedReviewNotes; + reviewNotesHeading = payload.reviewNotesHeading; + } + return { + overview: payload.overview, + whatChangedHeading: payload.whatChangedHeading, + changes, + validationHeading: payload.validationHeading, + validation, + reviewNotesHeading, + reviewNotes, + closesLinkedIssue: payload.closesLinkedIssue, + }; +} + +function stringArray(value: unknown, minimum: number, maximum: number): readonly string[] | undefined { + return Array.isArray(value) + && value.length >= minimum + && value.length <= maximum + && value.every(item => typeof item === 'string' && item.length <= 1_000) + ? value + : undefined; +} + +function sanitizeBlock(value: string): string { + return sanitizeAgentMarkdown(value, 1_500).trim().replace(/\s*\n\s*/gu, ' '); +} + +function sanitizeInline(value: string): string { + return sanitizeAgentMarkdown(value, 1_000) + .trim() + .replace(/^[-*+]\s+/u, '') + .replace(/\s+/gu, ' '); +} + +function hasForbiddenMarkdown(value: string): boolean { + return /\p{Extended_Pictographic}/u.test(value) + || /(^|\n)\s*#{1,6}\s/u.test(value) + || /(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*($|\n)/u.test(value) + || /(^|\n)\s*(?:[-*+]\s+)?\[[ xX]\]\s/u.test(value); +} + +function sentenceCount(value: string, locale: string): number { + const Segmenter = (Intl as typeof Intl & { + Segmenter?: new ( + locales?: string | readonly string[], + options?: { granularity: 'sentence' }, + ) => { segment(input: string): Iterable<{ segment: string }> }; + }).Segmenter; + if (Segmenter) { + return Array.from(new Segmenter(locale, { granularity: 'sentence' }).segment(value)) + .filter(part => part.segment.trim().length > 0) + .length; + } + const terminalGroups = value.match(/[.!?ใ€‚๏ผ๏ผŸ]+(?=\s|$)/gu)?.length ?? 0; + return Math.max(1, terminalGroups); +} + +function hasDuplicates(values: readonly string[], locale: string): boolean { + const normalized = values.map(value => value.toLocaleLowerCase(locale).trim()); + return new Set(normalized).size !== normalized.length; +} + +function renderList(values: readonly string[]): string { + return values.map(value => `- ${value}`).join('\n'); +} diff --git a/src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts b/src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts index 0bb5bd2df..2399aa1ec 100644 --- a/src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts +++ b/src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts @@ -66,6 +66,42 @@ describe('execution issue number policy', () => { expect(result.issueNumber).toBe(91); }); + it('does not treat an ordinary pull request as its own linked issue', () => { + expect(resolveEventIssueNumber(context({ + eventName: 'pull_request', + isPullRequest: true, + pullRequest: { number: 91, head: 'codex/pr-ux', base: 'develop' }, + })).issueNumber).toBeUndefined(); + + expect(resolveEventIssueNumber(context({ + eventName: 'pull_request', + isPullRequest: true, + pullRequest: { number: 91, head: 'feature/91-self-reference', base: 'develop' }, + })).issueNumber).toBeUndefined(); + }); + + it('does not treat a pull-request review-state event as its own linked issue', () => { + expect(resolveEventIssueNumber(context({ + eventName: 'pull_request_review', + isPullRequest: true, + pullRequest: { number: 91, head: 'codex/pr-ux', base: 'develop' }, + })).issueNumber).toBeUndefined(); + + expect(resolveEventIssueNumber(context({ + eventName: 'pull_request_review', + isPullRequest: true, + pullRequest: { number: 91, head: 'feature/42-work', base: 'develop' }, + })).issueNumber).toBe(42); + }); + + it('keeps a distinct branch issue as the ordinary pull-request linkage target', () => { + expect(resolveEventIssueNumber(context({ + eventName: 'pull_request', + isPullRequest: true, + pullRequest: { number: 91, head: 'feature/42-work', base: 'develop' }, + })).issueNumber).toBe(42); + }); + it('returns unresolved without provider calls for an invalid configured target', async () => { const result = await resolveSingleActionIssueNumber( context({ isSingleAction: true, singleAction: { diff --git a/src/application/usecases/execution/execution_issue_number_policy.ts b/src/application/usecases/execution/execution_issue_number_policy.ts index 471b262f6..38eb07fab 100644 --- a/src/application/usecases/execution/execution_issue_number_policy.ts +++ b/src/application/usecases/execution/execution_issue_number_policy.ts @@ -14,6 +14,12 @@ export function resolveEventIssueNumber(context: SetupExecutionContext): Executi else if (context.isPullRequest) { if (['check_suite', 'workflow_run'].includes(context.eventName)) { issueNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); + } else if (['pull_request', 'pull_request_review'].includes(context.eventName)) { + const pullRequestNumber = positiveIssueNumberOrUndefined(context.pullRequest.number); + const branchIssueNumber = positiveIssueNumberOrUndefined( + extractIssueNumberFromBranch(context.pullRequest.head), + ); + issueNumber = branchIssueNumber === pullRequestNumber ? undefined : branchIssueNumber; } else { issueNumber = positiveIssueNumberOrUndefined(extractIssueNumberFromBranch(context.pullRequest.head)) ?? positiveIssueNumberOrUndefined(context.pullRequest.number); diff --git a/src/application/usecases/execution/setup_execution_workflow.ts b/src/application/usecases/execution/setup_execution_workflow.ts index 7b79a309b..61af3076f 100644 --- a/src/application/usecases/execution/setup_execution_workflow.ts +++ b/src/application/usecases/execution/setup_execution_workflow.ts @@ -33,7 +33,9 @@ export async function runSetupExecution( setGlobalLoggerDebug(context.debug, context.local); const tokenUser = await loadTokenUser(context, dependencies.organizationSetupPort); const issueResolution = await resolveExecutionIssueNumber(context, dependencies.issueSetupPort); - if (issueResolution.issueNumber === undefined) { + const canConfigureUnlinkedPullRequest = context.isPullRequest + && positiveIssueNumberOrUndefined(context.pullRequest.number) !== undefined; + if (issueResolution.issueNumber === undefined && !canConfigureUnlinkedPullRequest) { return { status: 'issue-unresolved', tokenUser, issueResolution }; } @@ -42,11 +44,13 @@ export async function runSetupExecution( issueResolution.issueNumber, dependencies.configurationPort, ); - const currentIssueLabels = await loadIssueLabels( - context, - issueResolution.issueNumber, - dependencies.issueSetupPort, - ); + const currentIssueLabels = issueResolution.issueNumber === undefined + ? [] + : await loadIssueLabels( + context, + issueResolution.issueNumber, + dependencies.issueSetupPort, + ); let release: SetupReleaseState = { ...context.release, active: currentIssueLabels.includes(context.labelNames.release), @@ -86,7 +90,7 @@ export async function runSetupExecution( }; let currentPullRequestLabels = [...context.currentPullRequestLabels]; - if (context.isIssue && !context.isSingleAction) { + if (context.isIssue && !context.isSingleAction && issueResolution.issueNumber !== undefined) { const resolution = await dependencies.branchVersionResolver.resolve({ issueNumber: issueResolution.issueNumber, release, @@ -163,7 +167,7 @@ async function loadTokenUser( async function loadPreviousConfiguration( context: SetupExecutionContext, - resolvedIssueNumber: number, + resolvedIssueNumber: number | undefined, configurationPort: SetupConfigurationQueryPort, ) { const issueNumber = configurationIssueNumber(context, resolvedIssueNumber); @@ -186,7 +190,7 @@ async function loadIssueLabels( function configurationIssueNumber( context: SetupExecutionContext, - resolvedIssueNumber: number, + resolvedIssueNumber: number | undefined, ): number | undefined { if (context.isSingleAction || context.isPush) return positiveIssueNumberOrUndefined(resolvedIssueNumber); if (context.isIssue) return positiveIssueNumberOrUndefined(context.issue.number); @@ -229,6 +233,6 @@ function setupState( }; } -function positiveIssueNumberOrUndefined(value: number): number | undefined { - return value > 0 && Number.isSafeInteger(value) ? value : undefined; +function positiveIssueNumberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' && value > 0 && Number.isSafeInteger(value) ? value : undefined; } diff --git a/src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts index 34ba22f12..d3409541b 100644 --- a/src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts @@ -44,8 +44,8 @@ describe('CloseIssueAfterMergingUseCase', () => { expect(results[0].executed).toBe(false); }); - it('does not call GitHub when a pull request has no linked issue', async () => { - const results = await useCase.invoke({ ...baseParam(), issueNumber: -1 } as unknown as Parameters[0]); + it.each([-1, 10, Number.MAX_SAFE_INTEGER + 1, Number.NaN])('does not call GitHub when a pull request has no separate safe linked issue: %s', async (issueNumber) => { + const results = await useCase.invoke({ ...baseParam(), issueNumber } as unknown as Parameters[0]); expect(results[0]).toMatchObject({ success: true, executed: false }); expect(mockCloseIssue).not.toHaveBeenCalled(); diff --git a/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts b/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts index 409ea474d..5ce760372 100644 --- a/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts +++ b/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts @@ -5,6 +5,7 @@ import { getTaskEmoji } from "../../../../utils/task_emoji"; import { ParamUseCase } from "../../base/param_usecase"; import { toApplicationError } from "../../../errors/application_error"; import type { CloseIssueAfterMergeContext } from '../../issue_workflow_context'; +import { parsePositiveSafeInteger } from '../../../../domain/positive_integer_policy'; export class CloseIssueAfterMergingUseCase implements ParamUseCase { taskId: string = 'CloseIssueAfterMergingUseCase'; @@ -15,7 +16,8 @@ export class CloseIssueAfterMergingUseCase implements ParamUseCase { expect(mockUpdateDescription).not.toHaveBeenCalled(); }); + it.each([ + { issueNumber: -1 }, + { issueNumber: 10 }, + ])('skips without provider I/O when no separate issue exists: %p', async (override) => { + const results = await useCase.invoke(context(override)); + + expect(results[0]).toMatchObject({ success: true, executed: false }); + expect(results[0].steps[0]).toContain('No separate linked issue'); + expect(mockGetDetails).not.toHaveBeenCalled(); + expect(mockIsLinked).not.toHaveBeenCalled(); + expect(mockUpdateBaseBranch).not.toHaveBeenCalled(); + expect(mockUpdateDescription).not.toHaveBeenCalled(); + }); + + it('rejects an invalid PR identity even when no separate issue exists', async () => { + const results = await useCase.invoke(context({ pullRequestNumber: Number.NaN, issueNumber: -1 })); + + expect(results[0]).toMatchObject({ success: false, executed: false }); + expect(results[0].steps[0]).toContain('positive pull-request number'); + expect(mockGetDetails).not.toHaveBeenCalled(); + }); + it('cleans an owned pending operation even when GitHub already reports the link', async () => { const pending = ''; mockGetDetails.mockResolvedValue({ body: `Original\n\nResolves #42\n\n${pending}`, baseBranch: 'main' }); @@ -235,7 +257,6 @@ describe('LinkPullRequestIssueUseCase', () => { }); it.each([ - { issueNumber: -1 }, { pullRequestNumber: Number.MAX_SAFE_INTEGER + 1 }, { defaultBranch: '../unsafe' }, { originalBaseBranch: '' }, diff --git a/src/application/usecases/steps/pull_request/__tests__/sync_size_and_progress_labels_from_issue_to_pr_use_case.test.ts b/src/application/usecases/steps/pull_request/__tests__/sync_size_and_progress_labels_from_issue_to_pr_use_case.test.ts index a35a464ce..2a936f2de 100644 --- a/src/application/usecases/steps/pull_request/__tests__/sync_size_and_progress_labels_from_issue_to_pr_use_case.test.ts +++ b/src/application/usecases/steps/pull_request/__tests__/sync_size_and_progress_labels_from_issue_to_pr_use_case.test.ts @@ -30,8 +30,8 @@ describe('SyncSizeAndProgressLabelsFromIssueToPrUseCase', () => { mockSetLabels.mockReset(); }); - it('returns executed false when no issue linked', async () => { - const param = baseParam({ issueNumber: -1 }); + it.each([-1, 100, Number.MAX_SAFE_INTEGER + 1, Number.NaN])('returns executed false when no separate safe issue is linked: %s', async (issueNumber) => { + const param = baseParam({ issueNumber }); const results = await useCase.invoke(param); diff --git a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts index 5b5077b2b..f80479dca 100644 --- a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts +++ b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts @@ -19,10 +19,25 @@ const mockGetDetails = jest.fn(); async function localizedDescription(value: Parameters[0]): Promise { const response = await mockAskAgent(value); return typeof response === 'string' - ? { outputLocale: 'en-US', description: response } + ? descriptionContent(response) : response; } +function descriptionContent(overview = 'PR does X.', overrides: Record = {}): Record { + return { + outputLocale: 'en-US', + overview, + whatChangedHeading: 'What changed', + changes: ['Kept the useful outcome.', 'Removed redundant workflow noise.'], + validationHeading: 'Validation', + validation: ['`pnpm test`'], + reviewNotesHeading: null, + reviewNotes: null, + closesLinkedIssue: false, + ...overrides, + }; +} + function context(overrides: Partial = {}): PullRequestDescriptionContext { const ai = new Ai('http://localhost:4096', 'model', false, [], false, 'low', 20); return { @@ -63,7 +78,7 @@ describe('UpdatePullRequestDescriptionUseCase', () => { ); mockGetIssueDescription.mockResolvedValue('Issue description'); mockGetAllMembers.mockResolvedValue(['alice', 'bob']); - mockAskAgent.mockResolvedValue('## Summary\nPR does X.'); + mockAskAgent.mockResolvedValue('PR does X.'); mockGetDetails.mockResolvedValue({ body: 'Remote human context', headBranch: 'feature/42-x', baseBranch: 'develop' }); mockUpdateDescription.mockResolvedValue(undefined); }); @@ -140,14 +155,37 @@ describe('UpdatePullRequestDescriptionUseCase', () => { expect(mockGetIssueDescription).not.toHaveBeenCalled(); }); - it('skips when the linked issue has no authoritative description', async () => { + it('treats a self-number as unlinked and forbids a self-closing reference', async () => { + const results = await useCase.invoke(request({ issueNumber: 10 })); + + expect(results[0].success).toBe(true); + expect(mockGetIssueDescription).not.toHaveBeenCalled(); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('no separate linked issue'); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('`closesLinkedIssue` to false'); + expect(mockAskAgent.mock.calls[0][0].prompt).not.toContain('issue #10'); + }); + + it.each([Number.MAX_SAFE_INTEGER + 1, Number.NaN])( + 'treats an unsafe issue number as unlinked: %s', + async (issueNumber) => { + const results = await useCase.invoke(request({ issueNumber })); + + expect(results[0].success).toBe(true); + expect(mockGetIssueDescription).not.toHaveBeenCalled(); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('no separate linked issue'); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('`closesLinkedIssue` to false'); + }, + ); + + it('uses PR metadata and diff when the linked issue has no description', async () => { mockGetIssueDescription.mockResolvedValue(undefined); const results = await useCase.invoke(request()); - expect(results[0]).toMatchObject({ success: false, executed: false }); - expect(mockAskAgent).not.toHaveBeenCalled(); - expect(mockUpdateDescription).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ success: true, executed: true }); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('No linked issue description is available'); + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('issue #42'); + expect(mockUpdateDescription).toHaveBeenCalled(); }); it('does not publish blank agent output', async () => { @@ -159,7 +197,7 @@ describe('UpdatePullRequestDescriptionUseCase', () => { it.each([ ['null response', null], - ['missing description', { outputLocale: 'en-US' }], + ['missing structured content', { outputLocale: 'en-US' }], ])('does not publish a %s from the agent', async (_label, response) => { mockAskAgent.mockResolvedValue(response); @@ -169,6 +207,25 @@ describe('UpdatePullRequestDescriptionUseCase', () => { expect(mockUpdateDescription).not.toHaveBeenCalled(); }); + it('retains the existing body when structured content violates the concise contract', async () => { + mockAskAgent.mockResolvedValue(descriptionContent('One. Two. Three. Four.')); + + const results = await useCase.invoke(request()); + + expect(results[0].errors[0]).toMatchObject({ code: 'agent.failed' }); + expect(results[0].steps[0]).toContain('concise description contract'); + expect(mockUpdateDescription).not.toHaveBeenCalled(); + }); + + it('renders a closing reference only from a valid distinct issue decision', async () => { + mockAskAgent.mockResolvedValue(descriptionContent('PR does X.', { closesLinkedIssue: true })); + + const results = await useCase.invoke(request({ issueNumber: 42 })); + + expect(results[0].success).toBe(true); + expect(mockUpdateDescription).toHaveBeenCalledWith(10, expect.stringContaining('Closes #42')); + }); + it('enforces members-only before invoking the agent', async () => { mockGetAllMembers.mockResolvedValue(['bob']); const results = await useCase.invoke(request({ membersOnly: true })); @@ -185,7 +242,7 @@ describe('UpdatePullRequestDescriptionUseCase', () => { }); it('uses the PR locale contract and rejects mismatched output before updating the body', async () => { - mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', description: '# Rรฉsumรฉ' }); + mockAskAgent.mockResolvedValue({ ...descriptionContent('Rรฉsumรฉ.'), outputLocale: 'fr-FR' }); const results = await useCase.invoke(request({ targetLocale: 'es-ES' })); diff --git a/src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts b/src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts index 2f3076698..2c3f6babd 100644 --- a/src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts +++ b/src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts @@ -30,15 +30,31 @@ export async function runLinkPullRequestIssue( port: BoundPullRequestIssueLinkPort, delay: EventualConsistencyDelayPort, ): Promise { - if (!parsePositiveSafeInteger(param.pullRequestNumber) - || !parsePositiveSafeInteger(param.issueNumber) - || !isSafeBranchTree(param.originalBaseBranch) + const pullRequestNumber = parsePositiveSafeInteger(param.pullRequestNumber); + const issueNumber = parsePositiveSafeInteger(param.issueNumber); + if (!pullRequestNumber) { + return [new Result({ + id: taskId, + success: false, + executed: false, + steps: ['Pull-request linkage requires a positive pull-request number.'], + })]; + } + if (!issueNumber || issueNumber === pullRequestNumber) { + return [new Result({ + id: taskId, + success: true, + executed: false, + steps: ['No separate linked issue was inferred; pull-request linkage was skipped.'], + })]; + } + if (!isSafeBranchTree(param.originalBaseBranch) || !isSafeBranchTree(param.defaultBranch)) { return [new Result({ id: taskId, success: false, executed: false, - steps: ['Pull-request linkage requires positive issue/PR numbers and safe non-empty base branches.'], + steps: ['Pull-request linkage requires a positive issue number and safe non-empty base branches.'], })]; } const pendingMarker = buildPendingMarker(param); diff --git a/src/application/usecases/steps/pull_request/sync_size_and_progress_labels_from_issue_to_pr_use_case.ts b/src/application/usecases/steps/pull_request/sync_size_and_progress_labels_from_issue_to_pr_use_case.ts index 36f7a667d..5574e7b2a 100644 --- a/src/application/usecases/steps/pull_request/sync_size_and_progress_labels_from_issue_to_pr_use_case.ts +++ b/src/application/usecases/steps/pull_request/sync_size_and_progress_labels_from_issue_to_pr_use_case.ts @@ -6,6 +6,7 @@ import { ParamUseCase } from "../../base/param_usecase"; import { mergeSizeAndProgressLabels, selectSizeAndProgressLabels } from './sync_size_and_progress_labels_policy'; import { toApplicationError } from '../../../errors/application_error'; import type { SyncPullRequestLabelsContext } from '../../pull_request_workflow_context'; +import { parsePositiveSafeInteger } from '../../../../domain/positive_integer_policy'; /** * Copies size and progress labels from the linked issue to the PR. @@ -22,7 +23,8 @@ export class SyncSizeAndProgressLabelsFromIssueToPrUseCase implements ParamUseCa const result: Result[] = []; try { - if (param.issueNumber === -1) { + const linkedIssueNumber = parsePositiveSafeInteger(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { logDebugInfo('No issue linked to this PR. Skipping sync of size/progress labels.'); result.push( new Result({ @@ -35,10 +37,10 @@ export class SyncSizeAndProgressLabelsFromIssueToPrUseCase implements ParamUseCa return result; } - const issueLabels = await this.issueLabelsPort.getLabels(param.issueNumber); + const issueLabels = await this.issueLabelsPort.getLabels(linkedIssueNumber); const sizeAndProgressFromIssue = selectSizeAndProgressLabels(issueLabels, param.sizeLabels); if (sizeAndProgressFromIssue.length === 0) { - logDebugInfo(`Issue #${param.issueNumber} has no size or progress labels. Nothing to sync.`); + logDebugInfo(`Issue #${linkedIssueNumber} has no size or progress labels. Nothing to sync.`); result.push( new Result({ id: this.taskId, @@ -55,7 +57,7 @@ export class SyncSizeAndProgressLabelsFromIssueToPrUseCase implements ParamUseCa const nextPrLabels = mergeSizeAndProgressLabels(prLabels, sizeAndProgressFromIssue, param.sizeLabels); await this.issueLabelsPort.setLabels(prNumber, nextPrLabels); - logDebugInfo(`Synced size/progress labels from issue #${param.issueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); + logDebugInfo(`Synced size/progress labels from issue #${linkedIssueNumber} to PR #${prNumber}: ${sizeAndProgressFromIssue.join(', ')}`); result.push( new Result({ diff --git a/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts b/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts index 72fd28fcb..08b129242 100644 --- a/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts +++ b/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts @@ -8,7 +8,6 @@ import { getUpdatePullRequestDescriptionPrompt } from '../../../../prompts'; import { logDebugInfo, logError, logInfo } from '../../../ports/logging_ports'; import { PROJECT_CONTEXT_INSTRUCTION } from '../../../../utils/project_context_instruction'; import { getTaskEmoji } from '../../../../utils/task_emoji'; -import { sanitizeAgentMarkdown } from '../../../../application/policies/github_comment_publication_policy'; import { mergeManagedPullRequestDescription, shouldAutomaticallyUpdatePullRequestDescription, @@ -21,6 +20,7 @@ import { productFacingAgentQueryOptions, validateAgentOutputLocale, } from '../../../policies/agent_output_locale_policy'; +import { renderPullRequestDescriptionContent } from '../../../policies/pull_request_description_content_policy'; import type { PullRequestDescriptionRequest, PullRequestDescriptionContext, @@ -63,12 +63,13 @@ export async function runUpdatePullRequestDescriptionWorkflow( logDebugInfo( `PR description will be generated from workspace diff: base "${branches.baseBranch}", head "${branches.headBranch}" (configured agent will run git diff).`, ); - const issueDescription = context.issueNumber > 0 - ? (await dependencies.issueDescriptionQueryPort.getDescription(context.issueNumber)) ?? '' + const inferredIssueNumber = parsePositiveSafeInteger(context.issueNumber); + const linkedIssueNumber = inferredIssueNumber !== context.pullRequest.number + ? inferredIssueNumber + : undefined; + const issueDescription = linkedIssueNumber + ? (await dependencies.issueDescriptionQueryPort.getDescription(linkedIssueNumber)) ?? '' : ''; - if (context.issueNumber > 0 && issueDescription.length === 0) { - return skipped(taskId, 'No issue description found. Skipping update pull request description.'); - } const currentProjectMembers = await dependencies.organizationMembersPort.getAllMembers(); const creatorIsTeamMember = context.pullRequest.creator.length > 0 @@ -84,11 +85,11 @@ export async function runUpdatePullRequestDescriptionWorkflow( projectContextInstruction: PROJECT_CONTEXT_INSTRUCTION, baseBranch: branches.baseBranch, headBranch: branches.headBranch, - issueNumber: context.issueNumber > 0 ? String(context.issueNumber) : 'not linked', + issueNumber: linkedIssueNumber ? String(linkedIssueNumber) : 'not linked', issueDescription: issueDescription || 'No linked issue description is available. Infer intent from the pull request title, body, and diff.', - relatedIssueInstruction: context.issueNumber > 0 - ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` - : 'Do not add a Closes line because this pull request has no linked issue.', + relatedIssueInstruction: linkedIssueNumber + ? `Set \`closesLinkedIssue\` to true only when this PR fully resolves issue #${linkedIssueNumber}; otherwise set it to false. Do not put the closing reference in another field.` + : 'Set `closesLinkedIssue` to false because this pull request has no separate linked issue.', targetLocale: context.targetLocale, }); logDebugInfo( @@ -103,15 +104,7 @@ export async function runUpdatePullRequestDescriptionWorkflow( PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA, ), }); - const generatedDescription = sanitizeAgentMarkdown(extractDescription(response, context.targetLocale)); - if (!generatedDescription.trim()) { - return [new Result({ - id: taskId, - success: false, - executed: true, - steps: ['Configured agent did not return a PR description.'], - })]; - } + const generatedDescription = extractDescription(response, context.targetLocale, linkedIssueNumber); const currentBody = details?.body ?? context.pullRequest.body; const pullRequestBody = context.mode === 'replace' @@ -164,13 +157,26 @@ async function loadPullRequestDetails( : undefined; } -function extractDescription(response: string | Record | undefined, targetLocale: string): string { - if (response == null) return ''; +function extractDescription( + response: string | Record | undefined, + targetLocale: string, + linkedIssueNumber?: number, +): string { + if (response == null) { + throw new ApplicationError('agent.failed', 'Configured agent did not return PR description content. Existing body retained.'); + } const validation = validateAgentOutputLocale(response, targetLocale); if (validation.kind === 'invalid') { throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); } - return typeof validation.payload.description === 'string' ? validation.payload.description : ''; + const rendered = renderPullRequestDescriptionContent(validation.payload, targetLocale, linkedIssueNumber); + if (rendered.kind === 'invalid') { + throw new ApplicationError( + 'agent.failed', + `Configured agent returned PR content that failed the concise description contract (${rendered.reason}). Existing body retained.`, + ); + } + return rendered.markdown; } function skipped(taskId: string, step: string): Result[] { diff --git a/src/data/model/__tests__/execution.test.ts b/src/data/model/__tests__/execution.test.ts index 7a12dcea3..d87d7c91d 100644 --- a/src/data/model/__tests__/execution.test.ts +++ b/src/data/model/__tests__/execution.test.ts @@ -525,6 +525,26 @@ describe('Execution', () => { expect(mockConfigGet).toHaveBeenCalledWith(314); }); + it('configures an unlinked pull request without using the PR as its own issue', async () => { + const pullRequest = makePullRequest({ + eventName: 'pull_request', + repo: { owner: 'owner', repo: 'repository' }, + pull_request: { number: 314, head: { ref: 'codex/pr-enrichment-ux' }, base: { ref: 'develop' } }, + } as never); + const e = buildExecution({ + eventName: 'pull_request', + repo: { owner: 'owner', repo: 'repository' }, + pull_request: { number: 314, head: { ref: 'codex/pr-enrichment-ux' }, base: { ref: 'develop' } }, + } as never, { pullRequest }); + + await setupExecution(e); + + expect(e.issueNumber).toBe(-1); + expect(mockConfigGet).toHaveBeenCalledWith(314); + expect(mockGetLabels).toHaveBeenCalledTimes(1); + expect(mockGetLabels).toHaveBeenCalledWith(314); + }); + it('sets up a PR conversation comment from its exact payload number', async () => { const e = buildExecution({ eventName: 'issue_comment', diff --git a/src/prompts/__tests__/update_pull_request_description.test.ts b/src/prompts/__tests__/update_pull_request_description.test.ts index 061d63eb3..e3cfc0c9c 100644 --- a/src/prompts/__tests__/update_pull_request_description.test.ts +++ b/src/prompts/__tests__/update_pull_request_description.test.ts @@ -1,23 +1,34 @@ import { getUpdatePullRequestDescriptionPrompt } from '../update_pull_request_description'; describe('getUpdatePullRequestDescriptionPrompt', () => { - it('fills all params and contains template instructions', () => { + it('fills all params and requires a concise evidence-based body', () => { const prompt = getUpdatePullRequestDescriptionPrompt({ projectContextInstruction: '**Use repo.**', baseBranch: 'main', headBranch: 'feature/123', issueNumber: '42', issueDescription: 'Add login screen.', - relatedIssueInstruction: 'Include `Closes #42` when relevant.', + relatedIssueInstruction: 'Set `closesLinkedIssue` for issue #42 only when fully resolved.', targetLocale: 'es-ES', }); expect(prompt).toContain('**Use repo.**'); expect(prompt).toContain('`main`'); expect(prompt).toContain('`feature/123`'); - expect(prompt).toContain('Closes #42'); + expect(prompt).toContain('`closesLinkedIssue` for issue #42'); expect(prompt).toContain('Add login screen.'); expect(prompt).toContain('pull_request_template.md'); - expect(prompt).toContain('git diff'); + expect(prompt).toContain('git diff main...feature/123'); + expect(prompt).toContain('`whatChangedHeading`'); + expect(prompt).toContain('never infer that result from the presence of test files or commands'); + expect(prompt).toContain('validation was not run or was not available'); + expect(prompt).toContain('`validationHeading`'); + expect(prompt).toContain('application renders the Markdown structure'); + expect(prompt).toContain('normally under 4,000 characters'); + expect(prompt).toContain('never exceed 12,000 characters'); + expect(prompt).toContain('Never claim a check passed unless the evidence says it did'); + expect(prompt).toContain('Do not reproduce empty placeholder sections'); + expect(prompt).not.toContain('full filled template'); + expect(prompt).toContain('Do not use emoji, horizontal separators, generic checklists'); expect(prompt).toContain('outputLocale` exactly as `es-ES'); expect(prompt).not.toContain('{{'); }); diff --git a/src/prompts/update_pull_request_description.ts b/src/prompts/update_pull_request_description.ts index a36e00c3d..1de72373b 100644 --- a/src/prompts/update_pull_request_description.ts +++ b/src/prompts/update_pull_request_description.ts @@ -1,9 +1,9 @@ /** - * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + * Prompt for generating a concise PR description from an optional issue and the diff. */ import { fillTemplate } from './fill'; -const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +const TEMPLATE = `You are in the repository workspace. Your task is to write a concise, review-ready pull request description from the branch diff and any linked issue. Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. @@ -14,20 +14,15 @@ Write every human-readable sentence in {{targetLocale}}. Preserve code identifie - **Head (source) branch:** \`{{headBranch}}\` **Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +1. Read \`.github/pull_request_template.md\` as content guidance and repository-specific constraints. Do not reproduce empty placeholder sections or treat every heading as mandatory. +2. Get the full merge-base diff with \`git diff {{baseBranch}}...{{headBranch}}\`. Use it to understand the behavior and contracts that changed. 3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** {{relatedIssueInstruction}} - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. +4. Provide \`overview\` as one to three sentences that state the outcome and why it matters. +5. Provide \`whatChangedHeading\` as the plain-text {{targetLocale}} equivalent of "What changed" and \`changes\` as two to six short, outcome-oriented items. Do not inventory files, use-case names, internal categories, or every implementation step. +6. Provide \`validationHeading\` as the plain-text {{targetLocale}} equivalent of "Validation" and \`validation\` with only commands, automated checks, or manual scenarios supported by available evidence. Never claim a check passed unless the evidence says it did, and never infer that result from the presence of test files or commands. When no execution evidence is available, say concisely in {{targetLocale}} that validation was not run or was not available. +7. Set \`reviewNotesHeading\` and \`reviewNotes\` to \`null\` unless reviewers need material migration, security, performance, compatibility, rollout, manual-verification, risk, or follow-up context. Otherwise use the localized plain-text heading and one to four concise items. {{relatedIssueInstruction}} +8. Keep the description practical and normally under 4,000 characters. It must never exceed 12,000 characters. Do not use emoji, horizontal separators, generic checklists, empty headings, repeated statements, placeholder text, or unsupported "no impact" claims. +9. Return one JSON object with exactly \`outputLocale\`, \`overview\`, \`whatChangedHeading\`, \`changes\`, \`validationHeading\`, \`validation\`, \`reviewNotesHeading\`, \`reviewNotes\`, and \`closesLinkedIssue\`. Every content field is plain text except Markdown links, code spans, refs, and commands inside content values. The application renders the Markdown structure; do not include headings, bullet prefixes, a preamble, meta-commentary, or code fence in the values. **Issue description:** {{issueDescription}} diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index fa593c30c..6f112d605 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -134,9 +134,7 @@ describe('workflow contract validator', () => { group: manifest.file === 'copilot_pull_request.yml' ? 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}' : 'copilot-push-${{ github.repository }}-${{ github.ref_name }}', - 'cancel-in-progress': manifest.file === 'copilot_pull_request.yml' - ? "${{ github.event_name != 'pull_request' || github.event.action != 'edited' }}" - : true, + 'cancel-in-progress': true, }); } else { expect(workflow.jobs[manifest.jobId].concurrency).toBeUndefined(); @@ -147,15 +145,13 @@ describe('workflow contract validator', () => { }); it.each(['.github/workflows', 'setup/workflows'])( - 'rejects pull-request metadata events that can preempt an active review in %s', + 'rejects pull-request workflows that do not cancel superseded code events in %s', (directory) => { const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; - workflow.jobs['copilot-pull-requests'].concurrency['cancel-in-progress'] = true; + workflow.jobs['copilot-pull-requests'].concurrency['cancel-in-progress'] = false; - expect(() => validateWorkflow(file, workflow)).toThrow( - 'queue pull_request edited events without preempting an active review', - ); + expect(() => validateWorkflow(file, workflow)).toThrow('cancel superseded runs'); }, ); @@ -217,6 +213,25 @@ describe('workflow contract validator', () => { } }); + it.each(['.github/workflows', 'setup/workflows'])( + 'rejects metadata-only PR triggers and unsafe PR check identities in %s', + (directory) => { + const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); + const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; + + workflow.on.pull_request.types.push('edited'); + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('metadata-only edited events'); + + workflow.on.pull_request.types = workflow.on.pull_request.types.filter((type: string) => type !== 'edited'); + workflow.jobs['copilot-pull-requests'].name = 'Copilot - Pull Request'; + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('review-state events'); + + workflow.jobs['copilot-pull-requests'].name = "${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }}"; + workflow.jobs['copilot-merge-group'].name = 'Copilot - Merge Queue'; + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('required-check identity'); + }, + ); + it.each(['.github/workflows', 'setup/workflows'])('requires the exact push review range fetch in %s', (directory) => { const file = path.join(process.cwd(), directory, 'copilot_commit.yml'); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; From 385f19d9e5290f59b05f6ff46c5c13f2dc843fc8 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 03:58:05 +0200 Subject: [PATCH 2/6] codex-pr-enrichment-ux: isolate merge queue check --- .github/workflows/copilot_pull_request.yml | 16 ------- .../copilot_pull_request_merge_queue.yml | 20 +++++++++ docs/bugbot/how-it-works.mdx | 2 +- docs/features.mdx | 2 +- docs/how-to-use.mdx | 1 + docs/pull-requests/capabilities.mdx | 3 ++ docs/pull-requests/examples.mdx | 31 +++++++++++++- docs/pull-requests/workflow-setup.mdx | 8 ++-- .../operations/troubleshooting.mdx | 2 +- scripts/validate-workflow-contract.cjs | 42 ++++++++++++++++--- setup/workflows/copilot_pull_request.yml | 16 ------- .../copilot_pull_request_merge_queue.yml | 20 +++++++++ specs/CATALOG.md | 14 +++---- specs/bugbot-review-state-reconciliation.md | 9 ++-- specs/catalog.json | 10 +++-- ...ssue-and-pull-request-context-hardening.md | 4 +- specs/merge-queue-readiness.md | 6 ++- .../pull-request-lifecycle-and-enrichment.md | 20 +++++---- .../setup_configuration_policy.test.ts | 7 ++-- .../__tests__/setup_workflow_catalog.test.ts | 10 +++++ src/domain/setup_workflow_catalog.ts | 1 + .../validate_workflow_contract.test.ts | 24 ++++++++--- 22 files changed, 192 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/copilot_pull_request_merge_queue.yml create mode 100644 setup/workflows/copilot_pull_request_merge_queue.yml diff --git a/.github/workflows/copilot_pull_request.yml b/.github/workflows/copilot_pull_request.yml index f8d52c4a5..817d036ab 100644 --- a/.github/workflows/copilot_pull_request.yml +++ b/.github/workflows/copilot_pull_request.yml @@ -6,24 +6,8 @@ on: types: [opened, reopened, closed, synchronize] pull_request_review: types: [submitted, edited, dismissed] - merge_group: - types: [checks_requested] jobs: - copilot-merge-group: - if: ${{ github.event_name == 'merge_group' }} - # Keep the same required-check context as normal PR analysis. The run name - # above distinguishes merge_group:checks_requested in the Actions UI. - name: Copilot - Pull Request - runs-on: [self-hosted, codex] - timeout-minutes: 10 - permissions: - checks: write - contents: read - steps: - - name: Confirm merge-group compatibility - run: echo "Copilot PR analysis already ran on each constituent pull request." - copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} diff --git a/.github/workflows/copilot_pull_request_merge_queue.yml b/.github/workflows/copilot_pull_request_merge_queue.yml new file mode 100644 index 000000000..810fcf431 --- /dev/null +++ b/.github/workflows/copilot_pull_request_merge_queue.yml @@ -0,0 +1,20 @@ +name: Copilot - Pull Request Merge Queue +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + merge_group: + types: [checks_requested] + +jobs: + copilot-pull-request-required-check: + # GitHub required checks match the job name. Keep this identical to normal + # PR analysis while isolating merge-group runs in their own workflow. + name: Copilot - Pull Request + runs-on: [self-hosted, codex] + timeout-minutes: 10 + permissions: + checks: write + contents: read + steps: + - name: Confirm merge-group compatibility + run: echo "Copilot PR analysis already ran on each constituent pull request." diff --git a/docs/bugbot/how-it-works.mdx b/docs/bugbot/how-it-works.mdx index 4fd3c77f2..98d5a3f09 100644 --- a/docs/bugbot/how-it-works.mdx +++ b/docs/bugbot/how-it-works.mdx @@ -81,7 +81,7 @@ This page describes the **internal flow** of Bugbot: how detection runs, how the 7. **Re-read and project:** After mutations, Bugbot verifies the PR head, re-reads linked-issue findings, reviews, child comments, native thread facts, and the PR conversation concurrently, and then verifies the head again. If the head changed while those reads were in flight, the whole snapshot is discarded as superseded. Each surface records whether its read was verified, failed, or not applicable, and each non-clean issue and PR destination must be observed independently; one visible or clean destination cannot hide another that is open, unverifiable, or missing. The human-discussion prompt includes only provider-classified human authors; comments and inline reviews authored by GitHub Apps or bot accounts do not consume its item or character budget. Pure lifecycle, provider-projection, and reconciliation-plan policies derive `open`, `reopened`, `fixed`, `obsolete`, `dismissed`, `verification-required`, or `unknown`. A dedicated presentation use case then updates at most 20 affected historical review blocks per run with bounded concurrency, upserts the oldest trusted **Bugbot status** card, and feeds the Result, lifecycle labels, Job Summary, Check Run, and telemetry. Missing or malformed owned evidence fails closed; it is never presented as clean. User-facing PR, commit, run, review, and finding links come only from authenticated provider adapters. Finding links are accepted only when they belong to the same HTTPS server and repository, including GitHub Enterprise installations. -Review and Commit workflow templates use distinct repository-and-branch concurrency keys. Each can cancel only a superseded run from the same event owner; a paired `push` and `pull_request:synchronize` therefore cannot cancel one another. Commit retains issue progress and native push work, then Bugbot resolves the branch with a read-only exact-head provider lookup. A validated open same-repository PR makes the push stop before loading review context or invoking the agent, because the PR synchronization event exclusively owns review for that head. This does not depend on PR identity being present in the `push` payload. Branches without an open PR still receive push-time, issue-targeted Bugbot review. Fork PR jobs remain excluded by the workflow's same-repository admission gate. Metadata-only `pull_request: edited` events are not subscribed, preventing Copilot's description update from starting another PR run. The run name includes the event and action, review-state events have their own check identity, and normal PR plus merge-group jobs retain the same required-check context for branch-protection compatibility. The `Copilot / Review` Check remains reserved for exactly one structurally valid analysis snapshot for the exact head; duplicate telemetry or a valid snapshot beside malformed telemetry is rejected as ambiguous. Application head guards and idempotent provider writes still protect partial/canceled transitions. Other durable mutation workflows retain their workflow-local queue. +Review and Commit workflow templates use distinct repository-and-branch concurrency keys. Each can cancel only a superseded run from the same event owner; a paired `push` and `pull_request:synchronize` therefore cannot cancel one another. Commit retains issue progress and native push work, then Bugbot resolves the branch with a read-only exact-head provider lookup. A validated open same-repository PR makes the push stop before loading review context or invoking the agent, because the PR synchronization event exclusively owns review for that head. This does not depend on PR identity being present in the `push` payload. Branches without an open PR still receive push-time, issue-targeted Bugbot review. Fork PR jobs remain excluded by the workflow's same-repository admission gate. Metadata-only `pull_request: edited` events are not subscribed, preventing Copilot's description update from starting another PR run. The run name includes the event and action, review-state events have their own check identity, and a dedicated merge-group workflow preserves the normal PR required-check context without adding a skipped duplicate to PR runs. The `Copilot / Review` Check remains reserved for exactly one structurally valid analysis snapshot for the exact head; duplicate telemetry or a valid snapshot beside malformed telemetry is rejected as ambiguous. Application head guards and idempotent provider writes still protect partial/canceled transitions. Other durable mutation workflows retain their workflow-local queue. Every exit path emits optional content-free telemetry, including canonical selection reason, logical and raw request counts, the fixed concurrency limit, diff --git a/docs/features.mdx b/docs/features.mdx index 7fabaa2e2..c20a152e3 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -162,7 +162,7 @@ are deprecated. **Two coordination policies:** durable mutation workflows preserve every admitted event in a workflow-local queue. Commit and Pull Request each have a separate repository/branch latest-revision lane, so paired events cannot cancel one another. -GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}` and Pull Request uses `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}`. Each cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting one event cancel useful work from the other. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check; normal PR and merge-group jobs deliberately share the exact required-check context for branch-protection compatibility. +GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}` and Pull Request uses `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}`. Each cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting one event cancel useful work from the other. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. For non-replaceable issue and comment mutations, Copilot adds an application-level queue per workflow: every started run waits for earlier active runs of that same workflow, so intermediate events are not discarded by a native concurrency group. Runs triggered by the PAT owner that would only re-trigger the normal pipeline complete before entering this queue. diff --git a/docs/how-to-use.mdx b/docs/how-to-use.mdx index 242ff24c6..d1af1709d 100644 --- a/docs/how-to-use.mdx +++ b/docs/how-to-use.mdx @@ -319,6 +319,7 @@ and roles: |------|--------| | `copilot_issue.yml` | Runs on issue events (opened, edited, labeled, unlabeled, etc.): creates branches, links to projects, assignees, deploy trigger. | | `copilot_pull_request.yml` | Runs on PR events: links PR to issue/project, reviewers, AI description, etc. | +| `copilot_pull_request_merge_queue.yml` | Reports the existing `Copilot - Pull Request` required-check context for `merge_group` without adding a skipped merge job to normal PR runs. | | `copilot_commit.yml` | Runs the issue-centric push pipeline (all branches except main/develop by default): native issue state and size/progress. It also runs Bugbot when the branch has no open PR; otherwise the PR synchronization event owns review. | | `copilot_branch_sync.yml` | Lightweight push observer on all branches: detects parent/working drift and maintains a synchronization notice without loading Bugbot or an agent. | | `copilot_issue_comment.yml` | Runs on explicitly addressed issue comments: e.g. Think action (answer questions). | diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index cc6d34997..4508bd072 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -114,6 +114,9 @@ without opening logs. Review-state events use the distinct `Copilot - Pull Request Review State` check. Normal analysis and merge-group runs intentionally share `Copilot - Pull Request`, preserving the exact required-check context that GitHub branch protection and merge queues expect. +The merge-group producer lives in the separate +`copilot_pull_request_merge_queue.yml` workflow, so normal PR runs do not show a +second skipped check. Metadata-only body/title edits do not start the PR workflow; new commits and lifecycle changes still do. diff --git a/docs/pull-requests/examples.mdx b/docs/pull-requests/examples.mdx index b6bb5cce9..84a2dd4b6 100644 --- a/docs/pull-requests/examples.mdx +++ b/docs/pull-requests/examples.mdx @@ -68,6 +68,35 @@ jobs: - **`desired-reviewers-count`**: Number of reviewers to assign (e.g. 1). - **`commit-prefix-transforms`**: Transforms for commit prefix derived from branch name (e.g. `replace-slash` for `feature/123` โ†’ `feature-123`). +## Merge queue companion workflow + +When branch protection requires `Copilot - Pull Request`, keep merge-group +support in a separate file so normal PR runs do not show a skipped duplicate: + +```yaml +name: Copilot - Pull Request Merge Queue +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + merge_group: + types: [checks_requested] + +jobs: + copilot-pull-request-required-check: + name: Copilot - Pull Request + runs-on: ubuntu-latest + permissions: + checks: write + contents: read + steps: + - name: Confirm merge-group compatibility + run: echo "Copilot PR analysis already ran on each constituent pull request." +``` + +The workflow name and run name make the merge-group event recognizable. The job +name deliberately matches normal PR analysis because GitHub required checks use +that exact context. + ## Example: AI PR description only Minimal workflow that only adds AI-generated PR description (no project linking): @@ -95,7 +124,7 @@ jobs: agent-model: ${{ vars.AGENT_MODEL || 'gpt-5.6-luna' }} ``` -A linked issue with a non-empty description enriches the generated result, but it is **optional**. PRs without a linked issue are supported; the branch diff and repository template still provide the description context. See [AI PR description](/pull-requests/ai-description). +A linked issue description enriches the generated result when present, but it is **optional**. Empty issue descriptions and PRs without a linked issue are supported; the PR metadata, branch diff, and repository template still provide context. See [AI PR description](/pull-requests/ai-description). ## Example: Project columns and reviewers diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index 2784056fe..cfa38fd24 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -92,9 +92,11 @@ jobs: for the exact head owns the `Copilot / Review` Check; partial, skipped, and superseded reviews are neutral rather than successful. - If the repository uses a merge queue, also keep the shipped - `merge_group: checks_requested` job named `Copilot - Pull Request`. Its run - name identifies the merge-group event while its unchanged job name satisfies - the same required-check rule as normal PR analysis. + `copilot_pull_request_merge_queue.yml` workflow. Its only + `merge_group: checks_requested` job is named `Copilot - Pull Request`, so the + event-specific run remains separate while the unchanged job name satisfies + the same required-check rule as normal PR analysis. Keeping it separate also + avoids a skipped duplicate check on every normal PR run. ## What runs when diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index abf33e767..8a1d73d37 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -77,7 +77,7 @@ This guide helps you resolve common issues you might encounter while using Copil external GitHub wait and has no release runner to extend. npm visibility has its own bounded polling interval and timeout. 2. **Network:** Check GitHub API status, verify network access, and monitor rate limits. - 3. **Triggers:** Verify event triggers, workflow conditions, and concurrent executions. A newer run may intentionally cancel an older run from the same workflow, but a Commit run and its paired Pull Request run must not cancel one another. When an open same-repository PR exists, Commit must skip Bugbot and `pull_request:synchronize` must own review for that head. The supplied PR workflow must not subscribe to metadata-only `pull_request: edited`; if an automatic description update starts another PR run, update the workflow template. Run names must expose event/action and review-state events must have their own check identity. Normal PR and merge-group jobs must retain the same `Copilot - Pull Request` context so required checks continue to resolve. If event owners cross-cancel, both run Bugbot for one head, append lifecycle/debug-only comments, or a newer ambiguous Check says `Bugbot review: โ€”`, update the shipped workflows and Action before trusting the result. Bounded partial, skipped, and superseded reviews should be neutral, not successful. + 3. **Triggers:** Verify event triggers, workflow conditions, and concurrent executions. A newer run may intentionally cancel an older run from the same workflow, but a Commit run and its paired Pull Request run must not cancel one another. When an open same-repository PR exists, Commit must skip Bugbot and `pull_request:synchronize` must own review for that head. The supplied PR workflow must not subscribe to metadata-only `pull_request: edited`; if an automatic description update starts another PR run, update the workflow template. Run names must expose event/action and review-state events must have their own check identity. The dedicated merge-group workflow must retain the same `Copilot - Pull Request` context so required checks continue to resolve without a skipped duplicate in normal PR runs. If event owners cross-cancel, both run Bugbot for one head, append lifecycle/debug-only comments, or a newer ambiguous Check says `Bugbot review: โ€”`, update the shipped workflows and Action before trusting the result. Bounded partial, skipped, and superseded reviews should be neutral, not successful. diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index e26b56971..91bd51ff5 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -394,19 +394,50 @@ function assertDirectEventTriggers(file, workflow) { || ['opened', 'reopened', 'closed', 'synchronize'].some(type => !pullRequestTypes.includes(type))) { throw new Error(`${relativeFile} must handle code/lifecycle PR events without subscribing to metadata-only edited events.`); } + if (triggers.merge_group || workflow.jobs?.['copilot-merge-group']) { + throw new Error(`${relativeFile} must keep merge-group compatibility in the dedicated pull-request merge-queue workflow so normal PR runs do not show a skipped duplicate check.`); + } if (typeof workflow['run-name'] !== 'string' || !workflow['run-name'].includes('github.event_name') || !workflow['run-name'].includes('github.event.action')) { throw new Error(`${relativeFile} must expose the event kind and action in its run identity.`); } const pullRequestJobName = workflow.jobs?.['copilot-pull-requests']?.name; - const mergeQueueJobName = workflow.jobs?.['copilot-merge-group']?.name; const expectedPullRequestJobName = "${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }}"; if (pullRequestJobName !== expectedPullRequestJobName) { throw new Error(`${relativeFile} must give review-state events their exact distinct check identity while preserving the normal PR analysis identity.`); } - if (mergeQueueJobName !== 'Copilot - Pull Request') { - throw new Error(`${relativeFile} must preserve the Copilot - Pull Request required-check identity for merge-group runs; the run name provides event distinction.`); +} + +function assertPullRequestMergeQueueWorkflow(file, workflow) { + const relativeFile = relativeWorkflow(file); + if (!relativeFile.endsWith('/copilot_pull_request_merge_queue.yml')) return; + const triggers = workflow.on ?? {}; + const job = workflow.jobs?.['copilot-pull-request-required-check']; + if (workflow.name !== 'Copilot - Pull Request Merge Queue' + || triggers.pull_request + || triggers.pull_request_review + || !Array.isArray(triggers.merge_group?.types) + || !triggers.merge_group.types.includes('checks_requested')) { + throw new Error(`${relativeFile} must support merge_group checks_requested in a dedicated workflow.`); + } + if (typeof workflow['run-name'] !== 'string' + || !workflow['run-name'].includes('github.event_name') + || !workflow['run-name'].includes('github.event.action')) { + throw new Error(`${relativeFile} must expose the merge-group event and action in its run identity.`); + } + if (!job || job.name !== 'Copilot - Pull Request') { + throw new Error(`${relativeFile} must preserve the Copilot - Pull Request required-check identity for merge-group runs.`); + } + if (Object.keys(workflow.jobs ?? {}).length !== 1 + || job['timeout-minutes'] !== 10 + || job.permissions?.checks !== 'write' + || job.permissions?.contents !== 'read' + || (job.steps ?? []).length !== 1 + || job.steps[0]?.name !== 'Confirm merge-group compatibility' + || typeof job.steps[0]?.run !== 'string' + || (job.steps ?? []).some(isCopilotAction)) { + throw new Error(`${relativeFile} must remain a single lightweight, least-privilege merge-group check.`); } } @@ -818,8 +849,8 @@ function assertMergeQueueWorkflowSupport(file, workflow) { const required = new Set([ '.github/workflows/ci_check.yml', '.github/workflows/repowise.yml', - '.github/workflows/copilot_pull_request.yml', - 'setup/workflows/copilot_pull_request.yml', + '.github/workflows/copilot_pull_request_merge_queue.yml', + 'setup/workflows/copilot_pull_request_merge_queue.yml', ]); if (!required.has(relativeFile)) return; const types = workflow.on?.merge_group?.types; @@ -895,6 +926,7 @@ function assertSequentialMutationWorkflow(file, workflow) { function validateWorkflow(file, workflow) { if (!workflow || typeof workflow !== 'object') throw new Error('workflow document is empty.'); assertDirectEventTriggers(file, workflow); + assertPullRequestMergeQueueWorkflow(file, workflow); assertRunner(file, workflow); assertSequentialMutationWorkflow(file, workflow); assertAgentInputs(file, workflow); diff --git a/setup/workflows/copilot_pull_request.yml b/setup/workflows/copilot_pull_request.yml index d78b7d5ad..b0e2a5c21 100644 --- a/setup/workflows/copilot_pull_request.yml +++ b/setup/workflows/copilot_pull_request.yml @@ -6,24 +6,8 @@ on: types: [opened, reopened, closed, synchronize] pull_request_review: types: [submitted, edited, dismissed] - merge_group: - types: [checks_requested] jobs: - copilot-merge-group: - if: ${{ github.event_name == 'merge_group' }} - # Keep the same required-check context as normal PR analysis. The run name - # above distinguishes merge_group:checks_requested in the Actions UI. - name: Copilot - Pull Request - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - checks: write - contents: read - steps: - - name: Confirm merge-group compatibility - run: echo "Copilot PR analysis already ran on each constituent pull request." - copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} diff --git a/setup/workflows/copilot_pull_request_merge_queue.yml b/setup/workflows/copilot_pull_request_merge_queue.yml new file mode 100644 index 000000000..0be12abd4 --- /dev/null +++ b/setup/workflows/copilot_pull_request_merge_queue.yml @@ -0,0 +1,20 @@ +name: Copilot - Pull Request Merge Queue +run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + merge_group: + types: [checks_requested] + +jobs: + copilot-pull-request-required-check: + # GitHub required checks match the job name. Keep this identical to normal + # PR analysis while isolating merge-group runs in their own workflow. + name: Copilot - Pull Request + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + checks: write + contents: read + steps: + - name: Confirm merge-group compatibility + run: echo "Copilot PR analysis already ran on each constituent pull request." diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 6b8ee74bc..b3d96ff86 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -12,16 +12,16 @@ debt or convert unknown historic intent into a design decision. |---|---|---|---|---| | `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 148 paths ยท 2026-09-15 | | `release-orchestration` | Implemented | Release and hotfix promotion, publication, reconciliation, and durable recovery | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 52 paths ยท 2026-09-14 | -| `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 23 paths ยท 2026-09-14 | +| `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 24 paths ยท 2026-09-15 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths ยท 2026-09-15 | | `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 82 paths ยท 2026-09-15 | -| `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 71 paths ยท 2026-09-15 | +| `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 72 paths ยท 2026-09-15 | | `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 1 companion | 51 paths ยท 2026-09-14 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 21 paths ยท 2026-09-13 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 52 paths ยท 2026-09-15 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, analyze bounded evidence, publish stable findings, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 1 companion | 63 paths ยท 2026-09-13 | | `branch-synchronization` | As-built baseline | Observe parent drift and safely merge a parent branch into a linked working branch | [Branch synchronization and conflict recovery](./branch-synchronization-and-conflict-recovery.md) | 15 paths ยท 2026-09-11 | -| `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 35 paths ยท 2026-09-15 | +| `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 37 paths ยท 2026-09-15 | | `agent-runtime` | Implemented | Resolve, provision, authenticate, authorize, and execute only the agent roles reachable by a run | [Agent runtime, provider, model, and role routing](./agent-runtime-provider-and-model-routing.md) + 1 companion | 51 paths ยท 2026-09-12 | | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 29 paths ยท 2026-09-15 | @@ -52,9 +52,9 @@ debt or convert unknown historic intent into a design decision. ### `merge-queue-readiness` โ€” Merge queue readiness and effective target rules - Owner: Copilot maintainers -- Last verified: 2026-09-14 +- Last verified: 2026-09-15 - Specifications: [`specs/merge-queue-readiness.md`](./merge-queue-readiness.md) -- Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) ยท [`.github/workflows/repowise.yml`](../.github/workflows/repowise.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) +- Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) ยท [`.github/workflows/repowise.yml`](../.github/workflows/repowise.yml) ยท [`.github/workflows/copilot_pull_request_merge_queue.yml`](../.github/workflows/copilot_pull_request_merge_queue.yml) ยท [`setup/workflows/copilot_pull_request_merge_queue.yml`](../setup/workflows/copilot_pull_request_merge_queue.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) - Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) ยท [`src/domain/merge_queue_readiness.ts`](../src/domain/merge_queue_readiness.ts) ยท [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) ยท [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) ยท [`src/application/policies/deployment_plan_policy.ts`](../src/application/policies/deployment_plan_policy.ts) ยท [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) ยท [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) ยท [`src/data/repository/deployment/github_target_merge_capabilities_inspector.ts`](../src/data/repository/deployment/github_target_merge_capabilities_inspector.ts) ยท [`src/infrastructure/github/octokit_checks_adapters.ts`](../src/infrastructure/github/octokit_checks_adapters.ts) ยท [`scripts/validate-workflow-contract.cjs`](../scripts/validate-workflow-contract.cjs) - Tests: [`src/domain/__tests__/merge_queue_readiness.test.ts`](../src/domain/__tests__/merge_queue_readiness.test.ts) ยท [`src/application/policies/__tests__/deployment_plan_policy.test.ts`](../src/application/policies/__tests__/deployment_plan_policy.test.ts) ยท [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) ยท [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) ยท [`src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts`](../src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts) ยท [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) @@ -87,7 +87,7 @@ debt or convert unknown historic intent into a design decision. - Owner: Copilot maintainers - Last verified: 2026-09-15 - Specifications: [`specs/architecture-quality-and-scalability-hardening.md`](./architecture-quality-and-scalability-hardening.md) ยท [`specs/issue-and-pull-request-context-hardening.md`](./issue-and-pull-request-context-hardening.md) -- Workflows: [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/release_workflow.yml`](../.github/workflows/release_workflow.yml) ยท [`.github/workflows/hotfix_workflow.yml`](../.github/workflows/hotfix_workflow.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) +- Workflows: [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_merge_queue.yml`](../.github/workflows/copilot_pull_request_merge_queue.yml) ยท [`.github/workflows/release_workflow.yml`](../.github/workflows/release_workflow.yml) ยท [`.github/workflows/hotfix_workflow.yml`](../.github/workflows/hotfix_workflow.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) ยท [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) ยท [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) - Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) ยท [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) ยท [`src/application/errors/application_error.ts`](../src/application/errors/application_error.ts) ยท [`src/application/errors/application_error_context.ts`](../src/application/errors/application_error_context.ts) ยท [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) ยท [`src/application/policies/bugbot_result_finding_state_projection_policy.ts`](../src/application/policies/bugbot_result_finding_state_projection_policy.ts) ยท [`src/application/policies/bugbot_telemetry_projection_policy.ts`](../src/application/policies/bugbot_telemetry_projection_policy.ts) ยท [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) ยท [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) ยท [`src/architecture/execution_import_baseline.json`](../src/architecture/execution_import_baseline.json) ยท [`src/api.ts`](../src/api.ts) ยท [`src/data/model/execution.ts`](../src/data/model/execution.ts) ยท [`src/application/usecases/actions/deployment_orchestration_use_case.ts`](../src/application/usecases/actions/deployment_orchestration_use_case.ts) ยท [`src/application/usecases/steps/commit/bugbot/load_bugbot_context_use_case.ts`](../src/application/usecases/steps/commit/bugbot/load_bugbot_context_use_case.ts) ยท [`src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) ยท [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) ยท [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) ยท [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) ยท [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) ยท [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) ยท [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) ยท [`src/application/usecases/steps/common/store_configuration_use_case.ts`](../src/application/usecases/steps/common/store_configuration_use_case.ts) ยท [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) ยท [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) ยท [`src/infrastructure/composition/lifecycle_state_composition_root.ts`](../src/infrastructure/composition/lifecycle_state_composition_root.ts) ยท [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) ยท [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) ยท [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) ยท [`src/application/policies/agent_execution/agent_execution_policy_dispatcher.ts`](../src/application/policies/agent_execution/agent_execution_policy_dispatcher.ts) - Tests: [`src/tooling/__tests__/coverage_budget_validator.test.ts`](../src/tooling/__tests__/coverage_budget_validator.test.ts) ยท [`src/__tests__/api.test.ts`](../src/__tests__/api.test.ts) ยท [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) ยท [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) ยท [`src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts) ยท [`src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts) ยท [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) ยท [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) ยท [`src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts) ยท [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) ยท [`src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts) ยท [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) ยท [`src/architecture/__tests__/execution_import_ratchet.test.ts`](../src/architecture/__tests__/execution_import_ratchet.test.ts) ยท [`src/architecture/__tests__/raw_error_logging_boundaries.test.ts`](../src/architecture/__tests__/raw_error_logging_boundaries.test.ts) ยท [`src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts`](../src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts) ยท [`src/application/usecases/steps/commit/bugbot/__tests__/load_bugbot_context_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/load_bugbot_context_use_case.test.ts) ยท [`src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts) ยท [`src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts`](../src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts) ยท [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) ยท [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) ยท [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) ยท [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) ยท [`src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts) ยท [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) ยท [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) ยท [`src/application/policies/__tests__/agent_execution_plan_policy.test.ts`](../src/application/policies/__tests__/agent_execution_plan_policy.test.ts) ยท [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) @@ -153,7 +153,7 @@ debt or convert unknown historic intent into a design decision. - Owner: Copilot maintainers - Last verified: 2026-09-15 - Specifications: [`specs/pull-request-lifecycle-and-enrichment.md`](./pull-request-lifecycle-and-enrichment.md) -- Workflows: [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) +- Workflows: [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_merge_queue.yml`](../.github/workflows/copilot_pull_request_merge_queue.yml) ยท [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) ยท [`setup/workflows/copilot_pull_request_merge_queue.yml`](../setup/workflows/copilot_pull_request_merge_queue.yml) - Entrypoints: [`src/application/usecases/pull_request_use_case.ts`](../src/application/usecases/pull_request_use_case.ts) ยท [`src/actions/common_action.ts`](../src/actions/common_action.ts) - Core code: [`src/application/usecases/execution/execution_issue_number_policy.ts`](../src/application/usecases/execution/execution_issue_number_policy.ts) ยท [`src/application/usecases/execution/setup_execution_workflow.ts`](../src/application/usecases/execution/setup_execution_workflow.ts) ยท [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) ยท [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) ยท [`src/application/usecases/pull_request_workflow_steps.ts`](../src/application/usecases/pull_request_workflow_steps.ts) ยท [`src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts`](../src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts) ยท [`src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts) ยท [`src/prompts/update_pull_request_description.ts`](../src/prompts/update_pull_request_description.ts) ยท [`src/application/policies/agent_response_schemas.ts`](../src/application/policies/agent_response_schemas.ts) ยท [`src/application/policies/pull_request_description_content_policy.ts`](../src/application/policies/pull_request_description_content_policy.ts) ยท [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) ยท [`src/domain/pull_request_description.ts`](../src/domain/pull_request_description.ts) ยท [`src/data/repository/pull_request/pull_request_lifecycle_repository.ts`](../src/data/repository/pull_request/pull_request_lifecycle_repository.ts) - Tests: [`src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts`](../src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts) ยท [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) ยท [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) ยท [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) ยท [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts) ยท [`src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts) ยท [`src/application/policies/__tests__/pull_request_description_content_policy.test.ts`](../src/application/policies/__tests__/pull_request_description_content_policy.test.ts) ยท [`src/prompts/__tests__/update_pull_request_description.test.ts`](../src/prompts/__tests__/update_pull_request_description.test.ts) ยท [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) ยท [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) ยท [`src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts`](../src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts) diff --git a/specs/bugbot-review-state-reconciliation.md b/specs/bugbot-review-state-reconciliation.md index fc8d102ff..1561ff01b 100644 --- a/specs/bugbot-review-state-reconciliation.md +++ b/specs/bugbot-review-state-reconciliation.md @@ -236,10 +236,11 @@ PR #378 exposed the final workflow-level noise: Copilot's own description update still emitted redundant skipped `pull_request: edited` runs, and analysis, review-state, and merge-queue jobs shared an ambiguous visible name. The current contract excludes metadata-only edited events from the supplied PR workflow and -uses event/action run names plus a distinct review-state job identity. Normal PR -and merge-group jobs intentionally retain the same required-check context so -branch protection continues to resolve it. The quiet application publication -mode remains defense in depth for direct/API invocation. +uses event/action run names plus a distinct review-state job identity. A +dedicated merge-group workflow removes the skipped duplicate from normal PR +runs while intentionally retaining the same required-check context so branch +protection continues to resolve it. The quiet application publication mode +remains defense in depth for direct/API invocation. ## 3. Actors, surfaces, and terminology diff --git a/specs/catalog.json b/specs/catalog.json index 10658c125..a62ea1b5d 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -252,14 +252,15 @@ "status": "implemented", "scope": "Fail-closed validation of required checks and merge-group workflow support", "owner": "Copilot maintainers", - "lastVerified": "2026-09-14", + "lastVerified": "2026-09-15", "specs": [ "specs/merge-queue-readiness.md" ], "workflows": [ ".github/workflows/ci_check.yml", ".github/workflows/repowise.yml", - ".github/workflows/copilot_pull_request.yml", + ".github/workflows/copilot_pull_request_merge_queue.yml", + "setup/workflows/copilot_pull_request_merge_queue.yml", ".github/workflows/copilot_deployment_orchestration.yml" ], "entrypoints": [ @@ -487,6 +488,7 @@ "workflows": [ ".github/workflows/copilot_commit.yml", ".github/workflows/copilot_pull_request.yml", + ".github/workflows/copilot_pull_request_merge_queue.yml", ".github/workflows/release_workflow.yml", ".github/workflows/hotfix_workflow.yml", ".github/workflows/copilot_deployment_orchestration.yml" @@ -887,7 +889,9 @@ ], "workflows": [ ".github/workflows/copilot_pull_request.yml", - "setup/workflows/copilot_pull_request.yml" + ".github/workflows/copilot_pull_request_merge_queue.yml", + "setup/workflows/copilot_pull_request.yml", + "setup/workflows/copilot_pull_request_merge_queue.yml" ], "entrypoints": [ "src/application/usecases/pull_request_use_case.ts", diff --git a/specs/issue-and-pull-request-context-hardening.md b/specs/issue-and-pull-request-context-hardening.md index 30b12a2ce..d63a6f192 100644 --- a/specs/issue-and-pull-request-context-hardening.md +++ b/specs/issue-and-pull-request-context-hardening.md @@ -43,7 +43,9 @@ A newer PR or review-state event MAY cancel an obsolete PR run. PR analysis, review-state observation, and merge-queue admission MUST expose distinct run identities; review state MUST also use its own job/check name. Normal PR and merge-group jobs MUST share the configured required-check name so GitHub can -satisfy the same branch-protection rule in both contexts. The `Copilot / Review` +satisfy the same branch-protection rule in both contexts. The merge-group job +MUST live in its own workflow so PR runs do not expose a skipped duplicate +check. The `Copilot / Review` Check name is reserved for a result carrying exactly one current-schema Bugbot review telemetry snapshot for the exact head. diff --git a/specs/merge-queue-readiness.md b/specs/merge-queue-readiness.md index 9522e85fa..ea8b2ce36 100644 --- a/specs/merge-queue-readiness.md +++ b/specs/merge-queue-readiness.md @@ -4,7 +4,7 @@ complete; clean-tree bundle validation and controlled live queue validation remain external completion gates - Date: 2026-09-10 -- Last updated: 2026-09-14 +- Last updated: 2026-09-15 - Owners: `vypdev/copilot` product and engineering maintainers - Scope: discover the effective merge policy of every deployment target and prevent a managed pull request from entering an unusable merge queue. @@ -102,6 +102,10 @@ longer exhibit these behaviors. - A local YAML contract spike mapped those contexts uniquely to `.github/workflows/ci_check.yml` and `.github/workflows/repowise.yml`; both explicitly declare `merge_group: checks_requested`. +- Copilot's own required PR context is produced for merge groups by the + dedicated `copilot_pull_request_merge_queue.yml` workflow. Keeping that + lightweight producer separate avoids a skipped duplicate check on every + normal PR while retaining the exact `Copilot - Pull Request` context. - `master` has no effective `merge_queue` rule and `develop` currently has no effective rules. Consequently PR #358 is not presently exposed to this failure, although the implementation defect remains real for configured diff --git a/specs/pull-request-lifecycle-and-enrichment.md b/specs/pull-request-lifecycle-and-enrichment.md index 381ae6ee8..95275bbbe 100644 --- a/specs/pull-request-lifecycle-and-enrichment.md +++ b/specs/pull-request-lifecycle-and-enrichment.md @@ -5,7 +5,7 @@ - Last verified: 2026-09-15 on `develop` plus PR UX implementation branch - Owners: Copilot maintainers - Scope: PR-to-issue/project linkage, assignments, metadata, size/progress, description ownership, review integration, and merge closure -- Related issues/PRs: managed issue lifecycle and Bugbot SDDs; live UX evidence from [PR #378](https://github.com/vypdev/copilot/pull/378) +- Related issues/PRs: managed issue lifecycle and Bugbot SDDs; live UX evidence from [PR #378](https://github.com/vypdev/copilot/pull/378) and [PR #379](https://github.com/vypdev/copilot/pull/379) - Required review gates: product UX, architecture, testing, documentation, security/operations - Open decisions blocking readiness: none @@ -53,8 +53,9 @@ descriptions can also overwrite human content unless ownership is explicit. 6. Metadata-only edited events do not start the supplied workflow. Merged PRs close only a distinct linked issue. 7. Runs expose event/action identity and review-state events use a distinct job - name. Normal PR and merge-group jobs intentionally share the required-check - name so branch protection resolves the same context in both event paths. + name. A separate merge-group workflow avoids a skipped duplicate while its + job intentionally shares the normal PR required-check name so branch + protection resolves the same context in both event paths. Result publication, lifecycle labels, Job Summary, and optional Check Run expose state. @@ -70,6 +71,10 @@ descriptions can also overwrite human content unless ownership is explicit. remains required for every shipped workflow change. - Unknown rationale: `replace` is the current default, but historic selection evidence is unavailable. - Proposed improvements: changing the recommended default requires migration and user study. +- Live iteration: PR #379's first run displayed a skipped merge-group job beside + the active PR job under the same check name. Merge-group compatibility moved + to a dedicated workflow so subsequent normal PR runs expose only the relevant + analysis/review-state job while merge groups retain the required context. ## 3. Actors, surfaces, and terminology @@ -260,7 +265,7 @@ repeated copy, and unverified test/no-impact claims are forbidden. | synchronize | refresh owned body and exact-head review | duplicate card or stale review | | metadata edit | no supplied Copilot PR run | body-edit cascade or title rewrite | | review submitted/edited/dismissed | review-state job with distinct identity | full analysis masquerading under same check name | -| merge queue | event-specific run name plus lightweight required check | a renamed check that cannot satisfy branch protection | +| merge queue | separate event-specific run plus lightweight required check | skipped duplicate on normal PRs or a renamed check that cannot satisfy branch protection | | merged with distinct issue | close that issue | closure of the PR's own number | The PR body follows configured ownership; append uses one stable section. @@ -317,8 +322,8 @@ Optional capability absence is a visible skip; provider failure is not hidden as successful enrichment. Replays should not increase comment/body-section count. The Actions run name includes event and action. Review-state observation has a distinct check name, while normal PR analysis and merge-queue admission share -the exact required-check context deliberately; their run names provide the -human-visible distinction. +the exact required-check context deliberately in separate workflows; their run +names provide the human-visible distinction without a skipped duplicate. ## 13. Compatibility, migration, rollout, and rollback @@ -385,7 +390,8 @@ body mutation that produces no follow-up PR workflow. 13. Copilot's own PR body update creates zero follow-up PR workflow runs. 14. Actions distinguish PR analysis, review-state observation, and merge-queue admission without requiring log inspection; review state uses a distinct - check and merge queue preserves the normal PR required-check context. + check and a separate merge-queue workflow preserves the normal PR + required-check context without adding a skipped duplicate. ## 17. Requirements traceability diff --git a/src/application/policies/__tests__/setup_configuration_policy.test.ts b/src/application/policies/__tests__/setup_configuration_policy.test.ts index 49452c50b..3d3dcf0e5 100644 --- a/src/application/policies/__tests__/setup_configuration_policy.test.ts +++ b/src/application/policies/__tests__/setup_configuration_policy.test.ts @@ -19,9 +19,9 @@ describe('setup configuration policy', () => { const configuration = createDefaultSetupConfiguration(); const plan = buildSetupPlan(configuration); - expect(plan.workflowFiles).toHaveLength(11); + expect(plan.workflowFiles).toHaveLength(12); expect(plan.issueTemplateFiles).toHaveLength(8); - expect(plan.selectedFiles).toHaveLength(20); + expect(plan.selectedFiles).toHaveLength(21); expect(plan.variables).toEqual(expect.arrayContaining([ { name: 'AGENT_PROVIDER', value: 'codex' }, { name: 'AGENT_ALLOWED_MODELS', value: 'openai/gpt-5.6-luna' }, @@ -88,11 +88,12 @@ describe('setup configuration policy', () => { expect(plan.workflowFiles).toEqual(expect.arrayContaining([ 'copilot_issue.yml', 'copilot_pull_request.yml', + 'copilot_pull_request_merge_queue.yml', 'copilot_commit.yml', 'copilot_branch_sync.yml', ])); expect(plan.workflowFiles).not.toContain('release_workflow.yml'); - expect(plan.selectedFiles).toHaveLength(7); + expect(plan.selectedFiles).toHaveLength(8); }); it('keeps inactivity closure opt-in and wires its threshold when enabled', () => { diff --git a/src/domain/__tests__/setup_workflow_catalog.test.ts b/src/domain/__tests__/setup_workflow_catalog.test.ts index c7035365f..711f8b80d 100644 --- a/src/domain/__tests__/setup_workflow_catalog.test.ts +++ b/src/domain/__tests__/setup_workflow_catalog.test.ts @@ -7,6 +7,16 @@ describe('setup workflow catalog', () => { expect(files).toEqual(expect.arrayContaining(['copilot_commit.yml', 'copilot_branch_sync.yml'])); }); + it('keeps PR analysis and merge-queue compatibility under one capability', () => { + const files = enabledSetupWorkflowFiles({ pullRequests: true }); + + expect(files).toEqual(expect.arrayContaining([ + 'copilot_pull_request.yml', + 'copilot_pull_request_merge_queue.yml', + ])); + expect(isSetupWorkflowEnabled('copilot_pull_request_merge_queue.yml', { pullRequests: false })).toBe(false); + }); + it('disables every workflow owned by a disabled capability', () => { expect(isSetupWorkflowEnabled('copilot_commit.yml', { commits: false })).toBe(false); expect(isSetupWorkflowEnabled('copilot_branch_sync.yml', { commits: false })).toBe(false); diff --git a/src/domain/setup_workflow_catalog.ts b/src/domain/setup_workflow_catalog.ts index 76244e5c7..ab1fd3bcb 100644 --- a/src/domain/setup_workflow_catalog.ts +++ b/src/domain/setup_workflow_catalog.ts @@ -8,6 +8,7 @@ interface SetupWorkflowDefinition { const SETUP_WORKFLOWS: readonly SetupWorkflowDefinition[] = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, { file: 'copilot_issue_comment.yml', feature: 'issueComments' }, diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index 6f112d605..2d75c8840 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -214,7 +214,7 @@ describe('workflow contract validator', () => { }); it.each(['.github/workflows', 'setup/workflows'])( - 'rejects metadata-only PR triggers and unsafe PR check identities in %s', + 'rejects metadata-only PR triggers, embedded merge checks, and unsafe PR check identities in %s', (directory) => { const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; @@ -223,12 +223,24 @@ describe('workflow contract validator', () => { expect(() => assertDirectEventTriggers(file, workflow)).toThrow('metadata-only edited events'); workflow.on.pull_request.types = workflow.on.pull_request.types.filter((type: string) => type !== 'edited'); + workflow.on.merge_group = { types: ['checks_requested'] }; + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('dedicated pull-request merge-queue workflow'); + + delete workflow.on.merge_group; workflow.jobs['copilot-pull-requests'].name = 'Copilot - Pull Request'; expect(() => assertDirectEventTriggers(file, workflow)).toThrow('review-state events'); + }, + ); + + it.each(['.github/workflows', 'setup/workflows'])( + 'keeps merge-queue required checks isolated, exact, and lightweight in %s', + (directory) => { + const file = path.join(process.cwd(), directory, 'copilot_pull_request_merge_queue.yml'); + const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; - workflow.jobs['copilot-pull-requests'].name = "${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }}"; - workflow.jobs['copilot-merge-group'].name = 'Copilot - Merge Queue'; - expect(() => assertDirectEventTriggers(file, workflow)).toThrow('required-check identity'); + expect(() => validateWorkflow(file, workflow)).not.toThrow(); + workflow.jobs['copilot-pull-request-required-check'].name = 'Copilot - Merge Queue'; + expect(() => validateWorkflow(file, workflow)).toThrow('required-check identity'); }, ); @@ -310,8 +322,8 @@ describe('workflow contract validator', () => { it.each([ '.github/workflows/ci_check.yml', '.github/workflows/repowise.yml', - '.github/workflows/copilot_pull_request.yml', - 'setup/workflows/copilot_pull_request.yml', + '.github/workflows/copilot_pull_request_merge_queue.yml', + 'setup/workflows/copilot_pull_request_merge_queue.yml', ])('requires merge-group checks in %s', (relativeFile) => { const file = path.join(process.cwd(), relativeFile); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; From 893ee27663907f721a8d940742d9253d11b696a0 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:00:57 +0200 Subject: [PATCH 3/6] codex-pr-enrichment-ux: preserve unlinked titles --- build/cli/index.js | 1 + build/github_action/index.js | 1 + docs/pull-requests/capabilities.mdx | 2 +- docs/pull-requests/workflow-setup.mdx | 7 ++++--- specs/pull-request-lifecycle-and-enrichment.md | 11 ++++++----- .../__tests__/update_title_use_case.test.ts | 17 +++++++++++++++++ .../steps/common/update_title_workflow.ts | 9 +++++++-- 7 files changed, 37 insertions(+), 11 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 8b972ac98..34f2dff6e 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -74902,6 +74902,7 @@ exports.isSetupWorkflowEnabled = isSetupWorkflowEnabled; const SETUP_WORKFLOWS = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, { file: 'copilot_issue_comment.yml', feature: 'issueComments' }, diff --git a/build/github_action/index.js b/build/github_action/index.js index 448062f22..38d638db3 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -72805,6 +72805,7 @@ exports.isSetupWorkflowEnabled = isSetupWorkflowEnabled; const SETUP_WORKFLOWS = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, { file: 'copilot_issue_comment.yml', feature: 'issueComments' }, diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index 4508bd072..5ac2a2063 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -30,7 +30,7 @@ compensated failure says that the original body and base were restored. If a newer event sees a different current base, Copilot stops without writing; restore the base named by the failed run or start again from the current PR state. -The branch name must follow the pattern that includes the issue number (e.g. `/-`). If it does not, the PR still receives PR-native enrichment such as title normalization on open/reopen, assignee and reviewer selection, project linking, description, and review where configured. Issue-derived priority, size, and progress synchronization and merge closure are skipped because those policies need a separate issue. Copilot never uses the PR number itself as fallback linkage and never creates `Closes #`. See [Issues โ†’ Branch management](/issues/branch-management) for naming conventions. +The branch name must follow the pattern that includes the issue number (e.g. `/-`). If it does not, the PR still receives PR-native enrichment such as assignee and reviewer selection, project linking, description, and review where configured. Issue-derived title normalization, priority, size, and progress synchronization and merge closure are skipped because those policies need a separate issue. Copilot never uses the PR number itself as fallback linkage and never creates `Closes #`. See [Issues โ†’ Branch management](/issues/branch-management) for naming conventions. ## Project linking diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index cfa38fd24..c2f7e0341 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -28,9 +28,10 @@ on: Do not subscribe the supplied PR workflow to `pull_request: edited`. Copilot updates the PR body itself, so that metadata event would create redundant runs. -Human title/body edits do not trigger Copilot. Title normalization still runs -on open/reopen; subsequent synchronize events affect the body only according to -the configured AI description ownership mode. +Human title/body edits do not trigger Copilot. Issue-derived title normalization +still runs on open/reopen when a distinct issue exists; an unlinked PR keeps its +title. Subsequent synchronize events affect the body only according to the +configured AI description ownership mode. For **first-time setup**, at least **`opened`** and **`synchronize`** are useful so that new PRs get full treatment and updates when the branch changes. diff --git a/specs/pull-request-lifecycle-and-enrichment.md b/specs/pull-request-lifecycle-and-enrichment.md index 95275bbbe..4bf73efa2 100644 --- a/specs/pull-request-lifecycle-and-enrichment.md +++ b/specs/pull-request-lifecycle-and-enrichment.md @@ -134,8 +134,8 @@ link mutations are compensated on every edge, and partial cleanup is explicit. ### 6.1 Happy path 1. Same-repository PR opens; its managed branch may contain a distinct issue identity. -2. Copilot enriches PR-native title, people, projects, description, and review; - it adds issue linkage and issue-derived priority/size/progress synchronization +2. Copilot enriches PR-native people, projects, description, and review; it adds + issue linkage and issue-derived title/priority/size/progress synchronization only when a distinct issue exists. 3. Review result updates current status/lifecycle. 4. Synchronize reruns only refreshable content and review. @@ -143,9 +143,10 @@ link mutations are compensated on every edge, and partial cleanup is explicit. ### 6.2 Alternative paths -- No linked issue still permits title/assignee/reviewer/project/review enrichment - and a description inferred from PR metadata/diff, without an issue-provider - call, issue-derived label synchronization, or false `Closes` line. +- No linked issue preserves the PR title and still permits + assignee/reviewer/project/review enrichment and a description inferred from PR + metadata/diff, without an issue-provider call, issue-derived label + synchronization, or false `Closes` line. - A distinct linked issue with an empty description remains valid optional context; description generation continues from PR metadata and diff. - A branch number equal to the PR number is unlinked rather than self-linked. diff --git a/src/application/usecases/steps/common/__tests__/update_title_use_case.test.ts b/src/application/usecases/steps/common/__tests__/update_title_use_case.test.ts index a42055c9e..da1241012 100644 --- a/src/application/usecases/steps/common/__tests__/update_title_use_case.test.ts +++ b/src/application/usecases/steps/common/__tests__/update_title_use_case.test.ts @@ -75,6 +75,23 @@ describe('UpdateTitleUseCase', () => { expect(results[0].executed).toBe(false); }); + it.each([-1, 2, Number.MAX_SAFE_INTEGER + 1])( + 'preserves an unlinked PR title without issue-provider I/O: %s', + async (issueNumber) => { + const param = baseParam({ + isPullRequest: true, + issueNumber, + emoji: { emojiLabeledTitle: true, branchManagementEmoji: '' }, + }); + + const results = await invoke(param); + + expect(results[0]).toMatchObject({ success: true, executed: false }); + expect(mockGetTitle).not.toHaveBeenCalled(); + expect(mockUpdateTitlePullRequestFormat).not.toHaveBeenCalled(); + }, + ); + it('returns success executed true when isIssue, emojiLabeledTitle, and updateTitleIssueFormat returns new title', async () => { mockGetTitle.mockResolvedValue('Old title'); mockUpdateTitleIssueFormat.mockResolvedValue('v1.0.0 Old title'); diff --git a/src/application/usecases/steps/common/update_title_workflow.ts b/src/application/usecases/steps/common/update_title_workflow.ts index 865bbf15c..c5142f375 100644 --- a/src/application/usecases/steps/common/update_title_workflow.ts +++ b/src/application/usecases/steps/common/update_title_workflow.ts @@ -4,6 +4,7 @@ import type { TitleLabelFacts, } from '../../../../application/ports/issue_title_ports'; import { toApplicationError } from '../../../errors/application_error'; +import { parsePositiveSafeInteger } from '../../../../domain/positive_integer_policy'; export type UpdateTitleContext = | { @@ -98,14 +99,18 @@ export async function runPullRequestTitleUpdate( issueRepository: BoundIssueTitlePort, ): Promise { if (!param.enabled) return [skippedResult(taskId)]; - const issueTitle = await issueRepository.getTitle(param.issueNumber); + const linkedIssueNumber = parsePositiveSafeInteger(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { + return [skippedResult(taskId)]; + } + const issueTitle = await issueRepository.getTitle(linkedIssueNumber); if (issueTitle === undefined) { return [new Result({ id: taskId, success: false, executed: true, steps: ['Tried to update title, but there was a problem.'] })]; } const title = await issueRepository.updatePullRequestTitle({ pullRequestTitle: param.pullRequestTitle, issueTitle, - issueNumber: param.issueNumber, + issueNumber: linkedIssueNumber, pullRequestNumber: param.pullRequestNumber, labelFacts: param.labelFacts, }); From a03bf55a959370a5f87399c2c8a2488506a66590 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:02:43 +0200 Subject: [PATCH 4/6] codex-pr-enrichment-ux: refresh bundles --- build/cli/index.js | 9 +++++++-- build/github_action/index.js | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 34f2dff6e..47d101b09 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -59509,6 +59509,7 @@ exports.runPullRequestTitleUpdate = runPullRequestTitleUpdate; exports.titleUpdateFailure = titleUpdateFailure; const result_1 = __nccwpck_require__(73817); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); function projectUpdateTitleContext(source) { if (source.isIssue) { return Object.freeze({ @@ -59557,14 +59558,18 @@ async function runIssueTitleUpdate(param, taskId, issueRepository) { async function runPullRequestTitleUpdate(param, taskId, issueRepository) { if (!param.enabled) return [skippedResult(taskId)]; - const issueTitle = await issueRepository.getTitle(param.issueNumber); + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { + return [skippedResult(taskId)]; + } + const issueTitle = await issueRepository.getTitle(linkedIssueNumber); if (issueTitle === undefined) { return [new result_1.Result({ id: taskId, success: false, executed: true, steps: ['Tried to update title, but there was a problem.'] })]; } const title = await issueRepository.updatePullRequestTitle({ pullRequestTitle: param.pullRequestTitle, issueTitle, - issueNumber: param.issueNumber, + issueNumber: linkedIssueNumber, pullRequestNumber: param.pullRequestNumber, labelFacts: param.labelFacts, }); diff --git a/build/github_action/index.js b/build/github_action/index.js index 38d638db3..80a4abe8b 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -59596,6 +59596,7 @@ exports.runPullRequestTitleUpdate = runPullRequestTitleUpdate; exports.titleUpdateFailure = titleUpdateFailure; const result_1 = __nccwpck_require__(73817); const application_error_1 = __nccwpck_require__(75999); +const positive_integer_policy_1 = __nccwpck_require__(19879); function projectUpdateTitleContext(source) { if (source.isIssue) { return Object.freeze({ @@ -59644,14 +59645,18 @@ async function runIssueTitleUpdate(param, taskId, issueRepository) { async function runPullRequestTitleUpdate(param, taskId, issueRepository) { if (!param.enabled) return [skippedResult(taskId)]; - const issueTitle = await issueRepository.getTitle(param.issueNumber); + const linkedIssueNumber = (0, positive_integer_policy_1.parsePositiveSafeInteger)(param.issueNumber); + if (!linkedIssueNumber || linkedIssueNumber === param.pullRequestNumber) { + return [skippedResult(taskId)]; + } + const issueTitle = await issueRepository.getTitle(linkedIssueNumber); if (issueTitle === undefined) { return [new result_1.Result({ id: taskId, success: false, executed: true, steps: ['Tried to update title, but there was a problem.'] })]; } const title = await issueRepository.updatePullRequestTitle({ pullRequestTitle: param.pullRequestTitle, issueTitle, - issueNumber: param.issueNumber, + issueNumber: linkedIssueNumber, pullRequestNumber: param.pullRequestNumber, labelFacts: param.labelFacts, }); From cf98481b4c50bd0dd0a1bc1f9cf70ab5a1dd124e Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:07:26 +0200 Subject: [PATCH 5/6] codex-pr-enrichment-ux: isolate review-state concurrency --- .github/workflows/copilot_pull_request.yml | 2 +- docs/features.mdx | 2 +- docs/pull-requests/capabilities.mdx | 3 ++- docs/pull-requests/examples.mdx | 2 +- docs/pull-requests/workflow-setup.mdx | 2 +- scripts/validate-workflow-contract.cjs | 4 ++-- setup/workflows/copilot_pull_request.yml | 2 +- specs/bugbot-review-state-reconciliation.md | 6 ++++-- .../issue-and-pull-request-context-hardening.md | 12 +++++++----- .../__tests__/validate_workflow_contract.test.ts | 16 ++++++++++++++-- 10 files changed, 34 insertions(+), 17 deletions(-) diff --git a/.github/workflows/copilot_pull_request.yml b/.github/workflows/copilot_pull_request.yml index 817d036ab..c73ceab4d 100644 --- a/.github/workflows/copilot_pull_request.yml +++ b/.github/workflows/copilot_pull_request.yml @@ -14,7 +14,7 @@ jobs: runs-on: [self-hosted, codex] timeout-minutes: 120 concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} cancel-in-progress: true permissions: checks: write diff --git a/docs/features.mdx b/docs/features.mdx index c20a152e3..e69f28d42 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -162,7 +162,7 @@ are deprecated. **Two coordination policies:** durable mutation workflows preserve every admitted event in a workflow-local queue. Commit and Pull Request each have a separate repository/branch latest-revision lane, so paired events cannot cancel one another. -GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}` and Pull Request uses `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}`. Each cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting one event cancel useful work from the other. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. +GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}`; Pull Request code/lifecycle analysis uses the branch key with an `analysis` suffix; and review-state observation uses the same branch key with a `review-state` suffix. Each lane cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting a push or review-state event cancel useful PR analysis. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. For non-replaceable issue and comment mutations, Copilot adds an application-level queue per workflow: every started run waits for earlier active runs of that same workflow, so intermediate events are not discarded by a native concurrency group. Runs triggered by the PAT owner that would only re-trigger the normal pipeline complete before entering this queue. diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index 5ac2a2063..c1a4ea44c 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -111,7 +111,8 @@ operator diagnosis even when the surrounding copy is localized. The supplied workflow names runs as `Copilot PR ยท :`, so normal analysis, review-state observation, and merge-queue admission are recognizable without opening logs. Review-state events use the distinct -`Copilot - Pull Request Review State` check. Normal analysis and merge-group +`Copilot - Pull Request Review State` check and a separate concurrency lane, so +submitting or editing a review cannot cancel current code analysis. Normal analysis and merge-group runs intentionally share `Copilot - Pull Request`, preserving the exact required-check context that GitHub branch protection and merge queues expect. The merge-group producer lives in the separate diff --git a/docs/pull-requests/examples.mdx b/docs/pull-requests/examples.mdx index 84a2dd4b6..824514472 100644 --- a/docs/pull-requests/examples.mdx +++ b/docs/pull-requests/examples.mdx @@ -26,7 +26,7 @@ jobs: name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: ubuntu-latest concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} cancel-in-progress: true steps: - name: Checkout Repository diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index c2f7e0341..033d4d1a4 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -54,7 +54,7 @@ jobs: name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} runs-on: ubuntu-latest concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} cancel-in-progress: true steps: - name: Checkout Repository diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index 91bd51ff5..7aaf70fa2 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -25,7 +25,7 @@ const DISTRIBUTED_COPILOT_ACTION = 'vypdev/copilot@v3'; const CHECKOUT_ACTION = 'actions/checkout@v5'; const SETUP_NODE_ACTION = 'actions/setup-node@v7'; const PUSH_BRANCH_CONCURRENCY_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; -const PULL_REQUEST_BRANCH_CONCURRENCY_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}'; +const PULL_REQUEST_BRANCH_CONCURRENCY_GROUP = "copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }}"; const BUGBOT_CONCURRENCY_JOBS = Object.freeze({ 'copilot_commit.yml': Object.freeze({ jobId: 'copilot-commits', @@ -373,7 +373,7 @@ function assertReviewConcurrency(relativeFile, workflow) { } if (job.concurrency?.group !== group || job.concurrency?.['cancel-in-progress'] !== cancelInProgress) { - throw new Error(`${relativeFile} job ${jobId} must use its workflow-specific branch group, avoid cross-canceling the other event owner, and cancel superseded runs.`); + throw new Error(`${relativeFile} job ${jobId} must use its workflow- and event-specific branch group, avoid cross-canceling another event owner, and cancel superseded runs within its lane.`); } } } diff --git a/setup/workflows/copilot_pull_request.yml b/setup/workflows/copilot_pull_request.yml index b0e2a5c21..5356db0cd 100644 --- a/setup/workflows/copilot_pull_request.yml +++ b/setup/workflows/copilot_pull_request.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} cancel-in-progress: true permissions: checks: write diff --git a/specs/bugbot-review-state-reconciliation.md b/specs/bugbot-review-state-reconciliation.md index 1561ff01b..05923a0a1 100644 --- a/specs/bugbot-review-state-reconciliation.md +++ b/specs/bugbot-review-state-reconciliation.md @@ -484,8 +484,10 @@ readiness. `unknown` is a system failure and fails the review regardless of - A run whose analyzed head is no longer the PR head MUST return superseded and MUST NOT mutate findings or current-state projections. - Shipped Commit and Pull Request workflows MUST use distinct branch-scoped - concurrency groups. Each uses cancel-in-progress semantics only for its own - replaceable revisions. On push, a read-only exact-head preflight MUST validate + concurrency groups. Pull Request code/lifecycle and review-state events MUST + also use separate lane suffixes. Each uses cancel-in-progress semantics only + for its own replaceable revisions, so review-state observation cannot cancel + code analysis. On push, a read-only exact-head preflight MUST validate any open same-repository PR before Bugbot loads review context or invokes the agent; a validated match yields to the PR code-change event. The decision MUST NOT read PR identity from the push payload. The shipped PR workflow MUST NOT diff --git a/specs/issue-and-pull-request-context-hardening.md b/specs/issue-and-pull-request-context-hardening.md index d63a6f192..0fb69ad2f 100644 --- a/specs/issue-and-pull-request-context-hardening.md +++ b/specs/issue-and-pull-request-context-hardening.md @@ -362,11 +362,13 @@ treated as compensation-required, not ordinary failure. ### 6.5 Workflow concurrency and event ordering The Commit and Pull Request workflows use distinct normalized -repository/branch groups. Commit pushes plus PR `opened`, `reopened`, -`synchronize`, `closed`, and review-state events use cancel-in-progress behavior -within their own lane so newer evidence replaces obsolete work. The supplied PR -workflow excludes `pull_request: edited`, preventing body/title-only mutations -from entering either lane. Application head guards remain mandatory. +repository/branch groups. Within the Pull Request workflow, `opened`, `reopened`, +`synchronize`, and `closed` use an `analysis` lane while review-state events use +a `review-state` lane. Each lane uses cancel-in-progress behavior so newer +evidence replaces only obsolete work of the same class; a submitted or edited +review cannot cancel code analysis. The supplied PR workflow excludes +`pull_request: edited`, preventing body/title-only mutations from entering either +lane. Application head guards remain mandatory. This rule is identical in the repository workflow and the shipped setup copy. It is not configurable because admitting self-generated metadata events creates diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index 2d75c8840..9c5856b27 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -132,7 +132,7 @@ describe('workflow contract validator', () => { if (['copilot_commit.yml', 'copilot_pull_request.yml'].includes(manifest.file)) { expect(workflow.jobs[manifest.jobId].concurrency).toEqual({ group: manifest.file === 'copilot_pull_request.yml' - ? 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}' + ? "copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }}" : 'copilot-push-${{ github.repository }}-${{ github.ref_name }}', 'cancel-in-progress': true, }); @@ -165,7 +165,19 @@ describe('workflow contract validator', () => { const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; workflow.jobs[jobId].concurrency.group = sharedGroup; - expect(() => validateWorkflow(file, workflow)).toThrow('avoid cross-canceling the other event owner'); + expect(() => validateWorkflow(file, workflow)).toThrow('avoid cross-canceling another event owner'); + }, + ); + + it.each(['.github/workflows', 'setup/workflows'])( + 'rejects a PR group that lets review-state events cancel active analysis in %s', + (directory) => { + const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); + const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; + workflow.jobs['copilot-pull-requests'].concurrency.group = + 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}'; + + expect(() => validateWorkflow(file, workflow)).toThrow('avoid cross-canceling another event owner'); }, ); From 37164b5ba5359885804c6d0d2ee2eec89167dd30 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:23:03 +0200 Subject: [PATCH 6/6] codex-pr-enrichment-ux: split review-state checks --- .github/workflows/copilot_pull_request.yml | 6 +- .../copilot_pull_request_review_state.yml | 94 +++++++++++++++ build/cli/index.js | 1 + build/github_action/index.js | 1 + docs/features.mdx | 2 +- docs/how-to-use.mdx | 1 + docs/pull-requests/capabilities.mdx | 4 +- docs/pull-requests/examples.mdx | 35 +++++- docs/pull-requests/workflow-setup.mdx | 29 +++-- scripts/validate-workflow-contract.cjs | 63 ++++++---- setup/workflows/copilot_pull_request.yml | 6 +- .../copilot_pull_request_review_state.yml | 113 ++++++++++++++++++ specs/CATALOG.md | 4 +- specs/bugbot-review-state-reconciliation.md | 28 +++-- specs/catalog.json | 1 + ...ssue-and-pull-request-context-hardening.md | 13 +- .../setup_configuration_policy.test.ts | 7 +- src/domain/setup_workflow_catalog.ts | 1 + .../validate_workflow_contract.test.ts | 69 +++++++---- 19 files changed, 390 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/copilot_pull_request_review_state.yml create mode 100644 setup/workflows/copilot_pull_request_review_state.yml diff --git a/.github/workflows/copilot_pull_request.yml b/.github/workflows/copilot_pull_request.yml index c73ceab4d..144b236e9 100644 --- a/.github/workflows/copilot_pull_request.yml +++ b/.github/workflows/copilot_pull_request.yml @@ -4,17 +4,15 @@ run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: types: [opened, reopened, closed, synchronize] - pull_request_review: - types: [submitted, edited, dismissed] jobs: copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} - name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} + name: Copilot - Pull Request runs-on: [self-hosted, codex] timeout-minutes: 120 concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis cancel-in-progress: true permissions: checks: write diff --git a/.github/workflows/copilot_pull_request_review_state.yml b/.github/workflows/copilot_pull_request_review_state.yml new file mode 100644 index 000000000..a849abf23 --- /dev/null +++ b/.github/workflows/copilot_pull_request_review_state.yml @@ -0,0 +1,94 @@ +name: Copilot - Pull Request Review State +run-name: Copilot PR review ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + pull_request_review: + types: [submitted, edited, dismissed] + +jobs: + copilot-pull-request-review-state: + if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} + name: Copilot - Pull Request Review State + runs-on: [self-hosted, codex] + timeout-minutes: 120 + concurrency: + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-review-state + cancel-in-progress: true + permissions: + checks: write + contents: read + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Node.js 24 for pinned agent installation + uses: actions/setup-node@v7 + with: + node-version: '24.x' + + # Exercise the Action implementation from this PR so regressions are caught before merge. + - uses: ./ + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issues-locale: ${{ vars.ISSUES_LOCALE || '' }} + pull-requests-locale: ${{ vars.PULL_REQUESTS_LOCALE || '' }} + ai-ignore-files: build/*,specs/CATALOG.md + bugbot-severity: ${{ vars.BUGBOT_SEVERITY || 'low' }} + bugbot-comment-limit: ${{ vars.BUGBOT_COMMENT_LIMIT || '20' }} + bugbot-fix-verify-commands: ${{ vars.BUGBOT_AUTOFIX_VERIFY_COMMANDS }} + bugbot-dry-run: ${{ vars.BUGBOT_DRY_RUN || 'false' }} + bugbot-effort: ${{ vars.BUGBOT_EFFORT || 'smart' }} + bugbot-review-drafts: ${{ vars.BUGBOT_REVIEW_DRAFTS || 'false' }} + bugbot-trace-rules: ${{ vars.BUGBOT_TRACE_RULES || 'false' }} + bugbot-suggested-changes: ${{ vars.BUGBOT_SUGGESTED_CHANGES || 'true' }} + bugbot-telemetry: ${{ vars.BUGBOT_TELEMETRY || 'true' }} + bugbot-fail-on-unresolved: ${{ vars.BUGBOT_FAIL_ON_UNRESOLVED || 'false' }} + bugbot-organization-rules: ${{ vars.BUGBOT_ORGANIZATION_RULES }} + debug: ${{ vars.DEBUG }} + agent-provider: ${{ vars.AGENT_PROVIDER || 'codex' }} + agent-model-provider: ${{ vars.AGENT_MODEL_PROVIDER || 'openai' }} + agent-model: ${{ vars.AGENT_MODEL || 'gpt-5.6-luna' }} + agent-effort: ${{ vars.AGENT_EFFORT }} + agent-executable: ${{ vars.AGENT_EXECUTABLE }} + findings-provider: ${{ vars.FINDINGS_PROVIDER }} + findings-model-provider: ${{ vars.FINDINGS_MODEL_PROVIDER }} + findings-model: ${{ vars.FINDINGS_MODEL }} + findings-effort: ${{ vars.FINDINGS_EFFORT }} + findings-executable: ${{ vars.FINDINGS_EXECUTABLE }} + fixer-provider: ${{ vars.FIXER_PROVIDER }} + fixer-model-provider: ${{ vars.FIXER_MODEL_PROVIDER }} + fixer-model: ${{ vars.FIXER_MODEL }} + fixer-effort: ${{ vars.FIXER_EFFORT }} + fixer-executable: ${{ vars.FIXER_EXECUTABLE }} + planner-provider: ${{ vars.PLANNER_PROVIDER }} + planner-model-provider: ${{ vars.PLANNER_MODEL_PROVIDER }} + planner-model: ${{ vars.PLANNER_MODEL }} + planner-effort: ${{ vars.PLANNER_EFFORT }} + planner-executable: ${{ vars.PLANNER_EXECUTABLE }} + reviewer-provider: ${{ vars.REVIEWER_PROVIDER }} + reviewer-model-provider: ${{ vars.REVIEWER_MODEL_PROVIDER }} + reviewer-model: ${{ vars.REVIEWER_MODEL }} + reviewer-effort: ${{ vars.REVIEWER_EFFORT }} + reviewer-executable: ${{ vars.REVIEWER_EXECUTABLE }} + project-ids: ${{ vars.PROJECT_IDS }} + token: ${{ secrets.PAT }} + env: + AGENT_PROVIDER: ${{ vars.AGENT_PROVIDER || 'codex' }} + AGENT_MODEL_PROVIDER: ${{ vars.AGENT_MODEL_PROVIDER || 'openai' }} + AGENT_MODEL: ${{ vars.AGENT_MODEL }} + AGENT_EFFORT: ${{ vars.AGENT_EFFORT }} + AGENT_PROVISIONING: ${{ vars.AGENT_PROVISIONING || 'auto' }} + AGENT_ALLOWED_MODEL_PROVIDERS: ${{ vars.AGENT_ALLOWED_MODEL_PROVIDERS || 'openai' }} + AGENT_ALLOWED_MODELS: ${{ vars.AGENT_ALLOWED_MODELS || 'openai/gpt-5.6-luna' }} + AGENT_EXECUTABLE: ${{ vars.AGENT_EXECUTABLE }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + CURSOR_API_KEY: ${{ (vars.AGENT_PROVIDER == 'cursor' || vars.FINDINGS_PROVIDER == 'cursor' || vars.FIXER_PROVIDER == 'cursor' || vars.PLANNER_PROVIDER == 'cursor' || vars.REVIEWER_PROVIDER == 'cursor' || vars.TESTER_PROVIDER == 'cursor') && secrets.CURSOR_API_KEY || '' }} + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }} + COPILOT_EVIDENCE_TOKEN: ${{ github.token }} diff --git a/build/cli/index.js b/build/cli/index.js index 47d101b09..4befa374d 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -74907,6 +74907,7 @@ exports.isSetupWorkflowEnabled = isSetupWorkflowEnabled; const SETUP_WORKFLOWS = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_review_state.yml', feature: 'pullRequests' }, { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, diff --git a/build/github_action/index.js b/build/github_action/index.js index 80a4abe8b..7f3676b70 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -72810,6 +72810,7 @@ exports.isSetupWorkflowEnabled = isSetupWorkflowEnabled; const SETUP_WORKFLOWS = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_review_state.yml', feature: 'pullRequests' }, { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, diff --git a/docs/features.mdx b/docs/features.mdx index e69f28d42..e5b3104d2 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -162,7 +162,7 @@ are deprecated. **Two coordination policies:** durable mutation workflows preserve every admitted event in a workflow-local queue. Commit and Pull Request each have a separate repository/branch latest-revision lane, so paired events cannot cancel one another. -GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}`; Pull Request code/lifecycle analysis uses the branch key with an `analysis` suffix; and review-state observation uses the same branch key with a `review-state` suffix. Each lane cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting a push or review-state event cancel useful PR analysis. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Run names expose the event/action and review-state has a distinct check. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. +GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts and can retain only one pending run. The replaceable branch jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}`; Pull Request code/lifecycle analysis uses the branch key with an `analysis` suffix; and the dedicated review-state workflow uses the same branch key with a `review-state` suffix. Each lane cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting a push or review-state event cancel useful PR analysis. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Fixed workflow/job names expose analysis and review-state checks without leaking an unevaluated expression when GitHub skips bot-authored events. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. For non-replaceable issue and comment mutations, Copilot adds an application-level queue per workflow: every started run waits for earlier active runs of that same workflow, so intermediate events are not discarded by a native concurrency group. Runs triggered by the PAT owner that would only re-trigger the normal pipeline complete before entering this queue. diff --git a/docs/how-to-use.mdx b/docs/how-to-use.mdx index d1af1709d..1bc37dfcc 100644 --- a/docs/how-to-use.mdx +++ b/docs/how-to-use.mdx @@ -319,6 +319,7 @@ and roles: |------|--------| | `copilot_issue.yml` | Runs on issue events (opened, edited, labeled, unlabeled, etc.): creates branches, links to projects, assignees, deploy trigger. | | `copilot_pull_request.yml` | Runs on PR events: links PR to issue/project, reviewers, AI description, etc. | +| `copilot_pull_request_review_state.yml` | Reconciles lifecycle labels from submitted, edited, or dismissed reviews in a fixed, isolated review-state check. | | `copilot_pull_request_merge_queue.yml` | Reports the existing `Copilot - Pull Request` required-check context for `merge_group` without adding a skipped merge job to normal PR runs. | | `copilot_commit.yml` | Runs the issue-centric push pipeline (all branches except main/develop by default): native issue state and size/progress. It also runs Bugbot when the branch has no open PR; otherwise the PR synchronization event owns review. | | `copilot_branch_sync.yml` | Lightweight push observer on all branches: detects parent/working drift and maintains a synchronization notice without loading Bugbot or an agent. | diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index c1a4ea44c..8c58151c3 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -112,7 +112,9 @@ The supplied workflow names runs as `Copilot PR ยท :`, so normal analysis, review-state observation, and merge-queue admission are recognizable without opening logs. Review-state events use the distinct `Copilot - Pull Request Review State` check and a separate concurrency lane, so -submitting or editing a review cannot cancel current code analysis. Normal analysis and merge-group +submitting or editing a review cannot cancel current code analysis. Its producer +lives in `copilot_pull_request_review_state.yml`, which also keeps skipped +bot-authored review checks readable by avoiding a dynamic job name. Normal analysis and merge-group runs intentionally share `Copilot - Pull Request`, preserving the exact required-check context that GitHub branch protection and merge queues expect. The merge-group producer lives in the separate diff --git a/docs/pull-requests/examples.mdx b/docs/pull-requests/examples.mdx index 824514472..6f824f56f 100644 --- a/docs/pull-requests/examples.mdx +++ b/docs/pull-requests/examples.mdx @@ -18,15 +18,13 @@ run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: types: [opened, reopened, closed, synchronize] - pull_request_review: - types: [submitted, edited, dismissed] jobs: copilot-pull-requests: - name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} + name: Copilot - Pull Request runs-on: ubuntu-latest concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis cancel-in-progress: true steps: - name: Checkout Repository @@ -62,6 +60,35 @@ jobs: debug: ${{ vars.DEBUG }} ``` +Keep review-state observation in the shipped +`copilot_pull_request_review_state.yml` companion. Its fixed workflow and job +name remains readable even when a bot-authored review is skipped before the job +starts: + +```yaml +name: Copilot - Pull Request Review State +run-name: Copilot PR review ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + pull_request_review: + types: [submitted, edited, dismissed] + +jobs: + copilot-pull-request-review-state: + name: Copilot - Pull Request Review State + runs-on: ubuntu-latest + concurrency: + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-review-state + cancel-in-progress: true + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: vypdev/copilot@v3 + with: + token: ${{ secrets.PAT }} +``` + - **`token`** is required. Use a fine-grained PAT with repo and project permissions. - **`project-ids`**: Comma-separated project IDs so PRs are linked and moved to the right column. - **`project-column-*`**: Column names in your GitHub Project (must match exactly). diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index 033d4d1a4..c232f86fa 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -9,12 +9,16 @@ To run Copilot on **pull request** events, add a workflow that uses the `pull_re ## Trigger events -Use the `pull_request` trigger with the types you need. Common setup: +Use separate workflows for code/lifecycle analysis and review-state observation: ```yaml on: pull_request: types: [opened, reopened, closed, synchronize] +``` + +```yaml +on: pull_request_review: types: [submitted, edited, dismissed] ``` @@ -25,6 +29,7 @@ on: | `reopened` | A closed PR is reopened | Re-apply linking and labels. | | `synchronize` | New commits are pushed to the PR branch | Update the AI description when enabled and review the new commit range while reconciling open findings. | | `closed` | PR is closed or merged | Update project state. | +| `pull_request_review` | A review is submitted, edited, or dismissed | Reconcile lifecycle labels without invoking the review agent. | Do not subscribe the supplied PR workflow to `pull_request: edited`. Copilot updates the PR body itself, so that metadata event would create redundant runs. @@ -46,15 +51,13 @@ run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: types: [opened, reopened, closed, synchronize] - pull_request_review: - types: [submitted, edited, dismissed] jobs: copilot-pull-requests: - name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} + name: Copilot - Pull Request runs-on: ubuntu-latest concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis cancel-in-progress: true steps: - name: Checkout Repository @@ -80,14 +83,20 @@ jobs: project-ids: ${{ vars.PROJECT_IDS }} ``` +Install the shipped `copilot_pull_request_review_state.yml` alongside it. That +workflow listens only to `pull_request_review`, uses the fixed +`Copilot - Pull Request Review State` workflow/job name, and uses the same +branch key with a `review-state` suffix. + - **`token`** is required (use a fine-grained PAT with repo and project permissions; see [Authentication](/authentication)). - **`project-ids`** is optional but needed if you want PRs linked to GitHub Project boards and moved to columns (e.g. "In Progress"). - The conditional fetch downloads only the two commit objects needed by the incremental reviewer. Keep it when `synchronize` is enabled; a full-history checkout is not required. -- Keep the PR-specific concurrency group and cancellation policy exactly as - shown. A newer PR/review-state event cancels an obsolete run. The Commit +- Keep both PR-specific concurrency groups and cancellation policies exactly as + shown. A newer code/lifecycle event cancels only obsolete analysis; a newer + review event cancels only obsolete review-state observation. The Commit workflow uses its own `copilot-push-โ€ฆ` group, so a paired `push` event cannot - cancel this workflow (or be canceled by it). -- Keep the event/action run name and distinct review-state job name. They make + cancel either workflow (or be canceled by them). +- Keep the event/action run names and fixed job names. They make the Actions and Checks views understandable without opening logs. Only a result with exactly one validated, current-schema Bugbot telemetry snapshot for the exact head owns the `Copilot / Review` Check; partial, skipped, and @@ -120,7 +129,7 @@ jobs: 8. **Bugbot review:** On `opened` and `reopened`, the reviewer role analyzes the full PR. On `synchronize`, this workflow exclusively owns Bugbot for the new commit range and reconciles every open finding against current code; the paired Commit run still updates issue progress but skips its Bugbot pass. Metadata-only edits do not invoke the agent. Active findings move the PR to `state:changes-requested` and `state:awaiting-issue-author`; a clean review moves it to `state:ready` and `state:awaiting-maintainer`. During the run, `state:ai-processing` may coexist with those labels and is removed when the agent finishes. Configure `reviewer-provider`, `reviewer-model-provider`, `reviewer-model`, `reviewer-effort`, and `reviewer-executable` only when the reviewer should differ from the base agent. -9. **External lifecycle evidence:** `pull_request_review` updates only the managed lifecycle label. A requested change marks the PR as changes requested, and an approved review can mark it ready. This event does not invoke an agent pipeline. If you add private workflows for external checks, wire and scope those triggers in your own repository. +9. **External lifecycle evidence:** The dedicated `copilot_pull_request_review_state.yml` workflow updates only the managed lifecycle label. A requested change marks the PR as changes requested, and an approved review can mark it ready. This event does not invoke an agent pipeline. If you add private workflows for external checks, wire and scope those triggers in your own repository. ## Next steps diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index 7aaf70fa2..5e68279b5 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -25,7 +25,8 @@ const DISTRIBUTED_COPILOT_ACTION = 'vypdev/copilot@v3'; const CHECKOUT_ACTION = 'actions/checkout@v5'; const SETUP_NODE_ACTION = 'actions/setup-node@v7'; const PUSH_BRANCH_CONCURRENCY_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; -const PULL_REQUEST_BRANCH_CONCURRENCY_GROUP = "copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }}"; +const PULL_REQUEST_ANALYSIS_CONCURRENCY_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis'; +const PULL_REQUEST_REVIEW_STATE_CONCURRENCY_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-review-state'; const BUGBOT_CONCURRENCY_JOBS = Object.freeze({ 'copilot_commit.yml': Object.freeze({ jobId: 'copilot-commits', @@ -34,7 +35,12 @@ const BUGBOT_CONCURRENCY_JOBS = Object.freeze({ }), 'copilot_pull_request.yml': Object.freeze({ jobId: 'copilot-pull-requests', - group: PULL_REQUEST_BRANCH_CONCURRENCY_GROUP, + group: PULL_REQUEST_ANALYSIS_CONCURRENCY_GROUP, + cancelInProgress: true, + }), + 'copilot_pull_request_review_state.yml': Object.freeze({ + jobId: 'copilot-pull-request-review-state', + group: PULL_REQUEST_REVIEW_STATE_CONCURRENCY_GROUP, cancelInProgress: true, }), }); @@ -54,6 +60,7 @@ const QUEUE_WORKFLOW_MANIFEST = Object.freeze([ ['copilot_issue.yml', 'Copilot - Issue', 'copilot-issues'], ['copilot_issue_comment.yml', 'Copilot - Issue Comment', 'copilot-issues'], ['copilot_pull_request.yml', 'Copilot - Pull Request', 'copilot-pull-requests'], + ['copilot_pull_request_review_state.yml', 'Copilot - Pull Request Review State', 'copilot-pull-request-review-state'], ['copilot_pull_request_comment.yml', 'Copilot - Pull Request Comment', 'copilot-pull-requests'], ['copilot_close_inactive_issues.yml', 'Copilot - Close Inactive Issues', 'copilot-inactive-issues'], ].map(([file, workflowName, jobId]) => ({ file, workflowName, jobId }))); @@ -68,12 +75,14 @@ const BOT_GATED_WORKFLOW_FILES = new Set([ 'copilot_issue.yml', 'copilot_issue_comment.yml', 'copilot_pull_request.yml', + 'copilot_pull_request_review_state.yml', 'copilot_pull_request_comment.yml', ]); const BOT_GATE_EXPRESSION = "${{ vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }}"; const FORK_SAFE_BOT_GATE_EXPRESSION = "${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }}"; const FORK_GATED_WORKFLOW_FILES = new Set([ 'copilot_pull_request.yml', + 'copilot_pull_request_review_state.yml', 'copilot_pull_request_comment.yml', ]); const ZERO_OBJECT_ID = '0000000000000000000000000000000000000000'; @@ -89,6 +98,7 @@ const AGENT_ROLE_INPUTS = Object.freeze(Object.fromEntries( const WORKFLOW_AGENT_ROLES = Object.freeze({ 'copilot_issue.yml': ['planner'], 'copilot_pull_request.yml': ['planner', 'reviewer'], + 'copilot_pull_request_review_state.yml': ['planner', 'reviewer'], 'copilot_commit.yml': ['findings'], 'copilot_issue_comment.yml': ['findings', 'fixer', 'planner', 'reviewer', 'tester'], 'copilot_pull_request_comment.yml': ['findings', 'fixer', 'planner', 'reviewer', 'tester'], @@ -103,6 +113,7 @@ const REPOSITORY_BUGBOT_WORKFLOW_FILES = new Set([ 'copilot_issue.yml', 'copilot_issue_comment.yml', 'copilot_pull_request.yml', + 'copilot_pull_request_review_state.yml', 'copilot_pull_request_comment.yml', ]); @@ -281,7 +292,7 @@ function assertNoJobLevelSecrets(file, workflow) { function assertAgentWorkflowPermissions(file, workflow) { const relativeFile = relativeWorkflow(file); if (!WORKFLOW_AGENT_ROLES[path.basename(file)]) return; - const expectedPermissions = path.basename(file) === 'copilot_pull_request.yml' + const expectedPermissions = ['copilot_pull_request.yml', 'copilot_pull_request_review_state.yml'].includes(path.basename(file)) ? { checks: 'write', contents: 'read' } : { contents: 'read' }; for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) { @@ -384,29 +395,41 @@ function assertDirectEventTriggers(file, workflow) { if (triggers && typeof triggers === 'object' && Object.prototype.hasOwnProperty.call(triggers, 'workflow_run')) { throw new Error(`${relativeFile} must use direct event triggers and must not define workflow_run.`); } - if (!relativeFile.endsWith('/copilot_pull_request.yml')) return; - if (!triggers.pull_request || !triggers.pull_request_review) { - throw new Error(`${relativeFile} must define direct pull_request and pull_request_review triggers.`); - } - const pullRequestTypes = triggers.pull_request.types; - if (!Array.isArray(pullRequestTypes) - || pullRequestTypes.includes('edited') - || ['opened', 'reopened', 'closed', 'synchronize'].some(type => !pullRequestTypes.includes(type))) { - throw new Error(`${relativeFile} must handle code/lifecycle PR events without subscribing to metadata-only edited events.`); - } - if (triggers.merge_group || workflow.jobs?.['copilot-merge-group']) { - throw new Error(`${relativeFile} must keep merge-group compatibility in the dedicated pull-request merge-queue workflow so normal PR runs do not show a skipped duplicate check.`); + if (relativeFile.endsWith('/copilot_pull_request.yml')) { + if (!triggers.pull_request || triggers.pull_request_review) { + throw new Error(`${relativeFile} must define only direct pull_request code/lifecycle triggers.`); + } + const pullRequestTypes = triggers.pull_request.types; + if (!Array.isArray(pullRequestTypes) + || pullRequestTypes.includes('edited') + || ['opened', 'reopened', 'closed', 'synchronize'].some(type => !pullRequestTypes.includes(type))) { + throw new Error(`${relativeFile} must handle code/lifecycle PR events without subscribing to metadata-only edited events.`); + } + if (triggers.merge_group || workflow.jobs?.['copilot-merge-group']) { + throw new Error(`${relativeFile} must keep merge-group compatibility in the dedicated pull-request merge-queue workflow so normal PR runs do not show a skipped duplicate check.`); + } + if (workflow.jobs?.['copilot-pull-requests']?.name !== 'Copilot - Pull Request') { + throw new Error(`${relativeFile} must preserve the exact normal PR analysis check identity.`); + } + } else if (relativeFile.endsWith('/copilot_pull_request_review_state.yml')) { + const reviewTypes = triggers.pull_request_review?.types; + if (triggers.pull_request + || !Array.isArray(reviewTypes) + || ['submitted', 'edited', 'dismissed'].some(type => !reviewTypes.includes(type))) { + throw new Error(`${relativeFile} must define only direct pull_request_review state triggers.`); + } + if (workflow.name !== 'Copilot - Pull Request Review State' + || workflow.jobs?.['copilot-pull-request-review-state']?.name !== 'Copilot - Pull Request Review State') { + throw new Error(`${relativeFile} must preserve the exact review-state workflow and check identity.`); + } + } else { + return; } if (typeof workflow['run-name'] !== 'string' || !workflow['run-name'].includes('github.event_name') || !workflow['run-name'].includes('github.event.action')) { throw new Error(`${relativeFile} must expose the event kind and action in its run identity.`); } - const pullRequestJobName = workflow.jobs?.['copilot-pull-requests']?.name; - const expectedPullRequestJobName = "${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }}"; - if (pullRequestJobName !== expectedPullRequestJobName) { - throw new Error(`${relativeFile} must give review-state events their exact distinct check identity while preserving the normal PR analysis identity.`); - } } function assertPullRequestMergeQueueWorkflow(file, workflow) { diff --git a/setup/workflows/copilot_pull_request.yml b/setup/workflows/copilot_pull_request.yml index 5356db0cd..f6aea6e8b 100644 --- a/setup/workflows/copilot_pull_request.yml +++ b/setup/workflows/copilot_pull_request.yml @@ -4,17 +4,15 @@ run-name: Copilot PR ยท ${{ github.event_name }}:${{ github.event.action }} on: pull_request: types: [opened, reopened, closed, synchronize] - pull_request_review: - types: [submitted, edited, dismissed] jobs: copilot-pull-requests: if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} - name: ${{ github.event_name == 'pull_request_review' && 'Copilot - Pull Request Review State' || 'Copilot - Pull Request' }} + name: Copilot - Pull Request runs-on: ubuntu-latest timeout-minutes: 120 concurrency: - group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }} + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis cancel-in-progress: true permissions: checks: write diff --git a/setup/workflows/copilot_pull_request_review_state.yml b/setup/workflows/copilot_pull_request_review_state.yml new file mode 100644 index 000000000..9ff978566 --- /dev/null +++ b/setup/workflows/copilot_pull_request_review_state.yml @@ -0,0 +1,113 @@ +name: Copilot - Pull Request Review State +run-name: Copilot PR review ยท ${{ github.event_name }}:${{ github.event.action }} + +on: + pull_request_review: + types: [submitted, edited, dismissed] + +jobs: + copilot-pull-request-review-state: + if: ${{ (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN) && github.event.pull_request.head.repo.full_name == github.repository }} + name: Copilot - Pull Request Review State + runs-on: ubuntu-latest + timeout-minutes: 120 + concurrency: + group: copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-review-state + cancel-in-progress: true + permissions: + checks: write + contents: read + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Node.js 24 for pinned agent installation + uses: actions/setup-node@v7 + with: + node-version: '24.x' + + - uses: vypdev/copilot@v3 + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issues-locale: ${{ vars.ISSUES_LOCALE || '' }} + pull-requests-locale: ${{ vars.PULL_REQUESTS_LOCALE || '' }} + ai-ignore-files: ${{ vars.AI_IGNORE_FILES || 'build/*' }} + debug: ${{ vars.DEBUG }} + main-branch: ${{ vars.MAIN_BRANCH || 'master' }} + development-branch: ${{ vars.DEVELOPMENT_BRANCH || 'develop' }} + feature-tree: ${{ vars.FEATURE_TREE || 'feature' }} + bugfix-tree: ${{ vars.BUGFIX_TREE || 'bugfix' }} + hotfix-tree: ${{ vars.HOTFIX_TREE || 'hotfix' }} + release-tree: ${{ vars.RELEASE_TREE || 'release' }} + docs-tree: ${{ vars.DOCS_TREE || 'docs' }} + chore-tree: ${{ vars.CHORE_TREE || 'chore' }} + branch-management-always: ${{ vars.BRANCH_MANAGEMENT_ALWAYS || 'false' }} + reopen-issue-on-push: ${{ vars.REOPEN_ISSUE_ON_PUSH || 'true' }} + desired-assignees-count: ${{ vars.DESIRED_ASSIGNEES_COUNT || '1' }} + desired-reviewers-count: ${{ vars.DESIRED_REVIEWERS_COUNT || '1' }} + commit-prefix-transforms: ${{ vars.COMMIT_PREFIX_TRANSFORMS || 'replace-slash' }} + ai-pull-request-description-mode: ${{ vars.AI_PULL_REQUEST_DESCRIPTION_MODE || 'replace' }} + ai-members-only: ${{ vars.AI_MEMBERS_ONLY || 'false' }} + ai-include-reasoning: ${{ vars.AI_INCLUDE_REASONING || 'false' }} + bugbot-severity: ${{ vars.BUGBOT_SEVERITY || 'low' }} + bugbot-comment-limit: ${{ vars.BUGBOT_COMMENT_LIMIT || '20' }} + bugbot-fix-verify-commands: ${{ vars.BUGBOT_AUTOFIX_VERIFY_COMMANDS }} + bugbot-dry-run: ${{ vars.BUGBOT_DRY_RUN || 'false' }} + bugbot-effort: ${{ vars.BUGBOT_EFFORT || 'smart' }} + bugbot-review-drafts: ${{ vars.BUGBOT_REVIEW_DRAFTS || 'false' }} + bugbot-trace-rules: ${{ vars.BUGBOT_TRACE_RULES || 'false' }} + bugbot-suggested-changes: ${{ vars.BUGBOT_SUGGESTED_CHANGES || 'true' }} + bugbot-telemetry: ${{ vars.BUGBOT_TELEMETRY || 'true' }} + bugbot-fail-on-unresolved: ${{ vars.BUGBOT_FAIL_ON_UNRESOLVED || 'false' }} + bugbot-organization-rules: ${{ vars.BUGBOT_ORGANIZATION_RULES }} + project-column-issue-created: ${{ vars.PROJECT_COLUMN_ISSUE_CREATED || 'Todo' }} + project-column-pull-request-created: ${{ vars.PROJECT_COLUMN_PULL_REQUEST_CREATED || 'In Progress' }} + project-column-issue-in-progress: ${{ vars.PROJECT_COLUMN_ISSUE_IN_PROGRESS || 'In Progress' }} + project-column-pull-request-in-progress: ${{ vars.PROJECT_COLUMN_PULL_REQUEST_IN_PROGRESS || 'In Progress' }} + agent-provider: ${{ vars.AGENT_PROVIDER || 'codex' }} + agent-model-provider: ${{ vars.AGENT_MODEL_PROVIDER || 'openai' }} + agent-model: ${{ vars.AGENT_MODEL || 'gpt-5.6-luna' }} + agent-effort: ${{ vars.AGENT_EFFORT }} + agent-executable: ${{ vars.AGENT_EXECUTABLE }} + findings-provider: ${{ vars.FINDINGS_PROVIDER }} + findings-model-provider: ${{ vars.FINDINGS_MODEL_PROVIDER }} + findings-model: ${{ vars.FINDINGS_MODEL }} + findings-effort: ${{ vars.FINDINGS_EFFORT }} + findings-executable: ${{ vars.FINDINGS_EXECUTABLE }} + fixer-provider: ${{ vars.FIXER_PROVIDER }} + fixer-model-provider: ${{ vars.FIXER_MODEL_PROVIDER }} + fixer-model: ${{ vars.FIXER_MODEL }} + fixer-effort: ${{ vars.FIXER_EFFORT }} + fixer-executable: ${{ vars.FIXER_EXECUTABLE }} + planner-provider: ${{ vars.PLANNER_PROVIDER }} + planner-model-provider: ${{ vars.PLANNER_MODEL_PROVIDER }} + planner-model: ${{ vars.PLANNER_MODEL }} + planner-effort: ${{ vars.PLANNER_EFFORT }} + planner-executable: ${{ vars.PLANNER_EXECUTABLE }} + reviewer-provider: ${{ vars.REVIEWER_PROVIDER }} + reviewer-model-provider: ${{ vars.REVIEWER_MODEL_PROVIDER }} + reviewer-model: ${{ vars.REVIEWER_MODEL }} + reviewer-effort: ${{ vars.REVIEWER_EFFORT }} + reviewer-executable: ${{ vars.REVIEWER_EXECUTABLE }} + project-ids: ${{ vars.PROJECT_IDS }} + token: ${{ secrets.PAT }} + env: + AGENT_PROVIDER: ${{ vars.AGENT_PROVIDER || 'codex' }} + AGENT_MODEL_PROVIDER: ${{ vars.AGENT_MODEL_PROVIDER || 'openai' }} + AGENT_MODEL: ${{ vars.AGENT_MODEL }} + AGENT_EFFORT: ${{ vars.AGENT_EFFORT }} + AGENT_PROVISIONING: ${{ vars.AGENT_PROVISIONING || 'auto' }} + AGENT_ALLOWED_MODEL_PROVIDERS: ${{ vars.AGENT_ALLOWED_MODEL_PROVIDERS || 'openai' }} + AGENT_ALLOWED_MODELS: ${{ vars.AGENT_ALLOWED_MODELS || 'openai/gpt-5.6-luna' }} + AGENT_EXECUTABLE: ${{ vars.AGENT_EXECUTABLE }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + CURSOR_API_KEY: ${{ (vars.AGENT_PROVIDER == 'cursor' || vars.FINDINGS_PROVIDER == 'cursor' || vars.FIXER_PROVIDER == 'cursor' || vars.PLANNER_PROVIDER == 'cursor' || vars.REVIEWER_PROVIDER == 'cursor' || vars.TESTER_PROVIDER == 'cursor') && secrets.CURSOR_API_KEY || '' }} + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }} + COPILOT_EVIDENCE_TOKEN: ${{ github.token }} diff --git a/specs/CATALOG.md b/specs/CATALOG.md index b3d96ff86..e96a30108 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,7 +10,7 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| -| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 148 paths ยท 2026-09-15 | +| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 149 paths ยท 2026-09-15 | | `release-orchestration` | Implemented | Release and hotfix promotion, publication, reconciliation, and durable recovery | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 52 paths ยท 2026-09-14 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 24 paths ยท 2026-09-15 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths ยท 2026-09-15 | @@ -32,7 +32,7 @@ debt or convert unknown historic intent into a design decision. - Owner: Copilot maintainers - Last verified: 2026-09-15 - Specifications: [`specs/semantic-github-publication-and-notification.md`](./semantic-github-publication-and-notification.md) ยท [`specs/repository-locale-and-localization.md`](./repository-locale-and-localization.md) -- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) ยท [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) ยท [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) +- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) ยท [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) ยท [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) ยท [`.github/workflows/copilot_pull_request_review_state.yml`](../.github/workflows/copilot_pull_request_review_state.yml) ยท [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) ยท [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) ยท [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) ยท [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) ยท [`src/api.ts`](../src/api.ts) ยท [`src/cli.ts`](../src/cli.ts) - Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) ยท [`src/domain/locale.ts`](../src/domain/locale.ts) ยท [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) ยท [`src/data/model/locale.ts`](../src/data/model/locale.ts) ยท [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) ยท [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) ยท [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) ยท [`src/application/policies/action_summary_message_catalog.ts`](../src/application/policies/action_summary_message_catalog.ts) ยท [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) ยท [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) ยท [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) ยท [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) ยท [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) ยท [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) ยท [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) ยท [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) ยท [`src/application/policies/comment_translation_policy.ts`](../src/application/policies/comment_translation_policy.ts) ยท [`src/application/usecases/steps/common/think_request_policy.ts`](../src/application/usecases/steps/common/think_request_policy.ts) ยท [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) ยท [`src/application/usecases/steps/common/think_answer_workflow.ts`](../src/application/usecases/steps/common/think_answer_workflow.ts) ยท [`src/application/usecases/steps/common/think_use_case.ts`](../src/application/usecases/steps/common/think_use_case.ts) ยท [`src/application/usecases/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) ยท [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) ยท [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) ยท [`src/application/policies/publication_identity_policy.ts`](../src/application/policies/publication_identity_policy.ts) ยท [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) ยท [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) ยท [`src/application/usecases/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) ยท [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) ยท [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) ยท [`src/application/usecases/steps/issue/answer_issue_help_use_case.ts`](../src/application/usecases/steps/issue/answer_issue_help_use_case.ts) ยท [`src/application/usecases/steps/issue/answer_issue_help_workflow.ts`](../src/application/usecases/steps/issue/answer_issue_help_workflow.ts) ยท [`src/application/ports/issue_lifecycle_ports.ts`](../src/application/ports/issue_lifecycle_ports.ts) ยท [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) ยท [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) ยท [`src/actions/local_action.ts`](../src/actions/local_action.ts) ยท [`src/actions/local_action_output.ts`](../src/actions/local_action_output.ts) ยท [`src/cli/commands/think.ts`](../src/cli/commands/think.ts) ยท [`src/cli/commands/think_command_handler.ts`](../src/cli/commands/think_command_handler.ts) ยท [`src/infrastructure/composition/local_action_composition_root.ts`](../src/infrastructure/composition/local_action_composition_root.ts) ยท [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) ยท [`src/infrastructure/composition/issue_use_case_composition_root.ts`](../src/infrastructure/composition/issue_use_case_composition_root.ts) ยท [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) ยท [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) ยท [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) ยท [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) ยท [`src/application/policies/bugbot_message_catalog.ts`](../src/application/policies/bugbot_message_catalog.ts) ยท [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) ยท [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) ยท [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) ยท [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) ยท [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) ยท [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) ยท [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) ยท [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) ยท [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) ยท [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) - Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) ยท [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) ยท [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) ยท [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) ยท [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) ยท [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) ยท [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) ยท [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) ยท [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) ยท [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) ยท [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) ยท [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) ยท [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) ยท [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) ยท [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) ยท [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) ยท [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) ยท [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) ยท [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) ยท [`src/application/policies/__tests__/bugbot_message_catalog.test.ts`](../src/application/policies/__tests__/bugbot_message_catalog.test.ts) ยท [`src/application/policies/__tests__/deployment_message_catalog.test.ts`](../src/application/policies/__tests__/deployment_message_catalog.test.ts) ยท [`src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts`](../src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts) ยท [`src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts) ยท [`src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts) ยท [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) ยท [`src/application/policies/__tests__/deployment_presentation_policy.test.ts`](../src/application/policies/__tests__/deployment_presentation_policy.test.ts) ยท [`src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts) ยท [`src/application/usecases/steps/common/__tests__/think_request_policy.test.ts`](../src/application/usecases/steps/common/__tests__/think_request_policy.test.ts) ยท [`src/application/usecases/steps/common/__tests__/think_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/think_use_case.test.ts) ยท [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) ยท [`src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts) ยท [`src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts) ยท [`src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts) ยท [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) ยท [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) ยท [`src/actions/__tests__/local_action.test.ts`](../src/actions/__tests__/local_action.test.ts) ยท [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) ยท [`src/cli/commands/__tests__/think_command_handler.test.ts`](../src/cli/commands/__tests__/think_command_handler.test.ts) ยท [`src/infrastructure/composition/__tests__/local_action_composition_root.test.ts`](../src/infrastructure/composition/__tests__/local_action_composition_root.test.ts) ยท [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) ยท [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) ยท [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) ยท [`src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts) ยท [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) ยท [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) ยท [`src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts`](../src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts) ยท [`src/application/usecases/actions/__tests__/check_progress_use_case.test.ts`](../src/application/usecases/actions/__tests__/check_progress_use_case.test.ts) ยท [`src/application/usecases/__tests__/comment_automation_use_case.test.ts`](../src/application/usecases/__tests__/comment_automation_use_case.test.ts) diff --git a/specs/bugbot-review-state-reconciliation.md b/specs/bugbot-review-state-reconciliation.md index 05923a0a1..3cbb5b750 100644 --- a/specs/bugbot-review-state-reconciliation.md +++ b/specs/bugbot-review-state-reconciliation.md @@ -232,11 +232,15 @@ reported through its native workflow and Job Summary. Generic publication now uses one explicit mode: `pull_request: edited` is `omit-metadata-only`, a real Bugbot result is `omit-feature-owned`, and other routes remain `publish`. -PR #378 exposed the final workflow-level noise: Copilot's own description update -still emitted redundant skipped `pull_request: edited` runs, and analysis, -review-state, and merge-queue jobs shared an ambiguous visible name. The current -contract excludes metadata-only edited events from the supplied PR workflow and -uses event/action run names plus a distinct review-state job identity. A +PR #378 exposed workflow-level noise: Copilot's own description update still +emitted redundant skipped `pull_request: edited` runs, and analysis, review-state, +and merge-queue jobs shared an ambiguous visible name. The contract excludes +metadata-only edited events from the supplied PR analysis workflow and uses +event/action run names plus a distinct review-state identity. Live PR #379 then +showed that a dynamic job name can appear as its unevaluated expression when a +bot-authored review is skipped before job admission. Review-state observation +therefore has a dedicated workflow and fixed job name as well as its own +concurrency lane. A dedicated merge-group workflow removes the skipped duplicate from normal PR runs while intentionally retaining the same required-check context so branch protection continues to resolve it. The quiet application publication mode @@ -483,9 +487,10 @@ readiness. `unknown` is a system failure and fails the review regardless of updated again. - A run whose analyzed head is no longer the PR head MUST return superseded and MUST NOT mutate findings or current-state projections. -- Shipped Commit and Pull Request workflows MUST use distinct branch-scoped - concurrency groups. Pull Request code/lifecycle and review-state events MUST - also use separate lane suffixes. Each uses cancel-in-progress semantics only +- Shipped Commit, Pull Request analysis, and Pull Request review-state workflows + MUST use distinct branch-scoped concurrency groups. Pull Request code/lifecycle + and review-state events MUST use separate lane suffixes and dedicated workflow + files with fixed job names. Each uses cancel-in-progress semantics only for its own replaceable revisions, so review-state observation cannot cancel code analysis. On push, a read-only exact-head preflight MUST validate any open same-repository PR before Bugbot loads review context or invokes the @@ -741,9 +746,10 @@ presentation pattern: - Existing bot-owned review bodies are adoptable only when ownership is proven by current bot author plus trusted child finding markers or trusted review-level finding markers. -- Shipped PR and commit workflows use distinct normalized repository/branch - concurrency keys. PR code/lifecycle and review-state events cancel obsolete - PR-lane work; metadata-only `pull_request: edited` is not subscribed. The +- Shipped PR analysis, PR review-state, and commit workflows use distinct + normalized repository/branch concurrency keys. Code/lifecycle and review-state + events cancel obsolete work only in their respective lanes; metadata-only + `pull_request: edited` is not subscribed. The application still performs remote head checks. - The Review Check is single-purpose evidence. Metadata-only PR lifecycle runs publish no same-name Check and therefore cannot supersede the latest analyzed diff --git a/specs/catalog.json b/specs/catalog.json index a62ea1b5d..7e7309bb4 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -16,6 +16,7 @@ ".github/workflows/copilot_issue.yml", ".github/workflows/copilot_issue_comment.yml", ".github/workflows/copilot_pull_request.yml", + ".github/workflows/copilot_pull_request_review_state.yml", ".github/workflows/copilot_pull_request_comment.yml", ".github/workflows/copilot_commit.yml", ".github/workflows/copilot_deployment_orchestration.yml" diff --git a/specs/issue-and-pull-request-context-hardening.md b/specs/issue-and-pull-request-context-hardening.md index 0fb69ad2f..a5ecb2f53 100644 --- a/specs/issue-and-pull-request-context-hardening.md +++ b/specs/issue-and-pull-request-context-hardening.md @@ -30,8 +30,8 @@ requests. That operation MUST use a repository-owned URL/query, a durable hidden marker, ordered compensation, and truthful partial-state results. Event-provided URLs MUST never be fetched. -PR code/lifecycle and review-state events MUST share the PR-specific branch -serialization boundary. The supplied workflow MUST NOT subscribe to +PR code/lifecycle and review-state events MUST use separate, PR-specific branch +serialization lanes and dedicated workflows. The supplied analysis workflow MUST NOT subscribe to metadata-only `pull_request: edited`, because Copilot's own description update would create a redundant run; human metadata edits remain untouched. Commit uses a distinct push-specific boundary. Its Bugbot path MUST perform a provider-backed, @@ -39,9 +39,10 @@ exact-head, same-repository preflight and stop before review-context loading or agent invocation when that selection proves an open PR exists. It MUST NOT infer PR ownership from the push payload. PR synchronization then exclusively owns review for that head. -A newer PR or review-state event MAY cancel an obsolete PR run. PR analysis, -review-state observation, and merge-queue admission MUST expose distinct run -identities; review state MUST also use its own job/check name. Normal PR and +A newer event MAY cancel an obsolete run only within its own lane. PR analysis, +review-state observation, and merge-queue admission MUST expose distinct, +fixed run identities; review state MUST also use its own workflow and job/check +name. Normal PR and merge-group jobs MUST share the configured required-check name so GitHub can satisfy the same branch-protection rule in both contexts. The merge-group job MUST live in its own workflow so PR runs do not expose a skipped duplicate @@ -370,7 +371,7 @@ review cannot cancel code analysis. The supplied PR workflow excludes `pull_request: edited`, preventing body/title-only mutations from entering either lane. Application head guards remain mandatory. -This rule is identical in the repository workflow and the shipped setup copy. +These rules are identical in the repository workflows and the shipped setup copies. It is not configurable because admitting self-generated metadata events creates noise and can obscure code analysis. diff --git a/src/application/policies/__tests__/setup_configuration_policy.test.ts b/src/application/policies/__tests__/setup_configuration_policy.test.ts index 3d3dcf0e5..1292919f2 100644 --- a/src/application/policies/__tests__/setup_configuration_policy.test.ts +++ b/src/application/policies/__tests__/setup_configuration_policy.test.ts @@ -19,9 +19,9 @@ describe('setup configuration policy', () => { const configuration = createDefaultSetupConfiguration(); const plan = buildSetupPlan(configuration); - expect(plan.workflowFiles).toHaveLength(12); + expect(plan.workflowFiles).toHaveLength(13); expect(plan.issueTemplateFiles).toHaveLength(8); - expect(plan.selectedFiles).toHaveLength(21); + expect(plan.selectedFiles).toHaveLength(22); expect(plan.variables).toEqual(expect.arrayContaining([ { name: 'AGENT_PROVIDER', value: 'codex' }, { name: 'AGENT_ALLOWED_MODELS', value: 'openai/gpt-5.6-luna' }, @@ -88,12 +88,13 @@ describe('setup configuration policy', () => { expect(plan.workflowFiles).toEqual(expect.arrayContaining([ 'copilot_issue.yml', 'copilot_pull_request.yml', + 'copilot_pull_request_review_state.yml', 'copilot_pull_request_merge_queue.yml', 'copilot_commit.yml', 'copilot_branch_sync.yml', ])); expect(plan.workflowFiles).not.toContain('release_workflow.yml'); - expect(plan.selectedFiles).toHaveLength(8); + expect(plan.selectedFiles).toHaveLength(9); }); it('keeps inactivity closure opt-in and wires its threshold when enabled', () => { diff --git a/src/domain/setup_workflow_catalog.ts b/src/domain/setup_workflow_catalog.ts index ab1fd3bcb..3d126a531 100644 --- a/src/domain/setup_workflow_catalog.ts +++ b/src/domain/setup_workflow_catalog.ts @@ -8,6 +8,7 @@ interface SetupWorkflowDefinition { const SETUP_WORKFLOWS: readonly SetupWorkflowDefinition[] = [ { file: 'copilot_issue.yml', feature: 'issues' }, { file: 'copilot_pull_request.yml', feature: 'pullRequests' }, + { file: 'copilot_pull_request_review_state.yml', feature: 'pullRequests' }, { file: 'copilot_pull_request_merge_queue.yml', feature: 'pullRequests' }, { file: 'copilot_commit.yml', feature: 'commits' }, { file: 'copilot_branch_sync.yml', feature: 'commits' }, diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index 9c5856b27..26bd25ff3 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -129,11 +129,12 @@ describe('workflow contract validator', () => { jobs: Record; }; expect(workflow.concurrency).toBeUndefined(); - if (['copilot_commit.yml', 'copilot_pull_request.yml'].includes(manifest.file)) { + if (['copilot_commit.yml', 'copilot_pull_request.yml', 'copilot_pull_request_review_state.yml'].includes(manifest.file)) { + const expectedGroup = manifest.file === 'copilot_commit.yml' + ? 'copilot-push-${{ github.repository }}-${{ github.ref_name }}' + : `copilot-pr-${'${{ github.repository }}'}-${'${{ github.event.pull_request.head.ref || github.ref_name }}'}-${manifest.file === 'copilot_pull_request.yml' ? 'analysis' : 'review-state'}`; expect(workflow.jobs[manifest.jobId].concurrency).toEqual({ - group: manifest.file === 'copilot_pull_request.yml' - ? "copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-${{ github.event_name == 'pull_request_review' && 'review-state' || 'analysis' }}" - : 'copilot-push-${{ github.repository }}-${{ github.ref_name }}', + group: expectedGroup, 'cancel-in-progress': true, }); } else { @@ -144,12 +145,15 @@ describe('workflow contract validator', () => { } }); - it.each(['.github/workflows', 'setup/workflows'])( - 'rejects pull-request workflows that do not cancel superseded code events in %s', - (directory) => { - const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); + it.each(['.github/workflows', 'setup/workflows'].flatMap(directory => [ + [directory, 'copilot_pull_request.yml', 'copilot-pull-requests'], + [directory, 'copilot_pull_request_review_state.yml', 'copilot-pull-request-review-state'], + ]))( + 'rejects pull-request workflows that do not cancel superseded events in %s/%s', + (directory, fileName, jobId) => { + const file = path.join(process.cwd(), directory, fileName); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; - workflow.jobs['copilot-pull-requests'].concurrency['cancel-in-progress'] = false; + workflow.jobs[jobId].concurrency['cancel-in-progress'] = false; expect(() => validateWorkflow(file, workflow)).toThrow('cancel superseded runs'); }, @@ -158,6 +162,7 @@ describe('workflow contract validator', () => { it.each([ ['copilot_commit.yml', 'copilot-commits', 'copilot-pr-${{ github.repository }}-${{ github.ref_name }}'], ['copilot_pull_request.yml', 'copilot-pull-requests', 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'], + ['copilot_pull_request_review_state.yml', 'copilot-pull-request-review-state', 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}-analysis'], ])( 'rejects a cross-workflow concurrency group in %s', (workflowFile, jobId, sharedGroup) => { @@ -199,29 +204,38 @@ describe('workflow contract validator', () => { }, ); - it.each(['copilot_pull_request.yml', 'copilot_pull_request_comment.yml'])( + it.each(['copilot_pull_request.yml', 'copilot_pull_request_review_state.yml', 'copilot_pull_request_comment.yml'])( 'requires same-repository PR gating for %s', (fileName) => { for (const directory of ['.github/workflows', 'setup/workflows']) { const file = path.join(process.cwd(), directory, fileName); const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; - const job = workflow.jobs['copilot-pull-requests']; + const jobId = fileName === 'copilot_pull_request_review_state.yml' + ? 'copilot-pull-request-review-state' + : 'copilot-pull-requests'; + const job = workflow.jobs[jobId]; job.if = "${{ vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }}"; expect(() => validateWorkflow(file, workflow)).toThrow('same-repository PR gate'); } }, ); - it('rejects workflow_run and requires direct PR/review events in both distributed variants', () => { + it('rejects workflow_run and keeps PR analysis separate from review-state events', () => { for (const directory of ['.github/workflows', 'setup/workflows']) { - const file = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); - const workflow = yaml.load(readFileSync(file, 'utf8')) as Record; - expect(() => assertDirectEventTriggers(file, workflow)).not.toThrow(); - workflow.on.workflow_run = { types: ['completed'] }; - expect(() => assertDirectEventTriggers(file, workflow)).toThrow('must not define workflow_run'); - delete workflow.on.workflow_run; - delete workflow.on.pull_request_review; - expect(() => assertDirectEventTriggers(file, workflow)).toThrow('direct pull_request and pull_request_review'); + const analysisFile = path.join(process.cwd(), directory, 'copilot_pull_request.yml'); + const analysis = yaml.load(readFileSync(analysisFile, 'utf8')) as Record; + expect(() => assertDirectEventTriggers(analysisFile, analysis)).not.toThrow(); + analysis.on.pull_request_review = { types: ['submitted'] }; + expect(() => assertDirectEventTriggers(analysisFile, analysis)).toThrow('only direct pull_request'); + + const reviewFile = path.join(process.cwd(), directory, 'copilot_pull_request_review_state.yml'); + const review = yaml.load(readFileSync(reviewFile, 'utf8')) as Record; + expect(() => assertDirectEventTriggers(reviewFile, review)).not.toThrow(); + review.on.workflow_run = { types: ['completed'] }; + expect(() => assertDirectEventTriggers(reviewFile, review)).toThrow('must not define workflow_run'); + delete review.on.workflow_run; + review.on.pull_request = { types: ['synchronize'] }; + expect(() => assertDirectEventTriggers(reviewFile, review)).toThrow('only direct pull_request_review'); } }); @@ -239,8 +253,19 @@ describe('workflow contract validator', () => { expect(() => assertDirectEventTriggers(file, workflow)).toThrow('dedicated pull-request merge-queue workflow'); delete workflow.on.merge_group; - workflow.jobs['copilot-pull-requests'].name = 'Copilot - Pull Request'; - expect(() => assertDirectEventTriggers(file, workflow)).toThrow('review-state events'); + workflow.jobs['copilot-pull-requests'].name = 'Unsafe dynamic identity'; + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('normal PR analysis check identity'); + }, + ); + + it.each(['.github/workflows', 'setup/workflows'])( + 'rejects a non-fixed review-state identity in %s', + (directory) => { + const file = path.join(process.cwd(), directory, 'copilot_pull_request_review_state.yml'); + const workflow = yaml.load(readFileSync(file, 'utf8')) as MutationWorkflow; + workflow.jobs['copilot-pull-request-review-state'].name = '${{ github.event.action }}'; + + expect(() => assertDirectEventTriggers(file, workflow)).toThrow('review-state workflow and check identity'); }, );