diff --git a/build/cli/index.js b/build/cli/index.js index d0ecd9a1c..950487260 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -43856,13 +43856,15 @@ function renderReconciliationPullRequest(operation, target, context, catalog = ( } function renderDeploymentJobSummary(operation, context, previousPhase, catalog = (0, deployment_message_catalog_1.resolveStaticDeploymentCatalog)(context.repositoryLocale)) { const messages = (0, deployment_message_catalog_1.deploymentCopy)(catalog); - const failure = operation.phase === 'blocked' ? (0, deployment_operation_1.requiredDeploymentFailure)(operation) : undefined; + const issueCompletionPending = context.issueCompletionPending === true && operation.phase === 'completed'; + const failure = operation.phase === 'blocked' ? (0, deployment_operation_1.requiredDeploymentFailure)(operation) + : issueCompletionPending ? { message: `${messages.cleanup}: ${messages.pending}`, retryable: true } : undefined; const externallyPending = operation.phase === 'promotion_pr_pending' || operation.phase === 'reconciliation_pending'; - const result = operation.phase === 'blocked' + const result = failure ? messages.workflowFailure : externallyPending ? messages.externalWait : messages.phase[operation.phase]; const lines = [ - `# ${operation.phase === 'blocked' ? '❌' : externallyPending ? '⏳' : '✅'} ${messages.jobSummary}`, '', + `# ${failure ? '❌' : externallyPending ? '⏳' : '✅'} ${messages.jobSummary}`, '', `> **${messages.result}: ${result}.**`, '', `| ${messages.previousPhase} | ${messages.resultingPhase} | ${messages.retryable} |`, '|---|---|---|', `| ${inline(previousPhase ?? operation.phase)} | ${inline(operation.phase)} | ${failure?.retryable ? messages.yes : messages.no} |`, '', @@ -43877,9 +43879,11 @@ function renderDeploymentJobSummary(operation, context, previousPhase, catalog = ? inline(`${operation.publicationReceipt.tag}@${shortSha(operation.publicationReceipt.productionSha)}`) : messages.absent}`, '', ]; - if (operation.phase === 'blocked') { + if (failure) { lines.push(`## ${messages.actionRequired}`, '', `${safeText(failure.message)}. ${failure.retryable ? messages.retryAfterCorrection : messages.manualIntervention}.`, ''); } + if (issueCompletionPending) + lines.push(messages.noRepublish, ''); lines.push(deploymentLinks(operation, context, messages, catalog).join(' · ')); return lines.join('\n'); } @@ -55687,6 +55691,7 @@ class DeploymentMergeEffects { } async apply(action, pullRequest) { try { + await this.pullRequests.prepareIssueCompletion(pullRequest); if (action === "merge") { await this.pullRequests.mergePullRequest(pullRequest.number, pullRequest.headSha); } @@ -56708,6 +56713,26 @@ function noTagResult(taskId, tagName) { } +/***/ }), + +/***/ 26779: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.deferredDeploymentCompletionResult = deferredDeploymentCompletionResult; +const result_1 = __nccwpck_require__(73817); +function deferredDeploymentCompletionResult(id, facts, nested = false) { + const completion = { nativeClosed: facts.state === 'closed', reconciled: false, + deploymentCompletion: 'pending', outcome: 'deferred' }; + return new result_1.Result({ id, success: true, executed: false, + steps: ['Issue completion is deferred until deployment publication, reconciliation and cleanup are verified.', + ...(facts.state === 'closed' ? ['The existing native closure was retained for inspection.'] : [])], + payload: nested ? { issueCompletion: completion } : completion }); +} + + /***/ }), /***/ 28399: @@ -56818,8 +56843,7 @@ class ConfirmPublicationHandler { ?? (0, deployment_orchestration_runtime_1.deploymentSuccess)(`Publication for ${resumed.tag} was already verified; reconciliation state was recovered.`); } if (resumed.phase === "completed" && resumed.publicationVerified) { - await this.runtime.publishDashboard(context, resumed); - return (0, deployment_orchestration_runtime_1.deploymentSuccess)(`Publication for ${resumed.tag} was already verified; duplicate notification ignored.`); + return this.reconciliation.complete(context, resumed); } assertPublicationPhase(resumed); const published = { @@ -57142,6 +57166,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ReconciliationHandler = void 0; const deployment_plan_policy_1 = __nccwpck_require__(8352); const deployment_operation_1 = __nccwpck_require__(92730); +const result_1 = __nccwpck_require__(73817); +const application_error_1 = __nccwpck_require__(75999); const managed_pull_request_1 = __nccwpck_require__(95914); const deployment_orchestration_runtime_1 = __nccwpck_require__(77658); class ReconciliationHandler { @@ -57225,18 +57251,46 @@ class ReconciliationHandler { return undefined; } async complete(context, operation, completionContext = "") { + if (!operation.publicationVerified || !operation.publicationReceipt + || !operation.reconciliationTargets.every(target => target.status === 'completed')) { + throw new application_error_1.ApplicationError('workflow.stale', 'Deployment completion requires verified publication and all configured reconciliation.'); + } try { - await this.runtime.cleanup(context, operation); - if (operation.issueCompletion === "close") { - await this.runtime.dependencies.issues.closeIssue(context.singleAction.issue); - } + if (operation.phase !== 'completed') + await this.runtime.cleanup(context, operation); } catch (error) { const semanticError = (0, deployment_orchestration_runtime_1.semanticCleanupError)(error); return await this.runtime.block(context, operation, "cleanup", semanticError.message, true, semanticError); } const completed = { ...operation, phase: "completed", lastFailure: null }; - await this.runtime.persist(context, completed); + if (operation.phase !== 'completed') + await this.runtime.persist(context, completed); + try { + if (completed.issueCompletion === 'close') { + await this.runtime.dependencies.issues.closeIssue(context.singleAction.issue); + const results = await this.runtime.dependencies.issues.reconcileCompletion({ + issueNumber: context.singleAction.issue, locale: context.locale.issue, trustedBotLogin: context.tokenUser, + completionLabels: { lifecycle: context.labels.lifecycle, + protectedLabels: context.labels.completionProtectedLabels ?? [] }, + }); + const failed = results.find(result => !result.success); + if (failed) + throw failed.errors?.[0] ?? new application_error_1.ApplicationError('workflow.stale', 'Native issue completion metadata is unverified.'); + if (!results.length || results.some(result => { + const payload = (0, result_1.getResultPayload)(result.payload); + const completion = payload?.issueCompletion ? (0, result_1.getResultPayload)(payload.issueCompletion) : payload; + return completion?.nativeClosed !== true || completion.reconciled !== true; + })) + throw new application_error_1.ApplicationError('workflow.stale', 'Native issue closure and completion metadata are not fully verified.'); + } + } + catch (cause) { + const error = (0, application_error_1.toApplicationError)(cause, 'provider.unavailable', 'Deployment completed but native issue completion remains unverified.'); + return new result_1.Result({ id: 'DeploymentOrchestrationUseCase', success: false, executed: true, + steps: ['Verified publication, reconciliation and cleanup were retained. Retry the failed publication completion job to finish native issue completion.'], + errors: [error], payload: { deployment: { operationId: completed.operationId, phase: 'completed', issueCompletionVerified: false } } }); + } await this.runtime.publishDashboard(context, completed); await this.runtime.publishMilestone(context, completed, { kind: "orchestration-complete", tag: completed.tag }); return (0, deployment_orchestration_runtime_1.deploymentSuccess)(`Deployment ${completed.tag} completed${completionContext}.`); @@ -58846,6 +58900,8 @@ function failure(taskId, message, code) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ReconcileClosedIssueMetadataUseCase = void 0; const result_1 = __nccwpck_require__(73817); +const issue_completion_1 = __nccwpck_require__(31147); +const deferred_deployment_completion_1 = __nccwpck_require__(26779); const application_error_1 = __nccwpck_require__(75999); const closed_issue_reconciliation_error_1 = __nccwpck_require__(12183); const reconcile_issue_completion_metadata_1 = __nccwpck_require__(70614); @@ -58861,7 +58917,12 @@ class ReconcileClosedIssueMetadataUseCase { if (!Number.isSafeInteger(context.issueNumber) || context.issueNumber < 1) throw stale(); const target = this.issues.forIssue(context.issueNumber); - const repaired = await (0, reconcile_issue_completion_metadata_1.reconcileIssueCompletionMetadata)(target, context.issueNumber, context.completionLabels, progress); + progress.phase = 'read'; + const facts = await target.read(); + if (facts?.number === context.issueNumber && (0, issue_completion_1.planIssueCompletion)(facts, context.completionLabels).kind === 'deferred') { + return [(0, deferred_deployment_completion_1.deferredDeploymentCompletionResult)(this.taskId, facts, true)]; + } + const repaired = await (0, reconcile_issue_completion_metadata_1.reconcileIssueCompletionMetadata)(target, context.issueNumber, context.completionLabels, progress, facts); return [new result_1.Result({ id: this.taskId, success: true, executed: progress.labelsChanged > 0, steps: ['The closed issue completion metadata was verified; historical and unrelated labels were retained.'], payload: { issueCompletion: { nativeClosed: progress.nativeClosed, labelsChanged: progress.labelsChanged, reconciled: true, ...repaired } } })]; @@ -59000,6 +59061,7 @@ const publication_identity_policy_1 = __nccwpck_require__(45403); const branch_sync_notification_policy_1 = __nccwpck_require__(79895); const branch_sync_message_catalog_1 = __nccwpck_require__(89245); const application_error_1 = __nccwpck_require__(75999); +const deferred_deployment_completion_1 = __nccwpck_require__(26779); /** Retire existing owned status cards only; no create/delete, labels, Git or agent authority. */ class RetireBranchSyncUseCase { constructor(issues, comments, cardFreshness) { @@ -59033,6 +59095,8 @@ class RetireBranchSyncUseCase { const initial = await read(); if (initial.number !== context.issueNumber || !['open', 'closed'].includes(initial.state) || initial.state === 'closed' && !['completed', 'not_planned', 'duplicate', 'unknown'].includes(initial.reason)) throw stale(); + if (initial.deploymentCompletion === 'pending') + return [(0, deferred_deployment_completion_1.deferredDeploymentCompletionResult)(this.taskId, initial)]; if (initial.state !== 'closed') return [this.result(retired, nativeClosed)]; nativeClosed = true; @@ -59052,7 +59116,7 @@ class RetireBranchSyncUseCase { const verifyClosed = async () => { phase = 'native-fence'; const fresh = await read(); - if (!nativeClosed || fresh.reason !== initial.reason) + if (!nativeClosed || fresh.reason !== initial.reason || fresh.deploymentCompletion === 'pending') throw stale(); }; for (const card of cards) { @@ -59128,6 +59192,7 @@ const github_user_policy_1 = __nccwpck_require__(84403); const publication_identity_policy_1 = __nccwpck_require__(45403); const retired_progress_presentation_policy_1 = __nccwpck_require__(93427); const application_error_1 = __nccwpck_require__(75999); +const deferred_deployment_completion_1 = __nccwpck_require__(26779); /** Retire existing owned status cards only; no create/delete, labels, Git or agent authority. */ class RetireProgressUseCase { constructor(issues, comments, cardFreshness) { @@ -59161,6 +59226,8 @@ class RetireProgressUseCase { const initial = await read(); if (initial.number !== context.issueNumber || !['open', 'closed'].includes(initial.state) || initial.state === 'closed' && !['completed', 'not_planned', 'duplicate', 'unknown'].includes(initial.reason)) throw stale(); + if (initial.deploymentCompletion === 'pending') + return [(0, deferred_deployment_completion_1.deferredDeploymentCompletionResult)(this.taskId, initial)]; if (initial.state !== 'closed') return [this.result(retired, nativeClosed)]; nativeClosed = true; @@ -59179,7 +59246,7 @@ class RetireProgressUseCase { const verifyClosed = async () => { phase = 'native-fence'; const fresh = await read(); - if (!nativeClosed || fresh.reason !== initial.reason) + if (!nativeClosed || fresh.reason !== initial.reason || fresh.deploymentCompletion === 'pending') throw stale(); }; for (const card of cards) { @@ -62503,6 +62570,7 @@ function projectDeploymentOrchestrationContext(source) { return { owner: source.owner, repo: source.repo, + ...(source.tokenUser ? { tokenUser: source.tokenUser } : {}), branches: Object.freeze({ ...source.branches }), workflows: Object.freeze({ ...source.workflows }), locale: Object.freeze({ ...source.locale }), @@ -62517,6 +62585,8 @@ function projectDeploymentOrchestrationContext(source) { deploy: source.labels.deploy, deployed: source.labels.deployed, lifecycle: Object.freeze({ ...source.labels.lifecycle }), + ...(source.labels.completionProtectedLabels + ? { completionProtectedLabels: Object.freeze([...source.labels.completionProtectedLabels]) } : {}), }), deployment: deepFreezeCopy(source.deployment), singleAction: Object.freeze({ @@ -73724,6 +73794,7 @@ const result_1 = __nccwpck_require__(73817); const issue_completion_1 = __nccwpck_require__(31147); const application_error_1 = __nccwpck_require__(75999); const positive_integer_policy_1 = __nccwpck_require__(19879); +const deferred_deployment_completion_1 = __nccwpck_require__(26779); /** Native completion first, selective metadata second; retained effects survive partial failures. */ class CloseIssueAfterMergingUseCase { constructor(issues) { @@ -73753,6 +73824,8 @@ class CloseIssueAfterMergingUseCase { return current; }; let facts = await read(); + if (facts.deploymentCompletion === 'pending') + return [(0, deferred_deployment_completion_1.deferredDeploymentCompletionResult)(this.taskId, facts, true)]; if (facts.state === 'open' && facts.reason === 'reopened') throw stale(); if (facts.state === 'open') { @@ -85717,6 +85790,106 @@ function isNotFound(error) { } +/***/ }), + +/***/ 75508: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.prepareManagedIssueCompletion = prepareManagedIssueCompletion; +const application_error_1 = __nccwpck_require__(75999); +const managed_pull_request_1 = __nccwpck_require__(95914); +const query = `query DeploymentIssueCompletion($owner: String!, $repo: String!, $number: Int!, $issue: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + nameWithOwner + issue(number: $issue) { id number } + pullRequest(number: $number) { + id number state body headRefName headRefOid baseRefName + headRepository { nameWithOwner } + closingIssuesReferences(first: 100, after: $cursor) { + nodes { number repository { nameWithOwner } } + pageInfo { hasNextPage endCursor } + } + } + } +}`; +/** Detach only the verified operation's launcher reference before native merge handoff. */ +async function prepareManagedIssueCompletion(client, owner, repository, expected) { + if (typeof expected.body !== 'string' || typeof expected.repositoryFullName !== 'string' + || typeof expected.nodeId !== 'string' || !expected.nodeId + || typeof expected.headBranch !== 'string' || !expected.headBranch + || typeof expected.baseBranch !== 'string' || !expected.baseBranch + || typeof expected.headSha !== 'string') + throw invalid(); + const marker = (0, managed_pull_request_1.parseManagedPullRequestMarker)(expected.body); + const fullName = `${owner}/${repository}`.toLowerCase(); + if (!marker || !Number.isSafeInteger(expected.number) || expected.number < 1 || marker.issue === expected.number + || !sameRepository(expected.repositoryFullName, fullName) || expected.state !== 'open' || expected.merged !== false + || !/^[a-f0-9]{40}$/iu.test(expected.headSha)) + throw invalid(); + const read = async (cursor) => { + const snapshot = await client.graphql(query, { owner, repo: repository, number: expected.number, + issue: marker.issue, cursor }); + const resource = snapshot?.repository; + const issue = resource?.issue; + const pr = resource?.pullRequest; + const identity = (0, managed_pull_request_1.parseManagedPullRequestMarker)(pr?.body); + if (!sameRepository(resource?.nameWithOwner, fullName) || typeof issue?.id !== 'string' || !issue.id || issue.number !== marker.issue + || pr?.id !== expected.nodeId || pr.number !== expected.number || pr.state !== 'OPEN' + || !sameRepository(pr.headRepository?.nameWithOwner, fullName) + || pr.headRefName !== expected.headBranch || pr.headRefOid !== expected.headSha || pr.baseRefName !== expected.baseBranch + || !identity || identity.operationId !== marker.operationId || identity.phase !== marker.phase || identity.issue !== marker.issue + || pr.body.split(' B[Native promotion PR association] + B --> C[Verify owner and remove only launcher closing reference] + C --> D[Verify reference absent and use configured native merge policy] + D --> E[Verified publication and all reconciliation] + E --> F[Cleanup and persist completed state] + F --> G[Native issue completion and metadata] +``` + +Before configured merge, auto-merge, queue or create-only handoff, verify the +native repository, PR/issue IDs, managed operation/phase, branches and frozen head. +Use GitHub's `removeCloseIssueReferences` only for this launcher and managed PR; +never alter repository auto-close settings or delete a branch. Verify that the +closing relationship is absent afterwards. The current host's schema was checked +read-only; [the documented mutation](https://docs.github.com/en/graphql/reference/issues) +does not remove body-keyword references. Missing support, unreadable/changed +identity, a failed mutation or a remaining relationship blocks merge setup with +existing semantic diagnostics and an instruction to inspect closing references. +Retain all required checks, environment review and expected-head pins. At most +one reference-removal mutation and two pages of 100 closing references per +attempt; the existing one merge-readiness recovery remains the retry bound. +Separate provider calls are observations, not atomic compare-and-swap. + +Fresh native completion facts must identify a validated durable deployment that +has not completed. Generic merged-PR closure, closed-issue progress metadata and +recommendation retirement must defer for it. Native closure alone is not evidence +of publication or reconciliation. Malformed/unsupported durable state cannot be +downgraded to ordinary completion. Preserve ordinary completed, duplicate and +not-planned issue handling and configured keep-open behavior. Do not infer a +new native reopen authority from a historical premature close; retain that fact +and explain deferred completion rather than claiming repair. + +Final completion requires verified publication and its immutable receipt, all +configured reconciliation and successful cleanup. Persist completed functional +state before the final issue close so its native callback observes the correct +boundary. Supported replay must finish any remaining issue projection without +republishing, changing operation identity or repeating completed cleanup. A +projection failure retains completed irreversible effects and reports a truthful, +retryable result. No new public input, schema, provider permission or rollout is +introduced. A rollback may restore the old premature relationship behavior for +new promotions; finish active plans with the corrected runtime. + +Representative pending UI remains: `Release 3.4.0 — publishing artifacts`, with +production accepted, publication/reconciliation/cleanup unchecked and the launcher +open below 100%. Only the complete configured plan may produce closed/100%. +Existing dashboard, result steps and Job Summary carry deferred/error guidance; +no routine additional comment or notification is introduced. Update the release +guide, catalog/traceability and generated bundles with this implementation. + +Reserve **at least 39 distinct new cases before implementation**: + +| Area | Minimum | Required behavior | +|---|---:|---| +| Pure completion policy | 10 | pending phases, verified completion, incomplete publication/targets, unknown state, ordinary completion | +| Native PR relationship adapter | 10 | exact owned unlink, retained keyword reference, identity changes, provider failures, response/pagination bounds and idempotence | +| Native issue facts adapter | 4 | valid pending/completed state, ordinary metadata and malformed durable state | +| Application and finalization | 8 | deferred generic close/labels/cards, cleanup failure, completion-before-close ordering, projection retry and keep-open | +| Stateful causal integration | 4 | actual linked-branch/PR producer, pre-handler provider auto-close, queued/failed publication and ordinary-control preservation | +| Completion Job Summary | 3 | failed native projection remains actionable in en-US/es-ES and the real Action summary, while functional completion facts remain immutable | +| **Total** | **39** | no double counting; record additional distinct acceptance extensions | + +Use owned deterministic provider fixtures, with event-order assertions before the +closed handler. No real tag, package upload, release, environment bypass or private +consumer execution is part of regression verification. Keep existing timeouts, +global/module floors, architecture and setup ledger. New pure policy requires +100% all metrics; changed adapters/application modules require at least 95% +lines/statements and 90% branches/functions, plus exact added runtime lines 100%. +Fresh CI/native Linux/macOS/Windows acceptance, deferred cleanup, coverage and +complete zero-active-findings review remain merge gates. Run specification, +workflow/documentation validators, package smoke and the AST graph update. + +The completion Job Summary must show a failed, retryable native issue projection +even when functional state is already `completed`. This is transient result +evidence, never a rewrite of persisted completion/publication facts. Reserve the +three presentation/Action cases above before implementing this projection. diff --git a/specs/deployment-concurrency-and-state-fencing.md b/specs/deployment-concurrency-and-state-fencing.md index 43ba2a7c4..8f1bc1a2b 100644 --- a/specs/deployment-concurrency-and-state-fencing.md +++ b/specs/deployment-concurrency-and-state-fencing.md @@ -568,3 +568,16 @@ The existing monotonic-successor regression now includes such quoted text and verifies its preservation. Operation/revision fences, workflow serialization, publication receipts and issue completion behavior are unchanged by this classification correction. No deployment is performed for this regression. + +## Functional completion before native issue projection (#792) + +After verified publication, all configured reconciliation and cleanup, persist +the completed operation through the existing revision fence before final native +issue closure. This prevents its close callback from observing an unfinished +plan. Native issue projection failure retains that completed functional snapshot, +operation ID and immutable receipts; it does not move completed state backwards +or republish. A trusted publication-completion replay revalidates the external +receipt, skips completed cleanup and retries only native closure/metadata/cards. +The failed result and Job Summary identify pending projection independently of +functional phase. No new stored field, version, lock or provider permission is +introduced. The release SDD section24 owns the additional cases. diff --git a/specs/managed-issue-and-branch-lifecycle.md b/specs/managed-issue-and-branch-lifecycle.md index 06a0aef53..6a9148d48 100644 --- a/specs/managed-issue-and-branch-lifecycle.md +++ b/specs/managed-issue-and-branch-lifecycle.md @@ -856,3 +856,22 @@ workflows/specifications/catalogs, generated bundles, Graphify and current-head CI/review. Review the English/Spanish summary examples for readable status and one next action. Production convergence is not claimed until a fresh promoted consumer sequence verifies its native issue and required cleanup. + +## Typed deployment completion exception (#792, 2026-10-30) + +The native-close contract above continues for ordinary issues. Fresh bounded +issue facts additionally classify an owned valid deployment operation. Pending +publication, reconciliation or cleanup defers generic closure, 100%/lifecycle +cleanup and existing-card retirement, even if an earlier runtime already closed +the native issue. Unavailable, ambiguous or unsupported owned durable state +cannot certify ordinary completion. No close/reopen authority is added to the +metadata or card ports. The existing per-write native fences also reject a +concurrent pending operation. Deferred results have a distinct machine outcome +and do not advertise closed cleanup convergence. + +The release owner's [section 24](./configurable-release-orchestration.md#24-deferred-native-issue-completion-792-2026-10-30) +owns the additional numeric budget and native linked-branch/PR causal evidence; +do not double count it against this SDD. Final orchestration reuses these same +scoped reconciliation capabilities after its functional completion checkpoint. +Ordinary completed/not-planned/duplicate handling, selective label preservation, +card bounds, exact witnesses and cancellation behavior remain required. diff --git a/src/actions/__tests__/github_action_completion.test.ts b/src/actions/__tests__/github_action_completion.test.ts index 3fcc3dd38..2f474c520 100644 --- a/src/actions/__tests__/github_action_completion.test.ts +++ b/src/actions/__tests__/github_action_completion.test.ts @@ -4,8 +4,8 @@ import type { Execution } from '../../data/model/execution'; import { Ai } from '../../data/model/ai'; import { Result } from '../../data/model/result'; import { finishGithubAction } from '../github_action_completion'; -import * as core from '@actions/core'; import { ApplicationError } from '../../application/errors/application_error'; +import * as core from '@actions/core'; import type { DeploymentOperationSnapshot } from '../../domain/deployment_operation'; import { ResolveMessageCatalogUseCase } from '../../application/usecases/localization/resolve_message_catalog_use_case'; import { PublishResultUseCase } from '../../application/usecases/steps/common/publish_resume_use_case'; @@ -396,6 +396,24 @@ describe('finishGithubAction', () => { expect(mockPublishInvoke).not.toHaveBeenCalled(); }); + it('reports pending native completion in the deployment summary while retaining completed functional state', async () => { + const base = deploymentOperation(); const productionSha = 'c'.repeat(40); + const operation = { ...base, phase: 'completed', productionSha, publicationVerified: true, + publicationReceipt: { tag: base.tag, productionSha, operationId: base.operationId, + releaseUrl: 'https://github.com/vypdev/copilot/releases/tag/v3.4.0' } }; + const action = Object.assign(singleActionExecution(), { owner: 'vypdev', repo: 'copilot', + singleAction: { ...singleActionExecution().singleAction, issue: 11, isDeploymentOrchestrationAction: true }, + currentConfiguration: { results: [], deploymentOrchestration: operation } }) as Execution; + await finishGithubAction(action, [new Result({ id: 'DeploymentOrchestrationUseCase', success: false, executed: true, + errors: [new ApplicationError('provider.unavailable', 'Native issue projection remains unverified')], + payload: { deployment: { operationId: base.operationId, phase: 'completed', issueCompletionVerified: false } } })], + {} as never, {} as never, undefined, { publish: mockSummaryPublish }); + const summary = mockSummaryPublish.mock.calls[0][0] as string; + expect(summary).toContain('# ❌'); expect(summary).not.toContain('# ✅'); + expect(summary.replace(/\u200b/gu, '')).toContain('v3.4.0@ccccccc'); expect(operation.phase).toBe('completed'); + expect(operation.publicationVerified).toBe(true); + }); + it('links the published package from the first-party deployment summary', async () => { const base = deploymentOperation(); const productionSha = 'c'.repeat(40); diff --git a/src/actions/github_action_completion.ts b/src/actions/github_action_completion.ts index 3371cdfc9..4fd032664 100644 --- a/src/actions/github_action_completion.ts +++ b/src/actions/github_action_completion.ts @@ -180,6 +180,8 @@ async function writeActionSummary( }; appendLocalizationEvidence = true; body = renderDeploymentJobSummary(operation, { + issueCompletionPending: execution.currentConfiguration.results.some(result => + getResultPayload(getResultPayload(result.payload)?.deployment)?.issueCompletionVerified === false), owner: execution.owner, repository: execution.repo, issue: execution.singleAction.issue, diff --git a/src/application/policies/__tests__/deployment_presentation_policy.test.ts b/src/application/policies/__tests__/deployment_presentation_policy.test.ts index dbfa416dc..77ee49d64 100644 --- a/src/application/policies/__tests__/deployment_presentation_policy.test.ts +++ b/src/application/policies/__tests__/deployment_presentation_policy.test.ts @@ -58,6 +58,16 @@ const context = { }; describe("deployment presentation policy", () => { + it.each(['en-US', 'es-ES'])('shows actionable native completion failure in %s without changing published facts', repositoryLocale => { + const state = operation('completed', { productionSha: 'c'.repeat(40), publicationVerified: true, + publicationReceipt: { tag: 'v3.4.0', productionSha: 'c'.repeat(40), operationId: 'operation-12345678', + releaseUrl: 'https://github.com/vypdev/copilot/releases/tag/v3.4.0' } }); + const summary = renderDeploymentJobSummary(state, { ...context, repositoryLocale, issueCompletionPending: true }); + expect(summary).toContain('# ❌'); expect(summary).not.toContain('# ✅'); + expect(summary.replace(/\u200b/gu, '')).toContain('v3.4.0@ccccccc'); expect(summary).toContain('`completed`'); + expect(state.phase).toBe('completed'); expect(state.lastFailure).toBeNull(); + expect(state.publicationVerified).toBe(true); + }); it("uses a stable operation-scoped dashboard marker", () => { expect(deploymentDashboardMarker("operation-12345678", 355)) .toBe(''); diff --git a/src/application/policies/closed_issue_reconciliation_outcome_policy.ts b/src/application/policies/closed_issue_reconciliation_outcome_policy.ts index 733245197..878c970ed 100644 --- a/src/application/policies/closed_issue_reconciliation_outcome_policy.ts +++ b/src/application/policies/closed_issue_reconciliation_outcome_policy.ts @@ -13,6 +13,7 @@ export function projectClosedIssueCleanupCounts(results: readonly Result[]): if (!payload) continue; const completion = result.id === 'CloseIssueAfterMergingUseCase' || result.id === 'ReconcileClosedIssueMetadataUseCase' ? getResultPayload(payload.issueCompletion) : payload; + if (completion?.outcome === 'deferred' && completion.deploymentCompletion === 'pending' && result.executed === false) continue; if (completion?.outcome === 'not-applicable' && completion.nativeClosed === false) continue; if (completion?.nativeClosed !== true || completion.reconciled !== true) { invalid = true; continue; } if (completion.outcome === 'already-converged') converged++; diff --git a/src/application/policies/deployment_presentation_policy.ts b/src/application/policies/deployment_presentation_policy.ts index 8478d1b48..5e9dfad57 100644 --- a/src/application/policies/deployment_presentation_policy.ts +++ b/src/application/policies/deployment_presentation_policy.ts @@ -17,6 +17,7 @@ import { buildPublicationMarker, createSemanticDigest } from './publication_iden export const DEPLOYMENT_DASHBOARD_MARKER = 'copilot-deployment-dashboard'; export interface DeploymentPresentationContext { + readonly issueCompletionPending?: boolean; readonly owner: string; readonly repository: string; readonly issue: number; @@ -196,13 +197,15 @@ export function renderDeploymentJobSummary( catalog: DeploymentMessageCatalog = resolveStaticDeploymentCatalog(context.repositoryLocale), ): string { const messages = deploymentCopy(catalog); - const failure = operation.phase === 'blocked' ? requiredDeploymentFailure(operation) : undefined; + const issueCompletionPending = context.issueCompletionPending === true && operation.phase === 'completed'; + const failure = operation.phase === 'blocked' ? requiredDeploymentFailure(operation) + : issueCompletionPending ? { message: `${messages.cleanup}: ${messages.pending}`, retryable: true } : undefined; const externallyPending = operation.phase === 'promotion_pr_pending' || operation.phase === 'reconciliation_pending'; - const result = operation.phase === 'blocked' + const result = failure ? messages.workflowFailure : externallyPending ? messages.externalWait : messages.phase[operation.phase]; const lines = [ - `# ${operation.phase === 'blocked' ? '❌' : externallyPending ? '⏳' : '✅'} ${messages.jobSummary}`, '', + `# ${failure ? '❌' : externallyPending ? '⏳' : '✅'} ${messages.jobSummary}`, '', `> **${messages.result}: ${result}.**`, '', `| ${messages.previousPhase} | ${messages.resultingPhase} | ${messages.retryable} |`, '|---|---|---|', `| ${inline(previousPhase ?? operation.phase)} | ${inline(operation.phase)} | ${failure?.retryable ? messages.yes : messages.no} |`, '', @@ -217,12 +220,13 @@ export function renderDeploymentJobSummary( ? inline(`${operation.publicationReceipt.tag}@${shortSha(operation.publicationReceipt.productionSha)}`) : messages.absent}`, '', ]; - if (operation.phase === 'blocked') { + if (failure) { lines.push( `## ${messages.actionRequired}`, '', `${safeText(failure!.message)}. ${failure!.retryable ? messages.retryAfterCorrection : messages.manualIntervention}.`, '', ); } + if (issueCompletionPending) lines.push(messages.noRepublish, ''); lines.push(deploymentLinks(operation, context, messages, catalog).join(' · ')); return lines.join('\n'); } diff --git a/src/application/ports/deployment_orchestration_ports.ts b/src/application/ports/deployment_orchestration_ports.ts index 436f23e21..796829784 100644 --- a/src/application/ports/deployment_orchestration_ports.ts +++ b/src/application/ports/deployment_orchestration_ports.ts @@ -10,6 +10,7 @@ import type { DeploymentConfigurationValues } from "../../domain/deployment_conf import type { CopilotLifecycleLabels } from "../../domain/copilot_lifecycle"; import type { AgentConfiguration } from '../../domain/agent'; import type { LocaleProfile } from '../../domain/locale'; +import type { IssueCompletionLabelConfiguration } from '../../domain/issue_completion'; export interface ManagedPullRequestRecord { readonly number: number; @@ -67,6 +68,7 @@ export interface BoundTargetMergePolicyInspectionPort { } export interface ManagedPullRequestPort { + prepareIssueCompletion(owner: string, repository: string, pullRequest: ManagedPullRequestRecord, token: string): Promise; findManagedPullRequests(query: ManagedPullRequestQuery): Promise; createManagedPullRequest(command: ManagedPullRequestCreate): Promise; getPullRequest(owner: string, repository: string, pullRequest: number, token: string): Promise; @@ -84,6 +86,7 @@ export interface ManagedPullRequestPort { /** Repository-credential-bound managed pull-request authority. */ export interface BoundManagedPullRequestPort { + prepareIssueCompletion(pullRequest: ManagedPullRequestRecord): Promise; findManagedPullRequests(query: BoundManagedPullRequestQuery): Promise; createManagedPullRequest(command: BoundManagedPullRequestCreate): Promise; getPullRequest(pullRequest: number): Promise; @@ -214,6 +217,7 @@ export interface BoundDeploymentStateStoreFactoryPort { /** Narrow runtime view adapted structurally at the single-action boundary. */ export interface DeploymentOrchestrationContext { + readonly tokenUser?: string; readonly owner: string; readonly repo: string; readonly branches: { @@ -231,6 +235,7 @@ export interface DeploymentOrchestrationContext { readonly deploy: string; readonly deployed: string; readonly lifecycle: CopilotLifecycleLabels; + readonly completionProtectedLabels?: readonly string[]; }; readonly deployment: DeploymentConfigurationValues; readonly singleAction: { @@ -254,3 +259,10 @@ export interface DeploymentOrchestrationContext { readonly pullRequest: { readonly number: number }; readonly currentConfiguration: DeploymentIssueState; } + +export interface DeploymentIssueCompletionRequest { + readonly issueNumber: number; + readonly locale: string; + readonly trustedBotLogin?: string; + readonly completionLabels: IssueCompletionLabelConfiguration; +} diff --git a/src/application/services/__tests__/deployment_merge_effects.test.ts b/src/application/services/__tests__/deployment_merge_effects.test.ts index 1a3eaca90..879896a11 100644 --- a/src/application/services/__tests__/deployment_merge_effects.test.ts +++ b/src/application/services/__tests__/deployment_merge_effects.test.ts @@ -18,7 +18,7 @@ const rejected = (action: "merge" | "enable-auto-merge" = "merge", refresh = tru new DeploymentMergeEffectError(action, refresh, "provider.conflict", "Safe readiness rejection"); function harness(mode = "auto") { - const port = { mergePullRequest: jest.fn(), enableAutoMerge: jest.fn(), enqueuePullRequest: jest.fn(), + const port = { prepareIssueCompletion: jest.fn(), mergePullRequest: jest.fn(), enableAutoMerge: jest.fn(), enqueuePullRequest: jest.fn(), isPullRequestQueued: jest.fn().mockResolvedValue(false), getPullRequest: jest.fn().mockResolvedValue(pr()) }; const inspect = jest.fn().mockResolvedValue(inspection()); const persist = jest.fn(); @@ -29,6 +29,18 @@ function harness(mode = "auto") { } describe("deployment automatic merge effects", () => { + it.each([true, false])('removes the native launcher closing relation before automatic handoff (ready=%s)', async ready => { + const events: string[] = []; + let linked = true; + const port = { prepareIssueCompletion: jest.fn(async () => { events.push('detach'); linked = false; }), + mergePullRequest: jest.fn(async () => { events.push('merge'); expect(linked).toBe(false); }), + enableAutoMerge: jest.fn(async () => { events.push('enable'); expect(linked).toBe(false); }) }; + const service = new DeploymentMergeEffects(port as unknown as BoundManagedPullRequestPort); + await service.configure({ prMode: 'auto' } as DeploymentOperationSnapshot, pr(), inspection(ready), + jest.fn(), jest.fn()); + expect(events).toEqual(['detach', ready ? 'merge' : 'enable']); + expect(port.prepareIssueCompletion).toHaveBeenCalledWith(pr()); + }); it("re-observes a rejected ready merge and enables native waiting at the same head", async () => { const v = harness(); v.port.mergePullRequest.mockRejectedValue(rejected()); await expect(v.run()).resolves.toBe("auto-merge"); diff --git a/src/application/services/deployment_merge_effects.ts b/src/application/services/deployment_merge_effects.ts index 233971629..50dd7806c 100644 --- a/src/application/services/deployment_merge_effects.ts +++ b/src/application/services/deployment_merge_effects.ts @@ -50,6 +50,7 @@ export class DeploymentMergeEffects { private async apply(action: MergeAction, pullRequest: ManagedPullRequestRecord): Promise { try { + await this.pullRequests.prepareIssueCompletion(pullRequest); if (action === "merge") { await this.pullRequests.mergePullRequest(pullRequest.number, pullRequest.headSha); } else if (action === "enable-auto-merge" && !pullRequest.autoMergeEnabled) { diff --git a/src/application/services/deployment_orchestration_dependencies.ts b/src/application/services/deployment_orchestration_dependencies.ts index b4d491c56..c080aae61 100644 --- a/src/application/services/deployment_orchestration_dependencies.ts +++ b/src/application/services/deployment_orchestration_dependencies.ts @@ -7,10 +7,12 @@ import type { BoundDeploymentStateStoreFactoryPort, BoundManagedPullRequestPort, BoundTargetMergePolicyInspectionPort, + DeploymentIssueCompletionRequest, } from "../ports/deployment_orchestration_ports"; import type { BoundIssueClosurePort } from "../ports/issue_lifecycle_ports"; import type { BoundIssueLabelsPort } from "../ports/issue_management_ports"; import type { MessageCatalogResolutionPort } from '../ports/message_catalog_ports'; +import type { Result } from '../../data/model/result'; export interface DeploymentOrchestrationDependencies { readonly pullRequests: BoundManagedPullRequestPort; @@ -21,7 +23,7 @@ export interface DeploymentOrchestrationDependencies { readonly publication: BoundDeploymentPublicationReceiptPort; readonly state: BoundDeploymentStateStoreFactoryPort; readonly labels: BoundIssueLabelsPort; - readonly issues: BoundIssueClosurePort; + readonly issues: BoundIssueClosurePort & { reconcileCompletion(request: DeploymentIssueCompletionRequest): Promise }; readonly replacementInspection?: BoundDeploymentCandidateInspectionPort; readonly operationId: () => string; readonly catalogResolver?: MessageCatalogResolutionPort; diff --git a/src/application/usecases/__tests__/push_single_action_contexts.test.ts b/src/application/usecases/__tests__/push_single_action_contexts.test.ts index f0e6a8326..67608b29d 100644 --- a/src/application/usecases/__tests__/push_single_action_contexts.test.ts +++ b/src/application/usecases/__tests__/push_single_action_contexts.test.ts @@ -152,8 +152,11 @@ describe('push and single-action context projection', () => { expect(projected.labels).toEqual({ isRelease: kind === 'release', isHotfix: kind === 'hotfix', deploy: 'deploy', deployed: 'deployed', lifecycle: DEFAULT_COPILOT_LIFECYCLE_LABELS, + completionProtectedLabels: labels.completionProtectedLabels, }); expect(projected.pullRequest).toEqual({ number: 99 }); + expect(projected.tokenUser).toBe(input.tokenUser); + expect(Object.isFrozen(projected.labels.completionProtectedLabels)).toBe(true); pullRequest.inputs = { pull_request: { number: 100 } } as PullRequest['inputs']; singleAction.currentSingleAction = ''; labels.currentIssueLabels = []; diff --git a/src/application/usecases/actions/__tests__/deployment_candidate_replacement.integration.test.ts b/src/application/usecases/actions/__tests__/deployment_candidate_replacement.integration.test.ts index 9a3ab8239..04ee1ea3e 100644 --- a/src/application/usecases/actions/__tests__/deployment_candidate_replacement.integration.test.ts +++ b/src/application/usecases/actions/__tests__/deployment_candidate_replacement.integration.test.ts @@ -1,3 +1,4 @@ +import { Result } from "../../../../data/model/result"; import { DeploymentOrchestrationRuntime } from '../../../services/deployment_orchestration_runtime'; import { DeploymentStateBoundary } from '../../../services/deployment_state_boundary'; import { ApplicationError } from '../../../errors/application_error'; @@ -67,13 +68,14 @@ function harness(initial = oldCandidate()) { const pr = { number: 174, nodeId: 'PR174', body: command.body, headBranch: command.headBranch, headSha: candidateSha, baseBranch: command.baseBranch, state: 'open', merged: false, autoMergeEnabled: false, repositoryFullName: 'owner/project' }; created.push(pr); return pr; - }), getPullRequest: jest.fn(), enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn().mockResolvedValue(false), + }), getPullRequest: jest.fn(), prepareIssueCompletion: jest.fn(), + enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn().mockResolvedValue(false), enqueuePullRequest: jest.fn(), mergePullRequest: jest.fn(), }, targetRules: { getTargetCapabilities: jest.fn().mockResolvedValue({ autoMergeAllowed: false, mergeQueueRequired: false, immediatelyMergeable: false, requiresStrictStatusChecks: false, mergeQueueProducers: [], mergeQueueObservationProblems: [] }) }, presentation: { findDashboard: jest.fn(), createDashboard: jest.fn(), updateDashboard: jest.fn(), publishMilestone: jest.fn() }, - publication: { inspect: jest.fn() }, continuation: { dispatch: jest.fn() }, issues: { closeIssue: jest.fn(), addComment: jest.fn() }, + publication: { inspect: jest.fn() }, continuation: { dispatch: jest.fn() }, issues: { reconcileCompletion: jest.fn().mockResolvedValue([new Result({ id: 'ReconcileClosedIssueMetadataUseCase', success: true, executed: false, payload: { issueCompletion: { nativeClosed: true, reconciled: true, outcome: 'already-converged' } } })]), closeIssue: jest.fn(), addComment: jest.fn() }, }; const useCase = new DeploymentOrchestrationUseCase(dependencies); return { dependencies, useCase, store, created, current: () => durable, setCurrent: (value: DeploymentOperationSnapshot) => { durable = value; } }; diff --git a/src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts b/src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts new file mode 100644 index 000000000..acab2fc82 --- /dev/null +++ b/src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts @@ -0,0 +1,121 @@ +import { runCreateLinkedBranch } from '../../../../data/repository/branch/linked_branch_workflow'; +import { GithubManagedPullRequestRepository } from '../../../../data/repository/deployment/github_managed_pull_request_repository'; +import { IssueCompletionRepository } from '../../../../data/repository/issue/issue_completion_repository'; +import { bindManagedPullRequests } from '../../../../infrastructure/composition/push_single_action_capability_port_binding'; +import { bindIssueCompletionMetadata } from '../../../../infrastructure/composition/issue_completion_composition_root'; +import { DeploymentMergeEffects, type DeploymentMergeInspection } from '../../../services/deployment_merge_effects'; +import { renderPromotionPullRequest } from '../../../policies/deployment_presentation_policy'; +import { ReconcileClosedIssueMetadataUseCase } from '../reconcile_closed_issue_metadata_use_case'; +import { deploymentOperationFixture } from '../../../../testing/deployment_operation_fixture'; +import { DEFAULT_COPILOT_LIFECYCLE_LABELS } from '../../../../domain/copilot_lifecycle'; +import { projectClosedIssueCleanupCounts } from '../../../policies/closed_issue_reconciliation_outcome_policy'; +import type { GithubDeploymentClient, GithubDeploymentPullRequest } from '../../../../infrastructure/github/ports/github_deployment_provider_port'; + +const url = 'https://api.github.com/repos/owner/repo'; +const binding = { owner: 'owner', repository: 'repo', token: 'synthetic-token' }; +const inspection: Extract = { kind: 'ready', + decision: { kind: 'mode', mode: 'auto-merge', reasonCode: 'auto-merge-available' }, + capabilities: { autoMergeAllowed: true, immediatelyMergeable: true, requiresStrictStatusChecks: false, + mergeQueueRequired: false, mergeQueueProducers: [], mergeQueueObservationProblems: [] } }; + +/** Stateful provider model of documented linked-branch association and merge-time closure. No network or publication. */ +function provider(kind: 'release' | 'hotfix', ordinary = false) { + const operation = deploymentOperationFixture({ kind, sourceBranch: `${kind}/3.4.0`, + originBranch: kind === 'hotfix' ? 'v3.3.0' : 'develop', phase: 'promotion_pr_pending' }); + let branchLinked = false; + let closing = false; + let sticky = false; + let issueClosed = false; + let projectStatus = 'In progress'; + const events: string[] = []; + const labels = new Set(['55%', 'state:working', kind]); + let pull: GithubDeploymentPullRequest; + const graphql = jest.fn(async (query: string, variables: Record) => { + if (query.includes('createLinkedBranch(input:')) { + branchLinked = true; events.push('linked-branch-created'); + return { createLinkedBranch: { linkedBranch: { id: 'LB_355', ref: { name: operation.sourceBranch } } } }; + } + if (query.includes('removeCloseIssueReferences(input:')) { + expect(variables).toEqual({ issueId: 'I_355', pullRequestIds: ['PR_40'] }); + if (!sticky) closing = false; events.push('closing-reference-removed'); + return { removeCloseIssueReferences: { issue: { id: 'I_355', number: 355 } } }; + } + if (query.includes('query DeploymentIssueCompletion')) return { repository: { nameWithOwner: 'owner/repo', + issue: { id: 'I_355', number: 355 }, pullRequest: { id: pull.node_id, number: pull.number, state: pull.state === 'closed' ? 'CLOSED' : 'OPEN', + body: pull.body, headRefName: pull.head.ref, headRefOid: pull.head.sha, baseRefName: pull.base.ref, + headRepository: { nameWithOwner: 'owner/repo' }, closingIssuesReferences: { + nodes: closing ? [{ number: 355, repository: { nameWithOwner: 'owner/repo' } }] : [], + pageInfo: { hasNextPage: false, endCursor: null } } } } }; + return { repository: { id: 'R_1', issue: { id: 'I_355' }, ref: { target: { oid: 'b'.repeat(40) } } } }; + }); + const create = jest.fn(async (command: Record) => { + closing = branchLinked; events.push('promotion-created-with-native-closing-reference'); + pull = { number: 40, node_id: 'PR_40', body: String(command.body), state: 'open', merged: false, auto_merge: null, + head: { ref: String(command.head), sha: operation.sourceSha, repo: { full_name: 'owner/repo' } }, + base: { ref: String(command.base), repo: { full_name: 'owner/repo' } } }; + return { data: pull }; + }); + const merge = jest.fn(async () => { + pull = { ...pull, state: 'closed', merged: true, merge_commit_sha: 'c'.repeat(40) }; + if (closing) { issueClosed = true; projectStatus = 'Done'; events.push('provider-closed-issue-before-handler'); } + return { data: { merged: true, sha: 'c'.repeat(40) } }; + }); + const issueGet = jest.fn(async () => ({ url: `${url}/issues/355`, data: { number: 355, + repository_url: url, state: issueClosed ? 'closed' : 'open', state_reason: issueClosed ? 'completed' : null, + labels: [...labels], body: ordinary ? 'Human issue' : `` } })); + const client = { graphql, rest: { pulls: { create, merge }, issues: { get: issueGet, + update: jest.fn(), addLabels: jest.fn(async (request: { labels: string[] }) => { request.labels.forEach(label => labels.add(label)); }), + removeLabel: jest.fn(async (request: { name: string }) => { labels.delete(request.name); }) } } }; + const repository = new GithubManagedPullRequestRepository({ getClient: () => client as unknown as GithubDeploymentClient }); + const port = bindManagedPullRequests(repository, binding); + const metadata = new ReconcileClosedIssueMetadataUseCase(bindIssueCompletionMetadata(new IssueCompletionRepository( + { getClient: () => client as never }, { ...binding, apiBaseUrl: 'https://api.github.com' }))); + return { operation, port, events, merge, metadata, labels, setSticky: () => { sticky = true; }, + state: () => ({ issueClosed, projectStatus, closing }), + prepare: async () => { + const branch = await runCreateLinkedBranch({ getClient: () => client as never }, 'owner', 'repo', + kind === 'release' ? 'develop' : 'refs/tags/v3.3.0', operation.sourceBranch, 355, undefined, binding.token); + expect(branch.at(-1)?.success).toBe(true); + const content = ordinary ? { title: 'Ordinary work', body: 'Implements ordinary work' } + : renderPromotionPullRequest(operation, { owner: 'owner', repository: 'repo', issue: 355, + repositoryLocale: 'en-US', issueLocale: 'en-US', pullRequestLocale: 'en-US' }); + expect(content.body).not.toMatch(/(?:closes|resolves|fixes)\s+#355/iu); + return port.createManagedPullRequest({ operationId: operation.operationId, phase: 'promotion', issue: 355, + headBranch: operation.sourceBranch, baseBranch: 'master', ...content }); + } }; +} + +it.each(['release', 'hotfix'] as const)('keeps a %s launcher open before the closed handler despite native branch association', async kind => { + const legacy = provider(kind); const oldPull = await legacy.prepare(); + // This is the former merge effect, with its absent completion preparation represented explicitly. + await new DeploymentMergeEffects({ ...legacy.port, prepareIssueCompletion: async () => undefined }) + .configure(legacy.operation, oldPull, inspection, jest.fn(), jest.fn()); + expect(legacy.state().issueClosed).toBe(true); + expect(legacy.events.at(-1)).toBe('provider-closed-issue-before-handler'); + + const current = provider(kind); const pull = await current.prepare(); + expect(current.state().closing).toBe(true); + await new DeploymentMergeEffects(current.port).configure(current.operation, pull, inspection, jest.fn(), jest.fn()); + expect(current.state()).toEqual({ issueClosed: false, projectStatus: 'In progress', closing: false }); + expect(current.operation.publicationVerified).toBe(false); expect(current.operation.reconciliationTargets).toEqual([]); + const result = await current.metadata.invoke({ issueNumber: 355, + completionLabels: { lifecycle: DEFAULT_COPILOT_LIFECYCLE_LABELS, protectedLabels: [] } }); + expect(result[0]).toMatchObject({ success: true, executed: false, payload: { issueCompletion: { outcome: 'deferred' } } }); + expect(projectClosedIssueCleanupCounts(result)).toEqual({ status: 'absent' }); + expect(current.labels.has('100%')).toBe(false); expect(current.merge).toHaveBeenCalledTimes(1); +}); +it('blocks native merge if its launcher closing relationship cannot be removed', async () => { + const current = provider('release'); const pull = await current.prepare(); current.setSticky(); + await expect(new DeploymentMergeEffects(current.port).configure(current.operation, pull, inspection, jest.fn(), jest.fn())) + .rejects.toMatchObject({ code: 'provider.conflict' }); + expect(current.merge).not.toHaveBeenCalled(); expect(current.state().issueClosed).toBe(false); +}); +it('preserves normal native auto-closure and 100% metadata for an ordinary linked issue', async () => { + const ordinary = provider('release', true); const pull = await ordinary.prepare(); + await ordinary.port.mergePullRequest(pull.number, pull.headSha); + expect(ordinary.state().issueClosed).toBe(true); + const results = await ordinary.metadata.invoke({ issueNumber: 355, + completionLabels: { lifecycle: DEFAULT_COPILOT_LIFECYCLE_LABELS, protectedLabels: [] } }); + expect(results[0].success).toBe(true); expect(ordinary.labels.has('100%')).toBe(true); + expect(ordinary.state().projectStatus).toBe('Done'); +}); diff --git a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts index 4db5e93b6..ac87fa40f 100644 --- a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts @@ -1,3 +1,4 @@ +import { Result } from "../../../../data/model/result"; import { DEFAULT_DEPLOYMENT_CONFIGURATION } from "../../../../domain/deployment_configuration"; import type { DeploymentOperationSnapshot, DeploymentPhase } from "../../../../domain/deployment_operation"; import type { Execution } from "../../../../data/model/execution"; @@ -143,7 +144,8 @@ function harness() { findManagedPullRequests: jest.fn().mockResolvedValue([]), createManagedPullRequest: jest.fn().mockResolvedValue(pr()), getPullRequest: jest.fn().mockResolvedValue(pr()), - enableAutoMerge: jest.fn(), + prepareIssueCompletion: jest.fn(), + enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn().mockResolvedValue(false), enqueuePullRequest: jest.fn(), mergePullRequest: jest.fn(), @@ -193,7 +195,7 @@ function harness() { }; const stateFactory = { bind: jest.fn(() => state) }; const labels = { getLabels: jest.fn().mockResolvedValue(["release", "deploy"]), setLabels: jest.fn() }; - const issues = { closeIssue: jest.fn().mockResolvedValue(true), addComment: jest.fn() }; + const issues = { reconcileCompletion: jest.fn().mockResolvedValue([new Result({ id: 'ReconcileClosedIssueMetadataUseCase', success: true, executed: false, payload: { issueCompletion: { nativeClosed: true, reconciled: true, outcome: 'already-converged' } } })]), closeIssue: jest.fn().mockResolvedValue(true), addComment: jest.fn() }; const useCase = new DeploymentOrchestrationUseCase({ pullRequests, targetRules, diff --git a/src/application/usecases/actions/__tests__/deployment_phase_handlers.test.ts b/src/application/usecases/actions/__tests__/deployment_phase_handlers.test.ts index 2eade7f04..4c98f1b69 100644 --- a/src/application/usecases/actions/__tests__/deployment_phase_handlers.test.ts +++ b/src/application/usecases/actions/__tests__/deployment_phase_handlers.test.ts @@ -146,6 +146,7 @@ function runtimeHarness() { findManagedPullRequests: jest.fn().mockResolvedValue([]), createManagedPullRequest: jest.fn().mockResolvedValue(managedPullRequest({ state: "open", merged: false })), getPullRequest: jest.fn().mockResolvedValue(managedPullRequest()), + prepareIssueCompletion: jest.fn(), enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn().mockResolvedValue(false), enqueuePullRequest: jest.fn(), @@ -170,7 +171,7 @@ function runtimeHarness() { }, }), }, - issues: { closeIssue: jest.fn(), addComment: jest.fn() }, + issues: { reconcileCompletion: jest.fn().mockResolvedValue([new Result({ id: 'ReconcileClosedIssueMetadataUseCase', success: true, executed: false, payload: { issueCompletion: { nativeClosed: true, reconciled: true, outcome: 'already-converged' } } })]), closeIssue: jest.fn(), addComment: jest.fn() }, }; const blockedResult = new Result({ id: "test", success: false, executed: true }); const runtime = { @@ -196,6 +197,66 @@ function runtimeHarness() { return { runtime, dependencies, blockedResult }; } +describe('verified final native issue completion boundary', () => { + it('persists completed functional state before native closure and then verifies metadata', async () => { + const { runtime, dependencies } = runtimeHarness(); const state = operation('reconciliation_pending'); + const request = { ...context('published', state), tokenUser: 'bot', + labels: { ...context('published', state).labels, completionProtectedLabels: ['branched'] } }; + dependencies.issues.reconcileCompletion.mockResolvedValue([new Result({ id: 'metadata', success: true, executed: true, + payload: { issueCompletion: { nativeClosed: true, reconciled: true } } }), + new Result({ id: 'cards', success: true, executed: false, payload: { nativeClosed: true, reconciled: true } })]); + dependencies.issues.closeIssue.mockImplementation(async () => { + expect(request.currentConfiguration.deploymentOrchestration?.phase).toBe('completed'); return true; + }); + const result = await new ReconciliationHandler(runtime).complete(request, state); + expect(result.success).toBe(true); + expect((runtime.cleanup as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan((runtime.persist as jest.Mock).mock.invocationCallOrder[0]); + expect((runtime.persist as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(dependencies.issues.closeIssue.mock.invocationCallOrder[0]); + expect(dependencies.issues.reconcileCompletion).toHaveBeenCalledWith(expect.objectContaining({ issueNumber: 355, + trustedBotLogin: 'bot', completionLabels: { lifecycle: DEFAULT_COPILOT_LIFECYCLE_LABELS, protectedLabels: ['branched'] } })); + }); + it.each(['close', 'metadata', 'metadata-without-diagnostic', 'empty', 'unverified'] as const)('retries %s projection without repeating cleanup or publication', async failure => { + const { runtime, dependencies } = runtimeHarness(); const state = operation('reconciliation_pending'); + const request = context('published', state); const complete = new ReconciliationHandler(runtime); + if (failure === 'close') dependencies.issues.closeIssue.mockRejectedValueOnce(new Error('Native close unavailable')); + if (failure === 'metadata') dependencies.issues.reconcileCompletion.mockResolvedValueOnce([new Result({ id: 'metadata', + success: false, executed: true, errors: [new ApplicationError('provider.unavailable', 'Metadata unavailable')] })]); + if (failure === 'metadata-without-diagnostic') dependencies.issues.reconcileCompletion.mockResolvedValueOnce([new Result({ + id: 'metadata', success: false, executed: false })]); + if (failure === 'empty') dependencies.issues.reconcileCompletion.mockResolvedValueOnce([]); + if (failure === 'unverified') dependencies.issues.reconcileCompletion.mockResolvedValueOnce([new Result({ id: 'metadata', + success: true, executed: false, payload: { issueCompletion: { nativeClosed: false, reconciled: false } } })]); + expect(await complete.complete(request, state)).toMatchObject({ success: false, + payload: { deployment: { phase: 'completed', issueCompletionVerified: false } } }); + expect(request.currentConfiguration.deploymentOrchestration?.phase).toBe('completed'); + const retried = await new ConfirmPublicationHandler(runtime, complete).invoke(request); + expect(retried.success).toBe(true); expect(runtime.cleanup).toHaveBeenCalledTimes(1); + expect(runtime.persist).toHaveBeenCalledTimes(1); expect(runtime.block).not.toHaveBeenCalled(); + expect(dependencies.continuation.dispatch).not.toHaveBeenCalled(); + expect(request.currentConfiguration.deploymentOrchestration?.publicationReceipt).toEqual(state.publicationReceipt); + }); + it('retains a configured keep-open launcher after full completion', async () => { + const { runtime, dependencies } = runtimeHarness(); const state = operation('reconciliation_pending', { issueCompletion: 'keep-open' }); + expect((await new ReconciliationHandler(runtime).complete(context('published', state), state)).success).toBe(true); + expect(dependencies.issues.closeIssue).not.toHaveBeenCalled(); expect(dependencies.issues.reconcileCompletion).not.toHaveBeenCalled(); + }); + it('rejects incomplete publication or reconciliation before cleanup', async () => { + for (const change of [{ publicationVerified: false }, { publicationReceipt: undefined }, + { reconciliationTargets: [{ targetBranch: 'develop', sourceBranch: 'master', sourceSha, status: 'pending' as const }] }]) { + const { runtime, dependencies } = runtimeHarness(); const state = operation('reconciliation_pending', change); + await expect(new ReconciliationHandler(runtime).complete(context('published', state), state)).rejects.toMatchObject({ code: 'workflow.stale' }); + expect(runtime.cleanup).not.toHaveBeenCalled(); expect(dependencies.issues.closeIssue).not.toHaveBeenCalled(); + } + }); + it('retains the pending operation when cleanup fails and never closes the issue', async () => { + const { runtime, dependencies } = runtimeHarness(); const state = operation('reconciliation_pending'); + (runtime.cleanup as jest.Mock).mockRejectedValue(new Error('Cleanup unavailable')); + expect((await new ReconciliationHandler(runtime).complete(context('published', state), state)).success).toBe(false); + expect(runtime.persist).not.toHaveBeenCalled(); expect(dependencies.issues.closeIssue).not.toHaveBeenCalled(); + expect(runtime.block).toHaveBeenCalledWith(expect.anything(), state, 'cleanup', expect.any(String), true, expect.anything()); + }); +}); + describe("deployment phase handlers", () => { it.each([ [operation("blocked"), managedPullRequest(), "out-of-order"], @@ -233,9 +294,9 @@ describe("deployment phase handlers", () => { it("rechecks receipts on duplicate completed publication", async () => { const { runtime, dependencies } = runtimeHarness(); - const reconciliation = { plan: jest.fn() } as unknown as ReconciliationHandler; + const reconciliation = { plan: jest.fn(), complete: jest.fn() } as unknown as ReconciliationHandler; await new ConfirmPublicationHandler(runtime, reconciliation).invoke(context("published", operation("completed"))); - expect(runtime.publishDashboard).toHaveBeenCalled(); + expect(reconciliation.complete).toHaveBeenCalled(); expect(dependencies.publication.inspect).toHaveBeenCalled(); }); diff --git a/src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts b/src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts index 26c71d226..0ed058dc3 100644 --- a/src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts @@ -17,6 +17,22 @@ function fixture(reason: NativeIssueCompletionFacts['reason'] = 'completed') { } describe('guarded native-close metadata repair', () => { + it('defers progress and lifecycle cleanup for a pending deployment despite native closure', async () => { + const f = fixture(); const facts = await f.target.read(); + f.target.read.mockResolvedValue({ ...facts, deploymentCompletion: 'pending' } as never); + expect((await f.run())[0]).toMatchObject({ success: true, executed: false, + payload: { issueCompletion: { nativeClosed: true, deploymentCompletion: 'pending', outcome: 'deferred' } } }); + expect(f.target.addLabel).not.toHaveBeenCalled(); expect(f.target.removeLabel).not.toHaveBeenCalled(); + }); + it('does not close a pending launcher after a generic merged PR', async () => { + const f = fixture(); const close = jest.fn(); + f.target.read.mockResolvedValue({ number: 42, state: 'open', reason: 'unknown', labels: ['55%'], deploymentCompletion: 'pending' } as never); + const useCase = new CloseIssueAfterMergingUseCase({ forIssue: () => ({ ...f.target, close }), + readPullRequest: async () => ({ number: 10, state: 'closed', merged: true }) }); + expect((await useCase.invoke({ issueNumber: 42, pullRequestNumber: 10, completionLabels: configuration }))[0]) + .toMatchObject({ success: true, executed: false, payload: { issueCompletion: { outcome: 'deferred', nativeClosed: false } } }); + expect(close).not.toHaveBeenCalled(); expect(f.target.addLabel).not.toHaveBeenCalled(); + }); it.each(['completed', 'not_planned', 'duplicate', 'unknown'] as const)('repairs %s closure without changing native state or historical labels', async reason => { const f = fixture(reason); const [result] = await f.run(); expect(result).toMatchObject({ success: true, executed: true, payload: { issueCompletion: { nativeClosed: true, reconciled: true, reason } } }); diff --git a/src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts b/src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts index a053fa421..3e02bbef3 100644 --- a/src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts @@ -212,3 +212,12 @@ it('converges a relationship-retired historical card to native issue closure', a const [result]=await f.useCase.invoke(context); expect(result.success).toBe(true); expect(parsePublicationMarker(f.stored[0].body)?.sourceVersion).toBe('closed:issue'); }); + + +it('retains the active card while deployment completion is pending', async () => { + const f = fixture(); + f.issues.readIssue.mockResolvedValue({ number: 42, state: 'closed', reason: 'completed', labels: ['55%'], deploymentCompletion: 'pending' }); + expect((await f.useCase.invoke(context))[0]).toMatchObject({ success: true, executed: false, + payload: { outcome: 'deferred', nativeClosed: true, reconciled: false } }); + expect(f.comments.listIssueComments).not.toHaveBeenCalled(); expect(f.comments.updateComment).not.toHaveBeenCalled(); +}); diff --git a/src/application/usecases/actions/__tests__/retire_progress_use_case.test.ts b/src/application/usecases/actions/__tests__/retire_progress_use_case.test.ts index c0ea2fbcf..1b6ac568e 100644 --- a/src/application/usecases/actions/__tests__/retire_progress_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/retire_progress_use_case.test.ts @@ -216,3 +216,12 @@ it('bounds diagnostics for an oversized owned-card inventory without mutating it expect(result).toMatchObject({ success: false, errors: [{ recovery: { variables: { phase: 'card-inventory', remaining: 3000 } } }] }); expect(f.comments.updateComment).not.toHaveBeenCalled(); }); + + +it('retains the active card while deployment completion is pending', async () => { + const f = fixture(); + f.issues.readIssue.mockResolvedValue({ number: 42, state: 'closed', reason: 'completed', labels: ['55%'], deploymentCompletion: 'pending' }); + expect((await f.useCase.invoke(context))[0]).toMatchObject({ success: true, executed: false, + payload: { outcome: 'deferred', nativeClosed: true, reconciled: false } }); + expect(f.comments.listIssueComments).not.toHaveBeenCalled(); expect(f.comments.updateComment).not.toHaveBeenCalled(); +}); diff --git a/src/application/usecases/actions/deferred_deployment_completion.ts b/src/application/usecases/actions/deferred_deployment_completion.ts new file mode 100644 index 000000000..22af0e2ea --- /dev/null +++ b/src/application/usecases/actions/deferred_deployment_completion.ts @@ -0,0 +1,11 @@ +import { Result } from '../../../data/model/result'; +import type { NativeIssueCompletionFacts } from '../../../domain/issue_completion'; + +export function deferredDeploymentCompletionResult(id: string, facts: NativeIssueCompletionFacts, nested = false): Result { + const completion = { nativeClosed: facts.state === 'closed', reconciled: false, + deploymentCompletion: 'pending', outcome: 'deferred' }; + return new Result({ id, success: true, executed: false, + steps: ['Issue completion is deferred until deployment publication, reconciliation and cleanup are verified.', + ...(facts.state === 'closed' ? ['The existing native closure was retained for inspection.'] : [])], + payload: nested ? { issueCompletion: completion } : completion }); +} diff --git a/src/application/usecases/actions/deployment_handlers/confirm_publication_handler.ts b/src/application/usecases/actions/deployment_handlers/confirm_publication_handler.ts index 8de0865c7..7963ee9cb 100644 --- a/src/application/usecases/actions/deployment_handlers/confirm_publication_handler.ts +++ b/src/application/usecases/actions/deployment_handlers/confirm_publication_handler.ts @@ -68,8 +68,7 @@ export class ConfirmPublicationHandler { ?? deploymentSuccess(`Publication for ${resumed.tag} was already verified; reconciliation state was recovered.`); } if (resumed.phase === "completed" && resumed.publicationVerified) { - await this.runtime.publishDashboard(context, resumed); - return deploymentSuccess(`Publication for ${resumed.tag} was already verified; duplicate notification ignored.`); + return this.reconciliation.complete(context, resumed); } assertPublicationPhase(resumed); const published: DeploymentOperationSnapshot = { diff --git a/src/application/usecases/actions/deployment_handlers/reconciliation_handler.ts b/src/application/usecases/actions/deployment_handlers/reconciliation_handler.ts index a56968bbc..774d9bd2f 100644 --- a/src/application/usecases/actions/deployment_handlers/reconciliation_handler.ts +++ b/src/application/usecases/actions/deployment_handlers/reconciliation_handler.ts @@ -10,7 +10,8 @@ import { completeReconciliationTarget, type DeploymentOperationSnapshot, } from "../../../../domain/deployment_operation"; -import type { Result } from "../../../../data/model/result"; +import { Result, getResultPayload } from "../../../../data/model/result"; +import { ApplicationError, toApplicationError } from '../../../errors/application_error'; import { parseManagedPullRequestMarker } from "../../../../domain/managed_pull_request"; import { DeploymentOrchestrationRuntime, @@ -172,13 +173,12 @@ export class ReconciliationHandler { operation: DeploymentOperationSnapshot, completionContext = "", ): Promise { + if (!operation.publicationVerified || !operation.publicationReceipt + || !operation.reconciliationTargets.every(target => target.status === 'completed')) { + throw new ApplicationError('workflow.stale', 'Deployment completion requires verified publication and all configured reconciliation.'); + } try { - await this.runtime.cleanup(context, operation); - if (operation.issueCompletion === "close") { - await this.runtime.dependencies.issues.closeIssue( - context.singleAction.issue, - ); - } + if (operation.phase !== 'completed') await this.runtime.cleanup(context, operation); } catch (error) { const semanticError = semanticCleanupError(error); return await this.runtime.block( @@ -191,7 +191,29 @@ export class ReconciliationHandler { ); } const completed: DeploymentOperationSnapshot = { ...operation, phase: "completed", lastFailure: null }; - await this.runtime.persist(context, completed); + if (operation.phase !== 'completed') await this.runtime.persist(context, completed); + try { + if (completed.issueCompletion === 'close') { + await this.runtime.dependencies.issues.closeIssue(context.singleAction.issue); + const results = await this.runtime.dependencies.issues.reconcileCompletion({ + issueNumber: context.singleAction.issue, locale: context.locale.issue, trustedBotLogin: context.tokenUser, + completionLabels: { lifecycle: context.labels.lifecycle, + protectedLabels: context.labels.completionProtectedLabels ?? [] }, + }); + const failed = results.find(result => !result.success); + if (failed) throw failed.errors?.[0] ?? new ApplicationError('workflow.stale', 'Native issue completion metadata is unverified.'); + if (!results.length || results.some(result => { + const payload = getResultPayload(result.payload); + const completion = payload?.issueCompletion ? getResultPayload(payload.issueCompletion) : payload; + return completion?.nativeClosed !== true || completion.reconciled !== true; + })) throw new ApplicationError('workflow.stale', 'Native issue closure and completion metadata are not fully verified.'); + } + } catch (cause) { + const error = toApplicationError(cause, 'provider.unavailable', 'Deployment completed but native issue completion remains unverified.'); + return new Result({ id: 'DeploymentOrchestrationUseCase', success: false, executed: true, + steps: ['Verified publication, reconciliation and cleanup were retained. Retry the failed publication completion job to finish native issue completion.'], + errors: [error], payload: { deployment: { operationId: completed.operationId, phase: 'completed', issueCompletionVerified: false } } }); + } await this.runtime.publishDashboard(context, completed); await this.runtime.publishMilestone( context, diff --git a/src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts b/src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts index cd959560e..64769fc2e 100644 --- a/src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts +++ b/src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts @@ -1,5 +1,6 @@ import { Result } from '../../../data/model/result'; -import type { IssueCompletionLabelConfiguration } from '../../../domain/issue_completion'; +import { planIssueCompletion, type IssueCompletionLabelConfiguration } from '../../../domain/issue_completion'; +import { deferredDeploymentCompletionResult } from './deferred_deployment_completion'; import type { BoundIssueCompletionMetadataPort } from '../../ports/issue_completion_ports'; import { ApplicationError } from '../../errors/application_error'; import { closedIssueReconciliationError } from '../../errors/closed_issue_reconciliation_error'; @@ -21,7 +22,12 @@ export class ReconcileClosedIssueMetadataUseCase implements ParamUseCase 0, steps: ['The closed issue completion metadata was verified; historical and unrelated labels were retained.'], payload: { issueCompletion: { nativeClosed: progress.nativeClosed, labelsChanged: progress.labelsChanged, reconciled: true, ...repaired } } })]; diff --git a/src/application/usecases/actions/retire_branch_sync_use_case.ts b/src/application/usecases/actions/retire_branch_sync_use_case.ts index 3d61fda4b..af0f1929e 100644 --- a/src/application/usecases/actions/retire_branch_sync_use_case.ts +++ b/src/application/usecases/actions/retire_branch_sync_use_case.ts @@ -11,6 +11,7 @@ import { buildRetiredBranchSyncComment } from '../../policies/branch_sync_notifi import { resolveStaticBranchSyncCatalog } from '../../policies/branch_sync_message_catalog'; import { ApplicationError } from '../../errors/application_error'; import type { ParamUseCase } from '../base/param_usecase'; +import { deferredDeploymentCompletionResult } from './deferred_deployment_completion'; export interface RetireBranchSyncContext { readonly issueNumber: number; @@ -50,6 +51,7 @@ export class RetireBranchSyncUseCase implements ParamUseCase { phase = 'native-fence'; const fresh = await read(); - if (!nativeClosed || fresh.reason !== initial.reason) throw stale(); + if (!nativeClosed || fresh.reason !== initial.reason || fresh.deploymentCompletion === 'pending') throw stale(); }; for (const card of cards) { const marker = parsePublicationMarker(card.body)!; diff --git a/src/application/usecases/actions/retire_progress_use_case.ts b/src/application/usecases/actions/retire_progress_use_case.ts index b89b39a52..aba374ed1 100644 --- a/src/application/usecases/actions/retire_progress_use_case.ts +++ b/src/application/usecases/actions/retire_progress_use_case.ts @@ -10,6 +10,7 @@ import { parsePublicationMarker } from '../../policies/publication_identity_poli import { buildRetiredProgressComment } from '../../policies/retired_progress_presentation_policy'; import { ApplicationError } from '../../errors/application_error'; import type { ParamUseCase } from '../base/param_usecase'; +import { deferredDeploymentCompletionResult } from './deferred_deployment_completion'; export interface RetireProgressContext { readonly issueNumber: number; @@ -49,6 +50,7 @@ export class RetireProgressUseCase implements ParamUseCase { phase = 'native-fence'; const fresh = await read(); - if (!nativeClosed || fresh.reason !== initial.reason) throw stale(); + if (!nativeClosed || fresh.reason !== initial.reason || fresh.deploymentCompletion === 'pending') throw stale(); }; for (const card of cards) { const body = buildRetiredProgressComment(card.body ?? '', context.locale, initial.reason)!; diff --git a/src/application/usecases/push_single_action_contexts.ts b/src/application/usecases/push_single_action_contexts.ts index 6eabdc92a..77b89c13b 100644 --- a/src/application/usecases/push_single_action_contexts.ts +++ b/src/application/usecases/push_single_action_contexts.ts @@ -229,6 +229,7 @@ export function projectDeploymentOrchestrationContext( return { owner: source.owner, repo: source.repo, + ...(source.tokenUser ? { tokenUser: source.tokenUser } : {}), branches: Object.freeze({ ...source.branches }), workflows: Object.freeze({ ...source.workflows }), locale: Object.freeze({ ...source.locale }), @@ -243,6 +244,8 @@ export function projectDeploymentOrchestrationContext( deploy: source.labels.deploy, deployed: source.labels.deployed, lifecycle: Object.freeze({ ...source.labels.lifecycle }), + ...(source.labels.completionProtectedLabels + ? { completionProtectedLabels: Object.freeze([...source.labels.completionProtectedLabels]) } : {}), }), deployment: deepFreezeCopy(source.deployment) as DeploymentOrchestrationContext['deployment'], singleAction: Object.freeze({ diff --git a/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts b/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts index aa2343a90..52867543f 100644 --- a/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts +++ b/src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts @@ -7,6 +7,7 @@ import { ApplicationError } from '../../../errors/application_error'; import type { CloseIssueAfterMergeContext } from '../../issue_workflow_context'; import { parsePositiveSafeInteger } from '../../../../domain/positive_integer_policy'; import type { ParamUseCase } from '../../base/param_usecase'; +import { deferredDeploymentCompletionResult } from '../../actions/deferred_deployment_completion'; /** Native completion first, selective metadata second; retained effects survive partial failures. */ export class CloseIssueAfterMergingUseCase implements ParamUseCase { @@ -32,6 +33,7 @@ export class CloseIssueAfterMergingUseCase implements ParamUseCase', + headBranch: 'release/3.4.0', headSha: 'a'.repeat(40), baseBranch: 'master', state: 'open', merged: false, autoMergeEnabled: false }; +const reference = (number: number) => ({ number, repository: { nameWithOwner: 'owner/repo' } }); + +function fixture() { + let linked = true; + let sticky = false; + let mutated = false; + const issueId = 'I_355'; + let cursorMode = ''; + let patch: (value: ReturnType) => void = () => undefined; + const snapshot = () => { + const refs = linked ? [reference(355), reference(1)] : [reference(1)]; + const value = { repository: { nameWithOwner: 'owner/repo', issue: { id: issueId, number: 355 }, + pullRequest: { id: 'PR_40', number: 40, state: 'OPEN', body: record.body, headRefName: record.headBranch, + headRefOid: record.headSha, baseRefName: record.baseBranch, headRepository: { nameWithOwner: 'owner/repo' }, + closingIssuesReferences: { nodes: refs, pageInfo: { hasNextPage: false, endCursor: null as string | null } } } } }; + patch(value); return value; + }; + const graphql = jest.fn(async (query: string, variables: Record) => { + if (query.includes('removeCloseIssueReferences')) { + mutated = true; if (!sticky) linked = false; + return { removeCloseIssueReferences: { issue: { id: issueId, number: 355 } } }; + } + const value = snapshot(); + if (mutated && cursorMode) { + const page = value.repository.pullRequest.closingIssuesReferences; + page.nodes = Array.from({ length: 100 }, (_, i) => reference(i + (variables.cursor ? 101 : 1) + 500)); + page.pageInfo = { hasNextPage: ['excess', 'repeat'].includes(cursorMode) || !variables.cursor, + endCursor: cursorMode === 'missing' ? null : cursorMode === 'repeat' ? 'first' : variables.cursor ? 'second' : 'first' }; + } + return value; + }); + const client = { graphql: graphql as GithubDeploymentClient['graphql'] }; + return { graphql, run: (expected = record) => prepare(client, 'owner', 'repo', expected), + setLinked: (value: boolean) => { linked = value; }, setSticky: () => { sticky = true; }, + setPatch: (value: typeof patch) => { patch = value; }, setCursorMode: (value: string) => { cursorMode = value; }, + isLinked: () => linked, mutationCalls: () => graphql.mock.calls.filter(([q]) => q.includes('removeCloseIssueReferences')) }; +} + +it('removes only the owned launcher reference and verifies the other issue stays linked', async () => { + const f = fixture(); await f.run(); expect(f.isLinked()).toBe(false); + expect(f.mutationCalls()).toHaveLength(1); + expect(f.mutationCalls()[0][1]).toEqual({ issueId: 'I_355', pullRequestIds: ['PR_40'] }); + expect(f.graphql).toHaveBeenCalledTimes(3); +}); +it('is safe to replay after the reference was already detached', async () => { + const f = fixture(); f.setLinked(false); await f.run(); await f.run(); expect(f.isLinked()).toBe(false); +}); +it('rejects invalid expected identities before any provider call', async () => { + for (const change of [{ number: 0 }, { number: 355 }, { body: '' }, { repositoryFullName: 'other/repo' }, + { state: 'closed' as const }, { merged: true }, { nodeId: '' }, { headSha: 'wrong' }, + { body: 42 as never }, { repositoryFullName: 42 as never }, { nodeId: 42 as never }, + { headBranch: '' }, { headBranch: 42 as never }, { baseBranch: '' }, { baseBranch: 42 as never }, { headSha: 42 as never }]) { + const f = fixture(); await expect(f.run({ ...record, ...change })).rejects.toMatchObject({ code: 'provider.contract-invalid' }); + expect(f.graphql).not.toHaveBeenCalled(); + } +}); +it('rejects missing, foreign or changed native ownership before mutation', async () => { + const patches: Array['setPatch']>[0]> = [ + x => { x.repository.nameWithOwner = 'other/repo'; }, x => { x.repository.issue.number = 356; }, + x => { Object.assign(x.repository, { nameWithOwner: 42 }); }, + x => { Object.assign(x.repository.issue, { id: 42 }); }, + x => { x.repository.issue.id = ''; }, x => { x.repository.pullRequest.id = 'PR_41'; }, + x => { x.repository.pullRequest.number = 41; }, x => { x.repository.pullRequest.state = 'CLOSED'; }, + x => { x.repository.pullRequest.headRepository.nameWithOwner = 'fork/repo'; }, + x => { x.repository.pullRequest.headRefName = 'other'; }, x => { x.repository.pullRequest.headRefOid = 'b'.repeat(40); }, + x => { x.repository.pullRequest.baseRefName = 'other'; }, x => { x.repository.pullRequest.body = ''; }, + x => { x.repository.pullRequest.body = record.body.replace('operation-12345678', 'other-operation'); }, + x => { x.repository.pullRequest.body = record.body.replace('promotion', 'reconciliation'); }, + x => { x.repository.pullRequest.body = record.body.replace('355', '356'); }, + x => { x.repository.pullRequest.body += record.body; }, + ]; + for (const patch of patches) { const f = fixture(); f.setPatch(patch); + await expect(f.run()).rejects.toMatchObject({ code: 'provider.contract-invalid' }); expect(f.mutationCalls()).toHaveLength(0); } +}); +it('blocks retained keyword or native closing references after the unlink response', async () => { + const f = fixture(); f.setSticky(); await expect(f.run()).rejects.toMatchObject({ code: 'provider.conflict' }); +}); +it('maps read and mutation failures without granting merge authority', async () => { + for (const first of [true, false]) { const f = fixture(); + if (first) f.graphql.mockRejectedValueOnce(new Error('private provider response')); + else { const call = f.graphql.getMockImplementation()!; f.graphql.mockImplementation(async (q, v) => { + if (q.includes('removeCloseIssueReferences')) throw new Error('private provider response'); return call(q, v); }); } + const error = await f.run().then(() => { throw new Error('Expected provider failure.'); }, cause => cause); + expect(error).toMatchObject({ code: 'provider.unavailable' }); + expect(error.message).not.toContain('private provider response'); + } +}); +it('requires the mutation to confirm the exact launcher', async () => { + const f = fixture(); const call = f.graphql.getMockImplementation()!; + f.graphql.mockImplementation(async (q, v) => q.includes('removeCloseIssueReferences') + ? { removeCloseIssueReferences: { issue: { id: 'OTHER', number: 355 } } } : call(q, v)); + await expect(f.run()).rejects.toMatchObject({ code: 'provider.contract-invalid' }); +}); +it('checks both bounded pages before declaring the closing reference absent', async () => { + const f = fixture(); f.setCursorMode('two'); await f.run(); + expect(f.graphql.mock.calls.filter(([q]) => q.includes('query'))).toHaveLength(3); +}); +it('rejects a changed native launcher identity after the reference removal', async () => { + const f = fixture(); f.setPatch(x => { if (f.mutationCalls().length) x.repository.issue.id = 'OTHER'; }); + await expect(f.run()).rejects.toMatchObject({ code: 'provider.contract-invalid' }); +}); +it('rejects incomplete reference inventories or pagination', async () => { + for (const kind of ['nodes', 'limit', 'boolean', 'entry', 'null-entry', 'repository', 'repository-type', 'missing', 'repeat']) { + const f = fixture(); + if (['missing', 'repeat'].includes(kind)) f.setCursorMode(kind); + else f.setPatch(x => { const refs = x.repository.pullRequest.closingIssuesReferences; + if (kind === 'nodes') Object.assign(refs, { nodes: null }); + if (kind === 'limit') refs.nodes = Array.from({ length: 101 }, (_, i) => reference(i + 1)); + if (kind === 'boolean') Object.assign(refs.pageInfo, { hasNextPage: null }); + if (kind === 'entry') refs.nodes = [reference(0)]; + if (kind === 'null-entry') Object.assign(refs, { nodes: [null] }); + if (kind === 'repository') refs.nodes[0].repository.nameWithOwner = ''; + if (kind === 'repository-type') Object.assign(refs.nodes[0].repository, { nameWithOwner: 42 }); + }); + await expect(f.run()).rejects.toMatchObject({ code: 'provider.contract-invalid' }); + } +}); +it('fails closed when absence cannot be established within 200 references', async () => { + const f = fixture(); f.setCursorMode('excess'); await expect(f.run()).rejects.toThrow('200-reference'); +}); diff --git a/src/data/repository/deployment/github_deployment_issue_completion.ts b/src/data/repository/deployment/github_deployment_issue_completion.ts new file mode 100644 index 000000000..5dc2e035c --- /dev/null +++ b/src/data/repository/deployment/github_deployment_issue_completion.ts @@ -0,0 +1,106 @@ +import type { ManagedPullRequestRecord } from '../../../application/ports/deployment_orchestration_ports'; +import { ApplicationError, toApplicationError } from '../../../application/errors/application_error'; +import type { GithubDeploymentClient } from '../../../infrastructure/github/ports/github_deployment_provider_port'; +import { parseManagedPullRequestMarker } from '../../../domain/managed_pull_request'; + +interface Snapshot { + repository?: { + nameWithOwner?: string; + issue?: { id?: string; number?: number } | null; + pullRequest?: { + id?: string; number?: number; state?: string; body?: string | null; + headRefName?: string; headRefOid?: string; baseRefName?: string; + headRepository?: { nameWithOwner?: string } | null; + closingIssuesReferences?: { + nodes?: Array<{ number?: number; repository?: { nameWithOwner?: string } | null } | null>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + }; + } | null; + } | null; +} + +const query = `query DeploymentIssueCompletion($owner: String!, $repo: String!, $number: Int!, $issue: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + nameWithOwner + issue(number: $issue) { id number } + pullRequest(number: $number) { + id number state body headRefName headRefOid baseRefName + headRepository { nameWithOwner } + closingIssuesReferences(first: 100, after: $cursor) { + nodes { number repository { nameWithOwner } } + pageInfo { hasNextPage endCursor } + } + } + } +}`; + +/** Detach only the verified operation's launcher reference before native merge handoff. */ +export async function prepareManagedIssueCompletion( + client: Pick, owner: string, repository: string, + expected: ManagedPullRequestRecord, +): Promise { + if (typeof expected.body !== 'string' || typeof expected.repositoryFullName !== 'string' + || typeof expected.nodeId !== 'string' || !expected.nodeId + || typeof expected.headBranch !== 'string' || !expected.headBranch + || typeof expected.baseBranch !== 'string' || !expected.baseBranch + || typeof expected.headSha !== 'string') throw invalid(); + const marker = parseManagedPullRequestMarker(expected.body); + const fullName = `${owner}/${repository}`.toLowerCase(); + if (!marker || !Number.isSafeInteger(expected.number) || expected.number < 1 || marker.issue === expected.number + || !sameRepository(expected.repositoryFullName, fullName) || expected.state !== 'open' || expected.merged !== false + || !/^[a-f0-9]{40}$/iu.test(expected.headSha)) throw invalid(); + const read = async (cursor: string | null) => { + const snapshot = await client.graphql(query, { owner, repo: repository, number: expected.number, + issue: marker.issue, cursor }); + const resource = snapshot?.repository; + const issue = resource?.issue; + const pr = resource?.pullRequest; + const identity = parseManagedPullRequestMarker(pr?.body); + if (!sameRepository(resource?.nameWithOwner, fullName) || typeof issue?.id !== 'string' || !issue.id || issue.number !== marker.issue + || pr?.id !== expected.nodeId || pr.number !== expected.number || pr.state !== 'OPEN' + || !sameRepository(pr.headRepository?.nameWithOwner, fullName) + || pr.headRefName !== expected.headBranch || pr.headRefOid !== expected.headSha || pr.baseRefName !== expected.baseBranch + || !identity || identity.operationId !== marker.operationId || identity.phase !== marker.phase || identity.issue !== marker.issue + || pr.body!.split('`; + f.get.mockResolvedValue({ url: `${repositoryUrl}/issues/42`, data: { ...f.issue, body, state: 'open', state_reason: null } } as never); + expect(await f.repository.readIssue(42)).toMatchObject({ deploymentCompletion: expected }); + if (expected === 'pending') { await expect(f.repository.closeIssue(42)).rejects.toMatchObject({ code: 'workflow.stale' }); expect(f.update).not.toHaveBeenCalled(); } + else { expect(await f.repository.closeIssue(42)).toBe(true); expect(f.update).toHaveBeenCalledWith(expect.objectContaining({ state: 'closed' })); } + }); + it('keeps ordinary prose and configuration outside the typed deployment boundary', async () => { + const f = fixture(); + for (const body of ['Human', '']) { + f.get.mockResolvedValue({ url: `${repositoryUrl}/issues/42`, data: { ...f.issue, body } } as never); + expect(await f.repository.readIssue(42)).not.toHaveProperty('deploymentCompletion'); + } + }); + it('rejects ambiguous, malformed or unsupported durable completion evidence', async () => { + const f = fixture(); + const blocks = [42, '', + '', + ...[{ schemaVersion: 999, deploymentOrchestration: completedDeploymentFixture() }, + { schemaVersion: 3, deploymentOrchestration: { stateVersion: 99 } }].map(value => ``)]; + for (const body of blocks) { f.get.mockResolvedValue({ url: `${repositoryUrl}/issues/42`, data: { ...f.issue, body } } as never); + await expect(f.repository.readIssue(42)).rejects.toMatchObject({ code: 'provider.contract-invalid' }); } + expect(f.update).not.toHaveBeenCalled(); + }); it('normalizes an immutable complete inventory and rejects redirects at the HTTP boundary', async () => { const f = fixture(); const facts = await f.repository.readIssue(42); expect(facts).toEqual({ number: 42, state: 'closed', reason: 'completed', labels: ['SDD', '55%'] }); diff --git a/src/data/repository/issue/issue_completion_repository.ts b/src/data/repository/issue/issue_completion_repository.ts index 0b4684621..7643d0248 100644 --- a/src/data/repository/issue/issue_completion_repository.ts +++ b/src/data/repository/issue/issue_completion_repository.ts @@ -3,6 +3,9 @@ import { ISSUE_COMPLETION_LABEL_LIMIT, isIssueCompletionLabelName } from '../../ import type { GithubClientPort } from '../../../infrastructure/github/ports/github_client_provider_port'; import type { GithubIssueCompletionClient } from '../../../infrastructure/github/ports/github_issue_completion_client'; import { ApplicationError, toApplicationError } from '../../../application/errors/application_error'; +import { readConfigurationBlock } from '../../../manager/description/configuration_markers'; +import { projectDeploymentIssueCompletion } from '../../../domain/deployment_issue_completion'; +import { CONFIG_SCHEMA_VERSION } from '../../model/config'; interface Binding { readonly owner: string; readonly repository: string; readonly token: string; readonly apiBaseUrl: string } export interface NativePullRequestState { readonly number: number; readonly state: 'open' | 'closed'; readonly merged: boolean } @@ -34,9 +37,10 @@ export class IssueCompletionRepository { const labels = data.labels.map(value => typeof value === 'string' ? value : object(value).name); if (labels.some(value => !isIssueCompletionLabelName(value)) || new Set(labels.map(value => (value as string).toLowerCase())).size !== labels.length) throw invalid(); + const deploymentCompletion = readDeploymentCompletion(data.body); return Object.freeze({ number, state: data.state as 'open' | 'closed', reason: data.state_reason === null ? 'unknown' : data.state_reason as NativeIssueCompletionFacts['reason'], - labels: Object.freeze(labels as string[]) }); + labels: Object.freeze(labels as string[]), ...(deploymentCompletion ? { deploymentCompletion } : {}) }); } catch (error) { throw toApplicationError(error, 'provider.unavailable', 'Current native issue completion facts could not be verified.'); } } async readPullRequest(number: number): Promise { @@ -53,7 +57,9 @@ export class IssueCompletionRepository { } catch (error) { throw toApplicationError(error, 'provider.unavailable', 'Current native pull-request state could not be verified.'); } } async closeIssue(number: number): Promise { - if ((await this.readIssue(number)).state === 'closed') return false; + const facts = await this.readIssue(number); + if (facts.deploymentCompletion === 'pending') throw new ApplicationError('workflow.stale', 'The native deployment has not reached its completion boundary.'); + if (facts.state === 'closed') return false; try { await this.client.getClient(this.binding.token).rest.issues.update({ ...this.parameters(number), state: 'closed', state_reason: 'completed' }); return true; @@ -75,3 +81,16 @@ function object(value: unknown): Record { function invalid(): ApplicationError { return new ApplicationError('provider.contract-invalid', 'Native completion facts do not match the verified repository and subject.'); } + +function readDeploymentCompletion(body: unknown): NativeIssueCompletionFacts['deploymentCompletion'] { + if (body === undefined || body === null) return undefined; + if (typeof body !== 'string') throw invalid(); + const block = readConfigurationBlock(body); + if (block.kind === 'invalid') throw invalid(); + if (block.kind === 'absent') return undefined; + let payload: Record; + try { payload = object(JSON.parse(block.content)); } catch { throw invalid(); } + if (payload.deploymentOrchestration === undefined) return undefined; + if (payload.schemaVersion !== CONFIG_SCHEMA_VERSION) throw invalid(); + try { return projectDeploymentIssueCompletion(payload.deploymentOrchestration); } catch { throw invalid(); } +} diff --git a/src/domain/__tests__/deployment_issue_completion.test.ts b/src/domain/__tests__/deployment_issue_completion.test.ts new file mode 100644 index 000000000..509da3598 --- /dev/null +++ b/src/domain/__tests__/deployment_issue_completion.test.ts @@ -0,0 +1,21 @@ +import { projectDeploymentIssueCompletion as project } from '../deployment_issue_completion'; +import { blockDeploymentOperation, isDeploymentOperationSnapshot } from '../deployment_operation'; +import { deploymentOperationFixture as operation, completedDeploymentFixture as completed } from '../../testing/deployment_operation_fixture'; + +it('keeps ordinary issues outside the deployment completion boundary', () => expect(project(undefined)).toBeUndefined()); +it.each([null, {}, { stateVersion: 2 }])('rejects invalid or future durable state %j', value => expect(() => project(value)).toThrow()); +it.each(['preparing', 'publishing', 'reconciliation_pending'] as const)('defers %s even if native issue closure already occurred', phase => { + const state = operation({ phase }); expect(isDeploymentOperationSnapshot(state)).toBe(true); + expect(project(state)).toBe('pending'); +}); +it('defers retryable publication failure without discarding its operation', () => { + const state = blockDeploymentOperation(operation({ phase: 'publishing' }), 'publication', 'Protected publication pending', true); + expect(project(state)).toBe('pending'); expect(state.operationId).toBe('operation-12345678'); +}); +it('accepts only verified completed functional state', () => expect(project(completed())).toBe('complete')); +it('does not trust a completed phase without publication', () => expect(project(operation({ phase: 'completed' }))).toBe('pending')); +it('rejects a claimed publication without its immutable receipt', () => expect(() => project(completed({ publicationReceipt: undefined }))).toThrow()); +it('defers a completed claim with a reconciliation target still pending', () => { + expect(project(completed({ reconciliationTargets: [{ targetBranch: 'develop', sourceBranch: 'master', + sourceSha: 'c'.repeat(40), status: 'pending' }] }))).toBe('pending'); +}); diff --git a/src/domain/__tests__/issue_completion.test.ts b/src/domain/__tests__/issue_completion.test.ts index beef3c2dd..124f2b101 100644 --- a/src/domain/__tests__/issue_completion.test.ts +++ b/src/domain/__tests__/issue_completion.test.ts @@ -7,6 +7,19 @@ const facts = (overrides: Partial = {}): NativeIssue number: 17, state: 'closed', reason: 'completed', labels: [], ...overrides, }); describe('verified issue completion plan', () => { + it.each(['open', 'closed'] as const)('defers a pending native deployment on a %s issue', state => { + expect(planIssueCompletion(facts({ state, reason: state === 'open' ? 'unknown' : 'completed', + deploymentCompletion: 'pending', labels: ['state:working', '55%'] }), configuration)) + .toEqual({ kind: 'deferred' }); + }); + it('retains ordinary completion after the durable deployment boundary is verified', () => { + expect(planIssueCompletion(facts({ deploymentCompletion: 'complete' }), configuration)) + .toMatchObject({ kind: 'closed', add: ['100%'] }); + }); + it('refuses an unknown deployment completion fact instead of treating it as ordinary', () => { + expect(planIssueCompletion(facts({ deploymentCompletion: 'unknown' as never }), configuration)) + .toMatchObject({ kind: 'invalid', code: 'invalid-metadata' }); + }); it('removes only managed activity and stale progress while preserving kind and artifacts', () => { const active = ['in-progress', ...Object.values(DEFAULT_COPILOT_LIFECYCLE_LABELS), '55%']; const plan = planIssueCompletion(facts({ labels: [...active, ...configuration.protectedLabels, 'external'] }), configuration); diff --git a/src/domain/deployment_issue_completion.ts b/src/domain/deployment_issue_completion.ts new file mode 100644 index 000000000..ab66c4832 --- /dev/null +++ b/src/domain/deployment_issue_completion.ts @@ -0,0 +1,12 @@ +import { isDeploymentOperationSnapshot } from './deployment_operation'; + +export type DeploymentIssueCompletion = 'pending' | 'complete'; + +/** Ordinary issues have no operation. Unknown stored state cannot certify completion. */ +export function projectDeploymentIssueCompletion(value: unknown): DeploymentIssueCompletion | undefined { + if (value === undefined) return undefined; + if (!isDeploymentOperationSnapshot(value)) throw new Error('Durable deployment completion state is invalid or unsupported.'); + return value.phase === 'completed' && value.publicationVerified && value.publicationReceipt !== undefined + && value.reconciliationTargets.every(target => target.status === 'completed') + ? 'complete' : 'pending'; +} diff --git a/src/domain/issue_completion.ts b/src/domain/issue_completion.ts index 567d17a3d..19604e8d9 100644 --- a/src/domain/issue_completion.ts +++ b/src/domain/issue_completion.ts @@ -6,6 +6,7 @@ export interface NativeIssueCompletionFacts { readonly state: 'open' | 'closed'; readonly reason: 'completed' | 'not_planned' | 'duplicate' | 'reopened' | 'unknown'; readonly labels: readonly string[]; + readonly deploymentCompletion?: 'pending' | 'complete'; } export interface IssueCompletionLabelConfiguration { @@ -17,6 +18,7 @@ export const ISSUE_COMPLETION_LABEL_LIMIT = 1_000; export type IssueCompletionPlan = | { readonly kind: 'invalid'; readonly code: 'invalid-identity' | 'invalid-metadata' | 'invalid-configuration' } | { readonly kind: 'open' } + | { readonly kind: 'deferred' } | { readonly kind: 'closed'; readonly reason: NativeIssueCompletionFacts['reason']; readonly remove: readonly string[]; readonly add: readonly string[] }; const canonicalProgress = /^(?:0|[1-9][0-9]?|100)%$/u; export const isIssueCompletionLabelName = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 100 @@ -29,6 +31,7 @@ export function planIssueCompletion(facts: NativeIssueCompletionFacts, configura if (!Number.isSafeInteger(facts?.number) || facts.number < 1) return Object.freeze({ kind: 'invalid', code: 'invalid-identity' }); if (!['open', 'closed'].includes(facts.state) || !['completed', 'not_planned', 'duplicate', 'reopened', 'unknown'].includes(facts.reason) || !labelsValid(facts.labels) + || facts.deploymentCompletion !== undefined && !['pending', 'complete'].includes(facts.deploymentCompletion) || facts.state === 'open' && ['completed', 'not_planned', 'duplicate'].includes(facts.reason) || facts.state === 'closed' && facts.reason === 'reopened') return Object.freeze({ kind: 'invalid', code: 'invalid-metadata' }); const keys = Object.keys(DEFAULT_COPILOT_LIFECYCLE_LABELS) as (keyof typeof DEFAULT_COPILOT_LIFECYCLE_LABELS)[]; @@ -38,6 +41,7 @@ export function planIssueCompletion(facts: NativeIssueCompletionFacts, configura if (configuration.protectedLabels.some(name => managed.has(name.toLowerCase()) || canonicalProgress.test(name))) { return Object.freeze({ kind: 'invalid', code: 'invalid-configuration' }); } + if (facts.deploymentCompletion === 'pending') return Object.freeze({ kind: 'deferred' }); if (facts.state === 'open') return Object.freeze({ kind: 'open' }); const completed = facts.reason === 'completed'; return Object.freeze({ kind: 'closed', reason: facts.reason, diff --git a/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts b/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts index b8d26710b..f882e56fb 100644 --- a/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts +++ b/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts @@ -152,6 +152,7 @@ describe('push and single-action capability binding', () => { findManagedPullRequests: jest.fn(), createManagedPullRequest: jest.fn(), getPullRequest, + prepareIssueCompletion: jest.fn(), enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn(), enqueuePullRequest: jest.fn(), @@ -234,6 +235,7 @@ describe('push and single-action capability binding', () => { }; const pullRequestPort = { findManagedPullRequests: jest.fn(), createManagedPullRequest: jest.fn(), getPullRequest: jest.fn(), + prepareIssueCompletion: jest.fn(), enableAutoMerge: jest.fn(), isPullRequestQueued: jest.fn(), enqueuePullRequest: jest.fn(), mergePullRequest: jest.fn(), }; const presentationPort = { diff --git a/src/infrastructure/composition/main_run_route_composition_root.ts b/src/infrastructure/composition/main_run_route_composition_root.ts index 22cfa592e..d99ec344d 100644 --- a/src/infrastructure/composition/main_run_route_composition_root.ts +++ b/src/infrastructure/composition/main_run_route_composition_root.ts @@ -188,6 +188,7 @@ function createDeploymentOrchestrationUseCase( catalogResolver: ResolveMessageCatalogUseCase, ): DeploymentOrchestrationUseCase { const deploymentClient = new OctokitDeploymentClientAdapter(); + const completion = createClosedIssueRetirementUseCase(binding); return new DeploymentOrchestrationUseCase({ pullRequests: bindManagedPullRequests(new GithubManagedPullRequestRepository(deploymentClient), binding), targetRules: bindDeploymentTargetRules(new GithubTargetMergeCapabilitiesInspector(deploymentClient), binding), @@ -199,7 +200,10 @@ function createDeploymentOrchestrationUseCase( publication: bindDeploymentPublicationReceipt(publication, binding), state: bindDeploymentState(new DeploymentStateRepositoryFactory(issueDescriptionQueryPort), binding), labels: bindDeploymentLabels(createIssueLabelRepository(), binding), - issues: bindDeploymentIssues(createIssueClosureRepository(), binding), + issues: { + ...bindDeploymentIssues(createIssueClosureRepository(), binding), + reconcileCompletion: completion.invoke.bind(completion), + }, replacementInspection: createDeploymentCandidateInspection(binding), operationId: randomUUID, catalogResolver, diff --git a/src/infrastructure/composition/push_single_action_capability_port_binding.ts b/src/infrastructure/composition/push_single_action_capability_port_binding.ts index 950888383..0ec44d2b5 100644 --- a/src/infrastructure/composition/push_single_action_capability_port_binding.ts +++ b/src/infrastructure/composition/push_single_action_capability_port_binding.ts @@ -69,6 +69,7 @@ export function bindManagedPullRequests( binding: RepositoryCredentialBinding, ): BoundManagedPullRequestPort { return Object.freeze({ + prepareIssueCompletion: pullRequest => port.prepareIssueCompletion(binding.owner, binding.repository, pullRequest, binding.token), findManagedPullRequests: (query) => port.findManagedPullRequests({ ...query, owner: binding.owner, repository: binding.repository, token: binding.token }), createManagedPullRequest: (command) => port.createManagedPullRequest({ ...command, owner: binding.owner, repository: binding.repository, token: binding.token }), getPullRequest: (pullRequest) => port.getPullRequest(binding.owner, binding.repository, pullRequest, binding.token), diff --git a/src/testing/deployment_operation_fixture.ts b/src/testing/deployment_operation_fixture.ts new file mode 100644 index 000000000..95c706e13 --- /dev/null +++ b/src/testing/deployment_operation_fixture.ts @@ -0,0 +1,21 @@ +import type { DeploymentOperationSnapshot } from '../domain/deployment_operation'; + +/** Complete synthetic state shared only by deployment boundary regression tests. */ +export function deploymentOperationFixture(overrides: Partial = {}): DeploymentOperationSnapshot { + return { stateVersion: 1, revision: 1, operationId: 'operation-12345678', + locale: { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }, + kind: 'release', version: '3.4.0', title: 'Release', changelog: 'Synthetic changes', + phase: 'preparing', strategy: 'production-lineage', prMode: 'auto', backmergeMode: 'auto', + hotfixActiveReleasePolicy: 'prefer-release', cleanup: 'all', issueCompletion: 'close', + presentationMode: 'guided', diagrams: true, commentMode: 'update', sourceBranch: 'release/3.4.0', + sourceSha: 'a'.repeat(40), originBranch: 'develop', originSha: 'b'.repeat(40), + productionBranch: 'master', developmentBranch: 'develop', reconciliationTree: 'sync', + tag: 'v3.4.0', publicationWorkflow: 'release_workflow.yml', publicationVerified: false, + reconciliationTargets: [], lastFailure: null, ...overrides }; +} + +export function completedDeploymentFixture(overrides: Partial = {}): DeploymentOperationSnapshot { + return deploymentOperationFixture({ phase: 'completed', productionSha: 'c'.repeat(40), publicationVerified: true, + publicationReceipt: { tag: 'v3.4.0', productionSha: 'c'.repeat(40), operationId: 'operation-12345678', + releaseUrl: 'https://github.com/owner/repo/releases/tag/v3.4.0' }, ...overrides }); +}