From 1ef7ec45ad33c96c3531b0eda87bdb5cc67bcfd8 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 08:39:04 +0200 Subject: [PATCH] codex-publication-source-freshness: Guard progress publication against stale branch heads --- build/cli/index.js | 181 +++++++++++- build/github_action/index.js | 261 ++++++++++++++++-- docs/development/architecture.mdx | 11 + docs/features.mdx | 6 +- docs/issues/notifications-and-auto-close.mdx | 9 +- docs/single-actions/available-actions.mdx | 2 +- docs/single-actions/workflow-and-cli.mdx | 7 + scripts/coverage-budgets.json | 27 ++ specs/CATALOG.md | 8 +- specs/catalog.json | 16 ++ ...tic-github-publication-and-notification.md | 30 +- src/__tests__/cli.test.ts | 21 +- .../github_action_completion.test.ts | 27 ++ .../__tests__/github_event_inputs.test.ts | 18 ++ src/actions/github_action.ts | 7 + src/actions/github_action_completion.ts | 7 +- src/actions/github_event_inputs.ts | 4 + .../__tests__/action_summary_policy.test.ts | 17 ++ .../publication_outcome_policy.test.ts | 30 ++ ...semantic_result_publication_policy.test.ts | 25 +- .../action_summary_message_catalog.ts | 5 + .../policies/action_summary_policy.ts | 5 + .../policies/publication_outcome_policy.ts | 33 +++ .../semantic_result_publication_policy.ts | 14 +- .../ports/publication_freshness_ports.ts | 8 + .../push_single_action_contexts.test.ts | 8 + .../__tests__/check_progress_use_case.test.ts | 65 +++++ .../actions/check_progress_use_case.ts | 3 + .../actions/check_progress_workflow.ts | 65 ++++- .../actions/progress_analysis_workflow.ts | 9 + .../usecases/push_single_action_contexts.ts | 4 + .../__tests__/publish_resume_use_case.test.ts | 47 +++- .../status_card_publication_workflow.test.ts | 123 ++++++++- .../steps/common/publish_resume_use_case.ts | 4 +- .../steps/common/publish_resume_workflow.ts | 30 +- .../status_card_publication_workflow.ts | 44 ++- .../__tests__/issue_command_policy.test.ts | 5 +- src/cli/commands/check_progress.ts | 10 +- src/cli/commands/issue_command_policy.ts | 2 + src/cli_context.ts | 10 + ...thub_publication_source_repository.test.ts | 26 ++ .../github_publication_source_repository.ts | 26 ++ src/domain/__tests__/git_object_id.test.ts | 23 ++ src/domain/git_object_id.ts | 9 + src/domain/github_publication.ts | 5 + .../shared_capability_port_binding.test.ts | 8 + .../check_progress_composition_root.ts | 4 +- .../shared_capability_port_binding.ts | 18 ++ .../ports/github_branch_provider_ports.ts | 2 +- 49 files changed, 1242 insertions(+), 87 deletions(-) create mode 100644 src/application/policies/__tests__/publication_outcome_policy.test.ts create mode 100644 src/application/policies/publication_outcome_policy.ts create mode 100644 src/application/ports/publication_freshness_ports.ts create mode 100644 src/data/repository/__tests__/github_publication_source_repository.test.ts create mode 100644 src/data/repository/github_publication_source_repository.ts create mode 100644 src/domain/__tests__/git_object_id.test.ts create mode 100644 src/domain/git_object_id.ts diff --git a/build/cli/index.js b/build/cli/index.js index 5eb1b5d79..9e80211c8 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -44448,6 +44448,36 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ })); +/***/ }), + +/***/ 79719: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; +exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; +const result_1 = __nccwpck_require__(73817); +/** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ +function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { + return Object.freeze({ + publicationOutcome: Object.freeze({ + reason: 'stale-source', + branch, + sourceHeadSha, + }), + }); +} +function hasStaleSourcePublicationOutcome(results) { + return results.some(result => { + const payload = (0, result_1.getResultPayload)(result.payload); + const outcome = (0, result_1.getResultPayload)(payload?.publicationOutcome); + return outcome?.reason === 'stale-source'; + }); +} + + /***/ }), /***/ 43268: @@ -44823,6 +44853,7 @@ const copilot_interaction_policy_1 = __nccwpck_require__(90108); const status_command_policy_1 = __nccwpck_require__(3449); const comment_translation_policy_1 = __nccwpck_require__(27150); const application_error_presentation_policy_1 = __nccwpck_require__(95067); +const git_object_id_1 = __nccwpck_require__(88623); function selectSemanticStatusIntents(context) { return Object.freeze(context.results.flatMap(result => { if (!result.executed || !result.success) @@ -45055,10 +45086,14 @@ function translationProjection(value) { }); } function progressIntent(id, payload, locale) { + const sourceHeadSha = (0, git_object_id_1.canonicalGitObjectId)(payload.sourceHeadSha); + const branch = typeof payload.branch === 'string' ? payload.branch.trim() : ''; if (id !== 'CheckProgressUseCase' || !positiveInteger(payload.issueNumber) || typeof payload.progress !== 'number' - || typeof payload.summary !== 'string') + || typeof payload.summary !== 'string' + || !sourceHeadSha + || !branch) return undefined; const progress = Math.max(0, Math.min(100, Math.round(payload.progress))); const projection = Object.freeze({ @@ -45068,12 +45103,15 @@ function progressIntent(id, payload, locale) { ...(typeof payload.remaining === 'string' && payload.remaining.trim() ? { remaining: payload.remaining.trim() } : {}), - ...(typeof payload.branch === 'string' && payload.branch.trim() ? { branch: payload.branch.trim() } : {}), + branch, ...(typeof payload.developmentBranch === 'string' && payload.developmentBranch.trim() ? { developmentBranch: payload.developmentBranch.trim() } : {}), }); - return statusIntent('progress', payload.issueNumber, 'work', `progress:${(0, publication_identity_policy_1.createSemanticDigest)(projection)}`, locale, projection); + return Object.freeze({ + ...statusIntent('progress', payload.issueNumber, 'work', `head:${sourceHeadSha}`, locale, projection), + sourceGuard: Object.freeze({ kind: 'branch-head', branch, sha: sourceHeadSha }), + }); } function statusIntent(topic, issueNumber, key, sourceVersion, locale, projection) { return Object.freeze({ @@ -47432,13 +47470,14 @@ exports.CheckProgressUseCase = void 0; const check_progress_workflow_1 = __nccwpck_require__(94343); /** Application boundary for assessing and publishing issue progress. */ class CheckProgressUseCase { - constructor(issueDescriptionQueryPort, issueLabelsPort, issueProgressPort, branchRepository, pullRequestRepository, aiRepository) { + constructor(issueDescriptionQueryPort, issueLabelsPort, issueProgressPort, branchRepository, pullRequestRepository, aiRepository, publicationSourceQuery) { this.issueDescriptionQueryPort = issueDescriptionQueryPort; this.issueLabelsPort = issueLabelsPort; this.issueProgressPort = issueProgressPort; this.branchRepository = branchRepository; this.pullRequestRepository = pullRequestRepository; this.aiRepository = aiRepository; + this.publicationSourceQuery = publicationSourceQuery; this.taskId = 'CheckProgressUseCase'; } async invoke(param) { @@ -47449,6 +47488,7 @@ class CheckProgressUseCase { issueLabelsPort: this.issueLabelsPort, issueProgressPort: this.issueProgressPort, aiRepository: this.aiRepository, + publicationSourceQuery: this.publicationSourceQuery, }); } } @@ -47471,6 +47511,7 @@ const sync_progress_labels_to_open_pull_requests_1 = __nccwpck_require__(18277); const progress_summary_builder_1 = __nccwpck_require__(62721); const progress_analysis_workflow_1 = __nccwpck_require__(88729); const application_error_1 = __nccwpck_require__(75999); +const publication_outcome_policy_1 = __nccwpck_require__(79719); /** Publishes a completed progress assessment after the analysis workflow succeeds. */ async function runCheckProgressWorkflow(param, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId}.`); @@ -47478,14 +47519,22 @@ async function runCheckProgressWorkflow(param, taskId, dependencies) { const analysis = await (0, progress_analysis_workflow_1.analyzeProgress)(param, taskId, dependencies); if (analysis.kind === 'failure') return [analysis.result]; - const { attemptResult, issueNumber, branch, developmentBranch } = analysis; + if (analysis.kind === 'stale-source') { + (0, logging_ports_1.logInfo)(`Progress analysis omitted: ${analysis.branch} no longer points at the event source.`); + return [buildStaleSourceResult(taskId, analysis.branch, analysis.sourceHeadSha)]; + } + const { attemptResult, issueNumber, branch, developmentBranch, sourceHeadSha } = analysis; const { progress, summary, reasoning, remaining } = attemptResult; logProgressAssessment(progress, summary, reasoning, remaining); + if (!await sourceIsCurrent(branch, sourceHeadSha, dependencies.publicationSourceQuery)) { + (0, logging_ports_1.logInfo)(`Progress mutation omitted: ${branch} no longer points at the analyzed source.`); + return [buildStaleSourceResult(taskId, branch, sourceHeadSha)]; + } if (progress === 0) { - return [buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning)]; + return [buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning, sourceHeadSha)]; } await persistProgress(param, issueNumber, branch, progress, dependencies); - return [buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining)]; + return [buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining, sourceHeadSha)]; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'workflow.failed', `Unable to complete ${taskId}.`); @@ -47500,7 +47549,18 @@ async function runCheckProgressWorkflow(param, taskId, dependencies) { ]; } } -function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning) { +async function sourceIsCurrent(branch, sourceHeadSha, sourceQuery) { + return await sourceQuery.getBranchHeadSha(branch) === sourceHeadSha; +} +function buildStaleSourceResult(taskId, branch, sourceHeadSha) { + return new result_1.Result({ + id: taskId, + success: true, + executed: false, + payload: (0, publication_outcome_policy_1.buildStaleSourcePublicationPayload)(branch, sourceHeadSha), + }); +} +function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning, sourceHeadSha) { const message = 'Progress detection returned 0%. This may be due to a model error or no changes detected. Consider re-running the check.'; (0, logging_ports_1.logError)(message); return new result_1.Result({ @@ -47509,14 +47569,22 @@ function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, executed: true, steps: [`Progress for issue #${issueNumber}: 0%`, summary], errors: [new application_error_1.ApplicationError('agent.failed', message)], - payload: { progress: 0, summary, reasoning: reasoning || undefined, issueNumber, branch, developmentBranch }, + payload: { + progress: 0, + summary, + reasoning: reasoning || undefined, + issueNumber, + branch, + developmentBranch, + sourceHeadSha, + }, }); } async function persistProgress(param, issueNumber, branch, progress, dependencies) { await dependencies.issueProgressPort.setProgressLabel(issueNumber, progress); await (0, sync_progress_labels_to_open_pull_requests_1.syncProgressLabelsToOpenPullRequests)(branch, progress, dependencies.issueLabelsPort, dependencies.pullRequestRepository); } -function buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining) { +function buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining, sourceHeadSha) { return new result_1.Result({ id: taskId, success: true, @@ -47530,6 +47598,7 @@ function buildProgressResult(taskId, issueNumber, branch, developmentBranch, pro issueNumber, branch, developmentBranch, + sourceHeadSha, }, }); } @@ -49126,6 +49195,10 @@ async function analyzeProgress(param, taskId, dependencies) { }; } const resolvedBranch = branch; + const sourceHeadSha = await dependencies.publicationSourceQuery.getBranchHeadSha(resolvedBranch); + if (param.sourceHeadSha && param.sourceHeadSha !== sourceHeadSha) { + return { kind: 'stale-source', branch: resolvedBranch, sourceHeadSha: param.sourceHeadSha }; + } const developmentBranch = param.developmentBranch; (0, logging_ports_1.logInfo)(`📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${resolvedBranch}" (configured agent will run git diff).`); const prompt = (0, prompts_1.getCheckProgressPrompt)({ @@ -49152,6 +49225,7 @@ async function analyzeProgress(param, taskId, dependencies) { issueNumber, branch: resolvedBranch, developmentBranch, + sourceHeadSha, attemptResult, }; } @@ -52014,6 +52088,7 @@ exports.projectInitialSetupContext = projectInitialSetupContext; exports.projectIssueCommentActionContext = projectIssueCommentActionContext; exports.projectAgentActivityContext = projectAgentActivityContext; const issue_comment_publication_policy_1 = __nccwpck_require__(61899); +const git_object_id_1 = __nccwpck_require__(88623); function projectDeploymentPublicationContext(source) { return Object.freeze({ requestedOperationId: source.singleAction.operationId, @@ -52051,6 +52126,7 @@ function projectDeploymentOrchestrationContext(source) { }; } function projectProgressContext(source) { + const sourceHeadSha = (0, git_object_id_1.canonicalGitObjectId)(source.inputs?.after); return Object.freeze({ issueNumber: source.issueNumber, pushedBranch: source.commit.branch, @@ -52066,6 +52142,7 @@ function projectProgressContext(source) { agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), targetLocale: source.locale?.issue ?? 'en-US', + ...(sourceHeadSha ? { sourceHeadSha } : {}), }); } function projectRecommendStepsContext(source) { @@ -61383,7 +61460,13 @@ function registerCheckProgressCommand(program) { process.exitCode = 1; return; } - const params = (0, issue_command_policy_1.buildCheckProgressParams)(options, gitInfo); + const sourceHeadSha = (0, cli_context_1.getCurrentHeadSha)(); + if (!sourceHeadSha) { + (0, logger_1.logError)('Unable to resolve the current Git revision for progress analysis.'); + process.exitCode = 1; + return; + } + const params = (0, issue_command_policy_1.buildCheckProgressParams)(options, gitInfo, sourceHeadSha); if (!params) return; try { @@ -61851,7 +61934,7 @@ function sharedOptions(options) { function parseIssueNumber(value) { return (0, command_input_policy_1.parsePositiveCliInteger)((0, command_input_policy_1.cleanCliArgument)(value)); } -function buildCheckProgressParams(options, gitInfo) { +function buildCheckProgressParams(options, gitInfo, sourceHeadSha) { if ('error' in gitInfo) return undefined; const issueNumber = parseIssueNumber(options.issue); @@ -61865,6 +61948,7 @@ function buildCheckProgressParams(options, gitInfo) { [input_keys_1.INPUT_KEYS.AI_IGNORE_FILES]: process.env.AI_IGNORE_FILES || 'build/*,dist/*,node_modules/*,*.d.ts', repo: { owner: gitInfo.owner, repo: gitInfo.repo }, issue: { number: issueNumber }, + after: sourceHeadSha, ...(branch ? { commits: { ref: `refs/heads/${branch}` } } : {}), [input_keys_1.INPUT_KEYS.WELCOME_TITLE]: '📊 Progress Check', [input_keys_1.INPUT_KEYS.WELCOME_MESSAGES]: [`Checking progress for issue #${issueNumber} in ${gitInfo.owner}/${gitInfo.repo}...`], @@ -63300,11 +63384,13 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.cleanCliArg = cleanCliArg; exports.getGitInfo = getGitInfo; exports.getCurrentBranch = getCurrentBranch; +exports.getCurrentHeadSha = getCurrentHeadSha; exports.isInsideGitRepo = isInsideGitRepo; exports.isGitRepositoryRoot = isGitRepositoryRoot; const child_process_1 = __nccwpck_require__(32081); const node_fs_1 = __nccwpck_require__(87561); const cli_errors_1 = __nccwpck_require__(81853); +const git_object_id_1 = __nccwpck_require__(88623); function cleanCliArg(value) { if (value == null) return ''; @@ -63331,6 +63417,15 @@ function getCurrentBranch() { return 'main'; } } +/** Returns the canonical object ID for the workspace revision being analyzed. */ +function getCurrentHeadSha() { + try { + return (0, git_object_id_1.canonicalGitObjectId)((0, child_process_1.execSync)('git rev-parse HEAD').toString().trim()); + } + catch { + return undefined; + } +} function isInsideGitRepo(cwd) { try { (0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' }); @@ -68305,6 +68400,38 @@ function requireObject(data, operation) { } +/***/ }), + +/***/ 52644: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GithubPublicationSourceRepository = void 0; +const application_error_1 = __nccwpck_require__(75999); +const git_object_id_1 = __nccwpck_require__(88623); +/** Reads the authoritative branch head without exposing Octokit to application code. */ +class GithubPublicationSourceRepository { + constructor(clientProvider) { + this.clientProvider = clientProvider; + } + async getBranchHeadSha(owner, repository, branch, token) { + const { data } = await this.clientProvider.getClient(token).rest.git.getRef({ + owner, + repo: repository, + ref: `heads/${branch}`, + }); + const sha = (0, git_object_id_1.canonicalGitObjectId)(data.object?.sha); + if (!sha) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'GitHub returned an invalid branch-head object ID.'); + } + return sha; + } +} +exports.GithubPublicationSourceRepository = GithubPublicationSourceRepository; + + /***/ }), /***/ 88593: @@ -73521,6 +73648,27 @@ function isRecord(value) { } +/***/ }), + +/***/ 88623: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.canonicalGitObjectId = canonicalGitObjectId; +const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; +/** Canonicalizes a real SHA-1/SHA-256 object ID and rejects webhook null sentinels. */ +function canonicalGitObjectId(value) { + if (typeof value !== 'string') + return undefined; + const normalized = value.trim().toLowerCase(); + if (!GIT_OBJECT_ID_PATTERN.test(normalized) || /^0+$/u.test(normalized)) + return undefined; + return normalized; +} + + /***/ }), /***/ 21486: @@ -75210,13 +75358,14 @@ const issue_progress_label_repository_1 = __nccwpck_require__(66610); const issue_progress_tracking_repository_1 = __nccwpck_require__(26674); const branch_lifecycle_repository_1 = __nccwpck_require__(19504); const pull_request_lifecycle_repository_1 = __nccwpck_require__(24189); +const github_publication_source_repository_1 = __nccwpck_require__(52644); const shared_capability_port_binding_1 = __nccwpck_require__(47399); const lifecycle_capability_port_binding_1 = __nccwpck_require__(85785); const push_single_action_capability_port_binding_1 = __nccwpck_require__(49417); function createCheckProgressCompositionRoot(binding) { const labels = new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()); const content = new issue_content_repository_1.IssueContentRepository((0, github_issue_client_factory_1.createIssueContentClient)()); - return new check_progress_use_case_1.CheckProgressUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(content, binding), (0, lifecycle_capability_port_binding_1.bindIssueLabels)(labels, binding), (0, push_single_action_capability_port_binding_1.bindIssueProgress)(new issue_progress_tracking_repository_1.IssueProgressTrackingRepository(content, labels, new issue_progress_label_repository_1.IssueProgressLabelRepository(new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()))), binding), (0, push_single_action_capability_port_binding_1.bindBranchListQuery)(new branch_lifecycle_repository_1.BranchLifecycleRepository((0, github_branch_client_factory_1.createBranchClient)()), binding), (0, push_single_action_capability_port_binding_1.bindPullRequestBranchQuery)(new pull_request_lifecycle_repository_1.PullRequestLifecycleRepository((0, github_pull_request_client_factory_1.createPullRequestLifecycleClient)()), binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()); + return new check_progress_use_case_1.CheckProgressUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(content, binding), (0, lifecycle_capability_port_binding_1.bindIssueLabels)(labels, binding), (0, push_single_action_capability_port_binding_1.bindIssueProgress)(new issue_progress_tracking_repository_1.IssueProgressTrackingRepository(content, labels, new issue_progress_label_repository_1.IssueProgressLabelRepository(new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()))), binding), (0, push_single_action_capability_port_binding_1.bindBranchListQuery)(new branch_lifecycle_repository_1.BranchLifecycleRepository((0, github_branch_client_factory_1.createBranchClient)()), binding), (0, push_single_action_capability_port_binding_1.bindPullRequestBranchQuery)(new pull_request_lifecycle_repository_1.PullRequestLifecycleRepository((0, github_pull_request_client_factory_1.createPullRequestLifecycleClient)()), binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)(), (0, shared_capability_port_binding_1.bindPublicationSourceQuery)(new github_publication_source_repository_1.GithubPublicationSourceRepository((0, github_branch_client_factory_1.createBranchClient)()), binding)); } @@ -76464,6 +76613,7 @@ function createSetupDoctorUseCase() { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.bindPublicationSourceQuery = bindPublicationSourceQuery; exports.bindOrganizationMembers = bindOrganizationMembers; exports.bindIssueDescriptionQuery = bindIssueDescriptionQuery; exports.bindIssueNotification = bindIssueNotification; @@ -76472,6 +76622,11 @@ exports.bindIssueCommentUpdate = bindIssueCommentUpdate; exports.bindIssueTitle = bindIssueTitle; exports.bindProjectContent = bindProjectContent; const project_detail_1 = __nccwpck_require__(33428); +function bindPublicationSourceQuery(port, binding) { + return Object.freeze({ + getBranchHeadSha: (branch) => port.getBranchHeadSha(binding.owner, binding.repository, branch, binding.token), + }); +} function bindOrganizationMembers(port, binding) { return { getAllMembers: () => port.getAllMembers(binding.owner, binding.token), diff --git a/build/github_action/index.js b/build/github_action/index.js index 5924a3abf..bd25ba7dc 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -39373,6 +39373,9 @@ const application_error_context_1 = __nccwpck_require__(4034); const application_error_1 = __nccwpck_require__(75999); const application_error_presentation_policy_1 = __nccwpck_require__(95067); const push_single_action_capability_port_binding_1 = __nccwpck_require__(49417); +const shared_capability_port_binding_1 = __nccwpck_require__(47399); +const github_publication_source_repository_1 = __nccwpck_require__(52644); +const github_branch_client_factory_1 = __nccwpck_require__(30144); const agent_capability_composition_root_1 = __nccwpck_require__(85079); const resolve_message_catalog_use_case_1 = __nccwpck_require__(99961); const github_action_locale_inputs_1 = __nccwpck_require__(58893); @@ -39448,7 +39451,7 @@ async function runGitHubAction() { ...repositoryBinding, issueNumber: context.issueNumber, }, context), - }, (0, copilot_evidence_composition_root_1.createCopilotEvidenceCompositionRoot)(), (0, github_action_summary_composition_root_1.createGithubActionSummaryCompositionRoot)(), new resolve_message_catalog_use_case_1.ResolveMessageCatalogUseCase(agentRuntimeAuthorized ? (0, agent_capability_composition_root_1.createLanguageQueryPort)() : undefined)); + }, (0, copilot_evidence_composition_root_1.createCopilotEvidenceCompositionRoot)(), (0, github_action_summary_composition_root_1.createGithubActionSummaryCompositionRoot)(), new resolve_message_catalog_use_case_1.ResolveMessageCatalogUseCase(agentRuntimeAuthorized ? (0, agent_capability_composition_root_1.createLanguageQueryPort)() : undefined), (0, shared_capability_port_binding_1.bindPublicationSourceQuery)(new github_publication_source_repository_1.GithubPublicationSourceRepository((0, github_branch_client_factory_1.createBranchClient)()), repositoryBinding)); } /** * Runs the action entrypoint without forcing a successful process exit. @@ -39623,7 +39626,7 @@ const deployment_presentation_policy_1 = __nccwpck_require__(83221); const deployment_message_catalog_1 = __nccwpck_require__(79364); const bugbot_result_finding_state_projection_policy_1 = __nccwpck_require__(98117); const review_state_1 = __nccwpck_require__(79200); -async function finishGithubAction(execution, results, issueNotificationPort, configurationStorePort, evidencePort, summaryPort, catalogResolver) { +async function finishGithubAction(execution, results, issueNotificationPort, configurationStorePort, evidencePort, summaryPort, catalogResolver, publicationSourceQuery) { const stepCount = results.reduce((acc, result) => acc + (result.steps?.length ?? 0), 0); const errorCount = results.reduce((acc, result) => acc + (result.errors?.length ?? 0), 0); (0, logger_1.logInfo)(`Publishing result: ${results.length} result(s), ${stepCount} step(s), ${errorCount} error(s).`); @@ -39632,9 +39635,9 @@ async function finishGithubAction(execution, results, issueNotificationPort, con const dryRun = results.some((result) => (0, result_1.getResultPayload)(result.payload)?.dryRun === true); const ownsDeploymentPresentation = execution.singleAction.isDeploymentOrchestrationAction; if (!dryRun && !execution.singleAction.isPublishIssueCommentAction && !ownsDeploymentPresentation) { - const publicationFailure = await new publish_resume_use_case_1.PublishResultUseCase(issueNotificationPort, catalogResolver).invoke((0, publish_resume_workflow_1.projectPublishResultContext)(execution)); - if (publicationFailure) - results.push(publicationFailure); + const publicationOutcome = await new publish_resume_use_case_1.PublishResultUseCase(issueNotificationPort, catalogResolver, publicationSourceQuery).invoke((0, publish_resume_workflow_1.projectPublishResultContext)(execution)); + if (publicationOutcome) + results.push(publicationOutcome); } else if (execution.singleAction.isPublishIssueCommentAction || ownsDeploymentPresentation) { (0, logger_1.logInfo)('Generic result publication skipped: this single action owns its user-facing presentation.'); @@ -40242,8 +40245,12 @@ function buildGithubActionEventInputs(context) { const repository = (0, repository_context_1.requireRepositoryCoordinates)(context.repo); const eventName = requireNonEmptyContextValue(context.eventName, 'event name'); const actor = requireNonEmptyContextValue(context.actor, 'actor'); + const payloadAfter = typeof context.payload.after === 'string' && context.payload.after.trim() + ? context.payload.after.trim() + : undefined; return { ...context.payload, + ...(payloadAfter ? { after: payloadAfter } : {}), eventName, actor, repo: repository, @@ -41133,6 +41140,7 @@ const application_error_message_catalog_1 = __nccwpck_require__(64809); const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', + 'sourceFreshness', 'staleSourceSuppressed', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -41167,6 +41175,8 @@ const ENGLISH_SIMPLE = Object.freeze({ results: 'Results', findingStates: 'Finding states', bugbotReview: 'Bugbot review', + sourceFreshness: 'Source freshness', + staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -41201,6 +41211,8 @@ const SPANISH_SIMPLE = Object.freeze({ results: 'Resultados', findingStates: 'Estados de los hallazgos', bugbotReview: 'Revisión de Bugbot', + sourceFreshness: 'Vigencia del origen', + staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', @@ -41301,6 +41313,7 @@ const bugbot_result_finding_state_projection_policy_1 = __nccwpck_require__(9811 const review_state_1 = __nccwpck_require__(79200); const action_summary_message_catalog_1 = __nccwpck_require__(61544); const application_error_presentation_policy_1 = __nccwpck_require__(95067); +const publication_outcome_policy_1 = __nccwpck_require__(79719); const ENGLISH_LOCALIZATION_SUMMARY_LABELS = Object.freeze({ heading: 'Localization', property: 'Property', @@ -41319,6 +41332,7 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo const findingStates = findingStateProjection.status === 'valid' ? findingStateProjection.counts : undefined; const telemetryProjection = (0, bugbot_telemetry_projection_policy_1.projectBugbotResultTelemetry)(context.results); const bugbotTelemetry = telemetryProjection.status === 'valid' ? telemetryProjection.telemetry : undefined; + const staleSourceSuppressed = (0, publication_outcome_policy_1.hasStaleSourcePublicationOutcome)(context.results); const hasActionableFindings = findingStates ? (0, review_state_1.countActionableBugbotFindings)(findingStates) > 0 : false; const hasUnknownFindings = findingStateProjection.status === 'invalid' || (findingStates?.unknown ?? 0) > 0; const status = resolveActionSummaryStatus({ @@ -41340,6 +41354,9 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo `| ${catalogText(catalog, 'summary.results')} | ${formatResultCounts(context.results, catalog)} |`, `| ${catalogText(catalog, 'summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, `| ${catalogText(catalog, 'summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, + ...(staleSourceSuppressed ? [ + `| ${catalogText(catalog, 'summary.sourceFreshness')} | ${catalogText(catalog, 'summary.staleSourceSuppressed')} |`, + ] : []), ]; const localization = renderLocalizationSummarySection(context.locale, context.catalogResolutions, actionSummaryLocalizationLabels(catalog)); return [ @@ -46978,6 +46995,36 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ })); +/***/ }), + +/***/ 79719: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; +exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; +const result_1 = __nccwpck_require__(73817); +/** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ +function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { + return Object.freeze({ + publicationOutcome: Object.freeze({ + reason: 'stale-source', + branch, + sourceHeadSha, + }), + }); +} +function hasStaleSourcePublicationOutcome(results) { + return results.some(result => { + const payload = (0, result_1.getResultPayload)(result.payload); + const outcome = (0, result_1.getResultPayload)(payload?.publicationOutcome); + return outcome?.reason === 'stale-source'; + }); +} + + /***/ }), /***/ 43268: @@ -47353,6 +47400,7 @@ const copilot_interaction_policy_1 = __nccwpck_require__(90108); const status_command_policy_1 = __nccwpck_require__(3449); const comment_translation_policy_1 = __nccwpck_require__(27150); const application_error_presentation_policy_1 = __nccwpck_require__(95067); +const git_object_id_1 = __nccwpck_require__(88623); function selectSemanticStatusIntents(context) { return Object.freeze(context.results.flatMap(result => { if (!result.executed || !result.success) @@ -47585,10 +47633,14 @@ function translationProjection(value) { }); } function progressIntent(id, payload, locale) { + const sourceHeadSha = (0, git_object_id_1.canonicalGitObjectId)(payload.sourceHeadSha); + const branch = typeof payload.branch === 'string' ? payload.branch.trim() : ''; if (id !== 'CheckProgressUseCase' || !positiveInteger(payload.issueNumber) || typeof payload.progress !== 'number' - || typeof payload.summary !== 'string') + || typeof payload.summary !== 'string' + || !sourceHeadSha + || !branch) return undefined; const progress = Math.max(0, Math.min(100, Math.round(payload.progress))); const projection = Object.freeze({ @@ -47598,12 +47650,15 @@ function progressIntent(id, payload, locale) { ...(typeof payload.remaining === 'string' && payload.remaining.trim() ? { remaining: payload.remaining.trim() } : {}), - ...(typeof payload.branch === 'string' && payload.branch.trim() ? { branch: payload.branch.trim() } : {}), + branch, ...(typeof payload.developmentBranch === 'string' && payload.developmentBranch.trim() ? { developmentBranch: payload.developmentBranch.trim() } : {}), }); - return statusIntent('progress', payload.issueNumber, 'work', `progress:${(0, publication_identity_policy_1.createSemanticDigest)(projection)}`, locale, projection); + return Object.freeze({ + ...statusIntent('progress', payload.issueNumber, 'work', `head:${sourceHeadSha}`, locale, projection), + sourceGuard: Object.freeze({ kind: 'branch-head', branch, sha: sourceHeadSha }), + }); } function statusIntent(topic, issueNumber, key, sourceVersion, locale, projection) { return Object.freeze({ @@ -49130,13 +49185,14 @@ exports.CheckProgressUseCase = void 0; const check_progress_workflow_1 = __nccwpck_require__(94343); /** Application boundary for assessing and publishing issue progress. */ class CheckProgressUseCase { - constructor(issueDescriptionQueryPort, issueLabelsPort, issueProgressPort, branchRepository, pullRequestRepository, aiRepository) { + constructor(issueDescriptionQueryPort, issueLabelsPort, issueProgressPort, branchRepository, pullRequestRepository, aiRepository, publicationSourceQuery) { this.issueDescriptionQueryPort = issueDescriptionQueryPort; this.issueLabelsPort = issueLabelsPort; this.issueProgressPort = issueProgressPort; this.branchRepository = branchRepository; this.pullRequestRepository = pullRequestRepository; this.aiRepository = aiRepository; + this.publicationSourceQuery = publicationSourceQuery; this.taskId = 'CheckProgressUseCase'; } async invoke(param) { @@ -49147,6 +49203,7 @@ class CheckProgressUseCase { issueLabelsPort: this.issueLabelsPort, issueProgressPort: this.issueProgressPort, aiRepository: this.aiRepository, + publicationSourceQuery: this.publicationSourceQuery, }); } } @@ -49169,6 +49226,7 @@ const sync_progress_labels_to_open_pull_requests_1 = __nccwpck_require__(18277); const progress_summary_builder_1 = __nccwpck_require__(62721); const progress_analysis_workflow_1 = __nccwpck_require__(88729); const application_error_1 = __nccwpck_require__(75999); +const publication_outcome_policy_1 = __nccwpck_require__(79719); /** Publishes a completed progress assessment after the analysis workflow succeeds. */ async function runCheckProgressWorkflow(param, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId}.`); @@ -49176,14 +49234,22 @@ async function runCheckProgressWorkflow(param, taskId, dependencies) { const analysis = await (0, progress_analysis_workflow_1.analyzeProgress)(param, taskId, dependencies); if (analysis.kind === 'failure') return [analysis.result]; - const { attemptResult, issueNumber, branch, developmentBranch } = analysis; + if (analysis.kind === 'stale-source') { + (0, logging_ports_1.logInfo)(`Progress analysis omitted: ${analysis.branch} no longer points at the event source.`); + return [buildStaleSourceResult(taskId, analysis.branch, analysis.sourceHeadSha)]; + } + const { attemptResult, issueNumber, branch, developmentBranch, sourceHeadSha } = analysis; const { progress, summary, reasoning, remaining } = attemptResult; logProgressAssessment(progress, summary, reasoning, remaining); + if (!await sourceIsCurrent(branch, sourceHeadSha, dependencies.publicationSourceQuery)) { + (0, logging_ports_1.logInfo)(`Progress mutation omitted: ${branch} no longer points at the analyzed source.`); + return [buildStaleSourceResult(taskId, branch, sourceHeadSha)]; + } if (progress === 0) { - return [buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning)]; + return [buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning, sourceHeadSha)]; } await persistProgress(param, issueNumber, branch, progress, dependencies); - return [buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining)]; + return [buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining, sourceHeadSha)]; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'workflow.failed', `Unable to complete ${taskId}.`); @@ -49198,7 +49264,18 @@ async function runCheckProgressWorkflow(param, taskId, dependencies) { ]; } } -function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning) { +async function sourceIsCurrent(branch, sourceHeadSha, sourceQuery) { + return await sourceQuery.getBranchHeadSha(branch) === sourceHeadSha; +} +function buildStaleSourceResult(taskId, branch, sourceHeadSha) { + return new result_1.Result({ + id: taskId, + success: true, + executed: false, + payload: (0, publication_outcome_policy_1.buildStaleSourcePublicationPayload)(branch, sourceHeadSha), + }); +} +function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, summary, reasoning, sourceHeadSha) { const message = 'Progress detection returned 0%. This may be due to a model error or no changes detected. Consider re-running the check.'; (0, logging_ports_1.logError)(message); return new result_1.Result({ @@ -49207,14 +49284,22 @@ function buildZeroProgressResult(taskId, issueNumber, branch, developmentBranch, executed: true, steps: [`Progress for issue #${issueNumber}: 0%`, summary], errors: [new application_error_1.ApplicationError('agent.failed', message)], - payload: { progress: 0, summary, reasoning: reasoning || undefined, issueNumber, branch, developmentBranch }, + payload: { + progress: 0, + summary, + reasoning: reasoning || undefined, + issueNumber, + branch, + developmentBranch, + sourceHeadSha, + }, }); } async function persistProgress(param, issueNumber, branch, progress, dependencies) { await dependencies.issueProgressPort.setProgressLabel(issueNumber, progress); await (0, sync_progress_labels_to_open_pull_requests_1.syncProgressLabelsToOpenPullRequests)(branch, progress, dependencies.issueLabelsPort, dependencies.pullRequestRepository); } -function buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining) { +function buildProgressResult(taskId, issueNumber, branch, developmentBranch, progress, summary, reasoning, remaining, sourceHeadSha) { return new result_1.Result({ id: taskId, success: true, @@ -49228,6 +49313,7 @@ function buildProgressResult(taskId, issueNumber, branch, developmentBranch, pro issueNumber, branch, developmentBranch, + sourceHeadSha, }, }); } @@ -50824,6 +50910,10 @@ async function analyzeProgress(param, taskId, dependencies) { }; } const resolvedBranch = branch; + const sourceHeadSha = await dependencies.publicationSourceQuery.getBranchHeadSha(resolvedBranch); + if (param.sourceHeadSha && param.sourceHeadSha !== sourceHeadSha) { + return { kind: 'stale-source', branch: resolvedBranch, sourceHeadSha: param.sourceHeadSha }; + } const developmentBranch = param.developmentBranch; (0, logging_ports_1.logInfo)(`📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${resolvedBranch}" (configured agent will run git diff).`); const prompt = (0, prompts_1.getCheckProgressPrompt)({ @@ -50850,6 +50940,7 @@ async function analyzeProgress(param, taskId, dependencies) { issueNumber, branch: resolvedBranch, developmentBranch, + sourceHeadSha, attemptResult, }; } @@ -53853,6 +53944,7 @@ exports.projectInitialSetupContext = projectInitialSetupContext; exports.projectIssueCommentActionContext = projectIssueCommentActionContext; exports.projectAgentActivityContext = projectAgentActivityContext; const issue_comment_publication_policy_1 = __nccwpck_require__(61899); +const git_object_id_1 = __nccwpck_require__(88623); function projectDeploymentPublicationContext(source) { return Object.freeze({ requestedOperationId: source.singleAction.operationId, @@ -53890,6 +53982,7 @@ function projectDeploymentOrchestrationContext(source) { }; } function projectProgressContext(source) { + const sourceHeadSha = (0, git_object_id_1.canonicalGitObjectId)(source.inputs?.after); return Object.freeze({ issueNumber: source.issueNumber, pushedBranch: source.commit.branch, @@ -53905,6 +53998,7 @@ function projectProgressContext(source) { agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), targetLocale: source.locale?.issue ?? 'en-US', + ...(sourceHeadSha ? { sourceHeadSha } : {}), }); } function projectRecommendStepsContext(source) { @@ -59542,14 +59636,15 @@ const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const publish_resume_workflow_1 = __nccwpck_require__(55340); class PublishResultUseCase { - constructor(comments, catalogResolver) { + constructor(comments, catalogResolver, sourceQuery) { this.comments = comments; this.catalogResolver = catalogResolver; + this.sourceQuery = sourceQuery; this.taskId = 'PublishResultUseCase'; } async invoke(param) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Executing ${this.taskId}.`); - return (0, publish_resume_workflow_1.runPublishResume)(param, this.taskId, this.comments, this.catalogResolver); + return (0, publish_resume_workflow_1.runPublishResume)(param, this.taskId, this.comments, this.catalogResolver, this.sourceQuery); } } exports.PublishResultUseCase = PublishResultUseCase; @@ -59573,6 +59668,7 @@ const publication_identity_policy_1 = __nccwpck_require__(45403); const reply_publication_workflow_1 = __nccwpck_require__(22824); const status_card_publication_workflow_1 = __nccwpck_require__(62963); const publication_message_catalog_1 = __nccwpck_require__(34223); +const publication_outcome_policy_1 = __nccwpck_require__(79719); function projectPublishResultContext(source) { const target = publicationTarget(source); return Object.freeze({ @@ -59593,7 +59689,7 @@ function projectPublishResultContext(source) { * semantic payloads may reach GitHub; steps, reminders, errors, images, and * debug logs remain operator evidence in the Job Summary and logs. */ -async function runPublishResume(param, taskId, comments, catalogResolver) { +async function runPublishResume(param, taskId, comments, catalogResolver, sourceQuery) { try { const semanticContext = { locale: param.locale, @@ -59613,6 +59709,7 @@ async function runPublishResume(param, taskId, comments, catalogResolver) { return undefined; } const catalog = await (0, publication_message_catalog_1.resolvePublicationCatalog)(param.locale, param.languageConfiguration, catalogResolver); + let staleSourceEvidence; for (const intent of replies) { const outcome = await (0, reply_publication_workflow_1.reconcileReply)({ owner: param.owner, @@ -59630,10 +59727,25 @@ async function runPublishResume(param, taskId, comments, catalogResolver) { botLogin: param.botLogin, intent, catalog, - }, comments); - (0, logging_ports_1.logInfo)(`Semantic ${intent.identity.topic} publication ${outcome.effect}; duplicates compacted=${outcome.duplicatesCompacted}.`); + }, comments, sourceQuery); + (0, logging_ports_1.logInfo)(`Semantic ${intent.identity.topic} publication ${outcome.effect}; ` + + `reason=${outcome.reason ?? 'current'}; duplicates compacted=${outcome.duplicatesCompacted}.`); + if (outcome.reason === 'stale-source') { + const sourceGuard = intent.sourceGuard; + if (!sourceGuard) { + throw new Error('Stale-source publication outcome requires a source guard.'); + } + staleSourceEvidence ?? (staleSourceEvidence = Object.freeze({ branch: sourceGuard.branch, sha: sourceGuard.sha })); + } } - return undefined; + return staleSourceEvidence + ? new result_1.Result({ + id: taskId, + success: true, + executed: false, + payload: (0, publication_outcome_policy_1.buildStaleSourcePublicationPayload)(staleSourceEvidence.branch, staleSourceEvidence.sha), + }) + : undefined; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to publish semantic GitHub status.'); @@ -59762,14 +59874,19 @@ const github_user_policy_1 = __nccwpck_require__(84403); const publication_identity_policy_1 = __nccwpck_require__(45403); const publication_message_catalog_1 = __nccwpck_require__(34223); const semantic_result_publication_policy_1 = __nccwpck_require__(81985); -async function reconcileStatusCard(context, comments) { +const application_error_1 = __nccwpck_require__(75999); +async function reconcileStatusCard(context, comments, sourceQuery) { if (!context.botLogin.trim()) return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + if (!await sourceIsCurrent(context.intent, sourceQuery)) + return staleSourceOutcome(); const target = context.intent.identity.target; const rendered = (0, semantic_result_publication_policy_1.renderSemanticStatus)(context.intent, context.catalog); let owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); let effect = 'unchanged'; if (owned.length === 0) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) + return staleSourceOutcome(); await comments.addComment(target.number, rendered); effect = 'created'; owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); @@ -59779,16 +59896,41 @@ async function reconcileStatusCard(context, comments) { const [canonical, ...duplicates] = owned.sort((left, right) => left.id - right.id); const canonicalMarker = (0, publication_identity_policy_1.parsePublicationMarker)(canonical.body); if (canonicalMarker?.digest !== context.intent.digest) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) { + return staleSourceOutcome(canonical.id); + } await comments.updateComment(target.number, canonical.id, rendered); effect = effect === 'created' ? 'created' : 'updated'; } + let duplicatesCompacted = 0; for (const duplicate of duplicates) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) { + return staleSourceOutcome(canonical.id, effect, duplicatesCompacted); + } await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); + duplicatesCompacted += 1; } return Object.freeze({ effect, canonicalCommentId: canonical.id, - duplicatesCompacted: duplicates.length, + duplicatesCompacted, + }); +} +async function sourceIsCurrent(intent, sourceQuery) { + const guard = intent.sourceGuard; + if (!guard) + return true; + if (!sourceQuery) { + throw new application_error_1.ApplicationError('configuration.unsupported', 'Commit-derived status publication requires an authoritative source query.'); + } + return await sourceQuery.getBranchHeadSha(guard.branch) === guard.sha; +} +function staleSourceOutcome(canonicalCommentId, effect = 'unchanged', duplicatesCompacted = 0) { + return Object.freeze({ + effect, + ...(canonicalCommentId === undefined ? {} : { canonicalCommentId }), + duplicatesCompacted, + reason: 'stale-source', }); } function ownedCards(comments, identity, botLogin) { @@ -62429,11 +62571,13 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.cleanCliArg = cleanCliArg; exports.getGitInfo = getGitInfo; exports.getCurrentBranch = getCurrentBranch; +exports.getCurrentHeadSha = getCurrentHeadSha; exports.isInsideGitRepo = isInsideGitRepo; exports.isGitRepositoryRoot = isGitRepositoryRoot; const child_process_1 = __nccwpck_require__(32081); const node_fs_1 = __nccwpck_require__(87561); const cli_errors_1 = __nccwpck_require__(81853); +const git_object_id_1 = __nccwpck_require__(88623); function cleanCliArg(value) { if (value == null) return ''; @@ -62460,6 +62604,15 @@ function getCurrentBranch() { return 'main'; } } +/** Returns the canonical object ID for the workspace revision being analyzed. */ +function getCurrentHeadSha() { + try { + return (0, git_object_id_1.canonicalGitObjectId)((0, child_process_1.execSync)('git rev-parse HEAD').toString().trim()); + } + catch { + return undefined; + } +} function isInsideGitRepo(cwd) { try { (0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' }); @@ -67590,6 +67743,38 @@ function requireObject(data, operation) { } +/***/ }), + +/***/ 52644: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GithubPublicationSourceRepository = void 0; +const application_error_1 = __nccwpck_require__(75999); +const git_object_id_1 = __nccwpck_require__(88623); +/** Reads the authoritative branch head without exposing Octokit to application code. */ +class GithubPublicationSourceRepository { + constructor(clientProvider) { + this.clientProvider = clientProvider; + } + async getBranchHeadSha(owner, repository, branch, token) { + const { data } = await this.clientProvider.getClient(token).rest.git.getRef({ + owner, + repo: repository, + ref: `heads/${branch}`, + }); + const sha = (0, git_object_id_1.canonicalGitObjectId)(data.object?.sha); + if (!sha) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'GitHub returned an invalid branch-head object ID.'); + } + return sha; + } +} +exports.GithubPublicationSourceRepository = GithubPublicationSourceRepository; + + /***/ }), /***/ 88593: @@ -72738,6 +72923,27 @@ function isRecord(value) { } +/***/ }), + +/***/ 88623: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.canonicalGitObjectId = canonicalGitObjectId; +const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; +/** Canonicalizes a real SHA-1/SHA-256 object ID and rejects webhook null sentinels. */ +function canonicalGitObjectId(value) { + if (typeof value !== 'string') + return undefined; + const normalized = value.trim().toLowerCase(); + if (!GIT_OBJECT_ID_PATTERN.test(normalized) || /^0+$/u.test(normalized)) + return undefined; + return normalized; +} + + /***/ }), /***/ 21486: @@ -74249,13 +74455,14 @@ const issue_progress_label_repository_1 = __nccwpck_require__(66610); const issue_progress_tracking_repository_1 = __nccwpck_require__(26674); const branch_lifecycle_repository_1 = __nccwpck_require__(19504); const pull_request_lifecycle_repository_1 = __nccwpck_require__(24189); +const github_publication_source_repository_1 = __nccwpck_require__(52644); const shared_capability_port_binding_1 = __nccwpck_require__(47399); const lifecycle_capability_port_binding_1 = __nccwpck_require__(85785); const push_single_action_capability_port_binding_1 = __nccwpck_require__(49417); function createCheckProgressCompositionRoot(binding) { const labels = new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()); const content = new issue_content_repository_1.IssueContentRepository((0, github_issue_client_factory_1.createIssueContentClient)()); - return new check_progress_use_case_1.CheckProgressUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(content, binding), (0, lifecycle_capability_port_binding_1.bindIssueLabels)(labels, binding), (0, push_single_action_capability_port_binding_1.bindIssueProgress)(new issue_progress_tracking_repository_1.IssueProgressTrackingRepository(content, labels, new issue_progress_label_repository_1.IssueProgressLabelRepository(new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()))), binding), (0, push_single_action_capability_port_binding_1.bindBranchListQuery)(new branch_lifecycle_repository_1.BranchLifecycleRepository((0, github_branch_client_factory_1.createBranchClient)()), binding), (0, push_single_action_capability_port_binding_1.bindPullRequestBranchQuery)(new pull_request_lifecycle_repository_1.PullRequestLifecycleRepository((0, github_pull_request_client_factory_1.createPullRequestLifecycleClient)()), binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()); + return new check_progress_use_case_1.CheckProgressUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(content, binding), (0, lifecycle_capability_port_binding_1.bindIssueLabels)(labels, binding), (0, push_single_action_capability_port_binding_1.bindIssueProgress)(new issue_progress_tracking_repository_1.IssueProgressTrackingRepository(content, labels, new issue_progress_label_repository_1.IssueProgressLabelRepository(new issue_label_repository_1.IssueLabelRepository((0, github_issue_client_factory_1.createIssueLabelsClient)()))), binding), (0, push_single_action_capability_port_binding_1.bindBranchListQuery)(new branch_lifecycle_repository_1.BranchLifecycleRepository((0, github_branch_client_factory_1.createBranchClient)()), binding), (0, push_single_action_capability_port_binding_1.bindPullRequestBranchQuery)(new pull_request_lifecycle_repository_1.PullRequestLifecycleRepository((0, github_pull_request_client_factory_1.createPullRequestLifecycleClient)()), binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)(), (0, shared_capability_port_binding_1.bindPublicationSourceQuery)(new github_publication_source_repository_1.GithubPublicationSourceRepository((0, github_branch_client_factory_1.createBranchClient)()), binding)); } @@ -75445,6 +75652,7 @@ function bindSetupRemoteConfiguration(port, binding) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.bindPublicationSourceQuery = bindPublicationSourceQuery; exports.bindOrganizationMembers = bindOrganizationMembers; exports.bindIssueDescriptionQuery = bindIssueDescriptionQuery; exports.bindIssueNotification = bindIssueNotification; @@ -75453,6 +75661,11 @@ exports.bindIssueCommentUpdate = bindIssueCommentUpdate; exports.bindIssueTitle = bindIssueTitle; exports.bindProjectContent = bindProjectContent; const project_detail_1 = __nccwpck_require__(33428); +function bindPublicationSourceQuery(port, binding) { + return Object.freeze({ + getBranchHeadSha: (branch) => port.getBranchHeadSha(binding.owner, binding.repository, branch, binding.token), + }); +} function bindOrganizationMembers(port, binding) { return { getAllMembers: () => port.getAllMembers(binding.owner, binding.token), diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index c3818fa1f..27c7c9b58 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -28,6 +28,17 @@ bot-owned plan, direct-answer, and current or legacy welcome markers. An unavail closed by omitting the welcome. An unchanged stored recommendation is projected again so the status reconciler can adopt, preserve, or recreate its plan card. +Commit-derived progress crosses an additional source-freshness boundary. A +provider-neutral application port reads the authoritative branch `HEAD`; the +progress workflow snapshots it before agent analysis and checks it again +immediately before changing issue or PR labels. The status-card reconciler +rechecks that same expected object ID before every create or update. A push +event or CLI workspace whose source is already old is discarded before agent work. A branch that +advances during the run produces a successful, skipped `stale-source` outcome, +leaves native state and conversation state untouched, and is explained only in +the Job Summary. Git object-ID validation accepts canonical SHA-1 and SHA-256 +forms; Octokit and credentials remain confined to the data/composition layers. + Repository-facing language follows the same boundary. Entrypoints build one canonical, frozen locale profile: `repository-locale` defaults to `en-US`, and empty issue or pull-request overrides inherit it. Presentation policies request diff --git a/docs/features.mdx b/docs/features.mdx index 887f88966..6b929ad28 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -127,7 +127,7 @@ Codex is the default runtime for the repository's AI feature paths. OpenCode and | Feature | Where it runs | Description | |--------|----------------|-------------| -| **Check progress** | Push (commit) pipeline; optional single action `check_progress_action` / CLI `check-progress` | Compares issue vs branch diff, updates progress labels, and reconciles one issue progress card only when its semantic projection changes. It does not post per-commit narration. | +| **Check progress** | Push (commit) pipeline; optional single action `check_progress_action` / CLI `check-progress` | Compares issue vs branch diff, updates progress labels, and reconciles one issue progress card only when its semantic projection changes and the analyzed branch `HEAD` is still current. Stale results change nothing and are explained in the Job Summary. It does not post per-commit narration. | | **Bugbot (potential problems)** | Push (commit) pipeline; optional single action `detect_potential_problems_action` / CLI `detect-potential-problems` | Analyzes branch vs base and posts bounded findings near code plus one concise aggregate status card. A clean full-coverage card omits empty tables and technical detail; incomplete coverage names an effective recovery action instead of requesting identical rechecks. | | **Do user request** | Issue comment; PR review comment | When you comment asking to perform a change in the repo (or use `/copilot implement `), the configured agent applies the changes in the workspace, runs verify commands, and the action commits and pushes with a generic message. Organization repositories require an org member; personal repositories require the owner or a `push`/`maintain`/`admin` collaborator. Uses the same `bugbot-fix-verify-commands` and agent CLI setup. | | **Think / reasoning** | Issue/PR comment pipeline; single action `think_action` | Deep code analysis and change proposals (configured agent CLI). On GitHub, returns one sanitized, correlated semantic answer to an explicit `/copilot` command or exact bot mention; Think itself has no comment mutation capability. In the CLI, it prints the answer locally and `--issue` is optional context. | @@ -151,6 +151,10 @@ description refreshes, pushes, reopens, merges, and closes do not create a generic roll-up. The Job Summary keeps aggregate result counts and expands only safe error recovery evidence; internal result names, step narration, and arbitrary error text remain in logs. +Progress is guarded twice against branch movement: before native label writes +and before every progress-card mutation. This also applies to the on-demand +Action and repository-aware CLI because both snapshot the authoritative remote +branch before analysis. Hidden markers provide stable identity and are trusted only when the comment is authored by the configured bot. Decorative image inputs default to `false` and are deprecated. diff --git a/docs/issues/notifications-and-auto-close.mdx b/docs/issues/notifications-and-auto-close.mdx index 622cb3da3..fcded256c 100644 --- a/docs/issues/notifications-and-auto-close.mdx +++ b/docs/issues/notifications-and-auto-close.mdx @@ -16,6 +16,12 @@ current progress, an action, a direct answer, or a terminal policy explanation. When the push workflow runs for a branch linked to an issue, Copilot updates one owned progress card only when the progress projection changes. It does not post a commit list, image, reopen notice, debug report, or generic “Actions” summary. +The run snapshots the branch's authoritative GitHub `HEAD` before analysis and +revalidates it before changing progress labels and again before each card +mutation. If the event is already old, no agent call is made. If the branch +advances during analysis or publication, the stale result cannot replace newer +native or conversation state; the Job Summary shows **Source freshness** with +the reason that the stale result was suppressed. The workflow Job Summary remains the place for bounded operational state. Its headings and explanatory labels use `repository-locale` (English by default), while event names, lifecycle values, error codes, and other machine facts remain @@ -29,7 +35,8 @@ locales plus exact, base-language, dynamic, or English-fallback catalog evidence - **Where:** the issue associated with the branch (for example, `feature/123-title` → issue `123`). - **Identity:** one hidden, bot-owned semantic marker identifies the progress - card. Equivalent retries create or update nothing. + card and records the analyzed branch-head object ID. Equivalent retries + create or update nothing. - **Content:** outcome, current status, and the next useful action only. Agent reasoning and internal step names are excluded. diff --git a/docs/single-actions/available-actions.mdx b/docs/single-actions/available-actions.mdx index 116c5a2e2..17fdb9d5c 100644 --- a/docs/single-actions/available-actions.mdx +++ b/docs/single-actions/available-actions.mdx @@ -13,7 +13,7 @@ These actions need **`single-action-issue`** set to the issue number. The workfl | Action | Required inputs | Description | When to use | |--------|-----------------|-------------|-------------| -| **`check_progress_action`** | `single-action-issue` | Runs **progress check** on demand. The configured agent compares the issue description with the branch diff, updates the **progress** label (0–100%) on the issue and any open PR for that branch, and creates or updates one progress card. | Progress is normally updated on every **push** (commit workflow). Use this to re-run without pushing, or when you don’t use the push workflow. | +| **`check_progress_action`** | `single-action-issue` | Runs **progress check** on demand. The configured agent compares the issue description with the branch diff, updates the **progress** label (0–100%) on the issue and any open PR for that branch, and creates or updates one progress card. It snapshots the remote branch `HEAD` before analysis and suppresses every mutation if that source changes. | Progress is normally updated on every **push** (commit workflow). Use this to re-run without pushing, or when you don’t use the push workflow. Check out the branch you intend to assess; an absent remote branch fails closed. | | **`detect_potential_problems_action`** | `single-action-issue` | **Bugbot:** the configured agent analyzes the branch vs base and reports findings on the issue when no PR exists, or in one summarized review on an open PR; updates stored findings and resolves PR threads when findings are fixed. | Same as push-time Bugbot but on demand. See [Bugbot](/bugbot). | | **`recommend_steps_action`** | `single-action-issue` | Uses the configured agent's analysis role to recommend **implementation steps** from the issue description and creates or updates one bounded plan card. | When you want a one-off suggestion for how to implement the issue. | | **`publish_issue_comment`** | `single-action-issue`, `single-action-message` | Creates a Markdown comment. With `single-action-comment-id`, it replaces that issue comment by default; set `single-action-comment-mode: append` to preserve its current content and append the message. | Reusable workflow notifications such as release or hotfix failures. | diff --git a/docs/single-actions/workflow-and-cli.mdx b/docs/single-actions/workflow-and-cli.mdx index e7dac7dbf..dba0847cd 100644 --- a/docs/single-actions/workflow-and-cli.mdx +++ b/docs/single-actions/workflow-and-cli.mdx @@ -328,6 +328,13 @@ The first version deliberately scopes reconciliation to workflow templates. Bran ### `copilot check-progress` Checks the progress of an issue from the selected branch and updates the progress label on the issue and its related open pull requests. +The command sends the current workspace object ID, verifies that it matches the +selected remote branch `HEAD` before agent analysis, then revalidates that +remote snapshot before changing labels. The shared publisher performs the +same check before each progress-card mutation in GitHub Actions. If the branch +advances, the run succeeds as **No changes**, the stale result is suppressed, +and newer state remains untouched. The selected branch must therefore exist on +the configured GitHub remote. | Option | Required | Default | Description | | --- | --- | --- | --- | diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index ff9c2b0f1..c33c81119 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -240,6 +240,33 @@ ], "successMessage": "execution-boundary closure coverage: PASS (changed path 95% lines/statements, 90% branches/functions)" }, + { + "name": "Semantic publication freshness", + "missingEntryLabel": "semantic publication freshness", + "rules": [ + { + "files": [ + "src/domain/git_object_id.ts", + "src/application/policies/publication_outcome_policy.ts" + ], + "mode": "each", + "thresholdProfile": "exhaustive" + }, + { + "files": [ + "src/application/policies/semantic_result_publication_policy.ts", + "src/application/usecases/actions/check_progress_workflow.ts", + "src/application/usecases/steps/common/publish_resume_workflow.ts", + "src/application/usecases/steps/common/status_card_publication_workflow.ts", + "src/data/repository/github_publication_source_repository.ts", + "src/infrastructure/composition/shared_capability_port_binding.ts" + ], + "mode": "aggregate", + "thresholdProfile": "default" + } + ], + "successMessage": "semantic publication freshness coverage: PASS (object-id/outcome policies 100%; guarded path 95% lines/statements, 90% branches/functions)" + }, { "name": "Repository localization", "missingEntryLabel": "repository localization", diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 1cab90db9..fa2122345 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,7 +10,7 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| -| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 164 paths · 2026-09-15 | +| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 180 paths · 2026-09-15 | | `release-orchestration` | Implemented | Release and hotfix promotion, publication, reconciliation, and durable recovery | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 52 paths · 2026-09-14 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 24 paths · 2026-09-15 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths · 2026-09-15 | @@ -33,9 +33,9 @@ debt or convert unknown historic intent into a design decision. - Last verified: 2026-09-15 - Specifications: [`specs/semantic-github-publication-and-notification.md`](./semantic-github-publication-and-notification.md) · [`specs/repository-locale-and-localization.md`](./repository-locale-and-localization.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_review_state.yml`](../.github/workflows/copilot_pull_request_review_state.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_close_inactive_issues.yml`](../.github/workflows/copilot_close_inactive_issues.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) -- Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/cli.ts`](../src/cli.ts) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/locale.ts`](../src/domain/locale.ts) · [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) · [`src/data/model/locale.ts`](../src/data/model/locale.ts) · [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) · [`src/application/policies/action_summary_message_catalog.ts`](../src/application/policies/action_summary_message_catalog.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/inactivity_message_catalog.ts`](../src/application/policies/inactivity_message_catalog.ts) · [`src/application/policies/inactivity_notification_policy.ts`](../src/application/policies/inactivity_notification_policy.ts) · [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/policies/comment_translation_policy.ts`](../src/application/policies/comment_translation_policy.ts) · [`src/application/usecases/steps/common/think_request_policy.ts`](../src/application/usecases/steps/common/think_request_policy.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/think_answer_workflow.ts`](../src/application/usecases/steps/common/think_answer_workflow.ts) · [`src/application/usecases/steps/common/think_use_case.ts`](../src/application/usecases/steps/common/think_use_case.ts) · [`src/application/usecases/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/application/policies/publication_identity_policy.ts`](../src/application/policies/publication_identity_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/steps/issue/answer_issue_help_use_case.ts`](../src/application/usecases/steps/issue/answer_issue_help_use_case.ts) · [`src/application/usecases/steps/issue/answer_issue_help_workflow.ts`](../src/application/usecases/steps/issue/answer_issue_help_workflow.ts) · [`src/application/ports/issue_lifecycle_ports.ts`](../src/application/ports/issue_lifecycle_ports.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/actions/local_action.ts`](../src/actions/local_action.ts) · [`src/actions/local_action_output.ts`](../src/actions/local_action_output.ts) · [`src/cli/commands/think.ts`](../src/cli/commands/think.ts) · [`src/cli/commands/think_command_handler.ts`](../src/cli/commands/think_command_handler.ts) · [`src/infrastructure/composition/local_action_composition_root.ts`](../src/infrastructure/composition/local_action_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/issue_use_case_composition_root.ts`](../src/infrastructure/composition/issue_use_case_composition_root.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/policies/bugbot_message_catalog.ts`](../src/application/policies/bugbot_message_catalog.ts) · [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_use_case.ts`](../src/application/usecases/actions/close_inactive_issues_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_workflow.ts`](../src/application/usecases/actions/close_inactive_issues_workflow.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/infrastructure/composition/issue_inactivity_composition_root.ts`](../src/infrastructure/composition/issue_inactivity_composition_root.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) -- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/inactivity_message_catalog.test.ts`](../src/application/policies/__tests__/inactivity_message_catalog.test.ts) · [`src/application/policies/__tests__/inactivity_notification_policy.test.ts`](../src/application/policies/__tests__/inactivity_notification_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/policies/__tests__/bugbot_message_catalog.test.ts`](../src/application/policies/__tests__/bugbot_message_catalog.test.ts) · [`src/application/policies/__tests__/deployment_message_catalog.test.ts`](../src/application/policies/__tests__/deployment_message_catalog.test.ts) · [`src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts`](../src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/application/policies/__tests__/deployment_presentation_policy.test.ts`](../src/application/policies/__tests__/deployment_presentation_policy.test.ts) · [`src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts`](../src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/think_request_policy.test.ts`](../src/application/usecases/steps/common/__tests__/think_request_policy.test.ts) · [`src/application/usecases/steps/common/__tests__/think_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/think_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/actions/__tests__/local_action.test.ts`](../src/actions/__tests__/local_action.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/commands/__tests__/think_command_handler.test.ts`](../src/cli/commands/__tests__/think_command_handler.test.ts) · [`src/infrastructure/composition/__tests__/local_action_composition_root.test.ts`](../src/infrastructure/composition/__tests__/local_action_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts`](../src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts) · [`src/application/usecases/actions/__tests__/check_progress_use_case.test.ts`](../src/application/usecases/actions/__tests__/check_progress_use_case.test.ts) · [`src/application/usecases/__tests__/comment_automation_use_case.test.ts`](../src/application/usecases/__tests__/comment_automation_use_case.test.ts) +- Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/actions/github_event_inputs.ts`](../src/actions/github_event_inputs.ts) · [`src/cli_context.ts`](../src/cli_context.ts) · [`src/cli/commands/check_progress.ts`](../src/cli/commands/check_progress.ts) · [`src/cli/commands/issue_command_policy.ts`](../src/cli/commands/issue_command_policy.ts) · [`src/api.ts`](../src/api.ts) · [`src/cli.ts`](../src/cli.ts) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/locale.ts`](../src/domain/locale.ts) · [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) · [`src/data/model/locale.ts`](../src/data/model/locale.ts) · [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) · [`src/application/policies/action_summary_message_catalog.ts`](../src/application/policies/action_summary_message_catalog.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/inactivity_message_catalog.ts`](../src/application/policies/inactivity_message_catalog.ts) · [`src/application/policies/inactivity_notification_policy.ts`](../src/application/policies/inactivity_notification_policy.ts) · [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/policies/comment_translation_policy.ts`](../src/application/policies/comment_translation_policy.ts) · [`src/application/usecases/steps/common/think_request_policy.ts`](../src/application/usecases/steps/common/think_request_policy.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/think_answer_workflow.ts`](../src/application/usecases/steps/common/think_answer_workflow.ts) · [`src/application/usecases/steps/common/think_use_case.ts`](../src/application/usecases/steps/common/think_use_case.ts) · [`src/application/usecases/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/domain/git_object_id.ts`](../src/domain/git_object_id.ts) · [`src/application/ports/publication_freshness_ports.ts`](../src/application/ports/publication_freshness_ports.ts) · [`src/application/policies/publication_identity_policy.ts`](../src/application/policies/publication_identity_policy.ts) · [`src/application/policies/publication_outcome_policy.ts`](../src/application/policies/publication_outcome_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/steps/issue/answer_issue_help_use_case.ts`](../src/application/usecases/steps/issue/answer_issue_help_use_case.ts) · [`src/application/usecases/steps/issue/answer_issue_help_workflow.ts`](../src/application/usecases/steps/issue/answer_issue_help_workflow.ts) · [`src/application/ports/issue_lifecycle_ports.ts`](../src/application/ports/issue_lifecycle_ports.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/actions/local_action.ts`](../src/actions/local_action.ts) · [`src/actions/local_action_output.ts`](../src/actions/local_action_output.ts) · [`src/cli/commands/think.ts`](../src/cli/commands/think.ts) · [`src/cli/commands/think_command_handler.ts`](../src/cli/commands/think_command_handler.ts) · [`src/infrastructure/composition/local_action_composition_root.ts`](../src/infrastructure/composition/local_action_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/issue_use_case_composition_root.ts`](../src/infrastructure/composition/issue_use_case_composition_root.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/policies/bugbot_message_catalog.ts`](../src/application/policies/bugbot_message_catalog.ts) · [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_use_case.ts`](../src/application/usecases/actions/close_inactive_issues_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_workflow.ts`](../src/application/usecases/actions/close_inactive_issues_workflow.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/infrastructure/composition/issue_inactivity_composition_root.ts`](../src/infrastructure/composition/issue_inactivity_composition_root.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/application/usecases/actions/check_progress_use_case.ts`](../src/application/usecases/actions/check_progress_use_case.ts) · [`src/application/usecases/actions/progress_analysis_workflow.ts`](../src/application/usecases/actions/progress_analysis_workflow.ts) · [`src/data/repository/github_publication_source_repository.ts`](../src/data/repository/github_publication_source_repository.ts) · [`src/infrastructure/composition/check_progress_composition_root.ts`](../src/infrastructure/composition/check_progress_composition_root.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) +- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_event_inputs.test.ts`](../src/actions/__tests__/github_event_inputs.test.ts) · [`src/cli/commands/__tests__/issue_command_policy.test.ts`](../src/cli/commands/__tests__/issue_command_policy.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/domain/__tests__/git_object_id.test.ts`](../src/domain/__tests__/git_object_id.test.ts) · [`src/data/repository/__tests__/github_publication_source_repository.test.ts`](../src/data/repository/__tests__/github_publication_source_repository.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_outcome_policy.test.ts`](../src/application/policies/__tests__/publication_outcome_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/inactivity_message_catalog.test.ts`](../src/application/policies/__tests__/inactivity_message_catalog.test.ts) · [`src/application/policies/__tests__/inactivity_notification_policy.test.ts`](../src/application/policies/__tests__/inactivity_notification_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/policies/__tests__/bugbot_message_catalog.test.ts`](../src/application/policies/__tests__/bugbot_message_catalog.test.ts) · [`src/application/policies/__tests__/deployment_message_catalog.test.ts`](../src/application/policies/__tests__/deployment_message_catalog.test.ts) · [`src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts`](../src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/application/policies/__tests__/deployment_presentation_policy.test.ts`](../src/application/policies/__tests__/deployment_presentation_policy.test.ts) · [`src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts`](../src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/think_request_policy.test.ts`](../src/application/usecases/steps/common/__tests__/think_request_policy.test.ts) · [`src/application/usecases/steps/common/__tests__/think_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/think_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/actions/__tests__/local_action.test.ts`](../src/actions/__tests__/local_action.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/commands/__tests__/think_command_handler.test.ts`](../src/cli/commands/__tests__/think_command_handler.test.ts) · [`src/infrastructure/composition/__tests__/local_action_composition_root.test.ts`](../src/infrastructure/composition/__tests__/local_action_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts`](../src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts) · [`src/application/usecases/actions/__tests__/check_progress_use_case.test.ts`](../src/application/usecases/actions/__tests__/check_progress_use_case.test.ts) · [`src/application/usecases/__tests__/comment_automation_use_case.test.ts`](../src/application/usecases/__tests__/comment_automation_use_case.test.ts) - User documentation: [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/issues/configuration.mdx`](../docs/issues/configuration.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/issues/branch-synchronization.mdx`](../docs/issues/branch-synchronization.mdx) · [`docs/issues/type/feature.mdx`](../docs/issues/type/feature.mdx) · [`docs/issues/type/bugfix.mdx`](../docs/issues/type/bugfix.mdx) · [`docs/issues/type/docs.mdx`](../docs/issues/type/docs.mdx) · [`docs/issues/type/chore.mdx`](../docs/issues/type/chore.mdx) · [`docs/issues/type/hotfix.mdx`](../docs/issues/type/hotfix.mdx) · [`docs/issues/type/release.mdx`](../docs/issues/type/release.mdx) · [`docs/issues/deployment-orchestration.mdx`](../docs/issues/deployment-orchestration.mdx) · [`docs/pull-requests/configuration.mdx`](../docs/pull-requests/configuration.mdx) · [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/pull-requests/examples.mdx`](../docs/pull-requests/examples.mdx) · [`docs/bugbot/configuration.mdx`](../docs/bugbot/configuration.mdx) · [`docs/bugbot/finding-publication.mdx`](../docs/bugbot/finding-publication.mdx) · [`docs/bugbot/detection.mdx`](../docs/bugbot/detection.mdx) · [`docs/bugbot/how-it-works.mdx`](../docs/bugbot/how-it-works.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/bugbot/examples.mdx`](../docs/bugbot/examples.mdx) · [`docs/single-actions/configuration.mdx`](../docs/single-actions/configuration.mdx) · [`docs/single-actions/available-actions.mdx`](../docs/single-actions/available-actions.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) · [`docs/single-actions/examples.mdx`](../docs/single-actions/examples.mdx) · [`docs/security-operations/security/prompt-injection.mdx`](../docs/security-operations/security/prompt-injection.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) ### `release-orchestration` — Configurable production-first release orchestration diff --git a/specs/catalog.json b/specs/catalog.json index a26a66363..7b28a2ca3 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -25,6 +25,10 @@ "entrypoints": [ "src/actions/github_action.ts", "src/actions/github_action_completion.ts", + "src/actions/github_event_inputs.ts", + "src/cli_context.ts", + "src/cli/commands/check_progress.ts", + "src/cli/commands/issue_command_policy.ts", "src/api.ts", "src/cli.ts" ], @@ -55,7 +59,10 @@ "src/application/usecases/comment_automation_use_case.ts", "src/application/usecases/steps/common/publish_resume_workflow.ts", "src/domain/github_publication.ts", + "src/domain/git_object_id.ts", + "src/application/ports/publication_freshness_ports.ts", "src/application/policies/publication_identity_policy.ts", + "src/application/policies/publication_outcome_policy.ts", "src/application/policies/publication_message_catalog.ts", "src/application/policies/semantic_result_publication_policy.ts", "src/application/usecases/issue_use_case.ts", @@ -93,12 +100,18 @@ "src/application/policies/deployment_presentation_policy.ts", "src/application/usecases/actions/recommend_steps_workflow.ts", "src/application/usecases/actions/check_progress_workflow.ts", + "src/application/usecases/actions/check_progress_use_case.ts", + "src/application/usecases/actions/progress_analysis_workflow.ts", + "src/data/repository/github_publication_source_repository.ts", + "src/infrastructure/composition/check_progress_composition_root.ts", "src/data/repository/issue/issue_content_repository.ts" ], "tests": [ "src/domain/__tests__/locale.test.ts", "src/domain/__tests__/message_catalog.test.ts", "src/actions/__tests__/configuration_builders.test.ts", + "src/actions/__tests__/github_event_inputs.test.ts", + "src/cli/commands/__tests__/issue_command_policy.test.ts", "src/actions/__tests__/github_action_completion.test.ts", "src/application/policies/__tests__/comment_translation_policy.test.ts", "src/application/policies/__tests__/action_summary_message_catalog.test.ts", @@ -107,7 +120,10 @@ "src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts", "src/prompts/__tests__/localize_message_catalog.test.ts", "src/domain/__tests__/github_publication.test.ts", + "src/domain/__tests__/git_object_id.test.ts", + "src/data/repository/__tests__/github_publication_source_repository.test.ts", "src/application/policies/__tests__/publication_identity_policy.test.ts", + "src/application/policies/__tests__/publication_outcome_policy.test.ts", "src/application/policies/__tests__/publication_message_catalog.test.ts", "src/application/policies/__tests__/semantic_result_publication_policy.test.ts", "src/application/policies/__tests__/action_summary_policy.test.ts", diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index b52ac0e38..9751513a8 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -3,7 +3,8 @@ - Status: In implementation - Date: 2026-09-14 - Catalog capability ID: github-communication-experience -- Last verified: 2026-09-15 for the delivered shared publication, branch-sync, +- Last verified: 2026-09-15 for the delivered shared publication, progress + source-freshness, branch-sync, review-context, Bugbot, deployment, setup-doctor, generic Job Summary, application-error, explicit-request, and local-result slices; remaining clauses are prospective @@ -383,7 +384,16 @@ The shared marker format is: transient `comment:issue_comment:` form emitted during migration and compact it with the stable identity; new writes MUST NOT use that transient form. -- Commit-derived cards MUST revalidate the expected head SHA before update. +- Commit-derived progress MUST snapshot the authoritative remote branch head + before analysis. An event-provided head that is already stale MUST stop before + agent execution. The workflow MUST revalidate the snapshot immediately before + native issue/PR label mutation, and the card reconciler MUST revalidate it + immediately before every create or update. SHA-1 and SHA-256 object IDs are + accepted only in canonical hexadecimal form after case normalization. +- A stale progress result MUST mutate neither native state nor conversation + state. It is a successful skipped outcome with reason `stale-source`, and the + repository-locale Job Summary MUST explain that suppression without exposing + full object IDs. - Revisioned operations MUST reject any revision lower than the stored revision. - Events without an orderable revision may update only after the shared workflow queue confirms no newer conflicting run; otherwise they resolve to `none` with @@ -997,6 +1007,19 @@ complete English fallback, because a safe requested-language interpretation was not established. The local action presenter now applies the same outcome versus evidence split and cannot render internal `Result.steps` or reminder prose. +Progress freshness is now enforced end to end. The progress workflow resolves +the selected remote branch through a credential-bound, provider-neutral query +port, snapshots its canonical SHA-1 or SHA-256 object ID before agent work, and +revalidates it before native label writes. Event SHAs provide an earlier +preflight that discards superseded push runs without invoking the agent; the CLI +supplies its canonical workspace object ID for the same comparison. The +shared status reconciler independently revalidates the same source immediately +before comment creation, canonical-card update, and each duplicate compaction +write. Stale results return typed `stale-source` evidence; the localized Job +Summary displays the suppression reason while issue and PR conversations remain +unchanged. The GitHub adapter and credential binding stay outside application +policy, and the CLI, push, and on-demand Action paths reuse the same guard. + No remote product flag is required. Each phase must be independently releasable and its compatibility adapter must fail closed to Job Summary, not fall back to generic comments. @@ -1083,7 +1106,8 @@ removed. one progress card reflects the latest valid head and no commit/reopen/generic comments are created. 6. Given an older progress run that finishes after a newer head, when it reaches - publication, then it cannot overwrite the newer card. + publication, then it cannot overwrite progress labels or the newer card, and + the Job Summary records `stale-source` suppression. 7. Given two concurrent first publications for the same identity, when both provider creates succeed, then the lowest bot-owned comment becomes canonical and the later exact duplicate is deleted or compacted when deletion is not diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index a591bd66a..d497eaef0 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -91,7 +91,11 @@ describe('CLI', () => { process.env.AGENT_MODEL_PROVIDER = 'openai'; process.env.OPENAI_API_KEY = 'test-key'; exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - (execSync as jest.Mock).mockReturnValue(Buffer.from('https://github.com/test-owner/test-repo.git')); + (execSync as jest.Mock).mockImplementation((command: string) => Buffer.from( + command === 'git rev-parse HEAD' + ? 'a'.repeat(40) + : 'https://github.com/test-owner/test-repo.git', + )); (runLocalAction as jest.Mock).mockResolvedValue(undefined); mockIsIssue.mockResolvedValue(true); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -279,6 +283,7 @@ describe('CLI', () => { expect(params[INPUT_KEYS.SINGLE_ACTION]).toBe(ACTIONS.CHECK_PROGRESS); expect(params[INPUT_KEYS.SINGLE_ACTION_ISSUE]).toBe(99); expect(params.issue?.number).toBe(99); + expect(params.after).toBe('a'.repeat(40)); expect(params[INPUT_KEYS.WELCOME_TITLE]).toContain('Progress'); }); @@ -322,6 +327,20 @@ describe('CLI', () => { expect(params.commits?.ref).toBe('refs/heads/feature/foo'); }); + it('fails closed before local execution when the workspace revision is unavailable', async () => { + (execSync as jest.Mock).mockImplementation((command: string) => { + if (command === 'git rev-parse HEAD') throw new Error('missing head'); + return Buffer.from('https://github.com/test-owner/test-repo.git'); + }); + const { logError } = require('../utils/logger'); + + await program.parseAsync(['node', 'cli', 'check-progress', '-i', '5']); + + expect(logError).toHaveBeenCalledWith('Unable to resolve the current Git revision for progress analysis.'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + it('omits the correlation reference outside debug mode for check-progress failures', async () => { (runLocalAction as jest.Mock).mockRejectedValueOnce(new Error('check-progress-secret-marker')); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); diff --git a/src/actions/__tests__/github_action_completion.test.ts b/src/actions/__tests__/github_action_completion.test.ts index 9581bb313..6efc16678 100644 --- a/src/actions/__tests__/github_action_completion.test.ts +++ b/src/actions/__tests__/github_action_completion.test.ts @@ -6,6 +6,7 @@ import * as core from '@actions/core'; import { ApplicationError } from '../../application/errors/application_error'; 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'; jest.mock('@actions/core', () => ({ setOutput: jest.fn(), setFailed: jest.fn() })); @@ -184,6 +185,32 @@ describe('finishGithubAction', () => { expect(action.currentConfiguration.recommendationState).toBeUndefined(); }); + it('passes the source query to publication and records stale suppression in the Job Summary', async () => { + const sourceQuery = { getBranchHeadSha: jest.fn() }; + mockPublishInvoke.mockResolvedValue(new Result({ + id: 'PublishResultUseCase', success: true, executed: false, + payload: { publicationOutcome: { + reason: 'stale-source', branch: 'feature/11-work', sourceHeadSha: 'a'.repeat(40), + } }, + })); + + await finishGithubAction( + execution(), + [], + {} as never, + {} as never, + undefined, + { publish: mockSummaryPublish }, + undefined, + sourceQuery, + ); + + expect(PublishResultUseCase).toHaveBeenCalledWith(expect.anything(), undefined, sourceQuery); + expect(mockSummaryPublish).toHaveBeenCalledWith(expect.stringContaining( + '| Source freshness | Stale result suppressed; branch HEAD changed during the run |', + )); + }); + it('does not persist configuration for a non-stateful single action', async () => { const action = singleActionExecution(); diff --git a/src/actions/__tests__/github_event_inputs.test.ts b/src/actions/__tests__/github_event_inputs.test.ts index 6d04803d1..f781afd44 100644 --- a/src/actions/__tests__/github_event_inputs.test.ts +++ b/src/actions/__tests__/github_event_inputs.test.ts @@ -36,6 +36,24 @@ describe('buildGithubActionEventInputs', () => { }); }); + it('uses only an event-provided branch head as freshness evidence', () => { + const pushHead = 'a'.repeat(40); + expect(buildGithubActionEventInputs({ + payload: { after: ` ${pushHead} ` }, eventName: 'push', actor: 'octocat', + repo: { owner: 'vypdev', repo: 'copilot' }, + }).after).toBe(pushHead); + }); + + it.each(['issue_comment', 'workflow_dispatch'])( + 'does not infer source-head evidence from the %s runtime context', + (eventName) => { + expect(buildGithubActionEventInputs({ + payload: {}, eventName, actor: 'octocat', + repo: { owner: 'vypdev', repo: 'copilot' }, + })).not.toHaveProperty('after'); + }, + ); + it('rejects a runtime context without repository coordinates', () => { expect(() => buildGithubActionEventInputs({ payload: {}, diff --git a/src/actions/github_action.ts b/src/actions/github_action.ts index d922a9524..6be977624 100644 --- a/src/actions/github_action.ts +++ b/src/actions/github_action.ts @@ -24,6 +24,9 @@ import { runAtApplicationErrorBoundary } from '../application/errors/application import { toApplicationError } from '../application/errors/application_error'; import { renderApplicationErrorText } from '../application/policies/application_error_presentation_policy'; import { bindIssueCommentPublication } from '../infrastructure/composition/push_single_action_capability_port_binding'; +import { bindPublicationSourceQuery } from '../infrastructure/composition/shared_capability_port_binding'; +import { GithubPublicationSourceRepository } from '../data/repository/github_publication_source_repository'; +import { createBranchClient } from '../infrastructure/composition/github_branch_client_factory'; import { createLanguageQueryPort } from '../infrastructure/composition/agent_capability_composition_root'; import { ResolveMessageCatalogUseCase } from '../application/usecases/localization/resolve_message_catalog_use_case'; import { readGithubActionLocaleInputs } from './github_action_locale_inputs'; @@ -137,6 +140,10 @@ export async function runGitHubAction(): Promise { new ResolveMessageCatalogUseCase( agentRuntimeAuthorized ? createLanguageQueryPort() : undefined, ), + bindPublicationSourceQuery( + new GithubPublicationSourceRepository(createBranchClient()), + repositoryBinding, + ), ); } diff --git a/src/actions/github_action_completion.ts b/src/actions/github_action_completion.ts index 3ab8110a9..be22b4c02 100644 --- a/src/actions/github_action_completion.ts +++ b/src/actions/github_action_completion.ts @@ -28,6 +28,7 @@ import { projectBugbotResultFindingStates } from '../application/policies/bugbot import { countActionableBugbotFindings } from '../domain/bugbot/review_state'; import type { MessageCatalogResolutionPort } from '../application/ports/message_catalog_ports'; import type { ApplicationErrorMessageReader } from '../application/policies/application_error_message_catalog'; +import type { BoundPublicationSourceQueryPort } from '../application/ports/publication_freshness_ports'; export async function finishGithubAction( execution: Execution, @@ -37,6 +38,7 @@ export async function finishGithubAction( evidencePort?: CopilotEvidencePort, summaryPort?: ActionSummaryPort, catalogResolver?: MessageCatalogResolutionPort, + publicationSourceQuery?: BoundPublicationSourceQueryPort, ): Promise { const stepCount = results.reduce((acc, result) => acc + (result.steps?.length ?? 0), 0); const errorCount = results.reduce((acc, result) => acc + (result.errors?.length ?? 0), 0); @@ -47,11 +49,12 @@ export async function finishGithubAction( const dryRun = results.some((result) => getResultPayload(result.payload)?.dryRun === true); const ownsDeploymentPresentation = execution.singleAction.isDeploymentOrchestrationAction; if (!dryRun && !execution.singleAction.isPublishIssueCommentAction && !ownsDeploymentPresentation) { - const publicationFailure = await new PublishResultUseCase( + const publicationOutcome = await new PublishResultUseCase( issueNotificationPort, catalogResolver, + publicationSourceQuery, ).invoke(projectPublishResultContext(execution)); - if (publicationFailure) results.push(publicationFailure); + if (publicationOutcome) results.push(publicationOutcome); } else if (execution.singleAction.isPublishIssueCommentAction || ownsDeploymentPresentation) { logInfo('Generic result publication skipped: this single action owns its user-facing presentation.'); } else { diff --git a/src/actions/github_event_inputs.ts b/src/actions/github_event_inputs.ts index 0e079f196..2276449ce 100644 --- a/src/actions/github_event_inputs.ts +++ b/src/actions/github_event_inputs.ts @@ -26,9 +26,13 @@ export function buildGithubActionEventInputs( const repository = requireRepositoryCoordinates(context.repo); const eventName = requireNonEmptyContextValue(context.eventName, 'event name'); const actor = requireNonEmptyContextValue(context.actor, 'actor'); + const payloadAfter = typeof context.payload.after === 'string' && context.payload.after.trim() + ? context.payload.after.trim() + : undefined; return { ...context.payload, + ...(payloadAfter ? { after: payloadAfter } : {}), eventName, actor, repo: repository, diff --git a/src/application/policies/__tests__/action_summary_policy.test.ts b/src/application/policies/__tests__/action_summary_policy.test.ts index 76c0bd799..7713ea5a5 100644 --- a/src/application/policies/__tests__/action_summary_policy.test.ts +++ b/src/application/policies/__tests__/action_summary_policy.test.ts @@ -200,6 +200,23 @@ describe('action summary policy', () => { expect(rejected).not.toContain('Internal rejection detail.'); }); + it('explains stale-source suppression without exposing object IDs', () => { + const summary = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'push', issueNumber: 7, pullRequestNumber: -1, + results: [new Result({ + id: 'CheckProgressUseCase', success: true, executed: false, + payload: { publicationOutcome: { + reason: 'stale-source', branch: 'feature/7-work', sourceHeadSha: 'a'.repeat(40), + } }, + })], + }); + + expect(summary).toContain('| Status | ⏭️ Skipped |'); + expect(summary).toContain('| Source freshness | Stale result suppressed; branch HEAD changed during the run |'); + expect(summary).not.toContain('feature/7-work'); + expect(summary).not.toContain('a'.repeat(40)); + }); + it('reports active findings as a warning unless fail-on-unresolved is enabled', () => { const summary = buildActionSummary({ owner: 'owner', diff --git a/src/application/policies/__tests__/publication_outcome_policy.test.ts b/src/application/policies/__tests__/publication_outcome_policy.test.ts new file mode 100644 index 000000000..86ff5e090 --- /dev/null +++ b/src/application/policies/__tests__/publication_outcome_policy.test.ts @@ -0,0 +1,30 @@ +import { Result } from '../../../data/model/result'; +import { + buildStaleSourcePublicationPayload, + hasStaleSourcePublicationOutcome, +} from '../publication_outcome_policy'; + +describe('publication outcome policy', () => { + it('builds immutable stale-source evidence', () => { + const payload = buildStaleSourcePublicationPayload('feature/work', 'a'.repeat(40)); + + expect(payload).toEqual({ + publicationOutcome: { + reason: 'stale-source', branch: 'feature/work', sourceHeadSha: 'a'.repeat(40), + }, + }); + expect(Object.isFrozen(payload)).toBe(true); + expect(Object.isFrozen(payload.publicationOutcome)).toBe(true); + }); + + it('recognizes only a structured stale-source outcome', () => { + expect(hasStaleSourcePublicationOutcome([ + new Result({ id: 'stale', success: true, executed: false, payload: buildStaleSourcePublicationPayload('feature/work', 'a'.repeat(40)) }), + ])).toBe(true); + expect(hasStaleSourcePublicationOutcome([ + new Result({ id: 'other', success: true, executed: false, payload: { publicationOutcome: null } }), + new Result({ id: 'malformed', success: true, executed: false, payload: { publicationOutcome: 'stale-source' } }), + new Result({ id: 'current', success: true, executed: true, payload: { publicationOutcome: { reason: 'current' } } }), + ])).toBe(false); + }); +}); diff --git a/src/application/policies/__tests__/semantic_result_publication_policy.test.ts b/src/application/policies/__tests__/semantic_result_publication_policy.test.ts index fdaa16cd1..3c9481aaa 100644 --- a/src/application/policies/__tests__/semantic_result_publication_policy.test.ts +++ b/src/application/policies/__tests__/semantic_result_publication_policy.test.ts @@ -9,6 +9,8 @@ import { selectSemanticStatusIntents, } from '../semantic_result_publication_policy'; +const SOURCE_HEAD = 'a'.repeat(40); + describe('semantic result publication policy', () => { it('recognizes only bot-owned primary markers for the exact issue', () => { const plan = ''; @@ -51,7 +53,7 @@ describe('semantic result publication policy', () => { it('selects only successful, executed, validated plan and progress payloads', () => { const results = [ new Result({ id: 'RecommendStepsUseCase', success: true, executed: true, payload: { issueNumber: 7, recommendedSteps: '1. Build', recommendationState: { issueDescriptionFingerprint: 'abc' } } }), - new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 7, progress: 101.2, summary: ' Done ', remaining: '', branch: '', developmentBranch: ' develop ' } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 7, progress: 101.2, summary: ' Done ', remaining: '', branch: ' feature/work ', developmentBranch: ' develop ', sourceHeadSha: SOURCE_HEAD.toUpperCase() } }), new Result({ id: 'RecommendStepsUseCase', success: false, executed: true, payload: { issueNumber: 7, recommendedSteps: 'ignored' } }), new Result({ id: 'CheckProgressUseCase', success: true, executed: false, payload: { issueNumber: 7, progress: 20, summary: 'ignored' } }), new Result({ id: 'Other', success: true, executed: true, payload: { issueNumber: 7, progress: 20, summary: 'ignored' } }), @@ -61,9 +63,14 @@ describe('semantic result publication policy', () => { expect(intents).toHaveLength(2); expect(intents[0]).toMatchObject({ kind: 'status', identity: { topic: 'plan', key: 'implementation' }, projection: { kind: 'plan' } }); - expect(intents[1]).toMatchObject({ identity: { topic: 'progress', key: 'work' }, projection: { progress: 100, summary: 'Done', developmentBranch: 'develop' } }); + expect(intents[1]).toMatchObject({ + identity: { topic: 'progress', key: 'work' }, + sourceVersion: `head:${SOURCE_HEAD}`, + sourceGuard: { kind: 'branch-head', branch: 'feature/work', sha: SOURCE_HEAD }, + projection: { progress: 100, summary: 'Done', branch: 'feature/work', developmentBranch: 'develop' }, + }); expect(intents[1].projection).not.toHaveProperty('remaining'); - expect(intents[1].projection).not.toHaveProperty('branch'); + expect(Object.isFrozen(intents[1].sourceGuard)).toBe(true); expect(renderSemanticStatus(intents[1])).toContain('No action required.'); }); @@ -71,8 +78,12 @@ describe('semantic result publication policy', () => { new Result({ id: 'RecommendStepsUseCase', success: true, executed: true, payload: null }), new Result({ id: 'RecommendStepsUseCase', success: true, executed: true, payload: { issueNumber: 0, recommendedSteps: 'x' } }), new Result({ id: 'RecommendStepsUseCase', success: true, executed: true, payload: { issueNumber: 1, recommendedSteps: ' ' } }), - new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: '10', summary: 'x' } }), - new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 1 } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: '10', summary: 'x', branch: 'feature/work', sourceHeadSha: SOURCE_HEAD } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 1, branch: 'feature/work', sourceHeadSha: SOURCE_HEAD } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 'x', branch: '', sourceHeadSha: SOURCE_HEAD } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 'x', branch: 'feature/work' } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 'x', branch: 'feature/work', sourceHeadSha: 'not-a-sha' } }), + new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 1, progress: 10, summary: 'x', branch: 'feature/work', sourceHeadSha: '0'.repeat(40) } }), ])('rejects malformed compatibility payloads', (result) => { expect(selectSemanticStatusIntents({ locale: 'en-US', results: [result] })).toEqual([]); }); @@ -84,7 +95,7 @@ describe('semantic result publication policy', () => { }); const [progress] = selectSemanticStatusIntents({ locale: 'en-US', - results: [new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 2, progress: -4, summary: '@attacker', remaining: '/fix' } })], + results: [new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 2, progress: -4, summary: '@attacker', remaining: '/fix', branch: 'feature/work', sourceHeadSha: SOURCE_HEAD } })], }); const planBody = renderSemanticStatus(plan); @@ -99,7 +110,7 @@ describe('semantic result publication policy', () => { it('omits the next section for incomplete progress without remaining work', () => { const [intent] = selectSemanticStatusIntents({ locale: 'en-US', - results: [new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 2, progress: 50, summary: '' } })], + results: [new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 2, progress: 50, summary: '', branch: 'feature/work', sourceHeadSha: SOURCE_HEAD } })], }); const body = renderSemanticStatus(intent); expect(body).toContain('Progress was assessed without a summary.'); diff --git a/src/application/policies/action_summary_message_catalog.ts b/src/application/policies/action_summary_message_catalog.ts index 36ec8a0a4..c9241bdc4 100644 --- a/src/application/policies/action_summary_message_catalog.ts +++ b/src/application/policies/action_summary_message_catalog.ts @@ -20,6 +20,7 @@ import { const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', + 'sourceFreshness', 'staleSourceSuppressed', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -71,6 +72,8 @@ const ENGLISH_SIMPLE: Readonly> = Object.freeze results: 'Results', findingStates: 'Finding states', bugbotReview: 'Bugbot review', + sourceFreshness: 'Source freshness', + staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -106,6 +109,8 @@ const SPANISH_SIMPLE: Readonly> = Object.freeze results: 'Resultados', findingStates: 'Estados de los hallazgos', bugbotReview: 'Revisión de Bugbot', + sourceFreshness: 'Vigencia del origen', + staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', diff --git a/src/application/policies/action_summary_policy.ts b/src/application/policies/action_summary_policy.ts index eb78788dd..b4d87ca21 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -18,6 +18,7 @@ import { } from './action_summary_message_catalog'; import { buildApplicationErrorPresentation } from './application_error_presentation_policy'; import type { ApplicationErrorMessageReader } from './application_error_message_catalog'; +import { hasStaleSourcePublicationOutcome } from './publication_outcome_policy'; export interface ActionSummaryContext { readonly owner: string; @@ -67,6 +68,7 @@ export function buildActionSummary( const findingStates = findingStateProjection.status === 'valid' ? findingStateProjection.counts : undefined; const telemetryProjection = projectBugbotResultTelemetry(context.results); const bugbotTelemetry = telemetryProjection.status === 'valid' ? telemetryProjection.telemetry : undefined; + const staleSourceSuppressed = hasStaleSourcePublicationOutcome(context.results); const hasActionableFindings = findingStates ? countActionableBugbotFindings(findingStates) > 0 : false; const hasUnknownFindings = findingStateProjection.status === 'invalid' || (findingStates?.unknown ?? 0) > 0; const status = resolveActionSummaryStatus({ @@ -88,6 +90,9 @@ export function buildActionSummary( `| ${catalogText(catalog, 'summary.results')} | ${formatResultCounts(context.results, catalog)} |`, `| ${catalogText(catalog, 'summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, `| ${catalogText(catalog, 'summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, + ...(staleSourceSuppressed ? [ + `| ${catalogText(catalog, 'summary.sourceFreshness')} | ${catalogText(catalog, 'summary.staleSourceSuppressed')} |`, + ] : []), ]; const localization = renderLocalizationSummarySection( context.locale, diff --git a/src/application/policies/publication_outcome_policy.ts b/src/application/policies/publication_outcome_policy.ts new file mode 100644 index 000000000..900fcc3b0 --- /dev/null +++ b/src/application/policies/publication_outcome_policy.ts @@ -0,0 +1,33 @@ +import { getResultPayload, type Result } from '../../data/model/result'; + +export interface StaleSourcePublicationOutcome { + readonly reason: 'stale-source'; + readonly branch: string; + readonly sourceHeadSha: string; +} + +export interface PublicationOutcomePayload { + readonly publicationOutcome: StaleSourcePublicationOutcome; +} + +/** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ +export function buildStaleSourcePublicationPayload( + branch: string, + sourceHeadSha: string, +): Readonly { + return Object.freeze({ + publicationOutcome: Object.freeze({ + reason: 'stale-source', + branch, + sourceHeadSha, + }), + }); +} + +export function hasStaleSourcePublicationOutcome(results: readonly Result[]): boolean { + return results.some(result => { + const payload = getResultPayload(result.payload); + const outcome = getResultPayload(payload?.publicationOutcome); + return outcome?.reason === 'stale-source'; + }); +} diff --git a/src/application/policies/semantic_result_publication_policy.ts b/src/application/policies/semantic_result_publication_policy.ts index 1b957f026..f9b8c7c96 100644 --- a/src/application/policies/semantic_result_publication_policy.ts +++ b/src/application/policies/semantic_result_publication_policy.ts @@ -29,6 +29,7 @@ import { renderApplicationErrorMarkdown, type ApplicationErrorPresentationSource, } from './application_error_presentation_policy'; +import { canonicalGitObjectId } from '../../domain/git_object_id'; export interface PlanPublicationProjection { readonly kind: 'plan'; @@ -315,10 +316,14 @@ function translationProjection(value: unknown): TranslationPublication | undefin } function progressIntent(id: string, payload: Record, locale: string): SemanticStatusIntent | undefined { + const sourceHeadSha = canonicalGitObjectId(payload.sourceHeadSha); + const branch = typeof payload.branch === 'string' ? payload.branch.trim() : ''; if (id !== 'CheckProgressUseCase' || !positiveInteger(payload.issueNumber) || typeof payload.progress !== 'number' - || typeof payload.summary !== 'string') return undefined; + || typeof payload.summary !== 'string' + || !sourceHeadSha + || !branch) return undefined; const progress = Math.max(0, Math.min(100, Math.round(payload.progress))); const projection = Object.freeze({ kind: 'progress', @@ -327,12 +332,15 @@ function progressIntent(id: string, payload: Record, locale: st ...(typeof payload.remaining === 'string' && payload.remaining.trim() ? { remaining: payload.remaining.trim() } : {}), - ...(typeof payload.branch === 'string' && payload.branch.trim() ? { branch: payload.branch.trim() } : {}), + branch, ...(typeof payload.developmentBranch === 'string' && payload.developmentBranch.trim() ? { developmentBranch: payload.developmentBranch.trim() } : {}), }); - return statusIntent('progress', payload.issueNumber, 'work', `progress:${createSemanticDigest(projection)}`, locale, projection); + return Object.freeze({ + ...statusIntent('progress', payload.issueNumber, 'work', `head:${sourceHeadSha}`, locale, projection), + sourceGuard: Object.freeze({ kind: 'branch-head', branch, sha: sourceHeadSha }), + }); } function statusIntent( diff --git a/src/application/ports/publication_freshness_ports.ts b/src/application/ports/publication_freshness_ports.ts new file mode 100644 index 000000000..1b172b16b --- /dev/null +++ b/src/application/ports/publication_freshness_ports.ts @@ -0,0 +1,8 @@ +export interface PublicationSourceQueryPort { + getBranchHeadSha(owner: string, repository: string, branch: string, token: string): Promise; +} + +/** Repository-credential-bound authoritative source lookup for publication guards. */ +export interface BoundPublicationSourceQueryPort { + getBranchHeadSha(branch: string): Promise; +} 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 779748a44..e5b95e0f9 100644 --- a/src/application/usecases/__tests__/push_single_action_contexts.test.ts +++ b/src/application/usecases/__tests__/push_single_action_contexts.test.ts @@ -165,6 +165,14 @@ describe('push and single-action context projection', () => { expect(projected.agentConfiguration.model).toBe('findings-model'); expect(JSON.stringify(projected)).not.toContain('secret'); expect(Object.isFrozen(projected.branchTypes)).toBe(true); + expect(projected).not.toHaveProperty('sourceHeadSha'); + }); + + it('canonicalizes a valid push head for downstream freshness checks', () => { + const input = source(); + input.inputs!.after = 'A'.repeat(40); + + expect(projectProgressContext(input).sourceHeadSha).toBe('a'.repeat(40)); }); it('uses the fixed progress branch fallback for an empty development branch', () => { diff --git a/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts b/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts index 4d145964a..d35dd6a62 100644 --- a/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts @@ -25,6 +25,9 @@ const mockSetLabels = jest.fn(); const mockGetListOfBranches = jest.fn(); const mockGetOpenPullRequestNumbersByHeadBranch = jest.fn(); +const mockGetBranchHeadSha = jest.fn(); +const SOURCE_HEAD = 'a'.repeat(40); +const NEWER_HEAD = 'b'.repeat(40); const mockAskAgent = jest.fn(); @@ -69,6 +72,7 @@ describe('CheckProgressUseCase', () => { { getListOfBranches: mockGetListOfBranches }, { getOpenPullRequestNumbersByHeadBranch: mockGetOpenPullRequestNumbersByHeadBranch }, { query: localizedProgress }, + { getBranchHeadSha: mockGetBranchHeadSha }, ); invoke = (param) => useCase.invoke(projectProgressContext(param)); mockGetDescription.mockReset(); @@ -77,6 +81,8 @@ describe('CheckProgressUseCase', () => { mockSetLabels.mockReset(); mockGetListOfBranches.mockReset(); mockGetOpenPullRequestNumbersByHeadBranch.mockReset(); + mockGetBranchHeadSha.mockReset(); + mockGetBranchHeadSha.mockResolvedValue(SOURCE_HEAD); mockAskAgent.mockReset(); }); @@ -258,6 +264,65 @@ describe('CheckProgressUseCase', () => { ); }); + it('carries the canonical source head and revalidates it before changing labels', async () => { + mockGetDescription.mockResolvedValue('Issue body'); + mockAskAgent.mockResolvedValue({ progress: 75, summary: 'Current source' }); + mockGetOpenPullRequestNumbersByHeadBranch.mockResolvedValue([]); + + const results = await invoke(baseParam({ inputs: { after: SOURCE_HEAD.toUpperCase() } })); + + expect(mockGetBranchHeadSha).toHaveBeenCalledWith('feature/123-add-feature'); + expect(mockSetProgressLabel).toHaveBeenCalledWith(123, 75); + expect(results[0].payload).toMatchObject({ sourceHeadSha: SOURCE_HEAD }); + }); + + it('suppresses an event that is already stale before agent work starts', async () => { + mockGetDescription.mockResolvedValue('Issue body'); + mockAskAgent.mockResolvedValue({ progress: 75, summary: 'Outdated source' }); + mockGetBranchHeadSha.mockResolvedValue(NEWER_HEAD); + + const results = await invoke(baseParam({ inputs: { after: SOURCE_HEAD } })); + + expect(results[0]).toMatchObject({ + success: true, + executed: false, + payload: { publicationOutcome: { reason: 'stale-source', branch: 'feature/123-add-feature', sourceHeadSha: SOURCE_HEAD } }, + }); + expect(mockAskAgent).not.toHaveBeenCalled(); + expect(mockSetProgressLabel).not.toHaveBeenCalled(); + expect(mockGetLabels).not.toHaveBeenCalled(); + expect(mockSetLabels).not.toHaveBeenCalled(); + }); + + it('suppresses native and conversational state when the branch advances during analysis', async () => { + mockGetDescription.mockResolvedValue('Issue body'); + mockAskAgent.mockResolvedValue({ progress: 75, summary: 'Outdated source' }); + mockGetBranchHeadSha.mockResolvedValueOnce(SOURCE_HEAD).mockResolvedValueOnce(NEWER_HEAD); + + const results = await invoke(baseParam()); + + expect(results[0]).toMatchObject({ + success: true, + executed: false, + payload: { publicationOutcome: { reason: 'stale-source', branch: 'feature/123-add-feature', sourceHeadSha: SOURCE_HEAD } }, + }); + expect(mockAskAgent).toHaveBeenCalledTimes(1); + expect(mockSetProgressLabel).not.toHaveBeenCalled(); + expect(mockSetLabels).not.toHaveBeenCalled(); + }); + + it('maps authoritative source lookup failures without mutating labels', async () => { + mockGetDescription.mockResolvedValue('Issue body'); + mockAskAgent.mockResolvedValue({ progress: 75, summary: 'Current source' }); + mockGetBranchHeadSha.mockRejectedValue(new Error('secret provider detail')); + + const results = await invoke(baseParam({ inputs: { after: SOURCE_HEAD } })); + + expect(results[0].errors[0]).toMatchObject({ code: 'workflow.failed' }); + expect(mockAskAgent).not.toHaveBeenCalled(); + expect(mockSetProgressLabel).not.toHaveBeenCalled(); + }); + it('uses default summary when AI response has no summary', async () => { mockGetDescription.mockResolvedValue('Issue body'); mockAskAgent.mockResolvedValue({ progress: 30 }); diff --git a/src/application/usecases/actions/check_progress_use_case.ts b/src/application/usecases/actions/check_progress_use_case.ts index f286601a1..27483cee0 100644 --- a/src/application/usecases/actions/check_progress_use_case.ts +++ b/src/application/usecases/actions/check_progress_use_case.ts @@ -7,6 +7,7 @@ import type { BoundBranchListQueryPort } from '../../ports/branch_lifecycle_port import type { ProgressContext } from '../push_single_action_contexts'; import { ParamUseCase } from '../base/param_usecase'; import { runCheckProgressWorkflow } from './check_progress_workflow'; +import type { BoundPublicationSourceQueryPort } from '../../ports/publication_freshness_ports'; /** Application boundary for assessing and publishing issue progress. */ export class CheckProgressUseCase implements ParamUseCase { @@ -19,6 +20,7 @@ export class CheckProgressUseCase implements ParamUseCase { @@ -29,6 +31,7 @@ export class CheckProgressUseCase implements ParamUseCase { + return await sourceQuery.getBranchHeadSha(branch) === sourceHeadSha; +} + +function buildStaleSourceResult(taskId: string, branch: string, sourceHeadSha: string): Result { + return new Result({ + id: taskId, + success: true, + executed: false, + payload: buildStaleSourcePublicationPayload(branch, sourceHeadSha), + }); +} + function buildZeroProgressResult( taskId: string, issueNumber: number, @@ -58,6 +104,7 @@ function buildZeroProgressResult( developmentBranch: string, summary: string, reasoning: string, + sourceHeadSha: string, ): Result { const message = 'Progress detection returned 0%. This may be due to a model error or no changes detected. Consider re-running the check.'; logError(message); @@ -67,7 +114,15 @@ function buildZeroProgressResult( executed: true, steps: [`Progress for issue #${issueNumber}: 0%`, summary], errors: [new ApplicationError('agent.failed', message)], - payload: { progress: 0, summary, reasoning: reasoning || undefined, issueNumber, branch, developmentBranch }, + payload: { + progress: 0, + summary, + reasoning: reasoning || undefined, + issueNumber, + branch, + developmentBranch, + sourceHeadSha, + }, }); } @@ -96,6 +151,7 @@ function buildProgressResult( summary: string, reasoning: string, remaining: string, + sourceHeadSha: string, ): Result { return new Result({ id: taskId, @@ -110,6 +166,7 @@ function buildProgressResult( issueNumber, branch, developmentBranch, + sourceHeadSha, }, }); } diff --git a/src/application/usecases/actions/progress_analysis_workflow.ts b/src/application/usecases/actions/progress_analysis_workflow.ts index 207726254..1c18c9d65 100644 --- a/src/application/usecases/actions/progress_analysis_workflow.ts +++ b/src/application/usecases/actions/progress_analysis_workflow.ts @@ -17,20 +17,24 @@ import { } from './progress_response'; import { ApplicationError, type ApplicationErrorCode } from '../../errors/application_error'; import { productFacingAgentQueryOptions } from '../../policies/agent_output_locale_policy'; +import type { BoundPublicationSourceQueryPort } from '../../ports/publication_freshness_ports'; export interface ProgressAnalysisDependencies { issueDescriptionQueryPort: BoundIssueDescriptionQueryPort; branchRepository: BoundBranchListQueryPort; aiRepository: FindingsQueryPort; + publicationSourceQuery: BoundPublicationSourceQueryPort; } export type ProgressAnalysis = | { kind: 'failure'; result: Result } + | { kind: 'stale-source'; branch: string; sourceHeadSha: string } | { kind: 'ready'; issueNumber: number; branch: string; developmentBranch: string; + sourceHeadSha: string; attemptResult: ProgressAttemptResult; }; @@ -87,6 +91,10 @@ export async function analyzeProgress( } const resolvedBranch = branch as string; + const sourceHeadSha = await dependencies.publicationSourceQuery.getBranchHeadSha(resolvedBranch); + if (param.sourceHeadSha && param.sourceHeadSha !== sourceHeadSha) { + return { kind: 'stale-source', branch: resolvedBranch, sourceHeadSha: param.sourceHeadSha }; + } const developmentBranch = param.developmentBranch; logInfo( `📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${resolvedBranch}" (configured agent will run git diff).`, @@ -122,6 +130,7 @@ export async function analyzeProgress( issueNumber, branch: resolvedBranch, developmentBranch, + sourceHeadSha, attemptResult, }; } diff --git a/src/application/usecases/push_single_action_contexts.ts b/src/application/usecases/push_single_action_contexts.ts index c1c8d22e5..5ca76527a 100644 --- a/src/application/usecases/push_single_action_contexts.ts +++ b/src/application/usecases/push_single_action_contexts.ts @@ -16,6 +16,7 @@ import type { } from '../ports/issue_management_ports'; import type { IssueCommentPublicationRequest } from '../policies/issue_comment_publication_policy'; import { resolveIssueCommentPublicationRequest } from '../policies/issue_comment_publication_policy'; +import { canonicalGitObjectId } from '../../domain/git_object_id'; export interface DeploymentPublicationContext { readonly requestedOperationId: string; @@ -31,6 +32,7 @@ export interface ProgressContext { readonly agentConfiguration: Readonly; readonly includeReasoning: boolean; readonly targetLocale: string; + readonly sourceHeadSha?: string; } export interface RecommendStepsContext { @@ -270,6 +272,7 @@ export function projectDeploymentOrchestrationContext( } export function projectProgressContext(source: PushSingleActionContextSource): ProgressContext { + const sourceHeadSha = canonicalGitObjectId(source.inputs?.after); return Object.freeze({ issueNumber: source.issueNumber, pushedBranch: source.commit.branch, @@ -285,6 +288,7 @@ export function projectProgressContext(source: PushSingleActionContextSource): P agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), targetLocale: source.locale?.issue ?? 'en-US', + ...(sourceHeadSha ? { sourceHeadSha } : {}), }); } diff --git a/src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts b/src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts index fae09ea70..f61cdaf59 100644 --- a/src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts +++ b/src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts @@ -5,6 +5,9 @@ import type { IssueCommentPublicationTarget } from '../../../../ports/issue_life import type { MessageCatalogResolutionPort } from '../../../../ports/message_catalog_ports'; import { ApplicationError } from '../../../../errors/application_error'; +const SOURCE_HEAD = 'a'.repeat(40); +const NEWER_HEAD = 'b'.repeat(40); + function recommendation(steps = '1. Add the policy\n2. Add tests', fingerprint = 'a'.repeat(16)): Result { return new Result({ id: 'RecommendStepsUseCase', success: true, executed: true, @@ -20,7 +23,10 @@ function recommendation(steps = '1. Add the policy\n2. Add tests', fingerprint = function progress(value = 65, summary = 'Core behavior is implemented.', remaining = 'Finish validation.'): Result { return new Result({ id: 'CheckProgressUseCase', success: true, executed: true, - payload: { issueNumber: 42, progress: value, summary, remaining, branch: 'feature/work', developmentBranch: 'develop' }, + payload: { + issueNumber: 42, progress: value, summary, remaining, + branch: 'feature/work', developmentBranch: 'develop', sourceHeadSha: SOURCE_HEAD, + }, steps: ['legacy progress wrapper that must never be published'], }); } @@ -51,6 +57,11 @@ function inMemoryComments(initial: IssueCommentPublicationTarget[] = []) { }; } +function publicationSource(...heads: string[]) { + const remaining = [...heads]; + return { getBranchHeadSha: jest.fn(async () => remaining.shift() ?? SOURCE_HEAD) }; +} + describe('PublishResultUseCase semantic compatibility boundary', () => { it.each([ new Result({ id: 'metadata', success: true, executed: true, steps: ['Waiting state cleared.'] }), @@ -234,7 +245,8 @@ describe('PublishResultUseCase semantic compatibility boundary', () => { ] as const)('renders bounded progress state for %s%%', async (value, state) => { const comments = inMemoryComments(); - await new PublishResultUseCase(comments).invoke(projectPublishResultContext(source([progress(value)]))); + await new PublishResultUseCase(comments, undefined, publicationSource()) + .invoke(projectPublishResultContext(source([progress(value)]))); expect(comments.values[0].body).toContain(`## Progress: ${value}% — ${state}`); expect(comments.values[0].body).not.toContain('Reasoning'); @@ -243,7 +255,7 @@ describe('PublishResultUseCase semantic compatibility boundary', () => { it('uses the configured issue locale for deterministic Spanish copy', async () => { const comments = inMemoryComments(); - await new PublishResultUseCase(comments).invoke(projectPublishResultContext(source( + await new PublishResultUseCase(comments, undefined, publicationSource()).invoke(projectPublishResultContext(source( [progress(100)], { locale: { issue: 'es-MX', pullRequest: 'en-US' } }, ))); @@ -334,6 +346,35 @@ describe('PublishResultUseCase semantic compatibility boundary', () => { expect(failure?.errors[0]).toMatchObject({ code: 'provider.unavailable' }); }); + it('reports a stale progress publication without changing the issue conversation', async () => { + const comments = inMemoryComments(); + const sourceQuery = publicationSource(NEWER_HEAD); + + const outcome = await new PublishResultUseCase(comments, undefined, sourceQuery) + .invoke(projectPublishResultContext(source([progress()]))); + + expect(outcome).toMatchObject({ + success: true, + executed: false, + payload: { publicationOutcome: { reason: 'stale-source', branch: 'feature/work', sourceHeadSha: SOURCE_HEAD } }, + }); + expect(sourceQuery.getBranchHeadSha).toHaveBeenCalledWith('feature/work'); + expect(comments.listIssueComments).not.toHaveBeenCalled(); + expect(comments.addComment).not.toHaveBeenCalled(); + }); + + it('fails closed when progress publication has no authoritative source query', async () => { + const comments = inMemoryComments(); + + const failure = await new PublishResultUseCase(comments) + .invoke(projectPublishResultContext(source([progress()]))); + + expect(failure).toMatchObject({ success: false, executed: true }); + expect(failure?.errors[0]).toMatchObject({ code: 'configuration.unsupported' }); + expect(comments.listIssueComments).not.toHaveBeenCalled(); + expect(comments.addComment).not.toHaveBeenCalled(); + }); + it('projects fallback issue targets and recursively copies array payloads', () => { const mutable = [{ nested: ['value'] }]; const context = projectPublishResultContext(source([ diff --git a/src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts b/src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts index 0f5520ae2..d06bf0c46 100644 --- a/src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts +++ b/src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts @@ -2,10 +2,16 @@ import { reconcileStatusCard } from '../status_card_publication_workflow'; import { renderSemanticStatus, selectSemanticStatusIntents } from '../../../../policies/semantic_result_publication_policy'; import { Result } from '../../../../../data/model/result'; +const SOURCE_HEAD = 'a'.repeat(40); +const NEWER_HEAD = 'b'.repeat(40); + function intent(summary = 'Current') { return selectSemanticStatusIntents({ locale: 'en-US', - results: [new Result({ id: 'CheckProgressUseCase', success: true, executed: true, payload: { issueNumber: 7, progress: 50, summary } })], + results: [new Result({ + id: 'CheckProgressUseCase', success: true, executed: true, + payload: { issueNumber: 7, progress: 50, summary, branch: 'feature/work', sourceHeadSha: SOURCE_HEAD }, + })], })[0]; } @@ -24,11 +30,15 @@ function ports(initial: Array<{ id: number; body: string | null; user?: { login? } const context = (value = intent()) => ({ owner: 'acme', repository: 'widgets', botLogin: 'vypbot', intent: value }); +const currentSource = (...heads: string[]) => { + const remaining = [...heads]; + return { getBranchHeadSha: jest.fn(async () => remaining.shift() ?? SOURCE_HEAD) }; +}; describe('status card publication workflow', () => { it('creates and discovers one canonical card', async () => { const repository = ports(); - await expect(reconcileStatusCard(context(), repository)).resolves.toEqual({ effect: 'created', canonicalCommentId: 1, duplicatesCompacted: 0 }); + await expect(reconcileStatusCard(context(), repository, currentSource())).resolves.toEqual({ effect: 'created', canonicalCommentId: 1, duplicatesCompacted: 0 }); expect(repository.addComment).toHaveBeenCalledTimes(1); expect(repository.updateComment).not.toHaveBeenCalled(); }); @@ -38,9 +48,9 @@ describe('status card publication workflow', () => { const repository = ports([{ id: 3, body: renderSemanticStatus(first), user: { login: 'VypBot' } }]); const changed = intent('New'); - await expect(reconcileStatusCard(context(changed), repository)).resolves.toMatchObject({ effect: 'updated', canonicalCommentId: 3 }); + await expect(reconcileStatusCard(context(changed), repository, currentSource())).resolves.toMatchObject({ effect: 'updated', canonicalCommentId: 3 }); repository.updateComment.mockClear(); - await expect(reconcileStatusCard(context(changed), repository)).resolves.toMatchObject({ effect: 'unchanged' }); + await expect(reconcileStatusCard(context(changed), repository, currentSource())).resolves.toMatchObject({ effect: 'unchanged' }); expect(repository.updateComment).not.toHaveBeenCalled(); }); @@ -53,7 +63,7 @@ describe('status card publication workflow', () => { { id: 3, body: body.replace('key="work"', 'key="other"'), user: { login: 'vypbot' } }, ]); - await reconcileStatusCard(context(value), repository); + await reconcileStatusCard(context(value), repository, currentSource()); expect(repository.addComment).toHaveBeenCalledTimes(1); expect(repository.comments).toHaveLength(4); @@ -67,7 +77,7 @@ describe('status card publication workflow', () => { { id: 4, body: stale, user: { login: 'vypbot' } }, ]); - await expect(reconcileStatusCard(context(value), repository)).resolves.toEqual({ + await expect(reconcileStatusCard(context(value), repository, currentSource())).resolves.toEqual({ effect: 'updated', canonicalCommentId: 4, duplicatesCompacted: 1, }); expect(repository.comments.find(comment => comment.id === 4)?.body).toContain('Latest'); @@ -77,7 +87,7 @@ describe('status card publication workflow', () => { it('fails closed without a trusted bot identity', async () => { const repository = ports(); - await expect(reconcileStatusCard({ ...context(), botLogin: '' }, repository)).resolves.toEqual({ + await expect(reconcileStatusCard({ ...context(), botLogin: '' }, repository, currentSource())).resolves.toEqual({ effect: 'unchanged', duplicatesCompacted: 0, }); expect(repository.listIssueComments).not.toHaveBeenCalled(); @@ -86,7 +96,7 @@ describe('status card publication workflow', () => { it('tolerates create visibility lag without guessing a comment id', async () => { const repository = ports(); repository.addComment.mockImplementation(async () => undefined); - await expect(reconcileStatusCard(context(), repository)).resolves.toEqual({ effect: 'created', duplicatesCompacted: 0 }); + await expect(reconcileStatusCard(context(), repository, currentSource())).resolves.toEqual({ effect: 'created', duplicatesCompacted: 0 }); }); it('preserves the created outcome when post-create discovery finds a stale canonical card', async () => { @@ -98,7 +108,7 @@ describe('status card publication workflow', () => { return reads === 1 ? [] : repository.comments.map(comment => ({ ...comment })); }); - await expect(reconcileStatusCard(context(value), repository)).resolves.toMatchObject({ + await expect(reconcileStatusCard(context(value), repository, currentSource())).resolves.toMatchObject({ effect: 'created', canonicalCommentId: 1, }); expect(repository.comments[0].body).toContain('Latest'); @@ -116,8 +126,101 @@ describe('status card publication workflow', () => { { id: 4, body, user: { login: 'vypbot' } }, ]); - await reconcileStatusCard(context(value), repository); + await reconcileStatusCard(context(value), repository, currentSource()); expect(repository.comments.find(comment => comment.id === 7)?.body).toContain('/pull/9#issuecomment-4'); expect(repository.comments.find(comment => comment.id === 7)?.body).toContain('tarjeta canónica'); }); + + it('omits every read and mutation when the analyzed branch head is already stale', async () => { + const repository = ports(); + const source = currentSource(NEWER_HEAD); + + await expect(reconcileStatusCard(context(), repository, source)).resolves.toEqual({ + effect: 'unchanged', duplicatesCompacted: 0, reason: 'stale-source', + }); + + expect(source.getBranchHeadSha).toHaveBeenCalledWith('feature/work'); + expect(repository.listIssueComments).not.toHaveBeenCalled(); + expect(repository.addComment).not.toHaveBeenCalled(); + expect(repository.updateComment).not.toHaveBeenCalled(); + }); + + it('revalidates immediately before creating a missing card', async () => { + const repository = ports(); + + await expect(reconcileStatusCard( + context(), repository, currentSource(SOURCE_HEAD, NEWER_HEAD), + )).resolves.toEqual({ effect: 'unchanged', duplicatesCompacted: 0, reason: 'stale-source' }); + + expect(repository.listIssueComments).toHaveBeenCalledTimes(1); + expect(repository.addComment).not.toHaveBeenCalled(); + }); + + it('revalidates immediately before updating an existing card', async () => { + const repository = ports([{ + id: 3, body: renderSemanticStatus(intent('Old')), user: { login: 'vypbot' }, + }]); + + await expect(reconcileStatusCard( + context(intent('New')), repository, currentSource(SOURCE_HEAD, NEWER_HEAD), + )).resolves.toEqual({ + effect: 'unchanged', canonicalCommentId: 3, duplicatesCompacted: 0, reason: 'stale-source', + }); + + expect(repository.updateComment).not.toHaveBeenCalled(); + expect(repository.comments[0].body).toContain('Old'); + }); + + it('stops duplicate compaction if the branch advances between mutations', async () => { + const value = intent('Current'); + const body = renderSemanticStatus(value); + const repository = ports([ + { id: 2, body, user: { login: 'vypbot' } }, + { id: 3, body, user: { login: 'vypbot' } }, + { id: 4, body, user: { login: 'vypbot' } }, + ]); + + await expect(reconcileStatusCard( + context(value), repository, currentSource(SOURCE_HEAD, SOURCE_HEAD, NEWER_HEAD), + )).resolves.toEqual({ + effect: 'unchanged', canonicalCommentId: 2, duplicatesCompacted: 1, reason: 'stale-source', + }); + + expect(repository.updateComment).toHaveBeenCalledTimes(1); + expect(repository.comments[1].body).toContain('copilot:publication-duplicate'); + expect(repository.comments[2].body).toBe(body); + }); + + it('fails closed when a commit-derived card has no authoritative source port', async () => { + const repository = ports(); + + await expect(reconcileStatusCard(context(), repository)).rejects.toMatchObject({ + code: 'configuration.unsupported', + }); + expect(repository.listIssueComments).not.toHaveBeenCalled(); + }); + + it('requires exact canonical object-id equality', async () => { + const repository = ports(); + + await expect(reconcileStatusCard( + context(), repository, currentSource(SOURCE_HEAD.toUpperCase()), + )).resolves.toMatchObject({ reason: 'stale-source' }); + expect(repository.addComment).not.toHaveBeenCalled(); + }); + + it('does not query a branch head for a non-commit-derived plan card', async () => { + const [plan] = selectSemanticStatusIntents({ + locale: 'en-US', + results: [new Result({ + id: 'RecommendStepsUseCase', success: true, executed: true, + payload: { issueNumber: 7, recommendedSteps: '1. Implement' }, + })], + }); + const repository = ports(); + const source = currentSource(NEWER_HEAD); + + await expect(reconcileStatusCard(context(plan), repository, source)).resolves.toMatchObject({ effect: 'created' }); + expect(source.getBranchHeadSha).not.toHaveBeenCalled(); + }); }); diff --git a/src/application/usecases/steps/common/publish_resume_use_case.ts b/src/application/usecases/steps/common/publish_resume_use_case.ts index ab1bed34a..1c03b1ad5 100644 --- a/src/application/usecases/steps/common/publish_resume_use_case.ts +++ b/src/application/usecases/steps/common/publish_resume_use_case.ts @@ -5,6 +5,7 @@ import { getTaskEmoji } from '../../../../utils/task_emoji'; import { ParamUseCase } from '../../base/param_usecase'; import { runPublishResume, type PublishResultContext } from './publish_resume_workflow'; import type { MessageCatalogResolutionPort } from '../../../ports/message_catalog_ports'; +import type { BoundPublicationSourceQueryPort } from '../../../ports/publication_freshness_ports'; export class PublishResultUseCase implements ParamUseCase { taskId = 'PublishResultUseCase'; @@ -12,10 +13,11 @@ export class PublishResultUseCase implements ParamUseCase { logInfo(`${getTaskEmoji(this.taskId)} Executing ${this.taskId}.`); - return runPublishResume(param, this.taskId, this.comments, this.catalogResolver); + return runPublishResume(param, this.taskId, this.comments, this.catalogResolver, this.sourceQuery); } } diff --git a/src/application/usecases/steps/common/publish_resume_workflow.ts b/src/application/usecases/steps/common/publish_resume_workflow.ts index 5fd2637ec..e677f3f98 100644 --- a/src/application/usecases/steps/common/publish_resume_workflow.ts +++ b/src/application/usecases/steps/common/publish_resume_workflow.ts @@ -10,6 +10,8 @@ import { reconcileStatusCard } from './status_card_publication_workflow'; import type { AgentConfiguration } from '../../../../domain/agent'; import type { MessageCatalogResolutionPort } from '../../../ports/message_catalog_ports'; import { resolvePublicationCatalog } from '../../../policies/publication_message_catalog'; +import type { BoundPublicationSourceQueryPort } from '../../../ports/publication_freshness_ports'; +import { buildStaleSourcePublicationPayload } from '../../../policies/publication_outcome_policy'; export interface PublishResultContext { readonly owner: string; @@ -67,6 +69,7 @@ export async function runPublishResume( taskId: string, comments: BoundIssueCommentPublicationPort, catalogResolver?: MessageCatalogResolutionPort, + sourceQuery?: BoundPublicationSourceQueryPort, ): Promise { try { const semanticContext = { @@ -91,6 +94,7 @@ export async function runPublishResume( param.languageConfiguration, catalogResolver, ); + let staleSourceEvidence: Readonly<{ branch: string; sha: string }> | undefined; for (const intent of replies) { const outcome = await reconcileReply({ owner: param.owner, @@ -108,10 +112,30 @@ export async function runPublishResume( botLogin: param.botLogin, intent, catalog, - }, comments); - logInfo(`Semantic ${intent.identity.topic} publication ${outcome.effect}; duplicates compacted=${outcome.duplicatesCompacted}.`); + }, comments, sourceQuery); + logInfo( + `Semantic ${intent.identity.topic} publication ${outcome.effect}; ` + + `reason=${outcome.reason ?? 'current'}; duplicates compacted=${outcome.duplicatesCompacted}.`, + ); + if (outcome.reason === 'stale-source') { + const sourceGuard = intent.sourceGuard; + if (!sourceGuard) { + throw new Error('Stale-source publication outcome requires a source guard.'); + } + staleSourceEvidence ??= Object.freeze({ branch: sourceGuard.branch, sha: sourceGuard.sha }); + } } - return undefined; + return staleSourceEvidence + ? new Result({ + id: taskId, + success: true, + executed: false, + payload: buildStaleSourcePublicationPayload( + staleSourceEvidence.branch, + staleSourceEvidence.sha, + ), + }) + : undefined; } catch (error) { const semanticError = toApplicationError(error, 'provider.unavailable', 'Unable to publish semantic GitHub status.'); logError(semanticError); diff --git a/src/application/usecases/steps/common/status_card_publication_workflow.ts b/src/application/usecases/steps/common/status_card_publication_workflow.ts index d7e88438a..a201aee1d 100644 --- a/src/application/usecases/steps/common/status_card_publication_workflow.ts +++ b/src/application/usecases/steps/common/status_card_publication_workflow.ts @@ -5,6 +5,8 @@ import type { BoundIssueCommentPublicationPort, IssueCommentPublicationTarget } import { buildDuplicateMarker, parsePublicationMarker } from '../../../policies/publication_identity_policy'; import { resolveStaticPublicationCatalog, type PublicationMessageCatalog } from '../../../policies/publication_message_catalog'; import { renderSemanticStatus, type SemanticStatusIntent } from '../../../policies/semantic_result_publication_policy'; +import type { BoundPublicationSourceQueryPort } from '../../../ports/publication_freshness_ports'; +import { ApplicationError } from '../../../errors/application_error'; export interface StatusCardPublicationContext { readonly owner: string; @@ -18,18 +20,22 @@ export interface StatusCardPublicationOutcome { readonly effect: 'created' | 'updated' | 'unchanged'; readonly canonicalCommentId?: number; readonly duplicatesCompacted: number; + readonly reason?: 'stale-source'; } export async function reconcileStatusCard( context: StatusCardPublicationContext, comments: BoundIssueCommentPublicationPort, + sourceQuery?: BoundPublicationSourceQueryPort, ): Promise { if (!context.botLogin.trim()) return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + if (!await sourceIsCurrent(context.intent, sourceQuery)) return staleSourceOutcome(); const target = context.intent.identity.target; const rendered = renderSemanticStatus(context.intent, context.catalog); let owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); let effect: StatusCardPublicationOutcome['effect'] = 'unchanged'; if (owned.length === 0) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) return staleSourceOutcome(); await comments.addComment(target.number, rendered); effect = 'created'; owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); @@ -39,16 +45,52 @@ export async function reconcileStatusCard( const [canonical, ...duplicates] = owned.sort((left, right) => left.id - right.id); const canonicalMarker = parsePublicationMarker(canonical.body); if (canonicalMarker?.digest !== context.intent.digest) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) { + return staleSourceOutcome(canonical.id); + } await comments.updateComment(target.number, canonical.id, rendered); effect = effect === 'created' ? 'created' : 'updated'; } + let duplicatesCompacted = 0; for (const duplicate of duplicates) { + if (!await sourceIsCurrent(context.intent, sourceQuery)) { + return staleSourceOutcome(canonical.id, effect, duplicatesCompacted); + } await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); + duplicatesCompacted += 1; } return Object.freeze({ effect, canonicalCommentId: canonical.id, - duplicatesCompacted: duplicates.length, + duplicatesCompacted, + }); +} + +async function sourceIsCurrent( + intent: SemanticStatusIntent, + sourceQuery: BoundPublicationSourceQueryPort | undefined, +): Promise { + const guard = intent.sourceGuard; + if (!guard) return true; + if (!sourceQuery) { + throw new ApplicationError( + 'configuration.unsupported', + 'Commit-derived status publication requires an authoritative source query.', + ); + } + return await sourceQuery.getBranchHeadSha(guard.branch) === guard.sha; +} + +function staleSourceOutcome( + canonicalCommentId?: number, + effect: StatusCardPublicationOutcome['effect'] = 'unchanged', + duplicatesCompacted = 0, +): StatusCardPublicationOutcome { + return Object.freeze({ + effect, + ...(canonicalCommentId === undefined ? {} : { canonicalCommentId }), + duplicatesCompacted, + reason: 'stale-source', }); } diff --git a/src/cli/commands/__tests__/issue_command_policy.test.ts b/src/cli/commands/__tests__/issue_command_policy.test.ts index 9e8a262e4..f20b824ed 100644 --- a/src/cli/commands/__tests__/issue_command_policy.test.ts +++ b/src/cli/commands/__tests__/issue_command_policy.test.ts @@ -12,13 +12,16 @@ describe('issue command policy', () => { }); it('builds check-progress params with an optional branch reference', () => { - const params = buildCheckProgressParams({ issue: '12', branch: 'feature/test', debug: true }, gitInfo); + const params = buildCheckProgressParams( + { issue: '12', branch: 'feature/test', debug: true }, gitInfo, 'a'.repeat(40), + ); if (!params) throw new Error('Expected valid check-progress parameters.'); expect(params).toMatchObject({ [INPUT_KEYS.SINGLE_ACTION]: ACTIONS.CHECK_PROGRESS, [INPUT_KEYS.SINGLE_ACTION_ISSUE]: 12, issue: { number: 12 }, commits: { ref: 'refs/heads/feature/test' }, + after: 'a'.repeat(40), }); }); diff --git a/src/cli/commands/check_progress.ts b/src/cli/commands/check_progress.ts index 44d9311dd..f3f4b6617 100644 --- a/src/cli/commands/check_progress.ts +++ b/src/cli/commands/check_progress.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { runLocalAction } from '../../actions/local_action'; import { TITLE } from '../../application/contracts/product_identity'; import { logError } from '../../utils/logger'; -import { getGitInfo } from '../../cli_context'; +import { getCurrentHeadSha, getGitInfo } from '../../cli_context'; import { cleanCliArgument } from '../command_input_policy'; import { buildCheckProgressParams, parseIssueNumber } from './issue_command_policy'; import { toApplicationError } from '../../application/errors/application_error'; @@ -33,7 +33,13 @@ export function registerCheckProgressCommand(program: Command): void { process.exitCode = 1; return; } - const params = buildCheckProgressParams(options, gitInfo); + const sourceHeadSha = getCurrentHeadSha(); + if (!sourceHeadSha) { + logError('Unable to resolve the current Git revision for progress analysis.'); + process.exitCode = 1; + return; + } + const params = buildCheckProgressParams(options, gitInfo, sourceHeadSha); if (!params) return; try { await runLocalAction(params); diff --git a/src/cli/commands/issue_command_policy.ts b/src/cli/commands/issue_command_policy.ts index 7b43fe23f..ca99e9c35 100644 --- a/src/cli/commands/issue_command_policy.ts +++ b/src/cli/commands/issue_command_policy.ts @@ -24,6 +24,7 @@ export function parseIssueNumber(value: unknown): number | undefined { export function buildCheckProgressParams( options: IssueCommandOptions, gitInfo: GitInfo, + sourceHeadSha: string, ): Record | undefined { if ('error' in gitInfo) return undefined; const issueNumber = parseIssueNumber(options.issue); @@ -36,6 +37,7 @@ export function buildCheckProgressParams( [INPUT_KEYS.AI_IGNORE_FILES]: process.env.AI_IGNORE_FILES || 'build/*,dist/*,node_modules/*,*.d.ts', repo: { owner: gitInfo.owner, repo: gitInfo.repo }, issue: { number: issueNumber }, + after: sourceHeadSha, ...(branch ? { commits: { ref: `refs/heads/${branch}` } } : {}), [INPUT_KEYS.WELCOME_TITLE]: '📊 Progress Check', [INPUT_KEYS.WELCOME_MESSAGES]: [`Checking progress for issue #${issueNumber} in ${gitInfo.owner}/${gitInfo.repo}...`], diff --git a/src/cli_context.ts b/src/cli_context.ts index c89aa29b1..5661cba8a 100644 --- a/src/cli_context.ts +++ b/src/cli_context.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import { realpathSync } from 'node:fs'; import { ERRORS } from './cli/cli_errors'; +import { canonicalGitObjectId } from './domain/git_object_id'; export type GitInfo = { owner: string; repo: string } | { error: string }; @@ -29,6 +30,15 @@ export function getCurrentBranch(): string { } } +/** Returns the canonical object ID for the workspace revision being analyzed. */ +export function getCurrentHeadSha(): string | undefined { + try { + return canonicalGitObjectId(execSync('git rev-parse HEAD').toString().trim()); + } catch { + return undefined; + } +} + export function isInsideGitRepo(cwd: string): boolean { try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' }); diff --git a/src/data/repository/__tests__/github_publication_source_repository.test.ts b/src/data/repository/__tests__/github_publication_source_repository.test.ts new file mode 100644 index 000000000..903da86b7 --- /dev/null +++ b/src/data/repository/__tests__/github_publication_source_repository.test.ts @@ -0,0 +1,26 @@ +import { GithubPublicationSourceRepository } from '../github_publication_source_repository'; + +describe('GitHub publication source repository', () => { + it('returns a canonical authoritative branch head', async () => { + const getRef = jest.fn().mockResolvedValue({ data: { object: { sha: 'A'.repeat(40) } } }); + const repository = new GithubPublicationSourceRepository({ + getClient: () => ({ rest: { git: { getRef } } }), + } as never); + + await expect(repository.getBranchHeadSha('acme', 'widgets', 'feature/work', 'token')) + .resolves.toBe('a'.repeat(40)); + expect(getRef).toHaveBeenCalledWith({ owner: 'acme', repo: 'widgets', ref: 'heads/feature/work' }); + }); + + it.each([undefined, 'not-a-sha', '0'.repeat(40)])( + 'rejects an invalid provider object ID: %s', + async (sha) => { + const repository = new GithubPublicationSourceRepository({ + getClient: () => ({ rest: { git: { getRef: jest.fn().mockResolvedValue({ data: { object: { sha } } }) } } }), + } as never); + + await expect(repository.getBranchHeadSha('acme', 'widgets', 'feature/work', 'token')) + .rejects.toMatchObject({ code: 'provider.contract-invalid' }); + }, + ); +}); diff --git a/src/data/repository/github_publication_source_repository.ts b/src/data/repository/github_publication_source_repository.ts new file mode 100644 index 000000000..d23661a88 --- /dev/null +++ b/src/data/repository/github_publication_source_repository.ts @@ -0,0 +1,26 @@ +import { ApplicationError } from '../../application/errors/application_error'; +import type { PublicationSourceQueryPort } from '../../application/ports/publication_freshness_ports'; +import { canonicalGitObjectId } from '../../domain/git_object_id'; +import type { GithubBranchClient } from '../../infrastructure/github/ports/github_branch_provider_ports'; +import type { GithubClientPort } from '../../infrastructure/github/ports/github_client_provider_port'; + +/** Reads the authoritative branch head without exposing Octokit to application code. */ +export class GithubPublicationSourceRepository implements PublicationSourceQueryPort { + constructor(private readonly clientProvider: GithubClientPort) {} + + async getBranchHeadSha(owner: string, repository: string, branch: string, token: string): Promise { + const { data } = await this.clientProvider.getClient(token).rest.git.getRef({ + owner, + repo: repository, + ref: `heads/${branch}`, + }); + const sha = canonicalGitObjectId(data.object?.sha); + if (!sha) { + throw new ApplicationError( + 'provider.contract-invalid', + 'GitHub returned an invalid branch-head object ID.', + ); + } + return sha; + } +} diff --git a/src/domain/__tests__/git_object_id.test.ts b/src/domain/__tests__/git_object_id.test.ts new file mode 100644 index 000000000..db3f2e725 --- /dev/null +++ b/src/domain/__tests__/git_object_id.test.ts @@ -0,0 +1,23 @@ +import { canonicalGitObjectId } from '../git_object_id'; + +describe('git object id policy', () => { + it.each([ + ['A'.repeat(40), 'a'.repeat(40)], + [` ${'B'.repeat(64)} `, 'b'.repeat(64)], + ])('canonicalizes supported object IDs', (value, expected) => { + expect(canonicalGitObjectId(value)).toBe(expected); + }); + + it.each([ + undefined, + 42, + '', + 'a'.repeat(39), + 'a'.repeat(41), + 'g'.repeat(40), + '0'.repeat(40), + '0'.repeat(64), + ])('rejects invalid or null object IDs: %s', (value) => { + expect(canonicalGitObjectId(value)).toBeUndefined(); + }); +}); diff --git a/src/domain/git_object_id.ts b/src/domain/git_object_id.ts new file mode 100644 index 000000000..5d08f7d27 --- /dev/null +++ b/src/domain/git_object_id.ts @@ -0,0 +1,9 @@ +const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; + +/** Canonicalizes a real SHA-1/SHA-256 object ID and rejects webhook null sentinels. */ +export function canonicalGitObjectId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (!GIT_OBJECT_ID_PATTERN.test(normalized) || /^0+$/u.test(normalized)) return undefined; + return normalized; +} diff --git a/src/domain/github_publication.ts b/src/domain/github_publication.ts index ddf443e7b..280bd9b2d 100644 --- a/src/domain/github_publication.ts +++ b/src/domain/github_publication.ts @@ -53,6 +53,11 @@ export interface StatusPublicationIntent { readonly digest: string; readonly locale: string; readonly projection: Readonly; + readonly sourceGuard?: Readonly<{ + readonly kind: 'branch-head'; + readonly branch: string; + readonly sha: string; + }>; } export interface TransitionPublicationIntent { diff --git a/src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts b/src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts index 184dbc033..8216fe8e0 100644 --- a/src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts +++ b/src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts @@ -5,12 +5,20 @@ import { bindIssueNotification, bindIssueTitle, bindOrganizationMembers, + bindPublicationSourceQuery, bindProjectContent, } from '../shared_capability_port_binding'; const binding = { owner: 'acme', repository: 'demo', token: 'secret' }; describe('shared capability repository bindings', () => { + it('binds authoritative publication source lookups without exposing credentials', async () => { + const getBranchHeadSha = jest.fn().mockResolvedValue('a'.repeat(40)); + await expect(bindPublicationSourceQuery({ getBranchHeadSha }, binding).getBranchHeadSha('feature/work')) + .resolves.toBe('a'.repeat(40)); + expect(getBranchHeadSha).toHaveBeenCalledWith('acme', 'demo', 'feature/work', 'secret'); + }); + it('binds organization, description, notification, and comment credentials once', async () => { const getAllMembers = jest.fn().mockResolvedValue(['alice']); const getDescription = jest.fn().mockResolvedValue('body'); diff --git a/src/infrastructure/composition/check_progress_composition_root.ts b/src/infrastructure/composition/check_progress_composition_root.ts index 7a19c7bf0..25df51a95 100644 --- a/src/infrastructure/composition/check_progress_composition_root.ts +++ b/src/infrastructure/composition/check_progress_composition_root.ts @@ -9,8 +9,9 @@ import { IssueProgressLabelRepository } from "../../data/repository/issue/issue_ import { IssueProgressTrackingRepository } from "../../data/repository/issue/issue_progress_tracking_repository"; import { BranchLifecycleRepository } from "../../data/repository/branch_lifecycle_repository"; import { PullRequestLifecycleRepository } from "../../data/repository/pull_request/pull_request_lifecycle_repository"; +import { GithubPublicationSourceRepository } from '../../data/repository/github_publication_source_repository'; import type { RepositoryCredentialBinding } from './shared_capability_port_binding'; -import { bindIssueDescriptionQuery } from './shared_capability_port_binding'; +import { bindIssueDescriptionQuery, bindPublicationSourceQuery } from './shared_capability_port_binding'; import { bindIssueLabels } from './lifecycle_capability_port_binding'; import { bindBranchListQuery, bindIssueProgress, bindPullRequestBranchQuery } from './push_single_action_capability_port_binding'; @@ -28,5 +29,6 @@ export function createCheckProgressCompositionRoot(binding: RepositoryCredential bindBranchListQuery(new BranchLifecycleRepository(createBranchClient()), binding), bindPullRequestBranchQuery(new PullRequestLifecycleRepository(createPullRequestLifecycleClient()), binding), createFindingsQueryPort(), + bindPublicationSourceQuery(new GithubPublicationSourceRepository(createBranchClient()), binding), ); } diff --git a/src/infrastructure/composition/shared_capability_port_binding.ts b/src/infrastructure/composition/shared_capability_port_binding.ts index e85558569..f7b61b8f6 100644 --- a/src/infrastructure/composition/shared_capability_port_binding.ts +++ b/src/infrastructure/composition/shared_capability_port_binding.ts @@ -21,6 +21,10 @@ import type { } from '../../application/ports/project_board_link_ports'; import type { IssueIdentityQueryPort } from '../../application/ports/issue_identity_ports'; import { ProjectDetail } from '../../data/model/project_detail'; +import type { + BoundPublicationSourceQueryPort, + PublicationSourceQueryPort, +} from '../../application/ports/publication_freshness_ports'; export interface RepositoryCredentialBinding { readonly owner: string; @@ -28,6 +32,20 @@ export interface RepositoryCredentialBinding { readonly token: string; } +export function bindPublicationSourceQuery( + port: PublicationSourceQueryPort, + binding: RepositoryCredentialBinding, +): BoundPublicationSourceQueryPort { + return Object.freeze({ + getBranchHeadSha: (branch: string) => port.getBranchHeadSha( + binding.owner, + binding.repository, + branch, + binding.token, + ), + }); +} + export function bindOrganizationMembers( port: OrganizationMembersPort, binding: RepositoryCredentialBinding, diff --git a/src/infrastructure/github/ports/github_branch_provider_ports.ts b/src/infrastructure/github/ports/github_branch_provider_ports.ts index c6b4539b7..649709e03 100644 --- a/src/infrastructure/github/ports/github_branch_provider_ports.ts +++ b/src/infrastructure/github/ports/github_branch_provider_ports.ts @@ -18,7 +18,7 @@ export interface GithubBranchClient { listBranches(parameters: Record): Promise<{ data: Array<{ name: string }> }>; }; git: { - getRef(parameters: Record): Promise<{ data: { ref: string } }>; + getRef(parameters: Record): Promise<{ data: { ref: string; object?: { sha?: string } } }>; deleteRef(parameters: Record): Promise; }; };