diff --git a/.github/workflows/copilot_issue.yml b/.github/workflows/copilot_issue.yml index beef7c2a3..8414fda3b 100644 --- a/.github/workflows/copilot_issue.yml +++ b/.github/workflows/copilot_issue.yml @@ -4,9 +4,45 @@ on: issues: types: [opened, reopened, closed, edited, labeled, unlabeled, assigned, unassigned] +# Keep every pending lifecycle event; never replace a start/reopen with a label event. +# GitHub bounds this queue at 100 pending runs. Independent issues use different groups. +concurrency: + group: copilot-issue-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false + queue: max + jobs: + issue-admission: + name: Admit issue event + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + execute: ${{ steps.inspect.outputs.execute }} + receipt-key: ${{ steps.inspect.outputs.receipt-key }} + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Inspect native issue event + id: inspect + uses: ./issue-admission + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issue-event-admission-mode: inspect + token: ${{ secrets.PAT }} + configuration: ${{ format('[{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18},{19},{20},{21},{22},{23},{24},{25},{26},{27},{28},{29},{30},{31},{32},{33},{34},{35},{36},{37},{38},{39}]', toJSON(vars.AGENT_ALLOWED_MODELS), toJSON(vars.AGENT_ALLOWED_MODEL_PROVIDERS), toJSON(vars.AGENT_EFFORT), toJSON(vars.AGENT_EXECUTABLE), toJSON(vars.AGENT_MODEL), toJSON(vars.AGENT_MODEL_PROVIDER), toJSON(vars.AGENT_PROVIDER), toJSON(vars.BUGBOT_DRY_RUN), toJSON(vars.BUGBOT_EFFORT), toJSON(vars.BUGBOT_FAIL_ON_UNRESOLVED), toJSON(vars.BUGBOT_ORGANIZATION_RULES), toJSON(vars.BUGBOT_REVIEW_DRAFTS), toJSON(vars.BUGBOT_SUGGESTED_CHANGES), toJSON(vars.BUGBOT_TELEMETRY), toJSON(vars.BUGBOT_TRACE_RULES), toJSON(vars.COPILOT_ISSUE_WORKFLOW_PROFILE), toJSON(vars.DEBUG), toJSON(vars.DEPLOY_LABEL), toJSON(vars.FINDINGS_EFFORT), toJSON(vars.FINDINGS_EXECUTABLE), toJSON(vars.FINDINGS_MODEL), toJSON(vars.FINDINGS_MODEL_PROVIDER), toJSON(vars.FINDINGS_PROVIDER), toJSON(vars.FIXER_EFFORT), toJSON(vars.FIXER_EXECUTABLE), toJSON(vars.FIXER_MODEL), toJSON(vars.FIXER_MODEL_PROVIDER), toJSON(vars.FIXER_PROVIDER), toJSON(vars.ISSUES_LOCALE), toJSON(vars.PLANNER_EFFORT), toJSON(vars.PLANNER_EXECUTABLE), toJSON(vars.PLANNER_MODEL), toJSON(vars.PLANNER_MODEL_PROVIDER), toJSON(vars.PLANNER_PROVIDER), toJSON(vars.PROJECT_IDS), toJSON(vars.PULL_REQUESTS_LOCALE), toJSON(vars.REPOSITORY_LOCALE), toJSON(vars.REVIEWER_PROVIDER), toJSON(vars.TESTER_PROVIDER), toJSON(vars.VERIFICATION_SETUP)) }} + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} + env: + COPILOT_ISSUE_ADMISSION_TOKEN: ${{ github.token }} + copilot-issues: - if: ${{ (github.event.action == 'closed' && github.event.issue.state == 'closed') || vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }} + needs: issue-admission + if: ${{ needs.issue-admission.outputs.execute == 'true' }} name: Copilot - Issue runs-on: [self-hosted, codex, Linux] timeout-minutes: 120 @@ -19,7 +55,9 @@ jobs: persist-credentials: false - uses: ./ + id: copilot with: + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} issues-locale: ${{ vars.ISSUES_LOCALE || '' }} pull-requests-locale: ${{ vars.PULL_REQUESTS_LOCALE || '' }} @@ -74,3 +112,26 @@ jobs: 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 }} + + - name: Record unchanged successful reconciliation + id: receipt + if: ${{ success() && steps.copilot.outcome == 'success' && needs.issue-admission.outputs.receipt-key != '' }} + continue-on-error: true + uses: ./issue-admission + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issue-event-admission-mode: record + token: ${{ secrets.PAT }} + configuration: ${{ format('[{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18},{19},{20},{21},{22},{23},{24},{25},{26},{27},{28},{29},{30},{31},{32},{33},{34},{35},{36},{37},{38},{39}]', toJSON(vars.AGENT_ALLOWED_MODELS), toJSON(vars.AGENT_ALLOWED_MODEL_PROVIDERS), toJSON(vars.AGENT_EFFORT), toJSON(vars.AGENT_EXECUTABLE), toJSON(vars.AGENT_MODEL), toJSON(vars.AGENT_MODEL_PROVIDER), toJSON(vars.AGENT_PROVIDER), toJSON(vars.BUGBOT_DRY_RUN), toJSON(vars.BUGBOT_EFFORT), toJSON(vars.BUGBOT_FAIL_ON_UNRESOLVED), toJSON(vars.BUGBOT_ORGANIZATION_RULES), toJSON(vars.BUGBOT_REVIEW_DRAFTS), toJSON(vars.BUGBOT_SUGGESTED_CHANGES), toJSON(vars.BUGBOT_TELEMETRY), toJSON(vars.BUGBOT_TRACE_RULES), toJSON(vars.COPILOT_ISSUE_WORKFLOW_PROFILE), toJSON(vars.DEBUG), toJSON(vars.DEPLOY_LABEL), toJSON(vars.FINDINGS_EFFORT), toJSON(vars.FINDINGS_EXECUTABLE), toJSON(vars.FINDINGS_MODEL), toJSON(vars.FINDINGS_MODEL_PROVIDER), toJSON(vars.FINDINGS_PROVIDER), toJSON(vars.FIXER_EFFORT), toJSON(vars.FIXER_EXECUTABLE), toJSON(vars.FIXER_MODEL), toJSON(vars.FIXER_MODEL_PROVIDER), toJSON(vars.FIXER_PROVIDER), toJSON(vars.ISSUES_LOCALE), toJSON(vars.PLANNER_EFFORT), toJSON(vars.PLANNER_EXECUTABLE), toJSON(vars.PLANNER_MODEL), toJSON(vars.PLANNER_MODEL_PROVIDER), toJSON(vars.PLANNER_PROVIDER), toJSON(vars.PROJECT_IDS), toJSON(vars.PULL_REQUESTS_LOCALE), toJSON(vars.REPOSITORY_LOCALE), toJSON(vars.REVIEWER_PROVIDER), toJSON(vars.TESTER_PROVIDER), toJSON(vars.VERIFICATION_SETUP)) }} + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} + receipt-key: ${{ needs.issue-admission.outputs.receipt-key }} + + - name: Retain successful reconciliation receipt + if: ${{ steps.receipt.outputs.receipt-path != '' }} + continue-on-error: true + uses: actions/upload-artifact@v5 + with: + name: ${{ steps.receipt.outputs.receipt-name }} + path: ${{ steps.receipt.outputs.receipt-path }} + retention-days: 1 + if-no-files-found: error diff --git a/build/cli/index.js b/build/cli/index.js index 81f8f8931..d40165c40 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -36828,6 +36828,7 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository, { isSingleAction: execution.isSingleAction, pullRequestNumber: execution.inputs?.pull_request?.number, + issueNumber: execution.inputs?.issue?.number, }); } const setupExecution = (0, execution_setup_composition_root_1.createSetupExecutionUseCase)(latestTagQueryPort, { @@ -37691,6 +37692,7 @@ function buildPreviousWorkflowRunsQuery(repository, event) { eventName: process.env.GITHUB_EVENT_NAME ?? '', ref: process.env.GITHUB_REF, pullRequestNumber: event.pullRequestNumber, + issueNumber: event.issueNumber, }), } : {}), }; @@ -45168,6 +45170,7 @@ function mergeSetupOverrides(fileOverrides, flagOverrides) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.expectedNativeWorkflowGroup = expectedNativeWorkflowGroup; exports.hasNativeWorkflowCoordination = hasNativeWorkflowCoordination; +const ISSUE_GROUP = 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}'; const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; /** Closed recognition of the installed contract, without evaluating expressions. */ @@ -45183,6 +45186,9 @@ function expectedNativeWorkflowGroup(query) { if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') && native.ref.length > 'refs/heads/'.length) return PUSH_GROUP; + if (native.eventName === 'issues') + return typeof native.issueNumber === 'number' + && Number.isSafeInteger(native.issueNumber) && native.issueNumber > 0 ? ISSUE_GROUP : undefined; const pullRequestNumber = native.pullRequestNumber; if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) return undefined; @@ -45197,6 +45203,11 @@ function hasNativeWorkflowCoordination(workflow, jobId, expectedGroup) { const jobs = record(definition?.jobs); const job = record(jobs?.[jobId]); const concurrency = record(job?.concurrency); + if (expectedGroup === ISSUE_GROUP) { + const outer = record(definition?.concurrency); + return Array.isArray(job?.steps) && job?.uses === undefined && job?.concurrency === undefined + && outer?.group === ISSUE_GROUP && outer?.['cancel-in-progress'] === false && outer?.queue === 'max'; + } // Custom/reusable/workflow-level contracts retain conservative polling. // A single direct job also avoids confusing a reusable callee's job ID // with a different protected job in its caller's workflow context. @@ -53949,6 +53960,8 @@ function setupRunnerRole(file, job) { 'copilot_pull_request.yml': 'copilot-pull-requests', 'copilot_pull_request_comment.yml': 'copilot-pull-requests', 'copilot_commit.yml': 'copilot-commits', 'agent-cli-provisioning.yml': 'verify-agent-clis', }; + if (file === 'copilot_issue.yml' && job === 'issue-admission') + return 'isolated'; if (agentJobs[file] === job) return 'agent'; if (file === 'copilot_pull_request_approval.yml' && job === 'observe-approval') @@ -107417,6 +107430,9 @@ class IssueContentInterface extends content_interface_1.ContentInterface { if (updated === undefined) { throw new Error('Issue content markers are missing or inconsistent.'); } + // Byte-identical state must not emit another native issue edit event. + if (updated === description) + return updated; await this.issueDescriptionPort.updateDescription(query.owner, query.repository, query.issueNumber, updated, query.token); return updated; } diff --git a/build/github_action/index.js b/build/github_action/index.js index 771769a19..993fb1fac 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -36813,6 +36813,7 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository, { isSingleAction: execution.isSingleAction, pullRequestNumber: execution.inputs?.pull_request?.number, + issueNumber: execution.inputs?.issue?.number, }); } const setupExecution = (0, execution_setup_composition_root_1.createSetupExecutionUseCase)(latestTagQueryPort, { @@ -37137,6 +37138,7 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.runGitHubAction = runGitHubAction; exports.runGitHubActionEntry = runGitHubActionEntry; +const issue_event_admission_action_1 = __nccwpck_require__(38016); const native_verification_controller_1 = __nccwpck_require__(71046); const issue_completion_composition_root_1 = __nccwpck_require__(63719); const core = __importStar(__nccwpck_require__(75855)); @@ -37178,6 +37180,12 @@ const pull_request_approval_action_1 = __nccwpck_require__(56480); const bot_pull_request_analysis_policy_1 = __nccwpck_require__(52801); const pull_request_use_case_composition_root_1 = __nccwpck_require__(70636); async function runGitHubAction() { + if (process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP === '1') { + // Only the isolated bootstrap owns this input; ordinary JSON input maps + // must not redirect the mutation entry into a successful read-only no-op. + await (0, issue_event_admission_action_1.runIssueEventAdmissionAction)(core.getInput('issue-event-admission-mode', { required: true })); + return; + } if ((0, input_boolean_policy_1.isEnabledInput)((0, github_action_input_1.getGithubActionInput)('pr-approval-observer'))) { await (0, pull_request_approval_action_1.runPullRequestApprovalAction)((0, github_action_input_1.getGithubActionInput)(input_keys_1.INPUT_KEYS.TOKEN, { required: true })); return; @@ -37346,7 +37354,7 @@ function earlyGithubActionErrorMessage() { } // Only auto-run when executed as the action entry (not when imported by tests) /* istanbul ignore next -- the bundled production entry is covered by the Action smoke path. */ -if (typeof process.env.JEST_WORKER_ID === 'undefined') { +if (typeof process.env.JEST_WORKER_ID === 'undefined' && process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP !== '1') { void runGitHubActionEntry(() => (0, native_verification_controller_1.runNativeVerificationEntry)(runGitHubActionEntry)); } @@ -38200,6 +38208,101 @@ var input_values_policy_1 = __nccwpck_require__(83592); Object.defineProperty(exports, "parseDelimitedValues", ({ enumerable: true, get: function () { return input_values_policy_1.parseDelimitedValues; } })); +/***/ }), + +/***/ 38016: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.runIssueEventAdmissionAction = runIssueEventAdmissionAction; +const core = __importStar(__nccwpck_require__(75855)); +const github = __importStar(__nccwpck_require__(78227)); +const node_crypto_1 = __nccwpck_require__(6005); +const node_fs_1 = __nccwpck_require__(87561); +const node_path_1 = __nccwpck_require__(49411); +const application_error_1 = __nccwpck_require__(75999); +const issue_event_admission_policy_1 = __nccwpck_require__(83146); +const issue_event_admission_workflow_1 = __nccwpck_require__(83323); +const issue_event_admission_composition_root_1 = __nccwpck_require__(261); +/** Isolated read-only entry. Never prepares agents, mainRun, completion, or conversation writes. */ +async function runIssueEventAdmissionAction(mode) { + if (mode !== 'inspect' && mode !== 'record') + throw new application_error_1.ApplicationError('configuration.invalid', 'Unknown issue event admission mode.'); + const token = core.getInput('token', { required: true }); + const evidenceToken = process.env.COPILOT_ISSUE_ADMISSION_TOKEN?.trim(); + const context = { + owner: github.context.repo.owner, repository: github.context.repo.repo, + eventName: github.context.eventName, action: github.context.payload.action ?? '', actor: github.context.actor, + issueNumber: github.context.payload.issue?.number ?? 0, runId: github.context.runId, + workflowRevision: process.env.GITHUB_WORKFLOW_SHA ?? '', runtimeRevision: digest(Buffer.concat([runtimeFile(__filename), runtimeFile((0, node_path_1.join)(__dirname, '../../action.yml')), + runtimeFile((0, node_path_1.join)(__dirname, '../../issue-admission/action.yml')), runtimeFile((0, node_path_1.join)(__dirname, '../../issue-admission/index.cjs'))])), + configuration: core.getInput('configuration'), deployLabel: core.getInput('deploy-label') || 'deploy', + releaseLabel: core.getInput('release-label') || 'release', hotfixLabel: core.getInput('hotfix-label') || 'hotfix', + changedLabel: typeof github.context.payload.label?.name === 'string' ? github.context.payload.label.name : '', + }; + const port = (0, issue_event_admission_composition_root_1.createIssueEventAdmissionPort)(context.owner, context.repository, token, evidenceToken || token); + if (mode === 'inspect') { + const decision = await (0, issue_event_admission_workflow_1.inspectIssueEvent)(context, port, digest); + core.setOutput('execute', String(decision.execute)); + core.setOutput('reason', decision.reason); + core.setOutput('receipt-key', decision.receiptKey ?? ''); + core.info(`Issue event admission: ${decision.reason}.`); + await core.summary.addRaw((0, issue_event_admission_policy_1.issueEventAdmissionSummary)(decision, core.getInput('repository-locale'))).write(); + return; + } + const receipt = await (0, issue_event_admission_workflow_1.recordIssueEvent)(context, core.getInput('receipt-key'), port, digest); + if (!receipt) { + core.info('Issue state changed or is not coalescible; no reconciliation receipt was recorded.'); + return; + } + const temporary = process.env.RUNNER_TEMP; + if (!temporary) + throw new application_error_1.ApplicationError('configuration.invalid', 'Issue admission receipt requires the native runner temporary directory.'); + const file = (0, node_path_1.join)((0, node_fs_1.mkdtempSync)((0, node_path_1.join)(temporary, 'copilot-issue-receipt-')), 'receipt.json'); + (0, node_fs_1.writeFileSync)(file, JSON.stringify({ schema: 1, receipt }), { mode: 0o600 }); + core.setOutput('receipt-name', receipt); + core.setOutput('receipt-path', file); +} +function digest(value) { return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex'); } +/** Dynamic reads keep ncc from relocating the manifests away from the shipped action tree. */ +function runtimeFile(file) { return (0, node_fs_1.readFileSync)(file); } + + /***/ }), /***/ 28586: @@ -38290,6 +38393,7 @@ function buildPreviousWorkflowRunsQuery(repository, event) { eventName: process.env.GITHUB_EVENT_NAME ?? '', ref: process.env.GITHUB_REF, pullRequestNumber: event.pullRequestNumber, + issueNumber: event.issueNumber, }), } : {}), }; @@ -46408,6 +46512,64 @@ function resolveMode(mode, commentId) { } +/***/ }), + +/***/ 83146: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.isIssueReconciliationEvent = isIssueReconciliationEvent; +exports.coalescibleIssueReconciliation = coalescibleIssueReconciliation; +exports.discardIssueSelfEvent = discardIssueSelfEvent; +exports.issueReconciliationIdentity = issueReconciliationIdentity; +exports.issueAdmissionReceiptName = issueAdmissionReceiptName; +exports.issueEventAdmissionSummary = issueEventAdmissionSummary; +const issue_start_policy_1 = __nccwpck_require__(90332); +const github_user_policy_1 = __nccwpck_require__(84403); +function isIssueReconciliationEvent(context) { + return ['labeled', 'unlabeled'].includes(context.action) && context.changedLabel.length > 0 + && ![issue_start_policy_1.ISSUE_START_LABEL, context.deployLabel].map(label => label.toLowerCase()).includes(context.changedLabel.toLowerCase()); +} +/** Initial routing labels have no event-specific effects in an unstarted issue. */ +function coalescibleIssueReconciliation(context, issue) { + return isIssueReconciliationEvent(context) && issue.state === 'open' + && !issue.labels.some(label => [issue_start_policy_1.ISSUE_START_LABEL, context.releaseLabel, context.hotfixLabel].map(name => name.toLowerCase()).includes(label.toLowerCase())); +} +function discardIssueSelfEvent(context, tokenUser) { + // Native closure cleanup deliberately remains runnable for the product account. + return context.action !== 'closed' && (0, github_user_policy_1.githubUsersMatch)(context.actor, tokenUser); +} +/** Every input that can change the meaning of a backlog reconciliation invalidates its receipt. */ +function issueReconciliationIdentity(context, issue, productAccount) { + return JSON.stringify({ + schema: 1, productAccount: productAccount.toLowerCase(), repository: `${context.owner}/${context.repository}`, actor: context.actor.toLowerCase(), + workflow: context.workflowRevision, runtime: context.runtimeRevision, configuration: context.configuration, + deployLabel: context.deployLabel, releaseLabel: context.releaseLabel, hotfixLabel: context.hotfixLabel, + issue: { ...issue, labels: [...issue.labels].sort(), assignees: [...issue.assignees].sort() }, + }); +} +function issueAdmissionReceiptName(issueNumber, key) { + return `copilot-issue-reconciliation-v1-${issueNumber}-${key}`; +} +function issueEventAdmissionSummary(decision, locale) { + const spanish = /^es(?:-|$)/iu.test(locale); + const states = spanish ? { + 'self-event': 'Evento interno omitido: lo generó la cuenta del producto. No se asignó el runner de trabajo.', + 'lifecycle-event': 'Evento admitido. El flujo normal verificará permisos y reconciliará la issue.', + 'reconciliation-needed': 'Reconciliación admitida: no se ha verificado una ejecución correcta que cubra este estado.', + 'already-reconciled': 'Estado ya reconciliado por una ejecución correcta del mismo flujo. Se omitió el runner de trabajo.', + } : { + 'self-event': 'Internal event skipped: the product account generated it. No work runner was allocated.', + 'lifecycle-event': 'Event admitted. The normal pipeline will verify permissions and reconcile the issue.', + 'reconciliation-needed': 'Reconciliation admitted: no successful execution has been verified for this state.', + 'already-reconciled': 'State already reconciled by a successful execution of this workflow. The work runner was skipped.', + }; + return `## ${spanish ? 'Admisión de la issue' : 'Issue event admission'}\n\n${states[decision.reason]}\n\n${spanish ? 'No se requiere intervención. Los fallos y efectos parciales conservan el flujo normal y no generan un recibo reutilizable.' : 'No action is required. Failed or partial work retains the normal pipeline and cannot produce a reusable receipt.'}\n`; +} + + /***/ }), /***/ 34026: @@ -46780,6 +46942,7 @@ function boundedMergeQueueDiagnostic(value) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.expectedNativeWorkflowGroup = expectedNativeWorkflowGroup; exports.hasNativeWorkflowCoordination = hasNativeWorkflowCoordination; +const ISSUE_GROUP = 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}'; const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; /** Closed recognition of the installed contract, without evaluating expressions. */ @@ -46795,6 +46958,9 @@ function expectedNativeWorkflowGroup(query) { if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') && native.ref.length > 'refs/heads/'.length) return PUSH_GROUP; + if (native.eventName === 'issues') + return typeof native.issueNumber === 'number' + && Number.isSafeInteger(native.issueNumber) && native.issueNumber > 0 ? ISSUE_GROUP : undefined; const pullRequestNumber = native.pullRequestNumber; if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) return undefined; @@ -46809,6 +46975,11 @@ function hasNativeWorkflowCoordination(workflow, jobId, expectedGroup) { const jobs = record(definition?.jobs); const job = record(jobs?.[jobId]); const concurrency = record(job?.concurrency); + if (expectedGroup === ISSUE_GROUP) { + const outer = record(definition?.concurrency); + return Array.isArray(job?.steps) && job?.uses === undefined && job?.concurrency === undefined + && outer?.group === ISSUE_GROUP && outer?.['cancel-in-progress'] === false && outer?.queue === 'max'; + } // Custom/reusable/workflow-level contracts retain conservative polling. // A single direct job also avoids confusing a reusable callee's job ID // with a different protected job in its caller's workflow context. @@ -52348,6 +52519,8 @@ function setupRunnerRole(file, job) { 'copilot_pull_request.yml': 'copilot-pull-requests', 'copilot_pull_request_comment.yml': 'copilot-pull-requests', 'copilot_commit.yml': 'copilot-commits', 'agent-cli-provisioning.yml': 'verify-agent-clis', }; + if (file === 'copilot_issue.yml' && job === 'issue-admission') + return 'isolated'; if (agentJobs[file] === job) return 'agent'; if (file === 'copilot_pull_request_approval.yml' && job === 'observe-approval') @@ -58332,6 +58505,70 @@ function currentSingleAction(context) { } +/***/ }), + +/***/ 83323: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.inspectIssueEvent = inspectIssueEvent; +exports.recordIssueEvent = recordIssueEvent; +const application_error_1 = __nccwpck_require__(75999); +const issue_event_admission_policy_1 = __nccwpck_require__(83146); +async function inspectIssueEvent(context, port, digest) { + validateContext(context); + const login = await port.authenticatedLogin(); + if (!login.trim()) + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'Issue event admission could not verify the product account.'); + if ((0, issue_event_admission_policy_1.discardIssueSelfEvent)(context, login)) + return { execute: false, reason: 'self-event' }; + if (!(0, issue_event_admission_policy_1.isIssueReconciliationEvent)(context)) + return { execute: true, reason: 'lifecycle-event' }; + let issue; + try { + issue = await port.readIssue(context.issueNumber); + } + catch { + return { execute: true, reason: 'reconciliation-needed' }; + } + if (!(0, issue_event_admission_policy_1.coalescibleIssueReconciliation)(context, issue)) + return { execute: true, reason: 'lifecycle-event' }; + const receiptKey = digest((0, issue_event_admission_policy_1.issueReconciliationIdentity)(context, issue, login)); + let completed = false; + try { + completed = await port.hasSuccessfulReceipt((0, issue_event_admission_policy_1.issueAdmissionReceiptName)(context.issueNumber, receiptKey), context); + } + catch { /* Optimization unavailable: retain the full guarded pipeline. */ } + return { execute: !completed, reason: completed ? 'already-reconciled' : 'reconciliation-needed', receiptKey }; +} +async function recordIssueEvent(context, expectedKey, port, digest) { + validateContext(context); + if (!/^[a-f0-9]{64}$/u.test(expectedKey)) + return undefined; + const login = await port.authenticatedLogin(); + if (!login.trim() || (0, issue_event_admission_policy_1.discardIssueSelfEvent)(context, login)) + return undefined; + const issue = await port.readIssue(context.issueNumber); + if (!(0, issue_event_admission_policy_1.coalescibleIssueReconciliation)(context, issue)) + return undefined; + const currentKey = digest((0, issue_event_admission_policy_1.issueReconciliationIdentity)(context, issue, login)); + return currentKey === expectedKey ? (0, issue_event_admission_policy_1.issueAdmissionReceiptName)(context.issueNumber, currentKey) : undefined; +} +function validateContext(context) { + if (context.eventName !== 'issues' || !['opened', 'reopened', 'closed', 'edited', 'labeled', 'unlabeled', 'assigned', 'unassigned'].includes(context.action) + || !/^[\w.-]+$/u.test(context.owner) || !/^[\w.-]+$/u.test(context.repository) + || !/^[a-z0-9][a-z0-9-]*(?:\[bot\])?$/iu.test(context.actor) + || !Number.isSafeInteger(context.issueNumber) || context.issueNumber <= 0 + || !Number.isSafeInteger(context.runId) || context.runId <= 0 + || !/^[a-f0-9]{40}$/u.test(context.workflowRevision) || !/^[a-f0-9]{64}$/u.test(context.runtimeRevision) + || Buffer.byteLength(context.configuration, 'utf8') > 1048576) { + throw new application_error_1.ApplicationError('configuration.invalid', 'Issue event admission requires a valid native issue, workflow revision, and bounded configuration.'); + } +} + + /***/ }), /***/ 90972: @@ -82050,6 +82287,113 @@ function isActivePreviousRun(run, query) { } +/***/ }), + +/***/ 43302: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.IssueEventAdmissionRepository = void 0; +const application_error_1 = __nccwpck_require__(75999); +/** Repository-bound queries. No write permission or provider exception leaves this boundary. */ +class IssueEventAdmissionRepository { + constructor(product, evidence, owner, repository) { + this.product = product; + this.evidence = evidence; + this.owner = owner; + this.repository = repository; + } + async authenticatedLogin() { + try { + const user = record((await this.product.rest.users.getAuthenticated()).data); + if (typeof user?.login !== 'string' || !user.login.trim()) + throw new Error(); + return user.login; + } + catch { + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'Issue event admission could not verify the product account.'); + } + } + async readIssue(issueNumber) { + try { + const issue = record((await this.product.rest.issues.get({ owner: this.owner, repo: this.repository, issue_number: issueNumber })).data); + const author = record(issue?.user); + const milestone = issue?.milestone === null ? null : record(issue?.milestone)?.id; + const type = issue?.type === null || issue?.type === undefined ? null : record(issue.type)?.name; + if (!issue || issue.pull_request !== undefined || issue.number !== issueNumber + || !positive(issue.id) || typeof issue.title !== 'string' || !(issue.body === null || typeof issue.body === 'string') + || !['open', 'closed'].includes(String(issue.state)) || typeof issue.updated_at !== 'string' + || !timestamp(issue.updated_at) || typeof author?.login !== 'string' || !author.login.trim() + || typeof issue.locked !== 'boolean' || !(milestone === null || positive(milestone)) + || !(type === null || typeof type === 'string') + || issue.repository_url !== `${issueRepositoryApiPrefix(issue.repository_url)}/repos/${this.owner}/${this.repository}`) + throw new Error(); + return Object.freeze({ id: issue.id, number: issueNumber, title: issue.title, body: issue.body ?? '', + state: issue.state, updatedAt: issue.updated_at, author: author.login, + labels: Object.freeze(strings(issue.labels, 'name')), assignees: Object.freeze(strings(issue.assignees, 'login')), + milestone: milestone, issueType: type, locked: issue.locked }); + } + catch { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'Native issue state could not be verified before admission.'); + } + } + async hasSuccessfulReceipt(name, context) { + const binding = { owner: this.owner, repo: this.repository }; + const current = record((await this.evidence.rest.actions.getWorkflowRun({ ...binding, run_id: context.runId })).data); + if (!current || current.id !== context.runId || current.event !== 'issues' || current.head_sha !== context.workflowRevision + || !actorMatches(current.actor, context.actor) || current.run_attempt !== 1 || !timestamp(current.created_at) || !positive(current.workflow_id) || typeof current.path !== 'string' || !/^\.github\/workflows\/[\w.-]+\.ya?ml$/u.test(current.path)) + return false; + const response = record((await this.evidence.rest.actions.listArtifactsForRepo({ ...binding, name, per_page: 100 })).data); + if (!Array.isArray(response?.artifacts)) + return false; + // Bounded optimization. Ambiguous, absent, failed or canceled evidence never skips work. + for (const raw of response.artifacts.slice(0, 3)) { + const artifact = record(raw); + const source = record(artifact?.workflow_run); + if (artifact?.name !== name || artifact.expired !== false || !positive(source?.id) + || source.id >= context.runId || !timestamp(artifact.created_at) + || Date.parse(artifact.created_at) <= Date.parse(current.created_at) || !positive(artifact.size_in_bytes) || artifact.size_in_bytes > 4096) + continue; + const run = record((await this.evidence.rest.actions.getWorkflowRun({ ...binding, run_id: source.id })).data); + if (run?.id === source.id && run.workflow_id === current.workflow_id && run.path === current.path + && run.head_sha === context.workflowRevision && run.event === 'issues' && run.status === 'completed' && run.conclusion === 'success' + && actorMatches(run.actor, context.actor)) + return true; + } + return false; + } +} +exports.IssueEventAdmissionRepository = IssueEventAdmissionRepository; +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined; +} +function positive(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } +function strings(value, key) { + if (!Array.isArray(value) || value.length > 100) + throw new Error(); + return value.map(item => { const field = record(item)?.[key]; if (typeof field !== 'string' || !field) + throw new Error(); return field; }); +} +function issueRepositoryApiPrefix(value) { + if (typeof value !== 'string') + throw new Error(); + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) + throw new Error(); + return value.slice(0, value.indexOf('/repos/')); +} +function timestamp(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) + && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 19) === value.slice(0, 19); +} +function actorMatches(value, expected) { + const login = record(value)?.login; + return typeof login === 'string' && login.toLowerCase() === expected.toLowerCase(); +} + + /***/ }), /***/ 74039: @@ -89687,6 +90031,22 @@ function createIssueContentCompositionRoot() { } +/***/ }), + +/***/ 261: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.createIssueEventAdmissionPort = createIssueEventAdmissionPort; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +const issue_event_admission_repository_1 = __nccwpck_require__(43302); +function createIssueEventAdmissionPort(owner, repository, token, evidenceToken) { + return new issue_event_admission_repository_1.IssueEventAdmissionRepository((0, octokit_client_resolver_1.getOctokitClient)(token), (0, octokit_client_resolver_1.getOctokitClient)(evidenceToken), owner, repository); +} + + /***/ }), /***/ 74914: @@ -94182,6 +94542,9 @@ class IssueContentInterface extends content_interface_1.ContentInterface { if (updated === undefined) { throw new Error('Issue content markers are missing or inconsistent.'); } + // Byte-identical state must not emit another native issue edit event. + if (updated === description) + return updated; await this.issueDescriptionPort.updateDescription(query.owner, query.repository, query.issueNumber, updated, query.token); return updated; } diff --git a/docs/development/browser-extension.mdx b/docs/development/browser-extension.mdx index 72bdd8104..e9724cb4d 100644 --- a/docs/development/browser-extension.mdx +++ b/docs/development/browser-extension.mdx @@ -151,6 +151,8 @@ If cleanup fails after replacement, the command explicitly reports that the new after recovery; do not treat retained old output as the latest build. Generated extension outputs are ignored and excluded from the root npm package. +The root allowlist separately includes the isolated Issue Action descriptor +`issue-admission/action.yml`; this does not ship extension runtime or artifacts. Reverting H1 requires no user-data migration or external rollback. Keep root verification gates when reverting the extension orchestration. diff --git a/docs/issues/workflow-setup.mdx b/docs/issues/workflow-setup.mdx index 96b6f7f5f..954e77697 100644 --- a/docs/issues/workflow-setup.mdx +++ b/docs/issues/workflow-setup.mdx @@ -180,3 +180,43 @@ If publication is unconfirmed, inspect the exact check/head and safe provider cause in the run log and Job Summary. Permission rejection, rate limiting, invalid target and transport failures have distinct guidance. Unknown causes remain unknown. The optional warning never replaces the actual workflow result. + + +## Early issue admission and shared runner capacity + +The generated Issue workflow inspects events on a small fixed GitHub-hosted +`Admit issue event` job before allocating your selected work runner. It verifies +the actual product account, so internal title/label updates do not occupy the +work pool even when `COPILOT_BOT_LOGIN` is absent. Native bot-authored closure +cleanup still runs. Inspection receives no agent API keys. + +```text +Per-issue queue → hosted inspection → full work only when required +``` + +Opened/start/close/reopen/body/title/assignment intent remains executable. Only +an equivalent, unstarted label reconciliation already queued during successful +work may reuse an opaque one-day artifact receipt. It must match the current +issue state, actor/account, effective work-step Variables, workflow and runtime. +Only Variables referenced by the full step are serialized; unrelated repository +Variables are not forwarded into admission. Missing evidence, +provider uncertainty, later requests and manual retries use the normal pipeline. +A state change during work prevents receipt recording. Byte-identical description +persistence also omits the native update, so a configuration no-op does not +generate another internal edit. Job Summaries explain +intentional skips in English/Spanish with English fallback; no new issue card +or conversation is created. + +The queue keeps running work and up to 100 pending runs per issue using GitHub's +`queue: max`; it does not cancel active work or replace pending start events. +Independent issues have separate groups. Hosted inspection still consumes +hosted capacity: fewer full work allocations does not mean zero workflow runs. + +**Upgrade the runtime first**, ensuring it contains the `issue-admission` +subaction, and then review/apply the matching generated workflow through setup. Custom workflows must pass matching +deploy/release/hotfix label names to inspection/record and the full Action. +Do not point this new workflow at an older release missing that path. Enterprise +operators must confirm support for `queue: max`. Inspection uses only automatic +`contents: read`/`actions: read`; the full job keeps its existing permissions and +product PAT. Your runner selection applies to full work, not inspection. No +consumer adoption is performed by an upstream code merge. diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index 5ea3fd163..272c73d2f 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -624,3 +624,27 @@ for native Python/pnpm examples, profile lifetime, trust boundaries and migratio If root build passes but a workspace typecheck reports missing tools, compare the installed graph with the actual merged candidate. In the source incidents tracked by [#746](https://github.com/vypdev/copilot/issues/746), Node/pnpm were present, but installation ran on `master` before the extension workspace existed. Commands after typecheck were not executed. Installing one extra member or disabling verification does not fix the sequencing error. The corrected source coordinator provisions runtimes first and prepares the frozen candidate graph immediately before every verifier. Its sync result distinguishes dependency, native setup, runtime and command failures, and reports whether the original head was restored. Repair the reported prerequisite and inspect an unverified rollback before retrying. Confirm the corrected runtime and workflow are adopted on the default branch used by comment events: `develop` checks alone do not update a running `master` Action. Read [verification](./verification) for source limits and consumer setup alternatives. + + +### Issue work was skipped after early admission + +Open **Admit issue event** in the native workflow Summary. `self-event` identifies +an internal product-account transition. `already-reconciled` means a completed +successful run from the same workflow covered this exact queued reconciliation; +it does not claim a new branch build or review. The normal issue/run links in +Actions remain the navigation surface. No conversation comment is added. + +No receipt, an expired artifact, state/configuration/code changes, a failed or +canceled source run, a later deliberate request or a manual rerun all preserve +full runtime authorization and reconciliation. Optional artifact lookup/upload +failure never establishes permission to skip work. Partial effects retain their +normal recovery state. If authentication fails, repair the existing product +credential and rerun; do not broaden its scopes to enable receipt storage. + +A pending issue workflow may be waiting for its own earlier run, without holding +a work runner. GitHub limits each multi-pending queue to 100; inspect native +canceled runs after saturation and rerun the needed intent when capacity returns. +Do not enable active cancellation or single-pending replacement as a workaround. +Older runtime references missing `issue-admission` require the matching runtime +upgrade before installing the new generated workflow. This upstream change does +not itself prove a consumer's queue improved. diff --git a/extension/test/integration/package.test.ts b/extension/test/integration/package.test.ts index 2712fd3f4..5fa67983e 100644 --- a/extension/test/integration/package.test.ts +++ b/extension/test/integration/package.test.ts @@ -23,6 +23,8 @@ it('bexh1-foundation-C25 preserves root npm file selection and excludes extensio expect(files.some(file => file.startsWith('extension/'))).toBe(false); expect(files.some(file => file.startsWith('src/domain/browser_extension_contract/'))).toBe(false); expect(files).toContain('build/github_action/index.js'); + expect(files).toContain('issue-admission/action.yml'); + expect(files).toContain('issue-admission/index.cjs'); }); it('bexh1-foundation-C28 parses root commands and CI gates; contributor guidance matches target claims and navigation', () => { expect(() => validatePackageContract(pkg(), extension(), workspace(), ci())).not.toThrow(); diff --git a/extension/tooling/package_contract.mts b/extension/tooling/package_contract.mts index e284a3028..e02c53141 100644 --- a/extension/tooling/package_contract.mts +++ b/extension/tooling/package_contract.mts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util'; import { DEVELOPMENT_VERSION } from '../src/infrastructure/target_manifests.ts'; import { FoundationGateError } from './gate_error.mts'; -export const ROOT_PACKAGE_FILES = ['action.yml', 'build/cli/index.js', 'build/web/', 'build/github_action/index.js', +export const ROOT_PACKAGE_FILES = ['action.yml', 'issue-admission/', 'build/cli/index.js', 'build/web/', 'build/github_action/index.js', 'build/api/index.js', 'build/api/src/', 'setup/ISSUE_TEMPLATE/', 'setup/actions/', 'setup/workflows/', 'setup/source-workflows/', 'setup/pull_request_template.md', 'scripts/install-git-hooks.cjs', 'README.md', 'LICENSE']; diff --git a/issue-admission/action.yml b/issue-admission/action.yml new file mode 100644 index 000000000..7193f0294 --- /dev/null +++ b/issue-admission/action.yml @@ -0,0 +1,41 @@ +name: Copilot issue event admission +description: Read-only issue admission and successful-reconciliation receipts, without agent setup. +inputs: + issue-event-admission-mode: + description: Isolated mode, inspect before work or record after successful work. + required: true + token: + description: Existing product credential for native identity and issue reads. + required: true + configuration: + description: Repository Variables snapshot used only in a credential-free state digest. + default: '' + repository-locale: + description: Job Summary locale, English or Spanish with regional and English fallback. + default: en-US + deploy-label: + description: Deployment intent label; it must never be coalesced. + default: deploy + release-label: + description: Release intent label; must match the normal Action. + default: release + hotfix-label: + description: Hotfix intent label; must match the normal Action. + default: hotfix + receipt-key: + description: Exact digest returned by this run's successful inspect step. + default: '' +outputs: + execute: + description: True only when the normal guarded issue pipeline should run. + reason: + description: Closed admission reason; no issue text or credentials. + receipt-key: + description: Opaque native state, workflow and runtime digest. + receipt-name: + description: Artifact name after an unchanged successful reconciliation. + receipt-path: + description: Private temporary receipt file for native artifact upload. +runs: + using: node24 + main: index.cjs diff --git a/issue-admission/index.cjs b/issue-admission/index.cjs new file mode 100644 index 000000000..471c9d39c --- /dev/null +++ b/issue-admission/index.cjs @@ -0,0 +1,13 @@ +'use strict'; + +// Metadata's required:true is advisory; never fall through to the mutation entry. +const mode = (process.env['INPUT_ISSUE-EVENT-ADMISSION-MODE'] ?? '').trim(); +if (mode !== 'inspect' && mode !== 'record') { + process.stderr.write('Issue admission requires inspect or record mode. No pipeline was started.\n'); + process.exitCode = 1; +} else { + // This isolated action has no JSON input-override channel or native worker mode. + delete process.env.INPUT_VARS_JSON; + process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP = '1'; + void require('../build/github_action/index.js').runGitHubActionEntry(); +} diff --git a/jest.config.js b/jest.config.js index d9367a31c..1613e3f71 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.ts', 'scripts/git-hooks/verification-environment.cjs', + 'issue-admission/index.cjs', 'web/src/**/*.ts', '!web/src/main.ts', '!src/**/*.d.ts', diff --git a/package.json b/package.json index a6c1a32e7..3e333d0de 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "files": [ "action.yml", + "issue-admission/", "build/cli/index.js", "build/web/", "build/github_action/index.js", diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 47c517562..ccf9f671f 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -1086,6 +1086,31 @@ } ], "successMessage": "optional Check Run diagnostics: PASS (closed boundary/presentation/adapter100%; completion95/90)" + }, + { + "name": "Early issue event admission", + "missingEntryLabel": "early issue admission", + "rules": [ + { + "files": [ + "src/application/policies/issue_event_admission_policy.ts", + "issue-admission/index.cjs" + ], + "mode": "each", + "thresholdProfile": "exhaustive" + }, + { + "files": [ + "src/application/usecases/execution/issue_event_admission_workflow.ts", + "src/data/repository/workflow/issue_event_admission_repository.ts", + "src/actions/issue_event_admission_action.ts", + "src/infrastructure/composition/issue_event_admission_composition_root.ts" + ], + "mode": "each", + "thresholdProfile": "default" + } + ], + "successMessage": "early issue admission coverage: PASS (pure policy 100%; each boundary 95/90)" } ] } diff --git a/scripts/smoke-test-npm-package.cjs b/scripts/smoke-test-npm-package.cjs index 496bac85f..603a5eeda 100644 --- a/scripts/smoke-test-npm-package.cjs +++ b/scripts/smoke-test-npm-package.cjs @@ -37,6 +37,15 @@ try { }); const packageRoot = path.join(extractedDirectory, 'package'); + const admissionManifest = fs.readFileSync(path.join(packageRoot, 'issue-admission/action.yml'), 'utf8'); + if (!admissionManifest.includes('using: node24') || !admissionManifest.includes('main: index.cjs') + || !admissionManifest.includes('issue-event-admission-mode:')) { + throw new Error('Packaged issue admission subaction must resolve the shipped Node runtime.'); + } + const admissionBootstrap = fs.readFileSync(path.join(packageRoot, 'issue-admission/index.cjs'), 'utf8'); + if (!admissionBootstrap.includes("require('../build/github_action/index.js')") || !admissionBootstrap.includes('COPILOT_ISSUE_ADMISSION_BOOTSTRAP')) { + throw new Error('Packaged issue admission must use its fail-closed bootstrap.'); + } const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); const cliPath = path.join(packageRoot, 'build', 'cli', 'index.js'); const webIndexPath = path.join(packageRoot, 'build', 'web', 'index.html'); diff --git a/scripts/validate-npm-package.cjs b/scripts/validate-npm-package.cjs index ca5c35c79..a3bc9be66 100644 --- a/scripts/validate-npm-package.cjs +++ b/scripts/validate-npm-package.cjs @@ -33,6 +33,7 @@ if (packageJson.exports?.['./bugbot']?.default !== './build/api/index.js' const requiredPackageFiles = [ 'action.yml', + 'issue-admission/', 'build/cli/index.js', 'build/web/', 'build/github_action/index.js', @@ -53,6 +54,8 @@ for (const requiredFile of requiredPackageFiles) { const requiredRepositoryFiles = [ 'action.yml', + 'issue-admission/action.yml', + 'issue-admission/index.cjs', 'build/cli/index.js', 'build/web/index.html', 'build/github_action/index.js', diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index cf6639698..40343952b 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -84,7 +84,6 @@ const BOT_GATED_WORKFLOW_FILES = new Set([ 'copilot_pull_request_review_state.yml', 'copilot_pull_request_comment.yml', ]); -const CLOSED_ISSUE_BOT_GATE_EXPRESSION = "${{ (github.event.action == 'closed' && github.event.issue.state == 'closed') || vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }}"; 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 BOT_PR_ANALYSIS_GATE_EXPRESSION = "${{ github.event.pull_request.head.repo.full_name == github.repository && (vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN || ((github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize') && github.event.pull_request.user.login == github.actor)) }}"; @@ -473,7 +472,8 @@ function assertRunner(file, workflow) { continue; } let expected = ['self-hosted', 'codex']; - if (relativeFile.startsWith('setup/workflows/') + if ((path.basename(file) === 'copilot_issue.yml' && jobId === 'issue-admission') + || relativeFile.startsWith('setup/workflows/') || relativeFile === '.github/workflows/copilot_credential_health.yml' || (/^\.github\/workflows\/(?:release|hotfix)_workflow\.yml$/.test(relativeFile) && jobId === 'publish-npm')) { expected = ['ubuntu-latest']; @@ -603,10 +603,12 @@ function assertAgentWorkflowPermissions(file, workflow) { if (!WORKFLOW_AGENT_ROLES[path.basename(file)]) return; const nativeEvidenceWorkflow = ['copilot_commit.yml', 'copilot_pull_request.yml', 'copilot_pull_request_review_state.yml', 'copilot_issue_comment.yml', 'copilot_pull_request_comment.yml', 'copilot_comment_runtime.yml'].includes(path.basename(file)); - const expectedPermissions = nativeEvidenceWorkflow + const ordinaryPermissions = nativeEvidenceWorkflow ? { checks: 'write', contents: 'read' } : { contents: 'read' }; for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) { + const expectedPermissions = path.basename(file) === 'copilot_issue.yml' && jobId === 'issue-admission' + ? { actions: 'read', contents: 'read' } : ordinaryPermissions; const permissions = job.permissions ?? {}; const permissionKeys = Object.keys(permissions).sort(); const expectedKeys = Object.keys(expectedPermissions).sort(); @@ -685,6 +687,14 @@ function assertNoConcurrency(relativeFile, workflow) { } function assertReviewConcurrency(relativeFile, workflow) { + if (path.basename(relativeFile) === 'copilot_issue.yml') { + if (workflow.concurrency?.group !== 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}' + || workflow.concurrency?.['cancel-in-progress'] !== false || workflow.concurrency?.queue !== 'max' + || Object.values(workflow.jobs ?? {}).some(job => job.concurrency !== undefined)) { + throw new Error(`${relativeFile} must retain every pending issue event without canceling active work.`); + } + return; + } const contract = BUGBOT_CONCURRENCY_JOBS[path.basename(relativeFile)]; if (!contract) { assertNoConcurrency(relativeFile, workflow); @@ -1283,6 +1293,66 @@ function assertMergeQueueWorkflowSupport(file, workflow) { } } + +// Keep unrelated repository Variables out of the admission transport and logs. +// The executed workflow SHA binds this ordered list of effective input names. +function issueAdmissionConfigurationExpression(workflow) { + const job = workflow.jobs?.['copilot-issues']; + const steps = (job?.steps ?? []).filter(isCopilotAction); + const source = JSON.stringify({ steps, runsOn: job?.['runs-on'], environment: job?.env }); + if (/\bvars\s*\[/u.test(source)) throw new Error('Issue admission requires explicit literal Variable references.'); + const names = [...new Set([...source.matchAll(/\bvars\.([A-Za-z_][A-Za-z0-9_]*)/gu)].map(match => match[1]))].sort(); + if (!names.length) return "${{ '[]' }}"; + return "${{ format('[" + names.map((_, index) => '{' + index + '}').join(',') + "]', " + + names.map(name => 'toJSON(vars.' + name + ')').join(', ') + ') }}'; +} + +function assertIssueAdmissionWorkflow(relativeFile, workflow) { + const admission = workflow.jobs?.['issue-admission']; + const work = workflow.jobs?.['copilot-issues']; + const distributed = relativeFile.startsWith('setup/'); + const action = distributed ? 'vypdev/copilot/issue-admission@v3' : './issue-admission'; + const inspect = admission?.steps?.find(step => step.id === 'inspect'); + const product = work?.steps?.find(isCopilotAction); + const record = work?.steps?.find(step => step.id === 'receipt'); + const upload = work?.steps?.find(step => step.name === 'Retain successful reconciliation receipt'); + const variables = issueAdmissionConfigurationExpression(workflow); + const deploy = "${{ vars.DEPLOY_LABEL || 'deploy' }}"; + if (Object.keys(workflow.jobs ?? {}).sort().join(',') !== 'copilot-issues,issue-admission' + || admission?.['runs-on'] !== 'ubuntu-latest' || admission['timeout-minutes'] !== 5 + || admission.if !== undefined || admission.needs !== undefined || admission.env !== undefined + || Object.keys(admission.permissions ?? {}).sort().join(',') !== 'actions,contents' + || admission.permissions.actions !== 'read' || admission.permissions.contents !== 'read' + || admission.outputs?.execute !== '${{ steps.inspect.outputs.execute }}' + || admission.outputs?.['receipt-key'] !== '${{ steps.inspect.outputs.receipt-key }}' + || (admission.steps ?? []).some(step => step !== inspect && !(step.uses === CHECKOUT_ACTION && !distributed && step.with?.['persist-credentials'] === false)) + || inspect?.uses !== action || inspect.with?.['issue-event-admission-mode'] !== 'inspect' + || inspect.with?.token !== '${{ secrets.PAT }}' || inspect.with.configuration !== variables || inspect.with['deploy-label'] !== deploy + || JSON.stringify(inspect.env) !== JSON.stringify({ COPILOT_ISSUE_ADMISSION_TOKEN: '${{ github.token }}' }) + || Object.keys(inspect.with).sort().join(',') !== 'configuration,deploy-label,issue-event-admission-mode,repository-locale,token' + || inspect.with['repository-locale'] !== REPOSITORY_LOCALE_INPUTS['repository-locale'] + || inspect.if !== undefined || inspect['continue-on-error'] !== undefined + || (work?.steps ?? []).filter(isCopilotAction).length !== 1 || product?.with?.['single-action'] !== undefined + || (product?.with?.['release-label'] ?? 'release') !== (inspect?.with?.['release-label'] ?? 'release') + || (product?.with?.['hotfix-label'] ?? 'hotfix') !== (inspect?.with?.['hotfix-label'] ?? 'hotfix') + || product?.id !== 'copilot' || product.if !== undefined || product['continue-on-error'] !== undefined + || work?.needs !== 'issue-admission' || work.if !== "${{ needs.issue-admission.outputs.execute == 'true' }}" + || record?.uses !== action || record.with?.['issue-event-admission-mode'] !== 'record' + || record.with?.token !== '${{ secrets.PAT }}' || record.with.configuration !== variables || record.with['deploy-label'] !== deploy + || record.with['receipt-key'] !== '${{ needs.issue-admission.outputs.receipt-key }}' + || record.if !== "${{ success() && steps.copilot.outcome == 'success' && needs.issue-admission.outputs.receipt-key != '' }}" || record['continue-on-error'] !== true + || Object.keys(record.with).sort().join(',') !== 'configuration,deploy-label,issue-event-admission-mode,receipt-key,repository-locale,token' + || record.with['repository-locale'] !== REPOSITORY_LOCALE_INPUTS['repository-locale'] || record.env !== undefined + || upload?.uses !== 'actions/upload-artifact@v5' || upload.if !== "${{ steps.receipt.outputs.receipt-path != '' }}" + || upload['continue-on-error'] !== true || upload.with?.name !== '${{ steps.receipt.outputs.receipt-name }}' + || upload.with.path !== '${{ steps.receipt.outputs.receipt-path }}' || upload.with['retention-days'] !== 1 + || upload.with['if-no-files-found'] !== 'error' + || work.steps.indexOf(record) <= work.steps.findIndex(isCopilotAction) || work.steps.indexOf(upload) <= work.steps.indexOf(record) + || !(work.steps ?? []).some(step => isCopilotAction(step) && step.with?.['deploy-label'] === deploy)) { + throw new Error(`${relativeFile} must retain isolated native issue admission and success-only unchanged-state receipts.`); + } +} + function assertQueueWorkflow(file, workflow) { const relativeFile = relativeWorkflow(file); const manifest = QUEUE_WORKFLOW_MANIFEST.find(entry => relativeFile.endsWith(`/${entry.file}`)); @@ -1307,7 +1377,8 @@ function assertQueueWorkflow(file, workflow) { if (manifest.file === 'copilot_issue.yml' && !workflow.on?.issues?.types?.includes('closed')) { throw new Error(`${relativeFile} must subscribe to native closed issue events.`); } - const expectedBotGate = manifest.file === 'copilot_issue.yml' ? CLOSED_ISSUE_BOT_GATE_EXPRESSION + if (manifest.file === 'copilot_issue.yml') assertIssueAdmissionWorkflow(relativeFile, workflow); + const expectedBotGate = manifest.file === 'copilot_issue.yml' ? "${{ needs.issue-admission.outputs.execute == 'true' }}" : manifest.file === 'copilot_pull_request.yml' && relativeFile.startsWith('setup/') ? BOT_PR_ANALYSIS_GATE_EXPRESSION : FORK_GATED_WORKFLOW_FILES.has(manifest.file) @@ -1489,6 +1560,7 @@ function main() { if (require.main === module) main(); module.exports = { + issueAdmissionConfigurationExpression, assertNativeVerificationWorkflow, QUEUE_WAIT_MINUTES, MIN_QUEUE_JOB_TIMEOUT_MINUTES, diff --git a/setup/workflows/copilot_issue.yml b/setup/workflows/copilot_issue.yml index 05ce55b50..a12ac8c15 100644 --- a/setup/workflows/copilot_issue.yml +++ b/setup/workflows/copilot_issue.yml @@ -4,9 +4,40 @@ on: issues: types: [opened, reopened, closed, edited, labeled, unlabeled, assigned, unassigned] +# Keep every pending lifecycle event; never replace a start/reopen with a label event. +# GitHub bounds this queue at 100 pending runs. Independent issues use different groups. +concurrency: + group: copilot-issue-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false + queue: max + jobs: + issue-admission: + name: Admit issue event + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + execute: ${{ steps.inspect.outputs.execute }} + receipt-key: ${{ steps.inspect.outputs.receipt-key }} + steps: + - name: Inspect native issue event + id: inspect + uses: vypdev/copilot/issue-admission@v3 + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issue-event-admission-mode: inspect + token: ${{ secrets.PAT }} + configuration: ${{ format('[{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18},{19},{20},{21},{22},{23},{24},{25},{26},{27},{28},{29},{30},{31},{32},{33},{34},{35},{36},{37},{38},{39},{40},{41},{42},{43},{44},{45},{46},{47},{48},{49},{50},{51},{52},{53},{54},{55},{56},{57},{58},{59},{60},{61},{62},{63},{64}]', toJSON(vars.AGENT_ALLOWED_MODELS), toJSON(vars.AGENT_ALLOWED_MODEL_PROVIDERS), toJSON(vars.AGENT_EFFORT), toJSON(vars.AGENT_EXECUTABLE), toJSON(vars.AGENT_MODEL), toJSON(vars.AGENT_MODEL_PROVIDER), toJSON(vars.AGENT_PROVIDER), toJSON(vars.AI_IGNORE_FILES), toJSON(vars.AI_INCLUDE_REASONING), toJSON(vars.AI_MEMBERS_ONLY), toJSON(vars.AI_PULL_REQUEST_DESCRIPTION_MODE), toJSON(vars.BUGBOT_AUTOFIX_VERIFY_COMMANDS), toJSON(vars.BUGBOT_COMMENT_LIMIT), toJSON(vars.BUGBOT_DRY_RUN), toJSON(vars.BUGBOT_EFFORT), toJSON(vars.BUGBOT_FAIL_ON_UNRESOLVED), toJSON(vars.BUGBOT_ORGANIZATION_RULES), toJSON(vars.BUGBOT_REVIEW_DRAFTS), toJSON(vars.BUGBOT_SEVERITY), toJSON(vars.BUGBOT_SUGGESTED_CHANGES), toJSON(vars.BUGBOT_TELEMETRY), toJSON(vars.BUGBOT_TRACE_RULES), toJSON(vars.BUGFIX_TREE), toJSON(vars.CHORE_TREE), toJSON(vars.COMMIT_PREFIX_TRANSFORMS), toJSON(vars.COPILOT_ISSUE_WORKFLOW_PROFILE), toJSON(vars.DEBUG), toJSON(vars.DEPLOY_LABEL), toJSON(vars.DESIRED_ASSIGNEES_COUNT), toJSON(vars.DESIRED_REVIEWERS_COUNT), toJSON(vars.DEVELOPMENT_BRANCH), toJSON(vars.DOCS_TREE), toJSON(vars.FEATURE_TREE), toJSON(vars.FINDINGS_EFFORT), toJSON(vars.FINDINGS_EXECUTABLE), toJSON(vars.FINDINGS_MODEL), toJSON(vars.FINDINGS_MODEL_PROVIDER), toJSON(vars.FINDINGS_PROVIDER), toJSON(vars.FIXER_EFFORT), toJSON(vars.FIXER_EXECUTABLE), toJSON(vars.FIXER_MODEL), toJSON(vars.FIXER_MODEL_PROVIDER), toJSON(vars.FIXER_PROVIDER), toJSON(vars.HOTFIX_TREE), toJSON(vars.ISSUES_LOCALE), toJSON(vars.ISSUE_MANAGED_BRANCHES), toJSON(vars.MAIN_BRANCH), toJSON(vars.PLANNER_EFFORT), toJSON(vars.PLANNER_EXECUTABLE), toJSON(vars.PLANNER_MODEL), toJSON(vars.PLANNER_MODEL_PROVIDER), toJSON(vars.PLANNER_PROVIDER), toJSON(vars.PRE_BRANCH_SDD), toJSON(vars.PROJECT_COLUMN_ISSUE_CREATED), toJSON(vars.PROJECT_COLUMN_ISSUE_IN_PROGRESS), toJSON(vars.PROJECT_COLUMN_PULL_REQUEST_CREATED), toJSON(vars.PROJECT_COLUMN_PULL_REQUEST_IN_PROGRESS), toJSON(vars.PROJECT_IDS), toJSON(vars.PULL_REQUESTS_LOCALE), toJSON(vars.RELEASE_TREE), toJSON(vars.REOPEN_ISSUE_ON_PUSH), toJSON(vars.REPOSITORY_LOCALE), toJSON(vars.REVIEWER_PROVIDER), toJSON(vars.TESTER_PROVIDER), toJSON(vars.VERIFICATION_SETUP)) }} + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} + env: + COPILOT_ISSUE_ADMISSION_TOKEN: ${{ github.token }} + copilot-issues: - if: ${{ (github.event.action == 'closed' && github.event.issue.state == 'closed') || vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }} + needs: issue-admission + if: ${{ needs.issue-admission.outputs.execute == 'true' }} name: Copilot - Issue runs-on: ubuntu-latest timeout-minutes: 120 @@ -24,7 +55,9 @@ jobs: # Optional trusted-main setup: VERIFICATION_SETUP=true and .github/actions/copilot-verification-setup/action.yml. # https://docs.page/vypdev/copilot/security-operations/operations/verification#repository-verification-prerequisites - uses: vypdev/copilot@v3 + id: copilot with: + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} issues-locale: ${{ vars.ISSUES_LOCALE || '' }} pull-requests-locale: ${{ vars.PULL_REQUESTS_LOCALE || '' }} @@ -101,3 +134,26 @@ jobs: 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 }} + + - name: Record unchanged successful reconciliation + id: receipt + if: ${{ success() && steps.copilot.outcome == 'success' && needs.issue-admission.outputs.receipt-key != '' }} + continue-on-error: true + uses: vypdev/copilot/issue-admission@v3 + with: + repository-locale: ${{ vars.REPOSITORY_LOCALE || 'en-US' }} + issue-event-admission-mode: record + token: ${{ secrets.PAT }} + configuration: ${{ format('[{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18},{19},{20},{21},{22},{23},{24},{25},{26},{27},{28},{29},{30},{31},{32},{33},{34},{35},{36},{37},{38},{39},{40},{41},{42},{43},{44},{45},{46},{47},{48},{49},{50},{51},{52},{53},{54},{55},{56},{57},{58},{59},{60},{61},{62},{63},{64}]', toJSON(vars.AGENT_ALLOWED_MODELS), toJSON(vars.AGENT_ALLOWED_MODEL_PROVIDERS), toJSON(vars.AGENT_EFFORT), toJSON(vars.AGENT_EXECUTABLE), toJSON(vars.AGENT_MODEL), toJSON(vars.AGENT_MODEL_PROVIDER), toJSON(vars.AGENT_PROVIDER), toJSON(vars.AI_IGNORE_FILES), toJSON(vars.AI_INCLUDE_REASONING), toJSON(vars.AI_MEMBERS_ONLY), toJSON(vars.AI_PULL_REQUEST_DESCRIPTION_MODE), toJSON(vars.BUGBOT_AUTOFIX_VERIFY_COMMANDS), toJSON(vars.BUGBOT_COMMENT_LIMIT), toJSON(vars.BUGBOT_DRY_RUN), toJSON(vars.BUGBOT_EFFORT), toJSON(vars.BUGBOT_FAIL_ON_UNRESOLVED), toJSON(vars.BUGBOT_ORGANIZATION_RULES), toJSON(vars.BUGBOT_REVIEW_DRAFTS), toJSON(vars.BUGBOT_SEVERITY), toJSON(vars.BUGBOT_SUGGESTED_CHANGES), toJSON(vars.BUGBOT_TELEMETRY), toJSON(vars.BUGBOT_TRACE_RULES), toJSON(vars.BUGFIX_TREE), toJSON(vars.CHORE_TREE), toJSON(vars.COMMIT_PREFIX_TRANSFORMS), toJSON(vars.COPILOT_ISSUE_WORKFLOW_PROFILE), toJSON(vars.DEBUG), toJSON(vars.DEPLOY_LABEL), toJSON(vars.DESIRED_ASSIGNEES_COUNT), toJSON(vars.DESIRED_REVIEWERS_COUNT), toJSON(vars.DEVELOPMENT_BRANCH), toJSON(vars.DOCS_TREE), toJSON(vars.FEATURE_TREE), toJSON(vars.FINDINGS_EFFORT), toJSON(vars.FINDINGS_EXECUTABLE), toJSON(vars.FINDINGS_MODEL), toJSON(vars.FINDINGS_MODEL_PROVIDER), toJSON(vars.FINDINGS_PROVIDER), toJSON(vars.FIXER_EFFORT), toJSON(vars.FIXER_EXECUTABLE), toJSON(vars.FIXER_MODEL), toJSON(vars.FIXER_MODEL_PROVIDER), toJSON(vars.FIXER_PROVIDER), toJSON(vars.HOTFIX_TREE), toJSON(vars.ISSUES_LOCALE), toJSON(vars.ISSUE_MANAGED_BRANCHES), toJSON(vars.MAIN_BRANCH), toJSON(vars.PLANNER_EFFORT), toJSON(vars.PLANNER_EXECUTABLE), toJSON(vars.PLANNER_MODEL), toJSON(vars.PLANNER_MODEL_PROVIDER), toJSON(vars.PLANNER_PROVIDER), toJSON(vars.PRE_BRANCH_SDD), toJSON(vars.PROJECT_COLUMN_ISSUE_CREATED), toJSON(vars.PROJECT_COLUMN_ISSUE_IN_PROGRESS), toJSON(vars.PROJECT_COLUMN_PULL_REQUEST_CREATED), toJSON(vars.PROJECT_COLUMN_PULL_REQUEST_IN_PROGRESS), toJSON(vars.PROJECT_IDS), toJSON(vars.PULL_REQUESTS_LOCALE), toJSON(vars.RELEASE_TREE), toJSON(vars.REOPEN_ISSUE_ON_PUSH), toJSON(vars.REPOSITORY_LOCALE), toJSON(vars.REVIEWER_PROVIDER), toJSON(vars.TESTER_PROVIDER), toJSON(vars.VERIFICATION_SETUP)) }} + deploy-label: ${{ vars.DEPLOY_LABEL || 'deploy' }} + receipt-key: ${{ needs.issue-admission.outputs.receipt-key }} + + - name: Retain successful reconciliation receipt + if: ${{ steps.receipt.outputs.receipt-path != '' }} + continue-on-error: true + uses: actions/upload-artifact@v5 + with: + name: ${{ steps.receipt.outputs.receipt-name }} + path: ${{ steps.receipt.outputs.receipt-path }} + retention-days: 1 + if-no-files-found: error diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 86b5ffbca..8b49a85a2 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -14,7 +14,7 @@ debt or convert unknown historic intent into a design decision. | `release-orchestration` | Implemented | Release and hotfix promotion, publication, bounded merge readiness recovery, and receipt-verified reconciliation without republication | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 59 paths · 2026-10-16 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support; optional manual-only RepoWise analysis with independent CI and verified required-check migration; cached RepoWise exports and bounded full-history secret processing with truthful completeness and resource evidence | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 29 paths · 2026-10-21 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 86 paths · 2026-10-26 | -| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle with pinned native resource/lane coordination proof, conservative predecessor fencing, routing and durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 138 paths · 2026-10-27 | +| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle with pinned native resource/lane coordination proof, conservative predecessor fencing, routing and durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 4 companion | 154 paths · 2026-10-28 | | `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-10-13 | | `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) + 6 companion | 332 paths · 2026-10-24 | | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 2 companion | 248 paths · 2026-10-21 | @@ -31,7 +31,7 @@ debt or convert unknown historic intent into a design decision. | `configurable-issue-workflows` | Implemented | Select canonical issue workflows and explicit consumer field mappings; preserve forms, compare setup/doctor schemas, and report actionable fail-closed admission without state writes | [Configurable issue workflows and fail-closed admission](./configurable-issue-workflows-and-admission.md) | 119 paths · 2026-10-27 | | `repository-agent-collaboration` | Implemented | Generate safe repository-local profiles, guidance, and a skill for agents contributing through configured issues, Action-managed branches, pull requests, and deployment boundaries | [Repository agent collaboration contract](./repository-agent-collaboration-contract.md) | 47 paths · 2026-10-17 | | `guarded-pull-request-approval` | Proposed | Specify and locally implement revision-bound, evidence-gated native bot approvals for eligible human pull requests, with safe setup defaults, read-only doctor checks, and explainable recovery | [Guarded pull-request approval and setup readiness](./guarded-pull-request-approval.md) + 2 companion | 94 paths · 2026-10-22 | -| `github-browser-extension` | Proposed | Complete proposed desktop product: 150 required functions, local center, Action integration, shared setup and 803 product cases. H1 establishes builds; H2 implements shared runtime/session/settings/GET services and exact native-browser harnesses. Full product/core acceptance and signed delivery remain separate gates. | [Complete GitHub productivity extension and integrated Copilot setup](./github-browser-extension.md) + 6 companion | 121 paths · 2026-10-26 | +| `github-browser-extension` | Proposed | Complete proposed desktop product: 150 required functions, local center, Action integration, shared setup and 803 product cases. H1 establishes builds; H2 implements shared runtime/session/settings/GET services and exact native-browser harnesses. Full product/core acceptance and signed delivery remain separate gates. | [Complete GitHub productivity extension and integrated Copilot setup](./github-browser-extension.md) + 6 companion | 121 paths · 2026-10-28 | ## Evidence map @@ -82,13 +82,13 @@ 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-10-27 -- 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) +- Last verified: 2026-10-28 +- 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) · [`specs/early-issue-event-admission.md`](./early-issue-event-admission.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) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`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_message_catalog.ts`](../src/application/policies/application_error_message_catalog.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/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.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/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.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/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.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/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) · [`src/manager/description/configuration_handler.ts`](../src/manager/description/configuration_handler.ts) · [`src/manager/description/configuration_markers.ts`](../src/manager/description/configuration_markers.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) · [`src/application/policies/native_workflow_coordination_policy.ts`](../src/application/policies/native_workflow_coordination_policy.ts) · [`src/application/ports/workflow_run_ports.ts`](../src/application/ports/workflow_run_ports.ts) · [`src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts`](../src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts) · [`src/data/repository/workflow/active_previous_workflow_runs_repository.ts`](../src/data/repository/workflow/active_previous_workflow_runs_repository.ts) · [`src/data/repository/workflow/native_workflow_coordination_repository.ts`](../src/data/repository/workflow/native_workflow_coordination_repository.ts) · [`src/infrastructure/composition/workflow_queue_composition_root.ts`](../src/infrastructure/composition/workflow_queue_composition_root.ts) · [`src/domain/copilot_command_validation.ts`](../src/domain/copilot_command_validation.ts) · [`src/application/errors/comment_command_validation_error.ts`](../src/application/errors/comment_command_validation_error.ts) · [`src/application/policies/comment_command_validation_message_catalog.ts`](../src/application/policies/comment_command_validation_message_catalog.ts) · [`src/application/policies/comment_command_validation_presentation_policy.ts`](../src/application/policies/comment_command_validation_presentation_policy.ts) · [`src/domain/copilot_command_vocabulary.ts`](../src/domain/copilot_command_vocabulary.ts) · [`src/domain/change_size_reconciliation.ts`](../src/domain/change_size_reconciliation.ts) · [`src/application/ports/change_size_provider_error.ts`](../src/application/ports/change_size_provider_error.ts) · [`src/data/repository/github/change_size_provider_failure.ts`](../src/data/repository/github/change_size_provider_failure.ts) · [`src/application/usecases/steps/commit/change_size_reconciliation.ts`](../src/application/usecases/steps/commit/change_size_reconciliation.ts) · [`src/infrastructure/composition/change_size_diagnostics_composition_root.ts`](../src/infrastructure/composition/change_size_diagnostics_composition_root.ts) · [`src/data/repository/issue/issue_label_repository.ts`](../src/data/repository/issue/issue_label_repository.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/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.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/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_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/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/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/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) · [`src/manager/description/__tests__/configuration_recovery.test.ts`](../src/manager/description/__tests__/configuration_recovery.test.ts) · [`src/manager/description/__tests__/configuration_handler.test.ts`](../src/manager/description/__tests__/configuration_handler.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/application/policies/__tests__/native_workflow_coordination_policy.test.ts`](../src/application/policies/__tests__/native_workflow_coordination_policy.test.ts) · [`src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts`](../src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts) · [`src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts`](../src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/domain/__tests__/copilot_command_validation.test.ts`](../src/domain/__tests__/copilot_command_validation.test.ts) · [`src/application/policies/__tests__/comment_command_validation_presentation.test.ts`](../src/application/policies/__tests__/comment_command_validation_presentation.test.ts) · [`src/application/usecases/__tests__/command_validation_diagnostics.test.ts`](../src/application/usecases/__tests__/command_validation_diagnostics.test.ts) · [`src/domain/__tests__/change_size_reconciliation.test.ts`](../src/domain/__tests__/change_size_reconciliation.test.ts) · [`src/data/repository/github/__tests__/change_size_provider_failure.test.ts`](../src/data/repository/github/__tests__/change_size_provider_failure.test.ts) · [`src/application/usecases/steps/commit/__tests__/change_size_reconciliation.test.ts`](../src/application/usecases/steps/commit/__tests__/change_size_reconciliation.test.ts) · [`src/data/repository/issue/__tests__/issue_label_repository.test.ts`](../src/data/repository/issue/__tests__/issue_label_repository.test.ts) -- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`README.md`](../README.md) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) +- Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`issue-admission/action.yml`](../issue-admission/action.yml) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`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_message_catalog.ts`](../src/application/policies/application_error_message_catalog.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/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.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/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.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/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.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/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) · [`src/manager/description/configuration_handler.ts`](../src/manager/description/configuration_handler.ts) · [`src/manager/description/configuration_markers.ts`](../src/manager/description/configuration_markers.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) · [`src/application/policies/native_workflow_coordination_policy.ts`](../src/application/policies/native_workflow_coordination_policy.ts) · [`src/application/ports/workflow_run_ports.ts`](../src/application/ports/workflow_run_ports.ts) · [`src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts`](../src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts) · [`src/data/repository/workflow/active_previous_workflow_runs_repository.ts`](../src/data/repository/workflow/active_previous_workflow_runs_repository.ts) · [`src/data/repository/workflow/native_workflow_coordination_repository.ts`](../src/data/repository/workflow/native_workflow_coordination_repository.ts) · [`src/infrastructure/composition/workflow_queue_composition_root.ts`](../src/infrastructure/composition/workflow_queue_composition_root.ts) · [`src/domain/copilot_command_validation.ts`](../src/domain/copilot_command_validation.ts) · [`src/application/errors/comment_command_validation_error.ts`](../src/application/errors/comment_command_validation_error.ts) · [`src/application/policies/comment_command_validation_message_catalog.ts`](../src/application/policies/comment_command_validation_message_catalog.ts) · [`src/application/policies/comment_command_validation_presentation_policy.ts`](../src/application/policies/comment_command_validation_presentation_policy.ts) · [`src/domain/copilot_command_vocabulary.ts`](../src/domain/copilot_command_vocabulary.ts) · [`src/domain/change_size_reconciliation.ts`](../src/domain/change_size_reconciliation.ts) · [`src/application/ports/change_size_provider_error.ts`](../src/application/ports/change_size_provider_error.ts) · [`src/data/repository/github/change_size_provider_failure.ts`](../src/data/repository/github/change_size_provider_failure.ts) · [`src/application/usecases/steps/commit/change_size_reconciliation.ts`](../src/application/usecases/steps/commit/change_size_reconciliation.ts) · [`src/infrastructure/composition/change_size_diagnostics_composition_root.ts`](../src/infrastructure/composition/change_size_diagnostics_composition_root.ts) · [`src/data/repository/issue/issue_label_repository.ts`](../src/data/repository/issue/issue_label_repository.ts) · [`src/actions/issue_event_admission_action.ts`](../src/actions/issue_event_admission_action.ts) · [`src/data/repository/workflow/issue_event_admission_repository.ts`](../src/data/repository/workflow/issue_event_admission_repository.ts) · [`src/infrastructure/composition/issue_event_admission_composition_root.ts`](../src/infrastructure/composition/issue_event_admission_composition_root.ts) · [`src/application/policies/issue_event_admission_policy.ts`](../src/application/policies/issue_event_admission_policy.ts) · [`src/application/ports/issue_event_admission_ports.ts`](../src/application/ports/issue_event_admission_ports.ts) · [`src/application/usecases/execution/issue_event_admission_workflow.ts`](../src/application/usecases/execution/issue_event_admission_workflow.ts) · [`src/application/policies/setup_runner_policy.ts`](../src/application/policies/setup_runner_policy.ts) · [`src/manager/description/base/issue_content_interface.ts`](../src/manager/description/base/issue_content_interface.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/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.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/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_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/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/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/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) · [`src/manager/description/__tests__/configuration_recovery.test.ts`](../src/manager/description/__tests__/configuration_recovery.test.ts) · [`src/manager/description/__tests__/configuration_handler.test.ts`](../src/manager/description/__tests__/configuration_handler.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/application/policies/__tests__/native_workflow_coordination_policy.test.ts`](../src/application/policies/__tests__/native_workflow_coordination_policy.test.ts) · [`src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts`](../src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts) · [`src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts`](../src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/domain/__tests__/copilot_command_validation.test.ts`](../src/domain/__tests__/copilot_command_validation.test.ts) · [`src/application/policies/__tests__/comment_command_validation_presentation.test.ts`](../src/application/policies/__tests__/comment_command_validation_presentation.test.ts) · [`src/application/usecases/__tests__/command_validation_diagnostics.test.ts`](../src/application/usecases/__tests__/command_validation_diagnostics.test.ts) · [`src/domain/__tests__/change_size_reconciliation.test.ts`](../src/domain/__tests__/change_size_reconciliation.test.ts) · [`src/data/repository/github/__tests__/change_size_provider_failure.test.ts`](../src/data/repository/github/__tests__/change_size_provider_failure.test.ts) · [`src/application/usecases/steps/commit/__tests__/change_size_reconciliation.test.ts`](../src/application/usecases/steps/commit/__tests__/change_size_reconciliation.test.ts) · [`src/data/repository/issue/__tests__/issue_label_repository.test.ts`](../src/data/repository/issue/__tests__/issue_label_repository.test.ts) · [`src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts`](../src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts) · [`src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts`](../src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts) · [`src/actions/__tests__/issue_event_admission_action.test.ts`](../src/actions/__tests__/issue_event_admission_action.test.ts) · [`src/manager/description/base/__tests__/issue_content_interface.test.ts`](../src/manager/description/base/__tests__/issue_content_interface.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/utils/__tests__/setup_runner_installation.test.ts`](../src/utils/__tests__/setup_runner_installation.test.ts) +- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`README.md`](../README.md) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/issues/workflow-setup.mdx`](../docs/issues/workflow-setup.mdx) ### `architecture-quality-hardening` — Architecture quality and scalability hardening @@ -269,7 +269,7 @@ debt or convert unknown historic intent into a design decision. ### `github-browser-extension` — Complete GitHub productivity extension and integrated Copilot setup - Owner: Copilot maintainers -- Last verified: 2026-10-26 +- Last verified: 2026-10-28 - Specifications: [`specs/github-browser-extension.md`](./github-browser-extension.md) · [`specs/github-browser-extension-action-integration.md`](./github-browser-extension-action-integration.md) · [`specs/github-browser-extension-platforms-and-delivery.md`](./github-browser-extension-platforms-and-delivery.md) · [`specs/github-browser-extension-functional-catalog.md`](./github-browser-extension-functional-catalog.md) · [`specs/github-browser-extension-control-center-and-setup.md`](./github-browser-extension-control-center-and-setup.md) · [`specs/github-browser-extension-foundation.md`](./github-browser-extension-foundation.md) · [`specs/github-browser-extension-runtime.md`](./github-browser-extension-runtime.md) - Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`.github/workflows/setup_platform_smoke.yml`](../.github/workflows/setup_platform_smoke.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`extension/src/infrastructure/content_entry.ts`](../extension/src/infrastructure/content_entry.ts) · [`extension/src/infrastructure/background_entry.ts`](../extension/src/infrastructure/background_entry.ts) · [`extension/tooling/cli.mts`](../extension/tooling/cli.mts) diff --git a/specs/catalog.json b/specs/catalog.json index 205718ada..b6c82b937 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -603,12 +603,13 @@ "status": "implemented", "scope": "Shared GitHub Action lifecycle with pinned native resource/lane coordination proof, conservative predecessor fencing, routing and durable user-facing results", "owner": "Copilot maintainers", - "lastVerified": "2026-10-27", + "lastVerified": "2026-10-28", "specs": [ "specs/execution-admission-queue-and-publication.md", "specs/execution-error-and-context-hardening.md", "specs/execution-boundary-closure-audit.md", - "specs/push-and-single-action-context-hardening.md" + "specs/push-and-single-action-context-hardening.md", + "specs/early-issue-event-admission.md" ], "workflows": [ ".github/workflows/copilot_issue.yml", @@ -619,7 +620,8 @@ ], "entrypoints": [ "src/actions/github_action.ts", - "src/actions/common_action.ts" + "src/actions/common_action.ts", + "issue-admission/action.yml" ], "code": [ "scripts/coverage-budgets.json", @@ -689,7 +691,15 @@ "src/data/repository/github/change_size_provider_failure.ts", "src/application/usecases/steps/commit/change_size_reconciliation.ts", "src/infrastructure/composition/change_size_diagnostics_composition_root.ts", - "src/data/repository/issue/issue_label_repository.ts" + "src/data/repository/issue/issue_label_repository.ts", + "src/actions/issue_event_admission_action.ts", + "src/data/repository/workflow/issue_event_admission_repository.ts", + "src/infrastructure/composition/issue_event_admission_composition_root.ts", + "src/application/policies/issue_event_admission_policy.ts", + "src/application/ports/issue_event_admission_ports.ts", + "src/application/usecases/execution/issue_event_admission_workflow.ts", + "src/application/policies/setup_runner_policy.ts", + "src/manager/description/base/issue_content_interface.ts" ], "tests": [ "src/tooling/__tests__/coverage_budget_validator.test.ts", @@ -741,7 +751,13 @@ "src/domain/__tests__/change_size_reconciliation.test.ts", "src/data/repository/github/__tests__/change_size_provider_failure.test.ts", "src/application/usecases/steps/commit/__tests__/change_size_reconciliation.test.ts", - "src/data/repository/issue/__tests__/issue_label_repository.test.ts" + "src/data/repository/issue/__tests__/issue_label_repository.test.ts", + "src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts", + "src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts", + "src/actions/__tests__/issue_event_admission_action.test.ts", + "src/manager/description/base/__tests__/issue_content_interface.test.ts", + "src/application/usecases/__tests__/issue_use_case.test.ts", + "src/utils/__tests__/setup_runner_installation.test.ts" ], "documentation": [ "docs/overview.mdx", @@ -756,7 +772,8 @@ "README.md", "docs/features.mdx", "docs/pull-requests/workflow-setup.mdx", - "docs/issues/comment-commands.mdx" + "docs/issues/comment-commands.mdx", + "docs/issues/workflow-setup.mdx" ] }, { @@ -2870,7 +2887,7 @@ "status": "proposed", "scope": "Complete proposed desktop product: 150 required functions, local center, Action integration, shared setup and 803 product cases. H1 establishes builds; H2 implements shared runtime/session/settings/GET services and exact native-browser harnesses. Full product/core acceptance and signed delivery remain separate gates.", "owner": "Copilot maintainers", - "lastVerified": "2026-10-26", + "lastVerified": "2026-10-28", "specs": [ "specs/github-browser-extension.md", "specs/github-browser-extension-action-integration.md", diff --git a/specs/early-issue-event-admission.md b/specs/early-issue-event-admission.md new file mode 100644 index 000000000..949f48aa9 --- /dev/null +++ b/specs/early-issue-event-admission.md @@ -0,0 +1,326 @@ +# Safe early issue admission and reconciliation coalescing + +- Status: Implemented; remote PR validation required +- Date: 2026-10-28 +- Catalog capability ID: execution-lifecycle +- Owners: Copilot maintainers +- Scope: reduce complete issue runner allocations without replacing meaningful lifecycle intent +- Related issues/PRs: [#730](https://github.com/vypdev/copilot/issues/730) +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: none; remote acceptance remains required + +## 1. Executive summary + +The installed Issue workflow MUST admit events on a small hosted job before +allocating the selected work runner. A native per-issue queue retains pending +intent and never cancels active work. Only an unstarted, open issue's equivalent +label reconciliation MAY reuse a proven successful receipt. Unknown evidence +MUST retain the normal guarded pipeline. + +```text +Issue event -> native per-issue queue -> identity/state inspection + -> internal or proven duplicate: Summary, no full work runner + -> meaningful/unproven: normal pipeline -> unchanged success receipt +``` + +## 2. Problem, current behavior, and evidence + +Issue #730 records 13 distinct consumer executions after two forms, with 11 +queued and two running. One run waited 211 seconds before obtaining a runner; +its waiting time is distinct from execution time. Individual payload action +types were not retained. The report establishes repeated allocations, not a +specific race, outage or infinite loop. Existing runtime actor admission occurs +before agent preparation but after a full runner job has already been allocated. +An unset/stale `COPILOT_BOT_LOGIN` cannot provide authoritative early identity. + +GitHub documents separate opened and initial label deliveries, single-pending +replacement by default, and `queue: max` preserving up to 100 pending runs. +This is a prospective correction, not a claim of consumer rollout or measured +live queue improvement. Prior historical motivation is unknown. + +## 3. Actors, surfaces, and terminology + +Maintainers generate meaningful issue events; the product credential owns +internal mutations. The native workflow/run/artifact APIs own execution facts. +The preflight subaction owns read-only admission and a local receipt file; +the existing Action owns permissions, issue lifecycle and repository effects. +“Receipt” means an opaque digest artifact from completed successful work, not +an authorization grant or claim that a branch/build was verified. + +## 4. Goals, non-goals, and fixed invariants + +- MUST filter real product-account events before work-runner allocation even + when the bot-login Variable is absent. Bot-authored native closure remains runnable. +- MUST preserve opened, start addition/removal, deploy, body/title edits, + reopened, closed, assignment and independent issue behavior. +- MUST NOT cancel running jobs or replace pending lifecycle events. +- MUST NOT infer success from missing, canceled, failed, foreign or stale evidence. +- MUST keep runtime authorization, admission, branch/SDD, deployment and result gates. +- Non-goals: exactly-once GitHub delivery, eliminating every workflow record, + optimizing started issue work, live consumer adoption or altering application CI. + +## 5. Current versus proposed journey + +| Stage | Before | After | Operator meaning | +|---|---|---|---| +| Event burst | one full runner per delivery | hosted inspection first | work pool sees admitted jobs only | +| Bot metadata | Variable gate or full runtime discard | authenticated identity discard | configuration absence creates no recursion cost in work pool | +| Pending intent | workflow predecessor polling on runners | native per-issue queue | independent issues do not wait behind each other | +| Stable duplicate | full reconciliation again | success receipt may cover queued equivalent state | no new product mutation | +| Missing proof | full runtime | full runtime | correctness takes precedence over optimization | + +Text equivalent: queue, inspect, skip only proven redundant work; otherwise run +all normal guards and preserve recovery. Lightweight jobs still consume hosted +capacity; the design does not claim zero allocations. + +## 6. Functional behavior and state model + +The inspect entry MUST authenticate the actual product account. An internal +ordinary event returns `self-event`; a closed event remains admitted. Meaningful +non-label actions and start/deploy label changes always enter the full pipeline. +Only `labeled`/`unlabeled` reconciliation of an open issue without `in-progress`, +release or hotfix intent is a candidate. Provider state uncertainty means execute. + +The digest binds native issue ID/number, title, body, state, updated timestamp, +author, label/assignee sets, milestone, native type and lock; repository/actor, +product account, executing workflow SHA, bundle, fail-closed bootstrap and both action manifests, +snapshot of Variables referenced by the full work step and matching deploy/release/hotfix labels. Set order and login case are +canonical; issue text is neither truncated nor persisted in receipts. + +A receipt MUST match that digest and be produced by the same native workflow ID, +path, issues event, head SHA and original actor. Its run MUST be earlier and +completed successfully. Its artifact MUST have been created strictly AFTER the +current event's run was dispatched: later deliberate requests cannot be hidden +by old success. Manual attempts after the first never coalesce. Native ISO timestamps and typed actor logins are required: missing fields are +never coerced into a legitimate account name. Native run and artifact facts, +not filenames alone, establish authority. At most three matching +artifact candidates are inspected; uncertainty retains work. + +The shared description persistence boundary MUST return the exact rendered body +without a provider update when it is byte-identical to the freshly read native +body. Changed content still uses the existing error/permission boundary. This +prevents configuration no-ops from generating another admission edit. + +After successful full work, the record step rereads identity/state with the same +code and configuration. A changed digest produces no receipt. Failed/canceled +work cannot reach the success-only record/upload steps or provide completed +success provenance. A successful provider operation followed by partial failure +therefore retains the existing full recovery path. + +| State | Next | Human action | +|---|---|---| +| hosted inspection queued | native issue queue | normally none | +| self event / covered pending duplicate | Summary, full job skipped | none | +| meaningful / unproven | full guarded pipeline | normally none | +| identity invalid | blocked before full work | repair product credential, rerun | +| full work failed/partial | existing failure evidence, no usable receipt | existing recovery | +| full work succeeded but state changed | no receipt | subsequent event reconciles latest state | + +## 7. User-facing configuration + +Recommended setup retains fixed hosted `ubuntu-latest` inspection (5 minutes) +and the operator-selected full work runner. Per-issue native concurrency is +`copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}` with +`cancel-in-progress: false`, `queue: max`. GitHub caps this at 100 pending runs; +excess runs can be canceled by GitHub. Inspect Actions and re-run the relevant +intent after capacity returns; no unbounded queue or ordering promise is made. + +The isolated `issue-admission` subaction accepts only modes `inspect`/`record`, +existing product `token`, effective Variables snapshot (maximum 1 MiB UTF-8), deploy label, +locale, matching release/hotfix label aliases and an exact 64-hex receipt key. It returns closed reasons, execute flag, +opaque key and private receipt path/name. No issue text or credential is output. +The generated snapshot serializes each referenced Variable with `toJSON` before +formatting an ordered JSON array. Structural validation derives the same list +from work-step inputs/environment and job placement/environment, rejects dynamic +lookup, and prevents a new effective Variable from escaping the snapshot. +Unrelated repository Variables are not forwarded or exposed in admission inputs. +English/Spanish regional locales use English fallback. Deployment, release and hotfix labels must +match the full Action; the generated defaults are deploy/release/hotfix. Receipts retain one day and are an optimization only. + +There is no bypass input, sleep/debounce duration, user-controlled command, +provider-write credential, schema migration or new durable issue field. Custom +legacy workflows remain supported through their existing runtime fence, but do +not gain early coalescing until reviewed workflow/runtime adoption. + +## 8. Clean Architecture design + +Pure policy projects eligible reconciliation identity and fixed presentation. +The execution use case coordinates identity, native state and receipt queries +through `IssueEventAdmissionPort`. A repository-bound provider adapter selects +native fields and checks run/artifact authority; infrastructure owns SDK clients, +hashing, bundle/manifests, files and environment. The isolated entry routes before +normal execution, projects closed outputs and writes the Summary/local file. +A packaged bootstrap MUST reject missing/unknown modes before loading the root +bundle and MUST discard the normal JSON input-override channel. Metadata +`required: true` alone does not enforce this boundary. The root entry MUST +require the bootstrap-owned process flag and read its mode directly; a private +mode key in an ordinary JSON input map MUST NOT redirect normal work. Reserved label comparison +is conservative across case variants. +No agent, mainRun, completion, issue mutation or conversation publication port +is reachable from the subaction. + +```mermaid +flowchart LR + Q[Native issue queue] --> E[Isolated entry] + E --> U[Admission use case] + U --> P[Pure policy] + U --> R[Read-only port] + G[GitHub adapter] --> R + E --> S[Summary and optional local receipt] + E --> W[Admitted normal workflow] +``` + +Text equivalent: native serialization protects the whole workflow; a thin entry +calls pure policy/read ports and either reports a skip or permits normal work. +The normal runtime recognizes the exact pinned workflow-level per-issue native +contract. Missing/malformed/custom proof retains predecessor polling. Unlike +cancelable PR analysis lanes, issue lanes require `queue: max` and literal false +cancellation. Reusable/current-job mismatches do not grant a bypass. Source-level +architecture, native coordination tests, cycle checks and structural workflow +validation enforce boundaries. + +## 9. UI/UX and content contract + +The native jobs are `Admit issue event` and `Copilot - Issue`. Inspect emits one +Job Summary and zero conversation comments/checks/cards. Example primary views: + +```markdown +## Issue event admission +Event admitted. The normal pipeline will verify permissions and reconcile the issue. +No action is required. Failed or partial work retains the normal pipeline and cannot produce a reusable receipt. +``` + +- Pending: Actions shows the issue workflow waiting; running work is retained. +- Internal: “Internal event skipped: the product account generated it. No work runner was allocated.” +- Covered: “State already reconciled by a successful execution of this workflow. The work runner was skipped.” +- Unproven: normal pipeline admitted; no success is inferred from receipt lookup failure. +- Action required: sanitized identity/configuration failure links to the native run. +- Partial: normal failure Summary remains; receipt absence does not claim rollback. +- Complete: normal lifecycle evidence plus optional artifact; no new issue card. + +EN/ES regional fallback, textual headings and short paragraphs support narrow, +light/dark and screen-reader views. Native Actions provides issue/run navigation; +technical reason/digest outputs contain no title, body, raw exception or token. + +## 10. Failure, recovery, and cleanup + +Identity failure blocks inspection without full mutation. Optional native-state +or receipt uncertainty retains full authoritative runtime checks. Receipt record +or upload failure is best effort and preserves successful core work, with no +usable proof created. Changed state/code/account/configuration never reuses a +receipt. Manual rerun executes full work. Partial effects remain owned by the +existing operation state and cannot be erased/canceled by this optimization. +One-day artifact retention expires naturally; no remote deletion or new cleanup +permission is added. Private temp files are runner-owned and disposable. + +## 11. Security, permissions, and privacy + +Hosted inspection has `contents: read` and `actions: read` on automatic +GITHUB_TOKEN; the latter is scoped only to native receipt/run reads. The product +PAT retains existing identity/issue reads and normal full-work authority. No PAT +scope or full-job permission is broadened. No agent API key reaches inspection. +Native issue content is untrusted data: selected fields are hashed, never executed, +rendered or logged. Receipts contain only a schema and opaque name. Foreign +workflow artifacts, actor mismatches, old code, attempts and failed runs fail +closed with respect to skipping work. + +## 12. Observability and operational UX + +Closed reasons are self-event, lifecycle-event, reconciliation-needed and +already-reconciled. Each is visible in the native Summary/log; a skipped full job +is intentional, not proof of full verification. Compare complete work allocations +separately from hosted preflight records and queue time separately from runtime. +No polling loop, timer, model call or repeated issue notification is added. + +## 13. Compatibility, migration, rollout, and rollback + +New generated workflows require a matching runtime containing the isolated +`issue-admission/action.yml` path. Upgrade the runtime reference first, then apply +reviewed workflow changes through setup. An older release lacks the subaction and +fails before admitting a full job, rather than interpreting an unknown root input +as ordinary automation. No consumer was modified or live queue result claimed. +Legacy/custom workflows remain conservative. Review GitHub provider support for +`queue: max` before adopting on enterprise installations. Rollback restores the +previous reviewed workflow/runtime together; receipts then have no consumer and +expire. Existing issue configuration and branch/SDD ownership are unchanged. + +## 14. Testing strategy and numeric budget + +Minimum **120 distinct cases**, counted once per test/meaningful parameter row: + +| Area | Minimum | Risk | +|---|---:|---| +| Pure/application identity and transitions | 40 | closed/start/deploy/actor/state/configuration equivalence | +| Native provider authority and failures | 48 | receipt/run provenance, partial/stale/foreign/malformed responses | +| Workflows/setup/native coordination | 16 | before-runner gate, no cancellation/replacement, roles and least privilege | +| Entrypoint/integration/replay | 12 | isolated route, receipt file privacy, duplicate allocation reduction and recovery | +| Presentation/locales | 4 | four closed states, EN/ES/regional/fallback | +| **Total** | **120** | Deterministic fakes; no real waits or external writes | + +Repository global/module budgets remain mandatory. New pure policy MUST have +100% in all four metrics; changed/new executable patch lines and branches MUST +be covered. Before/after fixtures MUST show seven stable representative events +needing five full allocations instead of seven while preserving opened, start, +reopen and independent issue intent. Failed/partial runs, provider uncertainty, +manual reruns and later requests MUST remain fully executable. Local tests do +not prove live runner/consumer rollout; remote CI, review and Codecov remain gates. + +## 15. Documentation and discoverability + +Issue workflow setup describes the normal queue/admission journey, fixed hosted +role and matching runtime prerequisite. Troubleshooting explains skipped work, +artifact absence, queue saturation and manual recovery. Execution admission SDD +links this contract. Catalog evidence, test ledger, generated catalogs, package +manifest and setup-runner contracts change together. No new doc route is needed. + +## 16. Acceptance scenarios + +1. A real product metadata event is discarded before full work, including absent bot-login Variable. +2. Product closure and human open/start/reopen/close/edit/assignment retain normal behavior. +3. Stable initial routing duplicates already queued during successful work can reuse its exact receipt. +4. Missing proof, a different issue/state/actor/account/configuration/code or a later request executes normally. +5. Failed/canceled/partial source work cannot cover another event; a manual retry is not coalesced. +6. A concurrent state change prevents receipt publication; source-only hashes never expose native text. +7. Native issue lanes retain up to GitHub's 100 pending runs without active cancellation, independent issue groups progress. +8. EN/ES/fallback Summaries, structural guard mutation tests, package/subaction shipping and legacy fallback pass. + +## 17. Requirements traceability + +| Requirement | Owner | Evidence | Documentation | +|---|---|---|---| +| Exact equivalence and no lost intent | policy/use case | issue_event_admission_workflow tests | workflow setup | +| Successful queued-work proof only | provider adapter | issue_event_admission_repository tests | troubleshooting | +| Isolated boundary/no agent keys | entry/composition/workflow | action tests and structural guard | workflow setup | +| Native per-issue safety | coordination policy | native policy/query tests | execution SDD | +| Visible skips/privacy/locales | pure Summary | locale and file tests | troubleshooting | + +## 18. Implementation sequence + +1. Establish evidence and safe equivalence contract. +2. Implement pure policy, read ports, bounded native adapter and isolated entry. +3. Generate two-job workflows, native coordination recognition and structural guards. +4. Add duplicate/replay/race/privacy/identity/locale/package tests and docs together. +5. Update Graphify, run full frozen-source validation/coverage/hooks and publish normally. +6. Require exact-head remote CI/review/Codecov before integration; adopt consumers separately. + +## 19. Definition of Done + +- [x] 120-case budget and all normative acceptance scenarios pass. +- [x] No active/pending lifecycle cancellation, authorization bypass or product/full-job permission widening. +- [x] Pure/changed-module and full global/setup/extension coverage budgets pass. +- [x] Isolated bundled subaction, normal Action, CLI/API/web/package validation pass. +- [x] Documentation, catalog, ledger, Graphify and structural architecture/workflow checks pass. +- [ ] Exact-head remote checks, no findings and full Codecov modified-line coverage pass. +- [x] Rollout prerequisites and unverified live consumer scope remain explicit. + +## 20. References and decisions + +- [Native concurrency and queue limits](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) +- [Issue initial-label deliveries and workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) +- [Required metadata does not enforce missing inputs](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax#inputsinput_idrequired) +- [Node Action script resolution](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs) +- [Execution lifecycle](./execution-admission-queue-and-publication.md) +- [Managed issue lifecycle](./managed-issue-and-branch-lifecycle.md) +- Rejected: cancel-in-progress, single-pending replacement, timestamp-only debounce, + trusting a bot-login Variable as identity, body/label-only success claims, + issue configuration receipts, remote write scopes, or skipping unproven recovery. diff --git a/specs/execution-admission-queue-and-publication.md b/specs/execution-admission-queue-and-publication.md index bcfc1f154..6f54f7312 100644 --- a/specs/execution-admission-queue-and-publication.md +++ b/specs/execution-admission-queue-and-publication.md @@ -529,3 +529,18 @@ read permissions, shared-runner recovery and unchanged safety guards. lifecycle/debug-only summaries into the durable conversation because the native workflow and Job Summary already retain that evidence. - Follow-up outside scope: durable event storage beyond issue configuration markers. + + +## Early issue admission — issue #730 + +The generated Issue workflow now uses a fixed hosted preflight before the +selected work runner and a non-canceling, multi-pending native per-issue queue. +The isolated subaction authenticates the actual product identity and may reuse +only a successful unchanged-state receipt covering an event already in queue. +All substantive intent and unproven/partial work retains the normal runtime. +The exact contract, provider queue limit, matching runtime adoption requirement, +privacy model and numeric test budget are in +[early issue admission](./early-issue-event-admission.md). This extends §6.4: +issues use workflow-level `queue: max` and `cancel-in-progress: false`; the three +existing PR/push lanes keep their original contracts. Unsupported/custom proof +continues through the predecessor fence. No consumer rollout is implied. diff --git a/specs/github-browser-extension-foundation.md b/specs/github-browser-extension-foundation.md index b694897e8..f39c82b78 100644 --- a/specs/github-browser-extension-foundation.md +++ b/specs/github-browser-extension-foundation.md @@ -475,3 +475,14 @@ and tests do not complete inventory functions, the 96 full core scenarios or the 803-case product floor. The implementation PR records exact browser/source/ container receipts and failures separately; no compatibility, signed distribution or complete local-center/setup availability is inferred from build success. + + +### Root package allowlist maintenance — issue #730 (2026-10-28) + +The exact root npm allowlist now additionally ships `issue-admission/action.yml` and its fail-closed `index.cjs` bootstrap +for the upstream isolated issue-admission subaction. C25 MUST verify that file is +present while retaining the exclusion of `extension/`, generated extension +artifacts and browser-contract development sources. Package identity, private +extension version, target receipts, clean-build comparisons, workspace overrides +and every verification gate remain unchanged. This is root package maintenance, +not a browser product, permission, store or loading claim. diff --git a/src/actions/__tests__/github_action.test.ts b/src/actions/__tests__/github_action.test.ts index 898a1ebfb..5d3f1f1dc 100644 --- a/src/actions/__tests__/github_action.test.ts +++ b/src/actions/__tests__/github_action.test.ts @@ -14,6 +14,9 @@ import { ACTIONS } from '../../data/model/action_types'; import { INPUT_KEYS } from '../../application/contracts/input_keys'; import { ApplicationError } from '../../application/errors/application_error'; +const mockIssueAdmission = jest.fn(); +jest.mock('../issue_event_admission_action', () => ({ runIssueEventAdmissionAction: (...args: unknown[]) => mockIssueAdmission(...args) })); + jest.mock('@actions/github', () => ({ context: { payload: {}, @@ -769,3 +772,37 @@ describe('runGitHubActionEntry', () => { expect(core.setFailed).not.toHaveBeenCalledWith(expect.stringContaining('entry failed')); }); }); + + +it('routes the isolated issue subaction before identity setup, agents, mainRun or completion', async () => { + mockMainRun.mockClear(); mockExecutionAdmissionInvoke.mockClear(); mockProvision.mockClear(); + jest.mocked(core.getInput).mockImplementation(name => name === 'issue-event-admission-mode' ? 'inspect' : ''); + mockIssueAdmission.mockResolvedValue(undefined); + const before = process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP; + process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP = '1'; + try { + await runGitHubAction(); + expect(mockIssueAdmission).toHaveBeenCalledWith('inspect'); + expect(mockMainRun).not.toHaveBeenCalled(); expect(mockExecutionAdmissionInvoke).not.toHaveBeenCalled(); expect(mockProvision).not.toHaveBeenCalled(); + } finally { + if (before === undefined) delete process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP; + else process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP = before; + } +}); + + +it('keeps private admission-mode keys out of ordinary root input maps', async () => { + const beforeFlag = process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP; const beforeJson = process.env.INPUT_VARS_JSON; + delete process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP; + process.env.INPUT_VARS_JSON = '{"issue-event-admission-mode":"inspect"}'; + mockIssueAdmission.mockClear(); mockExecutionAdmissionInvoke.mockClear(); + jest.mocked(core.getInput).mockImplementation(name => name === 'token' ? 'product' : name === 'issue-event-admission-mode' ? 'inspect' : ''); + mockExecutionAdmissionInvoke.mockRejectedValueOnce(new Error('NORMAL_ENTRY_REACHED')); + try { + await expect(runGitHubAction()).rejects.toThrow('NORMAL_ENTRY_REACHED'); + expect(mockIssueAdmission).not.toHaveBeenCalled(); expect(mockExecutionAdmissionInvoke).toHaveBeenCalledTimes(1); + } finally { + if (beforeFlag === undefined) delete process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP; else process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP = beforeFlag; + if (beforeJson === undefined) delete process.env.INPUT_VARS_JSON; else process.env.INPUT_VARS_JSON = beforeJson; + } +}); diff --git a/src/actions/__tests__/issue_event_admission_action.test.ts b/src/actions/__tests__/issue_event_admission_action.test.ts new file mode 100644 index 000000000..980dc2f45 --- /dev/null +++ b/src/actions/__tests__/issue_event_admission_action.test.ts @@ -0,0 +1,106 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runIssueEventAdmissionAction } from '../issue_event_admission_action'; +import { createIssueEventAdmissionPort } from '../../infrastructure/composition/issue_event_admission_composition_root'; + +jest.mock('@actions/core', () => ({ getInput: jest.fn(), setOutput: jest.fn(), info: jest.fn(), summary: { addRaw: jest.fn().mockReturnThis(), write: jest.fn().mockResolvedValue(undefined) } })); +jest.mock('@actions/github', () => ({ context: { repo: { owner: 'org', repo: 'repo' }, eventName: 'issues', actor: 'maintainer', + runId: 200, payload: { action: 'labeled', issue: { number: 21 }, label: { name: 'bug' } } } })); +jest.mock('../../infrastructure/composition/issue_event_admission_composition_root'); +const factory = jest.mocked(createIssueEventAdmissionPort); +const issue = { id: 99, number: 21, title: 'Native', body: 'PRIVATE_BODY', state: 'open', updatedAt: '2026-10-28T00:00:00Z', + author: 'author', labels: ['bug'], assignees: [], milestone: null, issueType: 'Bug', locked: false }; +const port = { authenticatedLogin: jest.fn(), readIssue: jest.fn(), hasSuccessfulReceipt: jest.fn() }; +let temporary: string; let inputs: Record; +const env = { ...process.env }; +beforeEach(() => { + jest.clearAllMocks(); + github.context.payload = { action: 'labeled', issue: { number: 21 }, label: { name: 'bug' } }; + temporary = mkdtempSync(join(tmpdir(), 'copilot-730-action-')); process.env.RUNNER_TEMP = temporary; + process.env.GITHUB_WORKFLOW_SHA = 'a'.repeat(40); delete process.env.COPILOT_ISSUE_ADMISSION_TOKEN; + inputs = { token: 'PRIVATE_TOKEN', configuration: '{}', 'receipt-key': '' }; + jest.mocked(core.getInput).mockImplementation(name => inputs[name] ?? ''); factory.mockReturnValue(port as never); + port.authenticatedLogin.mockResolvedValue('product'); port.readIssue.mockResolvedValue(issue); port.hasSuccessfulReceipt.mockResolvedValue(false); +}); +afterEach(() => { rmSync(temporary, { recursive: true, force: true }); process.env = { ...env }; }); +function output(name: string): string | undefined { return jest.mocked(core.setOutput).mock.calls.find(call => call[0] === name)?.[1] as string | undefined; } +it('inspects with a dedicated native read credential and emits only closed reasons and digests', async () => { + process.env.COPILOT_ISSUE_ADMISSION_TOKEN = ' EVIDENCE_TOKEN '; + await runIssueEventAdmissionAction('inspect'); + expect(factory).toHaveBeenCalledWith('org', 'repo', 'PRIVATE_TOKEN', 'EVIDENCE_TOKEN'); + expect(output('execute')).toBe('true'); expect(output('receipt-key')).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.stringify(jest.mocked(core.info).mock.calls)).not.toMatch(/PRIVATE/); expect(readdirSync(temporary)).toEqual([]); +}); +it('retains the existing credential fallback and records a private file only for unchanged native state', async () => { + await runIssueEventAdmissionAction('inspect'); inputs['receipt-key'] = output('receipt-key')!; jest.mocked(core.setOutput).mockClear(); + await runIssueEventAdmissionAction('record'); + const file = output('receipt-path')!; expect(file.startsWith(temporary)).toBe(true); + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ schema: 1, receipt: output('receipt-name') }); + expect(readFileSync(file, 'utf8')).not.toMatch(/PRIVATE|Native/); + expect(factory).toHaveBeenCalledWith('org', 'repo', 'PRIVATE_TOKEN', 'PRIVATE_TOKEN'); +}); +it('records nothing after a state change or when no coalescible key exists', async () => { + await runIssueEventAdmissionAction('record'); expect(output('receipt-path')).toBeUndefined(); + await runIssueEventAdmissionAction('inspect'); inputs['receipt-key'] = output('receipt-key')!; jest.mocked(core.setOutput).mockClear(); + port.readIssue.mockResolvedValue({ ...issue, labels: ['in-progress'] }); + await runIssueEventAdmissionAction('record'); expect(output('receipt-path')).toBeUndefined(); expect(readdirSync(temporary)).toEqual([]); +}); +it('blocks unknown isolated modes before constructing any client', async () => { + await expect(runIssueEventAdmissionAction('unsafe')).rejects.toMatchObject({ code: 'configuration.invalid' }); expect(factory).not.toHaveBeenCalled(); +}); +it('requires the native temporary directory for receipt storage', async () => { + await runIssueEventAdmissionAction('inspect'); inputs['receipt-key'] = output('receipt-key')!; delete process.env.RUNNER_TEMP; + await expect(runIssueEventAdmissionAction('record')).rejects.toMatchObject({ code: 'configuration.invalid' }); +}); + + +it.each([ + ['missing action', { issue: { number: 21 }, label: { name: 'bug' } }], + ['missing issue', { action: 'labeled', label: { name: 'bug' } }], +])('blocks %s before normal runtime', async (_name, payload) => { + github.context.payload = payload; + await expect(runIssueEventAdmissionAction('inspect')).rejects.toMatchObject({ code: 'configuration.invalid' }); +}); +it('blocks a missing native workflow revision', async () => { + delete process.env.GITHUB_WORKFLOW_SHA; + await expect(runIssueEventAdmissionAction('inspect')).rejects.toMatchObject({ code: 'configuration.invalid' }); +}); +it('admits missing label evidence conservatively and emits no receipt key', async () => { + github.context.payload = { action: 'labeled', issue: { number: 21 } }; + await runIssueEventAdmissionAction('inspect'); expect(output('execute')).toBe('true'); expect(output('receipt-key')).toBe(''); +}); +it('filters an internal transition without publishing its private native body', async () => { + port.authenticatedLogin.mockResolvedValue('maintainer'); + await runIssueEventAdmissionAction('inspect'); expect(output('execute')).toBe('false'); expect(output('receipt-key')).toBe(''); + expect(port.readIssue).not.toHaveBeenCalled(); expect(JSON.stringify(jest.mocked(core.summary.addRaw).mock.calls)).not.toContain('PRIVATE'); +}); +it('preserves a custom deployment label as substantive intent', async () => { + inputs['deploy-label'] = 'ship'; github.context.payload.label = { name: 'ship' }; + await runIssueEventAdmissionAction('inspect'); expect(output('execute')).toBe('true'); expect(output('receipt-key')).toBe(''); +}); + + +it.each(['release', 'hotfix'])('retains the full pipeline for a configured %s label alias', async kind => { + inputs[kind + '-label'] = 'custom-kind'; port.readIssue.mockResolvedValue({ ...issue, labels: ['custom-kind'] }); + await runIssueEventAdmissionAction('inspect'); expect(output('execute')).toBe('true'); expect(output('receipt-key')).toBe(''); +}); + + +it.each([undefined, '', 'unsafe', ' inspect ', 'record'])('keeps the packaged bootstrap isolated for mode %s', mode => { + if (mode === undefined) delete process.env['INPUT_ISSUE-EVENT-ADMISSION-MODE']; + else process.env['INPUT_ISSUE-EVENT-ADMISSION-MODE'] = mode; + process.env.INPUT_VARS_JSON = '{"issue-event-admission-mode":""}'; + const entry = jest.fn().mockResolvedValue(undefined); + jest.doMock(join(process.cwd(), 'build/github_action/index.js'), () => ({ runGitHubActionEntry: entry })); + const beforeExit = process.exitCode; const diagnostic = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + jest.isolateModules(() => { require(join(process.cwd(), 'issue-admission/index.cjs')); }); + if (mode?.trim() === 'inspect' || mode === 'record') { + expect(entry).toHaveBeenCalledTimes(1); expect(process.env.INPUT_VARS_JSON).toBeUndefined(); + expect(process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP).toBe('1'); + } else { expect(process.exitCode).toBe(1); expect(entry).not.toHaveBeenCalled(); expect(diagnostic).toHaveBeenCalledTimes(1); } + } finally { process.exitCode = beforeExit; diagnostic.mockRestore(); } +}); diff --git a/src/actions/common_action.ts b/src/actions/common_action.ts index f2bc1bf6f..6cb5ded6c 100644 --- a/src/actions/common_action.ts +++ b/src/actions/common_action.ts @@ -66,6 +66,7 @@ export async function mainRun( await waitForPreviousWorkflowRuns(execution.tokens.token, repository, { isSingleAction: execution.isSingleAction, pullRequestNumber: execution.inputs?.pull_request?.number, + issueNumber: execution.inputs?.issue?.number, }); } diff --git a/src/actions/github_action.ts b/src/actions/github_action.ts index 83967c200..5ea3b0cba 100644 --- a/src/actions/github_action.ts +++ b/src/actions/github_action.ts @@ -1,3 +1,4 @@ +import { runIssueEventAdmissionAction } from './issue_event_admission_action'; import { runNativeVerificationEntry } from '../infrastructure/native_verification_controller'; import { createNativeStatusReadPort } from '../infrastructure/composition/issue_completion_composition_root'; import * as core from '@actions/core'; @@ -43,6 +44,12 @@ import { isBotPullRequestAnalysisEvent } from '../application/policies/bot_pull_ import { createPullRequestUseCaseCompositionRoot } from '../infrastructure/composition/pull_request_use_case_composition_root'; export async function runGitHubAction(): Promise { + if (process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP === '1') { + // Only the isolated bootstrap owns this input; ordinary JSON input maps + // must not redirect the mutation entry into a successful read-only no-op. + await runIssueEventAdmissionAction(core.getInput('issue-event-admission-mode', { required: true })); + return; + } if (isEnabledInput(getGithubActionInput('pr-approval-observer'))) { await runPullRequestApprovalAction(getGithubActionInput(INPUT_KEYS.TOKEN, { required: true })); return; @@ -255,6 +262,6 @@ function earlyGithubActionErrorMessage(): ApplicationErrorMessageReader { // Only auto-run when executed as the action entry (not when imported by tests) /* istanbul ignore next -- the bundled production entry is covered by the Action smoke path. */ -if (typeof process.env.JEST_WORKER_ID === 'undefined') { +if (typeof process.env.JEST_WORKER_ID === 'undefined' && process.env.COPILOT_ISSUE_ADMISSION_BOOTSTRAP !== '1') { void runGitHubActionEntry(() => runNativeVerificationEntry(runGitHubActionEntry)); } diff --git a/src/actions/issue_event_admission_action.ts b/src/actions/issue_event_admission_action.ts new file mode 100644 index 000000000..19a8216f9 --- /dev/null +++ b/src/actions/issue_event_admission_action.ts @@ -0,0 +1,48 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { createHash } from 'node:crypto'; +import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ApplicationError } from '../application/errors/application_error'; +import { issueEventAdmissionSummary } from '../application/policies/issue_event_admission_policy'; +import { inspectIssueEvent, recordIssueEvent } from '../application/usecases/execution/issue_event_admission_workflow'; +import { createIssueEventAdmissionPort } from '../infrastructure/composition/issue_event_admission_composition_root'; +import type { IssueEventAdmissionContext } from '../application/ports/issue_event_admission_ports'; + +/** Isolated read-only entry. Never prepares agents, mainRun, completion, or conversation writes. */ +export async function runIssueEventAdmissionAction(mode: string): Promise { + if (mode !== 'inspect' && mode !== 'record') throw new ApplicationError('configuration.invalid', 'Unknown issue event admission mode.'); + const token = core.getInput('token', { required: true }); + const evidenceToken = process.env.COPILOT_ISSUE_ADMISSION_TOKEN?.trim(); + const context: IssueEventAdmissionContext = { + owner: github.context.repo.owner, repository: github.context.repo.repo, + eventName: github.context.eventName, action: github.context.payload.action ?? '', actor: github.context.actor, + issueNumber: github.context.payload.issue?.number ?? 0, runId: github.context.runId, + workflowRevision: process.env.GITHUB_WORKFLOW_SHA ?? '', runtimeRevision: digest(Buffer.concat([runtimeFile(__filename), runtimeFile(join(__dirname, '../../action.yml')), + runtimeFile(join(__dirname, '../../issue-admission/action.yml')), runtimeFile(join(__dirname, '../../issue-admission/index.cjs'))])), + configuration: core.getInput('configuration'), deployLabel: core.getInput('deploy-label') || 'deploy', + releaseLabel: core.getInput('release-label') || 'release', hotfixLabel: core.getInput('hotfix-label') || 'hotfix', + changedLabel: typeof github.context.payload.label?.name === 'string' ? github.context.payload.label.name : '', + }; + const port = createIssueEventAdmissionPort(context.owner, context.repository, token, evidenceToken || token); + if (mode === 'inspect') { + const decision = await inspectIssueEvent(context, port, digest); + core.setOutput('execute', String(decision.execute)); + core.setOutput('reason', decision.reason); + core.setOutput('receipt-key', decision.receiptKey ?? ''); + core.info(`Issue event admission: ${decision.reason}.`); + await core.summary.addRaw(issueEventAdmissionSummary(decision, core.getInput('repository-locale'))).write(); + return; + } + const receipt = await recordIssueEvent(context, core.getInput('receipt-key'), port, digest); + if (!receipt) { core.info('Issue state changed or is not coalescible; no reconciliation receipt was recorded.'); return; } + const temporary = process.env.RUNNER_TEMP; + if (!temporary) throw new ApplicationError('configuration.invalid', 'Issue admission receipt requires the native runner temporary directory.'); + const file = join(mkdtempSync(join(temporary, 'copilot-issue-receipt-')), 'receipt.json'); + writeFileSync(file, JSON.stringify({ schema: 1, receipt }), { mode: 0o600 }); + core.setOutput('receipt-name', receipt); core.setOutput('receipt-path', file); +} +function digest(value: string | Buffer): string { return createHash('sha256').update(value).digest('hex'); } + +/** Dynamic reads keep ncc from relocating the manifests away from the shipped action tree. */ +function runtimeFile(file: string): Buffer { return readFileSync(file); } diff --git a/src/actions/main_run_lifecycle.ts b/src/actions/main_run_lifecycle.ts index 881aa1771..c109e235e 100644 --- a/src/actions/main_run_lifecycle.ts +++ b/src/actions/main_run_lifecycle.ts @@ -28,7 +28,7 @@ export class WorkflowQueueFailureError extends Error { export function buildPreviousWorkflowRunsQuery( repository: RepositoryCoordinates, - event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number }, + event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number; readonly issueNumber?: number }, ): PreviousWorkflowRunsQuery { const workflowIdentifier = resolveWorkflowIdentifier(process.env.GITHUB_WORKFLOW_REF); const query: PreviousWorkflowRunsQuery = { @@ -44,6 +44,7 @@ export function buildPreviousWorkflowRunsQuery( eventName: process.env.GITHUB_EVENT_NAME ?? '', ref: process.env.GITHUB_REF, pullRequestNumber: event.pullRequestNumber, + issueNumber: event.issueNumber, }), } : {}), }; @@ -53,7 +54,7 @@ export function buildPreviousWorkflowRunsQuery( export async function waitForPreviousWorkflowRuns( token: string, repository: RepositoryCoordinates, - event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number }, + event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number; readonly issueNumber?: number }, ): Promise { const query = buildPreviousWorkflowRunsQuery(repository, event); if (process.env.GITHUB_ACTIONS === 'true' && (!Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0)) { diff --git a/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts b/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts index 6834f0e30..3268bbb14 100644 --- a/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts +++ b/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts @@ -59,3 +59,29 @@ describe('native workflow coordination policy', () => { expect(hasNativeWorkflowCoordination(workflow({ group, 'cancel-in-progress': true, queue: 'single' }), 'review', group)).toBe(true); }); }); + + +describe('native issue lifecycle serialization', () => { + const issueGroup = 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}'; + const issueQuery = { ...query, nativeCoordination: { ...native, eventName: 'issues', issueNumber: 21 } }; + const definition = { concurrency: { group: issueGroup, 'cancel-in-progress': false, queue: 'max' }, jobs: { + admission: { steps: [] }, work: { steps: [], needs: 'admission' }, + } }; + it('recognizes outer per-issue coordination covering admission and work before runner acquisition', () => { + expect(expectedNativeWorkflowGroup(issueQuery)).toBe(issueGroup); + expect(hasNativeWorkflowCoordination(definition, 'work', issueGroup)).toBe(true); + }); + it.each([0, -1, 1.5, undefined, Number.MAX_SAFE_INTEGER + 1])('retains the conservative fence for invalid native issue %s', issueNumber => { + expect(expectedNativeWorkflowGroup({ ...issueQuery, nativeCoordination: { ...issueQuery.nativeCoordination, issueNumber } })).toBeUndefined(); + }); + it.each([ + { ...definition, concurrency: { ...definition.concurrency, queue: 'single' } }, + { ...definition, concurrency: { ...definition.concurrency, 'cancel-in-progress': true } }, + { ...definition, concurrency: { ...definition.concurrency, group: 'global' } }, + { ...definition, jobs: { work: { uses: 'org/repo/callee.yml@v3' } } }, + { ...definition, jobs: { work: { steps: [], concurrency: { group: 'nested' } } } }, + { ...definition, jobs: {} }, + ])('never substitutes a canceling, replacing, reusable or unrelated contract %j', workflow => { + expect(hasNativeWorkflowCoordination(workflow, 'work', issueGroup)).toBe(false); + }); +}); diff --git a/src/application/policies/__tests__/setup_runner_policy.test.ts b/src/application/policies/__tests__/setup_runner_policy.test.ts index 9e0309297..5086245c0 100644 --- a/src/application/policies/__tests__/setup_runner_policy.test.ts +++ b/src/application/policies/__tests__/setup_runner_policy.test.ts @@ -158,3 +158,9 @@ test.each([manual, { ...manual, validation: 'agent' as const }, selected, select expect(selectedSetupRunsOn(config, 'credential-health')).toBe('ubuntu-latest'); expect(selectedSetupRunsOn(DEFAULT_SETUP_RUNNERS, 'credential-health')).toBeUndefined(); }); + + +test('keeps issue inspection on its fixed hosted route even with an organization self-hosted profile', () => { + expect(setupRunnerRole('copilot_issue.yml', 'issue-admission')).toBe('isolated'); + expect(selectedSetupRunsOn(manual, setupRunnerRole('copilot_issue.yml', 'issue-admission'))).toBeUndefined(); +}); diff --git a/src/application/policies/issue_event_admission_policy.ts b/src/application/policies/issue_event_admission_policy.ts new file mode 100644 index 000000000..58297d692 --- /dev/null +++ b/src/application/policies/issue_event_admission_policy.ts @@ -0,0 +1,50 @@ +import { ISSUE_START_LABEL } from '../../domain/issue_start_policy'; +import { githubUsersMatch } from '../../domain/github_user_policy'; +import type { IssueAdmissionSnapshot, IssueEventAdmissionContext, IssueEventAdmissionDecision } from '../ports/issue_event_admission_ports'; + +export function isIssueReconciliationEvent(context: IssueEventAdmissionContext): boolean { + return ['labeled', 'unlabeled'].includes(context.action) && context.changedLabel.length > 0 + && ![ISSUE_START_LABEL, context.deployLabel].map(label => label.toLowerCase()).includes(context.changedLabel.toLowerCase()); +} + +/** Initial routing labels have no event-specific effects in an unstarted issue. */ +export function coalescibleIssueReconciliation(context: IssueEventAdmissionContext, issue: IssueAdmissionSnapshot): boolean { + return isIssueReconciliationEvent(context) && issue.state === 'open' + && !issue.labels.some(label => [ISSUE_START_LABEL, context.releaseLabel, context.hotfixLabel].map(name => name.toLowerCase()).includes(label.toLowerCase())); +} + +export function discardIssueSelfEvent(context: IssueEventAdmissionContext, tokenUser: string): boolean { + // Native closure cleanup deliberately remains runnable for the product account. + return context.action !== 'closed' && githubUsersMatch(context.actor, tokenUser); +} + +/** Every input that can change the meaning of a backlog reconciliation invalidates its receipt. */ +export function issueReconciliationIdentity(context: IssueEventAdmissionContext, issue: IssueAdmissionSnapshot, productAccount: string): string { + return JSON.stringify({ + schema: 1, productAccount: productAccount.toLowerCase(), repository: `${context.owner}/${context.repository}`, actor: context.actor.toLowerCase(), + workflow: context.workflowRevision, runtime: context.runtimeRevision, configuration: context.configuration, + deployLabel: context.deployLabel, releaseLabel: context.releaseLabel, hotfixLabel: context.hotfixLabel, + issue: { ...issue, labels: [...issue.labels].sort(), assignees: [...issue.assignees].sort() }, + }); +} + +export function issueAdmissionReceiptName(issueNumber: number, key: string): string { + return `copilot-issue-reconciliation-v1-${issueNumber}-${key}`; +} + + +export function issueEventAdmissionSummary(decision: IssueEventAdmissionDecision, locale: string): string { + const spanish = /^es(?:-|$)/iu.test(locale); + const states = spanish ? { + 'self-event': 'Evento interno omitido: lo generó la cuenta del producto. No se asignó el runner de trabajo.', + 'lifecycle-event': 'Evento admitido. El flujo normal verificará permisos y reconciliará la issue.', + 'reconciliation-needed': 'Reconciliación admitida: no se ha verificado una ejecución correcta que cubra este estado.', + 'already-reconciled': 'Estado ya reconciliado por una ejecución correcta del mismo flujo. Se omitió el runner de trabajo.', + } : { + 'self-event': 'Internal event skipped: the product account generated it. No work runner was allocated.', + 'lifecycle-event': 'Event admitted. The normal pipeline will verify permissions and reconcile the issue.', + 'reconciliation-needed': 'Reconciliation admitted: no successful execution has been verified for this state.', + 'already-reconciled': 'State already reconciled by a successful execution of this workflow. The work runner was skipped.', + }; + return `## ${spanish ? 'Admisión de la issue' : 'Issue event admission'}\n\n${states[decision.reason]}\n\n${spanish ? 'No se requiere intervención. Los fallos y efectos parciales conservan el flujo normal y no generan un recibo reutilizable.' : 'No action is required. Failed or partial work retains the normal pipeline and cannot produce a reusable receipt.'}\n`; +} diff --git a/src/application/policies/native_workflow_coordination_policy.ts b/src/application/policies/native_workflow_coordination_policy.ts index 338c09c51..8f7091b03 100644 --- a/src/application/policies/native_workflow_coordination_policy.ts +++ b/src/application/policies/native_workflow_coordination_policy.ts @@ -1,5 +1,6 @@ import type { PreviousWorkflowRunsQuery } from '../ports/workflow_run_ports'; +const ISSUE_GROUP = 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}'; const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; @@ -15,6 +16,8 @@ export function expectedNativeWorkflowGroup(query: PreviousWorkflowRunsQuery): s if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') && native.ref.length > 'refs/heads/'.length) return PUSH_GROUP; + if (native.eventName === 'issues') return typeof native.issueNumber === 'number' + && Number.isSafeInteger(native.issueNumber) && native.issueNumber > 0 ? ISSUE_GROUP : undefined; const pullRequestNumber = native.pullRequestNumber; if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) return undefined; if (native.eventName === 'pull_request') return `${PR_GROUP}-analysis`; @@ -27,6 +30,11 @@ export function hasNativeWorkflowCoordination(workflow: unknown, jobId: string, const jobs = record(definition?.jobs); const job = record(jobs?.[jobId]); const concurrency = record(job?.concurrency); + if (expectedGroup === ISSUE_GROUP) { + const outer = record(definition?.concurrency); + return Array.isArray(job?.steps) && job?.uses === undefined && job?.concurrency === undefined + && outer?.group === ISSUE_GROUP && outer?.['cancel-in-progress'] === false && outer?.queue === 'max'; + } // Custom/reusable/workflow-level contracts retain conservative polling. // A single direct job also avoids confusing a reusable callee's job ID // with a different protected job in its caller's workflow context. diff --git a/src/application/policies/setup_runner_policy.ts b/src/application/policies/setup_runner_policy.ts index 9c753ebc9..60e345a85 100644 --- a/src/application/policies/setup_runner_policy.ts +++ b/src/application/policies/setup_runner_policy.ts @@ -105,6 +105,7 @@ export function setupRunnerRole(file: string, job: string): SetupRunnerRole | un 'copilot_pull_request.yml': 'copilot-pull-requests', 'copilot_pull_request_comment.yml': 'copilot-pull-requests', 'copilot_commit.yml': 'copilot-commits', 'agent-cli-provisioning.yml': 'verify-agent-clis', }; + if (file === 'copilot_issue.yml' && job === 'issue-admission') return 'isolated'; if (agentJobs[file] === job) return 'agent'; if (file === 'copilot_pull_request_approval.yml' && job === 'observe-approval') return 'observer'; if (file === 'copilot_pull_request_merge_queue.yml' && job === 'copilot-pull-request-required-check') return 'isolated'; diff --git a/src/application/ports/issue_event_admission_ports.ts b/src/application/ports/issue_event_admission_ports.ts new file mode 100644 index 000000000..38f540874 --- /dev/null +++ b/src/application/ports/issue_event_admission_ports.ts @@ -0,0 +1,45 @@ +/** Read-only native issue admission; receipts are local files, never issue mutations. */ +export interface IssueEventAdmissionContext { + readonly owner: string; + readonly repository: string; + readonly eventName: string; + readonly action: string; + readonly actor: string; + readonly issueNumber: number; + readonly runId: number; + readonly workflowRevision: string; + readonly runtimeRevision: string; + readonly configuration: string; + readonly deployLabel: string; + readonly releaseLabel: string; + readonly hotfixLabel: string; + readonly changedLabel: string; +} + +export interface IssueAdmissionSnapshot { + readonly id: number; + readonly number: number; + readonly title: string; + readonly body: string; + readonly state: 'open' | 'closed'; + readonly updatedAt: string; + readonly author: string; + readonly labels: readonly string[]; + readonly assignees: readonly string[]; + readonly milestone: number | null; + readonly issueType: string | null; + readonly locked: boolean; +} + +export interface IssueEventAdmissionPort { + authenticatedLogin(): Promise; + readIssue(issueNumber: number): Promise; + /** Missing/unproven receipts mean execute; errors never grant a skip. */ + hasSuccessfulReceipt(name: string, context: IssueEventAdmissionContext): Promise; +} + +export interface IssueEventAdmissionDecision { + readonly execute: boolean; + readonly reason: 'self-event' | 'lifecycle-event' | 'reconciliation-needed' | 'already-reconciled'; + readonly receiptKey?: string; +} diff --git a/src/application/ports/workflow_run_ports.ts b/src/application/ports/workflow_run_ports.ts index 35f8f1f58..6e678b0fc 100644 --- a/src/application/ports/workflow_run_ports.ts +++ b/src/application/ports/workflow_run_ports.ts @@ -15,6 +15,7 @@ export interface NativeWorkflowCoordinationContext { readonly eventName: string; readonly ref?: string; readonly pullRequestNumber?: number; + readonly issueNumber?: number; } export interface NativeWorkflowCoordinationQueryPort { diff --git a/src/application/usecases/__tests__/issue_use_case.test.ts b/src/application/usecases/__tests__/issue_use_case.test.ts index 41d66b2ef..ad3255a33 100644 --- a/src/application/usecases/__tests__/issue_use_case.test.ts +++ b/src/application/usecases/__tests__/issue_use_case.test.ts @@ -785,4 +785,20 @@ describe("IssueUseCase", () => { expect(result.success).toBe(false);expect(result.errors[0]).toMatchObject({code:'configuration.invalid',recovery:undefined});expect(mockPrepareBranchesInvoke).not.toHaveBeenCalled(); }); + it.each(['bug', 'bugfix', 'priority: high'].flatMap(label => ['labeled', 'unlabeled'].map(action => [label, action])))( + 'reconciles the same backlog state without event-specific branch/model effects for %s/%s', async (label, action) => { + const results = await createUseCase().invoke(minimalExecution({ + actor: 'maintainer', eventName: 'issues', inputs: { action }, + issueStartDecision: { started: false, branchRequired: true, sddRequired: false, helpRequired: false }, + issue: { labeled: action === 'labeled', labelAdded: label, opened: false }, labels: { isBug: true, isBugfix: true }, + })); + expect(results.every(result => result.success)).toBe(true); + expect(mockAssignMemberInvoke).toHaveBeenCalledTimes(1); expect(mockUpdateIssueTypeInvoke).toHaveBeenCalledTimes(1); + expect(mockLinkIssueProjectInvoke).toHaveBeenCalledTimes(1); expect(mockCheckPriorityInvoke).toHaveBeenCalledTimes(1); + expect(mockPrepareBranchesInvoke).not.toHaveBeenCalled(); expect(mockRemoveIssueBranchesInvoke).not.toHaveBeenCalled(); + expect(mockRemoveNotNeededInvoke).not.toHaveBeenCalled(); expect(mockDeployAddedInvoke).not.toHaveBeenCalled(); + expect(mockRecommendStepsInvoke).not.toHaveBeenCalled(); expect(mockAnswerIssueHelpInvoke).not.toHaveBeenCalled(); + expect(mockListIssueComments).not.toHaveBeenCalled(); + }); + }); diff --git a/src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts b/src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts new file mode 100644 index 000000000..ba7f4362c --- /dev/null +++ b/src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts @@ -0,0 +1,146 @@ +import { createHash } from 'node:crypto'; +import { inspectIssueEvent, recordIssueEvent } from '../issue_event_admission_workflow'; +import { coalescibleIssueReconciliation, issueReconciliationIdentity, issueAdmissionReceiptName, issueEventAdmissionSummary } from '../../../policies/issue_event_admission_policy'; +import type { IssueAdmissionSnapshot, IssueEventAdmissionContext } from '../../../ports/issue_event_admission_ports'; + +const context: IssueEventAdmissionContext = { owner: 'org', repository: 'repo', eventName: 'issues', action: 'labeled', actor: 'maintainer', + issueNumber: 21, runId: 200, workflowRevision: 'a'.repeat(40), runtimeRevision: 'b'.repeat(64), configuration: '{}', deployLabel: 'ship', releaseLabel: 'release', hotfixLabel: 'hotfix', changedLabel: 'bug' }; +const issue: IssueAdmissionSnapshot = { id: 99, number: 21, title: 'A bug', body: 'Native form', state: 'open', updatedAt: '2026-10-28T00:00:00Z', + author: 'author', labels: ['bug', 'bugfix'], assignees: ['maintainer'], milestone: null, issueType: 'Bug', locked: false }; +const digest = (value: string) => createHash('sha256').update(value).digest('hex'); +function fixture() { return { authenticatedLogin: jest.fn().mockResolvedValue('product'), readIssue: jest.fn().mockResolvedValue(issue), hasSuccessfulReceipt: jest.fn().mockResolvedValue(false) }; } + +describe('native issue event admission', () => { + it('filters real product-account metadata even without a configured bot variable', async () => { + const p = fixture(); p.authenticatedLogin.mockResolvedValue('MAINTAINER'); + expect(await inspectIssueEvent(context, p, digest)).toEqual({ execute: false, reason: 'self-event' }); + expect(p.readIssue).not.toHaveBeenCalled(); expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); + }); + it('preserves bot-authored native closure cleanup', async () => { + const p = fixture(); p.authenticatedLogin.mockResolvedValue('maintainer'); + expect(await inspectIssueEvent({ ...context, action: 'closed' }, p, digest)).toEqual({ execute: true, reason: 'lifecycle-event' }); + }); + it.each(['opened', 'reopened', 'closed', 'edited', 'assigned', 'unassigned'])('never coalesces substantive %s intent', async action => { + const p = fixture(); expect((await inspectIssueEvent({ ...context, action }, p, digest)).execute).toBe(true); + expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); + }); + it.each([ + ['start addition', { changedLabel: 'in-progress' }, {}], ['start removal', { action: 'unlabeled', changedLabel: 'in-progress' }, {}], + ['custom deployment', { changedLabel: 'ship' }, {}], ['empty changed label', { changedLabel: '' }, {}], + ['already started', {}, { labels: ['bug', 'in-progress'] }], ['release', {}, { labels: ['release'] }], + ['hotfix', {}, { labels: ['hotfix'] }], ['closed issue', {}, { state: 'closed' }], + ])('retains the full pipeline for %s', async (_name, patch, native) => { + const p = fixture(); p.readIssue.mockResolvedValue({ ...issue, ...native }); + expect((await inspectIssueEvent({ ...context, ...patch }, p, digest)).execute).toBe(true); + expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); + }); + it('emits an opaque candidate and reuses only proven successful evidence', async () => { + const p = fixture(); const first = await inspectIssueEvent(context, p, digest); + expect(first).toMatchObject({ execute: true, reason: 'reconciliation-needed', receiptKey: expect.stringMatching(/^[a-f0-9]{64}$/) }); + expect(p.hasSuccessfulReceipt).toHaveBeenCalledWith(issueAdmissionReceiptName(21, first.receiptKey!), context); + p.hasSuccessfulReceipt.mockResolvedValue(true); + expect(await inspectIssueEvent(context, p, digest)).toEqual({ ...first, execute: false, reason: 'already-reconciled' }); + }); + it('falls back to the guarded full pipeline when optional evidence is unavailable', async () => { + const p = fixture(); p.hasSuccessfulReceipt.mockRejectedValue(new Error('PRIVATE_PROVIDER_RESPONSE')); + expect(await inspectIssueEvent(context, p, digest)).toMatchObject({ execute: true, reason: 'reconciliation-needed' }); + }); + it('does not trust a missing product identity', async () => { + const p = fixture(); p.authenticatedLogin.mockResolvedValue(' '); + await expect(inspectIssueEvent(context, p, digest)).rejects.toMatchObject({ code: 'authorization.credential-invalid' }); + }); + it.each([{ eventName: 'push' }, { action: 'unknown' }, { owner: '../org' }, { repository: '' }, { actor: '' }, + { issueNumber: 0 }, { issueNumber: 1.1 }, { runId: 0 }, { workflowRevision: 'main' }, { runtimeRevision: '' }])( + 'blocks malformed native admission identity %j', async patch => { + await expect(inspectIssueEvent({ ...context, ...patch }, fixture(), digest)).rejects.toMatchObject({ code: 'configuration.invalid' }); + }); + it('accepts native bot login syntax for a distinct service actor and the exact configuration limit', async () => { + expect((await inspectIssueEvent({ ...context, actor: 'github-actions[bot]', configuration: 'é'.repeat(524288) }, fixture(), digest)).execute).toBe(true); + }); + it.each([ + { owner: 'elsewhere' }, { repository: 'other' }, { actor: 'other' }, { workflowRevision: 'c'.repeat(40) }, + { runtimeRevision: 'c'.repeat(64) }, { configuration: '{"mode":"new"}' }, { deployLabel: 'deploy' }, { releaseLabel: 'ship-release' }, { hotfixLabel: 'urgent' }, + ])('invalidates evidence when execution meaning changes %j', patch => { + expect(issueReconciliationIdentity({ ...context, ...patch }, issue, 'product')).not.toBe(issueReconciliationIdentity(context, issue, 'product')); + }); + it.each([ + { id: 100 }, { title: 'Edited' }, { body: 'Edited body' }, { updatedAt: '2026-10-28T00:00:01Z' }, { author: 'other' }, + { labels: ['chore'] }, { assignees: ['other'] }, { milestone: 9 }, { issueType: 'Maintenance' }, { locked: true }, + ])('invalidates evidence for changed native state %j', patch => { + expect(issueReconciliationIdentity(context, { ...issue, ...patch }, 'product')).not.toBe(issueReconciliationIdentity(context, issue, 'product')); + }); + it('canonicalizes set ordering and login case, while binding the product account', () => { + const original = issueReconciliationIdentity(context, { ...issue, assignees: ['a', 'b'] }, 'Product'); + expect(issueReconciliationIdentity({ ...context, actor: 'MAINTAINER' }, { ...issue, labels: ['bugfix', 'bug'], assignees: ['b', 'a'] }, 'product')).toBe(original); + expect(issueReconciliationIdentity(context, { ...issue, assignees: ['a', 'b'] }, 'other-product')).not.toBe(original); + }); + it('records only an unchanged candidate after a successful guarded execution', async () => { + const p = fixture(); const before = (await inspectIssueEvent(context, p, digest)).receiptKey!; + expect(await recordIssueEvent(context, before, p, digest)).toBe(issueAdmissionReceiptName(21, before)); + p.readIssue.mockResolvedValue({ ...issue, updatedAt: '2026-10-28T00:00:01Z' }); + expect(await recordIssueEvent(context, before, p, digest)).toBeUndefined(); + }); + it.each(['', 'PRIVATE', 'a'.repeat(63), 'A'.repeat(64)])('never records a malformed or injected key %s', async key => { + const p = fixture(); expect(await recordIssueEvent(context, key, p, digest)).toBeUndefined(); expect(p.readIssue).not.toHaveBeenCalled(); + }); + it('does not record an in-progress transition, a changed product account, or a self event', async () => { + const p = fixture(); const before = (await inspectIssueEvent(context, p, digest)).receiptKey!; + p.readIssue.mockResolvedValue({ ...issue, labels: ['in-progress'] }); + expect(await recordIssueEvent(context, before, p, digest)).toBeUndefined(); + p.readIssue.mockResolvedValue(issue); p.authenticatedLogin.mockResolvedValue('other-product'); + expect(await recordIssueEvent(context, before, p, digest)).toBeUndefined(); + p.authenticatedLogin.mockResolvedValue('maintainer'); expect(await recordIssueEvent(context, before, p, digest)).toBeUndefined(); + p.authenticatedLogin.mockResolvedValue(''); expect(await recordIssueEvent(context, before, p, digest)).toBeUndefined(); + }); + it('reduces stable initial routing duplicates, retaining opened/start/reopen and a distinct issue', async () => { + const p = fixture(); const receipts = new Set(); let heavy = 0; + p.hasSuccessfulReceipt.mockImplementation(async name => receipts.has(name)); + for (const event of [ + { action: 'opened' }, { action: 'labeled', changedLabel: 'bug' }, { action: 'labeled', changedLabel: 'bugfix' }, + { action: 'labeled', changedLabel: 'priority: high' }, { action: 'labeled', changedLabel: 'in-progress' }, { action: 'reopened' }, + { action: 'labeled', issueNumber: 22 }, + ]) { + const next = { ...context, ...event }; p.readIssue.mockResolvedValue({ ...issue, number: next.issueNumber }); + const decision = await inspectIssueEvent(next, p, digest); + if (decision.execute) { heavy++; if (decision.receiptKey) { const receipt = await recordIssueEvent(next, decision.receiptKey, p, digest); if (receipt) receipts.add(receipt); } } + } + expect(heavy).toBe(5); // Seven full allocations before; no substantive event was replaced. + expect(coalescibleIssueReconciliation({ ...context, action: 'unlabeled' }, issue)).toBe(true); + }); +}); + + +it.each(['self-event', 'lifecycle-event', 'reconciliation-needed', 'already-reconciled'] as const)('renders bounded English and regional Spanish operator state %s', reason => { + const decision = { execute: reason === 'lifecycle-event' || reason === 'reconciliation-needed', reason }; + expect(issueEventAdmissionSummary(decision, 'en-US')).toContain('No action is required'); + expect(issueEventAdmissionSummary(decision, 'es-MX')).toContain('No se requiere intervención'); + expect(issueEventAdmissionSummary(decision, 'fr-FR')).toBe(issueEventAdmissionSummary(decision, 'en-US')); +}); + + +it('bounds the Variables snapshot without logging its content', async () => { + await expect(inspectIssueEvent({ ...context, configuration: 'é'.repeat(524289) }, fixture(), digest)).rejects.toMatchObject({ code: 'configuration.invalid' }); +}); +it('retains full authoritative runtime checks when the optional native state projection is unavailable', async () => { + const p = fixture(); p.readIssue.mockRejectedValue(new Error('PRIVATE_BODY')); + expect(await inspectIssueEvent(context, p, digest)).toEqual({ execute: true, reason: 'reconciliation-needed' }); + expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); +}); + + +it.each(['release', 'hotfix'])('preserves configured %s label aliases instead of treating them as ordinary backlog metadata', async kind => { + const p = fixture(); p.readIssue.mockResolvedValue({ ...issue, labels: ['custom-kind'] }); + const configured = { ...context, [kind + 'Label']: 'custom-kind' }; + expect(await inspectIssueEvent(configured, p, digest)).toEqual({ execute: true, reason: 'lifecycle-event' }); + expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); +}); + + +it.each([ + [{ changedLabel: 'IN-PROGRESS' }, {}], [{ changedLabel: 'SHIP' }, {}], [{}, { labels: ['IN-PROGRESS'] }], + [{ releaseLabel: 'Ship-Release' }, { labels: ['SHIP-RELEASE'] }], [{ hotfixLabel: 'Emergency' }, { labels: ['EMERGENCY'] }], +])('never coalesces reserved label case variants %j', async (configured, state) => { + const p = fixture(); p.readIssue.mockResolvedValue({ ...issue, ...state }); + expect((await inspectIssueEvent({ ...context, ...configured }, p, digest)).execute).toBe(true); + expect(p.hasSuccessfulReceipt).not.toHaveBeenCalled(); +}); diff --git a/src/application/usecases/execution/issue_event_admission_workflow.ts b/src/application/usecases/execution/issue_event_admission_workflow.ts new file mode 100644 index 000000000..2ec0c92c6 --- /dev/null +++ b/src/application/usecases/execution/issue_event_admission_workflow.ts @@ -0,0 +1,47 @@ +import { ApplicationError } from '../../errors/application_error'; +import { coalescibleIssueReconciliation, isIssueReconciliationEvent, discardIssueSelfEvent, issueAdmissionReceiptName, issueReconciliationIdentity } from '../../policies/issue_event_admission_policy'; +import type { IssueEventAdmissionContext, IssueEventAdmissionDecision, IssueEventAdmissionPort } from '../../ports/issue_event_admission_ports'; + +export async function inspectIssueEvent( + context: IssueEventAdmissionContext, port: IssueEventAdmissionPort, digest: (value: string) => string, +): Promise { + validateContext(context); + const login = await port.authenticatedLogin(); + if (!login.trim()) throw new ApplicationError('authorization.credential-invalid', 'Issue event admission could not verify the product account.'); + if (discardIssueSelfEvent(context, login)) return { execute: false, reason: 'self-event' }; + if (!isIssueReconciliationEvent(context)) return { execute: true, reason: 'lifecycle-event' }; + let issue; + try { issue = await port.readIssue(context.issueNumber); } + catch { return { execute: true, reason: 'reconciliation-needed' }; } + if (!coalescibleIssueReconciliation(context, issue)) return { execute: true, reason: 'lifecycle-event' }; + const receiptKey = digest(issueReconciliationIdentity(context, issue, login)); + let completed = false; + try { completed = await port.hasSuccessfulReceipt(issueAdmissionReceiptName(context.issueNumber, receiptKey), context); } + catch { /* Optimization unavailable: retain the full guarded pipeline. */ } + return { execute: !completed, reason: completed ? 'already-reconciled' : 'reconciliation-needed', receiptKey }; +} + +export async function recordIssueEvent( + context: IssueEventAdmissionContext, expectedKey: string, port: IssueEventAdmissionPort, digest: (value: string) => string, +): Promise { + validateContext(context); + if (!/^[a-f0-9]{64}$/u.test(expectedKey)) return undefined; + const login = await port.authenticatedLogin(); + if (!login.trim() || discardIssueSelfEvent(context, login)) return undefined; + const issue = await port.readIssue(context.issueNumber); + if (!coalescibleIssueReconciliation(context, issue)) return undefined; + const currentKey = digest(issueReconciliationIdentity(context, issue, login)); + return currentKey === expectedKey ? issueAdmissionReceiptName(context.issueNumber, currentKey) : undefined; +} + +function validateContext(context: IssueEventAdmissionContext): void { + if (context.eventName !== 'issues' || !['opened', 'reopened', 'closed', 'edited', 'labeled', 'unlabeled', 'assigned', 'unassigned'].includes(context.action) + || !/^[\w.-]+$/u.test(context.owner) || !/^[\w.-]+$/u.test(context.repository) + || !/^[a-z0-9][a-z0-9-]*(?:\[bot\])?$/iu.test(context.actor) + || !Number.isSafeInteger(context.issueNumber) || context.issueNumber <= 0 + || !Number.isSafeInteger(context.runId) || context.runId <= 0 + || !/^[a-f0-9]{40}$/u.test(context.workflowRevision) || !/^[a-f0-9]{64}$/u.test(context.runtimeRevision) + || Buffer.byteLength(context.configuration, 'utf8') > 1048576) { + throw new ApplicationError('configuration.invalid', 'Issue event admission requires a valid native issue, workflow revision, and bounded configuration.'); + } +} diff --git a/src/architecture/github_communication_test_budget.json b/src/architecture/github_communication_test_budget.json index a77c91d12..4901eb569 100644 --- a/src/architecture/github_communication_test_budget.json +++ b/src/architecture/github_communication_test_budget.json @@ -7,7 +7,7 @@ "id": "semantic-github-publication", "spec": "specs/semantic-github-publication-and-notification.md", "requiredCases": 135, - "allocatedCases": 577, + "allocatedCases": 880, "files": [ { "path": "src/domain/__tests__/github_publication.test.ts", @@ -31,7 +31,7 @@ }, { "path": "src/application/policies/__tests__/semantic_result_publication_policy.test.ts", - "qualifyingCases": 33 + "qualifyingCases": 37 }, { "path": "src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts", @@ -59,11 +59,11 @@ }, { "path": "src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts", - "qualifyingCases": 16 + "qualifyingCases": 28 }, { "path": "src/application/usecases/actions/__tests__/check_progress_use_case.test.ts", - "qualifyingCases": 7 + "qualifyingCases": 11 }, { "path": "src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts", @@ -71,7 +71,7 @@ }, { "path": "src/application/usecases/__tests__/issue_use_case.test.ts", - "qualifyingCases": 8 + "qualifyingCases": 30 }, { "path": "src/architecture/__tests__/github_publication_boundaries.test.ts", @@ -79,7 +79,7 @@ }, { "path": "src/application/policies/__tests__/action_summary_policy.test.ts", - "qualifyingCases": 4 + "qualifyingCases": 15 }, { "path": "src/domain/__tests__/copilot_operation.test.ts", @@ -91,7 +91,7 @@ }, { "path": "src/application/policies/__tests__/copilot_history_presentation_policy.test.ts", - "qualifyingCases": 13 + "qualifyingCases": 16 }, { "path": "src/application/policies/__tests__/copilot_operation_publication.test.ts", @@ -99,7 +99,7 @@ }, { "path": "src/application/usecases/__tests__/copilot_history_route.test.ts", - "qualifyingCases": 5 + "qualifyingCases": 8 }, { "path": "src/application/usecases/__tests__/copilot_history_workflow.test.ts", @@ -111,7 +111,7 @@ }, { "path": "src/data/repository/__tests__/progress_diff_repository.test.ts", - "qualifyingCases": 12 + "qualifyingCases": 16 }, { "path": "src/application/policies/__tests__/retired_progress_presentation_policy.test.ts", @@ -119,7 +119,7 @@ }, { "path": "src/application/usecases/actions/__tests__/retire_progress_use_case.test.ts", - "qualifyingCases": 22 + "qualifyingCases": 29 }, { "path": "src/application/usecases/actions/__tests__/retire_closed_issue_cards_use_case.test.ts", @@ -139,11 +139,11 @@ }, { "path": "src/application/policies/__tests__/progress_partition_response_policy.test.ts", - "qualifyingCases": 15 + "qualifyingCases": 23 }, { "path": "src/application/usecases/actions/__tests__/progress_multipart.integration.test.ts", - "qualifyingCases": 12 + "qualifyingCases": 22 }, { "path": "src/application/policies/__tests__/progress_scope_policy.test.ts", @@ -155,7 +155,7 @@ }, { "path": "src/prompts/__tests__/check_progress.test.ts", - "qualifyingCases": 2 + "qualifyingCases": 5 }, { "path": "src/prompts/__tests__/progress_scope.test.ts", @@ -167,11 +167,11 @@ }, { "path": "src/actions/__tests__/github_action_completion.test.ts", - "qualifyingCases": 14 + "qualifyingCases": 32 }, { "path": "src/domain/__tests__/issue_workflow_diagnostic.test.ts", - "qualifyingCases": 10 + "qualifyingCases": 11 }, { "path": "src/application/policies/__tests__/progress_patch_completeness_policy.test.ts", @@ -187,11 +187,11 @@ }, { "path": "src/data/repository/__tests__/progress_diff_recovery.test.ts", - "qualifyingCases": 11 + "qualifyingCases": 13 }, { "path": "src/infrastructure/composition/__tests__/progress_recovery_contract.test.ts", - "qualifyingCases": 2 + "qualifyingCases": 3 }, { "path": "src/application/policies/__tests__/progress_comparison_source_policy.test.ts", @@ -203,7 +203,7 @@ }, { "path": "src/application/usecases/actions/__tests__/progress_comparison_source.integration.test.ts", - "qualifyingCases": 9 + "qualifyingCases": 11 }, { "path": "src/domain/__tests__/progress_binary_evidence_diagnostic.test.ts", @@ -231,10 +231,42 @@ }, { "path": "src/tooling/__tests__/validate_workflow_contract.test.ts", - "qualifyingCases": 3 + "qualifyingCases": 85 }, { "path": "src/architecture/__tests__/raw_error_logging_boundaries.test.ts", + "qualifyingCases": 2 + }, + { + "path": "src/application/usecases/execution/__tests__/issue_event_admission_workflow.test.ts", + "qualifyingCases": 21 + }, + { + "path": "src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts", + "qualifyingCases": 10 + }, + { + "path": "src/actions/__tests__/issue_event_admission_action.test.ts", + "qualifyingCases": 12 + }, + { + "path": "src/infrastructure/composition/__tests__/issue_event_admission_composition_root.test.ts", + "qualifyingCases": 1 + }, + { + "path": "src/actions/__tests__/github_action.test.ts", + "qualifyingCases": 23 + }, + { + "path": "src/application/policies/__tests__/native_workflow_coordination_policy.test.ts", + "qualifyingCases": 7 + }, + { + "path": "src/application/policies/__tests__/setup_runner_policy.test.ts", + "qualifyingCases": 30 + }, + { + "path": "src/manager/description/base/__tests__/issue_content_interface.test.ts", "qualifyingCases": 1 } ] @@ -243,7 +275,7 @@ "id": "repository-locale-localization", "spec": "specs/repository-locale-and-localization.md", "requiredCases": 146, - "allocatedCases": 153, + "allocatedCases": 183, "files": [ { "path": "src/actions/__tests__/github_action_locale_inputs.test.ts", @@ -287,7 +319,7 @@ }, { "path": "src/application/policies/__tests__/resolved_message_catalog_policy.test.ts", - "qualifyingCases": 2 + "qualifyingCases": 6 }, { "path": "src/application/policies/__tests__/setup_doctor_message_catalog.test.ts", @@ -307,7 +339,7 @@ }, { "path": "src/application/usecases/setup/__tests__/doctor_use_case.test.ts", - "qualifyingCases": 4 + "qualifyingCases": 11 }, { "path": "src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts", @@ -323,7 +355,7 @@ }, { "path": "src/cli/__tests__/setup_prompt_rendering.test.ts", - "qualifyingCases": 2 + "qualifyingCases": 4 }, { "path": "src/domain/__tests__/locale.test.ts", @@ -335,7 +367,7 @@ }, { "path": "src/application/usecases/__tests__/comment_automation_use_case.test.ts", - "qualifyingCases": 3 + "qualifyingCases": 20 } ] } diff --git a/src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts b/src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts new file mode 100644 index 000000000..2f8d89f8d --- /dev/null +++ b/src/data/repository/workflow/__tests__/issue_event_admission_repository.test.ts @@ -0,0 +1,89 @@ +import { IssueEventAdmissionRepository } from '../issue_event_admission_repository'; +import type { IssueEventAdmissionContext } from '../../../../application/ports/issue_event_admission_ports'; + +const native = { id: 99, number: 21, title: 'Native', body: null, state: 'open', updated_at: '2026-10-28T00:00:00Z', + user: { login: 'author' }, labels: [{ name: 'bug' }], assignees: [{ login: 'member' }], milestone: null, type: { name: 'Bug' }, + locked: false, repository_url: 'https://api.github.com/repos/org/repo' }; +const context = { actor: 'maintainer', runId: 200, workflowRevision: 'a'.repeat(40) } as IssueEventAdmissionContext; +const run = { id: 200, actor: { login: 'maintainer' }, run_attempt: 1, created_at: '2026-10-28T00:01:00Z', workflow_id: 9, path: '.github/workflows/copilot_issue.yml', head_sha: 'a'.repeat(40), event: 'issues' }; +const artifact = { name: 'receipt', created_at: '2026-10-28T00:02:00Z', expired: false, size_in_bytes: 256, workflow_run: { id: 199 } }; +const source = { ...run, id: 199, completed_at: '2026-10-28T00:02:00Z', actor: { login: 'maintainer' }, status: 'completed', conclusion: 'success' }; +function fixture() { + const product = { rest: { users: { getAuthenticated: jest.fn().mockResolvedValue({ data: { login: 'product' } }) }, + issues: { get: jest.fn().mockResolvedValue({ data: native }) }, actions: { getWorkflowRun: jest.fn(), listArtifactsForRepo: jest.fn() } } }; + const evidence = { ...product, rest: { ...product.rest, actions: { getWorkflowRun: jest.fn().mockImplementation(async ({ run_id }) => ({ data: run_id === 200 ? run : source })), + listArtifactsForRepo: jest.fn().mockResolvedValue({ data: { artifacts: [artifact] } }) } } }; + return { product, evidence, repository: new IssueEventAdmissionRepository(product, evidence, 'org', 'repo') }; +} + +describe('repository-bound issue admission queries', () => { + it('uses the product credential for identity and native issue state, returning immutable selected facts', async () => { + const f = fixture(); expect(await f.repository.authenticatedLogin()).toBe('product'); + const value = await f.repository.readIssue(21); expect(value).toMatchObject({ id: 99, body: '', labels: ['bug'], assignees: ['member'], issueType: 'Bug' }); + expect(Object.isFrozen(value)).toBe(true); expect(Object.isFrozen(value.labels)).toBe(true); + expect(f.product.rest.issues.get).toHaveBeenCalledWith({ owner: 'org', repo: 'repo', issue_number: 21 }); + }); + it.each([{}, null, { login: '' }, { login: 8 }])('sanitizes malformed product identity %j', async data => { + const f = fixture(); f.product.rest.users.getAuthenticated.mockResolvedValue({ data }); + await expect(f.repository.authenticatedLogin()).rejects.toMatchObject({ code: 'authorization.credential-invalid' }); + }); + it('removes private authentication and native-read diagnostics', async () => { + const f = fixture(); f.product.rest.users.getAuthenticated.mockRejectedValue(new Error('PRIVATE_TOKEN')); + f.product.rest.issues.get.mockRejectedValue(new Error('PRIVATE_BODY')); + await expect(f.repository.authenticatedLogin()).rejects.not.toThrow('PRIVATE_TOKEN'); + await expect(f.repository.readIssue(21)).rejects.not.toThrow('PRIVATE_BODY'); + }); + it.each([null, [], { ...native, id: -1 }, { ...native, number: 22 }, { ...native, pull_request: {} }, + { ...native, body: 9 }, { ...native, title: null }, { ...native, state: 'unknown' }, { ...native, updated_at: 'bad' }, + { ...native, updated_at: null }, { ...native, user: {} }, { ...native, user: { login: ' ' } }, { ...native, updated_at: '1' }, { ...native, updated_at: '2026-13-28T00:00:00Z' }, { ...native, updated_at: '2026-02-30T00:00:00Z' }, { ...native, locked: 'false' }, { ...native, milestone: {} }, + { ...native, type: {} }, { ...native, labels: null }, { ...native, assignees: [null] }, { ...native, labels: [{ name: '' }] }, + { ...native, labels: Array.from({ length: 101 }, () => ({ name: 'label' })) }, + { ...native, repository_url: 'https://api.github.com/repos/other/repo' }, { ...native, repository_url: undefined }, + { ...native, repository_url: 'http://api.github.com/repos/org/repo' }, { ...native, repository_url: 'https://user:pass@api.github.com/repos/org/repo' }, + { ...native, repository_url: 'https://api.github.com/repos/org/repo?secret=1' }, { ...native, repository_url: 'https://api.github.com/repos/org/repo#fragment' }, + ])('rejects unverified or partial native state %j', async data => { + const f = fixture(); f.product.rest.issues.get.mockResolvedValue({ data } as never); + await expect(f.repository.readIssue(21)).rejects.toMatchObject({ code: 'provider.contract-invalid' }); + }); + it('accepts native absence of optional type and a real milestone on enterprise API paths', async () => { + const f = fixture(); f.product.rest.issues.get.mockResolvedValue({ data: { ...native, body: 'body', type: undefined, milestone: { id: 9 }, repository_url: 'https://ghe.example/api/v3/repos/org/repo' } } as never); + expect(await f.repository.readIssue(21)).toMatchObject({ body: 'body', milestone: 9, issueType: null }); + }); + it('uses a separate read credential and requires same-workflow native successful provenance', async () => { + const f = fixture(); expect(await f.repository.hasSuccessfulReceipt('receipt', context)).toBe(true); + expect(f.product.rest.actions.listArtifactsForRepo).not.toHaveBeenCalled(); + expect(f.evidence.rest.actions.listArtifactsForRepo).toHaveBeenCalledWith({ owner: 'org', repo: 'repo', name: 'receipt', per_page: 100 }); + }); + it.each([null, { ...run, id: 201 }, { ...run, event: 'push' }, { ...run, head_sha: 'b'.repeat(40) }, { ...run, workflow_id: 0 }, + { ...run, path: undefined }, { ...run, actor: { login: 'foreign' } }, { ...run, run_attempt: 2 }, { ...run, created_at: 'bad' }, { ...run, path: '.github/workflows/../foreign.yml' }])('ignores receipts without current workflow authority %j', async data => { + const f = fixture(); f.evidence.rest.actions.getWorkflowRun.mockResolvedValue({ data } as never); + expect(await f.repository.hasSuccessfulReceipt('receipt', context)).toBe(false); + }); + it.each([null, {}, { artifacts: [] }, { artifacts: [null] }, { artifacts: [{ ...artifact, name: 'other' }] }, + { artifacts: [{ ...artifact, expired: true }] }, { artifacts: [{ ...artifact, workflow_run: null }] }, + { artifacts: [{ ...artifact, workflow_run: { id: 200 } }] }, { artifacts: [{ ...artifact, created_at: '2026-10-28T00:01:00Z' }] }, { artifacts: [{ ...artifact, created_at: '2026-10-28T00:00:59Z' }] }, { artifacts: [{ ...artifact, created_at: null }] }, { artifacts: [{ ...artifact, size_in_bytes: 0 }] }, + { artifacts: [{ ...artifact, size_in_bytes: 4097 }] }])('retains work when receipt metadata is missing or invalid %j', async data => { + const f = fixture(); f.evidence.rest.actions.listArtifactsForRepo.mockResolvedValue({ data } as never); + expect(await f.repository.hasSuccessfulReceipt('receipt', context)).toBe(false); + }); + it.each([null, { ...source, id: 198 }, { ...source, workflow_id: 8 }, { ...source, path: '.github/workflows/other.yml' }, + { ...source, head_sha: 'b'.repeat(40) }, { ...source, event: 'pull_request' }, { ...source, status: 'in_progress' }, + { ...source, conclusion: 'failure' }, { ...source, conclusion: 'cancelled' }, { ...source, actor: null }, { ...source, actor: { login: 'foreign' } }])( + 'never coalesces with failed, partial, foreign, stale or unproven source %j', async data => { + const f = fixture(); f.evidence.rest.actions.getWorkflowRun.mockImplementation(async ({ run_id }) => ({ data: run_id === 200 ? run : data } as never)); + expect(await f.repository.hasSuccessfulReceipt('receipt', context)).toBe(false); + }); +}); + + +it('accepts native ISO fractional timestamps without normalization or coercing absent authority into a login', async () => { + const f = fixture(); f.product.rest.issues.get.mockResolvedValue({ data: { ...native, updated_at: '2026-10-28T00:00:00.125Z' } }); + expect((await f.repository.readIssue(21)).updatedAt).toBe('2026-10-28T00:00:00.125Z'); + const unusual = { ...context, actor: 'undefined' }; + f.evidence.rest.actions.getWorkflowRun.mockImplementation(async ({ run_id }) => ({ data: run_id === 200 ? { ...run, actor: { login: 'undefined' } } : { ...source, actor: {} } } as never)); + expect(await f.repository.hasSuccessfulReceipt('receipt', unusual)).toBe(false); + f.evidence.rest.actions.getWorkflowRun.mockImplementation(async ({ run_id }) => ({ data: run_id === 200 ? { ...run, actor: {} } : { ...source, actor: { login: 'undefined' } } } as never)); + expect(await f.repository.hasSuccessfulReceipt('receipt', unusual)).toBe(false); + f.evidence.rest.actions.getWorkflowRun.mockImplementation(async ({ run_id }) => ({ data: { ...(run_id === 200 ? run : source), actor: { login: 'undefined' } } } as never)); + expect(await f.repository.hasSuccessfulReceipt('receipt', unusual)).toBe(true); +}); diff --git a/src/data/repository/workflow/issue_event_admission_repository.ts b/src/data/repository/workflow/issue_event_admission_repository.ts new file mode 100644 index 000000000..463715852 --- /dev/null +++ b/src/data/repository/workflow/issue_event_admission_repository.ts @@ -0,0 +1,81 @@ +import type { IssueAdmissionSnapshot, IssueEventAdmissionContext, IssueEventAdmissionPort } from '../../../application/ports/issue_event_admission_ports'; +import { ApplicationError } from '../../../application/errors/application_error'; +import type { GithubIssueAdmissionClient } from '../../../infrastructure/github/ports/github_issue_admission_provider_ports'; + +/** Repository-bound queries. No write permission or provider exception leaves this boundary. */ +export class IssueEventAdmissionRepository implements IssueEventAdmissionPort { + constructor(private readonly product: GithubIssueAdmissionClient, private readonly evidence: GithubIssueAdmissionClient, + private readonly owner: string, private readonly repository: string) {} + + async authenticatedLogin(): Promise { + try { + const user = record((await this.product.rest.users.getAuthenticated()).data); + if (typeof user?.login !== 'string' || !user.login.trim()) throw new Error(); + return user.login; + } catch { throw new ApplicationError('authorization.credential-invalid', 'Issue event admission could not verify the product account.'); } + } + + async readIssue(issueNumber: number): Promise { + try { + const issue = record((await this.product.rest.issues.get({ owner: this.owner, repo: this.repository, issue_number: issueNumber })).data); + const author = record(issue?.user); + const milestone = issue?.milestone === null ? null : record(issue?.milestone)?.id; + const type = issue?.type === null || issue?.type === undefined ? null : record(issue.type)?.name; + if (!issue || issue.pull_request !== undefined || issue.number !== issueNumber + || !positive(issue.id) || typeof issue.title !== 'string' || !(issue.body === null || typeof issue.body === 'string') + || !['open', 'closed'].includes(String(issue.state)) || typeof issue.updated_at !== 'string' + || !timestamp(issue.updated_at) || typeof author?.login !== 'string' || !author.login.trim() + || typeof issue.locked !== 'boolean' || !(milestone === null || positive(milestone)) + || !(type === null || typeof type === 'string') + || issue.repository_url !== `${issueRepositoryApiPrefix(issue.repository_url)}/repos/${this.owner}/${this.repository}`) throw new Error(); + return Object.freeze({ id: issue.id as number, number: issueNumber, title: issue.title, body: issue.body ?? '', + state: issue.state as 'open' | 'closed', updatedAt: issue.updated_at, author: author.login, + labels: Object.freeze(strings(issue.labels, 'name')), assignees: Object.freeze(strings(issue.assignees, 'login')), + milestone: milestone as number | null, issueType: type, locked: issue.locked }); + } catch { throw new ApplicationError('provider.contract-invalid', 'Native issue state could not be verified before admission.'); } + } + + async hasSuccessfulReceipt(name: string, context: IssueEventAdmissionContext): Promise { + const binding = { owner: this.owner, repo: this.repository }; + const current = record((await this.evidence.rest.actions.getWorkflowRun({ ...binding, run_id: context.runId })).data); + if (!current || current.id !== context.runId || current.event !== 'issues' || current.head_sha !== context.workflowRevision + || !actorMatches(current.actor, context.actor) || current.run_attempt !== 1 || !timestamp(current.created_at) || !positive(current.workflow_id) || typeof current.path !== 'string' || !/^\.github\/workflows\/[\w.-]+\.ya?ml$/u.test(current.path)) return false; + const response = record((await this.evidence.rest.actions.listArtifactsForRepo({ ...binding, name, per_page: 100 })).data); + if (!Array.isArray(response?.artifacts)) return false; + // Bounded optimization. Ambiguous, absent, failed or canceled evidence never skips work. + for (const raw of response.artifacts.slice(0, 3)) { + const artifact = record(raw); const source = record(artifact?.workflow_run); + if (artifact?.name !== name || artifact.expired !== false || !positive(source?.id) + || (source.id as number) >= context.runId || !timestamp(artifact.created_at) + || Date.parse(artifact.created_at) <= Date.parse(current.created_at as string) || !positive(artifact.size_in_bytes) || (artifact.size_in_bytes as number) > 4096) continue; + const run = record((await this.evidence.rest.actions.getWorkflowRun({ ...binding, run_id: source.id as number })).data); + if (run?.id === source.id && run.workflow_id === current.workflow_id && run.path === current.path + && run.head_sha === context.workflowRevision && run.event === 'issues' && run.status === 'completed' && run.conclusion === 'success' + && actorMatches(run.actor, context.actor)) return true; + } + return false; + } +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; +} +function positive(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } +function strings(value: unknown, key: string): string[] { + if (!Array.isArray(value) || value.length > 100) throw new Error(); + return value.map(item => { const field = record(item)?.[key]; if (typeof field !== 'string' || !field) throw new Error(); return field; }); +} +function issueRepositoryApiPrefix(value: unknown): string { + if (typeof value !== 'string') throw new Error(); + const url = new URL(value); if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error(); + return value.slice(0, value.indexOf('/repos/')); +} + +function timestamp(value: unknown): value is string { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) + && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 19) === value.slice(0, 19); +} +function actorMatches(value: unknown, expected: string): boolean { + const login = record(value)?.login; + return typeof login === 'string' && login.toLowerCase() === expected.toLowerCase(); +} diff --git a/src/infrastructure/composition/__tests__/issue_event_admission_composition_root.test.ts b/src/infrastructure/composition/__tests__/issue_event_admission_composition_root.test.ts new file mode 100644 index 000000000..4c444a2f4 --- /dev/null +++ b/src/infrastructure/composition/__tests__/issue_event_admission_composition_root.test.ts @@ -0,0 +1,10 @@ +import { createIssueEventAdmissionPort } from '../issue_event_admission_composition_root'; +import { getOctokitClient } from '../../github/octokit_client_resolver'; +jest.mock('../../github/octokit_client_resolver'); +it('binds native issue identity to the product client and receipt queries to the separate read client', async () => { + const product = { rest: { users: { getAuthenticated: jest.fn().mockResolvedValue({ data: { login: 'product' } }) } } }; + const evidence = { rest: { actions: {} } }; + jest.mocked(getOctokitClient).mockReturnValueOnce(product).mockReturnValueOnce(evidence); + expect(await createIssueEventAdmissionPort('org', 'repo', 'PRODUCT', 'READ_ONLY').authenticatedLogin()).toBe('product'); + expect(jest.mocked(getOctokitClient).mock.calls).toEqual([['PRODUCT'], ['READ_ONLY']]); +}); diff --git a/src/infrastructure/composition/issue_event_admission_composition_root.ts b/src/infrastructure/composition/issue_event_admission_composition_root.ts new file mode 100644 index 000000000..0ac3cbda8 --- /dev/null +++ b/src/infrastructure/composition/issue_event_admission_composition_root.ts @@ -0,0 +1,8 @@ +import { getOctokitClient } from '../github/octokit_client_resolver'; +import { IssueEventAdmissionRepository } from '../../data/repository/workflow/issue_event_admission_repository'; +import type { GithubIssueAdmissionClient } from '../github/ports/github_issue_admission_provider_ports'; + +export function createIssueEventAdmissionPort(owner: string, repository: string, token: string, evidenceToken: string): IssueEventAdmissionRepository { + return new IssueEventAdmissionRepository(getOctokitClient(token), + getOctokitClient(evidenceToken), owner, repository); +} diff --git a/src/infrastructure/github/ports/github_issue_admission_provider_ports.ts b/src/infrastructure/github/ports/github_issue_admission_provider_ports.ts new file mode 100644 index 000000000..cd08a1a51 --- /dev/null +++ b/src/infrastructure/github/ports/github_issue_admission_provider_ports.ts @@ -0,0 +1,10 @@ +export interface GithubIssueAdmissionClient { + rest: { + users: { getAuthenticated(): Promise<{ data: unknown }> }; + issues: { get(parameters: { owner: string; repo: string; issue_number: number }): Promise<{ data: unknown }> }; + actions: { + listArtifactsForRepo(parameters: { owner: string; repo: string; name: string; per_page: number }): Promise<{ data: unknown }>; + getWorkflowRun(parameters: { owner: string; repo: string; run_id: number }): Promise<{ data: unknown }>; + }; + }; +} diff --git a/src/manager/description/base/__tests__/issue_content_interface.test.ts b/src/manager/description/base/__tests__/issue_content_interface.test.ts index 79c3c30e5..aeee6bb47 100644 --- a/src/manager/description/base/__tests__/issue_content_interface.test.ts +++ b/src/manager/description/base/__tests__/issue_content_interface.test.ts @@ -103,6 +103,12 @@ describe('IssueContentInterface', () => { }); describe('internalUpdate', () => { + it('does not produce a native edit event when the complete rendered description is unchanged', async () => { + const before = descriptionWithBlock('same'); mockGetDescription.mockResolvedValue(before); + expect(await handler.internalUpdate(configurationQuery(), 'same')).toBe(before); + expect(mockUpdateDescription).not.toHaveBeenCalled(); + }); + it('fetches description, updates content and calls updateDescription when isIssue', async () => { const desc = descriptionWithBlock('old'); mockGetDescription.mockResolvedValue(desc); diff --git a/src/manager/description/base/issue_content_interface.ts b/src/manager/description/base/issue_content_interface.ts index 87ca537c9..9769fd4ec 100644 --- a/src/manager/description/base/issue_content_interface.ts +++ b/src/manager/description/base/issue_content_interface.ts @@ -39,6 +39,9 @@ export abstract class IssueContentInterface extends ContentInterface { throw new Error('Issue content markers are missing or inconsistent.'); } + // Byte-identical state must not emit another native issue edit event. + if (updated === description) return updated; + await this.issueDescriptionPort.updateDescription( query.owner, query.repository, diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index fd4bb709d..2486d0ff5 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -10,6 +10,7 @@ import { selectedSetupRunsOn } from '../../application/policies/setup_runner_pol const CRLF_WHITESPACE = 'core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol'; interface ContractModule { + issueAdmissionConfigurationExpression(workflow: Record): string; assertQueueWorkflow(file: string, workflow: Record): void; assertDirectEventTriggers(file: string, workflow: Record): void; assertRunner(file: string, workflow: Record): void; @@ -37,6 +38,7 @@ interface ContractModule { } const { + issueAdmissionConfigurationExpression, assertQueueWorkflow, assertDirectEventTriggers, assertRunner, @@ -91,18 +93,7 @@ function expectMutationRejected( expect(() => validateWorkflow(file, workflow)).toThrow(expectedMessage); } -const validWorkflow = { - on: { issues: { types: ['opened', 'closed'] } }, - name: 'Copilot - Issue', - jobs: { - 'copilot-issues': { - if: "${{ (github.event.action == 'closed' && github.event.issue.state == 'closed') || vars.COPILOT_BOT_LOGIN == '' || github.actor != vars.COPILOT_BOT_LOGIN }}", - 'runs-on': ['self-hosted', 'codex'], - 'timeout-minutes': MIN_QUEUE_JOB_TIMEOUT_MINUTES, - steps: [{ uses: './', with: {} }], - }, - }, -}; +const validWorkflow = yaml.load(readFileSync(queueFile, 'utf8')) as MutationWorkflow; describe('workflow contract validator', () => { it.each(['.github/workflows', 'setup/workflows'].flatMap(directory => @@ -254,7 +245,9 @@ describe('workflow contract validator', () => { const workflow = yaml.load(readFileSync(file, 'utf8')) as Record & { jobs: Record; }; - expect(workflow.concurrency).toBeUndefined(); + if (manifest.file === 'copilot_issue.yml') { + expect(workflow.concurrency).toEqual({ group: 'copilot-issue-${{ github.repository }}-${{ github.event.issue.number }}', 'cancel-in-progress': false, queue: 'max' }); + } else expect(workflow.concurrency).toBeUndefined(); 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 }}' @@ -342,10 +335,10 @@ describe('workflow contract validator', () => { }, ); - it('rejects an event workflow that removes the generic bot actor gate', () => { + it('rejects an event workflow that bypasses native issue admission', () => { const workflow = JSON.parse(JSON.stringify(validWorkflow)); delete workflow.jobs['copilot-issues'].if; - expect(() => assertQueueWorkflow(queueFile, workflow)).toThrow('required bot actor gate'); + expect(() => assertQueueWorkflow(queueFile, workflow)).toThrow('isolated native issue admission'); }); it.each(['.github/workflows', 'setup/workflows'])( @@ -1781,3 +1774,51 @@ describe('optional commit Check Run credential route', () => { expect(() => assertAgentWorkflowPermissions(file, workflow)).toThrow('must scope COPILOT_EVIDENCE_TOKEN'); }); }); + + +describe('early issue event allocation contract', () => { + it.each(['.github/workflows', 'setup/workflows'])('admits before allocating the selected work runner in %s', directory => { + const { file, workflow } = loadMutationWorkflow(directory, 'copilot_issue.yml'); + expect(() => validateWorkflow(file, workflow)).not.toThrow(); + expect(workflow.jobs['copilot-issues'].needs).toBe('issue-admission'); + expect(workflow.jobs['issue-admission']['runs-on']).toBe('ubuntu-latest'); + expect(workflow.concurrency).toMatchObject({ queue: 'max', 'cancel-in-progress': false }); + }); + it.each([ + ['cancel active work', (w: MutationWorkflow) => { w.concurrency['cancel-in-progress'] = true; }], + ['replace pending events', (w: MutationWorkflow) => { w.concurrency.queue = 'single'; }], + ['global mutex', (w: MutationWorkflow) => { w.concurrency.group = 'all-issues'; }], + ['bypass inspection', (w: MutationWorkflow) => { w.jobs['copilot-issues'].needs = []; }], + ['fail-open inspection', (w: MutationWorkflow) => { w.jobs['issue-admission'].steps[1]['continue-on-error'] = true; }], + ['hide closure events', (w: MutationWorkflow) => { w.jobs['issue-admission'].if = "github.actor != vars.COPILOT_BOT_LOGIN"; }], + ['inspection mutations', (w: MutationWorkflow) => { w.jobs['issue-admission'].permissions.issues = 'write'; }], + ['single action outside the normal issue contract', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'copilot').with['single-action'] = 'recommend_steps'; }], + ['mismatched release alias', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'copilot').with['release-label'] = 'ship-release'; }], + ['mismatched hotfix alias', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'copilot').with['hotfix-label'] = 'urgent'; }], + ['ignore failed product work', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'copilot')['continue-on-error'] = true; }], + ['skip product work', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'copilot').if = 'false'; }], + ['record failed work', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'receipt').if = '${{ always() }}'; }], + ['unbound receipt', (w: MutationWorkflow) => { w.jobs['copilot-issues'].steps.find((s: any) => s.id === 'receipt').with['receipt-key'] = 'arbitrary'; }], + ['product token for artifact reads', (w: MutationWorkflow) => { w.jobs['issue-admission'].steps[1].env.COPILOT_ISSUE_ADMISSION_TOKEN = '${{ secrets.PAT }}'; }], + ['agent work in preflight', (w: MutationWorkflow) => { w.jobs['issue-admission'].steps.push({ uses: './' }); }], + ])('rejects %s', (_name, mutate) => { + const { file, workflow } = loadMutationWorkflow('.github/workflows', 'copilot_issue.yml'); mutate(workflow); + expect(() => validateWorkflow(file, workflow)).toThrow(); + }); +}); + + +it('snapshots only effective issue-step Variables, including job environment, with unambiguous JSON boundaries', () => { + const workflow = { jobs: { 'copilot-issues': { env: { MODE: '${{ vars.MODE }}' }, steps: [{ uses: './', with: { + first: '${{ vars.FIRST }}', duplicate: '${{ vars.FIRST }}', second: '${{ vars.SECOND }}', token: '${{ secrets.PAT }}', + } }] } }, unrelated: { UNUSED: 'PRIVATE_UNUSED_VALUE' } }; + const expression = issueAdmissionConfigurationExpression(workflow); + expect(expression).toBe("${{ format('[{0},{1},{2}]', toJSON(vars.FIRST), toJSON(vars.MODE), toJSON(vars.SECOND)) }}"); + const values = ['quotes " and braces {1}', 'mode', 'comma, and newline\ncontent']; + const rendered = '[{0},{1},{2}]'.replace(/\{(\d+)\}/gu, (_match, index) => JSON.stringify(values[Number(index)])); + expect(JSON.parse(rendered)).toEqual(values); expect(expression).not.toMatch(/PRIVATE|UNUSED|secrets/); +}); +it('retains an explicit empty snapshot and refuses dynamic Variable lookup without proof', () => { + expect(issueAdmissionConfigurationExpression({})).toBe("${{ '[]' }}"); + expect(() => issueAdmissionConfigurationExpression({ jobs: { 'copilot-issues': { steps: [{ uses: './', with: { value: '${{ vars[inputs.name] }}' } }] } } })).toThrow('explicit literal Variable'); +}); diff --git a/src/utils/__tests__/setup_runner_installation.test.ts b/src/utils/__tests__/setup_runner_installation.test.ts index fd44f6820..68ee099a2 100644 --- a/src/utils/__tests__/setup_runner_installation.test.ts +++ b/src/utils/__tests__/setup_runner_installation.test.ts @@ -115,10 +115,12 @@ describe('installation parity and reviewed migration', () => { write('.github/workflows/copilot_issue.yml', single('[self-hosted, old, Linux]')); const review = inspectSetupInstallation(root, config); expect(review.assets.find(asset => asset.path === '.github/workflows/copilot_issue.yml')?.status).toBe('customized'); - expect(review.jobs.find(job => job.file === 'copilot_issue.yml')?.runsOn).toEqual({ group: 'vypdev', labels: config.runners.labels }); + expect(review.jobs.find(job => job.file === 'copilot_issue.yml' && job.job === 'copilot-issues')?.runsOn).toEqual({ group: 'vypdev', labels: config.runners.labels }); + expect(review.jobs.find(job => job.file === 'copilot_issue.yml' && job.job === 'issue-admission')).toMatchObject({ role: 'isolated', runsOn: 'ubuntu-latest' }); expect(compareSetupWorkflows(root, config.features, undefined, config).find(workflow => workflow.file === 'copilot_issue.yml')?.status).toBe('changed'); copySetupFiles(root, undefined, config.features, { setupConfiguration: config, updateExistingWorkflows: true, approvedWorkflowFiles: ['copilot_issue.yml'] }); expect(parsed(readFileSync(join(root, '.github/workflows/copilot_issue.yml'), 'utf8')).jobs['copilot-issues']['runs-on']).toEqual({ group: 'vypdev', labels: config.runners.labels }); + expect(parsed(readFileSync(join(root, '.github/workflows/copilot_issue.yml'), 'utf8')).jobs['issue-admission']['runs-on']).toBe('ubuntu-latest'); expect(readFileSync(join(root, '.github/workflows/build.yml'), 'utf8')).toContain('uses: "./"'); }); test('clean preview includes every optional asset and separates roles', () => {