From 294840f4eb21d5fcab125220c48c6cde06bd18fa Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 09:41:07 +0200 Subject: [PATCH 1/2] codex-semantic-duplicate-cleanup: Remove exact semantic publication duplicates --- build/cli/index.js | 105 ++++++- build/github_action/index.js | 264 ++++++++++++++++-- docs/development/architecture.mdx | 6 + docs/features.mdx | 2 +- docs/issues/notifications-and-auto-close.mdx | 8 +- scripts/coverage-budgets.json | 25 ++ specs/CATALOG.md | 6 +- specs/catalog.json | 8 +- ...tic-github-publication-and-notification.md | 13 +- .../__tests__/action_summary_policy.test.ts | 32 +++ .../publication_outcome_policy.test.ts | 56 ++++ .../action_summary_message_catalog.ts | 12 + .../policies/action_summary_policy.ts | 25 +- .../policies/publication_outcome_policy.ts | 63 +++++ .../ports/issue_lifecycle_ports.ts | 19 +- .../actions/publish_issue_comment_use_case.ts | 4 +- .../actions/publish_issue_comment_workflow.ts | 4 +- ...duplicate_comment_cleanup_workflow.test.ts | 67 +++++ .../__tests__/publish_resume_use_case.test.ts | 26 ++ .../reply_publication_workflow.test.ts | 35 ++- .../status_card_publication_workflow.test.ts | 77 ++++- .../duplicate_comment_cleanup_workflow.ts | 23 ++ .../steps/common/publish_resume_workflow.ts | 40 ++- .../common/reply_publication_workflow.ts | 40 ++- .../status_card_publication_workflow.ts | 48 +++- .../github_publication_boundaries.test.ts | 3 +- .../github_publication_mutation_baseline.json | 5 +- .../__tests__/github_error_policy.test.ts | 28 +- .../deployment_presentation_repository.ts | 4 +- .../repository/github/github_error_policy.ts | 24 ++ .../issue_content_repository.test.ts | 41 +++ .../issue/issue_content_repository.ts | 32 +++ ...gle_action_capability_port_binding.test.ts | 13 +- ...h_single_action_capability_port_binding.ts | 1 + .../ports/github_issue_provider_ports.ts | 1 + 35 files changed, 1064 insertions(+), 96 deletions(-) create mode 100644 src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts create mode 100644 src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts diff --git a/build/cli/index.js b/build/cli/index.js index 9e80211c8..58a116da9 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -44458,7 +44458,10 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; +exports.buildDuplicateCompactionPublicationPayload = buildDuplicateCompactionPublicationPayload; +exports.duplicateCompactionPublicationOutcomes = duplicateCompactionPublicationOutcomes; const result_1 = __nccwpck_require__(73817); +const MAX_REPORTED_COMMENT_IDS = 20; /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { return Object.freeze({ @@ -44476,6 +44479,52 @@ function hasStaleSourcePublicationOutcome(results) { return outcome?.reason === 'stale-source'; }); } +/** Builds bounded operator evidence for duplicates retained as compact pointers. */ +function buildDuplicateCompactionPublicationPayload(commentIds) { + const validIds = [...new Set(commentIds.filter(isPositiveInteger))].sort((left, right) => left - right); + if (validIds.length === 0) + return undefined; + return Object.freeze({ + publicationCleanup: Object.freeze({ + reason: 'duplicate-deletion-forbidden', + compactedCommentIds: Object.freeze(validIds.slice(0, MAX_REPORTED_COMMENT_IDS)), + compactedCount: validIds.length, + }), + }); +} +function duplicateCompactionPublicationOutcomes(results) { + return Object.freeze(results.flatMap(result => { + const payload = (0, result_1.getResultPayload)(result.payload); + const cleanup = (0, result_1.getResultPayload)(payload?.publicationCleanup); + if (cleanup?.reason !== 'duplicate-deletion-forbidden' + || !isPositiveInteger(cleanup.compactedCount) + || !Array.isArray(cleanup.compactedCommentIds) + || cleanup.compactedCommentIds.length > MAX_REPORTED_COMMENT_IDS + || cleanup.compactedCommentIds.length > cleanup.compactedCount + || !areOrderedUniquePositiveIntegers(cleanup.compactedCommentIds)) { + return []; + } + return [Object.freeze({ + reason: 'duplicate-deletion-forbidden', + compactedCommentIds: Object.freeze([...cleanup.compactedCommentIds]), + compactedCount: cleanup.compactedCount, + })]; + })); +} +function isPositiveInteger(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} +function areOrderedUniquePositiveIntegers(values) { + if (values.length === 0) + return false; + let previous; + for (const value of values) { + if (!isPositiveInteger(value) || previous !== undefined && value <= previous) + return false; + previous = value; + } + return true; +} /***/ }), @@ -68296,7 +68345,7 @@ exports.GitCliRepository = GitCliRepository; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isGithubAlreadyExists = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; +exports.isGithubAlreadyExists = exports.isGithubPermissionDenied = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; const getGithubErrorStatus = (error) => { if (typeof error !== "object" || error === null) return undefined; @@ -68306,6 +68355,27 @@ const getGithubErrorStatus = (error) => { exports.getGithubErrorStatus = getGithubErrorStatus; const isGithubNotFound = (error) => (0, exports.getGithubErrorStatus)(error) === 404; exports.isGithubNotFound = isGithubNotFound; +const isGithubPermissionDenied = (error) => { + if ((0, exports.getGithubErrorStatus)(error) !== 403) + return false; + const errorRecord = readRecord(error); + const headers = readRecord(readRecord(errorRecord?.response)?.headers); + if (readHeader(headers, 'retry-after') !== undefined) + return false; + if (readHeader(headers, 'x-ratelimit-remaining') === '0') + return false; + const message = errorRecord?.message; + if (typeof message !== 'string') + return false; + const normalized = message.trim().toLowerCase(); + return normalized === 'forbidden' + || normalized.includes('resource not accessible by integration') + || normalized.includes('permission') + || normalized.includes('not permitted') + || normalized.includes('not allowed') + || normalized.includes('must have admin rights'); +}; +exports.isGithubPermissionDenied = isGithubPermissionDenied; const isGithubAlreadyExists = (error) => { if ((0, exports.getGithubErrorStatus)(error) !== 422) return false; @@ -68330,6 +68400,13 @@ function readRecord(value) { ? value : undefined; } +function readHeader(headers, expected) { + if (!headers) + return undefined; + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === expected); + const value = key ? headers[key] : undefined; + return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; +} /***/ }), @@ -68649,6 +68726,7 @@ const comment_content_policy_1 = __nccwpck_require__(77454); const logger_1 = __nccwpck_require__(91151); const github_pagination_policy_1 = __nccwpck_require__(44812); const application_error_1 = __nccwpck_require__(75999); +const github_error_policy_1 = __nccwpck_require__(58791); class IssueContentRepository { constructor(githubClient) { this.githubClient = githubClient; @@ -68724,6 +68802,30 @@ class IssueContentRepository { }); (0, logger_1.logDebugInfo)(`Comment ${commentId} updated in Issue ${issueNumber}.`); }; + this.removeComment = async (owner, repository, issueNumber, commentId, token) => { + const octokit = this.githubClient.getClient(token); + try { + await octokit.rest.issues.deleteComment({ + owner, + repo: repository, + comment_id: commentId, + }); + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} removed from Issue ${issueNumber}.`); + return 'removed'; + } + catch (error) { + const status = (0, github_error_policy_1.getGithubErrorStatus)(error); + if (status === 404) { + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} was already absent from Issue ${issueNumber}.`); + return 'removed'; + } + if ((0, github_error_policy_1.isGithubPermissionDenied)(error)) { + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} cannot be removed from Issue ${issueNumber}; compacting it instead.`); + return 'compaction-required'; + } + throw error; + } + }; this.listIssueComments = async (owner, repository, issueNumber, token) => { const octokit = this.githubClient.getClient(token); const all = []; @@ -76437,6 +76539,7 @@ function bindIssueCommentPublication(port, binding) { return Object.freeze({ addComment: (issueNumber, comment) => port.addComment(binding.owner, binding.repository, issueNumber, comment, binding.token), updateComment: (issueNumber, commentId, comment) => port.updateComment(binding.owner, binding.repository, issueNumber, commentId, comment, binding.token), + removeComment: (issueNumber, commentId) => port.removeComment(binding.owner, binding.repository, issueNumber, commentId, binding.token), listIssueComments: (issueNumber) => port.listIssueComments(binding.owner, binding.repository, issueNumber, binding.token), }); } diff --git a/build/github_action/index.js b/build/github_action/index.js index bd25ba7dc..8194e0616 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41141,6 +41141,7 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', 'sourceFreshness', 'staleSourceSuppressed', + 'duplicateCleanup', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -41151,6 +41152,9 @@ const TEMPLATE_MESSAGE_IDS = Object.freeze([ 'summary.target.issue', 'summary.target.repositoryRun', 'summary.bugbotTelemetry', + 'summary.duplicateCleanup.single', + 'summary.duplicateCleanup.multiple', + 'summary.duplicateCleanup.bounded', ]); const FINDING_STATE_KEYS = Object.freeze([ 'open', 'reopened', 'fixed', 'obsolete', 'dismissed', @@ -41177,6 +41181,7 @@ const ENGLISH_SIMPLE = Object.freeze({ bugbotReview: 'Bugbot review', sourceFreshness: 'Source freshness', staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', + duplicateCleanup: 'Duplicate cleanup', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -41213,6 +41218,7 @@ const SPANISH_SIMPLE = Object.freeze({ bugbotReview: 'Revisión de Bugbot', sourceFreshness: 'Vigencia del origen', staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', + duplicateCleanup: 'Limpieza de duplicados', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', @@ -41239,12 +41245,18 @@ const ENGLISH_TEMPLATES = Object.freeze({ 'summary.target.issue': 'Issue #{number}', 'summary.target.repositoryRun': 'Repository run', 'summary.bugbotTelemetry': '{outcome}, effort={effort}, {elapsed}ms', + 'summary.duplicateCleanup.single': 'Deletion was forbidden; retained a compact pointer for comment {ids}', + 'summary.duplicateCleanup.multiple': 'Deletion was forbidden; retained compact pointers for {count} comments (IDs: {ids})', + 'summary.duplicateCleanup.bounded': 'Deletion was forbidden; retained compact pointers for {count} comments (first {reported} IDs: {ids})', }); const SPANISH_TEMPLATES = Object.freeze({ 'summary.target.pullRequest': 'PR n.º {number}', 'summary.target.issue': 'Issue n.º {number}', 'summary.target.repositoryRun': 'Ejecución del repositorio', 'summary.bugbotTelemetry': '{outcome}, esfuerzo={effort}, {elapsed} ms', + 'summary.duplicateCleanup.single': 'Se denegó el borrado; se conservó un enlace compacto para el comentario {ids}', + 'summary.duplicateCleanup.multiple': 'Se denegó el borrado; se conservaron enlaces compactos para {count} comentarios (ID: {ids})', + 'summary.duplicateCleanup.bounded': 'Se denegó el borrado; se conservaron enlaces compactos para {count} comentarios (primeros {reported} ID: {ids})', }); const ENGLISH_FINDING_STATES = Object.freeze({ open: 'open', @@ -41333,6 +41345,7 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo 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 duplicateCompactions = (0, publication_outcome_policy_1.duplicateCompactionPublicationOutcomes)(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({ @@ -41357,6 +41370,9 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo ...(staleSourceSuppressed ? [ `| ${catalogText(catalog, 'summary.sourceFreshness')} | ${catalogText(catalog, 'summary.staleSourceSuppressed')} |`, ] : []), + ...(duplicateCompactions.length > 0 ? [ + `| ${catalogText(catalog, 'summary.duplicateCleanup')} | ${formatDuplicateCompactions(duplicateCompactions, catalog)} |`, + ] : []), ]; const localization = renderLocalizationSummarySection(context.locale, context.catalogResolutions, actionSummaryLocalizationLabels(catalog)); return [ @@ -41376,6 +41392,15 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo ...(localization ? ['', localization] : []), ].join('\n'); } +function formatDuplicateCompactions(outcomes, catalog) { + const count = outcomes.reduce((total, outcome) => total + outcome.compactedCount, 0); + const ids = [...new Set(outcomes.flatMap(outcome => outcome.compactedCommentIds))] + .sort((left, right) => left - right); + const variables = { count, reported: ids.length, ids: ids.join(', ') }; + if (count === 1) + return catalogText(catalog, 'summary.duplicateCleanup.single', variables); + return catalogText(catalog, count > ids.length ? 'summary.duplicateCleanup.bounded' : 'summary.duplicateCleanup.multiple', variables); +} function actionSummaryLocalizationLabels(catalog) { return Object.freeze({ heading: catalog.message('summary.localization'), @@ -47005,7 +47030,10 @@ exports.SPANISH_PUBLICATION_CATALOG = toPublicationCatalog(Object.freeze({ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; +exports.buildDuplicateCompactionPublicationPayload = buildDuplicateCompactionPublicationPayload; +exports.duplicateCompactionPublicationOutcomes = duplicateCompactionPublicationOutcomes; const result_1 = __nccwpck_require__(73817); +const MAX_REPORTED_COMMENT_IDS = 20; /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { return Object.freeze({ @@ -47023,6 +47051,52 @@ function hasStaleSourcePublicationOutcome(results) { return outcome?.reason === 'stale-source'; }); } +/** Builds bounded operator evidence for duplicates retained as compact pointers. */ +function buildDuplicateCompactionPublicationPayload(commentIds) { + const validIds = [...new Set(commentIds.filter(isPositiveInteger))].sort((left, right) => left - right); + if (validIds.length === 0) + return undefined; + return Object.freeze({ + publicationCleanup: Object.freeze({ + reason: 'duplicate-deletion-forbidden', + compactedCommentIds: Object.freeze(validIds.slice(0, MAX_REPORTED_COMMENT_IDS)), + compactedCount: validIds.length, + }), + }); +} +function duplicateCompactionPublicationOutcomes(results) { + return Object.freeze(results.flatMap(result => { + const payload = (0, result_1.getResultPayload)(result.payload); + const cleanup = (0, result_1.getResultPayload)(payload?.publicationCleanup); + if (cleanup?.reason !== 'duplicate-deletion-forbidden' + || !isPositiveInteger(cleanup.compactedCount) + || !Array.isArray(cleanup.compactedCommentIds) + || cleanup.compactedCommentIds.length > MAX_REPORTED_COMMENT_IDS + || cleanup.compactedCommentIds.length > cleanup.compactedCount + || !areOrderedUniquePositiveIntegers(cleanup.compactedCommentIds)) { + return []; + } + return [Object.freeze({ + reason: 'duplicate-deletion-forbidden', + compactedCommentIds: Object.freeze([...cleanup.compactedCommentIds]), + compactedCount: cleanup.compactedCount, + })]; + })); +} +function isPositiveInteger(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} +function areOrderedUniquePositiveIntegers(values) { + if (values.length === 0) + return false; + let previous; + for (const value of values) { + if (!isPositiveInteger(value) || previous !== undefined && value <= previous) + return false; + previous = value; + } + return true; +} /***/ }), @@ -59250,6 +59324,29 @@ function toCamelCase(input) { } +/***/ }), + +/***/ 79544: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.cleanupDuplicateComment = cleanupDuplicateComment; +/** Removes an exact owned duplicate, retaining a compact pointer only when deletion is forbidden. */ +async function cleanupDuplicateComment(context, comments, sourceIsCurrent) { + if (sourceIsCurrent && !await sourceIsCurrent()) + return 'stale'; + const removal = await comments.removeComment(context.issueNumber, context.duplicateCommentId); + if (removal === 'removed') + return 'removed'; + if (sourceIsCurrent && !await sourceIsCurrent()) + return 'stale'; + await comments.updateComment(context.issueNumber, context.duplicateCommentId, context.compactBody); + return 'compacted'; +} + + /***/ }), /***/ 65440: @@ -59710,6 +59807,7 @@ async function runPublishResume(param, taskId, comments, catalogResolver, source } const catalog = await (0, publication_message_catalog_1.resolvePublicationCatalog)(param.locale, param.languageConfiguration, catalogResolver); let staleSourceEvidence; + const compactedCommentIds = []; for (const intent of replies) { const outcome = await (0, reply_publication_workflow_1.reconcileReply)({ owner: param.owner, @@ -59718,7 +59816,9 @@ async function runPublishResume(param, taskId, comments, catalogResolver, source intent, catalog, }, comments); - (0, logging_ports_1.logInfo)(`Semantic ${intent.messageKey} reply ${outcome.effect}; duplicates compacted=${outcome.duplicatesCompacted}.`); + (0, logging_ports_1.logInfo)(`Semantic ${intent.messageKey} reply ${outcome.effect}; ` + + `duplicates removed=${outcome.duplicatesRemoved}; compacted=${outcome.duplicatesCompacted}.`); + compactedCommentIds.push(...outcome.compactedCommentIds); } for (const intent of statuses) { const outcome = await (0, status_card_publication_workflow_1.reconcileStatusCard)({ @@ -59729,7 +59829,8 @@ async function runPublishResume(param, taskId, comments, catalogResolver, source catalog, }, comments, sourceQuery); (0, logging_ports_1.logInfo)(`Semantic ${intent.identity.topic} publication ${outcome.effect}; ` - + `reason=${outcome.reason ?? 'current'}; duplicates compacted=${outcome.duplicatesCompacted}.`); + + `reason=${outcome.reason ?? 'current'}; duplicates removed=${outcome.duplicatesRemoved}; ` + + `compacted=${outcome.duplicatesCompacted}.`); if (outcome.reason === 'stale-source') { const sourceGuard = intent.sourceGuard; if (!sourceGuard) { @@ -59737,15 +59838,22 @@ async function runPublishResume(param, taskId, comments, catalogResolver, source } staleSourceEvidence ?? (staleSourceEvidence = Object.freeze({ branch: sourceGuard.branch, sha: sourceGuard.sha })); } + compactedCommentIds.push(...outcome.compactedCommentIds); } - return staleSourceEvidence - ? new result_1.Result({ - id: taskId, - success: true, - executed: false, - payload: (0, publication_outcome_policy_1.buildStaleSourcePublicationPayload)(staleSourceEvidence.branch, staleSourceEvidence.sha), - }) - : undefined; + const cleanupEvidence = (0, publication_outcome_policy_1.buildDuplicateCompactionPublicationPayload)(compactedCommentIds); + if (!staleSourceEvidence && !cleanupEvidence) + return undefined; + return new result_1.Result({ + id: taskId, + success: true, + executed: cleanupEvidence !== undefined, + payload: Object.freeze({ + ...(staleSourceEvidence + ? (0, publication_outcome_policy_1.buildStaleSourcePublicationPayload)(staleSourceEvidence.branch, staleSourceEvidence.sha) + : {}), + ...(cleanupEvidence ?? {}), + }), + }); } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to publish semantic GitHub status.'); @@ -59815,10 +59923,11 @@ 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); +const duplicate_comment_cleanup_workflow_1 = __nccwpck_require__(79544); /** Publishes an explicit reply at most once for a trusted request correlation. */ async function reconcileReply(context, comments) { if (!context.botLogin.trim()) - return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + return unchangedOutcome(); const target = context.intent.target; let owned = matchingReplies(await comments.listIssueComments(target.number), context); let effect = 'unchanged'; @@ -59827,13 +59936,41 @@ async function reconcileReply(context, comments) { effect = 'created'; owned = matchingReplies(await comments.listIssueComments(target.number), context); } - if (owned.length === 0) - return Object.freeze({ effect, duplicatesCompacted: 0 }); + if (owned.length === 0) { + return Object.freeze({ + effect, duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: Object.freeze([]), + }); + } const [canonical, ...duplicates] = owned.sort((left, right) => left.id - right.id); + let duplicatesRemoved = 0; + let duplicatesCompacted = 0; + const compactedCommentIds = []; for (const duplicate of duplicates) { - await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); + const cleanup = await (0, duplicate_comment_cleanup_workflow_1.cleanupDuplicateComment)({ + issueNumber: target.number, + duplicateCommentId: duplicate.id, + compactBody: duplicatePointer(context, canonical.id), + }, comments); + if (cleanup === 'removed') + duplicatesRemoved += 1; + else { + duplicatesCompacted += 1; + compactedCommentIds.push(duplicate.id); + } } - return Object.freeze({ effect, canonicalCommentId: canonical.id, duplicatesCompacted: duplicates.length }); + return Object.freeze({ + effect, + canonicalCommentId: canonical.id, + duplicatesRemoved, + duplicatesCompacted, + compactedCommentIds: Object.freeze(compactedCommentIds), + }); +} +function unchangedOutcome() { + return Object.freeze({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: Object.freeze([]), + }); } function matchingReplies(comments, context) { const expectedTarget = (0, github_publication_1.publicationTargetToken)(context.intent.target); @@ -59875,9 +60012,10 @@ 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); const application_error_1 = __nccwpck_require__(75999); +const duplicate_comment_cleanup_workflow_1 = __nccwpck_require__(79544); async function reconcileStatusCard(context, comments, sourceQuery) { if (!context.botLogin.trim()) - return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + return unchangedOutcome(); if (!await sourceIsCurrent(context.intent, sourceQuery)) return staleSourceOutcome(); const target = context.intent.identity.target; @@ -59891,8 +60029,11 @@ async function reconcileStatusCard(context, comments, sourceQuery) { effect = 'created'; owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); } - if (owned.length === 0) - return Object.freeze({ effect, duplicatesCompacted: 0 }); + if (owned.length === 0) { + return Object.freeze({ + effect, duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: Object.freeze([]), + }); + } 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) { @@ -59902,18 +60043,31 @@ async function reconcileStatusCard(context, comments, sourceQuery) { await comments.updateComment(target.number, canonical.id, rendered); effect = effect === 'created' ? 'created' : 'updated'; } + let duplicatesRemoved = 0; let duplicatesCompacted = 0; + const compactedCommentIds = []; for (const duplicate of duplicates) { - if (!await sourceIsCurrent(context.intent, sourceQuery)) { - return staleSourceOutcome(canonical.id, effect, duplicatesCompacted); + const cleanup = await (0, duplicate_comment_cleanup_workflow_1.cleanupDuplicateComment)({ + issueNumber: target.number, + duplicateCommentId: duplicate.id, + compactBody: duplicatePointer(context, canonical.id), + }, comments, () => sourceIsCurrent(context.intent, sourceQuery)); + if (cleanup === 'stale') { + return staleSourceOutcome(canonical.id, effect, duplicatesRemoved, duplicatesCompacted, compactedCommentIds); + } + if (cleanup === 'removed') + duplicatesRemoved += 1; + else { + duplicatesCompacted += 1; + compactedCommentIds.push(duplicate.id); } - await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); - duplicatesCompacted += 1; } return Object.freeze({ effect, canonicalCommentId: canonical.id, + duplicatesRemoved, duplicatesCompacted, + compactedCommentIds: Object.freeze(compactedCommentIds), }); } async function sourceIsCurrent(intent, sourceQuery) { @@ -59925,14 +60079,22 @@ async function sourceIsCurrent(intent, sourceQuery) { } return await sourceQuery.getBranchHeadSha(guard.branch) === guard.sha; } -function staleSourceOutcome(canonicalCommentId, effect = 'unchanged', duplicatesCompacted = 0) { +function staleSourceOutcome(canonicalCommentId, effect = 'unchanged', duplicatesRemoved = 0, duplicatesCompacted = 0, compactedCommentIds = []) { return Object.freeze({ effect, ...(canonicalCommentId === undefined ? {} : { canonicalCommentId }), + duplicatesRemoved, duplicatesCompacted, + compactedCommentIds: Object.freeze([...compactedCommentIds]), reason: 'stale-source', }); } +function unchangedOutcome() { + return Object.freeze({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: Object.freeze([]), + }); +} function ownedCards(comments, identity, botLogin) { return comments.filter(comment => { const marker = (0, publication_identity_policy_1.parsePublicationMarker)(comment.body); @@ -67639,7 +67801,7 @@ exports.GitCliRepository = GitCliRepository; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isGithubAlreadyExists = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; +exports.isGithubAlreadyExists = exports.isGithubPermissionDenied = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; const getGithubErrorStatus = (error) => { if (typeof error !== "object" || error === null) return undefined; @@ -67649,6 +67811,27 @@ const getGithubErrorStatus = (error) => { exports.getGithubErrorStatus = getGithubErrorStatus; const isGithubNotFound = (error) => (0, exports.getGithubErrorStatus)(error) === 404; exports.isGithubNotFound = isGithubNotFound; +const isGithubPermissionDenied = (error) => { + if ((0, exports.getGithubErrorStatus)(error) !== 403) + return false; + const errorRecord = readRecord(error); + const headers = readRecord(readRecord(errorRecord?.response)?.headers); + if (readHeader(headers, 'retry-after') !== undefined) + return false; + if (readHeader(headers, 'x-ratelimit-remaining') === '0') + return false; + const message = errorRecord?.message; + if (typeof message !== 'string') + return false; + const normalized = message.trim().toLowerCase(); + return normalized === 'forbidden' + || normalized.includes('resource not accessible by integration') + || normalized.includes('permission') + || normalized.includes('not permitted') + || normalized.includes('not allowed') + || normalized.includes('must have admin rights'); +}; +exports.isGithubPermissionDenied = isGithubPermissionDenied; const isGithubAlreadyExists = (error) => { if ((0, exports.getGithubErrorStatus)(error) !== 422) return false; @@ -67673,6 +67856,13 @@ function readRecord(value) { ? value : undefined; } +function readHeader(headers, expected) { + if (!headers) + return undefined; + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === expected); + const value = key ? headers[key] : undefined; + return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; +} /***/ }), @@ -67992,6 +68182,7 @@ const comment_content_policy_1 = __nccwpck_require__(77454); const logger_1 = __nccwpck_require__(91151); const github_pagination_policy_1 = __nccwpck_require__(44812); const application_error_1 = __nccwpck_require__(75999); +const github_error_policy_1 = __nccwpck_require__(58791); class IssueContentRepository { constructor(githubClient) { this.githubClient = githubClient; @@ -68067,6 +68258,30 @@ class IssueContentRepository { }); (0, logger_1.logDebugInfo)(`Comment ${commentId} updated in Issue ${issueNumber}.`); }; + this.removeComment = async (owner, repository, issueNumber, commentId, token) => { + const octokit = this.githubClient.getClient(token); + try { + await octokit.rest.issues.deleteComment({ + owner, + repo: repository, + comment_id: commentId, + }); + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} removed from Issue ${issueNumber}.`); + return 'removed'; + } + catch (error) { + const status = (0, github_error_policy_1.getGithubErrorStatus)(error); + if (status === 404) { + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} was already absent from Issue ${issueNumber}.`); + return 'removed'; + } + if ((0, github_error_policy_1.isGithubPermissionDenied)(error)) { + (0, logger_1.logDebugInfo)(`Duplicate comment ${commentId} cannot be removed from Issue ${issueNumber}; compacting it instead.`); + return 'compaction-required'; + } + throw error; + } + }; this.listIssueComments = async (owner, repository, issueNumber, token) => { const octokit = this.githubClient.getClient(token); const all = []; @@ -75540,6 +75755,7 @@ function bindIssueCommentPublication(port, binding) { return Object.freeze({ addComment: (issueNumber, comment) => port.addComment(binding.owner, binding.repository, issueNumber, comment, binding.token), updateComment: (issueNumber, commentId, comment) => port.updateComment(binding.owner, binding.repository, issueNumber, commentId, comment, binding.token), + removeComment: (issueNumber, commentId) => port.removeComment(binding.owner, binding.repository, issueNumber, commentId, binding.token), listIssueComments: (issueNumber) => port.listIssueComments(binding.owner, binding.repository, issueNumber, binding.token), }); } diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index 27c7c9b58..f73eac31f 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -14,6 +14,12 @@ and reply workflows validate exact bot ownership, stable correlation or topic identity, and semantic digests before creating or updating a comment. The checked-in publication mutation inventory fails CI if a new application use case writes a comment or review without an explicit architectural decision. +The same bounded publication port exposes duplicate removal only to semantic +reconcilers. They select later comments only after paginated discovery verifies +the exact identity and bot owner. The GitHub repository treats an already absent +comment as idempotently removed, maps a deletion-forbidden response to a +localized compact-pointer fallback plus bounded Job Summary evidence, and +propagates every other provider failure. Initial issue help and addressed Think requests follow that rule: each agent step returns an immutable `direct-answer` projection and receives no GitHub mutation port. The shared reply reconciler sanitizes and publishes the answer diff --git a/docs/features.mdx b/docs/features.mdx index 6b929ad28..18b7aa379 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -53,7 +53,7 @@ or `closed`): | **Reviewers** | Assigns up to `desired-reviewers-count` reviewers. | | **Priority & size** | Applies priority and size checks (labels and thresholds). | | **AI PR description** | When `ai-pull-request-description-mode` is `replace`, `append`, or `preserve`, the selected agent CLI writes a concise outcome and material changes from the optional issue context and branch diff. Validation appears only when supported by evidence; `disabled` turns the feature off. See [Pull Requests → AI-generated PR description](/pull-requests/ai-description). | -| **Conversation UX** | Publishes only purpose-specific replies and durable status cards. It does not add a generic action recap or decorative image. | +| **Conversation UX** | Publishes only purpose-specific replies and durable status cards. Equivalent retries are silent; exact bot-owned duplicates caused by concurrent runs are removed, with a short localized pointer retained only when GitHub forbids deletion. It does not add a generic action recap or decorative image. | | **Bugbot review** | Reviews the full PR on open/reopen and the new commit range on synchronize; publishes historical review snapshots and stable line findings, then reconciles native threads, review status blocks, one current PR card, and the Check from a provider-verified projection. | ### 3. Push events (`on: push`) diff --git a/docs/issues/notifications-and-auto-close.mdx b/docs/issues/notifications-and-auto-close.mdx index fcded256c..94f304209 100644 --- a/docs/issues/notifications-and-auto-close.mdx +++ b/docs/issues/notifications-and-auto-close.mdx @@ -36,7 +36,13 @@ locales plus exact, base-language, dynamic, or English-fallback catalog evidence `feature/123-title` → issue `123`). - **Identity:** one hidden, bot-owned semantic marker identifies the progress card and records the analyzed branch-head object ID. Equivalent retries - create or update nothing. + create or update nothing. Comment discovery follows every GitHub result page. + If concurrent runs create the same semantic card, Copilot keeps the oldest + exact bot-owned comment and removes later duplicates. A localized link to the + canonical card replaces a duplicate only when GitHub specifically forbids + deletion; the Job Summary records the affected comment IDs with a bounded + list. Human, third-party, malformed, and legacy comments are never removed by + this cleanup. - **Content:** outcome, current status, and the next useful action only. Agent reasoning and internal step names are excluded. diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index c33c81119..31d45c655 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -240,6 +240,31 @@ ], "successMessage": "execution-boundary closure coverage: PASS (changed path 95% lines/statements, 90% branches/functions)" }, + { + "name": "Semantic duplicate reconciliation", + "missingEntryLabel": "semantic duplicate reconciliation", + "rules": [ + { + "files": [ + "src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts" + ], + "mode": "each", + "thresholdProfile": "exhaustive" + }, + { + "files": [ + "src/application/usecases/steps/common/reply_publication_workflow.ts", + "src/application/usecases/steps/common/status_card_publication_workflow.ts", + "src/data/repository/github/github_error_policy.ts", + "src/data/repository/issue/issue_content_repository.ts", + "src/infrastructure/composition/push_single_action_capability_port_binding.ts" + ], + "mode": "aggregate", + "thresholdProfile": "default" + } + ], + "successMessage": "semantic duplicate reconciliation coverage: PASS (cleanup workflow 100%; guarded publication path 95% lines/statements, 90% branches/functions)" + }, { "name": "Semantic publication freshness", "missingEntryLabel": "semantic publication freshness", diff --git a/specs/CATALOG.md b/specs/CATALOG.md index fa2122345..8b3ed208a 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 | 180 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 | 186 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 | @@ -34,8 +34,8 @@ debt or convert unknown historic intent into a design decision. - 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/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) +- 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/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts`](../src/application/usecases/steps/common/duplicate_comment_cleanup_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) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) · [`src/infrastructure/github/ports/github_issue_provider_ports.ts`](../src/infrastructure/github/ports/github_issue_provider_ports.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/common/__tests__/duplicate_comment_cleanup_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/data/repository/__tests__/github_error_policy.test.ts`](../src/data/repository/__tests__/github_error_policy.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 7b28a2ca3..0a3581bbc 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -73,6 +73,7 @@ "src/application/ports/issue_lifecycle_ports.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/duplicate_comment_cleanup_workflow.ts", "src/actions/local_action.ts", "src/actions/local_action_output.ts", "src/cli/commands/think.ts", @@ -104,7 +105,9 @@ "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" + "src/data/repository/issue/issue_content_repository.ts", + "src/data/repository/github/github_error_policy.ts", + "src/infrastructure/github/ports/github_issue_provider_ports.ts" ], "tests": [ "src/domain/__tests__/locale.test.ts", @@ -150,6 +153,9 @@ "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__/reply_publication_workflow.test.ts", + "src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts", + "src/data/repository/issue/__tests__/issue_content_repository.test.ts", + "src/data/repository/__tests__/github_error_policy.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_pull_request_context_projection.test.ts", diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index 9751513a8..a42200105 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -4,7 +4,7 @@ - Date: 2026-09-14 - Catalog capability ID: github-communication-experience - Last verified: 2026-09-15 for the delivered shared publication, progress - source-freshness, branch-sync, + source-freshness, exact-duplicate cleanup, branch-sync, review-context, Bugbot, deployment, setup-doctor, generic Job Summary, application-error, explicit-request, and local-result slices; remaining clauses are prospective @@ -1020,6 +1020,17 @@ 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. +Shared status and correlated-reply reconciliation now completes the exact- +duplicate cleanup contract. Paginated discovery first proves an exact semantic +identity and matching bot login, keeps the lowest comment ID as canonical, and +requests removal only for later matches. GitHub 404 is an idempotent success; an +explicit permission-denied 403 retains a localized compact pointer to the +canonical comment, while rate-limit 403 responses and all other failures remain +visible as provider failures. The localized Job Summary reports the total and +up to 20 affected comment IDs. Commit-derived cards revalidate source freshness +before both deletion and any fallback update. Explicit caller-selected comment +and deployment ports remain narrower and receive no deletion authority. + 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. diff --git a/src/application/policies/__tests__/action_summary_policy.test.ts b/src/application/policies/__tests__/action_summary_policy.test.ts index 7713ea5a5..d3c5fa09b 100644 --- a/src/application/policies/__tests__/action_summary_policy.test.ts +++ b/src/application/policies/__tests__/action_summary_policy.test.ts @@ -137,6 +137,11 @@ describe('action summary policy', () => { success: false, executed: true, errors: [new ApplicationError('workflow.failed', 'Workflow failed.')], + }), new Result({ + id: 'cleanup', success: true, executed: true, + payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: [12], + } }, })], }, resolveStaticActionSummaryCatalog('es-ES')); @@ -150,6 +155,7 @@ describe('action summary policy', () => { expect(summary).toContain('El workflow no pudo completar la operación solicitada.'); expect(summary).toContain('**Código de error:** `workflow.failed`'); expect(summary).toContain('**Reintentable:** Sí'); + expect(summary).toContain('| Limpieza de duplicados | Se denegó el borrado; se conservó un enlace compacto para el comentario 12 |'); expect(summary).not.toContain('Workflow failed.'); expect(summary).not.toContain('The workflow could not complete'); expect(summary).toContain('## Localización'); @@ -217,6 +223,32 @@ describe('action summary policy', () => { expect(summary).not.toContain('a'.repeat(40)); }); + it('reports bounded comment IDs when forbidden deletion retains compact pointers', () => { + const ids = Array.from({ length: 20 }, (_, index) => index + 10); + const summary = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'issues', issueNumber: 7, pullRequestNumber: -1, + results: [new Result({ + id: 'PublishResultUseCase', success: true, executed: true, + payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 25, compactedCommentIds: ids, + } }, + })], + }); + + expect(summary).toContain('| Duplicate cleanup | Deletion was forbidden; retained compact pointers for 25 comments (first 20 IDs: 10, 11, 12'); + + const complete = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'issues', issueNumber: 7, pullRequestNumber: -1, + results: [new Result({ + id: 'PublishResultUseCase', success: true, executed: true, + payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 2, compactedCommentIds: [12, 15], + } }, + })], + }); + expect(complete).toContain('retained compact pointers for 2 comments (IDs: 12, 15)'); + }); + 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 index 86ff5e090..841234f86 100644 --- a/src/application/policies/__tests__/publication_outcome_policy.test.ts +++ b/src/application/policies/__tests__/publication_outcome_policy.test.ts @@ -1,6 +1,8 @@ import { Result } from '../../../data/model/result'; import { + buildDuplicateCompactionPublicationPayload, buildStaleSourcePublicationPayload, + duplicateCompactionPublicationOutcomes, hasStaleSourcePublicationOutcome, } from '../publication_outcome_policy'; @@ -27,4 +29,58 @@ describe('publication outcome policy', () => { new Result({ id: 'current', success: true, executed: true, payload: { publicationOutcome: { reason: 'current' } } }), ])).toBe(false); }); + + it('builds sorted, unique, bounded duplicate-compaction evidence', () => { + const payload = buildDuplicateCompactionPublicationPayload([ + 9, 3, 9, -1, 0, ...Array.from({ length: 25 }, (_, index) => index + 10), + ]); + + expect(payload?.publicationCleanup).toMatchObject({ + reason: 'duplicate-deletion-forbidden', + compactedCount: 27, + compactedCommentIds: [3, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], + }); + expect(Object.isFrozen(payload)).toBe(true); + expect(Object.isFrozen(payload?.publicationCleanup)).toBe(true); + expect(Object.isFrozen(payload?.publicationCleanup.compactedCommentIds)).toBe(true); + expect(buildDuplicateCompactionPublicationPayload([0, -1, Number.NaN])).toBeUndefined(); + }); + + it('projects only valid duplicate-compaction evidence', () => { + const valid = buildDuplicateCompactionPublicationPayload([12, 15]); + const projected = duplicateCompactionPublicationOutcomes([ + new Result({ id: 'cleanup', success: true, executed: true, payload: valid }), + new Result({ id: 'string', success: true, executed: true, payload: { publicationCleanup: 'invalid' } }), + new Result({ id: 'reason', success: true, executed: true, payload: { publicationCleanup: { reason: 'other' } } }), + new Result({ id: 'count', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 0, compactedCommentIds: [12], + } } }), + new Result({ id: 'not-array', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: '12', + } } }), + new Result({ id: 'over-count', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: [12, 15], + } } }), + new Result({ id: 'unbounded', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 21, + compactedCommentIds: Array.from({ length: 21 }, (_, index) => index + 1), + } } }), + new Result({ id: 'ids', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: ['12'], + } } }), + new Result({ id: 'empty-ids', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: [], + } } }), + new Result({ id: 'duplicate-ids', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 2, compactedCommentIds: [12, 12], + } } }), + new Result({ id: 'unordered-ids', success: true, executed: true, payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 2, compactedCommentIds: [15, 12], + } } }), + ]); + + expect(projected).toEqual([valid?.publicationCleanup]); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected[0])).toBe(true); + }); }); diff --git a/src/application/policies/action_summary_message_catalog.ts b/src/application/policies/action_summary_message_catalog.ts index c9241bdc4..2032cf6e5 100644 --- a/src/application/policies/action_summary_message_catalog.ts +++ b/src/application/policies/action_summary_message_catalog.ts @@ -21,6 +21,7 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', 'sourceFreshness', 'staleSourceSuppressed', + 'duplicateCleanup', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -32,6 +33,9 @@ const TEMPLATE_MESSAGE_IDS = Object.freeze([ 'summary.target.issue', 'summary.target.repositoryRun', 'summary.bugbotTelemetry', + 'summary.duplicateCleanup.single', + 'summary.duplicateCleanup.multiple', + 'summary.duplicateCleanup.bounded', ] as const); const FINDING_STATE_KEYS = Object.freeze([ @@ -74,6 +78,7 @@ const ENGLISH_SIMPLE: Readonly> = Object.freeze bugbotReview: 'Bugbot review', sourceFreshness: 'Source freshness', staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', + duplicateCleanup: 'Duplicate cleanup', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -111,6 +116,7 @@ const SPANISH_SIMPLE: Readonly> = Object.freeze bugbotReview: 'Revisión de Bugbot', sourceFreshness: 'Vigencia del origen', staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', + duplicateCleanup: 'Limpieza de duplicados', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', @@ -138,6 +144,9 @@ const ENGLISH_TEMPLATES: Readonly> = O 'summary.target.issue': 'Issue #{number}', 'summary.target.repositoryRun': 'Repository run', 'summary.bugbotTelemetry': '{outcome}, effort={effort}, {elapsed}ms', + 'summary.duplicateCleanup.single': 'Deletion was forbidden; retained a compact pointer for comment {ids}', + 'summary.duplicateCleanup.multiple': 'Deletion was forbidden; retained compact pointers for {count} comments (IDs: {ids})', + 'summary.duplicateCleanup.bounded': 'Deletion was forbidden; retained compact pointers for {count} comments (first {reported} IDs: {ids})', }); const SPANISH_TEMPLATES: Readonly> = Object.freeze({ @@ -145,6 +154,9 @@ const SPANISH_TEMPLATES: Readonly> = O 'summary.target.issue': 'Issue n.º {number}', 'summary.target.repositoryRun': 'Ejecución del repositorio', 'summary.bugbotTelemetry': '{outcome}, esfuerzo={effort}, {elapsed} ms', + 'summary.duplicateCleanup.single': 'Se denegó el borrado; se conservó un enlace compacto para el comentario {ids}', + 'summary.duplicateCleanup.multiple': 'Se denegó el borrado; se conservaron enlaces compactos para {count} comentarios (ID: {ids})', + 'summary.duplicateCleanup.bounded': 'Se denegó el borrado; se conservaron enlaces compactos para {count} comentarios (primeros {reported} ID: {ids})', }); const ENGLISH_FINDING_STATES: Readonly> = Object.freeze({ diff --git a/src/application/policies/action_summary_policy.ts b/src/application/policies/action_summary_policy.ts index b4d87ca21..1fcbe6f25 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -18,7 +18,10 @@ 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'; +import { + duplicateCompactionPublicationOutcomes, + hasStaleSourcePublicationOutcome, +} from './publication_outcome_policy'; export interface ActionSummaryContext { readonly owner: string; @@ -69,6 +72,7 @@ export function buildActionSummary( const telemetryProjection = projectBugbotResultTelemetry(context.results); const bugbotTelemetry = telemetryProjection.status === 'valid' ? telemetryProjection.telemetry : undefined; const staleSourceSuppressed = hasStaleSourcePublicationOutcome(context.results); + const duplicateCompactions = duplicateCompactionPublicationOutcomes(context.results); const hasActionableFindings = findingStates ? countActionableBugbotFindings(findingStates) > 0 : false; const hasUnknownFindings = findingStateProjection.status === 'invalid' || (findingStates?.unknown ?? 0) > 0; const status = resolveActionSummaryStatus({ @@ -93,6 +97,9 @@ export function buildActionSummary( ...(staleSourceSuppressed ? [ `| ${catalogText(catalog, 'summary.sourceFreshness')} | ${catalogText(catalog, 'summary.staleSourceSuppressed')} |`, ] : []), + ...(duplicateCompactions.length > 0 ? [ + `| ${catalogText(catalog, 'summary.duplicateCleanup')} | ${formatDuplicateCompactions(duplicateCompactions, catalog)} |`, + ] : []), ]; const localization = renderLocalizationSummarySection( context.locale, @@ -118,6 +125,22 @@ export function buildActionSummary( ].join('\n'); } +function formatDuplicateCompactions( + outcomes: ReturnType, + catalog: ActionSummaryMessageCatalog, +): string { + const count = outcomes.reduce((total, outcome) => total + outcome.compactedCount, 0); + const ids = [...new Set(outcomes.flatMap(outcome => outcome.compactedCommentIds))] + .sort((left, right) => left - right); + const variables = { count, reported: ids.length, ids: ids.join(', ') }; + if (count === 1) return catalogText(catalog, 'summary.duplicateCleanup.single', variables); + return catalogText( + catalog, + count > ids.length ? 'summary.duplicateCleanup.bounded' : 'summary.duplicateCleanup.multiple', + variables, + ); +} + export function actionSummaryLocalizationLabels(catalog: ActionSummaryMessageCatalog): LocalizationSummaryLabels { return Object.freeze({ heading: catalog.message('summary.localization'), diff --git a/src/application/policies/publication_outcome_policy.ts b/src/application/policies/publication_outcome_policy.ts index 900fcc3b0..276135bea 100644 --- a/src/application/policies/publication_outcome_policy.ts +++ b/src/application/policies/publication_outcome_policy.ts @@ -10,6 +10,18 @@ export interface PublicationOutcomePayload { readonly publicationOutcome: StaleSourcePublicationOutcome; } +export interface DuplicateCompactionPublicationOutcome { + readonly reason: 'duplicate-deletion-forbidden'; + readonly compactedCommentIds: readonly number[]; + readonly compactedCount: number; +} + +export interface PublicationCleanupPayload { + readonly publicationCleanup: DuplicateCompactionPublicationOutcome; +} + +const MAX_REPORTED_COMMENT_IDS = 20; + /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ export function buildStaleSourcePublicationPayload( branch: string, @@ -31,3 +43,54 @@ export function hasStaleSourcePublicationOutcome(results: readonly Result[]): bo return outcome?.reason === 'stale-source'; }); } + +/** Builds bounded operator evidence for duplicates retained as compact pointers. */ +export function buildDuplicateCompactionPublicationPayload( + commentIds: readonly number[], +): Readonly | undefined { + const validIds = [...new Set(commentIds.filter(isPositiveInteger))].sort((left, right) => left - right); + if (validIds.length === 0) return undefined; + return Object.freeze({ + publicationCleanup: Object.freeze({ + reason: 'duplicate-deletion-forbidden', + compactedCommentIds: Object.freeze(validIds.slice(0, MAX_REPORTED_COMMENT_IDS)), + compactedCount: validIds.length, + }), + }); +} + +export function duplicateCompactionPublicationOutcomes( + results: readonly Result[], +): readonly DuplicateCompactionPublicationOutcome[] { + return Object.freeze(results.flatMap(result => { + const payload = getResultPayload(result.payload); + const cleanup = getResultPayload(payload?.publicationCleanup); + if (cleanup?.reason !== 'duplicate-deletion-forbidden' + || !isPositiveInteger(cleanup.compactedCount) + || !Array.isArray(cleanup.compactedCommentIds) + || cleanup.compactedCommentIds.length > MAX_REPORTED_COMMENT_IDS + || cleanup.compactedCommentIds.length > cleanup.compactedCount + || !areOrderedUniquePositiveIntegers(cleanup.compactedCommentIds)) { + return []; + } + return [Object.freeze({ + reason: 'duplicate-deletion-forbidden' as const, + compactedCommentIds: Object.freeze([...cleanup.compactedCommentIds]), + compactedCount: cleanup.compactedCount, + })]; + })); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function areOrderedUniquePositiveIntegers(values: readonly unknown[]): values is readonly number[] { + if (values.length === 0) return false; + let previous: number | undefined; + for (const value of values) { + if (!isPositiveInteger(value) || previous !== undefined && value <= previous) return false; + previous = value; + } + return true; +} diff --git a/src/application/ports/issue_lifecycle_ports.ts b/src/application/ports/issue_lifecycle_ports.ts index 2c9017b7d..04cdd2ebc 100644 --- a/src/application/ports/issue_lifecycle_ports.ts +++ b/src/application/ports/issue_lifecycle_ports.ts @@ -43,6 +43,8 @@ export interface IssueCommentPublicationTarget { user?: { login?: string }; } +export type IssueCommentRemovalOutcome = 'removed' | 'compaction-required'; + export interface IssueCommentQueryPort { listIssueComments(owner: string, repository: string, issueNumber: number, token: string): Promise; } @@ -52,13 +54,24 @@ export interface BoundIssueCommentQueryPort { listIssueComments(issueNumber: number): Promise; } -/** Read/write boundary used by the generic issue-comment single action. */ -export interface IssueCommentPublicationPort extends IssueCommentQueryPort { +/** Unbound create/update authority for an explicit caller-selected comment. */ +export interface IssueCommentUpsertPort extends IssueCommentQueryPort { addComment(owner: string, repository: string, issueNumber: number, comment: string, token: string): Promise; updateComment(owner: string, repository: string, issueNumber: number, commentId: number, comment: string, token: string): Promise; } -export interface BoundIssueCommentPublicationPort extends BoundIssueCommentQueryPort { +/** Unbound semantic publication authority with exact-duplicate cleanup. */ +export interface IssueCommentPublicationPort extends IssueCommentUpsertPort { + removeComment(owner: string, repository: string, issueNumber: number, commentId: number, token: string): Promise; +} + +/** Bound create/update authority for an explicit caller-selected comment. */ +export interface BoundIssueCommentUpsertPort extends BoundIssueCommentQueryPort { addComment(issueNumber: number, comment: string): Promise; updateComment(issueNumber: number, commentId: number, comment: string): Promise; } + +/** Bound semantic publication authority with exact-duplicate cleanup. */ +export interface BoundIssueCommentPublicationPort extends BoundIssueCommentUpsertPort { + removeComment(issueNumber: number, commentId: number): Promise; +} diff --git a/src/application/usecases/actions/publish_issue_comment_use_case.ts b/src/application/usecases/actions/publish_issue_comment_use_case.ts index 2ab56c2ca..5daffcd1a 100644 --- a/src/application/usecases/actions/publish_issue_comment_use_case.ts +++ b/src/application/usecases/actions/publish_issue_comment_use_case.ts @@ -1,5 +1,5 @@ import type { Result } from '../../../data/model/result'; -import type { BoundIssueCommentPublicationPort } from '../../ports/issue_lifecycle_ports'; +import type { BoundIssueCommentUpsertPort } from '../../ports/issue_lifecycle_ports'; import type { IssueCommentActionContext } from '../push_single_action_contexts'; import { logInfo } from '../../ports/logging_ports'; import { getTaskEmoji } from '../../../utils/task_emoji'; @@ -10,7 +10,7 @@ import { runPublishIssueComment } from './publish_issue_comment_workflow'; export class PublishIssueCommentUseCase implements ParamUseCase { taskId = 'PublishIssueCommentUseCase'; - constructor(private readonly issueCommentPort: BoundIssueCommentPublicationPort) {} + constructor(private readonly issueCommentPort: BoundIssueCommentUpsertPort) {} async invoke(param: IssueCommentActionContext): Promise { logInfo(`${getTaskEmoji(this.taskId)} Executing ${this.taskId}.`); diff --git a/src/application/usecases/actions/publish_issue_comment_workflow.ts b/src/application/usecases/actions/publish_issue_comment_workflow.ts index 7fc0bbcf0..355538f12 100644 --- a/src/application/usecases/actions/publish_issue_comment_workflow.ts +++ b/src/application/usecases/actions/publish_issue_comment_workflow.ts @@ -1,6 +1,6 @@ import { Result } from '../../../data/model/result'; import { stripTrailingCommentWatermarks } from '../../../utils/comment_watermark'; -import type { BoundIssueCommentPublicationPort } from '../../ports/issue_lifecycle_ports'; +import type { BoundIssueCommentUpsertPort } from '../../ports/issue_lifecycle_ports'; import type { IssueCommentActionContext } from '../push_single_action_contexts'; import { logError } from '../../ports/logging_ports'; import { ApplicationError, toApplicationError } from '../../errors/application_error'; @@ -8,7 +8,7 @@ import { ApplicationError, toApplicationError } from '../../errors/application_e export async function runPublishIssueComment( param: IssueCommentActionContext, taskId: string, - issueCommentPort: BoundIssueCommentPublicationPort, + issueCommentPort: BoundIssueCommentUpsertPort, ): Promise { if (param.kind === 'invalid') { return [new Result({ diff --git a/src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts b/src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts new file mode 100644 index 000000000..4aac12fe4 --- /dev/null +++ b/src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts @@ -0,0 +1,67 @@ +import { cleanupDuplicateComment } from '../duplicate_comment_cleanup_workflow'; + +const context = { + issueNumber: 7, + duplicateCommentId: 12, + compactBody: 'Superseded. See the current status.', +}; + +function ports(removal: 'removed' | 'compaction-required' = 'removed') { + return { + removeComment: jest.fn().mockResolvedValue(removal), + updateComment: jest.fn().mockResolvedValue(undefined), + }; +} + +describe('duplicate comment cleanup workflow', () => { + it('removes a duplicate when the provider supports deletion', async () => { + const comments = ports(); + + await expect(cleanupDuplicateComment(context, comments)).resolves.toBe('removed'); + + expect(comments.removeComment).toHaveBeenCalledWith(7, 12); + expect(comments.updateComment).not.toHaveBeenCalled(); + }); + + it('compacts a duplicate only when deletion is forbidden', async () => { + const comments = ports('compaction-required'); + + await expect(cleanupDuplicateComment(context, comments)).resolves.toBe('compacted'); + + expect(comments.updateComment).toHaveBeenCalledWith(7, 12, context.compactBody); + }); + + it('checks source freshness before each attempted mutation', async () => { + const comments = ports('compaction-required'); + const sourceIsCurrent = jest.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(cleanupDuplicateComment(context, comments, sourceIsCurrent)).resolves.toBe('stale'); + + expect(sourceIsCurrent).toHaveBeenCalledTimes(2); + expect(comments.removeComment).toHaveBeenCalledTimes(1); + expect(comments.updateComment).not.toHaveBeenCalled(); + }); + + it('does nothing when the source is stale before deletion', async () => { + const comments = ports(); + + await expect(cleanupDuplicateComment( + context, + comments, + jest.fn().mockResolvedValue(false), + )).resolves.toBe('stale'); + + expect(comments.removeComment).not.toHaveBeenCalled(); + expect(comments.updateComment).not.toHaveBeenCalled(); + }); + + it('propagates unexpected removal failures without attempting compaction', async () => { + const comments = ports(); + comments.removeComment.mockRejectedValue(new Error('provider unavailable')); + + await expect(cleanupDuplicateComment(context, comments)).rejects.toThrow('provider unavailable'); + expect(comments.updateComment).not.toHaveBeenCalled(); + }); +}); 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 f61cdaf59..3a94ea137 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 @@ -53,6 +53,11 @@ function inMemoryComments(initial: IssueCommentPublicationTarget[] = []) { const comment = values.find(candidate => candidate.id === id); if (comment) comment.body = body; }), + removeComment: jest.fn(async (_issueNumber: number, id: number): Promise<'removed' | 'compaction-required'> => { + const index = values.findIndex(candidate => candidate.id === id); + if (index >= 0) values.splice(index, 1); + return 'removed'; + }), listIssueComments: jest.fn(async () => values.map(comment => ({ ...comment }))), }; } @@ -238,6 +243,27 @@ describe('PublishResultUseCase semantic compatibility boundary', () => { expect(comments.values[0].body).toContain('Replace the policy'); }); + it('returns bounded operator evidence when duplicate deletion is forbidden', async () => { + const comments = inMemoryComments(); + const useCase = new PublishResultUseCase(comments); + const value = projectPublishResultContext(source([recommendation()])); + await useCase.invoke(value); + comments.values.push({ id: 2, body: comments.values[0].body, user: { login: 'vypbot' } }); + comments.removeComment.mockResolvedValue('compaction-required'); + + const outcome = await useCase.invoke(value); + + expect(outcome).toMatchObject({ + success: true, + executed: true, + payload: { publicationCleanup: { + reason: 'duplicate-deletion-forbidden', compactedCount: 1, compactedCommentIds: [2], + } }, + }); + expect(comments.values.find(comment => comment.id === 2)?.body) + .toContain('copilot:publication-duplicate'); + }); + it.each([ [0, 'not started'], [65, 'in progress'], diff --git a/src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts b/src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts index 6d608b114..1166a7711 100644 --- a/src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts +++ b/src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts @@ -33,6 +33,11 @@ function ports(initial: Array<{ id: number; body: string | null; user?: { login? const comment = comments.find(value => value.id === id); if (comment) comment.body = body; }), + removeComment: jest.fn(async (_issue: number, id: number): Promise<'removed' | 'compaction-required'> => { + const index = comments.findIndex(value => value.id === id); + if (index >= 0) comments.splice(index, 1); + return 'removed'; + }), }; } @@ -67,9 +72,10 @@ describe('reply publication workflow', () => { const forged = ports([{ id: 4, body: original.comments[0].body, user: { login: 'human' } }]); await reconcileReply(context(), forged); expect(forged.addComment).toHaveBeenCalledTimes(1); + expect(forged.removeComment).not.toHaveBeenCalled(); }); - it('compacts concurrent bot-owned duplicates and keeps the lowest id', async () => { + it('removes concurrent bot-owned duplicates and keeps the lowest id', async () => { const seeded = ports(); await reconcileReply(context(), seeded); const body = seeded.comments[0].body; @@ -79,9 +85,11 @@ describe('reply publication workflow', () => { ]); await expect(reconcileReply(context(), repository)).resolves.toEqual({ - effect: 'unchanged', canonicalCommentId: 3, duplicatesCompacted: 1, + effect: 'unchanged', canonicalCommentId: 3, + duplicatesRemoved: 1, duplicatesCompacted: 0, compactedCommentIds: [], }); - expect(repository.comments.find(comment => comment.id === 9)?.body).toContain('/issues/7#issuecomment-3'); + expect(repository.comments.map(comment => comment.id)).toEqual([3]); + expect(repository.removeComment).toHaveBeenCalledWith(7, 9); }); it('adopts and compacts the transient issue-comment correlation namespace', async () => { @@ -98,17 +106,17 @@ describe('reply publication workflow', () => { ]); await expect(reconcileReply(context(), repository)).resolves.toEqual({ - effect: 'unchanged', canonicalCommentId: 3, duplicatesCompacted: 1, + effect: 'unchanged', canonicalCommentId: 3, + duplicatesRemoved: 1, duplicatesCompacted: 0, compactedCommentIds: [], }); expect(repository.addComment).not.toHaveBeenCalled(); - expect(repository.comments.find(comment => comment.id === 8)?.body) - .toContain('/issues/7#issuecomment-3'); + expect(repository.comments.find(comment => comment.id === 8)).toBeUndefined(); }); it('does no provider work without a trusted bot identity', async () => { const repository = ports(); await expect(reconcileReply({ ...context(), botLogin: '' }, repository)).resolves.toEqual({ - effect: 'unchanged', duplicatesCompacted: 0, + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], }); expect(repository.listIssueComments).not.toHaveBeenCalled(); }); @@ -116,7 +124,9 @@ describe('reply publication workflow', () => { it('tolerates create visibility lag without guessing a reply id', async () => { const repository = ports(); repository.addComment.mockImplementation(async () => undefined); - await expect(reconcileReply(context(), repository)).resolves.toEqual({ effect: 'created', duplicatesCompacted: 0 }); + await expect(reconcileReply(context(), repository)).resolves.toEqual({ + effect: 'created', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); }); it('links a compacted pull-request reply to the canonical PR comment', async () => { @@ -127,9 +137,16 @@ describe('reply publication workflow', () => { { id: 7, body: seeded.comments[0].body, user: { login: 'vypbot' } }, { id: 4, body: seeded.comments[0].body, user: { login: 'vypbot' } }, ]); + repository.removeComment.mockResolvedValue('compaction-required'); - await reconcileReply({ ...context(), intent: value }, repository); + await expect(reconcileReply({ ...context(), intent: value }, repository)).resolves.toMatchObject({ + duplicatesRemoved: 0, duplicatesCompacted: 1, compactedCommentIds: [7], + }); expect(repository.comments.find(comment => comment.id === 7)?.body).toContain('/pull/9#issuecomment-4'); expect(repository.comments.find(comment => comment.id === 7)?.body).toContain('respuesta duplicada'); + + await reconcileReply({ ...context(), intent: value }, repository); + expect(repository.removeComment).toHaveBeenCalledTimes(1); + expect(repository.updateComment).toHaveBeenCalledTimes(1); }); }); 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 d06bf0c46..d0e52013f 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 @@ -26,6 +26,11 @@ function ports(initial: Array<{ id: number; body: string | null; user?: { login? const comment = comments.find(value => value.id === id); if (comment) comment.body = body; }), + removeComment: jest.fn(async (_issue: number, id: number): Promise<'removed' | 'compaction-required'> => { + const index = comments.findIndex(value => value.id === id); + if (index >= 0) comments.splice(index, 1); + return 'removed'; + }), }; } @@ -38,7 +43,10 @@ const currentSource = (...heads: string[]) => { describe('status card publication workflow', () => { it('creates and discovers one canonical card', async () => { const repository = ports(); - await expect(reconcileStatusCard(context(), repository, currentSource())).resolves.toEqual({ effect: 'created', canonicalCommentId: 1, duplicatesCompacted: 0 }); + await expect(reconcileStatusCard(context(), repository, currentSource())).resolves.toEqual({ + effect: 'created', canonicalCommentId: 1, + duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); expect(repository.addComment).toHaveBeenCalledTimes(1); expect(repository.updateComment).not.toHaveBeenCalled(); }); @@ -67,9 +75,10 @@ describe('status card publication workflow', () => { expect(repository.addComment).toHaveBeenCalledTimes(1); expect(repository.comments).toHaveLength(4); + expect(repository.removeComment).not.toHaveBeenCalled(); }); - it('keeps the lowest bot-owned id and compacts concurrent duplicates', async () => { + it('keeps the lowest bot-owned id and removes concurrent duplicates', async () => { const value = intent('Latest'); const stale = renderSemanticStatus(intent('Stale')); const repository = ports([ @@ -78,17 +87,18 @@ describe('status card publication workflow', () => { ]); await expect(reconcileStatusCard(context(value), repository, currentSource())).resolves.toEqual({ - effect: 'updated', canonicalCommentId: 4, duplicatesCompacted: 1, + effect: 'updated', canonicalCommentId: 4, + duplicatesRemoved: 1, duplicatesCompacted: 0, compactedCommentIds: [], }); expect(repository.comments.find(comment => comment.id === 4)?.body).toContain('Latest'); - expect(repository.comments.find(comment => comment.id === 9)?.body).toContain('copilot:publication-duplicate'); - expect(repository.comments.find(comment => comment.id === 9)?.body).toContain('/issues/7#issuecomment-4'); + expect(repository.comments.find(comment => comment.id === 9)).toBeUndefined(); + expect(repository.removeComment).toHaveBeenCalledWith(7, 9); }); it('fails closed without a trusted bot identity', async () => { const repository = ports(); await expect(reconcileStatusCard({ ...context(), botLogin: '' }, repository, currentSource())).resolves.toEqual({ - effect: 'unchanged', duplicatesCompacted: 0, + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], }); expect(repository.listIssueComments).not.toHaveBeenCalled(); }); @@ -96,7 +106,9 @@ 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, currentSource())).resolves.toEqual({ effect: 'created', duplicatesCompacted: 0 }); + await expect(reconcileStatusCard(context(), repository, currentSource())).resolves.toEqual({ + effect: 'created', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); }); it('preserves the created outcome when post-create discovery finds a stale canonical card', async () => { @@ -125,10 +137,17 @@ describe('status card publication workflow', () => { { id: 7, body, user: { login: 'vypbot' } }, { id: 4, body, user: { login: 'vypbot' } }, ]); + repository.removeComment.mockResolvedValue('compaction-required'); - await reconcileStatusCard(context(value), repository, currentSource()); + await expect(reconcileStatusCard(context(value), repository, currentSource())).resolves.toMatchObject({ + duplicatesRemoved: 0, duplicatesCompacted: 1, compactedCommentIds: [7], + }); 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'); + + await reconcileStatusCard(context(value), repository, currentSource()); + expect(repository.removeComment).toHaveBeenCalledTimes(1); + expect(repository.updateComment).toHaveBeenCalledTimes(1); }); it('omits every read and mutation when the analyzed branch head is already stale', async () => { @@ -136,7 +155,8 @@ describe('status card publication workflow', () => { const source = currentSource(NEWER_HEAD); await expect(reconcileStatusCard(context(), repository, source)).resolves.toEqual({ - effect: 'unchanged', duplicatesCompacted: 0, reason: 'stale-source', + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: [], reason: 'stale-source', }); expect(source.getBranchHeadSha).toHaveBeenCalledWith('feature/work'); @@ -150,7 +170,10 @@ describe('status card publication workflow', () => { await expect(reconcileStatusCard( context(), repository, currentSource(SOURCE_HEAD, NEWER_HEAD), - )).resolves.toEqual({ effect: 'unchanged', duplicatesCompacted: 0, reason: 'stale-source' }); + )).resolves.toEqual({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: [], reason: 'stale-source', + }); expect(repository.listIssueComments).toHaveBeenCalledTimes(1); expect(repository.addComment).not.toHaveBeenCalled(); @@ -165,13 +188,14 @@ describe('status card publication workflow', () => { context(intent('New')), repository, currentSource(SOURCE_HEAD, NEWER_HEAD), )).resolves.toEqual({ effect: 'unchanged', canonicalCommentId: 3, duplicatesCompacted: 0, reason: 'stale-source', + duplicatesRemoved: 0, compactedCommentIds: [], }); expect(repository.updateComment).not.toHaveBeenCalled(); expect(repository.comments[0].body).toContain('Old'); }); - it('stops duplicate compaction if the branch advances between mutations', async () => { + it('stops duplicate cleanup if the branch advances between mutations', async () => { const value = intent('Current'); const body = renderSemanticStatus(value); const repository = ports([ @@ -183,12 +207,35 @@ describe('status card publication workflow', () => { await expect(reconcileStatusCard( context(value), repository, currentSource(SOURCE_HEAD, SOURCE_HEAD, NEWER_HEAD), )).resolves.toEqual({ - effect: 'unchanged', canonicalCommentId: 2, duplicatesCompacted: 1, reason: 'stale-source', + effect: 'unchanged', canonicalCommentId: 2, + duplicatesRemoved: 1, duplicatesCompacted: 0, compactedCommentIds: [], reason: 'stale-source', }); - expect(repository.updateComment).toHaveBeenCalledTimes(1); - expect(repository.comments[1].body).toContain('copilot:publication-duplicate'); - expect(repository.comments[2].body).toBe(body); + expect(repository.removeComment).toHaveBeenCalledTimes(1); + expect(repository.updateComment).not.toHaveBeenCalled(); + expect(repository.comments.map(comment => comment.id)).toEqual([2, 4]); + expect(repository.comments[1].body).toBe(body); + }); + + it('does not compact after a forbidden deletion when the branch advances', async () => { + const value = intent('Current'); + const body = renderSemanticStatus(value); + const repository = ports([ + { id: 2, body, user: { login: 'vypbot' } }, + { id: 3, body, user: { login: 'vypbot' } }, + ]); + repository.removeComment.mockResolvedValue('compaction-required'); + + await expect(reconcileStatusCard( + context(value), repository, currentSource(SOURCE_HEAD, SOURCE_HEAD, NEWER_HEAD), + )).resolves.toEqual({ + effect: 'unchanged', canonicalCommentId: 2, + duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], reason: 'stale-source', + }); + + expect(repository.removeComment).toHaveBeenCalledWith(7, 3); + expect(repository.updateComment).not.toHaveBeenCalled(); + expect(repository.comments).toHaveLength(2); }); it('fails closed when a commit-derived card has no authoritative source port', async () => { diff --git a/src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts b/src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts new file mode 100644 index 000000000..a35bb808e --- /dev/null +++ b/src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts @@ -0,0 +1,23 @@ +import type { BoundIssueCommentPublicationPort } from '../../../ports/issue_lifecycle_ports'; + +export type DuplicateCommentCleanupOutcome = 'removed' | 'compacted' | 'stale'; + +export interface DuplicateCommentCleanupContext { + readonly issueNumber: number; + readonly duplicateCommentId: number; + readonly compactBody: string; +} + +/** Removes an exact owned duplicate, retaining a compact pointer only when deletion is forbidden. */ +export async function cleanupDuplicateComment( + context: DuplicateCommentCleanupContext, + comments: Pick, + sourceIsCurrent?: () => Promise, +): Promise { + if (sourceIsCurrent && !await sourceIsCurrent()) return 'stale'; + const removal = await comments.removeComment(context.issueNumber, context.duplicateCommentId); + if (removal === 'removed') return 'removed'; + if (sourceIsCurrent && !await sourceIsCurrent()) return 'stale'; + await comments.updateComment(context.issueNumber, context.duplicateCommentId, context.compactBody); + return 'compacted'; +} diff --git a/src/application/usecases/steps/common/publish_resume_workflow.ts b/src/application/usecases/steps/common/publish_resume_workflow.ts index e677f3f98..e85a8597f 100644 --- a/src/application/usecases/steps/common/publish_resume_workflow.ts +++ b/src/application/usecases/steps/common/publish_resume_workflow.ts @@ -11,7 +11,10 @@ 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'; +import { + buildDuplicateCompactionPublicationPayload, + buildStaleSourcePublicationPayload, +} from '../../../policies/publication_outcome_policy'; export interface PublishResultContext { readonly owner: string; @@ -95,6 +98,7 @@ export async function runPublishResume( catalogResolver, ); let staleSourceEvidence: Readonly<{ branch: string; sha: string }> | undefined; + const compactedCommentIds: number[] = []; for (const intent of replies) { const outcome = await reconcileReply({ owner: param.owner, @@ -103,7 +107,11 @@ export async function runPublishResume( intent, catalog, }, comments); - logInfo(`Semantic ${intent.messageKey} reply ${outcome.effect}; duplicates compacted=${outcome.duplicatesCompacted}.`); + logInfo( + `Semantic ${intent.messageKey} reply ${outcome.effect}; ` + + `duplicates removed=${outcome.duplicatesRemoved}; compacted=${outcome.duplicatesCompacted}.`, + ); + compactedCommentIds.push(...outcome.compactedCommentIds); } for (const intent of statuses) { const outcome = await reconcileStatusCard({ @@ -115,7 +123,8 @@ export async function runPublishResume( }, comments, sourceQuery); logInfo( `Semantic ${intent.identity.topic} publication ${outcome.effect}; ` - + `reason=${outcome.reason ?? 'current'}; duplicates compacted=${outcome.duplicatesCompacted}.`, + + `reason=${outcome.reason ?? 'current'}; duplicates removed=${outcome.duplicatesRemoved}; ` + + `compacted=${outcome.duplicatesCompacted}.`, ); if (outcome.reason === 'stale-source') { const sourceGuard = intent.sourceGuard; @@ -124,18 +133,21 @@ export async function runPublishResume( } staleSourceEvidence ??= Object.freeze({ branch: sourceGuard.branch, sha: sourceGuard.sha }); } + compactedCommentIds.push(...outcome.compactedCommentIds); } - return staleSourceEvidence - ? new Result({ - id: taskId, - success: true, - executed: false, - payload: buildStaleSourcePublicationPayload( - staleSourceEvidence.branch, - staleSourceEvidence.sha, - ), - }) - : undefined; + const cleanupEvidence = buildDuplicateCompactionPublicationPayload(compactedCommentIds); + if (!staleSourceEvidence && !cleanupEvidence) return undefined; + return new Result({ + id: taskId, + success: true, + executed: cleanupEvidence !== undefined, + payload: Object.freeze({ + ...(staleSourceEvidence + ? buildStaleSourcePublicationPayload(staleSourceEvidence.branch, staleSourceEvidence.sha) + : {}), + ...(cleanupEvidence ?? {}), + }), + }); } catch (error) { const semanticError = toApplicationError(error, 'provider.unavailable', 'Unable to publish semantic GitHub status.'); logError(semanticError); diff --git a/src/application/usecases/steps/common/reply_publication_workflow.ts b/src/application/usecases/steps/common/reply_publication_workflow.ts index 63e9f2fa8..61ea141ab 100644 --- a/src/application/usecases/steps/common/reply_publication_workflow.ts +++ b/src/application/usecases/steps/common/reply_publication_workflow.ts @@ -8,6 +8,7 @@ import { } from '../../../policies/publication_identity_policy'; import { resolveStaticPublicationCatalog, type PublicationMessageCatalog } from '../../../policies/publication_message_catalog'; import { renderSemanticReply, type SemanticReplyIntent } from '../../../policies/semantic_result_publication_policy'; +import { cleanupDuplicateComment } from './duplicate_comment_cleanup_workflow'; export interface ReplyPublicationContext { readonly owner: string; @@ -20,7 +21,9 @@ export interface ReplyPublicationContext { export interface ReplyPublicationOutcome { readonly effect: 'created' | 'unchanged'; readonly canonicalCommentId?: number; + readonly duplicatesRemoved: number; readonly duplicatesCompacted: number; + readonly compactedCommentIds: readonly number[]; } /** Publishes an explicit reply at most once for a trusted request correlation. */ @@ -28,7 +31,7 @@ export async function reconcileReply( context: ReplyPublicationContext, comments: BoundIssueCommentPublicationPort, ): Promise { - if (!context.botLogin.trim()) return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + if (!context.botLogin.trim()) return unchangedOutcome(); const target = context.intent.target; let owned = matchingReplies(await comments.listIssueComments(target.number), context); let effect: ReplyPublicationOutcome['effect'] = 'unchanged'; @@ -37,13 +40,42 @@ export async function reconcileReply( effect = 'created'; owned = matchingReplies(await comments.listIssueComments(target.number), context); } - if (owned.length === 0) return Object.freeze({ effect, duplicatesCompacted: 0 }); + if (owned.length === 0) { + return Object.freeze({ + effect, duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: Object.freeze([]), + }); + } const [canonical, ...duplicates] = owned.sort((left, right) => left.id - right.id); + let duplicatesRemoved = 0; + let duplicatesCompacted = 0; + const compactedCommentIds: number[] = []; for (const duplicate of duplicates) { - await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); + const cleanup = await cleanupDuplicateComment({ + issueNumber: target.number, + duplicateCommentId: duplicate.id, + compactBody: duplicatePointer(context, canonical.id), + }, comments); + if (cleanup === 'removed') duplicatesRemoved += 1; + else { + duplicatesCompacted += 1; + compactedCommentIds.push(duplicate.id); + } } - return Object.freeze({ effect, canonicalCommentId: canonical.id, duplicatesCompacted: duplicates.length }); + return Object.freeze({ + effect, + canonicalCommentId: canonical.id, + duplicatesRemoved, + duplicatesCompacted, + compactedCommentIds: Object.freeze(compactedCommentIds), + }); +} + +function unchangedOutcome(): ReplyPublicationOutcome { + return Object.freeze({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: Object.freeze([]), + }); } function matchingReplies( 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 a201aee1d..592a376c2 100644 --- a/src/application/usecases/steps/common/status_card_publication_workflow.ts +++ b/src/application/usecases/steps/common/status_card_publication_workflow.ts @@ -7,6 +7,7 @@ import { resolveStaticPublicationCatalog, type PublicationMessageCatalog } from import { renderSemanticStatus, type SemanticStatusIntent } from '../../../policies/semantic_result_publication_policy'; import type { BoundPublicationSourceQueryPort } from '../../../ports/publication_freshness_ports'; import { ApplicationError } from '../../../errors/application_error'; +import { cleanupDuplicateComment } from './duplicate_comment_cleanup_workflow'; export interface StatusCardPublicationContext { readonly owner: string; @@ -19,7 +20,9 @@ export interface StatusCardPublicationContext { export interface StatusCardPublicationOutcome { readonly effect: 'created' | 'updated' | 'unchanged'; readonly canonicalCommentId?: number; + readonly duplicatesRemoved: number; readonly duplicatesCompacted: number; + readonly compactedCommentIds: readonly number[]; readonly reason?: 'stale-source'; } @@ -28,7 +31,7 @@ export async function reconcileStatusCard( comments: BoundIssueCommentPublicationPort, sourceQuery?: BoundPublicationSourceQueryPort, ): Promise { - if (!context.botLogin.trim()) return Object.freeze({ effect: 'unchanged', duplicatesCompacted: 0 }); + if (!context.botLogin.trim()) return unchangedOutcome(); if (!await sourceIsCurrent(context.intent, sourceQuery)) return staleSourceOutcome(); const target = context.intent.identity.target; const rendered = renderSemanticStatus(context.intent, context.catalog); @@ -40,7 +43,11 @@ export async function reconcileStatusCard( effect = 'created'; owned = ownedCards(await comments.listIssueComments(target.number), context.intent.identity, context.botLogin); } - if (owned.length === 0) return Object.freeze({ effect, duplicatesCompacted: 0 }); + if (owned.length === 0) { + return Object.freeze({ + effect, duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: Object.freeze([]), + }); + } const [canonical, ...duplicates] = owned.sort((left, right) => left.id - right.id); const canonicalMarker = parsePublicationMarker(canonical.body); @@ -51,18 +58,36 @@ export async function reconcileStatusCard( await comments.updateComment(target.number, canonical.id, rendered); effect = effect === 'created' ? 'created' : 'updated'; } + let duplicatesRemoved = 0; let duplicatesCompacted = 0; + const compactedCommentIds: number[] = []; for (const duplicate of duplicates) { - if (!await sourceIsCurrent(context.intent, sourceQuery)) { - return staleSourceOutcome(canonical.id, effect, duplicatesCompacted); + const cleanup = await cleanupDuplicateComment({ + issueNumber: target.number, + duplicateCommentId: duplicate.id, + compactBody: duplicatePointer(context, canonical.id), + }, comments, () => sourceIsCurrent(context.intent, sourceQuery)); + if (cleanup === 'stale') { + return staleSourceOutcome( + canonical.id, + effect, + duplicatesRemoved, + duplicatesCompacted, + compactedCommentIds, + ); + } + if (cleanup === 'removed') duplicatesRemoved += 1; + else { + duplicatesCompacted += 1; + compactedCommentIds.push(duplicate.id); } - await comments.updateComment(target.number, duplicate.id, duplicatePointer(context, canonical.id)); - duplicatesCompacted += 1; } return Object.freeze({ effect, canonicalCommentId: canonical.id, + duplicatesRemoved, duplicatesCompacted, + compactedCommentIds: Object.freeze(compactedCommentIds), }); } @@ -84,16 +109,27 @@ async function sourceIsCurrent( function staleSourceOutcome( canonicalCommentId?: number, effect: StatusCardPublicationOutcome['effect'] = 'unchanged', + duplicatesRemoved = 0, duplicatesCompacted = 0, + compactedCommentIds: readonly number[] = [], ): StatusCardPublicationOutcome { return Object.freeze({ effect, ...(canonicalCommentId === undefined ? {} : { canonicalCommentId }), + duplicatesRemoved, duplicatesCompacted, + compactedCommentIds: Object.freeze([...compactedCommentIds]), reason: 'stale-source', }); } +function unchangedOutcome(): StatusCardPublicationOutcome { + return Object.freeze({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, + compactedCommentIds: Object.freeze([]), + }); +} + function ownedCards( comments: readonly IssueCommentPublicationTarget[], identity: PublicationIdentity, diff --git a/src/architecture/__tests__/github_publication_boundaries.test.ts b/src/architecture/__tests__/github_publication_boundaries.test.ts index d1c3d09f2..e8476f256 100644 --- a/src/architecture/__tests__/github_publication_boundaries.test.ts +++ b/src/architecture/__tests__/github_publication_boundaries.test.ts @@ -5,6 +5,7 @@ interface BaselineEntry { readonly file: string; readonly addComment?: number; readonly updateComment?: number; + readonly removeComment?: number; readonly updatePullRequestReview?: number; readonly reason: string; } @@ -15,7 +16,7 @@ const baseline = JSON.parse(readFileSync( join(root, 'src/architecture/github_publication_mutation_baseline.json'), 'utf8', )) as { entries: BaselineEntry[] }; -const methods = ['addComment', 'updateComment', 'updatePullRequestReview'] as const; +const methods = ['addComment', 'updateComment', 'removeComment', 'updatePullRequestReview'] as const; function productionFiles(directory: string): string[] { return readdirSync(directory).flatMap(name => { diff --git a/src/architecture/github_publication_mutation_baseline.json b/src/architecture/github_publication_mutation_baseline.json index 479feaea3..1446e3e60 100644 --- a/src/architecture/github_publication_mutation_baseline.json +++ b/src/architecture/github_publication_mutation_baseline.json @@ -7,7 +7,8 @@ { "file": "src/application/usecases/steps/commit/bugbot/publish_overflow_comment.ts", "addComment": 1, "reason": "Publishes the bounded Bugbot overflow record." }, { "file": "src/application/usecases/steps/commit/bugbot/resolve_issue_finding.ts", "updateComment": 1, "reason": "Transitions an existing Bugbot finding to its resolved state." }, { "file": "src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts", "addComment": 1, "updateComment": 2, "updatePullRequestReview": 1, "reason": "Reconciles the Bugbot aggregate card, duplicates, and historical review state." }, - { "file": "src/application/usecases/steps/common/reply_publication_workflow.ts", "addComment": 1, "updateComment": 1, "reason": "Publishes a correlated explicit reply and compacts concurrent duplicates." }, - { "file": "src/application/usecases/steps/common/status_card_publication_workflow.ts", "addComment": 1, "updateComment": 2, "reason": "Creates or reconciles one canonical semantic status card." } + { "file": "src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts", "updateComment": 1, "removeComment": 1, "reason": "Removes exact bot-owned duplicates or compacts them when deletion is forbidden." }, + { "file": "src/application/usecases/steps/common/reply_publication_workflow.ts", "addComment": 1, "reason": "Publishes one correlated explicit reply and selects exact bot-owned duplicates for cleanup." }, + { "file": "src/application/usecases/steps/common/status_card_publication_workflow.ts", "addComment": 1, "updateComment": 1, "reason": "Creates or reconciles one canonical semantic status card and selects exact bot-owned duplicates for cleanup." } ] } diff --git a/src/data/repository/__tests__/github_error_policy.test.ts b/src/data/repository/__tests__/github_error_policy.test.ts index 99aad58f1..c73be7fb8 100644 --- a/src/data/repository/__tests__/github_error_policy.test.ts +++ b/src/data/repository/__tests__/github_error_policy.test.ts @@ -1,4 +1,9 @@ -import { getGithubErrorStatus, isGithubAlreadyExists, isGithubNotFound } from "../github/github_error_policy"; +import { + getGithubErrorStatus, + isGithubAlreadyExists, + isGithubNotFound, + isGithubPermissionDenied, +} from "../github/github_error_policy"; describe("github error policy", () => { it("extracts numeric status safely", () => { @@ -12,6 +17,27 @@ describe("github error policy", () => { expect(isGithubNotFound({ status: 403 })).toBe(false); }); + it.each([ + { status: 403, message: 'Forbidden' }, + { status: 403, message: 'Resource not accessible by integration' }, + { status: 403, message: 'Write permission is required' }, + { status: 403, message: 'Comment deletion is not permitted' }, + { status: 403, message: 'This operation is not allowed' }, + { status: 403, message: 'Must have admin rights to Repository.' }, + ])('recognizes an explicit permission denial: %j', (error) => { + expect(isGithubPermissionDenied(error)).toBe(true); + }); + + it.each([ + { status: 401, message: 'Forbidden' }, + { status: 403 }, + { status: 403, message: 'You have exceeded a secondary rate limit.' }, + { status: 403, message: 'Forbidden', response: { headers: { 'retry-after': '60' } } }, + { status: 403, message: 'Forbidden', response: { headers: { 'X-RateLimit-Remaining': 0 } } }, + ])('does not misclassify authentication or rate-limit failures: %j', (error) => { + expect(isGithubPermissionDenied(error)).toBe(false); + }); + it("recognizes already-existing validation conflicts", () => { expect(isGithubAlreadyExists({ status: 422, message: "Label already exists" })).toBe(true); expect(isGithubAlreadyExists({ diff --git a/src/data/repository/deployment/deployment_presentation_repository.ts b/src/data/repository/deployment/deployment_presentation_repository.ts index 8173580df..dfcc3d3f1 100644 --- a/src/data/repository/deployment/deployment_presentation_repository.ts +++ b/src/data/repository/deployment/deployment_presentation_repository.ts @@ -1,8 +1,8 @@ import type { DeploymentPresentationPort } from "../../../application/ports/deployment_orchestration_ports"; -import type { IssueCommentPublicationPort } from "../../../application/ports/issue_lifecycle_ports"; +import type { IssueCommentUpsertPort } from "../../../application/ports/issue_lifecycle_ports"; export class DeploymentPresentationRepository implements DeploymentPresentationPort { - constructor(private readonly issues: IssueCommentPublicationPort) {} + constructor(private readonly issues: IssueCommentUpsertPort) {} async findDashboard(owner: string, repository: string, issue: number, marker: string, token: string) { const comments = await this.issues.listIssueComments(owner, repository, issue, token); diff --git a/src/data/repository/github/github_error_policy.ts b/src/data/repository/github/github_error_policy.ts index 90647a6d2..edce030bf 100644 --- a/src/data/repository/github/github_error_policy.ts +++ b/src/data/repository/github/github_error_policy.ts @@ -12,6 +12,23 @@ export const getGithubErrorStatus = (error: unknown): number | undefined => { export const isGithubNotFound = (error: unknown): boolean => getGithubErrorStatus(error) === 404; +export const isGithubPermissionDenied = (error: unknown): boolean => { + if (getGithubErrorStatus(error) !== 403) return false; + const errorRecord = readRecord(error); + const headers = readRecord(readRecord(errorRecord?.response)?.headers); + if (readHeader(headers, 'retry-after') !== undefined) return false; + if (readHeader(headers, 'x-ratelimit-remaining') === '0') return false; + const message = errorRecord?.message; + if (typeof message !== 'string') return false; + const normalized = message.trim().toLowerCase(); + return normalized === 'forbidden' + || normalized.includes('resource not accessible by integration') + || normalized.includes('permission') + || normalized.includes('not permitted') + || normalized.includes('not allowed') + || normalized.includes('must have admin rights'); +}; + export const isGithubAlreadyExists = (error: unknown): boolean => { if (getGithubErrorStatus(error) !== 422) return false; return hasAlreadyExistsValidationCode(error) || hasAlreadyExistsMessage(error); @@ -36,3 +53,10 @@ function readRecord(value: unknown): Record | undefined { ? value as Record : undefined; } + +function readHeader(headers: Record | undefined, expected: string): string | undefined { + if (!headers) return undefined; + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === expected); + const value = key ? headers[key] : undefined; + return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; +} diff --git a/src/data/repository/issue/__tests__/issue_content_repository.test.ts b/src/data/repository/issue/__tests__/issue_content_repository.test.ts index c0da24e77..3141bb4db 100644 --- a/src/data/repository/issue/__tests__/issue_content_repository.test.ts +++ b/src/data/repository/issue/__tests__/issue_content_repository.test.ts @@ -10,6 +10,7 @@ const mockUpdate = jest.fn(); const mockGet = jest.fn(); const mockCreateComment = jest.fn(); const mockUpdateComment = jest.fn(); +const mockDeleteComment = jest.fn(); const mockIterator = jest.fn(); jest.mock('@actions/github', () => ({ @@ -20,6 +21,7 @@ jest.mock('@actions/github', () => ({ get: mockGet, createComment: mockCreateComment, updateComment: mockUpdateComment, + deleteComment: mockDeleteComment, listComments: jest.fn(), }, }, @@ -73,6 +75,45 @@ describe('IssueContentRepository', () => { expect(mockUpdateComment).not.toHaveBeenCalled(); }); + it('removes an exact duplicate comment', async () => { + mockDeleteComment.mockResolvedValue(undefined); + + await expect(repository.removeComment('owner', 'repo', 7, 12, 'token')).resolves.toBe('removed'); + + expect(mockDeleteComment).toHaveBeenCalledWith({ owner: 'owner', repo: 'repo', comment_id: 12 }); + }); + + it('treats an already-absent duplicate as removed', async () => { + mockDeleteComment.mockRejectedValue({ status: 404 }); + + await expect(repository.removeComment('owner', 'repo', 7, 12, 'token')).resolves.toBe('removed'); + }); + + it('requests compact fallback only when deletion is forbidden', async () => { + mockDeleteComment.mockRejectedValue({ status: 403, message: 'Resource not accessible by integration' }); + + await expect(repository.removeComment('owner', 'repo', 7, 12, 'token')) + .resolves.toBe('compaction-required'); + }); + + it('propagates transient duplicate-removal failures', async () => { + mockDeleteComment.mockRejectedValue({ status: 503, message: 'unavailable' }); + + await expect(repository.removeComment('owner', 'repo', 7, 12, 'token')) + .rejects.toMatchObject({ status: 503 }); + }); + + it('propagates rate-limit responses instead of disguising them as permission fallbacks', async () => { + mockDeleteComment.mockRejectedValue({ + status: 403, + message: 'Forbidden', + response: { headers: { 'x-ratelimit-remaining': '0' } }, + }); + + await expect(repository.removeComment('owner', 'repo', 7, 12, 'token')) + .rejects.toMatchObject({ status: 403 }); + }); + it('aggregates paginated comments', async () => { mockIterator.mockReturnValue((async function* () { yield { data: [{ id: 1, body: 'first', user: { login: 'one' } }] }; diff --git a/src/data/repository/issue/issue_content_repository.ts b/src/data/repository/issue/issue_content_repository.ts index 6aa5662b4..02e1092bc 100644 --- a/src/data/repository/issue/issue_content_repository.ts +++ b/src/data/repository/issue/issue_content_repository.ts @@ -7,6 +7,8 @@ import type { } from "../../../infrastructure/github/ports/github_issue_provider_ports"; import { requireArrayPage } from "../github/github_pagination_policy"; import { toApplicationError } from '../../../application/errors/application_error'; +import { getGithubErrorStatus, isGithubPermissionDenied } from '../github/github_error_policy'; +import type { IssueCommentRemovalOutcome } from '../../../application/ports/issue_lifecycle_ports'; export interface IssueComment { id: number; @@ -125,6 +127,36 @@ export class IssueContentRepository { logDebugInfo(`Comment ${commentId} updated in Issue ${issueNumber}.`); }; + removeComment = async ( + owner: string, + repository: string, + issueNumber: number, + commentId: number, + token: string, + ): Promise => { + const octokit = this.githubClient.getClient(token); + try { + await octokit.rest.issues.deleteComment({ + owner, + repo: repository, + comment_id: commentId, + }); + logDebugInfo(`Duplicate comment ${commentId} removed from Issue ${issueNumber}.`); + return 'removed'; + } catch (error) { + const status = getGithubErrorStatus(error); + if (status === 404) { + logDebugInfo(`Duplicate comment ${commentId} was already absent from Issue ${issueNumber}.`); + return 'removed'; + } + if (isGithubPermissionDenied(error)) { + logDebugInfo(`Duplicate comment ${commentId} cannot be removed from Issue ${issueNumber}; compacting it instead.`); + return 'compaction-required'; + } + throw error; + } + }; + listIssueComments = async ( owner: string, repository: string, diff --git a/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts b/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts index 00160e963..b81083d28 100644 --- a/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts +++ b/src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts @@ -59,11 +59,16 @@ describe('push and single-action capability binding', () => { it('binds issue-comment publication without accepting repository inputs', async () => { const addComment = jest.fn().mockResolvedValue(7); - const bound = bindIssueCommentPublication({ addComment, updateComment: jest.fn(), listIssueComments: jest.fn() } as never, binding); + const removeComment = jest.fn().mockResolvedValue('removed'); + const bound = bindIssueCommentPublication({ + addComment, removeComment, updateComment: jest.fn(), listIssueComments: jest.fn(), + } as never, binding); await bound.addComment(42, 'Ready'); + await bound.removeComment(42, 9); expect(addComment).toHaveBeenCalledWith('owner', 'repo', 42, 'Ready', 'secret-token'); + expect(removeComment).toHaveBeenCalledWith('owner', 'repo', 42, 9, 'secret-token'); }); it('binds branch-sync workspace credentials only on remote operations', async () => { @@ -220,7 +225,9 @@ describe('push and single-action capability binding', () => { updateTag: jest.fn(), createTag: jest.fn(), createOrVerifyTagAtSha: jest.fn(), }; const releasePort = { updateRelease: jest.fn(), createRelease: jest.fn() }; - const commentPort = { addComment: jest.fn(), updateComment: jest.fn(), listIssueComments: jest.fn() }; + const commentPort = { + addComment: jest.fn(), updateComment: jest.fn(), removeComment: jest.fn(), listIssueComments: jest.fn(), + }; const gitPort = { getBranchSha: jest.fn(), getMergeBaseSha: jest.fn(), isCommitReachable: jest.fn(), createOrVerifyBranch: jest.fn(), mergeCommitIntoBranch: jest.fn(), deleteBranch: jest.fn(), listBranches: jest.fn(), @@ -243,6 +250,7 @@ describe('push and single-action capability binding', () => { await tags.createOrVerifyTagAtSha('a'.repeat(40), 'v1.2.3'); await releases.updateRelease('next', 'stable'); await comments.updateComment(42, 7, 'updated'); + await comments.removeComment(42, 8); await comments.listIssueComments(42); await git.getMergeBaseSha('master', 'develop'); await git.isCommitReachable('master', 'a'.repeat(40)); @@ -265,6 +273,7 @@ describe('push and single-action capability binding', () => { expect(presentationPort.publishMilestone).toHaveBeenCalledWith( 'owner', 'repo', 42, '', 'body', 'secret-token', ); + expect(commentPort.removeComment).toHaveBeenCalledWith('owner', 'repo', 42, 8, 'secret-token'); }); it('forwards every remaining issue, branch-query, and synchronization operation', async () => { diff --git a/src/infrastructure/composition/push_single_action_capability_port_binding.ts b/src/infrastructure/composition/push_single_action_capability_port_binding.ts index 2b48bffa0..771c5eb13 100644 --- a/src/infrastructure/composition/push_single_action_capability_port_binding.ts +++ b/src/infrastructure/composition/push_single_action_capability_port_binding.ts @@ -228,6 +228,7 @@ export function bindIssueCommentPublication( return Object.freeze({ addComment: (issueNumber, comment) => port.addComment(binding.owner, binding.repository, issueNumber, comment, binding.token), updateComment: (issueNumber, commentId, comment) => port.updateComment(binding.owner, binding.repository, issueNumber, commentId, comment, binding.token), + removeComment: (issueNumber, commentId) => port.removeComment(binding.owner, binding.repository, issueNumber, commentId, binding.token), listIssueComments: (issueNumber) => port.listIssueComments(binding.owner, binding.repository, issueNumber, binding.token), }); } diff --git a/src/infrastructure/github/ports/github_issue_provider_ports.ts b/src/infrastructure/github/ports/github_issue_provider_ports.ts index cfae857e1..b9b2a9404 100644 --- a/src/infrastructure/github/ports/github_issue_provider_ports.ts +++ b/src/infrastructure/github/ports/github_issue_provider_ports.ts @@ -43,6 +43,7 @@ export interface GithubIssueContentClient { update(parameters: Record): Promise; createComment(parameters: Record): Promise; updateComment(parameters: Record): Promise; + deleteComment(parameters: Record): Promise; listComments(parameters: Record): Promise<{ data: GithubIssueComment[] }>; }; }; From f66ed7722e94ee2464001debc33a9c91f2b1fd95 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 09:52:56 +0200 Subject: [PATCH 2/2] codex-semantic-duplicate-cleanup: Cover narrowed deployment comment boundary --- specs/CATALOG.md | 4 +- specs/catalog.json | 1 + ...deployment_presentation_repository.test.ts | 77 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 8b3ed208a..786d0b026 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 | 186 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 | 187 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 | @@ -35,7 +35,7 @@ debt or convert unknown historic intent into a design decision. - 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/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/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts`](../src/application/usecases/steps/common/duplicate_comment_cleanup_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) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) · [`src/infrastructure/github/ports/github_issue_provider_ports.ts`](../src/infrastructure/github/ports/github_issue_provider_ports.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/common/__tests__/duplicate_comment_cleanup_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/data/repository/__tests__/github_error_policy.test.ts`](../src/data/repository/__tests__/github_error_policy.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) +- 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/common/__tests__/duplicate_comment_cleanup_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts`](../src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts) · [`src/data/repository/__tests__/github_error_policy.test.ts`](../src/data/repository/__tests__/github_error_policy.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 0a3581bbc..673a9f6e1 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -155,6 +155,7 @@ "src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts", "src/application/usecases/steps/common/__tests__/duplicate_comment_cleanup_workflow.test.ts", "src/data/repository/issue/__tests__/issue_content_repository.test.ts", + "src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts", "src/data/repository/__tests__/github_error_policy.test.ts", "src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts", "src/application/usecases/__tests__/issue_use_case.test.ts", diff --git a/src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts b/src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts new file mode 100644 index 000000000..3571bbbd4 --- /dev/null +++ b/src/data/repository/deployment/__tests__/deployment_presentation_repository.test.ts @@ -0,0 +1,77 @@ +import type { IssueCommentUpsertPort } from '../../../../application/ports/issue_lifecycle_ports'; +import { DeploymentPresentationRepository } from '../deployment_presentation_repository'; + +const owner = 'vypdev'; +const repository = 'copilot'; +const issueNumber = 42; +const token = 'token'; + +function createIssues(): jest.Mocked { + return { + addComment: jest.fn(), + updateComment: jest.fn(), + listIssueComments: jest.fn(), + }; +} + +describe('DeploymentPresentationRepository', () => { + it('finds the only dashboard with the requested marker', async () => { + const issues = createIssues(); + issues.listIssueComments.mockResolvedValue([ + { id: 7, body: 'unrelated' }, + { id: 9, body: 'dashboard\n' }, + ]); + + await expect(new DeploymentPresentationRepository(issues).findDashboard( + owner, repository, issueNumber, '', token, + )).resolves.toEqual({ id: 9, body: 'dashboard\n' }); + }); + + it('returns no dashboard when the marker is absent', async () => { + const issues = createIssues(); + issues.listIssueComments.mockResolvedValue([{ id: 7, body: null }]); + + await expect(new DeploymentPresentationRepository(issues).findDashboard( + owner, repository, issueNumber, '', token, + )).resolves.toBeUndefined(); + }); + + it('rejects ambiguous dashboard ownership', async () => { + const issues = createIssues(); + issues.listIssueComments.mockResolvedValue([ + { id: 7, body: '' }, + { id: 9, body: '' }, + ]); + + await expect(new DeploymentPresentationRepository(issues).findDashboard( + owner, repository, issueNumber, '', token, + )).rejects.toThrow('Multiple deployment dashboards match .'); + }); + + it('forwards explicit dashboard create and update requests', async () => { + const issues = createIssues(); + const presentation = new DeploymentPresentationRepository(issues); + + await presentation.createDashboard(owner, repository, issueNumber, 'created', token); + await presentation.updateDashboard(owner, repository, issueNumber, 17, 'updated', token); + + expect(issues.addComment).toHaveBeenCalledWith(owner, repository, issueNumber, 'created', token); + expect(issues.updateComment).toHaveBeenCalledWith(owner, repository, issueNumber, 17, 'updated', token); + }); + + it('publishes a milestone once with its marker', async () => { + const issues = createIssues(); + issues.listIssueComments + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 17, body: 'Published\n\n' }]); + const presentation = new DeploymentPresentationRepository(issues); + + await presentation.publishMilestone(owner, repository, issueNumber, '', 'Published', token); + await presentation.publishMilestone(owner, repository, issueNumber, '', 'Published', token); + + expect(issues.addComment).toHaveBeenCalledTimes(1); + expect(issues.addComment).toHaveBeenCalledWith( + owner, repository, issueNumber, 'Published\n\n', token, + ); + }); +});