From 65509f6b22d28651b65d65e8683d82e00c2a0b6c Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 20 Oct 2026 19:53:28 +0200 Subject: [PATCH 1/2] bugfix-619-approval-observer-hides-evidence-read-failures-behind-evidence-unavailable-without-actionable-diagnostics: retain causal approval evidence diagnostics --- build/api/index.js | 46 +++ .../api/src/data/model/application_error.d.ts | 4 +- .../domain/approval_evidence_diagnostic.d.ts | 30 ++ build/cli/index.js | 263 ++++++++++++--- build/github_action/index.js | 303 ++++++++++++++---- docs/pull-requests/guarded-approval.mdx | 30 ++ scripts/coverage-budgets.json | 15 + specs/CATALOG.md | 8 +- specs/catalog.json | 10 +- specs/guarded-pull-request-approval.md | 78 +++++ .../pull_request_approval_action.test.ts | 23 ++ src/actions/pull_request_approval_action.ts | 13 +- .../errors/approval_evidence_error.ts | 20 ++ ...lication_error_presentation_policy.test.ts | 5 + .../application_error_message_catalog.ts | 10 + ...rve_pull_request_approval_use_case.test.ts | 32 ++ .../observe_pull_request_approval_use_case.ts | 29 +- src/data/model/application_error.ts | 10 + .../__tests__/approval_evidence_read.test.ts | 59 ++++ .../pull_request_approval_repository.test.ts | 66 +++- .../pull_request/approval_evidence_read.ts | 21 ++ .../current_base_revision_query.ts | 5 +- .../pull_request_approval_repository.ts | 118 ++++--- src/domain/approval_evidence_diagnostic.ts | 34 ++ src/domain/pull_request_approval.ts | 2 + 25 files changed, 1069 insertions(+), 165 deletions(-) create mode 100644 build/api/src/domain/approval_evidence_diagnostic.d.ts create mode 100644 src/application/errors/approval_evidence_error.ts create mode 100644 src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts create mode 100644 src/data/repository/pull_request/approval_evidence_read.ts create mode 100644 src/domain/approval_evidence_diagnostic.ts diff --git a/build/api/index.js b/build/api/index.js index 896c35b7f..97c497a32 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -5152,6 +5152,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const approval_evidence_diagnostic_1 = __nccwpck_require__(8205); const branch_sync_validation_1 = __nccwpck_require__(981); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'pull-request-link-restored', @@ -5167,6 +5168,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', 'workspace-changes-rejected', + 'approval-evidence-read-failed', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -5358,6 +5360,7 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -5410,6 +5413,12 @@ function normalizeApplicationErrorRecovery(recovery) { && !branch_sync_validation_1.BRANCH_SYNC_VALIDATION_CODES.some(code => code === variables.validationCode)) { throw new TypeError('Application error recovery synchronization validation code is invalid.'); } + if (recovery.id === 'approval-evidence-read-failed' + && (!Object.prototype.hasOwnProperty.call(approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS, variables.operation) + || !approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_REASONS.some(reason => reason === variables.reason) + || approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS[variables.operation] !== variables.permission)) { + throw new TypeError('Approval evidence diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -5468,6 +5477,43 @@ function isAgentConfigurationReady(configuration) { } +/***/ }), + +/***/ 8205: +/***/ ((__unused_webpack_module, exports) => { + + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.APPROVAL_EVIDENCE_REASONS = exports.APPROVAL_EVIDENCE_PERMISSIONS = void 0; +/** Closed vocabulary: no provider text, URLs, headers, policy values or credentials. */ +exports.APPROVAL_EVIDENCE_PERMISSIONS = Object.freeze({ + 'evidence': 'repository-read', + 'pull-request': 'pull-requests-read', + 'base-ref': 'contents-read', + 'merge-commit': 'contents-read', + 'policy': 'actions-variables-read', + 'files': 'pull-requests-read', + 'reviews': 'pull-requests-read', + 'comments': 'issues-read', + 'head-checks': 'checks-read', + 'merge-checks': 'checks-read', + 'head-runs': 'actions-read', + 'merge-runs': 'actions-read', + 'jobs': 'actions-read', + 'rules': 'metadata-read', + 'classic-protection': 'administration-read', + 'authenticated-user': 'authenticated-user-read', + 'coverage-artifacts': 'actions-read', + 'coverage-download': 'actions-read', + 'coverage-parse': 'actions-read', +}); +exports.APPROVAL_EVIDENCE_REASONS = Object.freeze([ + 'http-400', 'http-401', 'http-403', 'http-404', 'http-409', 'http-422', 'http-429', + 'http-500', 'http-502', 'http-503', 'http-504', 'transport-unavailable', + 'invalid-response', 'invalid-page', 'page-limit', 'artifact-unavailable', 'artifact-invalid', 'artifact-mismatch', +]); + + /***/ }), /***/ 981: diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index 4de915bf9..bfcfa7452 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -1,9 +1,11 @@ +import { type ApprovalEvidenceDiagnostic } from '../../domain/approval_evidence_diagnostic'; import { type BranchSyncValidationCode } from '../../domain/branch_sync_validation'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "workspace-changes-rejected"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "workspace-changes-rejected", "approval-evidence-read-failed"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables { + readonly 'approval-evidence-read-failed': ApprovalEvidenceDiagnostic; readonly 'pull-request-link-restored': Readonly>; readonly 'pull-request-link-base-retained': Readonly>; readonly 'pull-request-link-reference-retained': Readonly>; diff --git a/build/api/src/domain/approval_evidence_diagnostic.d.ts b/build/api/src/domain/approval_evidence_diagnostic.d.ts new file mode 100644 index 000000000..544a2dfa1 --- /dev/null +++ b/build/api/src/domain/approval_evidence_diagnostic.d.ts @@ -0,0 +1,30 @@ +/** Closed vocabulary: no provider text, URLs, headers, policy values or credentials. */ +export declare const APPROVAL_EVIDENCE_PERMISSIONS: Readonly<{ + readonly evidence: "repository-read"; + readonly 'pull-request': "pull-requests-read"; + readonly 'base-ref': "contents-read"; + readonly 'merge-commit': "contents-read"; + readonly policy: "actions-variables-read"; + readonly files: "pull-requests-read"; + readonly reviews: "pull-requests-read"; + readonly comments: "issues-read"; + readonly 'head-checks': "checks-read"; + readonly 'merge-checks': "checks-read"; + readonly 'head-runs': "actions-read"; + readonly 'merge-runs': "actions-read"; + readonly jobs: "actions-read"; + readonly rules: "metadata-read"; + readonly 'classic-protection': "administration-read"; + readonly 'authenticated-user': "authenticated-user-read"; + readonly 'coverage-artifacts': "actions-read"; + readonly 'coverage-download': "actions-read"; + readonly 'coverage-parse': "actions-read"; +}>; +export type ApprovalEvidenceOperation = keyof typeof APPROVAL_EVIDENCE_PERMISSIONS; +export declare const APPROVAL_EVIDENCE_REASONS: readonly ["http-400", "http-401", "http-403", "http-404", "http-409", "http-422", "http-429", "http-500", "http-502", "http-503", "http-504", "transport-unavailable", "invalid-response", "invalid-page", "page-limit", "artifact-unavailable", "artifact-invalid", "artifact-mismatch"]; +export type ApprovalEvidenceReason = typeof APPROVAL_EVIDENCE_REASONS[number]; +export type ApprovalEvidenceDiagnostic = { + readonly operation: ApprovalEvidenceOperation; + readonly reason: ApprovalEvidenceReason; + readonly permission: typeof APPROVAL_EVIDENCE_PERMISSIONS[ApprovalEvidenceOperation]; +}; diff --git a/build/cli/index.js b/build/cli/index.js index e79e79b91..93e02f849 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -38089,6 +38089,36 @@ function runAtApplicationErrorBoundary(operation) { } +/***/ }), + +/***/ 50519: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.approvalEvidenceError = approvalEvidenceError; +exports.approvalDiagnosticError = approvalDiagnosticError; +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); +const application_error_1 = __nccwpck_require__(75999); +function approvalEvidenceError(operation, reason) { + return approvalDiagnosticError({ operation, reason, permission: approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS[operation] }); +} +function approvalDiagnosticError(diagnostic) { + const reason = diagnostic.reason; + const code = reason === 'http-401' ? 'authorization.credential-invalid' + : reason === 'http-403' ? 'authorization.denied' + : reason === 'http-404' ? 'provider.not-found' + : reason === 'http-429' ? 'provider.rate-limited' + : reason === 'http-409' ? 'provider.conflict' + : reason === 'transport-unavailable' || /^http-50[0234]$/u.test(reason) ? 'provider.unavailable' + : 'provider.contract-invalid'; + return new application_error_1.ApplicationError(code, `Approval evidence read failed (${diagnostic.operation}, ${reason}).`, { + recovery: { id: 'approval-evidence-read-failed', variables: diagnostic }, + }); +} + + /***/ }), /***/ 72594: @@ -39265,6 +39295,11 @@ const SPANISH_CONTENT = Object.freeze({ }), }); const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'Approval evidence could not be verified: {operation} ({reason}).', + action: 'Inspect {permission} access for the runtime credential and the named evidence response. Correct authorization, missing-resource or schema errors before rerunning; retry transient failures only after provider recovery or rate-limit reset.', + retainedState: 'No approval is authorized by this failed read. Existing PRs, reviews and completed external effects were preserved.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'The changes were rejected during workspace verification.', action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', @@ -39332,6 +39367,11 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'No se pudo verificar la evidencia de aprobación: {operation} ({reason}).', + action: 'Comprueba el acceso {permission} de la credencial de ejecución y la respuesta indicada. Corrige los errores de autorización, recurso o esquema antes de reejecutar; reintenta los fallos transitorios solo tras recuperarse el proveedor o restablecerse el límite.', + retainedState: 'Esta lectura fallida no autoriza ninguna aprobación. Se conservaron las PRs, revisiones y efectos externos completados.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'Los cambios se rechazaron durante la verificación del espacio de trabajo.', action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', @@ -78173,6 +78213,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); const branch_sync_validation_1 = __nccwpck_require__(90981); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'pull-request-link-restored', @@ -78188,6 +78229,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', 'workspace-changes-rejected', + 'approval-evidence-read-failed', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -78379,6 +78421,7 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -78431,6 +78474,12 @@ function normalizeApplicationErrorRecovery(recovery) { && !branch_sync_validation_1.BRANCH_SYNC_VALIDATION_CODES.some(code => code === variables.validationCode)) { throw new TypeError('Application error recovery synchronization validation code is invalid.'); } + if (recovery.id === 'approval-evidence-read-failed' + && (!Object.prototype.hasOwnProperty.call(approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS, variables.operation) + || !approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_REASONS.some(reason => reason === variables.reason) + || approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS[variables.operation] !== variables.permission)) { + throw new TypeError('Approval evidence diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -86219,6 +86268,41 @@ function parseApprovalCoverageZip(value) { } +/***/ }), + +/***/ 9958: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.readApprovalEvidence = readApprovalEvidence; +const application_error_1 = __nccwpck_require__(75999); +const approval_evidence_error_1 = __nccwpck_require__(50519); +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); +/** Terminates provider exceptions; even unknown statuses never interpolate provider text. */ +async function readApprovalEvidence(operation, read) { + try { + return await read(); + } + catch (cause) { + if (cause instanceof application_error_1.ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed') + throw cause; + const error = cause && typeof cause === 'object' ? cause : undefined; + const response = error?.response; + const status = error?.status; + const rateLimited = status === 403 && (response?.headers?.['x-ratelimit-remaining'] === '0' + || typeof response?.headers?.['retry-after'] === 'string' && /^\d{1,8}$/u.test(response.headers['retry-after'])); + const http = typeof status === 'number' ? `http-${status}` : undefined; + const reason = rateLimited ? 'http-429' + : approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_REASONS.some(value => value === http) ? http + : cause instanceof application_error_1.ApplicationError && cause.code === 'provider.contract-invalid' ? 'invalid-response' + : status === undefined ? 'transport-unavailable' : 'invalid-response'; + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, reason); + } +} + + /***/ }), /***/ 55165: @@ -86253,21 +86337,22 @@ exports.BugbotPullRequestRepository = BugbotPullRequestRepository; /***/ }), /***/ 68742: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.readCurrentBaseRevision = readCurrentBaseRevision; +const application_error_1 = __nccwpck_require__(75999); /** PR base metadata can lag a parent push; read the exact native branch ref. */ async function readCurrentBaseRevision(port, target, baseRef) { if (typeof baseRef !== 'string' || baseRef.length === 0 || baseRef.length > 1024 || baseRef.includes('\u0000') || /[\r\n]/u.test(baseRef)) - throw new Error('The PR base ref is invalid.'); + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The PR base ref is invalid.'); const { data } = await port.getRef({ owner: target.owner, repo: target.repository, ref: `heads/${baseRef}` }); if (data?.ref !== `refs/heads/${baseRef}` || data.object?.type !== 'commit' || typeof data.object.sha !== 'string' || !/^[a-f0-9]{40}$/u.test(data.object.sha)) - throw new Error('The PR base ref cannot be verified.'); + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The PR base ref cannot be verified.'); return data.object.sha; } @@ -86315,6 +86400,9 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.PullRequestApprovalRepository = void 0; exports.readEffectiveApprovalPolicy = readEffectiveApprovalPolicy; +const approval_evidence_read_1 = __nccwpck_require__(9958); +const approval_evidence_error_1 = __nccwpck_require__(50519); +const application_error_1 = __nccwpck_require__(75999); const current_base_revision_query_1 = __nccwpck_require__(68742); const logging_ports_1 = __nccwpck_require__(6152); const github = __importStar(__nccwpck_require__(78227)); @@ -86347,34 +86435,58 @@ class PullRequestApprovalRepository { return readEffectiveApprovalPolicy(this.octokit, target); } async loadEvidence(target) { - const { data: pull } = await this.octokit.rest.pulls.get({ - owner: target.owner, repo: target.repository, pull_number: target.pullNumber, + const diagnostics = []; + const { data: pull } = await (0, approval_evidence_read_1.readApprovalEvidence)('pull-request', async () => { + const response = await this.octokit.rest.pulls.get({ owner: target.owner, repo: target.repository, pull_number: target.pullNumber }); + if (!response.data?.head || !response.data?.base || typeof response.data.head.sha !== 'string' + || typeof response.data.head.ref !== 'string' || !Number.isSafeInteger(response.data.number)) { + throw (0, approval_evidence_error_1.approvalEvidenceError)('pull-request', 'invalid-response'); + } + return response; }); const headSha = pull.head.sha; const baseSha = await this.readCurrentBaseSha(target, pull.base.ref); - const policy = (0, pull_request_approval_policy_1.parsePullRequestApprovalPolicy)(await this.readPolicy(target)); + const policy = await (0, approval_evidence_read_1.readApprovalEvidence)('policy', async () => { + const raw = await this.readPolicy(target); + try { + return (0, pull_request_approval_policy_1.parsePullRequestApprovalPolicy)(raw); + } + catch { + throw (0, approval_evidence_error_1.approvalEvidenceError)('policy', 'invalid-response'); + } + }); let testedMergeSha; if (pull.mergeable === true && /^[a-f0-9]{40}$/iu.test(pull.merge_commit_sha ?? '')) { try { - const merge = await this.octokit.rest.git.getCommit({ - owner: target.owner, repo: target.repository, commit_sha: pull.merge_commit_sha, + const merge = await (0, approval_evidence_read_1.readApprovalEvidence)('merge-commit', async () => { + const response = await this.octokit.rest.git.getCommit({ owner: target.owner, repo: target.repository, commit_sha: pull.merge_commit_sha }); + if (!Array.isArray(response.data?.parents)) + throw (0, approval_evidence_error_1.approvalEvidenceError)('merge-commit', 'invalid-response'); + return response; }); if (merge.data.parents[0]?.sha === baseSha && merge.data.parents[1]?.sha === headSha && merge.data.parents.length === 2) testedMergeSha = pull.merge_commit_sha; } - catch { /* Missing or unreadable merge parentage is non-authorizing. */ } + catch (cause) { + collectDiagnostic(diagnostics, cause); + } } const [files, reviews, comments, headChecks, mergeChecks, headRuns, mergeRuns, rules, user] = await Promise.all([ - this.allPages(this.octokit.rest.pulls.listFiles, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), - this.allPages(this.octokit.rest.pulls.listReviews, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), - this.allPages(this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber }), - this.allPages(this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: headSha, filter: 'all' }), - testedMergeSha ? this.allPages(this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: testedMergeSha, filter: 'all' }) : Promise.resolve([]), - this.allPages(this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: headSha }), - testedMergeSha ? this.allPages(this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: testedMergeSha }) : Promise.resolve([]), - this.effectiveRules(target.owner, target.repository, pull.base.ref), - this.octokit.rest.users.getAuthenticated(), + this.allPages('files', this.octokit.rest.pulls.listFiles, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), + this.allPages('reviews', this.octokit.rest.pulls.listReviews, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), + this.allPages('comments', this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber }), + this.allPages('head-checks', this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: headSha, filter: 'all' }), + testedMergeSha ? this.allPages('merge-checks', this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: testedMergeSha, filter: 'all' }) : Promise.resolve([]), + this.allPages('head-runs', this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: headSha }), + testedMergeSha ? this.allPages('merge-runs', this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: testedMergeSha }) : Promise.resolve([]), + this.effectiveRules(target.owner, target.repository, pull.base.ref, diagnostics), + (0, approval_evidence_read_1.readApprovalEvidence)('authenticated-user', async () => { + const response = await this.octokit.rest.users.getAuthenticated(); + if (!Number.isSafeInteger(response.data?.id) || response.data.id < 1) + throw (0, approval_evidence_error_1.approvalEvidenceError)('authenticated-user', 'invalid-response'); + return response; + }), ]); const branch = Object.entries(this.settings.branchPrefixes) .find(([, prefix]) => pull.head.ref.startsWith(`${prefix}/`)); @@ -86395,7 +86507,7 @@ class PullRequestApprovalRepository { }); const currentJobs = await Promise.all(latestRuns.map(async (run) => ({ run, - jobs: await this.allPages(this.octokit.rest.actions.listJobsForWorkflowRunAttempt, { + jobs: await this.allPages('jobs', this.octokit.rest.actions.listJobsForWorkflowRunAttempt, { owner: target.owner, repo: target.repository, run_id: run.id, attempt_number: run.run_attempt, }), }))); @@ -86426,9 +86538,10 @@ class PullRequestApprovalRepository { submittedAt: review.submitted_at ?? '', })); const numericCoverage = policy.coverage.mode === 'numeric' - ? await this.numericCoverage(target, policy.coverage.artifactWorkflowName, headSha, baseSha, testedMergeSha, workflowRuns) + ? await this.numericCoverage(target, policy.coverage.artifactWorkflowName, headSha, baseSha, testedMergeSha, workflowRuns, diagnostics) : undefined; return { + ...(diagnostics.length ? { diagnostics: Object.freeze(diagnostics) } : {}), repositoryId: pull.base.repo?.id ?? 0, pullNumber: pull.number, headSha, @@ -86462,7 +86575,7 @@ class PullRequestApprovalRepository { ...(numericCoverage ? { numericCoverage } : {}), }; } - async numericCoverage(target, workflowName, headSha, baseSha, testedMergeSha, workflowRuns) { + async numericCoverage(target, workflowName, headSha, baseSha, testedMergeSha, workflowRuns, diagnostics) { const candidates = workflowRuns.filter(run => run.name === workflowName && (run.head_sha === headSha || run.head_sha === testedMergeSha)); const mergeCandidates = candidates.filter(run => run.head_sha === testedMergeSha); @@ -86471,25 +86584,27 @@ class PullRequestApprovalRepository { if (!run || run.conclusion !== 'success' || run.status !== 'completed') return undefined; try { - const artifacts = await this.allPages(this.octokit.rest.actions.listWorkflowRunArtifacts, { + const artifacts = await this.allPages('coverage-artifacts', this.octokit.rest.actions.listWorkflowRunArtifacts, { owner: target.owner, repo: target.repository, run_id: run.id, }); const matches = artifacts.filter(artifact => artifact.name === 'copilot-diff-coverage-v1' && !artifact.expired); if (matches.length !== 1 || matches[0].size_in_bytes > 64 * 1024) - return undefined; - const response = await this.octokit.rest.actions.downloadArtifact({ + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-artifacts', 'artifact-unavailable'); + const response = await (0, approval_evidence_read_1.readApprovalEvidence)('coverage-download', async () => this.octokit.rest.actions.downloadArtifact({ owner: target.owner, repo: target.repository, artifact_id: matches[0].id, archive_format: 'zip', - }); + })); const data = response.data; const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : typeof data === 'string' ? Buffer.from(data, 'binary') : undefined; const attestation = bytes ? (0, approval_coverage_artifact_1.parseApprovalCoverageZip)(bytes) : undefined; - if (!attestation || attestation.repositoryId !== target.repositoryId + if (!attestation) + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-parse', 'artifact-invalid'); + if (attestation.repositoryId !== target.repositoryId || attestation.pullNumber !== target.pullNumber || attestation.headSha !== headSha || attestation.baseSha !== baseSha || attestation.workflowRunId !== run.id || attestation.workflowRunAttempt !== run.run_attempt) - return undefined; + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-parse', 'artifact-mismatch'); return { coveredChangedLines: attestation.coveredChangedLines, totalChangedLines: attestation.totalChangedLines, @@ -86497,7 +86612,8 @@ class PullRequestApprovalRepository { baseSha, }; } - catch { + catch (cause) { + collectDiagnostic(diagnostics, cause); return undefined; } } @@ -86523,7 +86639,7 @@ class PullRequestApprovalRepository { throw new Error('PR head or base changed before assessment publication.'); } const user = await this.octokit.rest.users.getAuthenticated(); - const comments = await this.allPages(this.octokit.rest.issues.listComments, { + const comments = await this.allPages('comments', this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber, }); const card = [...comments].reverse().find(comment => comment.user?.id === user.data.id @@ -86569,24 +86685,26 @@ class PullRequestApprovalRepository { } } async readCurrentBaseSha(target, baseRef) { - return (0, current_base_revision_query_1.readCurrentBaseRevision)(this.octokit.rest.git, target, baseRef); + return (0, approval_evidence_read_1.readApprovalEvidence)('base-ref', () => (0, current_base_revision_query_1.readCurrentBaseRevision)(this.octokit.rest.git, target, baseRef)); } - async effectiveRules(owner, repo, branch) { + async effectiveRules(owner, repo, branch, diagnostics) { try { - const rulesResponse = await this.octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner, repo, branch }); + const rulesResponse = await (0, approval_evidence_read_1.readApprovalEvidence)('rules', async () => this.octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner, repo, branch })); if (!Array.isArray(rulesResponse.data) || rulesResponse.data.some((rule) => !record(rule) || typeof record(rule)?.type !== 'string' || record(rule)?.type === '')) { - throw new Error('GitHub returned an invalid effective rules inventory.'); + throw (0, approval_evidence_error_1.approvalEvidenceError)('rules', 'invalid-response'); } const rules = rulesResponse.data; let classic; try { const response = await this.octokit.rest.repos.getBranchProtection({ owner, repo, branch }); + if (!record(response.data)) + throw (0, approval_evidence_error_1.approvalEvidenceError)('classic-protection', 'invalid-response'); classic = response.data; } catch (error) { if (statusOf(error) !== 404) - throw error; + await (0, approval_evidence_read_1.readApprovalEvidence)('classic-protection', async () => { throw error; }); } const reviewRules = rules.filter(rule => rule.type === 'pull_request'); const classicReview = classic?.required_pull_request_reviews; @@ -86612,23 +86730,26 @@ class PullRequestApprovalRepository { } return { readable: true, dismissesStaleReviews: hasStaleRule && allStale, requiredChecks: [...requiredChecks] }; } - catch { + catch (cause) { + collectDiagnostic(diagnostics, cause); return { readable: false, dismissesStaleReviews: false, requiredChecks: [] }; } } - async allPages(method, params) { - const result = []; - for await (const response of this.octokit.paginate.iterator(method, { ...params, per_page: 100 })) { - const page = Array.isArray(response.data) ? response.data - : record(response.data)?.check_runs ?? record(response.data)?.workflow_runs - ?? record(response.data)?.artifacts ?? record(response.data)?.jobs; - if (!Array.isArray(page)) - throw new Error('GitHub pagination returned an invalid page.'); - result.push(...page); - if (result.length > 3000) - throw new Error('Approval evidence pagination exceeded the safe bound.'); - } - return result; + async allPages(operation, method, params) { + return (0, approval_evidence_read_1.readApprovalEvidence)(operation, async () => { + const result = []; + for await (const response of this.octokit.paginate.iterator(method, { ...params, per_page: 100 })) { + const page = Array.isArray(response.data) ? response.data + : record(response.data)?.check_runs ?? record(response.data)?.workflow_runs + ?? record(response.data)?.artifacts ?? record(response.data)?.jobs; + if (!Array.isArray(page)) + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, 'invalid-page'); + result.push(...page); + if (result.length > 3000) + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, 'page-limit'); + } + return result; + }); } } exports.PullRequestApprovalRepository = PullRequestApprovalRepository; @@ -86689,6 +86810,14 @@ async function readEffectiveApprovalPolicy(octokit, target) { throw error; } } +function collectDiagnostic(diagnostics, cause) { + if (cause instanceof application_error_1.ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed') { + diagnostics.push(cause.recovery.variables); + } + else { + diagnostics.push((0, approval_evidence_error_1.approvalEvidenceError)('evidence', 'invalid-response').recovery.variables); + } +} /***/ }), @@ -88929,6 +89058,44 @@ class AgentResponseLengthFailure extends Error { exports.AgentResponseLengthFailure = AgentResponseLengthFailure; +/***/ }), + +/***/ 98205: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.APPROVAL_EVIDENCE_REASONS = exports.APPROVAL_EVIDENCE_PERMISSIONS = void 0; +/** Closed vocabulary: no provider text, URLs, headers, policy values or credentials. */ +exports.APPROVAL_EVIDENCE_PERMISSIONS = Object.freeze({ + 'evidence': 'repository-read', + 'pull-request': 'pull-requests-read', + 'base-ref': 'contents-read', + 'merge-commit': 'contents-read', + 'policy': 'actions-variables-read', + 'files': 'pull-requests-read', + 'reviews': 'pull-requests-read', + 'comments': 'issues-read', + 'head-checks': 'checks-read', + 'merge-checks': 'checks-read', + 'head-runs': 'actions-read', + 'merge-runs': 'actions-read', + 'jobs': 'actions-read', + 'rules': 'metadata-read', + 'classic-protection': 'administration-read', + 'authenticated-user': 'authenticated-user-read', + 'coverage-artifacts': 'actions-read', + 'coverage-download': 'actions-read', + 'coverage-parse': 'actions-read', +}); +exports.APPROVAL_EVIDENCE_REASONS = Object.freeze([ + 'http-400', 'http-401', 'http-403', 'http-404', 'http-409', 'http-422', 'http-429', + 'http-500', 'http-502', 'http-503', 'http-504', 'transport-unavailable', + 'invalid-response', 'invalid-page', 'page-limit', 'artifact-unavailable', 'artifact-invalid', 'artifact-mismatch', +]); + + /***/ }), /***/ 51114: diff --git a/build/github_action/index.js b/build/github_action/index.js index 2c1cbdb8b..12f8eb866 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -38189,6 +38189,8 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.runPullRequestApprovalAction = runPullRequestApprovalAction; +const application_error_presentation_policy_1 = __nccwpck_require__(95067); +const application_error_message_catalog_1 = __nccwpck_require__(64809); const core = __importStar(__nccwpck_require__(75855)); const github = __importStar(__nccwpck_require__(78227)); const observe_pull_request_approval_use_case_1 = __nccwpck_require__(36169); @@ -38238,15 +38240,22 @@ async function runPullRequestApprovalAction(token) { }); const useCase = new observe_pull_request_approval_use_case_1.ObservePullRequestApprovalUseCase(port); const failedPulls = []; + let firstFailure; for (const pullNumber of targetNumbers) { const target = { owner, repository: repo, repositoryId, pullNumber }; const result = await useCase.execute(target); + if (result.failure) { + firstFailure ?? (firstFailure = result.failure); + const diagnostic = (0, application_error_presentation_policy_1.renderApplicationErrorText)(result.failure, (0, application_error_message_catalog_1.resolveStaticApplicationErrorCatalog)(locale).message); + core.info(diagnostic); + core.summary.addRaw(`
${diagnostic.replace(/&/gu, '&').replace(//gu, '>')}
\n`); + } const safeCode = result.decision.code.replace(/[^a-z0-9-]/gu, '').slice(0, 60); core.info(`PR #${pullNumber}: ${result.decision.status} (${safeCode}); assessment ${result.publication}.`); if (result.publication === 'failed' || [ 'invalid-policy', 'policy-unavailable', 'evidence-unavailable', 'target-mismatch', 'pre-submit-unavailable', 'publication-unknown', - ].includes(result.decision.code)) + ].includes(result.decision.code) || result.failure) failedPulls.push(pullNumber); if (result.evidence) { core.summary.addRaw((0, pull_request_approval_presentation_policy_1.renderApprovalRunSummary)({ @@ -38267,6 +38276,8 @@ async function runPullRequestApprovalAction(token) { } } await core.summary.write(); + if (firstFailure) + throw firstFailure; if (failedPulls.length > 0) { throw new Error(`Approval observation needs attention for PR(s) ${failedPulls.join(', ')}; inspect the Job Summary and rerun after recovery.`); } @@ -38816,6 +38827,36 @@ function runAtApplicationErrorBoundary(operation) { } +/***/ }), + +/***/ 50519: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.approvalEvidenceError = approvalEvidenceError; +exports.approvalDiagnosticError = approvalDiagnosticError; +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); +const application_error_1 = __nccwpck_require__(75999); +function approvalEvidenceError(operation, reason) { + return approvalDiagnosticError({ operation, reason, permission: approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS[operation] }); +} +function approvalDiagnosticError(diagnostic) { + const reason = diagnostic.reason; + const code = reason === 'http-401' ? 'authorization.credential-invalid' + : reason === 'http-403' ? 'authorization.denied' + : reason === 'http-404' ? 'provider.not-found' + : reason === 'http-429' ? 'provider.rate-limited' + : reason === 'http-409' ? 'provider.conflict' + : reason === 'transport-unavailable' || /^http-50[0234]$/u.test(reason) ? 'provider.unavailable' + : 'provider.contract-invalid'; + return new application_error_1.ApplicationError(code, `Approval evidence read failed (${diagnostic.operation}, ${reason}).`, { + recovery: { id: 'approval-evidence-read-failed', variables: diagnostic }, + }); +} + + /***/ }), /***/ 72594: @@ -40592,6 +40633,11 @@ const SPANISH_CONTENT = Object.freeze({ }), }); const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'Approval evidence could not be verified: {operation} ({reason}).', + action: 'Inspect {permission} access for the runtime credential and the named evidence response. Correct authorization, missing-resource or schema errors before rerunning; retry transient failures only after provider recovery or rate-limit reset.', + retainedState: 'No approval is authorized by this failed read. Existing PRs, reviews and completed external effects were preserved.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'The changes were rejected during workspace verification.', action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', @@ -40659,6 +40705,11 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'No se pudo verificar la evidencia de aprobación: {operation} ({reason}).', + action: 'Comprueba el acceso {permission} de la credencial de ejecución y la respuesta indicada. Corrige los errores de autorización, recurso o esquema antes de reejecutar; reintenta los fallos transitorios solo tras recuperarse el proveedor o restablecerse el límite.', + retainedState: 'Esta lectura fallida no autoriza ninguna aprobación. Se conservaron las PRs, revisiones y efectos externos completados.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'Los cambios se rechazaron durante la verificación del espacio de trabajo.', action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', @@ -57197,6 +57248,8 @@ function buildCatalogResponseSchema(source, ids, targetLocale = 'en-US') { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ObservePullRequestApprovalUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +const approval_evidence_error_1 = __nccwpck_require__(50519); const pull_request_approval_1 = __nccwpck_require__(8024); const pull_request_approval_policy_1 = __nccwpck_require__(98820); /** Read-first replay and exact-revision guard around the non-atomic GitHub review API. */ @@ -57232,8 +57285,9 @@ class ObservePullRequestApprovalUseCase { try { initial = await this.port.loadEvidence(target); } - catch { + catch (cause) { return { + failure: evidenceFailure(cause), decision: { status: 'blocked', code: 'evidence-unavailable', detail: 'GitHub approval evidence could not be read; rerun this observer.' }, publication: 'not-applicable', }; @@ -57241,7 +57295,10 @@ class ObservePullRequestApprovalUseCase { if (initial.repositoryId !== target.repositoryId || initial.pullNumber !== target.pullNumber) { return { decision: { status: 'blocked', code: 'target-mismatch', detail: 'The resolved PR does not match the bound repository and number.' }, publication: 'not-applicable' }; } - let decision = (0, pull_request_approval_1.decidePullRequestApproval)(policy, initial); + let failure = initial.diagnostics?.[0] ? (0, approval_evidence_error_1.approvalDiagnosticError)(initial.diagnostics[0]) : undefined; + let decision = failure + ? { status: 'blocked', code: 'evidence-unavailable', detail: 'Approval evidence is unavailable; inspect the causal diagnostic in the observer Job Summary.' } + : (0, pull_request_approval_1.decidePullRequestApproval)(policy, initial); let reviewId; if (decision.status === 'eligible') { let current; @@ -57250,9 +57307,16 @@ class ObservePullRequestApprovalUseCase { current = await this.port.loadEvidence(target); currentPolicy = await this.port.readPolicy(target); } - catch { + catch (cause) { + failure = evidenceFailure(cause); + current = undefined; decision = { status: 'blocked', code: 'pre-submit-unavailable', detail: 'Current approval evidence could not be reread before submission.' }; } + if (current?.diagnostics?.length) { + failure = (0, approval_evidence_error_1.approvalDiagnosticError)(current.diagnostics[0]); + current = undefined; + decision = { status: 'blocked', code: 'pre-submit-unavailable', detail: 'Current evidence is unavailable; inspect the observer diagnostic.' }; + } if (current && currentPolicy !== rawPolicy) { decision = { status: 'pending', code: 'superseded', detail: 'The approval policy changed before submission; recheck the current head.' }; } @@ -57294,11 +57358,11 @@ class ObservePullRequestApprovalUseCase { try { // The adapter guards the current head again before editing the single card. await this.port.publishAssessment(target, initial, decision, reviewId); - return { decision, headSha: initial.headSha, evidence: initial, mode: policy.mode, + return { decision, ...(failure ? { failure } : {}), headSha: initial.headSha, evidence: initial, mode: policy.mode, ...(reviewId ? { reviewId } : {}), publication: 'published' }; } catch { - return { decision, headSha: initial.headSha, evidence: initial, mode: policy.mode, + return { decision, ...(failure ? { failure } : {}), headSha: initial.headSha, evidence: initial, mode: policy.mode, ...(reviewId ? { reviewId } : {}), publication: 'failed' }; } } @@ -57314,6 +57378,10 @@ function sameRevisionAndEvidence(left, right) { function approvalReviewBody(headSha) { return `\nCopilot approved this revision (${headSha.slice(0, 7)}) after its configured CI and coverage gates passed and Bugbot reported no blocking findings. GitHub still applies branch rules. Configure a separate human-review requirement if needed; a bot approval may count toward a generic approval minimum.`; } +function evidenceFailure(cause) { + return cause instanceof application_error_1.ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed' + ? cause : (0, approval_evidence_error_1.approvalEvidenceError)('evidence', 'transport-unavailable'); +} /***/ }), @@ -68492,6 +68560,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); const branch_sync_validation_1 = __nccwpck_require__(90981); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'pull-request-link-restored', @@ -68507,6 +68576,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', 'workspace-changes-rejected', + 'approval-evidence-read-failed', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -68698,6 +68768,7 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -68750,6 +68821,12 @@ function normalizeApplicationErrorRecovery(recovery) { && !branch_sync_validation_1.BRANCH_SYNC_VALIDATION_CODES.some(code => code === variables.validationCode)) { throw new TypeError('Application error recovery synchronization validation code is invalid.'); } + if (recovery.id === 'approval-evidence-read-failed' + && (!Object.prototype.hasOwnProperty.call(approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS, variables.operation) + || !approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_REASONS.some(reason => reason === variables.reason) + || approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_PERMISSIONS[variables.operation] !== variables.permission)) { + throw new TypeError('Approval evidence diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -76675,6 +76752,41 @@ function parseApprovalCoverageZip(value) { } +/***/ }), + +/***/ 9958: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.readApprovalEvidence = readApprovalEvidence; +const application_error_1 = __nccwpck_require__(75999); +const approval_evidence_error_1 = __nccwpck_require__(50519); +const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); +/** Terminates provider exceptions; even unknown statuses never interpolate provider text. */ +async function readApprovalEvidence(operation, read) { + try { + return await read(); + } + catch (cause) { + if (cause instanceof application_error_1.ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed') + throw cause; + const error = cause && typeof cause === 'object' ? cause : undefined; + const response = error?.response; + const status = error?.status; + const rateLimited = status === 403 && (response?.headers?.['x-ratelimit-remaining'] === '0' + || typeof response?.headers?.['retry-after'] === 'string' && /^\d{1,8}$/u.test(response.headers['retry-after'])); + const http = typeof status === 'number' ? `http-${status}` : undefined; + const reason = rateLimited ? 'http-429' + : approval_evidence_diagnostic_1.APPROVAL_EVIDENCE_REASONS.some(value => value === http) ? http + : cause instanceof application_error_1.ApplicationError && cause.code === 'provider.contract-invalid' ? 'invalid-response' + : status === undefined ? 'transport-unavailable' : 'invalid-response'; + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, reason); + } +} + + /***/ }), /***/ 55165: @@ -76709,21 +76821,22 @@ exports.BugbotPullRequestRepository = BugbotPullRequestRepository; /***/ }), /***/ 68742: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.readCurrentBaseRevision = readCurrentBaseRevision; +const application_error_1 = __nccwpck_require__(75999); /** PR base metadata can lag a parent push; read the exact native branch ref. */ async function readCurrentBaseRevision(port, target, baseRef) { if (typeof baseRef !== 'string' || baseRef.length === 0 || baseRef.length > 1024 || baseRef.includes('\u0000') || /[\r\n]/u.test(baseRef)) - throw new Error('The PR base ref is invalid.'); + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The PR base ref is invalid.'); const { data } = await port.getRef({ owner: target.owner, repo: target.repository, ref: `heads/${baseRef}` }); if (data?.ref !== `refs/heads/${baseRef}` || data.object?.type !== 'commit' || typeof data.object.sha !== 'string' || !/^[a-f0-9]{40}$/u.test(data.object.sha)) - throw new Error('The PR base ref cannot be verified.'); + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The PR base ref cannot be verified.'); return data.object.sha; } @@ -76771,6 +76884,9 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.PullRequestApprovalRepository = void 0; exports.readEffectiveApprovalPolicy = readEffectiveApprovalPolicy; +const approval_evidence_read_1 = __nccwpck_require__(9958); +const approval_evidence_error_1 = __nccwpck_require__(50519); +const application_error_1 = __nccwpck_require__(75999); const current_base_revision_query_1 = __nccwpck_require__(68742); const logging_ports_1 = __nccwpck_require__(6152); const github = __importStar(__nccwpck_require__(78227)); @@ -76803,34 +76919,58 @@ class PullRequestApprovalRepository { return readEffectiveApprovalPolicy(this.octokit, target); } async loadEvidence(target) { - const { data: pull } = await this.octokit.rest.pulls.get({ - owner: target.owner, repo: target.repository, pull_number: target.pullNumber, + const diagnostics = []; + const { data: pull } = await (0, approval_evidence_read_1.readApprovalEvidence)('pull-request', async () => { + const response = await this.octokit.rest.pulls.get({ owner: target.owner, repo: target.repository, pull_number: target.pullNumber }); + if (!response.data?.head || !response.data?.base || typeof response.data.head.sha !== 'string' + || typeof response.data.head.ref !== 'string' || !Number.isSafeInteger(response.data.number)) { + throw (0, approval_evidence_error_1.approvalEvidenceError)('pull-request', 'invalid-response'); + } + return response; }); const headSha = pull.head.sha; const baseSha = await this.readCurrentBaseSha(target, pull.base.ref); - const policy = (0, pull_request_approval_policy_1.parsePullRequestApprovalPolicy)(await this.readPolicy(target)); + const policy = await (0, approval_evidence_read_1.readApprovalEvidence)('policy', async () => { + const raw = await this.readPolicy(target); + try { + return (0, pull_request_approval_policy_1.parsePullRequestApprovalPolicy)(raw); + } + catch { + throw (0, approval_evidence_error_1.approvalEvidenceError)('policy', 'invalid-response'); + } + }); let testedMergeSha; if (pull.mergeable === true && /^[a-f0-9]{40}$/iu.test(pull.merge_commit_sha ?? '')) { try { - const merge = await this.octokit.rest.git.getCommit({ - owner: target.owner, repo: target.repository, commit_sha: pull.merge_commit_sha, + const merge = await (0, approval_evidence_read_1.readApprovalEvidence)('merge-commit', async () => { + const response = await this.octokit.rest.git.getCommit({ owner: target.owner, repo: target.repository, commit_sha: pull.merge_commit_sha }); + if (!Array.isArray(response.data?.parents)) + throw (0, approval_evidence_error_1.approvalEvidenceError)('merge-commit', 'invalid-response'); + return response; }); if (merge.data.parents[0]?.sha === baseSha && merge.data.parents[1]?.sha === headSha && merge.data.parents.length === 2) testedMergeSha = pull.merge_commit_sha; } - catch { /* Missing or unreadable merge parentage is non-authorizing. */ } + catch (cause) { + collectDiagnostic(diagnostics, cause); + } } const [files, reviews, comments, headChecks, mergeChecks, headRuns, mergeRuns, rules, user] = await Promise.all([ - this.allPages(this.octokit.rest.pulls.listFiles, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), - this.allPages(this.octokit.rest.pulls.listReviews, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), - this.allPages(this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber }), - this.allPages(this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: headSha, filter: 'all' }), - testedMergeSha ? this.allPages(this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: testedMergeSha, filter: 'all' }) : Promise.resolve([]), - this.allPages(this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: headSha }), - testedMergeSha ? this.allPages(this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: testedMergeSha }) : Promise.resolve([]), - this.effectiveRules(target.owner, target.repository, pull.base.ref), - this.octokit.rest.users.getAuthenticated(), + this.allPages('files', this.octokit.rest.pulls.listFiles, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), + this.allPages('reviews', this.octokit.rest.pulls.listReviews, { owner: target.owner, repo: target.repository, pull_number: target.pullNumber }), + this.allPages('comments', this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber }), + this.allPages('head-checks', this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: headSha, filter: 'all' }), + testedMergeSha ? this.allPages('merge-checks', this.octokit.rest.checks.listForRef, { owner: target.owner, repo: target.repository, ref: testedMergeSha, filter: 'all' }) : Promise.resolve([]), + this.allPages('head-runs', this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: headSha }), + testedMergeSha ? this.allPages('merge-runs', this.octokit.rest.actions.listWorkflowRunsForRepo, { owner: target.owner, repo: target.repository, head_sha: testedMergeSha }) : Promise.resolve([]), + this.effectiveRules(target.owner, target.repository, pull.base.ref, diagnostics), + (0, approval_evidence_read_1.readApprovalEvidence)('authenticated-user', async () => { + const response = await this.octokit.rest.users.getAuthenticated(); + if (!Number.isSafeInteger(response.data?.id) || response.data.id < 1) + throw (0, approval_evidence_error_1.approvalEvidenceError)('authenticated-user', 'invalid-response'); + return response; + }), ]); const branch = Object.entries(this.settings.branchPrefixes) .find(([, prefix]) => pull.head.ref.startsWith(`${prefix}/`)); @@ -76851,7 +76991,7 @@ class PullRequestApprovalRepository { }); const currentJobs = await Promise.all(latestRuns.map(async (run) => ({ run, - jobs: await this.allPages(this.octokit.rest.actions.listJobsForWorkflowRunAttempt, { + jobs: await this.allPages('jobs', this.octokit.rest.actions.listJobsForWorkflowRunAttempt, { owner: target.owner, repo: target.repository, run_id: run.id, attempt_number: run.run_attempt, }), }))); @@ -76882,9 +77022,10 @@ class PullRequestApprovalRepository { submittedAt: review.submitted_at ?? '', })); const numericCoverage = policy.coverage.mode === 'numeric' - ? await this.numericCoverage(target, policy.coverage.artifactWorkflowName, headSha, baseSha, testedMergeSha, workflowRuns) + ? await this.numericCoverage(target, policy.coverage.artifactWorkflowName, headSha, baseSha, testedMergeSha, workflowRuns, diagnostics) : undefined; return { + ...(diagnostics.length ? { diagnostics: Object.freeze(diagnostics) } : {}), repositoryId: pull.base.repo?.id ?? 0, pullNumber: pull.number, headSha, @@ -76918,7 +77059,7 @@ class PullRequestApprovalRepository { ...(numericCoverage ? { numericCoverage } : {}), }; } - async numericCoverage(target, workflowName, headSha, baseSha, testedMergeSha, workflowRuns) { + async numericCoverage(target, workflowName, headSha, baseSha, testedMergeSha, workflowRuns, diagnostics) { const candidates = workflowRuns.filter(run => run.name === workflowName && (run.head_sha === headSha || run.head_sha === testedMergeSha)); const mergeCandidates = candidates.filter(run => run.head_sha === testedMergeSha); @@ -76927,25 +77068,27 @@ class PullRequestApprovalRepository { if (!run || run.conclusion !== 'success' || run.status !== 'completed') return undefined; try { - const artifacts = await this.allPages(this.octokit.rest.actions.listWorkflowRunArtifacts, { + const artifacts = await this.allPages('coverage-artifacts', this.octokit.rest.actions.listWorkflowRunArtifacts, { owner: target.owner, repo: target.repository, run_id: run.id, }); const matches = artifacts.filter(artifact => artifact.name === 'copilot-diff-coverage-v1' && !artifact.expired); if (matches.length !== 1 || matches[0].size_in_bytes > 64 * 1024) - return undefined; - const response = await this.octokit.rest.actions.downloadArtifact({ + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-artifacts', 'artifact-unavailable'); + const response = await (0, approval_evidence_read_1.readApprovalEvidence)('coverage-download', async () => this.octokit.rest.actions.downloadArtifact({ owner: target.owner, repo: target.repository, artifact_id: matches[0].id, archive_format: 'zip', - }); + })); const data = response.data; const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : typeof data === 'string' ? Buffer.from(data, 'binary') : undefined; const attestation = bytes ? (0, approval_coverage_artifact_1.parseApprovalCoverageZip)(bytes) : undefined; - if (!attestation || attestation.repositoryId !== target.repositoryId + if (!attestation) + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-parse', 'artifact-invalid'); + if (attestation.repositoryId !== target.repositoryId || attestation.pullNumber !== target.pullNumber || attestation.headSha !== headSha || attestation.baseSha !== baseSha || attestation.workflowRunId !== run.id || attestation.workflowRunAttempt !== run.run_attempt) - return undefined; + throw (0, approval_evidence_error_1.approvalEvidenceError)('coverage-parse', 'artifact-mismatch'); return { coveredChangedLines: attestation.coveredChangedLines, totalChangedLines: attestation.totalChangedLines, @@ -76953,7 +77096,8 @@ class PullRequestApprovalRepository { baseSha, }; } - catch { + catch (cause) { + collectDiagnostic(diagnostics, cause); return undefined; } } @@ -76979,7 +77123,7 @@ class PullRequestApprovalRepository { throw new Error('PR head or base changed before assessment publication.'); } const user = await this.octokit.rest.users.getAuthenticated(); - const comments = await this.allPages(this.octokit.rest.issues.listComments, { + const comments = await this.allPages('comments', this.octokit.rest.issues.listComments, { owner: target.owner, repo: target.repository, issue_number: target.pullNumber, }); const card = [...comments].reverse().find(comment => comment.user?.id === user.data.id @@ -77025,24 +77169,26 @@ class PullRequestApprovalRepository { } } async readCurrentBaseSha(target, baseRef) { - return (0, current_base_revision_query_1.readCurrentBaseRevision)(this.octokit.rest.git, target, baseRef); + return (0, approval_evidence_read_1.readApprovalEvidence)('base-ref', () => (0, current_base_revision_query_1.readCurrentBaseRevision)(this.octokit.rest.git, target, baseRef)); } - async effectiveRules(owner, repo, branch) { + async effectiveRules(owner, repo, branch, diagnostics) { try { - const rulesResponse = await this.octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner, repo, branch }); + const rulesResponse = await (0, approval_evidence_read_1.readApprovalEvidence)('rules', async () => this.octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner, repo, branch })); if (!Array.isArray(rulesResponse.data) || rulesResponse.data.some((rule) => !record(rule) || typeof record(rule)?.type !== 'string' || record(rule)?.type === '')) { - throw new Error('GitHub returned an invalid effective rules inventory.'); + throw (0, approval_evidence_error_1.approvalEvidenceError)('rules', 'invalid-response'); } const rules = rulesResponse.data; let classic; try { const response = await this.octokit.rest.repos.getBranchProtection({ owner, repo, branch }); + if (!record(response.data)) + throw (0, approval_evidence_error_1.approvalEvidenceError)('classic-protection', 'invalid-response'); classic = response.data; } catch (error) { if (statusOf(error) !== 404) - throw error; + await (0, approval_evidence_read_1.readApprovalEvidence)('classic-protection', async () => { throw error; }); } const reviewRules = rules.filter(rule => rule.type === 'pull_request'); const classicReview = classic?.required_pull_request_reviews; @@ -77068,23 +77214,26 @@ class PullRequestApprovalRepository { } return { readable: true, dismissesStaleReviews: hasStaleRule && allStale, requiredChecks: [...requiredChecks] }; } - catch { + catch (cause) { + collectDiagnostic(diagnostics, cause); return { readable: false, dismissesStaleReviews: false, requiredChecks: [] }; } } - async allPages(method, params) { - const result = []; - for await (const response of this.octokit.paginate.iterator(method, { ...params, per_page: 100 })) { - const page = Array.isArray(response.data) ? response.data - : record(response.data)?.check_runs ?? record(response.data)?.workflow_runs - ?? record(response.data)?.artifacts ?? record(response.data)?.jobs; - if (!Array.isArray(page)) - throw new Error('GitHub pagination returned an invalid page.'); - result.push(...page); - if (result.length > 3000) - throw new Error('Approval evidence pagination exceeded the safe bound.'); - } - return result; + async allPages(operation, method, params) { + return (0, approval_evidence_read_1.readApprovalEvidence)(operation, async () => { + const result = []; + for await (const response of this.octokit.paginate.iterator(method, { ...params, per_page: 100 })) { + const page = Array.isArray(response.data) ? response.data + : record(response.data)?.check_runs ?? record(response.data)?.workflow_runs + ?? record(response.data)?.artifacts ?? record(response.data)?.jobs; + if (!Array.isArray(page)) + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, 'invalid-page'); + result.push(...page); + if (result.length > 3000) + throw (0, approval_evidence_error_1.approvalEvidenceError)(operation, 'page-limit'); + } + return result; + }); } } exports.PullRequestApprovalRepository = PullRequestApprovalRepository; @@ -77145,6 +77294,14 @@ async function readEffectiveApprovalPolicy(octokit, target) { throw error; } } +function collectDiagnostic(diagnostics, cause) { + if (cause instanceof application_error_1.ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed') { + diagnostics.push(cause.recovery.variables); + } + else { + diagnostics.push((0, approval_evidence_error_1.approvalEvidenceError)('evidence', 'invalid-response').recovery.variables); + } +} /***/ }), @@ -79385,6 +79542,44 @@ class AgentResponseLengthFailure extends Error { exports.AgentResponseLengthFailure = AgentResponseLengthFailure; +/***/ }), + +/***/ 98205: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.APPROVAL_EVIDENCE_REASONS = exports.APPROVAL_EVIDENCE_PERMISSIONS = void 0; +/** Closed vocabulary: no provider text, URLs, headers, policy values or credentials. */ +exports.APPROVAL_EVIDENCE_PERMISSIONS = Object.freeze({ + 'evidence': 'repository-read', + 'pull-request': 'pull-requests-read', + 'base-ref': 'contents-read', + 'merge-commit': 'contents-read', + 'policy': 'actions-variables-read', + 'files': 'pull-requests-read', + 'reviews': 'pull-requests-read', + 'comments': 'issues-read', + 'head-checks': 'checks-read', + 'merge-checks': 'checks-read', + 'head-runs': 'actions-read', + 'merge-runs': 'actions-read', + 'jobs': 'actions-read', + 'rules': 'metadata-read', + 'classic-protection': 'administration-read', + 'authenticated-user': 'authenticated-user-read', + 'coverage-artifacts': 'actions-read', + 'coverage-download': 'actions-read', + 'coverage-parse': 'actions-read', +}); +exports.APPROVAL_EVIDENCE_REASONS = Object.freeze([ + 'http-400', 'http-401', 'http-403', 'http-404', 'http-409', 'http-422', 'http-429', + 'http-500', 'http-502', 'http-503', 'http-504', 'transport-unavailable', + 'invalid-response', 'invalid-page', 'page-limit', 'artifact-unavailable', 'artifact-invalid', 'artifact-mismatch', +]); + + /***/ }), /***/ 51114: diff --git a/docs/pull-requests/guarded-approval.mdx b/docs/pull-requests/guarded-approval.mdx index 8031e55b7..e921a5b39 100644 --- a/docs/pull-requests/guarded-approval.mdx +++ b/docs/pull-requests/guarded-approval.mdx @@ -168,3 +168,33 @@ recovery path. This diagnostic does not change credentials, precedence, fallback or approval authority. The dedicated observer initializes runtime logging before reading policy, including manual rechecks. Safe policy-read diagnostics do not require the general agent pipeline or debug mode. + +## Evidence read failures + +If an observer reports `evidence-unavailable`, open its **Job Summary**. The +causal diagnostic identifies a fixed operation, reason and relevant access +category, even when there is no assessment card. For example, +`jobs (http-403)` with `actions-read` means the workflow credential could not +read the producer's jobs. Successful application CI or a credential-health +check alone does not prove access to that endpoint. + +| Diagnostic | Next action | +| --- | --- | +| `http-401` / `http-403` | Check the runtime credential and the named endpoint access; correct the specific denial before rerunning. | +| `http-404` | Verify resource identity and visibility under that same credential. A private resource can be hidden by authorization. | +| `http-429`, supported 5xx or `transport-unavailable` | Wait for provider recovery or rate-limit reset, then make a bounded new observation. | +| `invalid-response`, `invalid-page` or `page-limit` | Inspect the integration or bounded inventory; repeating the same invalid evidence does not authorize approval. | +| `coverage-artifacts`, `coverage-download`, `coverage-parse` | Inspect the fixed `copilot-diff-coverage-v1` producer artifact, download access, ZIP/schema and current PR/run provenance. Ordinary JUnit or coverage XML does not replace this attestation. | + +The error code, retryability and correlation ID survive the Action error boundary. +Operation and permission identifiers stay stable; explanatory recovery is +available in English and Spanish. Diagnostics omit provider messages, response +bodies, headers, credentials and policy values. No missing evidence is promoted +to approval. A final evidence or policy read failure also stops submission. + +A missing classic protection record (404) is supported absence; it does not +prove safe effective rules. Current merge-ref CI remains bound to the live base +branch and head even when the PR's cached base SHA is historical. Existing PRs, +reviews and completed effects remain retained. Correct the named problem before +rerunning **Copilot - Pull Request Approval** with the PR number; do not broaden +all token permissions or repeatedly retry an unchanged permanent failure. diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index f095d9d6f..9700e45b8 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -868,6 +868,21 @@ } ], "successMessage": "setup runner profiles/parity coverage: PASS (pure decisions 100%; each adapter/presenter 95% lines/statements, 90% branches/functions)" + }, + { + "name": "Approval evidence diagnostics", + "missingEntryLabel": "approval evidence diagnostics", + "rules": [ + { + "files": [ + "src/application/errors/approval_evidence_error.ts", + "src/data/repository/pull_request/approval_evidence_read.ts" + ], + "mode": "aggregate", + "thresholdProfile": "default" + } + ], + "successMessage": "approval evidence diagnostics coverage: PASS (95% lines/statements, 90% branches/functions)" } ] } diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 232bbd438..93d90e00a 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -30,7 +30,7 @@ debt or convert unknown historic intent into a design decision. | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 44 paths · 2026-10-19 | | `configurable-issue-workflows` | Implemented | Select one canonical set of issue workflows and enforce its forms, dependencies, branch policy, runtime admission, migration, and diagnosis | [Configurable issue workflows and fail-closed admission](./configurable-issue-workflows-and-admission.md) | 89 paths · 2026-10-13 | | `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 | 90 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-20 | ## Evidence map @@ -257,12 +257,12 @@ debt or convert unknown historic intent into a design decision. ### `guarded-pull-request-approval` — Guarded pull-request approval and setup readiness - Owner: Copilot maintainers -- Last verified: 2026-10-17 +- Last verified: 2026-10-20 - Specifications: [`specs/guarded-pull-request-approval.md`](./guarded-pull-request-approval.md) · [`specs/guarded-pull-request-approval-setup-and-doctor.md`](./guarded-pull-request-approval-setup-and-doctor.md) · [`specs/guarded-pull-request-approval-status.md`](./guarded-pull-request-approval-status.md) - Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`.github/workflows/copilot_pull_request_approval.yml`](../.github/workflows/copilot_pull_request_approval.yml) · [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) · [`setup/workflows/copilot_pull_request_review_state.yml`](../setup/workflows/copilot_pull_request_review_state.yml) · [`setup/workflows/copilot_pull_request_approval.yml`](../setup/workflows/copilot_pull_request_approval.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/pull_request_approval_action.ts`](../src/actions/pull_request_approval_action.ts) · [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) · [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) · [`src/infrastructure/composition/pull_request_status_composition_root.ts`](../src/infrastructure/composition/pull_request_status_composition_root.ts) -- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/pull_request_approval.ts`](../src/domain/pull_request_approval.ts) · [`src/domain/pull_request_approval_policy.ts`](../src/domain/pull_request_approval_policy.ts) · [`src/domain/setup_approval_workflow.ts`](../src/domain/setup_approval_workflow.ts) · [`src/domain/setup_workflow_catalog.ts`](../src/domain/setup_workflow_catalog.ts) · [`src/application/policies/bot_pull_request_analysis_policy.ts`](../src/application/policies/bot_pull_request_analysis_policy.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/policies/pull_request_approval_presentation_policy.ts`](../src/application/policies/pull_request_approval_presentation_policy.ts) · [`src/application/policies/pull_request_readiness_policy.ts`](../src/application/policies/pull_request_readiness_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/approval_doctor_message_catalog.ts`](../src/application/policies/approval_doctor_message_catalog.ts) · [`src/application/policies/setup_approval_doctor_policy.ts`](../src/application/policies/setup_approval_doctor_policy.ts) · [`src/application/policies/setup_configuration_defaults.ts`](../src/application/policies/setup_configuration_defaults.ts) · [`src/application/policies/setup_configuration_plan.ts`](../src/application/policies/setup_configuration_plan.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts`](../src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/data/repository/pull_request/pull_request_review_comment_command_repository.ts`](../src/data/repository/pull_request/pull_request_review_comment_command_repository.ts) · [`src/data/repository/pull_request/pull_request_approval_repository.ts`](../src/data/repository/pull_request/pull_request_approval_repository.ts) · [`src/data/repository/pull_request/approval_coverage_artifact.ts`](../src/data/repository/pull_request/approval_coverage_artifact.ts) · [`src/infrastructure/setup_approval_readiness_adapter.ts`](../src/infrastructure/setup_approval_readiness_adapter.ts) · [`src/utils/setup_files.ts`](../src/utils/setup_files.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`web/src/i18n/permissions/en.ts`](../web/src/i18n/permissions/en.ts) · [`web/src/i18n/permissions/es.ts`](../web/src/i18n/permissions/es.ts) · [`web/src/i18n/permissions/fr.ts`](../web/src/i18n/permissions/fr.ts) · [`web/src/i18n/permissions/pt.ts`](../web/src/i18n/permissions/pt.ts) · [`src/application/ports/pull_request_status_read_ports.ts`](../src/application/ports/pull_request_status_read_ports.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/domain/copilot_command.ts`](../src/domain/copilot_command.ts) · [`src/data/repository/pull_request/pull_request_status_query_repository.ts`](../src/data/repository/pull_request/pull_request_status_query_repository.ts) · [`src/infrastructure/composition/pull_request_status_composition_root.ts`](../src/infrastructure/composition/pull_request_status_composition_root.ts) · [`src/domain/action_input_source.ts`](../src/domain/action_input_source.ts) · [`src/domain/input_values_policy.ts`](../src/domain/input_values_policy.ts) · [`src/infrastructure/github_action_input.ts`](../src/infrastructure/github_action_input.ts) · [`src/application/usecases/read_pull_request_status_readiness_workflow.ts`](../src/application/usecases/read_pull_request_status_readiness_workflow.ts) · [`src/application/policies/pull_request_readiness_status_policy.ts`](../src/application/policies/pull_request_readiness_status_policy.ts) · [`src/data/repository/pull_request/current_base_revision_query.ts`](../src/data/repository/pull_request/current_base_revision_query.ts) -- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/bot_pull_request_analysis_policy.test.ts`](../src/application/policies/__tests__/bot_pull_request_analysis_policy.test.ts) · [`src/application/policies/__tests__/pull_request_approval_presentation_policy.test.ts`](../src/application/policies/__tests__/pull_request_approval_presentation_policy.test.ts) · [`src/application/policies/__tests__/pull_request_readiness_policy.test.ts`](../src/application/policies/__tests__/pull_request_readiness_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/policies/__tests__/setup_approval_doctor_policy.test.ts`](../src/application/policies/__tests__/setup_approval_doctor_policy.test.ts) · [`src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts`](../src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/domain/__tests__/pull_request_approval.test.ts`](../src/domain/__tests__/pull_request_approval.test.ts) · [`src/domain/__tests__/setup_approval_workflow.test.ts`](../src/domain/__tests__/setup_approval_workflow.test.ts) · [`src/actions/__tests__/pull_request_approval_action.test.ts`](../src/actions/__tests__/pull_request_approval_action.test.ts) · [`src/actions/__tests__/pull_request_approval_logging.test.ts`](../src/actions/__tests__/pull_request_approval_logging.test.ts) · [`src/data/repository/pull_request/__tests__/approval_coverage_artifact.test.ts`](../src/data/repository/pull_request/__tests__/approval_coverage_artifact.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_approval_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_approval_repository.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_review_comment_command_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_review_comment_command_repository.test.ts) · [`src/infrastructure/__tests__/setup_approval_readiness_adapter.test.ts`](../src/infrastructure/__tests__/setup_approval_readiness_adapter.test.ts) · [`src/utils/__tests__/setup_files.test.ts`](../src/utils/__tests__/setup_files.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/usecases/__tests__/pull_request_readiness_workflow.test.ts`](../src/application/usecases/__tests__/pull_request_readiness_workflow.test.ts) · [`src/application/policies/__tests__/pull_request_readiness_presentation.test.ts`](../src/application/policies/__tests__/pull_request_readiness_presentation.test.ts) · [`src/application/usecases/__tests__/pull_request_readiness_security_integration.test.ts`](../src/application/usecases/__tests__/pull_request_readiness_security_integration.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_status_query_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_status_query_repository.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_status_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_status_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/application/policies/__tests__/setup_approval_operator_permissions.test.ts`](../src/application/policies/__tests__/setup_approval_operator_permissions.test.ts) · [`src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts`](../src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts) +- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/pull_request_approval.ts`](../src/domain/pull_request_approval.ts) · [`src/domain/pull_request_approval_policy.ts`](../src/domain/pull_request_approval_policy.ts) · [`src/domain/setup_approval_workflow.ts`](../src/domain/setup_approval_workflow.ts) · [`src/domain/setup_workflow_catalog.ts`](../src/domain/setup_workflow_catalog.ts) · [`src/application/policies/bot_pull_request_analysis_policy.ts`](../src/application/policies/bot_pull_request_analysis_policy.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/policies/pull_request_approval_presentation_policy.ts`](../src/application/policies/pull_request_approval_presentation_policy.ts) · [`src/application/policies/pull_request_readiness_policy.ts`](../src/application/policies/pull_request_readiness_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/approval_doctor_message_catalog.ts`](../src/application/policies/approval_doctor_message_catalog.ts) · [`src/application/policies/setup_approval_doctor_policy.ts`](../src/application/policies/setup_approval_doctor_policy.ts) · [`src/application/policies/setup_configuration_defaults.ts`](../src/application/policies/setup_configuration_defaults.ts) · [`src/application/policies/setup_configuration_plan.ts`](../src/application/policies/setup_configuration_plan.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts`](../src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/data/repository/pull_request/pull_request_review_comment_command_repository.ts`](../src/data/repository/pull_request/pull_request_review_comment_command_repository.ts) · [`src/data/repository/pull_request/pull_request_approval_repository.ts`](../src/data/repository/pull_request/pull_request_approval_repository.ts) · [`src/data/repository/pull_request/approval_coverage_artifact.ts`](../src/data/repository/pull_request/approval_coverage_artifact.ts) · [`src/infrastructure/setup_approval_readiness_adapter.ts`](../src/infrastructure/setup_approval_readiness_adapter.ts) · [`src/utils/setup_files.ts`](../src/utils/setup_files.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`web/src/i18n/permissions/en.ts`](../web/src/i18n/permissions/en.ts) · [`web/src/i18n/permissions/es.ts`](../web/src/i18n/permissions/es.ts) · [`web/src/i18n/permissions/fr.ts`](../web/src/i18n/permissions/fr.ts) · [`web/src/i18n/permissions/pt.ts`](../web/src/i18n/permissions/pt.ts) · [`src/application/ports/pull_request_status_read_ports.ts`](../src/application/ports/pull_request_status_read_ports.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/domain/copilot_command.ts`](../src/domain/copilot_command.ts) · [`src/data/repository/pull_request/pull_request_status_query_repository.ts`](../src/data/repository/pull_request/pull_request_status_query_repository.ts) · [`src/infrastructure/composition/pull_request_status_composition_root.ts`](../src/infrastructure/composition/pull_request_status_composition_root.ts) · [`src/domain/action_input_source.ts`](../src/domain/action_input_source.ts) · [`src/domain/input_values_policy.ts`](../src/domain/input_values_policy.ts) · [`src/infrastructure/github_action_input.ts`](../src/infrastructure/github_action_input.ts) · [`src/application/usecases/read_pull_request_status_readiness_workflow.ts`](../src/application/usecases/read_pull_request_status_readiness_workflow.ts) · [`src/application/policies/pull_request_readiness_status_policy.ts`](../src/application/policies/pull_request_readiness_status_policy.ts) · [`src/data/repository/pull_request/current_base_revision_query.ts`](../src/data/repository/pull_request/current_base_revision_query.ts) · [`src/domain/approval_evidence_diagnostic.ts`](../src/domain/approval_evidence_diagnostic.ts) · [`src/application/errors/approval_evidence_error.ts`](../src/application/errors/approval_evidence_error.ts) · [`src/data/repository/pull_request/approval_evidence_read.ts`](../src/data/repository/pull_request/approval_evidence_read.ts) +- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/bot_pull_request_analysis_policy.test.ts`](../src/application/policies/__tests__/bot_pull_request_analysis_policy.test.ts) · [`src/application/policies/__tests__/pull_request_approval_presentation_policy.test.ts`](../src/application/policies/__tests__/pull_request_approval_presentation_policy.test.ts) · [`src/application/policies/__tests__/pull_request_readiness_policy.test.ts`](../src/application/policies/__tests__/pull_request_readiness_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/policies/__tests__/setup_approval_doctor_policy.test.ts`](../src/application/policies/__tests__/setup_approval_doctor_policy.test.ts) · [`src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts`](../src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/domain/__tests__/pull_request_approval.test.ts`](../src/domain/__tests__/pull_request_approval.test.ts) · [`src/domain/__tests__/setup_approval_workflow.test.ts`](../src/domain/__tests__/setup_approval_workflow.test.ts) · [`src/actions/__tests__/pull_request_approval_action.test.ts`](../src/actions/__tests__/pull_request_approval_action.test.ts) · [`src/actions/__tests__/pull_request_approval_logging.test.ts`](../src/actions/__tests__/pull_request_approval_logging.test.ts) · [`src/data/repository/pull_request/__tests__/approval_coverage_artifact.test.ts`](../src/data/repository/pull_request/__tests__/approval_coverage_artifact.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_approval_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_approval_repository.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_review_comment_command_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_review_comment_command_repository.test.ts) · [`src/infrastructure/__tests__/setup_approval_readiness_adapter.test.ts`](../src/infrastructure/__tests__/setup_approval_readiness_adapter.test.ts) · [`src/utils/__tests__/setup_files.test.ts`](../src/utils/__tests__/setup_files.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/usecases/__tests__/pull_request_readiness_workflow.test.ts`](../src/application/usecases/__tests__/pull_request_readiness_workflow.test.ts) · [`src/application/policies/__tests__/pull_request_readiness_presentation.test.ts`](../src/application/policies/__tests__/pull_request_readiness_presentation.test.ts) · [`src/application/usecases/__tests__/pull_request_readiness_security_integration.test.ts`](../src/application/usecases/__tests__/pull_request_readiness_security_integration.test.ts) · [`src/data/repository/pull_request/__tests__/pull_request_status_query_repository.test.ts`](../src/data/repository/pull_request/__tests__/pull_request_status_query_repository.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_status_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_status_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/application/policies/__tests__/setup_approval_operator_permissions.test.ts`](../src/application/policies/__tests__/setup_approval_operator_permissions.test.ts) · [`src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts`](../src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts) · [`src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts`](../src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts) - User documentation: [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) · [`docs/pull-requests/guarded-approval.mdx`](../docs/pull-requests/guarded-approval.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/bugbot/configuration.mdx`](../docs/bugbot/configuration.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) ## Maintenance contract diff --git a/specs/catalog.json b/specs/catalog.json index a7b99d743..6adaf8897 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -2481,7 +2481,7 @@ "status": "proposed", "scope": "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", "owner": "Copilot maintainers", - "lastVerified": "2026-10-17", + "lastVerified": "2026-10-20", "specs": [ "specs/guarded-pull-request-approval.md", "specs/guarded-pull-request-approval-setup-and-doctor.md", @@ -2543,7 +2543,10 @@ "src/infrastructure/github_action_input.ts", "src/application/usecases/read_pull_request_status_readiness_workflow.ts", "src/application/policies/pull_request_readiness_status_policy.ts", - "src/data/repository/pull_request/current_base_revision_query.ts" + "src/data/repository/pull_request/current_base_revision_query.ts", + "src/domain/approval_evidence_diagnostic.ts", + "src/application/errors/approval_evidence_error.ts", + "src/data/repository/pull_request/approval_evidence_read.ts" ], "tests": [ "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", @@ -2574,7 +2577,8 @@ "src/infrastructure/composition/__tests__/pull_request_status_composition_root.test.ts", "src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts", "src/application/policies/__tests__/setup_approval_operator_permissions.test.ts", - "src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts" + "src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts", + "src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts" ], "documentation": [ "docs/authentication.mdx", diff --git a/specs/guarded-pull-request-approval.md b/specs/guarded-pull-request-approval.md index d9abd3c15..dc4d1b1a5 100644 --- a/specs/guarded-pull-request-approval.md +++ b/specs/guarded-pull-request-approval.md @@ -397,3 +397,81 @@ Native observer run `38176191371` reported the closed repository HTTP 403 diagno The same live PR still reported cached `base.sha=c47f612f3fceac6ae54908f62bdad9977da06a4d` while GitHub's native `refs/heads/develop` was `481d86a2df8f925d767165a75d3ec404fbee1a62`. Repeated PR reads therefore could not detect a base push. The adapter now reads and validates that native ref for evidence and again before assessment publication. Merge parentage and numeric attestations use that verified SHA; branch retargeting, denied reads and malformed identities withhold publication. Eighteen new SDK cases exercise these boundaries, raising the distinct budget from 171 to 189 without changing coverage thresholds or configuration. Full local verification passed 566 suites and 7,502 tests with 35 configured skips (7,537 total). The changed repository adapter reaches 100% lines/statements/functions and 91.94% branches, above its unchanged thresholds. A separate live branch-sync verification failure exposed branch-specific numeric literals in the communication-budget test; those four existing cases now assert the same fixed required minima, exact retained row sums and deliberate allocation mismatch without duplicating mutable branch totals. Their focused suite, typecheck, lint, specification and documentation validators pass; this fixture correction does not change runtime validation or add to this SDD's case count. Native revalidation of the new base-ref behavior remains required after publication. + +### Causal evidence diagnostics — #619 (2026-10-20) + +Observed evidence: [#619](https://github.com/vypdev/copilot/issues/619) reports +released 3.9.1 observers failing before assessment after successful consumer CI. +The endpoint failure remains unknown; the defect established by source is the +loss of the cause in the evidence catch and the generic Action exception. + +The adapter MUST translate evidence failures into the closed operation/reason/ +permission vocabulary in `approval_evidence_diagnostic.ts`. The application +MUST retain that semantic failure, its retryability and correlation ID through +Action completion and JSON serialization. The Job Summary and final failure +MUST identify the operation even when no assessment can be published. Missing +facts MUST remain non-authorizing. Failed optional reads retain independent +facts plus diagnostics; the observer blocks approval when any required read +has failed, including its final evidence/policy reread. + +```text +trusted observer -> adapter reads current evidence -> sanitized causal failure + -> blocked assessment/use-case result -> Job Summary -> same semantic Action error +``` + +Text equivalent: the adapter terminates provider exceptions; application and +presentation consume fixed semantic facts, never raw provider messages. No +provider DTO enters the application port. Existing architecture, raw-error and +credential boundary tests enforce that dependency direction. The diagnostic +adds no durable state or configurable input, no new comment, and no permission. +Read bounds, producer selection, live base authority and native approval fences +remain fixed. Existing configuration precedence and migration are unchanged. + +Representative operator output (technical operation identifiers are stable in +both English and Spanish): + +```text +PR #190: blocked (evidence-unavailable); assessment not-applicable. +Impact: Approval evidence could not be verified: jobs (http-403). +Error code: authorization.denied +Action: Inspect actions-read access for the runtime credential and the named evidence response. +Retained state: No approval is authorized by this failed read. Existing PRs, reviews and completed external effects were preserved. +Retryable: No +``` + +Authorization/resource/schema failures require correction before another run; +transport, 409, rate-limit and supported 5xx failures permit a bounded new +observation after recovery. A 403 with native rate-limit headers is classified +as rate-limited without rendering headers. Classic protection 404 remains a +legitimate absence; it MUST NOT synthesize a read failure or safe stale-review +rules. Coverage diagnostics identify the fixed artifact collection, download, +ZIP/schema or provenance phase; source content, arbitrary artifact names, +headers, URLs, policy values and provider text never appear in errors. Pending +producers and complete observations retain the existing pending/completed UI. +Partial native effects remain retained; this change never retries a review POST. + +Allocate **40 additional distinct cases**, separate from preceding budgets: +20 classification/serialization/localization/forgery cases, 14 SDK inventory, +rules, coverage and current-merge cases, four application final-read/fail-closed +cases and two Action boundary cases. Existing raw-message assertions migrate to +semantic operation/reason assertions. Repository coverage thresholds remain +90% lines/statements, 88% functions and 82% branches; the new diagnostic mapping +and adapter helper require 95% lines/statements and 90% branches/functions. +Deterministic SDK fixtures replace network calls and waits. Review the rendered +English/Spanish Job Summary text for readable recovery and intact machine IDs. + +Acceptance and traceability: each failing SDK fixture yields the named operation +and closed reason; use-case fixtures prove no approval on initial or final read +failure; Action fixtures retain the same error/correlation and expose no private +text; serialization/forgery fixtures enforce bounded public records. Completed +merge-ref CI with historical PR base metadata still uses the live branch ref. +`docs/pull-requests/guarded-approval.mdx` documents this recovery table. Definition +of Done includes these 40 cases, coverage, types/lint, architecture, docs, +workflows/specification validators, generated catalog/bundles and Graphify. +Rollout is additive with no schema migration; rollback restores generic +errors but does not change native reviews. A fresh consumer run after promotion +is required to establish its actual endpoint cause; fixtures do not prove that +production diagnosis. + +Primary provider semantics: [GitHub REST troubleshooting](https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api) +and [classic branch protection](https://docs.github.com/en/rest/branches/branch-protection#get-branch-protection). diff --git a/src/actions/__tests__/pull_request_approval_action.test.ts b/src/actions/__tests__/pull_request_approval_action.test.ts index e67454239..3d9e6c8a5 100644 --- a/src/actions/__tests__/pull_request_approval_action.test.ts +++ b/src/actions/__tests__/pull_request_approval_action.test.ts @@ -1,3 +1,4 @@ +import { approvalEvidenceError } from '../../application/errors/approval_evidence_error'; import * as github from '@actions/github'; import * as core from '@actions/core'; import { getGithubActionInput } from '../github_action_input'; @@ -137,3 +138,25 @@ describe('trusted PR approval Action route', () => { expect(mockExecute).toHaveBeenCalledWith(expect.objectContaining({ pullNumber: 42 })); }); }); + + +describe('causal Action evidence failures', () => { + beforeEach(() => { + jest.clearAllMocks(); + context().payload = { repository: { id: 17 } }; context().eventName = 'workflow_dispatch'; + (getGithubActionInput as jest.Mock).mockImplementation((name: string) => name === 'pr-approval-pr-number' ? '42' : ''); + (ObservePullRequestApprovalUseCase as jest.Mock).mockImplementation(() => ({ execute: mockExecute })); + (core.summary.addRaw as jest.Mock).mockReturnValue(core.summary); + (github.getOctokit as jest.Mock).mockReturnValue({ rest: { repos: { getCollaboratorPermissionLevel: jest.fn().mockResolvedValue({ data: { permission: 'write' } }) } } }); + }); +it.each(['http-403', 'http-503'] as const)('preserves %s operation, retryability and correlation through the Action boundary', async reason => { + const failure = approvalEvidenceError('jobs', reason); + mockExecute.mockResolvedValue({ failure, decision: { status: 'blocked', code: 'evidence-unavailable', detail: 'SECRET' }, publication: 'not-applicable' }); + await expect(runPullRequestApprovalAction('pat')).rejects.toBe(failure); + const summary = (core.summary.addRaw as jest.Mock).mock.calls.map(call => call[0]).join(''); + expect(summary).toContain(`jobs (${reason})`); expect(summary).toContain('actions-read'); + expect(summary).toContain(failure.correlationId); expect(summary).not.toContain('SECRET'); + expect(core.summary.write).toHaveBeenCalled(); +}); + +}); diff --git a/src/actions/pull_request_approval_action.ts b/src/actions/pull_request_approval_action.ts index 7d6742673..227719a3f 100644 --- a/src/actions/pull_request_approval_action.ts +++ b/src/actions/pull_request_approval_action.ts @@ -1,3 +1,6 @@ +import { renderApplicationErrorText } from '../application/policies/application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../application/policies/application_error_message_catalog'; +import type { ApplicationError } from '../application/errors/application_error'; import * as core from '@actions/core'; import * as github from '@actions/github'; import { ObservePullRequestApprovalUseCase } from '../application/usecases/pull_request_approval/observe_pull_request_approval_use_case'; @@ -46,15 +49,22 @@ export async function runPullRequestApprovalAction(token: string): Promise }); const useCase = new ObservePullRequestApprovalUseCase(port); const failedPulls: number[] = []; + let firstFailure: ApplicationError | undefined; for (const pullNumber of targetNumbers) { const target: ApprovalObservationTarget = { owner, repository: repo, repositoryId, pullNumber }; const result = await useCase.execute(target); + if (result.failure) { + firstFailure ??= result.failure; + const diagnostic = renderApplicationErrorText(result.failure, resolveStaticApplicationErrorCatalog(locale).message); + core.info(diagnostic); + core.summary.addRaw(`
${diagnostic.replace(/&/gu, '&').replace(//gu, '>')}
\n`); + } const safeCode = result.decision.code.replace(/[^a-z0-9-]/gu, '').slice(0, 60); core.info(`PR #${pullNumber}: ${result.decision.status} (${safeCode}); assessment ${result.publication}.`); if (result.publication === 'failed' || [ 'invalid-policy', 'policy-unavailable', 'evidence-unavailable', 'target-mismatch', 'pre-submit-unavailable', 'publication-unknown', - ].includes(result.decision.code)) failedPulls.push(pullNumber); + ].includes(result.decision.code) || result.failure) failedPulls.push(pullNumber); if (result.evidence) { core.summary.addRaw(renderApprovalRunSummary({ target, evidence: result.evidence, decision: result.decision, @@ -72,6 +82,7 @@ export async function runPullRequestApprovalAction(token: string): Promise } } await core.summary.write(); + if (firstFailure) throw firstFailure; if (failedPulls.length > 0) { throw new Error(`Approval observation needs attention for PR(s) ${failedPulls.join(', ')}; inspect the Job Summary and rerun after recovery.`); } diff --git a/src/application/errors/approval_evidence_error.ts b/src/application/errors/approval_evidence_error.ts new file mode 100644 index 000000000..16cf333dc --- /dev/null +++ b/src/application/errors/approval_evidence_error.ts @@ -0,0 +1,20 @@ +import { APPROVAL_EVIDENCE_PERMISSIONS, type ApprovalEvidenceDiagnostic, type ApprovalEvidenceOperation, type ApprovalEvidenceReason } from '../../domain/approval_evidence_diagnostic'; +import { ApplicationError, type ApplicationErrorCode } from './application_error'; + +export function approvalEvidenceError(operation: ApprovalEvidenceOperation, reason: ApprovalEvidenceReason): ApplicationError { + return approvalDiagnosticError({ operation, reason, permission: APPROVAL_EVIDENCE_PERMISSIONS[operation] }); +} + +export function approvalDiagnosticError(diagnostic: ApprovalEvidenceDiagnostic): ApplicationError { + const reason = diagnostic.reason; + const code: ApplicationErrorCode = reason === 'http-401' ? 'authorization.credential-invalid' + : reason === 'http-403' ? 'authorization.denied' + : reason === 'http-404' ? 'provider.not-found' + : reason === 'http-429' ? 'provider.rate-limited' + : reason === 'http-409' ? 'provider.conflict' + : reason === 'transport-unavailable' || /^http-50[0234]$/u.test(reason) ? 'provider.unavailable' + : 'provider.contract-invalid'; + return new ApplicationError(code, `Approval evidence read failed (${diagnostic.operation}, ${reason}).`, { + recovery: { id: 'approval-evidence-read-failed', variables: diagnostic }, + }); +} diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index fe0d29f7b..bc116406d 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -19,6 +19,11 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + { + recovery: { id: 'approval-evidence-read-failed', variables: { operation: 'jobs', reason: 'http-403', permission: 'actions-read' } }, + english: 'No approval is authorized by this failed read. Existing PRs, reviews and completed external effects were preserved.', + spanish: 'Esta lectura fallida no autoriza ninguna aprobación. Se conservaron las PRs, revisiones y efectos externos completados.', + }, { recovery: { id: 'branch-sync-resolution-invalid', variables: { validationCode: 'sdd-catalog-invalid', command: '/copilot sync-branch' } }, english: 'No synchronization commit was pushed; remote branches were preserved.', diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index 5b25ee3fa..b6b638cde 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -206,6 +206,11 @@ const SPANISH_CONTENT: ErrorContent = Object.freeze({ }); const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'Approval evidence could not be verified: {operation} ({reason}).', + action: 'Inspect {permission} access for the runtime credential and the named evidence response. Correct authorization, missing-resource or schema errors before rerunning; retry transient failures only after provider recovery or rate-limit reset.', + retainedState: 'No approval is authorized by this failed read. Existing PRs, reviews and completed external effects were preserved.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'The changes were rejected during workspace verification.', action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', @@ -274,6 +279,11 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze({ + impact: 'No se pudo verificar la evidencia de aprobación: {operation} ({reason}).', + action: 'Comprueba el acceso {permission} de la credencial de ejecución y la respuesta indicada. Corrige los errores de autorización, recurso o esquema antes de reejecutar; reintenta los fallos transitorios solo tras recuperarse el proveedor o restablecerse el límite.', + retainedState: 'Esta lectura fallida no autoriza ninguna aprobación. Se conservaron las PRs, revisiones y efectos externos completados.', + }), 'workspace-changes-rejected': Object.freeze({ impact: 'Los cambios se rechazaron durante la verificación del espacio de trabajo.', action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', diff --git a/src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts b/src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts index 4c33e78a9..775bddf24 100644 --- a/src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts +++ b/src/application/usecases/pull_request_approval/__tests__/observe_pull_request_approval_use_case.test.ts @@ -1,3 +1,4 @@ +import { approvalEvidenceError } from '../../../errors/approval_evidence_error'; import { ObservePullRequestApprovalUseCase } from '../observe_pull_request_approval_use_case'; import type { ApprovalEvidence } from '../../../../domain/pull_request_approval'; import type { PullRequestApprovalPort } from '../../../ports/pull_request_approval_ports'; @@ -138,3 +139,34 @@ describe('ObservePullRequestApprovalUseCase', () => { expect(gateway.publishAssessment).not.toHaveBeenCalled(); }); }); + + +describe('approval read failure propagation (#619)', () => { + it('retains the exact semantic failure through the use case without an approval or assessment', async () => { + const failure = approvalEvidenceError('jobs', 'http-403'); + const gateway = port({ loadEvidence: jest.fn().mockRejectedValue(failure) }); + const result = await new ObservePullRequestApprovalUseCase(gateway).execute(target); + expect(result.failure).toBe(failure); expect(result.decision.code).toBe('evidence-unavailable'); + expect(gateway.submitApproval).not.toHaveBeenCalled(); expect(gateway.publishAssessment).not.toHaveBeenCalled(); + }); + it('blocks even otherwise eligible evidence when an independent rule read failed', async () => { + const diagnostics = [{ operation: 'rules', reason: 'http-403', permission: 'metadata-read' }] as const; + const gateway = port({ loadEvidence: jest.fn().mockResolvedValue({ ...evidence(), diagnostics }) }); + const result = await new ObservePullRequestApprovalUseCase(gateway).execute(target); + expect(result).toMatchObject({ decision: { code: 'evidence-unavailable' }, failure: { retryable: false } }); + expect(gateway.submitApproval).not.toHaveBeenCalled(); + }); + it('blocks a new diagnostic discovered on the final evidence reread', async () => { + const diagnostics = [{ operation: 'merge-commit', reason: 'http-503', permission: 'contents-read' }] as const; + const gateway = port({ loadEvidence: jest.fn().mockResolvedValueOnce(evidence()).mockResolvedValueOnce({ ...evidence(), diagnostics }) }); + const result = await new ObservePullRequestApprovalUseCase(gateway).execute(target); + expect(result).toMatchObject({ decision: { code: 'pre-submit-unavailable' }, failure: { retryable: true } }); + expect(gateway.submitApproval).not.toHaveBeenCalled(); + }); + it('never approves after a successful evidence reread followed by a policy read failure', async () => { + const gateway = port({ readPolicy: jest.fn().mockResolvedValueOnce(policy).mockRejectedValueOnce(new Error('SECRET')) }); + const result = await new ObservePullRequestApprovalUseCase(gateway).execute(target); + expect(result.decision.code).toBe('pre-submit-unavailable'); expect(gateway.submitApproval).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('SECRET'); + }); +}); diff --git a/src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts b/src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts index 2b6437546..404d03b47 100644 --- a/src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts +++ b/src/application/usecases/pull_request_approval/observe_pull_request_approval_use_case.ts @@ -1,8 +1,11 @@ +import { ApplicationError } from '../../errors/application_error'; +import { approvalDiagnosticError, approvalEvidenceError } from '../../errors/approval_evidence_error'; import { decidePullRequestApproval, type ApprovalDecision, type ApprovalEvidence } from '../../../domain/pull_request_approval'; import { parsePullRequestApprovalPolicy } from '../../../domain/pull_request_approval_policy'; import type { ApprovalObservationTarget, PullRequestApprovalPort } from '../../ports/pull_request_approval_ports'; export interface ApprovalObservationResult { + readonly failure?: ApplicationError; readonly decision: ApprovalDecision; readonly headSha?: string; readonly evidence?: ApprovalEvidence; @@ -40,8 +43,9 @@ export class ObservePullRequestApprovalUseCase { let initial: ApprovalEvidence; try { initial = await this.port.loadEvidence(target); - } catch { + } catch (cause) { return { + failure: evidenceFailure(cause), decision: { status: 'blocked', code: 'evidence-unavailable', detail: 'GitHub approval evidence could not be read; rerun this observer.' }, publication: 'not-applicable', }; @@ -49,7 +53,10 @@ export class ObservePullRequestApprovalUseCase { if (initial.repositoryId !== target.repositoryId || initial.pullNumber !== target.pullNumber) { return { decision: { status: 'blocked', code: 'target-mismatch', detail: 'The resolved PR does not match the bound repository and number.' }, publication: 'not-applicable' }; } - let decision = decidePullRequestApproval(policy, initial); + let failure = initial.diagnostics?.[0] ? approvalDiagnosticError(initial.diagnostics[0]) : undefined; + let decision: ApprovalDecision = failure + ? { status: 'blocked', code: 'evidence-unavailable', detail: 'Approval evidence is unavailable; inspect the causal diagnostic in the observer Job Summary.' } + : decidePullRequestApproval(policy, initial); let reviewId: number | undefined; if (decision.status === 'eligible') { let current: ApprovalEvidence | undefined; @@ -57,9 +64,16 @@ export class ObservePullRequestApprovalUseCase { try { current = await this.port.loadEvidence(target); currentPolicy = await this.port.readPolicy(target); - } catch { + } catch (cause) { + failure = evidenceFailure(cause); + current = undefined; decision = { status: 'blocked', code: 'pre-submit-unavailable', detail: 'Current approval evidence could not be reread before submission.' }; } + if (current?.diagnostics?.length) { + failure = approvalDiagnosticError(current.diagnostics[0]); + current = undefined; + decision = { status: 'blocked', code: 'pre-submit-unavailable', detail: 'Current evidence is unavailable; inspect the observer diagnostic.' }; + } if (current && currentPolicy !== rawPolicy) { decision = { status: 'pending', code: 'superseded', detail: 'The approval policy changed before submission; recheck the current head.' }; } else if (current && !sameRevisionAndEvidence(initial, current)) { @@ -96,10 +110,10 @@ export class ObservePullRequestApprovalUseCase { try { // The adapter guards the current head again before editing the single card. await this.port.publishAssessment(target, initial, decision, reviewId); - return { decision, headSha: initial.headSha, evidence: initial, mode: policy.mode, + return { decision, ...(failure ? { failure } : {}), headSha: initial.headSha, evidence: initial, mode: policy.mode, ...(reviewId ? { reviewId } : {}), publication: 'published' }; } catch { - return { decision, headSha: initial.headSha, evidence: initial, mode: policy.mode, + return { decision, ...(failure ? { failure } : {}), headSha: initial.headSha, evidence: initial, mode: policy.mode, ...(reviewId ? { reviewId } : {}), publication: 'failed' }; } } @@ -116,3 +130,8 @@ function sameRevisionAndEvidence(left: ApprovalEvidence, right: ApprovalEvidence function approvalReviewBody(headSha: string): string { return `\nCopilot approved this revision (${headSha.slice(0, 7)}) after its configured CI and coverage gates passed and Bugbot reported no blocking findings. GitHub still applies branch rules. Configure a separate human-review requirement if needed; a bot approval may count toward a generic approval minimum.`; } + +function evidenceFailure(cause: unknown): ApplicationError { + return cause instanceof ApplicationError && cause.recovery?.id === 'approval-evidence-read-failed' + ? cause : approvalEvidenceError('evidence', 'transport-unavailable'); +} diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 795098735..789ddd4de 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -1,3 +1,4 @@ +import { APPROVAL_EVIDENCE_PERMISSIONS, APPROVAL_EVIDENCE_REASONS, type ApprovalEvidenceDiagnostic } from '../../domain/approval_evidence_diagnostic'; import { BRANCH_SYNC_VALIDATION_CODES, type BranchSyncValidationCode } from '../../domain/branch_sync_validation'; export type ApplicationErrorKind = @@ -48,11 +49,13 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', 'workspace-changes-rejected', + 'approval-evidence-read-failed', ] as const); export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables { + readonly 'approval-evidence-read-failed': ApprovalEvidenceDiagnostic; readonly 'pull-request-link-restored': Readonly>; readonly 'pull-request-link-base-retained': Readonly>; readonly 'pull-request-link-reference-retained': Readonly>; @@ -307,6 +310,7 @@ export class ApplicationError extends Error { } const RECOVERY_VARIABLE_KEYS: Readonly> = Object.freeze({ + 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -361,6 +365,12 @@ function normalizeApplicationErrorRecovery( && !BRANCH_SYNC_VALIDATION_CODES.some(code => code === variables.validationCode)) { throw new TypeError('Application error recovery synchronization validation code is invalid.'); } + if (recovery.id === 'approval-evidence-read-failed' + && (!Object.prototype.hasOwnProperty.call(APPROVAL_EVIDENCE_PERMISSIONS, variables.operation) + || !APPROVAL_EVIDENCE_REASONS.some(reason => reason === variables.reason) + || APPROVAL_EVIDENCE_PERMISSIONS[variables.operation as keyof typeof APPROVAL_EVIDENCE_PERMISSIONS] !== variables.permission)) { + throw new TypeError('Approval evidence diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), diff --git a/src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts b/src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts new file mode 100644 index 000000000..501c9d4a4 --- /dev/null +++ b/src/data/repository/pull_request/__tests__/approval_evidence_read.test.ts @@ -0,0 +1,59 @@ +import { readApprovalEvidence } from '../approval_evidence_read'; +import { approvalEvidenceError } from '../../../../application/errors/approval_evidence_error'; +import { renderApplicationErrorText } from '../../../../application/policies/application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../../../../application/policies/application_error_message_catalog'; +import { ApplicationError } from '../../../../application/errors/application_error'; + +describe('sanitized approval evidence failures', () => { + it.each([ + [401, 'authorization.credential-invalid', false], [403, 'authorization.denied', false], + [404, 'provider.not-found', false], [409, 'provider.conflict', true], + [422, 'provider.contract-invalid', false], [429, 'provider.rate-limited', true], + [500, 'provider.unavailable', true], [502, 'provider.unavailable', true], + [503, 'provider.unavailable', true], [504, 'provider.unavailable', true], + ])('preserves HTTP %s with stable retryability across serialization', async (status, code, retryable) => { + const cause = { status, message: 'SECRET @everyone