From 00920a9e3210b9de7abf394188c759723330d484 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 00:00:46 +0200 Subject: [PATCH 1/3] codex-localized-action-summary: localize GitHub action summaries --- build/github_action/index.js | 309 +++++++++++++++--- docs/development/architecture.mdx | 7 + docs/issues/notifications-and-auto-close.mdx | 6 +- docs/pull-requests/capabilities.mdx | 4 + scripts/coverage-budgets.json | 2 + specs/CATALOG.md | 6 +- specs/catalog.json | 2 + specs/repository-locale-and-localization.md | 11 +- ...tic-github-publication-and-notification.md | 11 +- .../github_action_completion.test.ts | 25 ++ src/actions/github_action_completion.ts | 22 +- .../action_summary_message_catalog.test.ts | 80 +++++ .../__tests__/action_summary_policy.test.ts | 25 ++ .../action_summary_message_catalog.ts | 215 ++++++++++++ .../policies/action_summary_policy.ts | 133 +++++--- .../github_publication_boundaries.test.ts | 1 + 16 files changed, 741 insertions(+), 118 deletions(-) create mode 100644 src/application/policies/__tests__/action_summary_message_catalog.test.ts create mode 100644 src/application/policies/action_summary_message_catalog.ts diff --git a/build/github_action/index.js b/build/github_action/index.js index c4d4e0462..68bdf3366 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -39603,6 +39603,7 @@ const publish_resume_workflow_1 = __nccwpck_require__(55340); const store_configuration_use_case_2 = __nccwpck_require__(84375); const logger_1 = __nccwpck_require__(91151); const action_summary_policy_1 = __nccwpck_require__(72995); +const action_summary_message_catalog_1 = __nccwpck_require__(61544); const copilot_lifecycle_1 = __nccwpck_require__(72418); const copilot_evidence_policy_1 = __nccwpck_require__(47632); const application_error_1 = __nccwpck_require__(75999); @@ -39664,6 +39665,7 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { : locale; let body; let localizationLabels; + let appendLocalizationEvidence = false; if (execution.singleAction.isDeploymentOrchestrationAction && operation) { const effectiveLocale = operation.locale ?? locale; const catalog = await (0, deployment_message_catalog_1.resolveDeploymentCatalog)(effectiveLocale.repository, execution.ai.getAgentConfiguration('planner'), catalogResolver); @@ -39679,6 +39681,7 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { descriptors: messages.descriptors, reason: messages.reason, }; + appendLocalizationEvidence = true; body = (0, deployment_presentation_policy_1.renderDeploymentJobSummary)(operation, { owner: execution.owner, repository: execution.repo, @@ -39693,6 +39696,7 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { }, operation.lastFailure?.previousPhase, catalog); } else { + const catalog = await (0, action_summary_message_catalog_1.resolveActionSummaryCatalog)(locale.repository, execution.ai.getAgentConfiguration('planner'), catalogResolver); body = (0, action_summary_policy_1.buildActionSummary)({ owner: execution.owner, repository: execution.repo, @@ -39711,13 +39715,15 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { }, catalogResolutions: catalogResolver?.observations?.() ?? [], results: execution.currentConfiguration.results, - }); + }, catalog); } - const localizationEvidence = (0, action_summary_policy_1.renderLocalizationSummarySection)({ - repository: summaryLocale.repository, - issue: summaryLocale.issue, - pullRequest: summaryLocale.pullRequest, - }, catalogResolver?.observations?.() ?? [], localizationLabels); + const localizationEvidence = appendLocalizationEvidence + ? (0, action_summary_policy_1.renderLocalizationSummarySection)({ + repository: summaryLocale.repository, + issue: summaryLocale.issue, + pullRequest: summaryLocale.pullRequest, + }, catalogResolver?.observations?.() ?? [], localizationLabels) + : ''; const summaryText = [body, localizationEvidence].filter(Boolean).join('\n\n'); if (!summaryPort) return summaryText; @@ -41118,6 +41124,180 @@ function runAtApplicationErrorBoundary(operation) { } +/***/ }), + +/***/ 61544: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ACTION_SUMMARY_CATALOG_DEFINITIONS = exports.SPANISH_ACTION_SUMMARY_DEFINITION = exports.ENGLISH_ACTION_SUMMARY_DEFINITION = exports.ACTION_SUMMARY_MESSAGE_IDS = void 0; +exports.resolveStaticActionSummaryCatalog = resolveStaticActionSummaryCatalog; +exports.resolveActionSummaryCatalog = resolveActionSummaryCatalog; +const message_catalog_1 = __nccwpck_require__(27097); +const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +const SIMPLE_MESSAGE_KEYS = Object.freeze([ + 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', + 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', + 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', + 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', + 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', + 'none', 'noResult', 'unnamedResult', 'impact', 'cause', 'action', + 'retainedState', 'reference', +]); +const TEMPLATE_MESSAGE_IDS = Object.freeze([ + 'summary.target.pullRequest', + 'summary.target.issue', + 'summary.target.repositoryRun', + 'summary.bugbotTelemetry', +]); +const FINDING_STATE_KEYS = Object.freeze([ + 'open', 'reopened', 'fixed', 'obsolete', 'dismissed', + 'verification-required', 'unknown', +]); +exports.ACTION_SUMMARY_MESSAGE_IDS = Object.freeze([ + ...SIMPLE_MESSAGE_KEYS.map(key => `summary.${key}`), + ...TEMPLATE_MESSAGE_IDS, + ...FINDING_STATE_KEYS.map(key => `summary.findingState.${key}`), +]); +const ENGLISH_SIMPLE = Object.freeze({ + heading: 'Copilot execution', + repository: 'Repository', + property: 'Property', + value: 'Value', + status: 'Status', + event: 'Event', + target: 'Target', + lifecycle: 'Lifecycle', + descriptionPolicy: 'PR description policy', + results: 'Results', + findingStates: 'Finding states', + bugbotReview: 'Bugbot review', + resultDetails: 'Result details', + localization: 'Localization', + repositoryLocale: 'Repository locale', + issueLocale: 'Issue locale', + pullRequestLocale: 'Pull-request locale', + catalogResolution: 'Catalog resolution', + descriptors: 'descriptors', + reason: 'reason', + failure: 'Failure', + findings: 'Findings', + partial: 'Partial', + superseded: 'Superseded', + skipped: 'Skipped', + dryRun: 'Dry run', + success: 'Success', + invalid: 'invalid', + none: 'none', + noResult: 'No application result was produced.', + unnamedResult: 'Unnamed result', + impact: 'Impact', + cause: 'Cause', + action: 'Action', + retainedState: 'Retained state', + reference: 'Reference', +}); +const SPANISH_SIMPLE = Object.freeze({ + heading: 'Ejecución de Copilot', + repository: 'Repositorio', + property: 'Propiedad', + value: 'Valor', + status: 'Estado', + event: 'Evento', + target: 'Destino', + lifecycle: 'Ciclo de vida', + descriptionPolicy: 'Política de descripción de PR', + results: 'Resultados', + findingStates: 'Estados de los hallazgos', + bugbotReview: 'Revisión de Bugbot', + resultDetails: 'Detalles del resultado', + localization: 'Localización', + repositoryLocale: 'Locale del repositorio', + issueLocale: 'Locale de la issue', + pullRequestLocale: 'Locale de la pull request', + catalogResolution: 'Resolución del catálogo', + descriptors: 'descriptores', + reason: 'motivo', + failure: 'Fallo', + findings: 'Hallazgos', + partial: 'Parcial', + superseded: 'Sustituido', + skipped: 'Omitido', + dryRun: 'Simulación', + success: 'Correcto', + invalid: 'no válido', + none: 'ninguno', + noResult: 'No se ha producido ningún resultado de aplicación.', + unnamedResult: 'Resultado sin nombre', + impact: 'Impacto', + cause: 'Causa', + action: 'Acción', + retainedState: 'Estado conservado', + reference: 'Referencia', +}); +const ENGLISH_TEMPLATES = Object.freeze({ + 'summary.target.pullRequest': 'PR #{number}', + 'summary.target.issue': 'Issue #{number}', + 'summary.target.repositoryRun': 'Repository run', + 'summary.bugbotTelemetry': '{outcome}, effort={effort}, {elapsed}ms', +}); +const SPANISH_TEMPLATES = Object.freeze({ + 'summary.target.pullRequest': 'PR n.º {number}', + 'summary.target.issue': 'Issue n.º {number}', + 'summary.target.repositoryRun': 'Ejecución del repositorio', + 'summary.bugbotTelemetry': '{outcome}, esfuerzo={effort}, {elapsed} ms', +}); +const ENGLISH_FINDING_STATES = Object.freeze({ + open: 'open', + reopened: 'reopened', + fixed: 'fixed', + obsolete: 'obsolete', + dismissed: 'dismissed', + 'verification-required': 'verification required', + unknown: 'unknown', +}); +const SPANISH_FINDING_STATES = Object.freeze({ + open: 'abiertos', + reopened: 'reabiertos', + fixed: 'corregidos', + obsolete: 'obsoletos', + dismissed: 'descartados', + 'verification-required': 'requieren verificación', + unknown: 'desconocidos', +}); +function catalogMessages(simple, templates, findingStates) { + return Object.freeze({ + ...Object.fromEntries(SIMPLE_MESSAGE_KEYS.map(key => [`summary.${key}`, simple[key]])), + ...templates, + ...Object.fromEntries(FINDING_STATE_KEYS.map(key => [`summary.findingState.${key}`, findingStates[key]])), + }); +} +exports.ENGLISH_ACTION_SUMMARY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_TEMPLATES, ENGLISH_FINDING_STATES), +}); +exports.SPANISH_ACTION_SUMMARY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: catalogMessages(SPANISH_SIMPLE, SPANISH_TEMPLATES, SPANISH_FINDING_STATES), +}); +exports.ACTION_SUMMARY_CATALOG_DEFINITIONS = Object.freeze([ + exports.ENGLISH_ACTION_SUMMARY_DEFINITION, + exports.SPANISH_ACTION_SUMMARY_DEFINITION, +]); +function resolveStaticActionSummaryCatalog(locale) { + return (0, resolved_message_catalog_policy_1.resolveStaticMessageCatalogView)(locale, exports.ENGLISH_ACTION_SUMMARY_DEFINITION, exports.ACTION_SUMMARY_CATALOG_DEFINITIONS); +} +async function resolveActionSummaryCatalog(locale, configuration, resolver) { + return (0, resolved_message_catalog_policy_1.resolveMessageCatalogView)(locale, exports.ACTION_SUMMARY_MESSAGE_IDS, exports.ENGLISH_ACTION_SUMMARY_DEFINITION, exports.ACTION_SUMMARY_CATALOG_DEFINITIONS, configuration, resolver); +} + + /***/ }), /***/ 72995: @@ -41127,12 +41307,14 @@ function runAtApplicationErrorBoundary(operation) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildActionSummary = buildActionSummary; +exports.actionSummaryLocalizationLabels = actionSummaryLocalizationLabels; exports.renderLocalizationSummarySection = renderLocalizationSummarySection; const github_comment_publication_policy_1 = __nccwpck_require__(72712); const application_error_presentation_policy_1 = __nccwpck_require__(95067); const bugbot_telemetry_projection_policy_1 = __nccwpck_require__(43244); const bugbot_result_finding_state_projection_policy_1 = __nccwpck_require__(98117); const review_state_1 = __nccwpck_require__(79200); +const action_summary_message_catalog_1 = __nccwpck_require__(61544); const ENGLISH_LOCALIZATION_SUMMARY_LABELS = Object.freeze({ heading: 'Localization', property: 'Property', @@ -41144,8 +41326,8 @@ const ENGLISH_LOCALIZATION_SUMMARY_LABELS = Object.freeze({ descriptors: 'descriptors', reason: 'reason', }); -/** Builds a bounded, publication-safe GitHub Actions Job Summary. */ -function buildActionSummary(context) { +/** Builds one bounded, publication-safe, repository-locale GitHub Actions Job Summary. */ +function buildActionSummary(context, catalog = (0, action_summary_message_catalog_1.resolveStaticActionSummaryCatalog)(context.locale?.repository ?? 'en-US')) { const failures = context.results.filter(result => !result.success && result.executed); const findingStateProjection = (0, bugbot_result_finding_state_projection_policy_1.projectBugbotResultFindingStates)(context.results); const findingStates = findingStateProjection.status === 'valid' ? findingStateProjection.counts : undefined; @@ -41159,35 +41341,49 @@ function buildActionSummary(context) { hasActionableFindings, failOnUnresolvedFindings: context.failOnUnresolvedFindings === true, bugbotTelemetry, - }); - const target = resolveActionSummaryTarget(context); + }, catalog); + const target = resolveActionSummaryTarget(context, catalog); const lifecycle = context.lifecycleState ? `\`${(0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(context.lifecycleState, 100)}\`` : '—'; const rows = [ - `| Status | ${status} |`, - `| Event | \`${escapeTable(context.eventName)}\` |`, - `| Target | ${escapeTable(target)} |`, - `| Lifecycle | ${lifecycle} |`, - `| PR description policy | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, - `| Results | ${context.results.length} |`, - `| Finding states | ${formatFindingStates(findingStateProjection)} |`, - `| Bugbot review | ${formatBugbotTelemetry(telemetryProjection)} |`, - ...localizationSummaryRows(context.locale, context.catalogResolutions), + `| ${catalog.message('summary.status')} | ${status} |`, + `| ${catalog.message('summary.event')} | \`${escapeTable(context.eventName)}\` |`, + `| ${catalog.message('summary.target')} | ${escapeTable(target)} |`, + `| ${catalog.message('summary.lifecycle')} | ${lifecycle} |`, + `| ${catalog.message('summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, + `| ${catalog.message('summary.results')} | ${context.results.length} |`, + `| ${catalog.message('summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, + `| ${catalog.message('summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, ]; + const localization = renderLocalizationSummarySection(context.locale, context.catalogResolutions, actionSummaryLocalizationLabels(catalog)); return [ - '# Copilot execution', + `# ${catalog.message('summary.heading')}`, '', - `Repository: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, + `${catalog.message('summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, '', - '| Property | Value |', + `| ${catalog.message('summary.property')} | ${catalog.message('summary.value')} |`, '| --- | --- |', ...rows, '', - '## Result details', + `## ${catalog.message('summary.resultDetails')}`, '', - renderResults(context.results), + renderResults(context.results, catalog), '', + localization, ].join('\n'); } +function actionSummaryLocalizationLabels(catalog) { + return Object.freeze({ + heading: catalog.message('summary.localization'), + property: catalog.message('summary.property'), + value: catalog.message('summary.value'), + repositoryLocale: catalog.message('summary.repositoryLocale'), + issueLocale: catalog.message('summary.issueLocale'), + pullRequestLocale: catalog.message('summary.pullRequestLocale'), + catalogResolution: catalog.message('summary.catalogResolution'), + descriptors: catalog.message('summary.descriptors'), + reason: catalog.message('summary.reason'), + }); +} /** Renders the same content-free locale evidence for summaries owned by specialized workflows. */ function renderLocalizationSummarySection(locale, catalogResolutions = [], labels = ENGLISH_LOCALIZATION_SUMMARY_LABELS) { const rows = localizationSummaryRows(locale, catalogResolutions, labels); @@ -41220,50 +41416,55 @@ function formatCatalogResolutions(observations, labels) { return `\`${escapeTable(observation.requestedLocale)} -> ${escapeTable(observation.resolvedLocale)} (${observation.source}, ${labels.descriptors}=${observation.descriptorCount}${fallback})\``; }).join('
'); } -function resolveActionSummaryStatus(input) { +function resolveActionSummaryStatus(input, catalog) { if (input.failureCount > 0 || input.hasUnknownFindings) - return '❌ Failure'; + return `❌ ${catalog.message('summary.failure')}`; if (input.bugbotTelemetry?.outcome === 'failed') - return '❌ Failure'; + return `❌ ${catalog.message('summary.failure')}`; if (input.hasActionableFindings && input.failOnUnresolvedFindings) - return '❌ Failure'; + return `❌ ${catalog.message('summary.failure')}`; if (input.hasActionableFindings) - return '⚠️ Findings'; + return `⚠️ ${catalog.message('summary.findings')}`; switch (input.bugbotTelemetry?.outcome) { - case 'partial': return '⚠️ Partial'; - case 'superseded': return '⏭️ Superseded'; - case 'skipped': return '⏭️ Skipped'; - case 'dry-run': return '🧪 Dry run'; - default: return '✅ Success'; + case 'partial': return `⚠️ ${catalog.message('summary.partial')}`; + case 'superseded': return `⏭️ ${catalog.message('summary.superseded')}`; + case 'skipped': return `⏭️ ${catalog.message('summary.skipped')}`; + case 'dry-run': return `🧪 ${catalog.message('summary.dryRun')}`; + default: return `✅ ${catalog.message('summary.success')}`; } } -function resolveActionSummaryTarget(context) { - if (context.pullRequestNumber > 0) - return `PR #${context.pullRequestNumber}`; +function resolveActionSummaryTarget(context, catalog) { + if (context.pullRequestNumber > 0) { + return catalog.message('summary.target.pullRequest', { number: context.pullRequestNumber }); + } if (context.issueNumber > 0) - return `Issue #${context.issueNumber}`; - return 'Repository run'; + return catalog.message('summary.target.issue', { number: context.issueNumber }); + return catalog.message('summary.target.repositoryRun'); } -function formatBugbotTelemetry(projection) { +function formatBugbotTelemetry(projection, catalog) { if (projection.status === 'invalid') - return 'invalid'; + return catalog.message('summary.invalid'); if (projection.status === 'absent') return '—'; - return `${escapeTable(projection.telemetry.outcome)}, effort=${escapeTable(projection.telemetry.configuredEffort)}, ${Math.max(0, Math.round(projection.telemetry.elapsedMs))}ms`; + return catalog.message('summary.bugbotTelemetry', { + outcome: escapeTable(projection.telemetry.outcome), + effort: escapeTable(projection.telemetry.configuredEffort), + elapsed: Math.max(0, Math.round(projection.telemetry.elapsedMs)), + }); } -function formatFindingStates(projection) { +function formatFindingStates(projection, catalog) { if (projection.status === 'invalid') - return 'invalid'; + return catalog.message('summary.invalid'); if (projection.status === 'absent') return '—'; return Object.entries(projection.counts) .filter(([, value]) => value > 0) - .map(([state, value]) => `${state}=${value}`) - .join(', ') || 'none'; + .map(([state, value]) => `${catalog.message(`summary.findingState.${state}`)}=${value}`) + .join(', ') || catalog.message('summary.none'); } -function renderResults(results) { +function renderResults(results, catalog) { if (results.length === 0) - return '_No application result was produced._'; + return `_${catalog.message('summary.noResult')}_`; return results.map(result => { const icon = result.success ? '✅' : '❌'; const details = result.steps @@ -41273,14 +41474,14 @@ function renderResults(results) { .flatMap((error) => { const view = (0, application_error_presentation_policy_1.buildApplicationErrorPresentation)(error); return [ - ` - **Impact:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.impact)}`, - ` - **Cause (\`${view.code}\`):** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.cause)}`, - ` - **Action:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.action)}`, - ` - **Retained state:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.retainedState)}`, - ` - **Reference:** \`${view.reference}\``, + ` - **${catalog.message('summary.impact')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.impact)}`, + ` - **${catalog.message('summary.cause')} (\`${view.code}\`):** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.cause)}`, + ` - **${catalog.message('summary.action')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.action)}`, + ` - **${catalog.message('summary.retainedState')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.retainedState)}`, + ` - **${catalog.message('summary.reference')}:** \`${view.reference}\``, ]; }); - return [`- ${icon} **${escapeTable(result.id || 'Unnamed result')}**`, ...details, ...errors].join('\n'); + return [`- ${icon} **${escapeTable(result.id || catalog.message('summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); }).join('\n'); } function escapeTable(value) { diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index f32fc69ba..cda22b067 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -341,3 +341,10 @@ and deployment Job Summary. Provider mutations accept those rendered values but cannot choose phase transitions. Architecture tests enforce the narrow context, provider-neutral port vocabulary, mutation-free renderer, global dependency direction, and acyclic production imports. + +The generic GitHub Actions Job Summary follows the same boundary. One complete +typed catalog is resolved for `repository-locale`; bundled English and Spanish, +arbitrary dynamic locales, and atomic English fallback share the repository +localization resolver. The summary renders localization evidence once, keeps +machine event/state/error values stable, and retains sanitized internal result +detail without publishing it to an issue or pull-request conversation. diff --git a/docs/issues/notifications-and-auto-close.mdx b/docs/issues/notifications-and-auto-close.mdx index d4ce014e8..48920118c 100644 --- a/docs/issues/notifications-and-auto-close.mdx +++ b/docs/issues/notifications-and-auto-close.mdx @@ -16,7 +16,11 @@ current progress, an action, a direct answer, or a terminal policy explanation. When the push workflow runs for a branch linked to an issue, Copilot updates one owned progress card only when the progress projection changes. It does not post a commit list, image, reopen notice, debug report, or generic “Actions” summary. -The workflow Job Summary remains the place for bounded operational detail. +The workflow Job Summary remains the place for bounded operational detail. Its +headings and explanatory labels use `repository-locale` (English by default), +while event names, lifecycle values, error codes, and other machine facts remain +stable. It contains one localization section showing the effective issue and PR +locales plus exact, base-language, dynamic, or English-fallback catalog evidence. - **Where:** the issue associated with the branch (for example, `feature/123-title` → issue `123`). diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index ae0a9abfd..27ba4d2a7 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -102,6 +102,10 @@ one stable `Copilot / Review` Check for the PR head. Actionable findings are `neutral` by default and become `failure` only with `bugbot-fail-on-unresolved: true`; `unknown`, incomplete reconciliation, and analysis failures always fail. Missing Checks capability does not make an otherwise successful functional workflow fail. +The Job Summary uses `repository-locale` for headings and explanatory labels, +defaults to English, and records one non-duplicated localization section. Stable +event, lifecycle, finding-state, and error-code values remain suitable for +operator diagnosis even when the surrounding copy is localized. ## Next steps diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 530cd9a55..45e63cd2f 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -248,6 +248,7 @@ "files": [ "src/domain/locale.ts", "src/domain/message_catalog.ts", + "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/comment_translation_policy.ts", "src/application/policies/merge_queue_message_catalog.ts", "src/application/policies/publication_message_catalog.ts", @@ -264,6 +265,7 @@ "src/actions/github_action_completion.ts", "src/actions/github_action_execution.ts", "src/application/policies/action_summary_policy.ts", + "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/comment_translation_policy.ts", "src/application/policies/copilot_interaction_policy.ts", "src/application/policies/publication_message_catalog.ts", diff --git a/specs/CATALOG.md b/specs/CATALOG.md index e2db33e85..44c369670 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 | 114 paths · 2026-09-14 | +| `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 | 116 paths · 2026-09-14 | | `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) | 23 paths · 2026-09-14 | | `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-14 | @@ -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_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/cli.ts`](../src/cli.ts) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/locale.ts`](../src/domain/locale.ts) · [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) · [`src/data/model/locale.ts`](../src/data/model/locale.ts) · [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.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/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/application/policies/publication_identity_policy.ts`](../src/application/policies/publication_identity_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/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/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/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/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) -- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/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/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__/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/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/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/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/application/policies/publication_identity_policy.ts`](../src/application/policies/publication_identity_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/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/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/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/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) +- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/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/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__/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/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/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 f15a8fe71..a81867332 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -34,6 +34,7 @@ "src/actions/github_action_locale_inputs.ts", "src/application/ports/message_catalog_ports.ts", "src/application/policies/resolved_message_catalog_policy.ts", + "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/branch_sync_message_catalog.ts", "src/application/policies/merge_queue_message_catalog.ts", "src/application/policies/setup_doctor_message_catalog.ts", @@ -72,6 +73,7 @@ "src/actions/__tests__/configuration_builders.test.ts", "src/actions/__tests__/github_action_completion.test.ts", "src/application/policies/__tests__/comment_translation_policy.test.ts", + "src/application/policies/__tests__/action_summary_message_catalog.test.ts", "src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts", "src/prompts/__tests__/localize_message_catalog.test.ts", "src/domain/__tests__/github_publication.test.ts", diff --git a/specs/repository-locale-and-localization.md b/specs/repository-locale-and-localization.md index 6520fa065..d38498ae6 100644 --- a/specs/repository-locale-and-localization.md +++ b/specs/repository-locale-and-localization.md @@ -4,8 +4,8 @@ - Date: 2026-09-14 - Catalog capability ID: github-communication-experience - Last verified: 2026-09-14 for the delivered foundation, shared publication, - branch-sync, Bugbot, and deployment-presentation slices; remaining clauses - are prospective + branch-sync, Bugbot, deployment-presentation, setup-doctor, and generic Job + Summary slices; remaining clauses are prospective - Owners: Copilot maintainers - Scope: Define one English-default repository locale profile and apply it generically to deterministic UI, agent-generated content, and safe interpretation of addressed comments. - Related issues/PRs: [issue #334](https://github.com/vypdev/copilot/issues/334), [PR #363](https://github.com/vypdev/copilot/pull/363), [PR #365](https://github.com/vypdev/copilot/pull/365) @@ -958,8 +958,11 @@ the complete doctor artifact, reuses it for merge-readiness rows and terminal presentation, replaces pull-request-mode prose with stable reason codes, and keeps raw provider and credential-health diagnostics out of UI. Setup itself remains one authoritative English artifact while it creates the repository -profile. Lifecycle and the remaining public surfaces are not claimed complete -by this evidence. +profile. The generic Actions Job Summary now resolves one complete catalog in +the repository locale, localizes its headings and explanatory labels, preserves +machine values, and emits localization evidence once instead of duplicating it +inside and below the main table. Lifecycle and the remaining public surfaces are +not claimed complete by this evidence. ### 13.5 Rollback diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index beaff9d85..f1c94f28d 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -3,7 +3,9 @@ - Status: In implementation - Date: 2026-09-14 - Catalog capability ID: github-communication-experience -- Last verified: 2026-09-14 for the delivered shared publication, branch-sync, review-context, and Bugbot slices; remaining clauses are prospective +- Last verified: 2026-09-14 for the delivered shared publication, branch-sync, + review-context, Bugbot, deployment, setup-doctor, and generic Job Summary + slices; remaining clauses are prospective - Owners: Copilot maintainers - Scope: Replace generic step-dump comments with bounded, semantic, idempotent GitHub messages across issues, pull requests, reviews, pushes, and single actions. - Related issues/PRs: [issue #334](https://github.com/vypdev/copilot/issues/334), [issue #344](https://github.com/vypdev/copilot/issues/344), [issue #355](https://github.com/vypdev/copilot/issues/355), [PR #358](https://github.com/vypdev/copilot/pull/358), [PR #363](https://github.com/vypdev/copilot/pull/363), [PR #365](https://github.com/vypdev/copilot/pull/365) @@ -932,8 +934,11 @@ feature-owned review/thread model while replacing ad hoc public strings with a single typed catalog per publication operation, proper plural forms, stable machine markers, and bounded English-default/localized renderers. The executable mutation inventory, locale-branch ratchet, and pseudo-plural ratchet protect -these boundaries. Other capability rows and the global numeric budget remain -open and are not claimed complete by this milestone. +these boundaries. Subsequent deployment and setup-doctor slices cover their +feature-owned views, and the generic Job Summary slice resolves repository- +locale copy atomically, retains sanitized operator steps, and renders one +localization evidence section instead of two. Other capability rows and the +global numeric budget remain open and are not claimed complete by this milestone. 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 diff --git a/src/actions/__tests__/github_action_completion.test.ts b/src/actions/__tests__/github_action_completion.test.ts index 8f88389f1..a7d4c7a4d 100644 --- a/src/actions/__tests__/github_action_completion.test.ts +++ b/src/actions/__tests__/github_action_completion.test.ts @@ -254,6 +254,31 @@ describe('finishGithubAction', () => { expect(mockSummaryPublish).toHaveBeenCalledWith(expect.stringContaining('test-owner/test-repo')); }); + it('uses one repository-locale section in a localized generic summary', async () => { + const action = Object.assign(execution(), { + locale: { repository: 'es-ES', issue: 'es-ES', pullRequest: 'es-ES' }, + }); + const resolver = new ResolveMessageCatalogUseCase(); + + await finishGithubAction( + action, + [new Result({ id: 'MetadataUseCase', success: true, executed: true, steps: ['Updated labels.'] })], + {} as never, + {} as never, + undefined, + { publish: mockSummaryPublish }, + resolver, + ); + + const summary = mockSummaryPublish.mock.calls[0][0] as string; + expect(summary).toContain('# Ejecución de Copilot'); + expect(summary).toContain('| Estado | ✅ Correcto |'); + expect(summary).toContain('## Detalles del resultado'); + expect(summary).toContain('| Locale del repositorio | `es-ES` |'); + expect(summary.match(/## Localización/gu)).toHaveLength(1); + expect(summary).not.toContain('## Localization'); + }); + it('uses the durable repository locale for deployment summaries without replaying internal steps', async () => { const action = Object.assign(singleActionExecution(), { singleAction: { diff --git a/src/actions/github_action_completion.ts b/src/actions/github_action_completion.ts index 8a2295108..d3af83aa4 100644 --- a/src/actions/github_action_completion.ts +++ b/src/actions/github_action_completion.ts @@ -15,6 +15,7 @@ import { renderLocalizationSummarySection, type LocalizationSummaryLabels, } from '../application/policies/action_summary_policy'; +import { resolveActionSummaryCatalog } from '../application/policies/action_summary_message_catalog'; import { lifecycleStateFromLabels } from '../domain/copilot_lifecycle'; import type { CopilotEvidencePort } from '../application/ports/copilot_evidence_ports'; import { buildCopilotEvidence } from '../application/policies/copilot_evidence_policy'; @@ -92,6 +93,7 @@ async function writeActionSummary( : locale; let body: string; let localizationLabels: LocalizationSummaryLabels | undefined; + let appendLocalizationEvidence = false; if (execution.singleAction.isDeploymentOrchestrationAction && operation) { const effectiveLocale = operation.locale ?? locale; const catalog = await resolveDeploymentCatalog( @@ -111,6 +113,7 @@ async function writeActionSummary( descriptors: messages.descriptors, reason: messages.reason, }; + appendLocalizationEvidence = true; body = renderDeploymentJobSummary(operation, { owner: execution.owner, repository: execution.repo, @@ -124,6 +127,11 @@ async function writeActionSummary( : undefined, }, operation.lastFailure?.previousPhase, catalog); } else { + const catalog = await resolveActionSummaryCatalog( + locale.repository, + execution.ai.getAgentConfiguration('planner'), + catalogResolver, + ); body = buildActionSummary({ owner: execution.owner, repository: execution.repo, @@ -145,13 +153,15 @@ async function writeActionSummary( }, catalogResolutions: catalogResolver?.observations?.() ?? [], results: execution.currentConfiguration.results, - }); + }, catalog); } - const localizationEvidence = renderLocalizationSummarySection({ - repository: summaryLocale.repository, - issue: summaryLocale.issue, - pullRequest: summaryLocale.pullRequest, - }, catalogResolver?.observations?.() ?? [], localizationLabels); + const localizationEvidence = appendLocalizationEvidence + ? renderLocalizationSummarySection({ + repository: summaryLocale.repository, + issue: summaryLocale.issue, + pullRequest: summaryLocale.pullRequest, + }, catalogResolver?.observations?.() ?? [], localizationLabels) + : ''; const summaryText = [body, localizationEvidence].filter(Boolean).join('\n\n'); if (!summaryPort) return summaryText; try { diff --git a/src/application/policies/__tests__/action_summary_message_catalog.test.ts b/src/application/policies/__tests__/action_summary_message_catalog.test.ts new file mode 100644 index 000000000..9d9cd09d6 --- /dev/null +++ b/src/application/policies/__tests__/action_summary_message_catalog.test.ts @@ -0,0 +1,80 @@ +import type { CatalogMessage } from '../../../domain/message_catalog'; +import { catalogPlaceholders, validateCatalogDefinition } from '../../../domain/message_catalog'; +import { ResolveMessageCatalogUseCase } from '../../usecases/localization/resolve_message_catalog_use_case'; +import { + ACTION_SUMMARY_CATALOG_DEFINITIONS, + ACTION_SUMMARY_MESSAGE_IDS, + ENGLISH_ACTION_SUMMARY_DEFINITION, + SPANISH_ACTION_SUMMARY_DEFINITION, + resolveActionSummaryCatalog, + resolveStaticActionSummaryCatalog, +} from '../action_summary_message_catalog'; + +const configuration = { provider: 'codex' as const, model: 'model' }; + +function translatedMessages(): Record { + return Object.fromEntries(Object.entries(ENGLISH_ACTION_SUMMARY_DEFINITION.messages).map(([id, message]) => [ + id, + `FR ${message as string}`, + ])); +} + +describe('action summary message catalog', () => { + it('ships complete, unique, immutable English and Spanish definitions', () => { + expect(new Set(ACTION_SUMMARY_MESSAGE_IDS).size).toBe(ACTION_SUMMARY_MESSAGE_IDS.length); + for (const definition of ACTION_SUMMARY_CATALOG_DEFINITIONS) { + expect(validateCatalogDefinition(definition, ACTION_SUMMARY_MESSAGE_IDS)).toEqual([]); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.messages)).toBe(true); + } + for (const id of ACTION_SUMMARY_MESSAGE_IDS) { + expect(catalogPlaceholders(SPANISH_ACTION_SUMMARY_DEFINITION.messages[id])) + .toEqual(catalogPlaceholders(ENGLISH_ACTION_SUMMARY_DEFINITION.messages[id])); + } + }); + + it.each([ + ['en-US', 'en-US', 'exact'], + ['en-AU', 'en-US', 'base'], + ['es-ES', 'es-ES', 'exact'], + ['es-AR', 'es-ES', 'base'], + ['fr-FR', 'en-US', 'fallback'], + ] as const)('resolves %s as %s from %s', (requested, resolved, source) => { + expect(resolveStaticActionSummaryCatalog(requested)).toMatchObject({ + requestedLocale: requested, + locale: resolved, + resolutionSource: source, + }); + }); + + it('resolves and caches one complete catalog for an arbitrary locale', async () => { + const query = jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages: translatedMessages() }); + const resolver = new ResolveMessageCatalogUseCase({ query }); + const first = await resolveActionSummaryCatalog('fr-FR', configuration, resolver); + const replay = await resolveActionSummaryCatalog('fr-FR', configuration, resolver); + + expect(first).toMatchObject({ requestedLocale: 'fr-FR', locale: 'fr-FR', resolutionSource: 'dynamic' }); + expect(first.message('summary.target.issue', { number: 12 })).toBe('FR Issue #12'); + expect(replay.message('summary.heading')).toBe('FR Copilot execution'); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('falls back atomically to English when a dynamic descriptor is missing', async () => { + const messages = translatedMessages(); + delete messages['summary.status']; + const resolver = new ResolveMessageCatalogUseCase({ + query: jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages }), + }); + + const catalog = await resolveActionSummaryCatalog('fr-FR', configuration, resolver); + + expect(catalog).toMatchObject({ + requestedLocale: 'fr-FR', + locale: 'en-US', + resolutionSource: 'fallback', + fallbackReason: 'dynamic-response-invalid', + }); + expect(catalog.message('summary.status')).toBe('Status'); + expect(catalog.message('summary.heading')).not.toContain('FR '); + }); +}); diff --git a/src/application/policies/__tests__/action_summary_policy.test.ts b/src/application/policies/__tests__/action_summary_policy.test.ts index 31f3161ec..2a057a486 100644 --- a/src/application/policies/__tests__/action_summary_policy.test.ts +++ b/src/application/policies/__tests__/action_summary_policy.test.ts @@ -1,6 +1,7 @@ import { Result } from '../../../data/model/result'; import { buildActionSummary, renderLocalizationSummarySection } from '../action_summary_policy'; import { ApplicationError } from '../../errors/application_error'; +import { resolveStaticActionSummaryCatalog } from '../action_summary_message_catalog'; const findingStates = (overrides: Record = {}) => ({ open: 0, @@ -67,6 +68,30 @@ describe('action summary policy', () => { expect(summary).toContain('| Repository locale | `fr-FR` |'); expect(summary).toContain('| Issue locale | `es-ES` |'); expect(summary).toContain('fr-FR -> en-US (fallback, descriptors=63, reason=dynamic-response-invalid)'); + expect(summary.match(/## Localization/gu)).toHaveLength(1); + }); + + it('renders the whole summary atomically in the repository locale', () => { + const summary = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'issues', issueNumber: 7, pullRequestNumber: -1, + locale: { repository: 'es-ES', issue: 'es-ES', pullRequest: 'es-ES' }, + results: [new Result({ + id: '', + success: false, + executed: true, + errors: [new ApplicationError('workflow.failed', 'Workflow failed.')], + })], + }, resolveStaticActionSummaryCatalog('es-ES')); + + expect(summary).toContain('# Ejecución de Copilot'); + expect(summary).toContain('Repositorio: [owner/repo]'); + expect(summary).toContain('| Estado | ❌ Fallo |'); + expect(summary).toContain('| Destino | Issue n.º 7 |'); + expect(summary).toContain('## Detalles del resultado'); + expect(summary).toContain('**Resultado sin nombre**'); + expect(summary).toContain('**Impacto:**'); + expect(summary).toContain('## Localización'); + expect(summary).not.toContain('## Localization'); }); it('reports executed failures without exposing raw stack traces', () => { diff --git a/src/application/policies/action_summary_message_catalog.ts b/src/application/policies/action_summary_message_catalog.ts new file mode 100644 index 000000000..dfca7872f --- /dev/null +++ b/src/application/policies/action_summary_message_catalog.ts @@ -0,0 +1,215 @@ +import type { AgentConfiguration } from '../../domain/agent'; +import { + MESSAGE_CATALOG_VERSION, + type CatalogMessage, + type MessageCatalogDefinition, +} from '../../domain/message_catalog'; +import type { MessageCatalogResolutionPort } from '../ports/message_catalog_ports'; +import { + resolveMessageCatalogView, + resolveStaticMessageCatalogView, + type ResolvedMessageCatalogView, +} from './resolved_message_catalog_policy'; + +const SIMPLE_MESSAGE_KEYS = Object.freeze([ + 'heading', 'repository', 'property', 'value', 'status', 'event', 'target', + 'lifecycle', 'descriptionPolicy', 'results', 'findingStates', 'bugbotReview', + 'resultDetails', 'localization', 'repositoryLocale', 'issueLocale', + 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', + 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', + 'none', 'noResult', 'unnamedResult', 'impact', 'cause', 'action', + 'retainedState', 'reference', +] as const); + +const TEMPLATE_MESSAGE_IDS = Object.freeze([ + 'summary.target.pullRequest', + 'summary.target.issue', + 'summary.target.repositoryRun', + 'summary.bugbotTelemetry', +] as const); + +const FINDING_STATE_KEYS = Object.freeze([ + 'open', 'reopened', 'fixed', 'obsolete', 'dismissed', + 'verification-required', 'unknown', +] as const); + +type SimpleMessageKey = typeof SIMPLE_MESSAGE_KEYS[number]; +type SimpleMessageId = `summary.${SimpleMessageKey}`; +type TemplateMessageId = typeof TEMPLATE_MESSAGE_IDS[number]; +export type ActionSummaryFindingState = typeof FINDING_STATE_KEYS[number]; +type FindingStateMessageId = `summary.findingState.${ActionSummaryFindingState}`; + +export type ActionSummaryMessageId = SimpleMessageId | TemplateMessageId | FindingStateMessageId; +export type ActionSummaryMessageCatalog = ResolvedMessageCatalogView; + +export const ACTION_SUMMARY_MESSAGE_IDS: readonly ActionSummaryMessageId[] = Object.freeze([ + ...SIMPLE_MESSAGE_KEYS.map(key => `summary.${key}` as const), + ...TEMPLATE_MESSAGE_IDS, + ...FINDING_STATE_KEYS.map(key => `summary.findingState.${key}` as const), +]); + +const ENGLISH_SIMPLE: Readonly> = Object.freeze({ + heading: 'Copilot execution', + repository: 'Repository', + property: 'Property', + value: 'Value', + status: 'Status', + event: 'Event', + target: 'Target', + lifecycle: 'Lifecycle', + descriptionPolicy: 'PR description policy', + results: 'Results', + findingStates: 'Finding states', + bugbotReview: 'Bugbot review', + resultDetails: 'Result details', + localization: 'Localization', + repositoryLocale: 'Repository locale', + issueLocale: 'Issue locale', + pullRequestLocale: 'Pull-request locale', + catalogResolution: 'Catalog resolution', + descriptors: 'descriptors', + reason: 'reason', + failure: 'Failure', + findings: 'Findings', + partial: 'Partial', + superseded: 'Superseded', + skipped: 'Skipped', + dryRun: 'Dry run', + success: 'Success', + invalid: 'invalid', + none: 'none', + noResult: 'No application result was produced.', + unnamedResult: 'Unnamed result', + impact: 'Impact', + cause: 'Cause', + action: 'Action', + retainedState: 'Retained state', + reference: 'Reference', +}); + +const SPANISH_SIMPLE: Readonly> = Object.freeze({ + heading: 'Ejecución de Copilot', + repository: 'Repositorio', + property: 'Propiedad', + value: 'Valor', + status: 'Estado', + event: 'Evento', + target: 'Destino', + lifecycle: 'Ciclo de vida', + descriptionPolicy: 'Política de descripción de PR', + results: 'Resultados', + findingStates: 'Estados de los hallazgos', + bugbotReview: 'Revisión de Bugbot', + resultDetails: 'Detalles del resultado', + localization: 'Localización', + repositoryLocale: 'Locale del repositorio', + issueLocale: 'Locale de la issue', + pullRequestLocale: 'Locale de la pull request', + catalogResolution: 'Resolución del catálogo', + descriptors: 'descriptores', + reason: 'motivo', + failure: 'Fallo', + findings: 'Hallazgos', + partial: 'Parcial', + superseded: 'Sustituido', + skipped: 'Omitido', + dryRun: 'Simulación', + success: 'Correcto', + invalid: 'no válido', + none: 'ninguno', + noResult: 'No se ha producido ningún resultado de aplicación.', + unnamedResult: 'Resultado sin nombre', + impact: 'Impacto', + cause: 'Causa', + action: 'Acción', + retainedState: 'Estado conservado', + reference: 'Referencia', +}); + +const ENGLISH_TEMPLATES: Readonly> = Object.freeze({ + 'summary.target.pullRequest': 'PR #{number}', + 'summary.target.issue': 'Issue #{number}', + 'summary.target.repositoryRun': 'Repository run', + 'summary.bugbotTelemetry': '{outcome}, effort={effort}, {elapsed}ms', +}); + +const SPANISH_TEMPLATES: Readonly> = Object.freeze({ + 'summary.target.pullRequest': 'PR n.º {number}', + 'summary.target.issue': 'Issue n.º {number}', + 'summary.target.repositoryRun': 'Ejecución del repositorio', + 'summary.bugbotTelemetry': '{outcome}, esfuerzo={effort}, {elapsed} ms', +}); + +const ENGLISH_FINDING_STATES: Readonly> = Object.freeze({ + open: 'open', + reopened: 'reopened', + fixed: 'fixed', + obsolete: 'obsolete', + dismissed: 'dismissed', + 'verification-required': 'verification required', + unknown: 'unknown', +}); + +const SPANISH_FINDING_STATES: Readonly> = Object.freeze({ + open: 'abiertos', + reopened: 'reabiertos', + fixed: 'corregidos', + obsolete: 'obsoletos', + dismissed: 'descartados', + 'verification-required': 'requieren verificación', + unknown: 'desconocidos', +}); + +function catalogMessages( + simple: Readonly>, + templates: Readonly>, + findingStates: Readonly>, +): Readonly> { + return Object.freeze({ + ...Object.fromEntries(SIMPLE_MESSAGE_KEYS.map(key => [`summary.${key}`, simple[key]])), + ...templates, + ...Object.fromEntries(FINDING_STATE_KEYS.map(key => [`summary.findingState.${key}`, findingStates[key]])), + }) as Readonly>; +} + +export const ENGLISH_ACTION_SUMMARY_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_TEMPLATES, ENGLISH_FINDING_STATES), +}); + +export const SPANISH_ACTION_SUMMARY_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: catalogMessages(SPANISH_SIMPLE, SPANISH_TEMPLATES, SPANISH_FINDING_STATES), +}); + +export const ACTION_SUMMARY_CATALOG_DEFINITIONS = Object.freeze([ + ENGLISH_ACTION_SUMMARY_DEFINITION, + SPANISH_ACTION_SUMMARY_DEFINITION, +]); + +export function resolveStaticActionSummaryCatalog(locale: string): ActionSummaryMessageCatalog { + return resolveStaticMessageCatalogView( + locale, + ENGLISH_ACTION_SUMMARY_DEFINITION, + ACTION_SUMMARY_CATALOG_DEFINITIONS, + ); +} + +export async function resolveActionSummaryCatalog( + locale: string, + configuration: Readonly | undefined, + resolver: MessageCatalogResolutionPort | undefined, +): Promise { + return resolveMessageCatalogView( + locale, + ACTION_SUMMARY_MESSAGE_IDS, + ENGLISH_ACTION_SUMMARY_DEFINITION, + ACTION_SUMMARY_CATALOG_DEFINITIONS, + configuration, + resolver, + ); +} diff --git a/src/application/policies/action_summary_policy.ts b/src/application/policies/action_summary_policy.ts index 00f17338b..f20cc31b3 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -12,6 +12,11 @@ import { } from './bugbot_result_finding_state_projection_policy'; import { countActionableBugbotFindings } from '../../domain/bugbot/review_state'; import type { CatalogResolutionObservation } from '../../domain/message_catalog'; +import { + resolveStaticActionSummaryCatalog, + type ActionSummaryFindingState, + type ActionSummaryMessageCatalog, +} from './action_summary_message_catalog'; export interface ActionSummaryContext { readonly owner: string; @@ -51,8 +56,11 @@ const ENGLISH_LOCALIZATION_SUMMARY_LABELS: LocalizationSummaryLabels = Object.fr reason: 'reason', }); -/** Builds a bounded, publication-safe GitHub Actions Job Summary. */ -export function buildActionSummary(context: ActionSummaryContext): string { +/** Builds one bounded, publication-safe, repository-locale GitHub Actions Job Summary. */ +export function buildActionSummary( + context: ActionSummaryContext, + catalog: ActionSummaryMessageCatalog = resolveStaticActionSummaryCatalog(context.locale?.repository ?? 'en-US'), +): string { const failures = context.results.filter(result => !result.success && result.executed); const findingStateProjection = projectBugbotResultFindingStates(context.results); const findingStates = findingStateProjection.status === 'valid' ? findingStateProjection.counts : undefined; @@ -66,37 +74,56 @@ export function buildActionSummary(context: ActionSummaryContext): string { hasActionableFindings, failOnUnresolvedFindings: context.failOnUnresolvedFindings === true, bugbotTelemetry, - }); - const target = resolveActionSummaryTarget(context); + }, catalog); + const target = resolveActionSummaryTarget(context, catalog); const lifecycle = context.lifecycleState ? `\`${sanitizeAgentMarkdown(context.lifecycleState, 100)}\`` : '—'; const rows = [ - `| Status | ${status} |`, - `| Event | \`${escapeTable(context.eventName)}\` |`, - `| Target | ${escapeTable(target)} |`, - `| Lifecycle | ${lifecycle} |`, - `| PR description policy | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, - `| Results | ${context.results.length} |`, - `| Finding states | ${formatFindingStates(findingStateProjection)} |`, - `| Bugbot review | ${formatBugbotTelemetry(telemetryProjection)} |`, - ...localizationSummaryRows(context.locale, context.catalogResolutions), + `| ${catalog.message('summary.status')} | ${status} |`, + `| ${catalog.message('summary.event')} | \`${escapeTable(context.eventName)}\` |`, + `| ${catalog.message('summary.target')} | ${escapeTable(target)} |`, + `| ${catalog.message('summary.lifecycle')} | ${lifecycle} |`, + `| ${catalog.message('summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, + `| ${catalog.message('summary.results')} | ${context.results.length} |`, + `| ${catalog.message('summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, + `| ${catalog.message('summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, ]; + const localization = renderLocalizationSummarySection( + context.locale, + context.catalogResolutions, + actionSummaryLocalizationLabels(catalog), + ); return [ - '# Copilot execution', + `# ${catalog.message('summary.heading')}`, '', - `Repository: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, + `${catalog.message('summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, '', - '| Property | Value |', + `| ${catalog.message('summary.property')} | ${catalog.message('summary.value')} |`, '| --- | --- |', ...rows, '', - '## Result details', + `## ${catalog.message('summary.resultDetails')}`, '', - renderResults(context.results), + renderResults(context.results, catalog), '', + localization, ].join('\n'); } +export function actionSummaryLocalizationLabels(catalog: ActionSummaryMessageCatalog): LocalizationSummaryLabels { + return Object.freeze({ + heading: catalog.message('summary.localization'), + property: catalog.message('summary.property'), + value: catalog.message('summary.value'), + repositoryLocale: catalog.message('summary.repositoryLocale'), + issueLocale: catalog.message('summary.issueLocale'), + pullRequestLocale: catalog.message('summary.pullRequestLocale'), + catalogResolution: catalog.message('summary.catalogResolution'), + descriptors: catalog.message('summary.descriptors'), + reason: catalog.message('summary.reason'), + }); +} + /** Renders the same content-free locale evidence for summaries owned by specialized workflows. */ export function renderLocalizationSummarySection( locale: ActionSummaryContext['locale'], @@ -150,43 +177,55 @@ interface ActionSummaryStatusInput { readonly bugbotTelemetry?: BugbotTelemetryProjection; } -function resolveActionSummaryStatus(input: ActionSummaryStatusInput): string { - if (input.failureCount > 0 || input.hasUnknownFindings) return '❌ Failure'; - if (input.bugbotTelemetry?.outcome === 'failed') return '❌ Failure'; - if (input.hasActionableFindings && input.failOnUnresolvedFindings) return '❌ Failure'; - if (input.hasActionableFindings) return '⚠️ Findings'; +function resolveActionSummaryStatus(input: ActionSummaryStatusInput, catalog: ActionSummaryMessageCatalog): string { + if (input.failureCount > 0 || input.hasUnknownFindings) return `❌ ${catalog.message('summary.failure')}`; + if (input.bugbotTelemetry?.outcome === 'failed') return `❌ ${catalog.message('summary.failure')}`; + if (input.hasActionableFindings && input.failOnUnresolvedFindings) return `❌ ${catalog.message('summary.failure')}`; + if (input.hasActionableFindings) return `⚠️ ${catalog.message('summary.findings')}`; switch (input.bugbotTelemetry?.outcome) { - case 'partial': return '⚠️ Partial'; - case 'superseded': return '⏭️ Superseded'; - case 'skipped': return '⏭️ Skipped'; - case 'dry-run': return '🧪 Dry run'; - default: return '✅ Success'; + case 'partial': return `⚠️ ${catalog.message('summary.partial')}`; + case 'superseded': return `⏭️ ${catalog.message('summary.superseded')}`; + case 'skipped': return `⏭️ ${catalog.message('summary.skipped')}`; + case 'dry-run': return `🧪 ${catalog.message('summary.dryRun')}`; + default: return `✅ ${catalog.message('summary.success')}`; } } -function resolveActionSummaryTarget(context: ActionSummaryContext): string { - if (context.pullRequestNumber > 0) return `PR #${context.pullRequestNumber}`; - if (context.issueNumber > 0) return `Issue #${context.issueNumber}`; - return 'Repository run'; +function resolveActionSummaryTarget(context: ActionSummaryContext, catalog: ActionSummaryMessageCatalog): string { + if (context.pullRequestNumber > 0) { + return catalog.message('summary.target.pullRequest', { number: context.pullRequestNumber }); + } + if (context.issueNumber > 0) return catalog.message('summary.target.issue', { number: context.issueNumber }); + return catalog.message('summary.target.repositoryRun'); } -function formatBugbotTelemetry(projection: BugbotResultTelemetryProjection): string { - if (projection.status === 'invalid') return 'invalid'; +function formatBugbotTelemetry( + projection: BugbotResultTelemetryProjection, + catalog: ActionSummaryMessageCatalog, +): string { + if (projection.status === 'invalid') return catalog.message('summary.invalid'); if (projection.status === 'absent') return '—'; - return `${escapeTable(projection.telemetry.outcome)}, effort=${escapeTable(projection.telemetry.configuredEffort)}, ${Math.max(0, Math.round(projection.telemetry.elapsedMs))}ms`; + return catalog.message('summary.bugbotTelemetry', { + outcome: escapeTable(projection.telemetry.outcome), + effort: escapeTable(projection.telemetry.configuredEffort), + elapsed: Math.max(0, Math.round(projection.telemetry.elapsedMs)), + }); } -function formatFindingStates(projection: BugbotResultFindingStateProjection): string { - if (projection.status === 'invalid') return 'invalid'; +function formatFindingStates( + projection: BugbotResultFindingStateProjection, + catalog: ActionSummaryMessageCatalog, +): string { + if (projection.status === 'invalid') return catalog.message('summary.invalid'); if (projection.status === 'absent') return '—'; return Object.entries(projection.counts) .filter(([, value]) => value > 0) - .map(([state, value]) => `${state}=${value}`) - .join(', ') || 'none'; + .map(([state, value]) => `${catalog.message(`summary.findingState.${state as ActionSummaryFindingState}`)}=${value}`) + .join(', ') || catalog.message('summary.none'); } -function renderResults(results: readonly Result[]): string { - if (results.length === 0) return '_No application result was produced._'; +function renderResults(results: readonly Result[], catalog: ActionSummaryMessageCatalog): string { + if (results.length === 0) return `_${catalog.message('summary.noResult')}_`; return results.map(result => { const icon = result.success ? '✅' : '❌'; const details = result.steps @@ -196,14 +235,14 @@ function renderResults(results: readonly Result[]): string { .flatMap((error) => { const view = buildApplicationErrorPresentation(error); return [ - ` - **Impact:** ${sanitizePublishedError(view.impact)}`, - ` - **Cause (\`${view.code}\`):** ${sanitizePublishedError(view.cause)}`, - ` - **Action:** ${sanitizePublishedError(view.action)}`, - ` - **Retained state:** ${sanitizePublishedError(view.retainedState)}`, - ` - **Reference:** \`${view.reference}\``, + ` - **${catalog.message('summary.impact')}:** ${sanitizePublishedError(view.impact)}`, + ` - **${catalog.message('summary.cause')} (\`${view.code}\`):** ${sanitizePublishedError(view.cause)}`, + ` - **${catalog.message('summary.action')}:** ${sanitizePublishedError(view.action)}`, + ` - **${catalog.message('summary.retainedState')}:** ${sanitizePublishedError(view.retainedState)}`, + ` - **${catalog.message('summary.reference')}:** \`${view.reference}\``, ]; }); - return [`- ${icon} **${escapeTable(result.id || 'Unnamed result')}**`, ...details, ...errors].join('\n'); + return [`- ${icon} **${escapeTable(result.id || catalog.message('summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); }).join('\n'); } diff --git a/src/architecture/__tests__/github_publication_boundaries.test.ts b/src/architecture/__tests__/github_publication_boundaries.test.ts index 814cd6ed5..934a98d25 100644 --- a/src/architecture/__tests__/github_publication_boundaries.test.ts +++ b/src/architecture/__tests__/github_publication_boundaries.test.ts @@ -82,6 +82,7 @@ describe('GitHub conversation publication boundaries', () => { it('keeps common presentation locale decisions inside the message catalog', () => { const files = [ 'src/application/policies/copilot_interaction_policy.ts', + 'src/application/policies/action_summary_policy.ts', 'src/application/policies/branch_sync_notification_policy.ts', 'src/application/policies/bugbot_finding_marker_policy.ts', 'src/application/policies/bugbot_review_presentation_policy.ts', From 84e6ccb318fb7c8b5824a639930a0e7a93441f3c Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 00:14:27 +0200 Subject: [PATCH 2/3] codex-localized-action-summary: keep action summary markdown code-owned --- build/github_action/index.js | 97 +++++++++------- .../__tests__/action_summary_policy.test.ts | 52 +++++++++ .../policies/action_summary_policy.ts | 106 +++++++++++------- src/domain/__tests__/message_catalog.test.ts | 2 + 4 files changed, 179 insertions(+), 78 deletions(-) diff --git a/build/github_action/index.js b/build/github_action/index.js index 68bdf3366..989415056 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41345,26 +41345,26 @@ function buildActionSummary(context, catalog = (0, action_summary_message_catalo const target = resolveActionSummaryTarget(context, catalog); const lifecycle = context.lifecycleState ? `\`${(0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(context.lifecycleState, 100)}\`` : '—'; const rows = [ - `| ${catalog.message('summary.status')} | ${status} |`, - `| ${catalog.message('summary.event')} | \`${escapeTable(context.eventName)}\` |`, - `| ${catalog.message('summary.target')} | ${escapeTable(target)} |`, - `| ${catalog.message('summary.lifecycle')} | ${lifecycle} |`, - `| ${catalog.message('summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, - `| ${catalog.message('summary.results')} | ${context.results.length} |`, - `| ${catalog.message('summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, - `| ${catalog.message('summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, + `| ${catalogText(catalog, 'summary.status')} | ${status} |`, + `| ${catalogText(catalog, 'summary.event')} | \`${escapeTable(context.eventName)}\` |`, + `| ${catalogText(catalog, 'summary.target')} | ${escapeTable(target)} |`, + `| ${catalogText(catalog, 'summary.lifecycle')} | ${lifecycle} |`, + `| ${catalogText(catalog, 'summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, + `| ${catalogText(catalog, 'summary.results')} | ${context.results.length} |`, + `| ${catalogText(catalog, 'summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, + `| ${catalogText(catalog, 'summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, ]; const localization = renderLocalizationSummarySection(context.locale, context.catalogResolutions, actionSummaryLocalizationLabels(catalog)); return [ - `# ${catalog.message('summary.heading')}`, + `# ${catalogText(catalog, 'summary.heading')}`, '', - `${catalog.message('summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, + `${catalogText(catalog, 'summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, '', - `| ${catalog.message('summary.property')} | ${catalog.message('summary.value')} |`, + `| ${catalogText(catalog, 'summary.property')} | ${catalogText(catalog, 'summary.value')} |`, '| --- | --- |', ...rows, '', - `## ${catalog.message('summary.resultDetails')}`, + `## ${catalogText(catalog, 'summary.resultDetails')}`, '', renderResults(context.results, catalog), '', @@ -41386,13 +41386,14 @@ function actionSummaryLocalizationLabels(catalog) { } /** Renders the same content-free locale evidence for summaries owned by specialized workflows. */ function renderLocalizationSummarySection(locale, catalogResolutions = [], labels = ENGLISH_LOCALIZATION_SUMMARY_LABELS) { - const rows = localizationSummaryRows(locale, catalogResolutions, labels); + const safeLabels = sanitizeLocalizationSummaryLabels(labels); + const rows = localizationSummaryRows(locale, catalogResolutions, safeLabels); if (rows.length === 0) return ''; return [ - `## ${labels.heading}`, + `## ${safeLabels.heading}`, '', - `| ${labels.property} | ${labels.value} |`, + `| ${safeLabels.property} | ${safeLabels.value} |`, '| --- | --- |', ...rows, '', @@ -41418,35 +41419,35 @@ function formatCatalogResolutions(observations, labels) { } function resolveActionSummaryStatus(input, catalog) { if (input.failureCount > 0 || input.hasUnknownFindings) - return `❌ ${catalog.message('summary.failure')}`; + return `❌ ${catalogText(catalog, 'summary.failure')}`; if (input.bugbotTelemetry?.outcome === 'failed') - return `❌ ${catalog.message('summary.failure')}`; + return `❌ ${catalogText(catalog, 'summary.failure')}`; if (input.hasActionableFindings && input.failOnUnresolvedFindings) - return `❌ ${catalog.message('summary.failure')}`; + return `❌ ${catalogText(catalog, 'summary.failure')}`; if (input.hasActionableFindings) - return `⚠️ ${catalog.message('summary.findings')}`; + return `⚠️ ${catalogText(catalog, 'summary.findings')}`; switch (input.bugbotTelemetry?.outcome) { - case 'partial': return `⚠️ ${catalog.message('summary.partial')}`; - case 'superseded': return `⏭️ ${catalog.message('summary.superseded')}`; - case 'skipped': return `⏭️ ${catalog.message('summary.skipped')}`; - case 'dry-run': return `🧪 ${catalog.message('summary.dryRun')}`; - default: return `✅ ${catalog.message('summary.success')}`; + case 'partial': return `⚠️ ${catalogText(catalog, 'summary.partial')}`; + case 'superseded': return `⏭️ ${catalogText(catalog, 'summary.superseded')}`; + case 'skipped': return `⏭️ ${catalogText(catalog, 'summary.skipped')}`; + case 'dry-run': return `🧪 ${catalogText(catalog, 'summary.dryRun')}`; + default: return `✅ ${catalogText(catalog, 'summary.success')}`; } } function resolveActionSummaryTarget(context, catalog) { if (context.pullRequestNumber > 0) { - return catalog.message('summary.target.pullRequest', { number: context.pullRequestNumber }); + return catalogText(catalog, 'summary.target.pullRequest', { number: context.pullRequestNumber }); } if (context.issueNumber > 0) - return catalog.message('summary.target.issue', { number: context.issueNumber }); - return catalog.message('summary.target.repositoryRun'); + return catalogText(catalog, 'summary.target.issue', { number: context.issueNumber }); + return catalogText(catalog, 'summary.target.repositoryRun'); } function formatBugbotTelemetry(projection, catalog) { if (projection.status === 'invalid') - return catalog.message('summary.invalid'); + return catalogText(catalog, 'summary.invalid'); if (projection.status === 'absent') return '—'; - return catalog.message('summary.bugbotTelemetry', { + return catalogText(catalog, 'summary.bugbotTelemetry', { outcome: escapeTable(projection.telemetry.outcome), effort: escapeTable(projection.telemetry.configuredEffort), elapsed: Math.max(0, Math.round(projection.telemetry.elapsedMs)), @@ -41454,17 +41455,17 @@ function formatBugbotTelemetry(projection, catalog) { } function formatFindingStates(projection, catalog) { if (projection.status === 'invalid') - return catalog.message('summary.invalid'); + return catalogText(catalog, 'summary.invalid'); if (projection.status === 'absent') return '—'; return Object.entries(projection.counts) .filter(([, value]) => value > 0) - .map(([state, value]) => `${catalog.message(`summary.findingState.${state}`)}=${value}`) - .join(', ') || catalog.message('summary.none'); + .map(([state, value]) => `${catalogText(catalog, `summary.findingState.${state}`)}=${value}`) + .join(', ') || catalogText(catalog, 'summary.none'); } function renderResults(results, catalog) { if (results.length === 0) - return `_${catalog.message('summary.noResult')}_`; + return `_${catalogText(catalog, 'summary.noResult')}_`; return results.map(result => { const icon = result.success ? '✅' : '❌'; const details = result.steps @@ -41474,16 +41475,34 @@ function renderResults(results, catalog) { .flatMap((error) => { const view = (0, application_error_presentation_policy_1.buildApplicationErrorPresentation)(error); return [ - ` - **${catalog.message('summary.impact')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.impact)}`, - ` - **${catalog.message('summary.cause')} (\`${view.code}\`):** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.cause)}`, - ` - **${catalog.message('summary.action')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.action)}`, - ` - **${catalog.message('summary.retainedState')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.retainedState)}`, - ` - **${catalog.message('summary.reference')}:** \`${view.reference}\``, + ` - **${catalogText(catalog, 'summary.impact')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.impact)}`, + ` - **${catalogText(catalog, 'summary.cause')} (\`${view.code}\`):** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.cause)}`, + ` - **${catalogText(catalog, 'summary.action')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.action)}`, + ` - **${catalogText(catalog, 'summary.retainedState')}:** ${(0, github_comment_publication_policy_1.sanitizePublishedError)(view.retainedState)}`, + ` - **${catalogText(catalog, 'summary.reference')}:** \`${view.reference}\``, ]; }); - return [`- ${icon} **${escapeTable(result.id || catalog.message('summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); + return [`- ${icon} **${escapeTable(result.id || catalogText(catalog, 'summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); }).join('\n'); } +function catalogText(catalog, id, variables = {}) { + return escapeMarkdownText(catalog.message(id, variables)); +} +function sanitizeLocalizationSummaryLabels(labels) { + return Object.freeze(Object.fromEntries(Object.entries(labels).map(([key, value]) => [key, escapeMarkdownText(value)]))); +} +/** Catalog output is untrusted prose; renderers alone own Markdown structure. */ +function escapeMarkdownText(value) { + return String(value ?? '').slice(0, 2000) + .replace(/[\r\n]+/gu, ' ') + .replace(//gu, '-->') + .replace(/::/gu, ':\u200b:') + .replace(/@(?=[a-zA-Z0-9][a-zA-Z0-9-])/gu, '@\u200b') + .replace(/\b(https?):\/\//giu, '$1:\u200b//') + .replace(/\\/gu, '\\\\') + .replace(/([`*_[\]<>|~])/gu, '\\$1'); +} function escapeTable(value) { return String(value ?? '').replace(/[|\r\n]/g, match => match === '|' ? '\\|' : ' '); } diff --git a/src/application/policies/__tests__/action_summary_policy.test.ts b/src/application/policies/__tests__/action_summary_policy.test.ts index 2a057a486..ffac0b099 100644 --- a/src/application/policies/__tests__/action_summary_policy.test.ts +++ b/src/application/policies/__tests__/action_summary_policy.test.ts @@ -2,6 +2,7 @@ import { Result } from '../../../data/model/result'; import { buildActionSummary, renderLocalizationSummarySection } from '../action_summary_policy'; import { ApplicationError } from '../../errors/application_error'; import { resolveStaticActionSummaryCatalog } from '../action_summary_message_catalog'; +import type { ActionSummaryMessageCatalog, ActionSummaryMessageId } from '../action_summary_message_catalog'; const findingStates = (overrides: Record = {}) => ({ open: 0, @@ -37,6 +38,57 @@ describe('action summary policy', () => { }])).toContain('fr-FR -> en-US (fallback, descriptors=63, reason=dynamic-response-invalid)'); }); + it('keeps Markdown structure code-owned even if an injected catalog bypasses validation', () => { + const injected: Partial> = { + 'summary.heading': 'Summary\n# Forged heading', + 'summary.repository': '[Forged link](https://example.com)', + 'summary.status': 'Status | forged cell', + 'summary.noResult': '
forged block
', + }; + const catalog: ActionSummaryMessageCatalog = { + locale: 'fr-FR', + requestedLocale: 'fr-FR', + resolutionSource: 'dynamic', + message: id => injected[id] ?? 'Safe', + }; + + const summary = buildActionSummary({ + owner: 'owner', repository: 'repo', eventName: 'issues', + issueNumber: 7, pullRequestNumber: -1, results: [], + }, catalog); + + expect(summary).toContain('# Summary # Forged heading'); + expect(summary).toContain('\\[Forged link\\](https:\u200b//example.com)'); + expect(summary).toContain('| Status \\| forged cell |'); + expect(summary).toContain('_\\forged block\\_'); + expect(summary).not.toContain('\n# Forged heading'); + expect(summary).not.toContain('[Forged link](https://example.com)'); + expect(summary).not.toContain('https://example.com'); + expect(summary).not.toContain('
forged block
'); + }); + + it('sanitizes labels supplied to a specialized localization section', () => { + const summary = renderLocalizationSummarySection({ + repository: 'en-US', issue: 'en-US', pullRequest: 'en-US', + }, [], { + heading: 'Locale\n# Forged', + property: 'Property | forged', + value: '[Value](https://example.com)', + repositoryLocale: '
Repository
', + issueLocale: 'Issue', + pullRequestLocale: 'Pull request', + catalogResolution: 'Resolution', + descriptors: 'descriptors', + reason: 'reason', + }); + + expect(summary).toContain('## Locale # Forged'); + expect(summary).toContain('| Property \\| forged | \\[Value\\](https:\u200b//example.com) |'); + expect(summary).toContain('\\Repository\\'); + expect(summary).not.toContain('\n# Forged'); + expect(summary).not.toContain('
Repository
'); + }); + it('renders bounded result details and lifecycle metadata', () => { const summary = buildActionSummary({ owner: 'owner', diff --git a/src/application/policies/action_summary_policy.ts b/src/application/policies/action_summary_policy.ts index f20cc31b3..e1adc9013 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -78,14 +78,14 @@ export function buildActionSummary( const target = resolveActionSummaryTarget(context, catalog); const lifecycle = context.lifecycleState ? `\`${sanitizeAgentMarkdown(context.lifecycleState, 100)}\`` : '—'; const rows = [ - `| ${catalog.message('summary.status')} | ${status} |`, - `| ${catalog.message('summary.event')} | \`${escapeTable(context.eventName)}\` |`, - `| ${catalog.message('summary.target')} | ${escapeTable(target)} |`, - `| ${catalog.message('summary.lifecycle')} | ${lifecycle} |`, - `| ${catalog.message('summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, - `| ${catalog.message('summary.results')} | ${context.results.length} |`, - `| ${catalog.message('summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, - `| ${catalog.message('summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, + `| ${catalogText(catalog, 'summary.status')} | ${status} |`, + `| ${catalogText(catalog, 'summary.event')} | \`${escapeTable(context.eventName)}\` |`, + `| ${catalogText(catalog, 'summary.target')} | ${escapeTable(target)} |`, + `| ${catalogText(catalog, 'summary.lifecycle')} | ${lifecycle} |`, + `| ${catalogText(catalog, 'summary.descriptionPolicy')} | ${escapeTable(context.pullRequestDescriptionMode ?? '—')} |`, + `| ${catalogText(catalog, 'summary.results')} | ${context.results.length} |`, + `| ${catalogText(catalog, 'summary.findingStates')} | ${formatFindingStates(findingStateProjection, catalog)} |`, + `| ${catalogText(catalog, 'summary.bugbotReview')} | ${formatBugbotTelemetry(telemetryProjection, catalog)} |`, ]; const localization = renderLocalizationSummarySection( context.locale, @@ -94,15 +94,15 @@ export function buildActionSummary( ); return [ - `# ${catalog.message('summary.heading')}`, + `# ${catalogText(catalog, 'summary.heading')}`, '', - `${catalog.message('summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, + `${catalogText(catalog, 'summary.repository')}: [${escapeTable(`${context.owner}/${context.repository}`)}](https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)})`, '', - `| ${catalog.message('summary.property')} | ${catalog.message('summary.value')} |`, + `| ${catalogText(catalog, 'summary.property')} | ${catalogText(catalog, 'summary.value')} |`, '| --- | --- |', ...rows, '', - `## ${catalog.message('summary.resultDetails')}`, + `## ${catalogText(catalog, 'summary.resultDetails')}`, '', renderResults(context.results, catalog), '', @@ -130,12 +130,13 @@ export function renderLocalizationSummarySection( catalogResolutions: ActionSummaryContext['catalogResolutions'] = [], labels: LocalizationSummaryLabels = ENGLISH_LOCALIZATION_SUMMARY_LABELS, ): string { - const rows = localizationSummaryRows(locale, catalogResolutions, labels); + const safeLabels = sanitizeLocalizationSummaryLabels(labels); + const rows = localizationSummaryRows(locale, catalogResolutions, safeLabels); if (rows.length === 0) return ''; return [ - `## ${labels.heading}`, + `## ${safeLabels.heading}`, '', - `| ${labels.property} | ${labels.value} |`, + `| ${safeLabels.property} | ${safeLabels.value} |`, '| --- | --- |', ...rows, '', @@ -178,34 +179,34 @@ interface ActionSummaryStatusInput { } function resolveActionSummaryStatus(input: ActionSummaryStatusInput, catalog: ActionSummaryMessageCatalog): string { - if (input.failureCount > 0 || input.hasUnknownFindings) return `❌ ${catalog.message('summary.failure')}`; - if (input.bugbotTelemetry?.outcome === 'failed') return `❌ ${catalog.message('summary.failure')}`; - if (input.hasActionableFindings && input.failOnUnresolvedFindings) return `❌ ${catalog.message('summary.failure')}`; - if (input.hasActionableFindings) return `⚠️ ${catalog.message('summary.findings')}`; + if (input.failureCount > 0 || input.hasUnknownFindings) return `❌ ${catalogText(catalog, 'summary.failure')}`; + if (input.bugbotTelemetry?.outcome === 'failed') return `❌ ${catalogText(catalog, 'summary.failure')}`; + if (input.hasActionableFindings && input.failOnUnresolvedFindings) return `❌ ${catalogText(catalog, 'summary.failure')}`; + if (input.hasActionableFindings) return `⚠️ ${catalogText(catalog, 'summary.findings')}`; switch (input.bugbotTelemetry?.outcome) { - case 'partial': return `⚠️ ${catalog.message('summary.partial')}`; - case 'superseded': return `⏭️ ${catalog.message('summary.superseded')}`; - case 'skipped': return `⏭️ ${catalog.message('summary.skipped')}`; - case 'dry-run': return `🧪 ${catalog.message('summary.dryRun')}`; - default: return `✅ ${catalog.message('summary.success')}`; + case 'partial': return `⚠️ ${catalogText(catalog, 'summary.partial')}`; + case 'superseded': return `⏭️ ${catalogText(catalog, 'summary.superseded')}`; + case 'skipped': return `⏭️ ${catalogText(catalog, 'summary.skipped')}`; + case 'dry-run': return `🧪 ${catalogText(catalog, 'summary.dryRun')}`; + default: return `✅ ${catalogText(catalog, 'summary.success')}`; } } function resolveActionSummaryTarget(context: ActionSummaryContext, catalog: ActionSummaryMessageCatalog): string { if (context.pullRequestNumber > 0) { - return catalog.message('summary.target.pullRequest', { number: context.pullRequestNumber }); + return catalogText(catalog, 'summary.target.pullRequest', { number: context.pullRequestNumber }); } - if (context.issueNumber > 0) return catalog.message('summary.target.issue', { number: context.issueNumber }); - return catalog.message('summary.target.repositoryRun'); + if (context.issueNumber > 0) return catalogText(catalog, 'summary.target.issue', { number: context.issueNumber }); + return catalogText(catalog, 'summary.target.repositoryRun'); } function formatBugbotTelemetry( projection: BugbotResultTelemetryProjection, catalog: ActionSummaryMessageCatalog, ): string { - if (projection.status === 'invalid') return catalog.message('summary.invalid'); + if (projection.status === 'invalid') return catalogText(catalog, 'summary.invalid'); if (projection.status === 'absent') return '—'; - return catalog.message('summary.bugbotTelemetry', { + return catalogText(catalog, 'summary.bugbotTelemetry', { outcome: escapeTable(projection.telemetry.outcome), effort: escapeTable(projection.telemetry.configuredEffort), elapsed: Math.max(0, Math.round(projection.telemetry.elapsedMs)), @@ -216,16 +217,16 @@ function formatFindingStates( projection: BugbotResultFindingStateProjection, catalog: ActionSummaryMessageCatalog, ): string { - if (projection.status === 'invalid') return catalog.message('summary.invalid'); + if (projection.status === 'invalid') return catalogText(catalog, 'summary.invalid'); if (projection.status === 'absent') return '—'; return Object.entries(projection.counts) .filter(([, value]) => value > 0) - .map(([state, value]) => `${catalog.message(`summary.findingState.${state as ActionSummaryFindingState}`)}=${value}`) - .join(', ') || catalog.message('summary.none'); + .map(([state, value]) => `${catalogText(catalog, `summary.findingState.${state as ActionSummaryFindingState}`)}=${value}`) + .join(', ') || catalogText(catalog, 'summary.none'); } function renderResults(results: readonly Result[], catalog: ActionSummaryMessageCatalog): string { - if (results.length === 0) return `_${catalog.message('summary.noResult')}_`; + if (results.length === 0) return `_${catalogText(catalog, 'summary.noResult')}_`; return results.map(result => { const icon = result.success ? '✅' : '❌'; const details = result.steps @@ -235,17 +236,44 @@ function renderResults(results: readonly Result[], catalog: ActionSummaryMessage .flatMap((error) => { const view = buildApplicationErrorPresentation(error); return [ - ` - **${catalog.message('summary.impact')}:** ${sanitizePublishedError(view.impact)}`, - ` - **${catalog.message('summary.cause')} (\`${view.code}\`):** ${sanitizePublishedError(view.cause)}`, - ` - **${catalog.message('summary.action')}:** ${sanitizePublishedError(view.action)}`, - ` - **${catalog.message('summary.retainedState')}:** ${sanitizePublishedError(view.retainedState)}`, - ` - **${catalog.message('summary.reference')}:** \`${view.reference}\``, + ` - **${catalogText(catalog, 'summary.impact')}:** ${sanitizePublishedError(view.impact)}`, + ` - **${catalogText(catalog, 'summary.cause')} (\`${view.code}\`):** ${sanitizePublishedError(view.cause)}`, + ` - **${catalogText(catalog, 'summary.action')}:** ${sanitizePublishedError(view.action)}`, + ` - **${catalogText(catalog, 'summary.retainedState')}:** ${sanitizePublishedError(view.retainedState)}`, + ` - **${catalogText(catalog, 'summary.reference')}:** \`${view.reference}\``, ]; }); - return [`- ${icon} **${escapeTable(result.id || catalog.message('summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); + return [`- ${icon} **${escapeTable(result.id || catalogText(catalog, 'summary.unnamedResult'))}**`, ...details, ...errors].join('\n'); }).join('\n'); } +function catalogText( + catalog: ActionSummaryMessageCatalog, + id: Parameters[0], + variables: Readonly> = {}, +): string { + return escapeMarkdownText(catalog.message(id, variables)); +} + +function sanitizeLocalizationSummaryLabels(labels: LocalizationSummaryLabels): LocalizationSummaryLabels { + return Object.freeze(Object.fromEntries( + Object.entries(labels).map(([key, value]) => [key, escapeMarkdownText(value)]), + ) as unknown as LocalizationSummaryLabels); +} + +/** Catalog output is untrusted prose; renderers alone own Markdown structure. */ +function escapeMarkdownText(value: unknown): string { + return String(value ?? '').slice(0, 2_000) + .replace(/[\r\n]+/gu, ' ') + .replace(//gu, '-->') + .replace(/::/gu, ':\u200b:') + .replace(/@(?=[a-zA-Z0-9][a-zA-Z0-9-])/gu, '@\u200b') + .replace(/\b(https?):\/\//giu, '$1:\u200b//') + .replace(/\\/gu, '\\\\') + .replace(/([`*_[\]<>|~])/gu, '\\$1'); +} + function escapeTable(value: string): string { return String(value ?? '').replace(/[|\r\n]/g, match => match === '|' ? '\\|' : ' '); } diff --git a/src/domain/__tests__/message_catalog.test.ts b/src/domain/__tests__/message_catalog.test.ts index 0c5d57b1c..f68b5c240 100644 --- a/src/domain/__tests__/message_catalog.test.ts +++ b/src/domain/__tests__/message_catalog.test.ts @@ -157,6 +157,8 @@ describe('message catalog domain policy', () => { [{ greeting: 'Voir https://example.com/{name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'URL'], [{ greeting: ' {name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'marker'], [{ greeting: 'Bonjour | forged {name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'table delimiter'], + [{ greeting: 'Bonjour\n# forged {name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'heading'], + [{ greeting: '[Bonjour](example) {name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'Markdown link'], [{ greeting: '\u202EBonjour {name}', items: { one: '{count} élément', other: '{count} éléments' } }, 'bidi control'], [{ greeting: 'Bonjour {name}', items: { one: '{count} élément', other: '' } }, 'empty plural variant'], ])('rejects unsafe dynamic catalog output: %s', (value, _reason) => { From 56614d900c564bca4e2c6bc70c19c22abce5a6f2 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 00:21:56 +0200 Subject: [PATCH 3/3] codex-localized-action-summary: remove unreachable catalog fallback --- build/github_action/index.js | 2 +- src/application/policies/action_summary_policy.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build/github_action/index.js b/build/github_action/index.js index 989415056..13f5186e0 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41493,7 +41493,7 @@ function sanitizeLocalizationSummaryLabels(labels) { } /** Catalog output is untrusted prose; renderers alone own Markdown structure. */ function escapeMarkdownText(value) { - return String(value ?? '').slice(0, 2000) + return value.slice(0, 2000) .replace(/[\r\n]+/gu, ' ') .replace(//gu, '-->') diff --git a/src/application/policies/action_summary_policy.ts b/src/application/policies/action_summary_policy.ts index e1adc9013..1e2f73a90 100644 --- a/src/application/policies/action_summary_policy.ts +++ b/src/application/policies/action_summary_policy.ts @@ -262,8 +262,8 @@ function sanitizeLocalizationSummaryLabels(labels: LocalizationSummaryLabels): L } /** Catalog output is untrusted prose; renderers alone own Markdown structure. */ -function escapeMarkdownText(value: unknown): string { - return String(value ?? '').slice(0, 2_000) +function escapeMarkdownText(value: string): string { + return value.slice(0, 2_000) .replace(/[\r\n]+/gu, ' ') .replace(//gu, '-->')