diff --git a/build/api/index.js b/build/api/index.js index 332e74e1b..34fb90798 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -1453,12 +1453,15 @@ function neutralizeGithubControls(value) { Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; +exports.TRANSITION_FINGERPRINT_ACTIONS = exports.PUBLICATION_TRANSITION_MARKER_PREFIX = exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; exports.createSemanticDigest = createSemanticDigest; +exports.createTransitionFingerprint = createTransitionFingerprint; exports.buildPublicationMarker = buildPublicationMarker; exports.parsePublicationMarker = parsePublicationMarker; exports.buildPublicationReplyMarker = buildPublicationReplyMarker; exports.parsePublicationReplyMarker = parsePublicationReplyMarker; +exports.buildPublicationTransitionMarker = buildPublicationTransitionMarker; +exports.parsePublicationTransitionMarker = parsePublicationTransitionMarker; exports.readablePublicationReplyCorrelationIds = readablePublicationReplyCorrelationIds; exports.buildDuplicateMarker = buildDuplicateMarker; const node_crypto_1 = __nccwpck_require__(6005); @@ -1467,11 +1470,31 @@ exports.PUBLICATION_SCHEMA = '1'; exports.PUBLICATION_MARKER_PREFIX = 'copilot:publication'; exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = 'copilot:publication-duplicate'; exports.PUBLICATION_REPLY_MARKER_PREFIX = 'copilot:reply'; +exports.PUBLICATION_TRANSITION_MARKER_PREFIX = 'copilot:transition'; +exports.TRANSITION_FINGERPRINT_ACTIONS = Object.freeze([ + 'branch-sync-required', +]); const SAFE_VALUE = /^[A-Za-z0-9._:-]{1,128}$/u; const DIGEST = /^[a-f0-9]{8,64}$/u; function createSemanticDigest(value) { return (0, node_crypto_1.createHash)('sha256').update(stableSerialize(value), 'utf8').digest('hex').slice(0, 16); } +/** Derives a notification identity exclusively from trusted, bounded transition facts. */ +function createTransitionFingerprint(identity, action, sourceVersion) { + const target = (0, github_publication_1.publicationTargetToken)(identity.target); + for (const value of [identity.topic, target, identity.key, action, sourceVersion]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Transition fingerprint contains an unsafe identity value.'); + } + if (!exports.TRANSITION_FINGERPRINT_ACTIONS.includes(action)) { + throw new Error('Transition fingerprint contains an unknown action.'); + } + return createSemanticDigest({ + action, + identity: { key: identity.key, target, topic: identity.topic }, + sourceVersion, + }); +} function buildPublicationMarker(marker) { const target = (0, github_publication_1.publicationTargetToken)(marker.identity.target); for (const value of [marker.identity.topic, target, marker.identity.key, marker.sourceVersion]) { @@ -1521,6 +1544,39 @@ function parsePublicationReplyMarker(body) { return undefined; return Object.freeze({ target: match[1], correlationId: match[2], messageKey: match[3], digest: match[4] }); } +function buildPublicationTransitionMarker(intent) { + const target = (0, github_publication_1.publicationTargetToken)(intent.identity.target); + for (const value of [intent.identity.topic, target, intent.identity.key, intent.messageKey]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Publication transition marker contains an unsafe identity value.'); + } + if (!DIGEST.test(intent.fingerprint)) { + throw new Error('Publication transition marker contains an invalid fingerprint.'); + } + return ``; +} +function parsePublicationTransitionMarker(body) { + if (typeof body !== 'string') + return undefined; + const match = body.match(//u); + if (!match) + return undefined; + const topic = match[1]; + if (!github_publication_1.PUBLICATION_TOPICS.includes(topic)) + return undefined; + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number < 1) + return undefined; + return Object.freeze({ + identity: Object.freeze({ + topic, + target: Object.freeze({ kind: match[2] === 'pr' ? 'pull-request' : 'issue', number }), + key: match[4], + }), + fingerprint: match[5], + messageKey: match[6], + }); +} /** * Reads the stable issue-comment identity plus the short-lived namespaced form * emitted during migration. Review-comment identities remain transport-scoped. @@ -5239,6 +5295,12 @@ function publicationIdentityEquals(left, right) { && left.key === right.key; } function publicationTargetToken(target) { + if (target.kind !== 'issue' && target.kind !== 'pull-request') { + throw new Error('Publication target kind must be issue or pull-request.'); + } + if (!Number.isSafeInteger(target.number) || target.number < 1) { + throw new Error('Publication target number must be a positive safe integer.'); + } return `${target.kind === 'pull-request' ? 'pr' : 'issue'}:${target.number}`; } diff --git a/build/cli/index.js b/build/cli/index.js index 58a116da9..cc1c6cb22 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -43983,12 +43983,15 @@ function progressPercentToColor(percent) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; +exports.TRANSITION_FINGERPRINT_ACTIONS = exports.PUBLICATION_TRANSITION_MARKER_PREFIX = exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; exports.createSemanticDigest = createSemanticDigest; +exports.createTransitionFingerprint = createTransitionFingerprint; exports.buildPublicationMarker = buildPublicationMarker; exports.parsePublicationMarker = parsePublicationMarker; exports.buildPublicationReplyMarker = buildPublicationReplyMarker; exports.parsePublicationReplyMarker = parsePublicationReplyMarker; +exports.buildPublicationTransitionMarker = buildPublicationTransitionMarker; +exports.parsePublicationTransitionMarker = parsePublicationTransitionMarker; exports.readablePublicationReplyCorrelationIds = readablePublicationReplyCorrelationIds; exports.buildDuplicateMarker = buildDuplicateMarker; const node_crypto_1 = __nccwpck_require__(6005); @@ -43997,11 +44000,31 @@ exports.PUBLICATION_SCHEMA = '1'; exports.PUBLICATION_MARKER_PREFIX = 'copilot:publication'; exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = 'copilot:publication-duplicate'; exports.PUBLICATION_REPLY_MARKER_PREFIX = 'copilot:reply'; +exports.PUBLICATION_TRANSITION_MARKER_PREFIX = 'copilot:transition'; +exports.TRANSITION_FINGERPRINT_ACTIONS = Object.freeze([ + 'branch-sync-required', +]); const SAFE_VALUE = /^[A-Za-z0-9._:-]{1,128}$/u; const DIGEST = /^[a-f0-9]{8,64}$/u; function createSemanticDigest(value) { return (0, node_crypto_1.createHash)('sha256').update(stableSerialize(value), 'utf8').digest('hex').slice(0, 16); } +/** Derives a notification identity exclusively from trusted, bounded transition facts. */ +function createTransitionFingerprint(identity, action, sourceVersion) { + const target = (0, github_publication_1.publicationTargetToken)(identity.target); + for (const value of [identity.topic, target, identity.key, action, sourceVersion]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Transition fingerprint contains an unsafe identity value.'); + } + if (!exports.TRANSITION_FINGERPRINT_ACTIONS.includes(action)) { + throw new Error('Transition fingerprint contains an unknown action.'); + } + return createSemanticDigest({ + action, + identity: { key: identity.key, target, topic: identity.topic }, + sourceVersion, + }); +} function buildPublicationMarker(marker) { const target = (0, github_publication_1.publicationTargetToken)(marker.identity.target); for (const value of [marker.identity.topic, target, marker.identity.key, marker.sourceVersion]) { @@ -44051,6 +44074,39 @@ function parsePublicationReplyMarker(body) { return undefined; return Object.freeze({ target: match[1], correlationId: match[2], messageKey: match[3], digest: match[4] }); } +function buildPublicationTransitionMarker(intent) { + const target = (0, github_publication_1.publicationTargetToken)(intent.identity.target); + for (const value of [intent.identity.topic, target, intent.identity.key, intent.messageKey]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Publication transition marker contains an unsafe identity value.'); + } + if (!DIGEST.test(intent.fingerprint)) { + throw new Error('Publication transition marker contains an invalid fingerprint.'); + } + return ``; +} +function parsePublicationTransitionMarker(body) { + if (typeof body !== 'string') + return undefined; + const match = body.match(//u); + if (!match) + return undefined; + const topic = match[1]; + if (!github_publication_1.PUBLICATION_TOPICS.includes(topic)) + return undefined; + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number < 1) + return undefined; + return Object.freeze({ + identity: Object.freeze({ + topic, + target: Object.freeze({ kind: match[2] === 'pr' ? 'pull-request' : 'issue', number }), + key: match[4], + }), + fingerprint: match[5], + messageKey: match[6], + }); +} /** * Reads the stable issue-comment identity plus the short-lived namespaced form * emitted during migration. Review-comment identities remain transport-scoped. @@ -44460,8 +44516,12 @@ exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; exports.buildDuplicateCompactionPublicationPayload = buildDuplicateCompactionPublicationPayload; exports.duplicateCompactionPublicationOutcomes = duplicateCompactionPublicationOutcomes; +exports.buildTransitionPublicationPayload = buildTransitionPublicationPayload; +exports.transitionPublicationOutcomes = transitionPublicationOutcomes; const result_1 = __nccwpck_require__(73817); +const github_publication_1 = __nccwpck_require__(35793); const MAX_REPORTED_COMMENT_IDS = 20; +const MAX_REPORTED_TRANSITIONS = 20; /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { return Object.freeze({ @@ -44511,6 +44571,48 @@ function duplicateCompactionPublicationOutcomes(results) { })]; })); } +/** Builds content-free, bounded evidence for one action-notification decision. */ +function buildTransitionPublicationPayload(intent, effect) { + return Object.freeze({ + publicationTransition: Object.freeze({ + topic: intent.identity.topic, + target: (0, github_publication_1.publicationTargetToken)(intent.identity.target), + effect, + fingerprint: intent.fingerprint, + }), + }); +} +function transitionPublicationOutcomes(results) { + return Object.freeze(results.flatMap(result => { + if (!result.success || !result.executed) + return []; + const payload = (0, result_1.getResultPayload)(result.payload); + const transition = (0, result_1.getResultPayload)(payload?.publicationTransition); + if (!transition + || typeof transition.topic !== 'string' + || !github_publication_1.PUBLICATION_TOPICS.includes(transition.topic) + || typeof transition.target !== 'string' + || !isPublicationTargetToken(transition.target) + || transition.effect !== 'created' && transition.effect !== 'unchanged' + || typeof transition.fingerprint !== 'string' + || !/^[a-f0-9]{8,64}$/u.test(transition.fingerprint)) { + return []; + } + return [Object.freeze({ + topic: transition.topic, + target: transition.target, + effect: transition.effect, + fingerprint: transition.fingerprint, + })]; + }).slice(0, MAX_REPORTED_TRANSITIONS)); +} +function isPublicationTargetToken(value) { + const match = value.match(/^(?:issue|pr):([1-9]\d*)$/u); + if (!match) + return false; + const number = Number(match[1]); + return Number.isSafeInteger(number) && number > 0; +} function isPositiveInteger(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } @@ -73816,6 +73918,12 @@ function publicationIdentityEquals(left, right) { && left.key === right.key; } function publicationTargetToken(target) { + if (target.kind !== 'issue' && target.kind !== 'pull-request') { + throw new Error('Publication target kind must be issue or pull-request.'); + } + if (!Number.isSafeInteger(target.number) || target.number < 1) { + throw new Error('Publication target number must be a positive safe integer.'); + } return `${target.kind === 'pull-request' ? 'pr' : 'issue'}:${target.number}`; } diff --git a/build/github_action/index.js b/build/github_action/index.js index 8194e0616..5868de482 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41141,7 +41141,8 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', 'sourceFreshness', 'staleSourceSuppressed', - 'duplicateCleanup', + 'duplicateCleanup', 'actionNotifications', 'actionNotificationCreated', + 'actionNotificationReused', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -41155,6 +41156,7 @@ const TEMPLATE_MESSAGE_IDS = Object.freeze([ 'summary.duplicateCleanup.single', 'summary.duplicateCleanup.multiple', 'summary.duplicateCleanup.bounded', + 'summary.actionNotification.entry', ]); const FINDING_STATE_KEYS = Object.freeze([ 'open', 'reopened', 'fixed', 'obsolete', 'dismissed', @@ -41182,6 +41184,9 @@ const ENGLISH_SIMPLE = Object.freeze({ sourceFreshness: 'Source freshness', staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', duplicateCleanup: 'Duplicate cleanup', + actionNotifications: 'Action notifications', + actionNotificationCreated: 'created', + actionNotificationReused: 'reused', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -41219,6 +41224,9 @@ const SPANISH_SIMPLE = Object.freeze({ sourceFreshness: 'Vigencia del origen', staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', duplicateCleanup: 'Limpieza de duplicados', + actionNotifications: 'Notificaciones de acción', + actionNotificationCreated: 'creada', + actionNotificationReused: 'reutilizada', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', @@ -41248,6 +41256,7 @@ const ENGLISH_TEMPLATES = Object.freeze({ '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})', + 'summary.actionNotification.entry': '{topic} on {target}: {effect} (fingerprint {fingerprint})', }); const SPANISH_TEMPLATES = Object.freeze({ 'summary.target.pullRequest': 'PR n.º {number}', @@ -41257,6 +41266,7 @@ const SPANISH_TEMPLATES = Object.freeze({ '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})', + 'summary.actionNotification.entry': '{topic} en {target}: {effect} (huella {fingerprint})', }); const ENGLISH_FINDING_STATES = Object.freeze({ open: 'open', @@ -41346,6 +41356,7 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo 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 transitions = (0, publication_outcome_policy_1.transitionPublicationOutcomes)(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({ @@ -41373,6 +41384,9 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo ...(duplicateCompactions.length > 0 ? [ `| ${catalogText(catalog, 'summary.duplicateCleanup')} | ${formatDuplicateCompactions(duplicateCompactions, catalog)} |`, ] : []), + ...(transitions.length > 0 ? [ + `| ${catalogText(catalog, 'summary.actionNotifications')} | ${formatTransitionPublications(transitions, catalog)} |`, + ] : []), ]; const localization = renderLocalizationSummarySection(context.locale, context.catalogResolutions, actionSummaryLocalizationLabels(catalog)); return [ @@ -41392,6 +41406,16 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo ...(localization ? ['', localization] : []), ].join('\n'); } +function formatTransitionPublications(outcomes, catalog) { + return outcomes.map(outcome => catalogText(catalog, 'summary.actionNotification.entry', { + topic: outcome.topic, + target: outcome.target, + effect: catalog.message(outcome.effect === 'created' + ? 'summary.actionNotificationCreated' + : 'summary.actionNotificationReused'), + fingerprint: outcome.fingerprint, + })).join('; '); +} function formatDuplicateCompactions(outcomes, catalog) { const count = outcomes.reduce((total, outcome) => total + outcome.compactedCount, 0); const ids = [...new Set(outcomes.flatMap(outcome => outcome.compactedCommentIds))] @@ -46555,12 +46579,15 @@ function progressPercentToColor(percent) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; +exports.TRANSITION_FINGERPRINT_ACTIONS = exports.PUBLICATION_TRANSITION_MARKER_PREFIX = exports.PUBLICATION_REPLY_MARKER_PREFIX = exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = exports.PUBLICATION_MARKER_PREFIX = exports.PUBLICATION_SCHEMA = void 0; exports.createSemanticDigest = createSemanticDigest; +exports.createTransitionFingerprint = createTransitionFingerprint; exports.buildPublicationMarker = buildPublicationMarker; exports.parsePublicationMarker = parsePublicationMarker; exports.buildPublicationReplyMarker = buildPublicationReplyMarker; exports.parsePublicationReplyMarker = parsePublicationReplyMarker; +exports.buildPublicationTransitionMarker = buildPublicationTransitionMarker; +exports.parsePublicationTransitionMarker = parsePublicationTransitionMarker; exports.readablePublicationReplyCorrelationIds = readablePublicationReplyCorrelationIds; exports.buildDuplicateMarker = buildDuplicateMarker; const node_crypto_1 = __nccwpck_require__(6005); @@ -46569,11 +46596,31 @@ exports.PUBLICATION_SCHEMA = '1'; exports.PUBLICATION_MARKER_PREFIX = 'copilot:publication'; exports.PUBLICATION_DUPLICATE_MARKER_PREFIX = 'copilot:publication-duplicate'; exports.PUBLICATION_REPLY_MARKER_PREFIX = 'copilot:reply'; +exports.PUBLICATION_TRANSITION_MARKER_PREFIX = 'copilot:transition'; +exports.TRANSITION_FINGERPRINT_ACTIONS = Object.freeze([ + 'branch-sync-required', +]); const SAFE_VALUE = /^[A-Za-z0-9._:-]{1,128}$/u; const DIGEST = /^[a-f0-9]{8,64}$/u; function createSemanticDigest(value) { return (0, node_crypto_1.createHash)('sha256').update(stableSerialize(value), 'utf8').digest('hex').slice(0, 16); } +/** Derives a notification identity exclusively from trusted, bounded transition facts. */ +function createTransitionFingerprint(identity, action, sourceVersion) { + const target = (0, github_publication_1.publicationTargetToken)(identity.target); + for (const value of [identity.topic, target, identity.key, action, sourceVersion]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Transition fingerprint contains an unsafe identity value.'); + } + if (!exports.TRANSITION_FINGERPRINT_ACTIONS.includes(action)) { + throw new Error('Transition fingerprint contains an unknown action.'); + } + return createSemanticDigest({ + action, + identity: { key: identity.key, target, topic: identity.topic }, + sourceVersion, + }); +} function buildPublicationMarker(marker) { const target = (0, github_publication_1.publicationTargetToken)(marker.identity.target); for (const value of [marker.identity.topic, target, marker.identity.key, marker.sourceVersion]) { @@ -46623,6 +46670,39 @@ function parsePublicationReplyMarker(body) { return undefined; return Object.freeze({ target: match[1], correlationId: match[2], messageKey: match[3], digest: match[4] }); } +function buildPublicationTransitionMarker(intent) { + const target = (0, github_publication_1.publicationTargetToken)(intent.identity.target); + for (const value of [intent.identity.topic, target, intent.identity.key, intent.messageKey]) { + if (!SAFE_VALUE.test(value)) + throw new Error('Publication transition marker contains an unsafe identity value.'); + } + if (!DIGEST.test(intent.fingerprint)) { + throw new Error('Publication transition marker contains an invalid fingerprint.'); + } + return ``; +} +function parsePublicationTransitionMarker(body) { + if (typeof body !== 'string') + return undefined; + const match = body.match(//u); + if (!match) + return undefined; + const topic = match[1]; + if (!github_publication_1.PUBLICATION_TOPICS.includes(topic)) + return undefined; + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number < 1) + return undefined; + return Object.freeze({ + identity: Object.freeze({ + topic, + target: Object.freeze({ kind: match[2] === 'pr' ? 'pull-request' : 'issue', number }), + key: match[4], + }), + fingerprint: match[5], + messageKey: match[6], + }); +} /** * Reads the stable issue-comment identity plus the short-lived namespaced form * emitted during migration. Review-comment identities remain transport-scoped. @@ -47032,8 +47112,12 @@ exports.buildStaleSourcePublicationPayload = buildStaleSourcePublicationPayload; exports.hasStaleSourcePublicationOutcome = hasStaleSourcePublicationOutcome; exports.buildDuplicateCompactionPublicationPayload = buildDuplicateCompactionPublicationPayload; exports.duplicateCompactionPublicationOutcomes = duplicateCompactionPublicationOutcomes; +exports.buildTransitionPublicationPayload = buildTransitionPublicationPayload; +exports.transitionPublicationOutcomes = transitionPublicationOutcomes; const result_1 = __nccwpck_require__(73817); +const github_publication_1 = __nccwpck_require__(35793); const MAX_REPORTED_COMMENT_IDS = 20; +const MAX_REPORTED_TRANSITIONS = 20; /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ function buildStaleSourcePublicationPayload(branch, sourceHeadSha) { return Object.freeze({ @@ -47083,6 +47167,48 @@ function duplicateCompactionPublicationOutcomes(results) { })]; })); } +/** Builds content-free, bounded evidence for one action-notification decision. */ +function buildTransitionPublicationPayload(intent, effect) { + return Object.freeze({ + publicationTransition: Object.freeze({ + topic: intent.identity.topic, + target: (0, github_publication_1.publicationTargetToken)(intent.identity.target), + effect, + fingerprint: intent.fingerprint, + }), + }); +} +function transitionPublicationOutcomes(results) { + return Object.freeze(results.flatMap(result => { + if (!result.success || !result.executed) + return []; + const payload = (0, result_1.getResultPayload)(result.payload); + const transition = (0, result_1.getResultPayload)(payload?.publicationTransition); + if (!transition + || typeof transition.topic !== 'string' + || !github_publication_1.PUBLICATION_TOPICS.includes(transition.topic) + || typeof transition.target !== 'string' + || !isPublicationTargetToken(transition.target) + || transition.effect !== 'created' && transition.effect !== 'unchanged' + || typeof transition.fingerprint !== 'string' + || !/^[a-f0-9]{8,64}$/u.test(transition.fingerprint)) { + return []; + } + return [Object.freeze({ + topic: transition.topic, + target: transition.target, + effect: transition.effect, + fingerprint: transition.fingerprint, + })]; + }).slice(0, MAX_REPORTED_TRANSITIONS)); +} +function isPublicationTargetToken(value) { + const match = value.match(/^(?:issue|pr):([1-9]\d*)$/u); + if (!match) + return false; + const number = Number(match[1]); + return Number.isSafeInteger(number) && number > 0; +} function isPositiveInteger(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } @@ -73204,6 +73330,12 @@ function publicationIdentityEquals(left, right) { && left.key === right.key; } function publicationTargetToken(target) { + if (target.kind !== 'issue' && target.kind !== 'pull-request') { + throw new Error('Publication target kind must be issue or pull-request.'); + } + if (!Number.isSafeInteger(target.number) || target.number < 1) { + throw new Error('Publication target number must be a positive safe integer.'); + } return `${target.kind === 'pull-request' ? 'pr' : 'issue'}:${target.number}`; } diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index f73eac31f..c4ad5a790 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -20,6 +20,12 @@ 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. +Action notifications use a separate immutable transition marker and the same +publication boundary. A closed trusted action, semantic identity, and source +version derive the fingerprint; visible prose never participates. The shared +coordinator creates at most one notification for that fingerprint, re-lists to +heal concurrent creation, and reports `created` or `reused` with content-free +identity evidence in the repository-locale Job Summary. 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/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 31d45c655..fbcdd386d 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -246,6 +246,7 @@ "rules": [ { "files": [ + "src/application/policies/publication_identity_policy.ts", "src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts" ], "mode": "each", @@ -255,6 +256,7 @@ "files": [ "src/application/usecases/steps/common/reply_publication_workflow.ts", "src/application/usecases/steps/common/status_card_publication_workflow.ts", + "src/application/usecases/steps/common/transition_notification_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" @@ -263,7 +265,7 @@ "thresholdProfile": "default" } ], - "successMessage": "semantic duplicate reconciliation coverage: PASS (cleanup workflow 100%; guarded publication path 95% lines/statements, 90% branches/functions)" + "successMessage": "semantic duplicate reconciliation coverage: PASS (identity/cleanup policies 100%; guarded publication path 95% lines/statements, 90% branches/functions)" }, { "name": "Semantic publication freshness", diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 786d0b026..f0b9cc8f9 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 | 187 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 | 189 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/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/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) +- 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/transition_notification_workflow.ts`](../src/application/usecases/steps/common/transition_notification_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__/transition_notification_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/transition_notification_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 673a9f6e1..de969ee6c 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/transition_notification_workflow.ts", "src/application/usecases/steps/common/duplicate_comment_cleanup_workflow.ts", "src/actions/local_action.ts", "src/actions/local_action_output.ts", @@ -153,6 +154,7 @@ "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__/transition_notification_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", diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index a42200105..7e6657d30 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -4,7 +4,8 @@ - Date: 2026-09-14 - Catalog capability ID: github-communication-experience - Last verified: 2026-09-15 for the delivered shared publication, progress - source-freshness, exact-duplicate cleanup, branch-sync, + source-freshness, exact-duplicate cleanup, transition-notification substrate, + branch-sync, review-context, Bugbot, deployment, setup-doctor, generic Job Summary, application-error, explicit-request, and local-result slices; remaining clauses are prospective @@ -363,6 +364,24 @@ For a `status` intent the application MUST: Historic generic comments and comments without an exact owned marker MUST NOT be deleted or rewritten. +For a `transition` intent the application MUST: + +1. derive the fingerprint from a closed trusted action, exact semantic identity, + and trusted source version—never from visible or model-generated prose; +2. accept only a non-empty deterministic message no longer than 400 characters + with at most two Markdown links and no embedded HTML marker; +3. list comments matching the exact identity, fingerprint, valid transition + marker, and configured bot identity; +4. create only when no match exists, then re-list to detect concurrent creation; +5. keep the lowest exact bot-owned comment ID as the immutable notification; +6. delete later exact duplicates, or replace them with a localized compact + pointer when deletion is forbidden; and +7. report topic, target, `created` or `reused`, fingerprint, and bounded cleanup + evidence in the repository-locale Job Summary without copying message bodies. + +A renderer or translation change MUST NOT rewrite or recreate an already-issued +notification for the same fingerprint. Human and third-party markers are inert. + ### 6.5 Stable identity and source freshness The shared marker format is: @@ -371,6 +390,12 @@ The shared marker format is: ``` +Action notifications use a distinct immutable envelope: + +```html + +``` + - Allowed topics at launch are `plan`, `progress`, `branch-sync`, `bugbot`, `release`, `inactivity`, and `access-policy`. - Marker values MUST be generated from trusted domain data, restricted to @@ -1031,6 +1056,16 @@ 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. +The shared transition-notification substrate now adds a strict immutable marker, +a fingerprint derived only from closed trusted transition facts, and a bounded +reconciler over the existing semantic issue-comment publication port. It creates +once, re-lists after creation, keeps the lowest exact bot-owned ID, removes or +compacts concurrent duplicates, and never rewrites an issued notification when +copy changes. The generic Job Summary projects only topic, target, effect, and +fingerprint in the configured repository locale. No feature is considered +migrated merely because this shared substrate exists; branch-sync adoption is a +separate rollout slice. + 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 d3c5fa09b..21bbd1f1b 100644 --- a/src/application/policies/__tests__/action_summary_policy.test.ts +++ b/src/application/policies/__tests__/action_summary_policy.test.ts @@ -249,6 +249,41 @@ describe('action summary policy', () => { expect(complete).toContain('retained compact pointers for 2 comments (IDs: 12, 15)'); }); + it('reports content-free action-notification decisions in the repository locale', () => { + const result = new Result({ + id: 'ObserveBranchSyncUseCase', success: true, executed: true, + payload: { publicationTransition: { + topic: 'branch-sync', target: 'issue:7', effect: 'created', fingerprint: '0123abcd', + } }, + }); + + expect(buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'push', issueNumber: 7, + pullRequestNumber: -1, results: [result], + })).toContain('| Action notifications | branch-sync on issue:7: created (fingerprint 0123abcd) |'); + + expect(buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'push', issueNumber: 7, + pullRequestNumber: -1, results: [new Result({ + id: 'Replay', success: true, executed: true, + payload: { publicationTransition: { + topic: 'branch-sync', target: 'issue:7', effect: 'unchanged', fingerprint: '0123abcd', + } }, + })], + locale: { repository: 'es-ES', issue: 'es-ES', pullRequest: 'es-ES' }, + }, resolveStaticActionSummaryCatalog('es-ES'))) + .toContain('| Notificaciones de acción | branch-sync en issue:7: reutilizada (huella 0123abcd) |'); + + const failed = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'push', issueNumber: 7, + pullRequestNumber: -1, results: [new Result({ + id: 'FailedPublication', success: false, executed: true, + payload: result.payload, + })], + }); + expect(failed).not.toContain('Action notifications'); + }); + 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_identity_policy.test.ts b/src/application/policies/__tests__/publication_identity_policy.test.ts index a9c38dc37..7d31ca7fb 100644 --- a/src/application/policies/__tests__/publication_identity_policy.test.ts +++ b/src/application/policies/__tests__/publication_identity_policy.test.ts @@ -2,9 +2,12 @@ import { buildDuplicateMarker, buildPublicationMarker, buildPublicationReplyMarker, + buildPublicationTransitionMarker, createSemanticDigest, + createTransitionFingerprint, parsePublicationMarker, parsePublicationReplyMarker, + parsePublicationTransitionMarker, readablePublicationReplyCorrelationIds, } from '../publication_identity_policy'; @@ -94,4 +97,66 @@ describe('publication identity policy', () => { 'event:0123abcd', ]); }); + + it('round-trips strict issue and pull-request transition markers', () => { + const transition = { + identity: { topic: 'branch-sync' as const, target: { kind: 'issue' as const, number: 42 }, key: 'develop:feature-42' }, + fingerprint: '0123abcd', + messageKey: 'branch-sync-action-required', + }; + expect(parsePublicationTransitionMarker(buildPublicationTransitionMarker(transition))).toEqual(transition); + + const pullRequest = { + ...transition, + identity: { ...transition.identity, target: { kind: 'pull-request' as const, number: 9 } }, + }; + expect(parsePublicationTransitionMarker(buildPublicationTransitionMarker(pullRequest))?.identity.target) + .toEqual({ kind: 'pull-request', number: 9 }); + }); + + it.each([ + undefined, + null, + '', + '', + '', + '', + '', + ])('treats malformed transition markers as inert: %p', (body) => { + expect(parsePublicationTransitionMarker(body)).toBeUndefined(); + }); + + it('rejects unsafe transition-marker construction', () => { + const transition = { + identity: { topic: 'branch-sync' as const, target: { kind: 'issue' as const, number: 42 }, key: 'sync' }, + fingerprint: '0123abcd', + messageKey: 'action', + }; + expect(() => buildPublicationTransitionMarker({ + ...transition, identity: { ...transition.identity, key: 'unsafe key' }, + })).toThrow('unsafe identity'); + expect(() => buildPublicationTransitionMarker({ ...transition, fingerprint: 'invalid' })) + .toThrow('invalid fingerprint'); + expect(() => buildPublicationTransitionMarker({ + ...transition, identity: { ...transition.identity, target: { kind: 'issue', number: 0 } }, + })).toThrow('positive safe integer'); + }); + + it('derives stable transition fingerprints from closed trusted facts', () => { + const identity = { topic: 'branch-sync' as const, target: { kind: 'issue' as const, number: 42 }, key: 'develop:feature-42' }; + const fingerprint = createTransitionFingerprint(identity, 'branch-sync-required', 'head:abc1234'); + + expect(fingerprint).toHaveLength(16); + expect(createTransitionFingerprint(identity, 'branch-sync-required', 'head:abc1234')).toBe(fingerprint); + expect(createTransitionFingerprint(identity, 'branch-sync-required', 'head:def5678')).not.toBe(fingerprint); + expect(createTransitionFingerprint({ ...identity, key: 'develop:feature-43' }, 'branch-sync-required', 'head:abc1234')) + .not.toBe(fingerprint); + expect(() => createTransitionFingerprint(identity, 'branch-sync-required', 'unsafe/source')) + .toThrow('unsafe identity'); + expect(() => createTransitionFingerprint(identity, 'unknown' as never, 'head:abc1234')) + .toThrow('unknown action'); + expect(() => createTransitionFingerprint({ + ...identity, target: { kind: 'issue', number: Number.NaN }, + }, 'branch-sync-required', 'head:abc1234')).toThrow('positive safe integer'); + }); }); diff --git a/src/application/policies/__tests__/publication_outcome_policy.test.ts b/src/application/policies/__tests__/publication_outcome_policy.test.ts index 841234f86..c86198cdd 100644 --- a/src/application/policies/__tests__/publication_outcome_policy.test.ts +++ b/src/application/policies/__tests__/publication_outcome_policy.test.ts @@ -2,8 +2,10 @@ import { Result } from '../../../data/model/result'; import { buildDuplicateCompactionPublicationPayload, buildStaleSourcePublicationPayload, + buildTransitionPublicationPayload, duplicateCompactionPublicationOutcomes, hasStaleSourcePublicationOutcome, + transitionPublicationOutcomes, } from '../publication_outcome_policy'; describe('publication outcome policy', () => { @@ -83,4 +85,62 @@ describe('publication outcome policy', () => { expect(Object.isFrozen(projected)).toBe(true); expect(Object.isFrozen(projected[0])).toBe(true); }); + + it('builds immutable, content-free transition evidence', () => { + const payload = buildTransitionPublicationPayload({ + kind: 'transition', + identity: { topic: 'branch-sync', target: { kind: 'pull-request', number: 12 }, key: 'develop:feature-12' }, + fingerprint: '0123abcd', + messageKey: 'branch-sync-action-required', + locale: 'en-US', + values: { ignoredPublicCopy: 'never projected' }, + }, 'created'); + + expect(payload).toEqual({ publicationTransition: { + topic: 'branch-sync', target: 'pr:12', effect: 'created', fingerprint: '0123abcd', + } }); + expect(JSON.stringify(payload)).not.toContain('ignoredPublicCopy'); + expect(Object.isFrozen(payload)).toBe(true); + expect(Object.isFrozen(payload.publicationTransition)).toBe(true); + }); + + it('projects only strict transition evidence', () => { + const valid = { publicationTransition: { + topic: 'branch-sync', target: 'issue:7', effect: 'unchanged', fingerprint: '0123abcd', + } }; + const malformed = [ + null, + 'invalid', + { topic: 'unknown', target: 'issue:7', effect: 'created', fingerprint: '0123abcd' }, + { topic: 'branch-sync', target: 'repository:7', effect: 'created', fingerprint: '0123abcd' }, + { topic: 'branch-sync', target: 'issue:0', effect: 'created', fingerprint: '0123abcd' }, + { topic: 'branch-sync', target: `issue:${Number.MAX_SAFE_INTEGER}0`, effect: 'created', fingerprint: '0123abcd' }, + { topic: 'branch-sync', target: 'issue:7', effect: 'updated', fingerprint: '0123abcd' }, + { topic: 'branch-sync', target: 'issue:7', effect: 'created', fingerprint: 'invalid' }, + ]; + const projected = transitionPublicationOutcomes([ + new Result({ id: 'valid', success: true, executed: true, payload: valid }), + new Result({ id: 'failed', success: false, executed: true, payload: valid }), + new Result({ id: 'skipped', success: true, executed: false, payload: valid }), + ...malformed.map((publicationTransition, index) => new Result({ + id: `invalid-${index}`, success: true, executed: true, payload: { publicationTransition }, + })), + ]); + + expect(projected).toEqual([valid.publicationTransition]); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected[0])).toBe(true); + }); + + it('bounds transition evidence projected into operator UX', () => { + const results = Array.from({ length: 25 }, (_, index) => new Result({ + id: `transition-${index}`, success: true, executed: true, + payload: { publicationTransition: { + topic: 'branch-sync', target: `issue:${index + 1}`, + effect: 'created', fingerprint: index.toString(16).padStart(8, '0'), + } }, + })); + + expect(transitionPublicationOutcomes(results)).toHaveLength(20); + }); }); diff --git a/src/application/policies/action_summary_message_catalog.ts b/src/application/policies/action_summary_message_catalog.ts index 2032cf6e5..84a60c391 100644 --- a/src/application/policies/action_summary_message_catalog.ts +++ b/src/application/policies/action_summary_message_catalog.ts @@ -21,7 +21,8 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', 'sourceFreshness', 'staleSourceSuppressed', - 'duplicateCleanup', + 'duplicateCleanup', 'actionNotifications', 'actionNotificationCreated', + 'actionNotificationReused', 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', @@ -36,6 +37,7 @@ const TEMPLATE_MESSAGE_IDS = Object.freeze([ 'summary.duplicateCleanup.single', 'summary.duplicateCleanup.multiple', 'summary.duplicateCleanup.bounded', + 'summary.actionNotification.entry', ] as const); const FINDING_STATE_KEYS = Object.freeze([ @@ -79,6 +81,9 @@ const ENGLISH_SIMPLE: Readonly> = Object.freeze sourceFreshness: 'Source freshness', staleSourceSuppressed: 'Stale result suppressed; branch HEAD changed during the run', duplicateCleanup: 'Duplicate cleanup', + actionNotifications: 'Action notifications', + actionNotificationCreated: 'created', + actionNotificationReused: 'reused', resultDetails: 'Failure details', localization: 'Localization', repositoryLocale: 'Repository locale', @@ -117,6 +122,9 @@ const SPANISH_SIMPLE: Readonly> = Object.freeze sourceFreshness: 'Vigencia del origen', staleSourceSuppressed: 'Resultado obsoleto omitido; el HEAD de la rama cambió durante la ejecución', duplicateCleanup: 'Limpieza de duplicados', + actionNotifications: 'Notificaciones de acción', + actionNotificationCreated: 'creada', + actionNotificationReused: 'reutilizada', resultDetails: 'Detalles del fallo', localization: 'Localización', repositoryLocale: 'Locale del repositorio', @@ -147,6 +155,7 @@ const ENGLISH_TEMPLATES: Readonly> = O '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})', + 'summary.actionNotification.entry': '{topic} on {target}: {effect} (fingerprint {fingerprint})', }); const SPANISH_TEMPLATES: Readonly> = Object.freeze({ @@ -157,6 +166,7 @@ const SPANISH_TEMPLATES: Readonly> = O '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})', + 'summary.actionNotification.entry': '{topic} en {target}: {effect} (huella {fingerprint})', }); 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 1fcbe6f25..7ffcd78d5 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -21,6 +21,7 @@ import type { ApplicationErrorMessageReader } from './application_error_message_ import { duplicateCompactionPublicationOutcomes, hasStaleSourcePublicationOutcome, + transitionPublicationOutcomes, } from './publication_outcome_policy'; export interface ActionSummaryContext { @@ -73,6 +74,7 @@ export function buildActionSummary( const bugbotTelemetry = telemetryProjection.status === 'valid' ? telemetryProjection.telemetry : undefined; const staleSourceSuppressed = hasStaleSourcePublicationOutcome(context.results); const duplicateCompactions = duplicateCompactionPublicationOutcomes(context.results); + const transitions = transitionPublicationOutcomes(context.results); const hasActionableFindings = findingStates ? countActionableBugbotFindings(findingStates) > 0 : false; const hasUnknownFindings = findingStateProjection.status === 'invalid' || (findingStates?.unknown ?? 0) > 0; const status = resolveActionSummaryStatus({ @@ -100,6 +102,9 @@ export function buildActionSummary( ...(duplicateCompactions.length > 0 ? [ `| ${catalogText(catalog, 'summary.duplicateCleanup')} | ${formatDuplicateCompactions(duplicateCompactions, catalog)} |`, ] : []), + ...(transitions.length > 0 ? [ + `| ${catalogText(catalog, 'summary.actionNotifications')} | ${formatTransitionPublications(transitions, catalog)} |`, + ] : []), ]; const localization = renderLocalizationSummarySection( context.locale, @@ -125,6 +130,22 @@ export function buildActionSummary( ].join('\n'); } +function formatTransitionPublications( + outcomes: ReturnType, + catalog: ActionSummaryMessageCatalog, +): string { + return outcomes.map(outcome => catalogText(catalog, 'summary.actionNotification.entry', { + topic: outcome.topic, + target: outcome.target, + effect: catalog.message( + outcome.effect === 'created' + ? 'summary.actionNotificationCreated' + : 'summary.actionNotificationReused', + ), + fingerprint: outcome.fingerprint, + })).join('; '); +} + function formatDuplicateCompactions( outcomes: ReturnType, catalog: ActionSummaryMessageCatalog, diff --git a/src/application/policies/publication_identity_policy.ts b/src/application/policies/publication_identity_policy.ts index 9d6a0c9c5..d601eda5d 100644 --- a/src/application/policies/publication_identity_policy.ts +++ b/src/application/policies/publication_identity_policy.ts @@ -4,12 +4,18 @@ import { publicationTargetToken, type PublicationIdentity, type PublicationTopic, + type TransitionPublicationIntent, } from '../../domain/github_publication'; export const PUBLICATION_SCHEMA = '1'; export const PUBLICATION_MARKER_PREFIX = 'copilot:publication'; export const PUBLICATION_DUPLICATE_MARKER_PREFIX = 'copilot:publication-duplicate'; export const PUBLICATION_REPLY_MARKER_PREFIX = 'copilot:reply'; +export const PUBLICATION_TRANSITION_MARKER_PREFIX = 'copilot:transition'; +export const TRANSITION_FINGERPRINT_ACTIONS = Object.freeze([ + 'branch-sync-required', +] as const); +export type TransitionFingerprintAction = typeof TRANSITION_FINGERPRINT_ACTIONS[number]; const SAFE_VALUE = /^[A-Za-z0-9._:-]{1,128}$/u; const DIGEST = /^[a-f0-9]{8,64}$/u; @@ -26,10 +32,36 @@ export interface PublicationReplyMarker { readonly digest: string; } +export interface PublicationTransitionMarker { + readonly identity: PublicationIdentity; + readonly fingerprint: string; + readonly messageKey: string; +} + export function createSemanticDigest(value: unknown): string { return createHash('sha256').update(stableSerialize(value), 'utf8').digest('hex').slice(0, 16); } +/** Derives a notification identity exclusively from trusted, bounded transition facts. */ +export function createTransitionFingerprint( + identity: PublicationIdentity, + action: TransitionFingerprintAction, + sourceVersion: string, +): string { + const target = publicationTargetToken(identity.target); + for (const value of [identity.topic, target, identity.key, action, sourceVersion]) { + if (!SAFE_VALUE.test(value)) throw new Error('Transition fingerprint contains an unsafe identity value.'); + } + if (!TRANSITION_FINGERPRINT_ACTIONS.includes(action)) { + throw new Error('Transition fingerprint contains an unknown action.'); + } + return createSemanticDigest({ + action, + identity: { key: identity.key, target, topic: identity.topic }, + sourceVersion, + }); +} + export function buildPublicationMarker(marker: PublicationMarker): string { const target = publicationTargetToken(marker.identity.target); for (const value of [marker.identity.topic, target, marker.identity.key, marker.sourceVersion]) { @@ -73,6 +105,40 @@ export function parsePublicationReplyMarker(body: string | null | undefined): Pu return Object.freeze({ target: match[1], correlationId: match[2], messageKey: match[3], digest: match[4] }); } +export function buildPublicationTransitionMarker( + intent: Pick, +): string { + const target = publicationTargetToken(intent.identity.target); + for (const value of [intent.identity.topic, target, intent.identity.key, intent.messageKey]) { + if (!SAFE_VALUE.test(value)) throw new Error('Publication transition marker contains an unsafe identity value.'); + } + if (!DIGEST.test(intent.fingerprint)) { + throw new Error('Publication transition marker contains an invalid fingerprint.'); + } + return ``; +} + +export function parsePublicationTransitionMarker( + body: string | null | undefined, +): PublicationTransitionMarker | undefined { + if (typeof body !== 'string') return undefined; + const match = body.match(//u); + if (!match) return undefined; + const topic = match[1] as PublicationTopic; + if (!PUBLICATION_TOPICS.includes(topic)) return undefined; + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number < 1) return undefined; + return Object.freeze({ + identity: Object.freeze({ + topic, + target: Object.freeze({ kind: match[2] === 'pr' ? 'pull-request' : 'issue', number }), + key: match[4], + }), + fingerprint: match[5], + messageKey: match[6], + }); +} + /** * Reads the stable issue-comment identity plus the short-lived namespaced form * emitted during migration. Review-comment identities remain transport-scoped. diff --git a/src/application/policies/publication_outcome_policy.ts b/src/application/policies/publication_outcome_policy.ts index 276135bea..700e3b60f 100644 --- a/src/application/policies/publication_outcome_policy.ts +++ b/src/application/policies/publication_outcome_policy.ts @@ -1,4 +1,10 @@ import { getResultPayload, type Result } from '../../data/model/result'; +import { + PUBLICATION_TOPICS, + publicationTargetToken, + type PublicationTopic, + type TransitionPublicationIntent, +} from '../../domain/github_publication'; export interface StaleSourcePublicationOutcome { readonly reason: 'stale-source'; @@ -20,7 +26,19 @@ export interface PublicationCleanupPayload { readonly publicationCleanup: DuplicateCompactionPublicationOutcome; } +export interface TransitionPublicationOutcome { + readonly topic: PublicationTopic; + readonly target: string; + readonly effect: 'created' | 'unchanged'; + readonly fingerprint: string; +} + +export interface TransitionPublicationPayload { + readonly publicationTransition: TransitionPublicationOutcome; +} + const MAX_REPORTED_COMMENT_IDS = 20; +const MAX_REPORTED_TRANSITIONS = 20; /** Builds bounded evidence for a commit-derived result that was intentionally suppressed. */ export function buildStaleSourcePublicationPayload( @@ -81,6 +99,54 @@ export function duplicateCompactionPublicationOutcomes( })); } +/** Builds content-free, bounded evidence for one action-notification decision. */ +export function buildTransitionPublicationPayload( + intent: TransitionPublicationIntent, + effect: TransitionPublicationOutcome['effect'], +): Readonly { + return Object.freeze({ + publicationTransition: Object.freeze({ + topic: intent.identity.topic, + target: publicationTargetToken(intent.identity.target), + effect, + fingerprint: intent.fingerprint, + }), + }); +} + +export function transitionPublicationOutcomes( + results: readonly Result[], +): readonly TransitionPublicationOutcome[] { + return Object.freeze(results.flatMap(result => { + if (!result.success || !result.executed) return []; + const payload = getResultPayload(result.payload); + const transition = getResultPayload(payload?.publicationTransition); + if (!transition + || typeof transition.topic !== 'string' + || !PUBLICATION_TOPICS.includes(transition.topic as PublicationTopic) + || typeof transition.target !== 'string' + || !isPublicationTargetToken(transition.target) + || transition.effect !== 'created' && transition.effect !== 'unchanged' + || typeof transition.fingerprint !== 'string' + || !/^[a-f0-9]{8,64}$/u.test(transition.fingerprint)) { + return []; + } + return [Object.freeze({ + topic: transition.topic as PublicationTopic, + target: transition.target, + effect: transition.effect, + fingerprint: transition.fingerprint, + })]; + }).slice(0, MAX_REPORTED_TRANSITIONS)); +} + +function isPublicationTargetToken(value: string): boolean { + const match = value.match(/^(?:issue|pr):([1-9]\d*)$/u); + if (!match) return false; + const number = Number(match[1]); + return Number.isSafeInteger(number) && number > 0; +} + function isPositiveInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } diff --git a/src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts b/src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts new file mode 100644 index 000000000..67397b606 --- /dev/null +++ b/src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts @@ -0,0 +1,174 @@ +import { + MAX_TRANSITION_NOTIFICATION_CHARACTERS, + reconcileTransitionNotification, + renderTransitionNotification, +} from '../transition_notification_workflow'; +import type { TransitionPublicationIntent } from '../../../../../domain/github_publication'; + +function intent(overrides: Partial = {}): TransitionPublicationIntent { + return { + kind: 'transition', + identity: { topic: 'branch-sync', target: { kind: 'issue', number: 7 }, key: 'develop:feature-7' }, + fingerprint: '0123abcd', + messageKey: 'branch-sync-action-required', + locale: 'en-US', + values: {}, + ...overrides, + }; +} + +function ports(initial: Array<{ id: number; body: string | null; user?: { login?: string } }> = []) { + const comments = initial.map(value => ({ ...value })); + let next = Math.max(0, ...comments.map(comment => comment.id)) + 1; + return { + comments, + listIssueComments: jest.fn(async () => comments.map(comment => ({ ...comment }))), + addComment: jest.fn(async (_issue: number, body: string) => { + comments.push({ id: next++, body, user: { login: 'vypbot' } }); + }), + updateComment: jest.fn(async (_issue: number, id: number, body: string) => { + 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'; + }), + }; +} + +const message = 'Branch synchronization needs attention. [Open the current status](https://github.com/acme/widgets/issues/7#issuecomment-4).'; +const context = (value = intent()) => ({ + owner: 'acme', repository: 'widgets', botLogin: 'vypbot', intent: value, message, + duplicatePointer: (url: string) => `This duplicate notification was suppressed. [View the original notification](${url}).`, +}); + +describe('transition notification workflow', () => { + it('creates and discovers one canonical notification', async () => { + const repository = ports(); + + await expect(reconcileTransitionNotification(context(), repository)).resolves.toEqual({ + effect: 'created', canonicalCommentId: 1, + duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); + expect(repository.addComment).toHaveBeenCalledTimes(1); + expect(repository.comments[0].body).toContain('copilot:transition'); + expect(repository.comments[0].body).toContain(message); + }); + + it('reuses the same fingerprint without rewriting immutable notification copy', async () => { + const value = intent(); + const repository = ports([{ + id: 4, body: renderTransitionNotification(value, message), user: { login: 'VypBot' }, + }]); + + await expect(reconcileTransitionNotification({ ...context(value), message: 'New renderer copy.' }, repository)) + .resolves.toMatchObject({ effect: 'unchanged', canonicalCommentId: 4 }); + expect(repository.addComment).not.toHaveBeenCalled(); + expect(repository.updateComment).not.toHaveBeenCalled(); + expect(repository.comments[0].body).toContain(message); + }); + + it('creates a notification for a new fingerprint', async () => { + const previous = intent(); + const current = intent({ fingerprint: '89abcdef' }); + const repository = ports([{ + id: 4, body: renderTransitionNotification(previous, message), user: { login: 'vypbot' }, + }]); + + await expect(reconcileTransitionNotification(context(current), repository)).resolves.toMatchObject({ + effect: 'created', canonicalCommentId: 5, + }); + expect(repository.comments).toHaveLength(2); + }); + + it('ignores forged, third-party, malformed, and different-identity markers', async () => { + const value = intent(); + const body = renderTransitionNotification(value, message); + const repository = ports([ + { id: 1, body, user: { login: 'human' } }, + { id: 2, body: 'not a marker', user: { login: 'vypbot' } }, + { id: 3, body: body.replace('key="develop:feature-7"', 'key="other"'), user: { login: 'vypbot' } }, + { id: 4, body }, + ]); + + await reconcileTransitionNotification(context(value), repository); + + expect(repository.addComment).toHaveBeenCalledTimes(1); + expect(repository.removeComment).not.toHaveBeenCalled(); + }); + + it('keeps the lowest exact bot-owned id and removes later concurrent duplicates', async () => { + const body = renderTransitionNotification(intent(), message); + const repository = ports([ + { id: 9, body, user: { login: 'vypbot' } }, + { id: 4, body: body.replace('message="branch-sync-action-required"', 'message="renamed-renderer"'), user: { login: 'vypbot' } }, + ]); + + await expect(reconcileTransitionNotification(context(), repository)).resolves.toEqual({ + effect: 'unchanged', canonicalCommentId: 4, + duplicatesRemoved: 1, duplicatesCompacted: 0, compactedCommentIds: [], + }); + expect(repository.removeComment).toHaveBeenCalledWith(7, 9); + expect(repository.comments.map(comment => comment.id)).toEqual([4]); + }); + + it('compacts a duplicate when deletion is explicitly forbidden', async () => { + const value = intent({ identity: { topic: 'branch-sync', target: { kind: 'pull-request', number: 8 }, key: 'develop:feature-8' } }); + const body = renderTransitionNotification(value, message); + const repository = ports([ + { id: 3, body, user: { login: 'vypbot' } }, + { id: 6, body, user: { login: 'vypbot' } }, + ]); + repository.removeComment.mockResolvedValue('compaction-required'); + + await expect(reconcileTransitionNotification(context(value), repository)).resolves.toEqual({ + effect: 'unchanged', canonicalCommentId: 3, + duplicatesRemoved: 0, duplicatesCompacted: 1, compactedCommentIds: [6], + }); + expect(repository.comments[1].body).toContain('copilot:publication-duplicate'); + expect(repository.comments[1].body).toContain('/pull/8#issuecomment-3'); + + await reconcileTransitionNotification(context(value), repository); + expect(repository.removeComment).toHaveBeenCalledTimes(1); + }); + + it('fails closed without a trusted bot identity', async () => { + const repository = ports(); + + await expect(reconcileTransitionNotification({ ...context(), botLogin: ' ' }, repository)).resolves.toEqual({ + effect: 'unchanged', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); + expect(repository.listIssueComments).not.toHaveBeenCalled(); + }); + + it('tolerates create visibility lag without inventing a comment id', async () => { + const repository = ports(); + repository.addComment.mockImplementation(async () => undefined); + + await expect(reconcileTransitionNotification(context(), repository)).resolves.toEqual({ + effect: 'created', duplicatesRemoved: 0, duplicatesCompacted: 0, compactedCommentIds: [], + }); + }); + + it.each([ + ['', 'must not be empty'], + ['x'.repeat(MAX_TRANSITION_NOTIFICATION_CHARACTERS + 1), 'character budget'], + ['[one](https://example.com/1) [two](https://example.com/2) [three](https://example.com/3)', 'link budget'], + ['Visible marker', 'HTML marker'], + ])('rejects invalid public copy before reading or writing: %s', async (invalid, error) => { + const repository = ports(); + + await expect(reconcileTransitionNotification({ ...context(), message: invalid }, repository)).rejects.toThrow(error); + expect(repository.listIssueComments).not.toHaveBeenCalled(); + }); + + it('propagates provider failures without a compensating domain mutation', async () => { + const repository = ports(); + repository.listIssueComments.mockRejectedValue(new Error('provider unavailable')); + + await expect(reconcileTransitionNotification(context(), repository)).rejects.toThrow('provider unavailable'); + expect(repository.addComment).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/usecases/steps/common/transition_notification_workflow.ts b/src/application/usecases/steps/common/transition_notification_workflow.ts new file mode 100644 index 000000000..9983cffba --- /dev/null +++ b/src/application/usecases/steps/common/transition_notification_workflow.ts @@ -0,0 +1,141 @@ +import type { PublicationIdentity, TransitionPublicationIntent } from '../../../../domain/github_publication'; +import { publicationIdentityEquals } from '../../../../domain/github_publication'; +import { githubUsersMatch } from '../../../../domain/github_user_policy'; +import type { + BoundIssueCommentPublicationPort, + IssueCommentPublicationTarget, +} from '../../../ports/issue_lifecycle_ports'; +import { + buildDuplicateMarker, + buildPublicationTransitionMarker, + parsePublicationTransitionMarker, +} from '../../../policies/publication_identity_policy'; +import { cleanupDuplicateComment } from './duplicate_comment_cleanup_workflow'; + +export const MAX_TRANSITION_NOTIFICATION_CHARACTERS = 400; +export const MAX_TRANSITION_NOTIFICATION_LINKS = 2; + +export interface TransitionNotificationContext { + readonly owner: string; + readonly repository: string; + readonly botLogin: string; + readonly intent: TransitionPublicationIntent; + readonly message: string; + readonly duplicatePointer: (canonicalCommentUrl: string) => string; +} + +export interface TransitionNotificationOutcome { + readonly effect: 'created' | 'unchanged'; + readonly canonicalCommentId?: number; + readonly duplicatesRemoved: number; + readonly duplicatesCompacted: number; + readonly compactedCommentIds: readonly number[]; +} + +/** Creates one immutable action notification per exact transition fingerprint. */ +export async function reconcileTransitionNotification( + context: TransitionNotificationContext, + comments: BoundIssueCommentPublicationPort, +): Promise { + if (!context.botLogin.trim()) return unchangedOutcome(); + const target = context.intent.identity.target; + const rendered = renderTransitionNotification(context.intent, context.message); + let owned = ownedNotifications( + await comments.listIssueComments(target.number), + context.intent.identity, + context.intent.fingerprint, + context.botLogin, + ); + let effect: TransitionNotificationOutcome['effect'] = 'unchanged'; + if (owned.length === 0) { + await comments.addComment(target.number, rendered); + effect = 'created'; + owned = ownedNotifications( + await comments.listIssueComments(target.number), + context.intent.identity, + context.intent.fingerprint, + context.botLogin, + ); + } + if (owned.length === 0) return outcome(effect); + + 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) { + const pointer = validateVisibleMessage(context.duplicatePointer(canonicalCommentUrl(context, canonical.id))); + const cleanup = await cleanupDuplicateComment({ + issueNumber: target.number, + duplicateCommentId: duplicate.id, + compactBody: [buildDuplicateMarker(canonical.id), '', pointer].join('\n'), + }, comments); + if (cleanup === 'removed') duplicatesRemoved += 1; + if (cleanup === 'compacted') { + duplicatesCompacted += 1; + compactedCommentIds.push(duplicate.id); + } + } + return outcome(effect, canonical.id, duplicatesRemoved, duplicatesCompacted, compactedCommentIds); +} + +function canonicalCommentUrl(context: TransitionNotificationContext, commentId: number): string { + const target = context.intent.identity.target; + const targetPath = target.kind === 'pull-request' ? 'pull' : 'issues'; + return `https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)}/${targetPath}/${target.number}#issuecomment-${commentId}`; +} + +export function renderTransitionNotification(intent: TransitionPublicationIntent, message: string): string { + return [buildPublicationTransitionMarker(intent), '', validateVisibleMessage(message)].join('\n'); +} + +function validateVisibleMessage(message: string): string { + const normalized = message.trim(); + if (!normalized) throw new Error('Transition notification message must not be empty.'); + if (normalized.length > MAX_TRANSITION_NOTIFICATION_CHARACTERS) { + throw new Error('Transition notification message exceeds the character budget.'); + } + const links = normalized.match(/\[[^\]]*\]\([^)]*\)/gu) ?? []; + if (links.length > MAX_TRANSITION_NOTIFICATION_LINKS) { + throw new Error('Transition notification message exceeds the link budget.'); + } + if (normalized.includes('