diff --git a/build/cli/index.js b/build/cli/index.js index 12414ca97..fae3f1500 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -44265,6 +44265,13 @@ const PUBLICATION_SURFACE_MESSAGE_IDS = Object.freeze([ 'interaction.translation.interpretedRequest', 'interaction.translation.originalRequest', 'interaction.error.heading', + 'interaction.branchSync.heading', + 'interaction.branchSync.alreadyAligned', + 'interaction.branchSync.dryRunClean', + 'interaction.branchSync.dryRunConflicted', + 'interaction.branchSync.merged', + 'interaction.branchSync.agentResolution', + 'interaction.branchSync.verification', 'interaction.status.heading', 'interaction.status.repository', 'interaction.status.target', @@ -44349,6 +44356,22 @@ const ENGLISH_MESSAGES = Object.freeze({ 'interaction.translation.interpretedRequest': 'Interpreted request', 'interaction.translation.originalRequest': 'Original request', 'interaction.error.heading': 'Request could not be completed', + 'interaction.branchSync.heading': 'Branch synchronized', + 'interaction.branchSync.alreadyAligned': 'No changes were needed — {workingBranch} already contains {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Dry run complete — {parentBranch} can be merged into {workingBranch} without conflicts. Nothing was pushed.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflict. Nothing was pushed and no agent was invoked.', + other: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflicts. Nothing was pushed and no agent was invoked.', + }), + 'interaction.branchSync.merged': '{parentBranch} was merged into {workingBranch} and pushed as {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'The fixer resolved {count} conflicted file.', + other: 'The fixer resolved {count} conflicted files.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: '{count} verification check passed.', + other: '{count} verification checks passed.', + }), 'interaction.status.heading': 'Copilot status', 'interaction.status.repository': 'Repository', 'interaction.status.target': 'Target', @@ -44430,6 +44453,25 @@ const SPANISH_MESSAGES = Object.freeze({ 'interaction.translation.interpretedRequest': 'Solicitud interpretada', 'interaction.translation.originalRequest': 'Solicitud original', 'interaction.error.heading': 'No se pudo completar la solicitud', + 'interaction.branchSync.heading': 'Rama sincronizada', + 'interaction.branchSync.alreadyAligned': 'No fue necesario hacer cambios: {workingBranch} ya contiene {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Simulación completada: {parentBranch} se puede integrar en {workingBranch} sin conflictos. No se envió ningún cambio.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflicto. No se envió ningún cambio ni se invocó al agente.', + many: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + other: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + }), + 'interaction.branchSync.merged': '{parentBranch} se integró en {workingBranch} y se envió como {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'El agente de corrección resolvió {count} archivo con conflictos.', + many: 'El agente de corrección resolvió {count} archivos con conflictos.', + other: 'El agente de corrección resolvió {count} archivos con conflictos.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: 'Se superó {count} comprobación de verificación.', + many: 'Se superaron {count} comprobaciones de verificación.', + other: 'Se superaron {count} comprobaciones de verificación.', + }), 'interaction.status.heading': 'Estado de Copilot', 'interaction.status.repository': 'Repositorio', 'interaction.status.target': 'Destino', @@ -45118,11 +45160,16 @@ function selectSemanticReplyIntents(context) { return []; const correlationId = safeMarkerToken(context.correlationId); const replies = context.results.flatMap(result => { - if (!result.executed || !result.success) + if (!result.success) return []; const payload = (0, result_1.getResultPayload)(result.payload); if (!payload) return []; + const branchSync = branchSyncResultProjection(result.id, payload, context.correlationId); + if (branchSync) + return [replyIntent(context, correlationId, 'branch-sync-result', branchSync)]; + if (!result.executed) + return []; const directAnswer = directAnswerProjection(payload); if (directAnswer) return [replyIntent(context, correlationId, 'direct-answer', directAnswer)]; @@ -45197,8 +45244,38 @@ function renderReplyBody(intent, catalog) { (0, application_error_presentation_policy_1.renderApplicationErrorMarkdown)(intent.projection.error, messages.render), ].join('\n'); } + if (intent.projection.kind === 'branch-sync-result') { + return renderBranchSyncResult(intent.projection, catalog); + } return (0, status_command_policy_1.formatCopilotStatus)(intent.projection.snapshot, intent.locale, catalog); } +function renderBranchSyncResult(projection, catalog) { + const values = { + parentBranch: inlineRef(projection.parentBranch), + workingBranch: inlineRef(projection.workingBranch), + }; + if (projection.outcome === 'already-aligned') { + return safeCatalogSentence(catalog.render('interaction.branchSync.alreadyAligned', values)); + } + if (projection.outcome === 'dry-run-clean') { + return safeCatalogSentence(catalog.render('interaction.branchSync.dryRunClean', values)); + } + if (projection.outcome === 'dry-run-conflicted') { + return safeCatalogSentence(catalog.render('interaction.branchSync.dryRunConflicted', { ...values, count: projection.conflictCount })); + } + const details = [ + safeCatalogSentence(catalog.render('interaction.branchSync.merged', { + ...values, + commitSha: inlineRef(projection.commitSha), + })), + ...(projection.outcome === 'merged-with-agent' ? [ + safeCatalogSentence(catalog.render('interaction.branchSync.agentResolution', { count: projection.conflictCount })), + ] : []), + safeCatalogSentence(catalog.render('interaction.branchSync.verification', { count: projection.verificationCount })), + ]; + return [`## ${safeCatalogSentence(catalog.render('interaction.branchSync.heading'))}`, '', ...details] + .join('\n\n'); +} function renderDirectAnswer(projection, catalog) { const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(projection.answer).trim(); const translation = projection.translation @@ -45282,6 +45359,39 @@ function directAnswerProjection(payload) { ...(translation ? { translation } : {}), }); } +function branchSyncResultProjection(resultId, payload, correlationId) { + const outcomes = [ + 'already-aligned', + 'dry-run-clean', + 'dry-run-conflicted', + 'merged-cleanly', + 'merged-with-agent', + ]; + if (resultId !== 'SyncBranchUseCase' + || !correlationId?.startsWith('comment:') + || !outcomes.includes(payload.outcome) + || typeof payload.parentBranch !== 'string' + || !payload.parentBranch.trim() + || typeof payload.workingBranch !== 'string' + || !payload.workingBranch.trim()) + return undefined; + const commitSha = (0, git_object_id_1.canonicalGitObjectId)(payload.commitSha); + const outcome = payload.outcome; + if ((outcome === 'merged-cleanly' || outcome === 'merged-with-agent') && !commitSha) + return undefined; + const conflictPaths = Array.isArray(payload.conflictPaths) ? payload.conflictPaths : []; + const verificationCount = positiveCount(payload.verificationCount); + const common = { + kind: 'branch-sync-result', + parentBranch: payload.parentBranch.trim(), + workingBranch: payload.workingBranch.trim(), + conflictCount: conflictPaths.length, + verificationCount, + }; + return outcome === 'merged-cleanly' || outcome === 'merged-with-agent' + ? Object.freeze({ ...common, outcome, commitSha: commitSha }) + : Object.freeze({ ...common, outcome }); +} function translationProjection(value) { const translation = (0, result_1.getResultPayload)(value); if (!translation @@ -45342,6 +45452,15 @@ function statusIntent(topic, issueNumber, key, sourceVersion, locale, projection function positiveInteger(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } +function positiveCount(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; +} +function inlineRef(value) { + return `\`${value.replace(/[\r\n`<>]/gu, '').replace(/@/gu, '@\u200b').slice(0, 255)}\``; +} +function safeCatalogSentence(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 700).replace(/[\r\n]+/gu, ' ').trim(); +} function replyIntent(context, correlationId, messageKey, projection) { return Object.freeze({ kind: 'reply', diff --git a/build/github_action/index.js b/build/github_action/index.js index 17a6e3fcb..f10616326 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -39661,12 +39661,34 @@ async function finishGithubAction(execution, results, issueNotificationPort, con } const summary = await writeActionSummary(execution, summaryPort, catalogResolver); if (!dryRun) - await publishCopilotEvidence(execution, results, summary.text, evidencePort); + await publishCopilotEvidence(execution, results, summary.text, evidencePort, catalogResolver); const completionError = firstApplicationError(results) ?? bugbotCompletionError(execution, results, dryRun); if (completionError) core.setFailed((0, application_error_presentation_policy_1.renderApplicationErrorText)(completionError, summary.errorMessage)); } +function actionSummaryContext(execution, catalogResolutions) { + const locale = execution.locale ?? { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }; + return Object.freeze({ + owner: execution.owner, + repository: execution.repo, + eventName: execution.eventName, + issueNumber: execution.issue?.number ?? -1, + pullRequestNumber: execution.pullRequest?.number ?? -1, + lifecycleState: (0, copilot_lifecycle_1.lifecycleStateFromLabels)(execution.isPullRequest + ? execution.labels?.currentPullRequestLabels ?? [] + : execution.labels?.currentIssueLabels ?? [], execution.labels?.lifecycle), + pullRequestDescriptionMode: execution.ai.getPullRequestDescriptionMode(), + failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, + locale: Object.freeze({ + repository: locale.repository, + issue: locale.issue, + pullRequest: locale.pullRequest, + }), + catalogResolutions, + results: execution.currentConfiguration.results, + }); +} function extractBugbotTelemetry(results) { return results.flatMap((result) => { const snapshot = (0, result_1.getResultPayload)(result.payload)?.bugbotTelemetry; @@ -39716,25 +39738,7 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { else { const catalog = await (0, action_summary_message_catalog_1.resolveActionSummaryCatalog)(locale.repository, execution.ai.getAgentConfiguration('planner'), catalogResolver); errorMessage = (id, variables) => catalog.message(id, variables); - body = (0, action_summary_policy_1.buildActionSummary)({ - owner: execution.owner, - repository: execution.repo, - eventName: execution.eventName, - issueNumber: execution.issue?.number ?? -1, - pullRequestNumber: execution.pullRequest?.number ?? -1, - lifecycleState: (0, copilot_lifecycle_1.lifecycleStateFromLabels)(execution.isPullRequest - ? execution.labels?.currentPullRequestLabels ?? [] - : execution.labels?.currentIssueLabels ?? [], execution.labels?.lifecycle), - pullRequestDescriptionMode: execution.ai.getPullRequestDescriptionMode(), - failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, - locale: { - repository: locale.repository, - issue: locale.issue, - pullRequest: locale.pullRequest, - }, - catalogResolutions: catalogResolver?.observations?.() ?? [], - results: execution.currentConfiguration.results, - }, catalog); + body = (0, action_summary_policy_1.buildActionSummary)(actionSummaryContext(execution, catalogResolver?.observations?.() ?? []), catalog); } const localizationEvidence = appendLocalizationEvidence ? (0, action_summary_policy_1.renderLocalizationSummarySection)({ @@ -39754,17 +39758,33 @@ async function writeActionSummary(execution, summaryPort, catalogResolver) { } return { text: summaryText, errorMessage }; } -async function publishCopilotEvidence(execution, results, summary, evidencePort) { +async function publishCopilotEvidence(execution, results, summary, evidencePort, catalogResolver) { if (!evidencePort) return; const headSha = execution.inputs?.pull_request?.head?.sha || (execution.isPush ? process.env.GITHUB_SHA : undefined); - const evidence = (0, copilot_evidence_policy_1.buildCopilotEvidence)({ + const evidenceInput = { eventName: execution.eventName, headSha, summary, results, failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, + }; + if (!(0, copilot_evidence_policy_1.buildCopilotEvidence)(evidenceInput)) + return; + const locale = execution.locale ?? { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }; + const surfaceLocale = execution.isPullRequest + ? locale.pullRequest + : execution.isIssue ? locale.issue : locale.repository; + const catalog = await (0, action_summary_message_catalog_1.resolveActionSummaryCatalog)(surfaceLocale, execution.ai.getAgentConfiguration('planner'), catalogResolver); + const evidenceSummary = surfaceLocale === locale.repository + ? summary + : (0, action_summary_policy_1.buildActionSummary)(actionSummaryContext(execution, catalogResolver?.observations?.() ?? []), catalog); + const evidence = (0, copilot_evidence_policy_1.buildCopilotEvidence)({ + ...evidenceInput, + summary: evidenceSummary, + locale: surfaceLocale, + catalog, }); if (!evidence) return; @@ -41147,6 +41167,9 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', 'none', 'resultSucceeded', 'resultFailed', 'resultSkipped', + 'evidenceActionableFindings', 'evidenceActionableFailures', + 'evidencePartialCoverage', 'evidenceSuperseded', 'evidenceSkipped', + 'evidenceNoActionableResult', 'evidenceCompleted', ]); const TEMPLATE_MESSAGE_IDS = Object.freeze([ 'summary.target.pullRequest', @@ -41207,6 +41230,13 @@ const ENGLISH_SIMPLE = Object.freeze({ resultSucceeded: 'Succeeded', resultFailed: 'Failed', resultSkipped: 'Skipped', + evidenceActionableFindings: 'Copilot found actionable findings', + evidenceActionableFailures: 'Copilot found actionable failures', + evidencePartialCoverage: 'Copilot review has partial coverage', + evidenceSuperseded: 'Copilot review was superseded', + evidenceSkipped: 'Copilot review was skipped', + evidenceNoActionableResult: 'Copilot review produced no actionable result', + evidenceCompleted: 'Copilot completed successfully', }); const SPANISH_SIMPLE = Object.freeze({ heading: 'Ejecución de Copilot', @@ -41247,6 +41277,13 @@ const SPANISH_SIMPLE = Object.freeze({ resultSucceeded: 'Completado', resultFailed: 'Fallido', resultSkipped: 'Omitido', + evidenceActionableFindings: 'Copilot encontró hallazgos que requieren atención', + evidenceActionableFailures: 'Copilot encontró fallos que requieren atención', + evidencePartialCoverage: 'La revisión de Copilot tiene cobertura parcial', + evidenceSuperseded: 'La revisión de Copilot fue sustituida', + evidenceSkipped: 'La revisión de Copilot fue omitida', + evidenceNoActionableResult: 'La revisión de Copilot no produjo resultados que requieran atención', + evidenceCompleted: 'Copilot terminó correctamente', }); const ENGLISH_TEMPLATES = Object.freeze({ 'summary.target.pullRequest': 'PR #{number}', @@ -44829,6 +44866,8 @@ exports.buildCopilotEvidence = buildCopilotEvidence; 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 github_comment_publication_policy_1 = __nccwpck_require__(72712); +const action_summary_message_catalog_1 = __nccwpck_require__(61544); /** Creates a stable native Check Run projection without performing GitHub I/O. */ function buildCopilotEvidence(context) { const headSha = context.headSha?.trim(); @@ -44852,11 +44891,12 @@ function buildCopilotEvidence(context) { hasResults: context.results.length > 0, bugbotTelemetry, }); + const catalog = context.catalog ?? (0, action_summary_message_catalog_1.resolveStaticActionSummaryCatalog)(context.locale ?? 'en-US'); return { name: resolveEvidenceName(context.eventName, isReviewEvent), headSha, conclusion, - title: resolveEvidenceTitle(conclusion, failures, hasActionableFindings, bugbotTelemetry), + title: safeEvidenceTitle(resolveEvidenceTitle(conclusion, failures, hasActionableFindings, bugbotTelemetry, catalog)), summary: context.summary.slice(0, 20000), }; } @@ -44896,26 +44936,33 @@ function resolveEvidenceName(eventName, isReviewEvent) { return 'Copilot / Plan'; return 'Copilot / Verification'; } -function resolveEvidenceTitle(conclusion, failureCount, hasActionableFindings, telemetry) { +function resolveEvidenceTitle(conclusion, failureCount, hasActionableFindings, telemetry, catalog) { if (hasActionableFindings && failureCount === 0) - return 'Copilot found actionable findings'; + return catalog.message('summary.evidenceActionableFindings'); if (conclusion === 'failure') - return 'Copilot found actionable failures'; - const incompleteTitle = incompleteReviewTitle(telemetry); + return catalog.message('summary.evidenceActionableFailures'); + const incompleteTitle = incompleteReviewTitle(telemetry, catalog); if (incompleteTitle) return incompleteTitle; if (conclusion === 'neutral') - return 'Copilot review produced no actionable result'; - return 'Copilot completed successfully'; + return catalog.message('summary.evidenceNoActionableResult'); + return catalog.message('summary.evidenceCompleted'); } -function incompleteReviewTitle(telemetry) { +function incompleteReviewTitle(telemetry, catalog = (0, action_summary_message_catalog_1.resolveStaticActionSummaryCatalog)('en-US')) { switch (telemetry?.outcome) { - case 'partial': return 'Copilot review has partial coverage'; - case 'superseded': return 'Copilot review was superseded'; - case 'skipped': return 'Copilot review was skipped'; + case 'partial': return catalog.message('summary.evidencePartialCoverage'); + case 'superseded': return catalog.message('summary.evidenceSuperseded'); + case 'skipped': return catalog.message('summary.evidenceSkipped'); default: return undefined; } } +function safeEvidenceTitle(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 255) + .replace(/[\r\n]+/gu, ' ') + .trim() + .slice(0, 255) + || 'Copilot result'; +} /***/ }), @@ -46861,6 +46908,13 @@ const PUBLICATION_SURFACE_MESSAGE_IDS = Object.freeze([ 'interaction.translation.interpretedRequest', 'interaction.translation.originalRequest', 'interaction.error.heading', + 'interaction.branchSync.heading', + 'interaction.branchSync.alreadyAligned', + 'interaction.branchSync.dryRunClean', + 'interaction.branchSync.dryRunConflicted', + 'interaction.branchSync.merged', + 'interaction.branchSync.agentResolution', + 'interaction.branchSync.verification', 'interaction.status.heading', 'interaction.status.repository', 'interaction.status.target', @@ -46945,6 +46999,22 @@ const ENGLISH_MESSAGES = Object.freeze({ 'interaction.translation.interpretedRequest': 'Interpreted request', 'interaction.translation.originalRequest': 'Original request', 'interaction.error.heading': 'Request could not be completed', + 'interaction.branchSync.heading': 'Branch synchronized', + 'interaction.branchSync.alreadyAligned': 'No changes were needed — {workingBranch} already contains {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Dry run complete — {parentBranch} can be merged into {workingBranch} without conflicts. Nothing was pushed.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflict. Nothing was pushed and no agent was invoked.', + other: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflicts. Nothing was pushed and no agent was invoked.', + }), + 'interaction.branchSync.merged': '{parentBranch} was merged into {workingBranch} and pushed as {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'The fixer resolved {count} conflicted file.', + other: 'The fixer resolved {count} conflicted files.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: '{count} verification check passed.', + other: '{count} verification checks passed.', + }), 'interaction.status.heading': 'Copilot status', 'interaction.status.repository': 'Repository', 'interaction.status.target': 'Target', @@ -47026,6 +47096,25 @@ const SPANISH_MESSAGES = Object.freeze({ 'interaction.translation.interpretedRequest': 'Solicitud interpretada', 'interaction.translation.originalRequest': 'Solicitud original', 'interaction.error.heading': 'No se pudo completar la solicitud', + 'interaction.branchSync.heading': 'Rama sincronizada', + 'interaction.branchSync.alreadyAligned': 'No fue necesario hacer cambios: {workingBranch} ya contiene {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Simulación completada: {parentBranch} se puede integrar en {workingBranch} sin conflictos. No se envió ningún cambio.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflicto. No se envió ningún cambio ni se invocó al agente.', + many: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + other: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + }), + 'interaction.branchSync.merged': '{parentBranch} se integró en {workingBranch} y se envió como {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'El agente de corrección resolvió {count} archivo con conflictos.', + many: 'El agente de corrección resolvió {count} archivos con conflictos.', + other: 'El agente de corrección resolvió {count} archivos con conflictos.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: 'Se superó {count} comprobación de verificación.', + many: 'Se superaron {count} comprobaciones de verificación.', + other: 'Se superaron {count} comprobaciones de verificación.', + }), 'interaction.status.heading': 'Estado de Copilot', 'interaction.status.repository': 'Repositorio', 'interaction.status.target': 'Destino', @@ -47714,11 +47803,16 @@ function selectSemanticReplyIntents(context) { return []; const correlationId = safeMarkerToken(context.correlationId); const replies = context.results.flatMap(result => { - if (!result.executed || !result.success) + if (!result.success) return []; const payload = (0, result_1.getResultPayload)(result.payload); if (!payload) return []; + const branchSync = branchSyncResultProjection(result.id, payload, context.correlationId); + if (branchSync) + return [replyIntent(context, correlationId, 'branch-sync-result', branchSync)]; + if (!result.executed) + return []; const directAnswer = directAnswerProjection(payload); if (directAnswer) return [replyIntent(context, correlationId, 'direct-answer', directAnswer)]; @@ -47793,8 +47887,38 @@ function renderReplyBody(intent, catalog) { (0, application_error_presentation_policy_1.renderApplicationErrorMarkdown)(intent.projection.error, messages.render), ].join('\n'); } + if (intent.projection.kind === 'branch-sync-result') { + return renderBranchSyncResult(intent.projection, catalog); + } return (0, status_command_policy_1.formatCopilotStatus)(intent.projection.snapshot, intent.locale, catalog); } +function renderBranchSyncResult(projection, catalog) { + const values = { + parentBranch: inlineRef(projection.parentBranch), + workingBranch: inlineRef(projection.workingBranch), + }; + if (projection.outcome === 'already-aligned') { + return safeCatalogSentence(catalog.render('interaction.branchSync.alreadyAligned', values)); + } + if (projection.outcome === 'dry-run-clean') { + return safeCatalogSentence(catalog.render('interaction.branchSync.dryRunClean', values)); + } + if (projection.outcome === 'dry-run-conflicted') { + return safeCatalogSentence(catalog.render('interaction.branchSync.dryRunConflicted', { ...values, count: projection.conflictCount })); + } + const details = [ + safeCatalogSentence(catalog.render('interaction.branchSync.merged', { + ...values, + commitSha: inlineRef(projection.commitSha), + })), + ...(projection.outcome === 'merged-with-agent' ? [ + safeCatalogSentence(catalog.render('interaction.branchSync.agentResolution', { count: projection.conflictCount })), + ] : []), + safeCatalogSentence(catalog.render('interaction.branchSync.verification', { count: projection.verificationCount })), + ]; + return [`## ${safeCatalogSentence(catalog.render('interaction.branchSync.heading'))}`, '', ...details] + .join('\n\n'); +} function renderDirectAnswer(projection, catalog) { const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(projection.answer).trim(); const translation = projection.translation @@ -47878,6 +48002,39 @@ function directAnswerProjection(payload) { ...(translation ? { translation } : {}), }); } +function branchSyncResultProjection(resultId, payload, correlationId) { + const outcomes = [ + 'already-aligned', + 'dry-run-clean', + 'dry-run-conflicted', + 'merged-cleanly', + 'merged-with-agent', + ]; + if (resultId !== 'SyncBranchUseCase' + || !correlationId?.startsWith('comment:') + || !outcomes.includes(payload.outcome) + || typeof payload.parentBranch !== 'string' + || !payload.parentBranch.trim() + || typeof payload.workingBranch !== 'string' + || !payload.workingBranch.trim()) + return undefined; + const commitSha = (0, git_object_id_1.canonicalGitObjectId)(payload.commitSha); + const outcome = payload.outcome; + if ((outcome === 'merged-cleanly' || outcome === 'merged-with-agent') && !commitSha) + return undefined; + const conflictPaths = Array.isArray(payload.conflictPaths) ? payload.conflictPaths : []; + const verificationCount = positiveCount(payload.verificationCount); + const common = { + kind: 'branch-sync-result', + parentBranch: payload.parentBranch.trim(), + workingBranch: payload.workingBranch.trim(), + conflictCount: conflictPaths.length, + verificationCount, + }; + return outcome === 'merged-cleanly' || outcome === 'merged-with-agent' + ? Object.freeze({ ...common, outcome, commitSha: commitSha }) + : Object.freeze({ ...common, outcome }); +} function translationProjection(value) { const translation = (0, result_1.getResultPayload)(value); if (!translation @@ -47938,6 +48095,15 @@ function statusIntent(topic, issueNumber, key, sourceVersion, locale, projection function positiveInteger(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } +function positiveCount(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; +} +function inlineRef(value) { + return `\`${value.replace(/[\r\n`<>]/gu, '').replace(/@/gu, '@\u200b').slice(0, 255)}\``; +} +function safeCatalogSentence(value) { + return (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(value, 700).replace(/[\r\n]+/gu, ' ').trim(); +} function replyIntent(context, correlationId, messageKey, projection) { return Object.freeze({ kind: 'reply', diff --git a/docs/configuration.mdx b/docs/configuration.mdx index fef5713c3..c6d6a0a81 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -36,7 +36,7 @@ Copilot provides extensive configuration options to customize your workflow. Use Addressed comments are interpreted in the effective surface locale with at most one agent call. Copilot never edits the human-authored source comment. When translation is needed, the useful bot response includes a collapsed, escaped copy of the interpreted request and original text. Unaddressed comments remain inert. Commands, paths, refs, URLs, identifiers, machine-readable fields, and operational logs are not localized. See [Agent CLI configuration](/agents/cli-configuration). - Repository-aware `copilot doctor`, local command errors, Job Summaries, and Action completion errors use `repository-locale`, not the issue or pull-request overrides. English is the default and atomic fallback; Spanish exact/base variants use reviewed bundled copy, while any other valid BCP-47 locale requires one complete schema-constrained catalog response from the configured planner/language agent. Error views localize impact, recovery, retained state, and retry guidance while preserving stable codes and correlation references; raw producer or provider messages are not replayed. Setup and CLI failures that occur before a repository profile is available remain in English. + Repository-aware `copilot doctor`, local command errors, Job Summaries, and Action completion errors use `repository-locale`, not the issue or pull-request overrides. Native Check names remain stable for branch protection; their human-facing title and summary use the target surface locale (issue, pull request, or repository). English is the default and atomic fallback; Spanish exact/base variants use reviewed bundled copy, while any other valid BCP-47 locale requires one complete schema-constrained catalog response from the configured planner/language agent. Error views localize impact, recovery, retained state, and retry guidance while preserving stable codes and correlation references; raw producer or provider messages are not replayed. Setup and CLI failures that occur before a repository profile is available remain in English. - `ai-ignore-files`: Comma-separated list of paths to ignore for AI operations (e.g. progress detection, Bugbot; not used for PR description, where the agent computes the diff in the workspace). - `bugbot-severity`: Minimum severity for Bugbot findings to report: `info`, `low`, `medium`, or `high` (default: `low`). Findings below this threshold are not posted on the issue or PR. - `bugbot-comment-limit`: Maximum number of findings to publish as individual issue comments or inline PR review comments (default: `20`). Extra findings are summarized in the PR review body or in one issue-only overflow comment. Clamped between 1 and 200. diff --git a/docs/issues/branch-synchronization.mdx b/docs/issues/branch-synchronization.mdx index c162532b0..1e7ae027c 100644 --- a/docs/issues/branch-synchronization.mdx +++ b/docs/issues/branch-synchronization.mdx @@ -128,6 +128,18 @@ Every command finishes with a comment in the conversation. The report distinguis - merged after fixer-agent conflict resolution; or - safely aborted, with a publishable reason and no provider-secret details. +Successful and no-change reports use the issue or pull-request surface locale, +are correlated to the requesting comment, and are reconciled at most once on +event replay. For example, an already aligned branch receives one sentence: + +```markdown +No changes were needed — `feature/42` already contains `develop`. +``` + +A completed merge names the branches, pushed commit, fixer use when applicable, +and number of verification checks. It never repeats internal execution steps or +adds a generic action summary to the conversation. + Successful reports include the parent and working branches, verification count, and merge commit SHA in the structured run result. The subsequent push observer resolves the prior stale-branch card. ## Why the normal commit workflow remains limited diff --git a/docs/pull-requests/capabilities.mdx b/docs/pull-requests/capabilities.mdx index 8901c095c..ceee97f68 100644 --- a/docs/pull-requests/capabilities.mdx +++ b/docs/pull-requests/capabilities.mdx @@ -106,7 +106,10 @@ 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. +operator diagnosis even when the surrounding copy is localized. The stable +`Copilot / Review` Check name remains locale-independent for branch protection; +its human-facing title and summary use `pull-requests-locale` (or the inherited +repository locale) and fall back atomically to English. The supplied workflow names runs as `Copilot PR · :`, so normal analysis, review-state observation, and merge-queue admission are recognizable diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index c3a8dac39..d23df2ba8 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -324,6 +324,7 @@ "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_evidence_policy.ts", "src/application/policies/copilot_interaction_policy.ts", "src/application/policies/inactivity_message_catalog.ts", "src/application/policies/inactivity_notification_policy.ts", diff --git a/specs/repository-locale-and-localization.md b/specs/repository-locale-and-localization.md index e8ce35dd5..77553a3e7 100644 --- a/specs/repository-locale-and-localization.md +++ b/specs/repository-locale-and-localization.md @@ -1019,6 +1019,13 @@ text reports localized semantic status and result counts without replaying internal step or reminder prose. Catalog version 3 owns the added reply and CLI descriptors. +Native Check presentation now follows the surface-locale row in §6.1. The +branch-protection Check name remains stable, while its bounded title and summary +resolve from one complete issue, pull-request, or repository catalog. The same +publication catalog renders source-correlated `sync-branch` outcomes in the +requesting conversation's effective locale; branch names, commit IDs, command +identity, markers, and correlation remain locale-independent. + ### 13.5 Rollback Rollback MUST preserve the new input inheritance reader and legacy/new marker diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index fe282d8a7..44d5e236f 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -1078,6 +1078,13 @@ through the generic semantic publication port, and content-free transition and cleanup evidence feeds the repository-locale Job Summary. A publication-only failure preserves the updated stale card and cannot replay branch work. +Native Check and branch-sync command feedback close two additional surface +gaps. Check names remain stable machine contracts, while their title and summary +resolve atomically in the effective issue, pull-request, or repository locale. +An explicitly addressed `sync-branch` request produces one source-correlated +semantic reply for aligned, dry-run, and successful merge outcomes; replay +reuses the reply and never exposes the command's internal `Result.steps`. + No remote product flag is required. Each phase must be independently releasable and its compatibility adapter must fail closed to Job Summary, not fall back to generic comments. diff --git a/src/actions/__tests__/github_action_completion.test.ts b/src/actions/__tests__/github_action_completion.test.ts index 6efc16678..efbe35312 100644 --- a/src/actions/__tests__/github_action_completion.test.ts +++ b/src/actions/__tests__/github_action_completion.test.ts @@ -493,6 +493,175 @@ describe('finishGithubAction', () => { ); }); + it('renders a PR Check title and summary in the pull-request locale while the Job Summary stays in the repository locale', async () => { + const action = Object.assign(execution(), { + eventName: 'pull_request', + isIssue: false, + isPullRequest: true, + pullRequest: { number: 12, action: 'synchronize' }, + locale: { repository: 'en-US', issue: 'en-US', pullRequest: 'es-ES' }, + inputs: { pull_request: { head: { sha: 'abc1234' } } }, + }); + const results = [new Result({ + id: 'DetectPotentialProblemsUseCase', + success: true, + executed: true, + payload: { + bugbotTelemetry: { + schemaVersion: 1, + outcome: 'no-findings', + elapsedMs: 12, + configuredEffort: 'smart', + headSha: 'abc1234', + }, + findingStates: completeFindingStates(), + }, + })]; + + await finishGithubAction( + action, + results, + {} as never, + {} as never, + { publish: mockEvidencePublish }, + { publish: mockSummaryPublish }, + new ResolveMessageCatalogUseCase(), + ); + + expect(mockSummaryPublish).toHaveBeenCalledWith(expect.stringContaining('# Copilot execution')); + expect(mockEvidencePublish).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Copilot / Review', + title: 'Copilot terminó correctamente', + summary: expect.stringContaining('# Ejecución de Copilot'), + }), + 'test-owner', + 'test-repo', + 'product-pat', + ); + const evidence = mockEvidencePublish.mock.calls[0][0]; + expect(evidence.summary).not.toContain('# Copilot execution'); + }); + + it('uses the repository locale for a push Check without changing its stable name', async () => { + const previousSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = 'push-sha'; + const action = Object.assign(execution(), { + eventName: 'push', + isIssue: false, + isPullRequest: false, + isPush: true, + locale: { repository: 'es-ES', issue: 'en-US', pullRequest: 'en-US' }, + }); + try { + await finishGithubAction( + action, + [new Result({ id: 'VerifyUseCase', success: true, executed: true })], + {} as never, + {} as never, + { publish: mockEvidencePublish }, + { publish: mockSummaryPublish }, + new ResolveMessageCatalogUseCase(), + ); + } finally { + if (previousSha === undefined) delete process.env.GITHUB_SHA; + else process.env.GITHUB_SHA = previousSha; + } + + expect(mockSummaryPublish).toHaveBeenCalledWith(expect.stringContaining('# Ejecución de Copilot')); + expect(mockEvidencePublish).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Copilot / Verification', + headSha: 'push-sha', + title: 'Copilot terminó correctamente', + summary: expect.stringContaining('# Ejecución de Copilot'), + }), + 'test-owner', + 'test-repo', + 'product-pat', + ); + }); + + it('defaults legacy push completion without locale or conversation targets to English', async () => { + const previousSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = 'legacy-push-sha'; + const action = Object.assign(execution(), { + eventName: 'push', + isIssue: false, + isPullRequest: false, + isPush: true, + }); + const legacyShape = action as unknown as { + locale?: Execution['locale']; + issue?: Execution['issue']; + pullRequest?: Execution['pullRequest']; + }; + delete legacyShape.locale; + delete legacyShape.issue; + delete legacyShape.pullRequest; + try { + await finishGithubAction( + action, + [new Result({ id: 'VerifyUseCase', success: true, executed: true })], + {} as never, + {} as never, + { publish: mockEvidencePublish }, + { publish: mockSummaryPublish }, + ); + } finally { + if (previousSha === undefined) delete process.env.GITHUB_SHA; + else process.env.GITHUB_SHA = previousSha; + } + + expect(mockSummaryPublish).toHaveBeenCalledWith(expect.stringContaining('# Copilot execution')); + expect(mockEvidencePublish).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Copilot / Verification', + headSha: 'legacy-push-sha', + title: 'Copilot completed successfully', + summary: expect.stringContaining('# Copilot execution'), + }), + 'test-owner', + 'test-repo', + 'product-pat', + ); + }); + + it('keeps optional summary and Check provider failures non-blocking', async () => { + mockSummaryPublish.mockRejectedValueOnce(new Error('summary unavailable')); + mockEvidencePublish.mockRejectedValueOnce(new Error('checks unavailable')); + const action = Object.assign(execution(), { + eventName: 'pull_request', + isIssue: false, + isPullRequest: true, + pullRequest: { number: 12, action: 'synchronize' }, + inputs: { pull_request: { head: { sha: 'abc1234' } } }, + }); + const results = [new Result({ + id: 'DetectPotentialProblemsUseCase', success: true, executed: true, + payload: { + bugbotTelemetry: { + schemaVersion: 1, outcome: 'no-findings', elapsedMs: 12, + configuredEffort: 'smart', headSha: 'abc1234', + }, + findingStates: completeFindingStates(), + }, + })]; + + await expect(finishGithubAction( + action, + results, + {} as never, + {} as never, + { publish: mockEvidencePublish }, + { publish: mockSummaryPublish }, + )).resolves.toBeUndefined(); + + expect(mockSummaryPublish).toHaveBeenCalledTimes(1); + expect(mockEvidencePublish).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + it('keeps metadata-only PR completion out of the stable Review Check', async () => { const action = Object.assign(execution(), { owner: 'test-owner', diff --git a/src/actions/github_action_completion.ts b/src/actions/github_action_completion.ts index be22b4c02..3e2021479 100644 --- a/src/actions/github_action_completion.ts +++ b/src/actions/github_action_completion.ts @@ -13,8 +13,10 @@ import { logInfo } from '../utils/logger'; import { buildActionSummary, renderLocalizationSummarySection, + type ActionSummaryContext, type LocalizationSummaryLabels, } from '../application/policies/action_summary_policy'; +import type { CatalogResolutionObservation } from '../domain/message_catalog'; import { resolveActionSummaryCatalog } from '../application/policies/action_summary_message_catalog'; import { lifecycleStateFromLabels } from '../domain/copilot_lifecycle'; import type { CopilotEvidencePort } from '../application/ports/copilot_evidence_ports'; @@ -73,7 +75,13 @@ export async function finishGithubAction( logInfo('Configuration persistence skipped: this single action does not modify execution configuration.'); } const summary = await writeActionSummary(execution, summaryPort, catalogResolver); - if (!dryRun) await publishCopilotEvidence(execution, results, summary.text, evidencePort); + if (!dryRun) await publishCopilotEvidence( + execution, + results, + summary.text, + evidencePort, + catalogResolver, + ); const completionError = firstApplicationError(results) ?? bugbotCompletionError(execution, results, dryRun); if (completionError) core.setFailed(renderApplicationErrorText(completionError, summary.errorMessage)); @@ -84,6 +92,35 @@ interface WrittenActionSummary { readonly errorMessage: ApplicationErrorMessageReader; } +function actionSummaryContext( + execution: Execution, + catalogResolutions: readonly CatalogResolutionObservation[], +): ActionSummaryContext { + const locale = execution.locale ?? { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }; + return Object.freeze({ + owner: execution.owner, + repository: execution.repo, + eventName: execution.eventName, + issueNumber: execution.issue?.number ?? -1, + pullRequestNumber: execution.pullRequest?.number ?? -1, + lifecycleState: lifecycleStateFromLabels( + execution.isPullRequest + ? execution.labels?.currentPullRequestLabels ?? [] + : execution.labels?.currentIssueLabels ?? [], + execution.labels?.lifecycle, + ), + pullRequestDescriptionMode: execution.ai.getPullRequestDescriptionMode(), + failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, + locale: Object.freeze({ + repository: locale.repository, + issue: locale.issue, + pullRequest: locale.pullRequest, + }), + catalogResolutions, + results: execution.currentConfiguration.results, + }); +} + function extractBugbotTelemetry(results: readonly Result[]): unknown[] { return results.flatMap((result) => { const snapshot = getResultPayload(result.payload)?.bugbotTelemetry; @@ -145,28 +182,10 @@ async function writeActionSummary( catalogResolver, ); errorMessage = (id, variables) => catalog.message(id, variables); - body = buildActionSummary({ - owner: execution.owner, - repository: execution.repo, - eventName: execution.eventName, - issueNumber: execution.issue?.number ?? -1, - pullRequestNumber: execution.pullRequest?.number ?? -1, - lifecycleState: lifecycleStateFromLabels( - execution.isPullRequest - ? execution.labels?.currentPullRequestLabels ?? [] - : execution.labels?.currentIssueLabels ?? [], - execution.labels?.lifecycle, - ), - pullRequestDescriptionMode: execution.ai.getPullRequestDescriptionMode(), - failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, - locale: { - repository: locale.repository, - issue: locale.issue, - pullRequest: locale.pullRequest, - }, - catalogResolutions: catalogResolver?.observations?.() ?? [], - results: execution.currentConfiguration.results, - }, catalog); + body = buildActionSummary( + actionSummaryContext(execution, catalogResolver?.observations?.() ?? []), + catalog, + ); } const localizationEvidence = appendLocalizationEvidence ? renderLocalizationSummarySection({ @@ -190,16 +209,39 @@ async function publishCopilotEvidence( results: Result[], summary: string, evidencePort: CopilotEvidencePort | undefined, + catalogResolver?: MessageCatalogResolutionPort, ): Promise { if (!evidencePort) return; const headSha = execution.inputs?.pull_request?.head?.sha || (execution.isPush ? process.env.GITHUB_SHA : undefined); - const evidence = buildCopilotEvidence({ + const evidenceInput = { eventName: execution.eventName, headSha, summary, results, failOnUnresolvedFindings: execution.ai.getBugbotReviewConfiguration().failOnUnresolved, + }; + if (!buildCopilotEvidence(evidenceInput)) return; + const locale = execution.locale ?? { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }; + const surfaceLocale = execution.isPullRequest + ? locale.pullRequest + : execution.isIssue ? locale.issue : locale.repository; + const catalog = await resolveActionSummaryCatalog( + surfaceLocale, + execution.ai.getAgentConfiguration('planner'), + catalogResolver, + ); + const evidenceSummary = surfaceLocale === locale.repository + ? summary + : buildActionSummary( + actionSummaryContext(execution, catalogResolver?.observations?.() ?? []), + catalog, + ); + const evidence = buildCopilotEvidence({ + ...evidenceInput, + summary: evidenceSummary, + locale: surfaceLocale, + catalog, }); if (!evidence) return; try { diff --git a/src/application/policies/__tests__/copilot_evidence_policy.test.ts b/src/application/policies/__tests__/copilot_evidence_policy.test.ts index 2890ff496..2d89c9030 100644 --- a/src/application/policies/__tests__/copilot_evidence_policy.test.ts +++ b/src/application/policies/__tests__/copilot_evidence_policy.test.ts @@ -1,5 +1,6 @@ import { buildCopilotEvidence } from '../copilot_evidence_policy'; import { Result } from '../../../data/model/result'; +import { resolveStaticActionSummaryCatalog } from '../action_summary_message_catalog'; const telemetry = (outcome: string, headSha = 'sha-123') => ({ bugbotTelemetry: { schemaVersion: 1, outcome, elapsedMs: 10, configuredEffort: 'smart', headSha }, @@ -207,6 +208,57 @@ describe('buildCopilotEvidence', () => { })).toMatchObject({ name: 'Copilot / Verification', conclusion: 'success' }); }); + it('localizes human-facing Check titles while preserving the stable Check name', () => { + const evidence = buildCopilotEvidence({ + eventName: 'issues', + headSha: 'sha-123', + summary: '# Ejecución de Copilot', + results: [new Result({ id: 'plan', success: true, executed: true })], + locale: 'es-MX', + }); + + expect(evidence).toMatchObject({ + name: 'Copilot / Plan', + title: 'Copilot terminó correctamente', + summary: '# Ejecución de Copilot', + }); + }); + + it('bounds and neutralizes a dynamically resolved Check title', () => { + const english = resolveStaticActionSummaryCatalog('en-US'); + const catalog = { + ...english, + message: jest.fn((id: string) => id === 'summary.evidenceCompleted' + ? `@team\n::notice:: ${'x'.repeat(400)}` + : english.message(id as Parameters[0])), + } as typeof english; + + const evidence = buildCopilotEvidence({ + eventName: 'push', headSha: 'sha-123', summary: 'summary', catalog, + results: [new Result({ id: 'verify', success: true, executed: true })], + }); + + expect(evidence?.title.length).toBeLessThanOrEqual(255); + expect(evidence?.title).toContain('@\u200bteam'); + expect(evidence?.title).toContain(':\u200b:notice'); + expect(evidence?.title).not.toContain('\n'); + }); + + it('falls back to a stable English Check title when a resolved catalog returns empty copy', () => { + const english = resolveStaticActionSummaryCatalog('en-US'); + const catalog = { + ...english, + message: jest.fn((id: string) => id === 'summary.evidenceCompleted' + ? ' ' + : english.message(id as Parameters[0])), + } as typeof english; + + expect(buildCopilotEvidence({ + eventName: 'push', headSha: 'sha-123', summary: 'summary', catalog, + results: [new Result({ id: 'verify', success: true, executed: true })], + })?.title).toBe('Copilot result'); + }); + it.each([ { open: '1', reopened: 0 }, { open: 0, reopened: '1' }, diff --git a/src/application/policies/__tests__/publication_message_catalog.test.ts b/src/application/policies/__tests__/publication_message_catalog.test.ts index 9086a7b77..162707ae9 100644 --- a/src/application/policies/__tests__/publication_message_catalog.test.ts +++ b/src/application/policies/__tests__/publication_message_catalog.test.ts @@ -37,8 +37,10 @@ describe('publication message catalog', () => { expect(validateCatalogDefinition(ENGLISH_PUBLICATION_DEFINITION, PUBLICATION_MESSAGE_IDS)).toEqual([]); expect(validateCatalogDefinition(SPANISH_PUBLICATION_DEFINITION, PUBLICATION_MESSAGE_IDS)).toEqual([]); for (const id of PUBLICATION_MESSAGE_IDS) { - expect(catalogPlaceholders(SPANISH_PUBLICATION_DEFINITION.messages[id])) - .toEqual(catalogPlaceholders(ENGLISH_PUBLICATION_DEFINITION.messages[id])); + const english = ENGLISH_PUBLICATION_DEFINITION.messages[id]; + const spanish = SPANISH_PUBLICATION_DEFINITION.messages[id]; + expect(catalogPlaceholders(typeof spanish === 'string' ? spanish : spanish.other)) + .toEqual(catalogPlaceholders(typeof english === 'string' ? english : english.other)); } }); diff --git a/src/application/policies/__tests__/semantic_result_publication_policy.test.ts b/src/application/policies/__tests__/semantic_result_publication_policy.test.ts index 3c9481aaa..a5dbd523e 100644 --- a/src/application/policies/__tests__/semantic_result_publication_policy.test.ts +++ b/src/application/policies/__tests__/semantic_result_publication_policy.test.ts @@ -273,6 +273,107 @@ describe('semantic result publication policy', () => { expect(body).not.toContain('banned'); }); + it.each([ + ['already-aligned', 'No changes were needed', undefined], + ['dry-run-clean', 'Dry run complete', undefined], + ['dry-run-conflicted', '2 conflicts', undefined], + ['merged-cleanly', '## Branch synchronized', SOURCE_HEAD], + ['merged-with-agent', 'The fixer resolved 2 conflicted files.', SOURCE_HEAD], + ] as const)('renders the explicit branch-sync %s outcome as one correlated reply', (outcome, expected, commitSha) => { + const [intent] = selectSemanticReplyIntents({ + locale: 'en-US', target: { kind: 'issue', number: 8 }, correlationId: 'comment:81', + results: [new Result({ + id: 'SyncBranchUseCase', success: true, executed: outcome !== 'already-aligned', + steps: ['legacy branch-sync prose'], + payload: { + outcome, + parentBranch: 'develop', + workingBranch: 'feature/8-sync', + conflictPaths: outcome === 'dry-run-conflicted' || outcome === 'merged-with-agent' ? ['a.ts', 'b.ts'] : [], + verificationCount: 2, + ...(commitSha ? { commitSha } : {}), + }, + })], + }); + + expect(intent).toMatchObject({ messageKey: 'branch-sync-result', projection: { kind: 'branch-sync-result', outcome } }); + const body = renderSemanticReply(intent); + expect(body).toContain(expected); + expect(body).not.toContain('legacy branch-sync prose'); + expect(body.length).toBeLessThan(800); + }); + + it('renders branch-sync outcomes in the configured issue locale and keeps refs inert', () => { + const [intent] = selectSemanticReplyIntents({ + locale: 'es-ES', target: { kind: 'issue', number: 8 }, correlationId: 'comment:81', + results: [new Result({ + id: 'SyncBranchUseCase', success: true, executed: false, + payload: { + outcome: 'already-aligned', + parentBranch: 'develop@team', + workingBranch: 'feature/`', + conflictPaths: [], verificationCount: 0, + }, + })], + }); + + const body = renderSemanticReply(intent); + expect(body).toContain('No fue necesario hacer cambios'); + expect(body).toContain('@\u200bteam'); + expect(body).not.toContain(''); + }); + + it.each([ + ['background event', 'event:abc12345', SOURCE_HEAD, 'merged-cleanly'], + ['missing commit identity', 'comment:81', undefined, 'merged-cleanly'], + ['unknown outcome', 'comment:81', SOURCE_HEAD, 'unknown'], + ] as const)('does not publish a malformed or %s branch-sync success', (_label, correlationId, commitSha, outcome) => { + expect(selectSemanticReplyIntents({ + locale: 'en-US', target: { kind: 'issue', number: 8 }, correlationId, + results: [new Result({ + id: 'SyncBranchUseCase', success: true, executed: true, + payload: { + outcome, parentBranch: 'develop', workingBranch: 'feature/8', + conflictPaths: [], verificationCount: 1, ...(commitSha ? { commitSha } : {}), + }, + })], + })).toEqual([]); + }); + + it.each([ + ['wrong result', 'OtherUseCase', 'develop', 'feature/8'], + ['non-string parent', 'SyncBranchUseCase', 42, 'feature/8'], + ['blank parent', 'SyncBranchUseCase', ' ', 'feature/8'], + ['non-string working branch', 'SyncBranchUseCase', 'develop', 42], + ['blank working branch', 'SyncBranchUseCase', 'develop', ' '], + ] as const)('rejects a branch-sync payload with %s', (_label, id, parentBranch, workingBranch) => { + expect(selectSemanticReplyIntents({ + locale: 'en-US', target: { kind: 'issue', number: 8 }, correlationId: 'comment:81', + results: [new Result({ + id, success: true, executed: true, + payload: { + outcome: 'already-aligned', parentBranch, workingBranch, + conflictPaths: [], verificationCount: 0, + }, + })], + })).toEqual([]); + }); + + it.each(['invalid', -1, 1.5] as const)('normalizes an invalid branch-sync verification count (%s)', verificationCount => { + const [intent] = selectSemanticReplyIntents({ + locale: 'en-US', target: { kind: 'issue', number: 8 }, correlationId: 'comment:81', + results: [new Result({ + id: 'SyncBranchUseCase', success: true, executed: true, + payload: { + outcome: 'dry-run-conflicted', parentBranch: 'develop', workingBranch: 'feature/8', + conflictPaths: 'not-an-array', verificationCount, + }, + })], + }); + + expect(intent.projection).toMatchObject({ kind: 'branch-sync-result', conflictCount: 0, verificationCount: 0 }); + }); + it('publishes one localized semantic error for an explicit request without producer prose', () => { const [intent] = selectSemanticReplyIntents({ locale: 'es-ES', target: { kind: 'issue', number: 8 }, correlationId: 'comment:81', diff --git a/src/application/policies/action_summary_message_catalog.ts b/src/application/policies/action_summary_message_catalog.ts index 84a60c391..7b13dd625 100644 --- a/src/application/policies/action_summary_message_catalog.ts +++ b/src/application/policies/action_summary_message_catalog.ts @@ -27,6 +27,9 @@ const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'pullRequestLocale', 'catalogResolution', 'descriptors', 'reason', 'failure', 'findings', 'partial', 'superseded', 'skipped', 'dryRun', 'success', 'invalid', 'none', 'resultSucceeded', 'resultFailed', 'resultSkipped', + 'evidenceActionableFindings', 'evidenceActionableFailures', + 'evidencePartialCoverage', 'evidenceSuperseded', 'evidenceSkipped', + 'evidenceNoActionableResult', 'evidenceCompleted', ] as const); const TEMPLATE_MESSAGE_IDS = Object.freeze([ @@ -104,6 +107,13 @@ const ENGLISH_SIMPLE: Readonly> = Object.freeze resultSucceeded: 'Succeeded', resultFailed: 'Failed', resultSkipped: 'Skipped', + evidenceActionableFindings: 'Copilot found actionable findings', + evidenceActionableFailures: 'Copilot found actionable failures', + evidencePartialCoverage: 'Copilot review has partial coverage', + evidenceSuperseded: 'Copilot review was superseded', + evidenceSkipped: 'Copilot review was skipped', + evidenceNoActionableResult: 'Copilot review produced no actionable result', + evidenceCompleted: 'Copilot completed successfully', }); const SPANISH_SIMPLE: Readonly> = Object.freeze({ @@ -145,6 +155,13 @@ const SPANISH_SIMPLE: Readonly> = Object.freeze resultSucceeded: 'Completado', resultFailed: 'Fallido', resultSkipped: 'Omitido', + evidenceActionableFindings: 'Copilot encontró hallazgos que requieren atención', + evidenceActionableFailures: 'Copilot encontró fallos que requieren atención', + evidencePartialCoverage: 'La revisión de Copilot tiene cobertura parcial', + evidenceSuperseded: 'La revisión de Copilot fue sustituida', + evidenceSkipped: 'La revisión de Copilot fue omitida', + evidenceNoActionableResult: 'La revisión de Copilot no produjo resultados que requieran atención', + evidenceCompleted: 'Copilot terminó correctamente', }); const ENGLISH_TEMPLATES: Readonly> = Object.freeze({ diff --git a/src/application/policies/copilot_evidence_policy.ts b/src/application/policies/copilot_evidence_policy.ts index 102bfdebd..7c984cf09 100644 --- a/src/application/policies/copilot_evidence_policy.ts +++ b/src/application/policies/copilot_evidence_policy.ts @@ -3,6 +3,11 @@ import type { CopilotEvidence } from '../ports/copilot_evidence_ports'; import { projectBugbotResultTelemetry, type BugbotTelemetryProjection } from './bugbot_telemetry_projection_policy'; import { projectBugbotResultFindingStates } from './bugbot_result_finding_state_projection_policy'; import { countActionableBugbotFindings } from '../../domain/bugbot/review_state'; +import { sanitizeAgentMarkdown } from './github_comment_publication_policy'; +import { + resolveStaticActionSummaryCatalog, + type ActionSummaryMessageCatalog, +} from './action_summary_message_catalog'; export interface CopilotEvidenceContext { readonly eventName: string; @@ -10,6 +15,8 @@ export interface CopilotEvidenceContext { readonly summary: string; readonly results: readonly Result[]; readonly failOnUnresolvedFindings?: boolean; + readonly locale?: string; + readonly catalog?: ActionSummaryMessageCatalog; } /** Creates a stable native Check Run projection without performing GitHub I/O. */ @@ -33,11 +40,18 @@ export function buildCopilotEvidence(context: CopilotEvidenceContext): CopilotEv hasResults: context.results.length > 0, bugbotTelemetry, }); + const catalog = context.catalog ?? resolveStaticActionSummaryCatalog(context.locale ?? 'en-US'); return { name: resolveEvidenceName(context.eventName, isReviewEvent), headSha, conclusion, - title: resolveEvidenceTitle(conclusion, failures, hasActionableFindings, bugbotTelemetry), + title: safeEvidenceTitle(resolveEvidenceTitle( + conclusion, + failures, + hasActionableFindings, + bugbotTelemetry, + catalog, + )), summary: context.summary.slice(0, 20_000), }; } @@ -88,20 +102,32 @@ function resolveEvidenceTitle( failureCount: number, hasActionableFindings: boolean, telemetry: BugbotTelemetryProjection | undefined, + catalog: ActionSummaryMessageCatalog, ): string { - if (hasActionableFindings && failureCount === 0) return 'Copilot found actionable findings'; - if (conclusion === 'failure') return 'Copilot found actionable failures'; - const incompleteTitle = incompleteReviewTitle(telemetry); + if (hasActionableFindings && failureCount === 0) return catalog.message('summary.evidenceActionableFindings'); + if (conclusion === 'failure') return catalog.message('summary.evidenceActionableFailures'); + const incompleteTitle = incompleteReviewTitle(telemetry, catalog); if (incompleteTitle) return incompleteTitle; - if (conclusion === 'neutral') return 'Copilot review produced no actionable result'; - return 'Copilot completed successfully'; + if (conclusion === 'neutral') return catalog.message('summary.evidenceNoActionableResult'); + return catalog.message('summary.evidenceCompleted'); } -function incompleteReviewTitle(telemetry: BugbotTelemetryProjection | undefined): string | undefined { +function incompleteReviewTitle( + telemetry: BugbotTelemetryProjection | undefined, + catalog: ActionSummaryMessageCatalog = resolveStaticActionSummaryCatalog('en-US'), +): string | undefined { switch (telemetry?.outcome) { - case 'partial': return 'Copilot review has partial coverage'; - case 'superseded': return 'Copilot review was superseded'; - case 'skipped': return 'Copilot review was skipped'; + case 'partial': return catalog.message('summary.evidencePartialCoverage'); + case 'superseded': return catalog.message('summary.evidenceSuperseded'); + case 'skipped': return catalog.message('summary.evidenceSkipped'); default: return undefined; } } + +function safeEvidenceTitle(value: string): string { + return sanitizeAgentMarkdown(value, 255) + .replace(/[\r\n]+/gu, ' ') + .trim() + .slice(0, 255) + || 'Copilot result'; +} diff --git a/src/application/policies/publication_message_catalog.ts b/src/application/policies/publication_message_catalog.ts index 23b3fbe14..47a249331 100644 --- a/src/application/policies/publication_message_catalog.ts +++ b/src/application/policies/publication_message_catalog.ts @@ -66,6 +66,13 @@ const PUBLICATION_SURFACE_MESSAGE_IDS = Object.freeze([ 'interaction.translation.interpretedRequest', 'interaction.translation.originalRequest', 'interaction.error.heading', + 'interaction.branchSync.heading', + 'interaction.branchSync.alreadyAligned', + 'interaction.branchSync.dryRunClean', + 'interaction.branchSync.dryRunConflicted', + 'interaction.branchSync.merged', + 'interaction.branchSync.agentResolution', + 'interaction.branchSync.verification', 'interaction.status.heading', 'interaction.status.repository', 'interaction.status.target', @@ -193,6 +200,22 @@ const ENGLISH_MESSAGES: Readonly> = 'interaction.translation.interpretedRequest': 'Interpreted request', 'interaction.translation.originalRequest': 'Original request', 'interaction.error.heading': 'Request could not be completed', + 'interaction.branchSync.heading': 'Branch synchronized', + 'interaction.branchSync.alreadyAligned': 'No changes were needed — {workingBranch} already contains {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Dry run complete — {parentBranch} can be merged into {workingBranch} without conflicts. Nothing was pushed.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflict. Nothing was pushed and no agent was invoked.', + other: 'Dry run complete — merging {parentBranch} into {workingBranch} has {count} conflicts. Nothing was pushed and no agent was invoked.', + }), + 'interaction.branchSync.merged': '{parentBranch} was merged into {workingBranch} and pushed as {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'The fixer resolved {count} conflicted file.', + other: 'The fixer resolved {count} conflicted files.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: '{count} verification check passed.', + other: '{count} verification checks passed.', + }), 'interaction.status.heading': 'Copilot status', 'interaction.status.repository': 'Repository', 'interaction.status.target': 'Target', @@ -275,6 +298,25 @@ const SPANISH_MESSAGES: Readonly> = 'interaction.translation.interpretedRequest': 'Solicitud interpretada', 'interaction.translation.originalRequest': 'Solicitud original', 'interaction.error.heading': 'No se pudo completar la solicitud', + 'interaction.branchSync.heading': 'Rama sincronizada', + 'interaction.branchSync.alreadyAligned': 'No fue necesario hacer cambios: {workingBranch} ya contiene {parentBranch}.', + 'interaction.branchSync.dryRunClean': 'Simulación completada: {parentBranch} se puede integrar en {workingBranch} sin conflictos. No se envió ningún cambio.', + 'interaction.branchSync.dryRunConflicted': Object.freeze({ + one: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflicto. No se envió ningún cambio ni se invocó al agente.', + many: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + other: 'Simulación completada: integrar {parentBranch} en {workingBranch} produce {count} conflictos. No se envió ningún cambio ni se invocó al agente.', + }), + 'interaction.branchSync.merged': '{parentBranch} se integró en {workingBranch} y se envió como {commitSha}.', + 'interaction.branchSync.agentResolution': Object.freeze({ + one: 'El agente de corrección resolvió {count} archivo con conflictos.', + many: 'El agente de corrección resolvió {count} archivos con conflictos.', + other: 'El agente de corrección resolvió {count} archivos con conflictos.', + }), + 'interaction.branchSync.verification': Object.freeze({ + one: 'Se superó {count} comprobación de verificación.', + many: 'Se superaron {count} comprobaciones de verificación.', + other: 'Se superaron {count} comprobaciones de verificación.', + }), 'interaction.status.heading': 'Estado de Copilot', 'interaction.status.repository': 'Repositorio', 'interaction.status.target': 'Destino', diff --git a/src/application/policies/semantic_result_publication_policy.ts b/src/application/policies/semantic_result_publication_policy.ts index f9b8c7c96..423a0628a 100644 --- a/src/application/policies/semantic_result_publication_policy.ts +++ b/src/application/policies/semantic_result_publication_policy.ts @@ -48,6 +48,41 @@ export interface ProgressPublicationProjection { export type SemanticStatusProjection = PlanPublicationProjection | ProgressPublicationProjection; export type SemanticStatusIntent = StatusPublicationIntent; +type BranchSyncReplyProjection = + | { + readonly kind: 'branch-sync-result'; + readonly outcome: 'already-aligned'; + readonly parentBranch: string; + readonly workingBranch: string; + readonly conflictCount: number; + readonly verificationCount: number; + } + | { + readonly kind: 'branch-sync-result'; + readonly outcome: 'dry-run-clean'; + readonly parentBranch: string; + readonly workingBranch: string; + readonly conflictCount: number; + readonly verificationCount: number; + } + | { + readonly kind: 'branch-sync-result'; + readonly outcome: 'dry-run-conflicted'; + readonly parentBranch: string; + readonly workingBranch: string; + readonly conflictCount: number; + readonly verificationCount: number; + } + | { + readonly kind: 'branch-sync-result'; + readonly outcome: 'merged-cleanly' | 'merged-with-agent'; + readonly parentBranch: string; + readonly workingBranch: string; + readonly conflictCount: number; + readonly verificationCount: number; + readonly commitSha: string; + }; + export type SemanticReplyProjection = | { readonly kind: 'help'; readonly botLogin: string } | { readonly kind: 'welcome'; readonly botLogin: string } @@ -57,6 +92,7 @@ export type SemanticReplyProjection = readonly translation?: TranslationPublication; } | { readonly kind: 'application-error'; readonly error: ApplicationErrorPresentationSource } + | BranchSyncReplyProjection | { readonly kind: 'access-policy' } | { readonly kind: 'status-command'; readonly snapshot: CopilotStatusSnapshot }; export type SemanticReplyIntent = ReplyPublicationIntent; @@ -122,9 +158,12 @@ export function selectSemanticReplyIntents(context: SemanticPublicationContext): if (!context.target || !positiveInteger(context.target.number) || !context.correlationId?.trim()) return []; const correlationId = safeMarkerToken(context.correlationId); const replies = context.results.flatMap(result => { - if (!result.executed || !result.success) return []; + if (!result.success) return []; const payload = getResultPayload(result.payload); if (!payload) return []; + const branchSync = branchSyncResultProjection(result.id, payload, context.correlationId); + if (branchSync) return [replyIntent(context, correlationId, 'branch-sync-result', branchSync)]; + if (!result.executed) return []; const directAnswer = directAnswerProjection(payload); if (directAnswer) return [replyIntent(context, correlationId, 'direct-answer', directAnswer)]; const publication = getResultPayload(payload.publication); @@ -198,9 +237,52 @@ function renderReplyBody(intent: SemanticReplyIntent, catalog: PublicationMessag renderApplicationErrorMarkdown(intent.projection.error, messages.render), ].join('\n'); } + if (intent.projection.kind === 'branch-sync-result') { + return renderBranchSyncResult(intent.projection, catalog); + } return formatCopilotStatus(intent.projection.snapshot, intent.locale, catalog); } +function renderBranchSyncResult( + projection: BranchSyncReplyProjection, + catalog: PublicationMessageCatalog, +): string { + const values = { + parentBranch: inlineRef(projection.parentBranch), + workingBranch: inlineRef(projection.workingBranch), + }; + if (projection.outcome === 'already-aligned') { + return safeCatalogSentence(catalog.render('interaction.branchSync.alreadyAligned', values)); + } + if (projection.outcome === 'dry-run-clean') { + return safeCatalogSentence(catalog.render('interaction.branchSync.dryRunClean', values)); + } + if (projection.outcome === 'dry-run-conflicted') { + return safeCatalogSentence(catalog.render( + 'interaction.branchSync.dryRunConflicted', + { ...values, count: projection.conflictCount }, + )); + } + const details = [ + safeCatalogSentence(catalog.render('interaction.branchSync.merged', { + ...values, + commitSha: inlineRef(projection.commitSha), + })), + ...(projection.outcome === 'merged-with-agent' ? [ + safeCatalogSentence(catalog.render( + 'interaction.branchSync.agentResolution', + { count: projection.conflictCount }, + )), + ] : []), + safeCatalogSentence(catalog.render( + 'interaction.branchSync.verification', + { count: projection.verificationCount }, + )), + ]; + return [`## ${safeCatalogSentence(catalog.render('interaction.branchSync.heading'))}`, '', ...details] + .join('\n\n'); +} + function renderDirectAnswer( projection: Extract, catalog: PublicationMessageCatalog, @@ -296,6 +378,42 @@ function directAnswerProjection(payload: Record): SemanticReply }); } +function branchSyncResultProjection( + resultId: string, + payload: Record, + correlationId: string | undefined, +): BranchSyncReplyProjection | undefined { + const outcomes = [ + 'already-aligned', + 'dry-run-clean', + 'dry-run-conflicted', + 'merged-cleanly', + 'merged-with-agent', + ] as const; + if (resultId !== 'SyncBranchUseCase' + || !correlationId?.startsWith('comment:') + || !outcomes.includes(payload.outcome as typeof outcomes[number]) + || typeof payload.parentBranch !== 'string' + || !payload.parentBranch.trim() + || typeof payload.workingBranch !== 'string' + || !payload.workingBranch.trim()) return undefined; + const commitSha = canonicalGitObjectId(payload.commitSha); + const outcome = payload.outcome as typeof outcomes[number]; + if ((outcome === 'merged-cleanly' || outcome === 'merged-with-agent') && !commitSha) return undefined; + const conflictPaths = Array.isArray(payload.conflictPaths) ? payload.conflictPaths : []; + const verificationCount = positiveCount(payload.verificationCount); + const common = { + kind: 'branch-sync-result', + parentBranch: payload.parentBranch.trim(), + workingBranch: payload.workingBranch.trim(), + conflictCount: conflictPaths.length, + verificationCount, + } as const; + return outcome === 'merged-cleanly' || outcome === 'merged-with-agent' + ? Object.freeze({ ...common, outcome, commitSha: commitSha as string }) + : Object.freeze({ ...common, outcome }); +} + function translationProjection(value: unknown): TranslationPublication | undefined { const translation = getResultPayload(value); if (!translation @@ -365,6 +483,18 @@ function positiveInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } +function positiveCount(value: unknown): number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +function inlineRef(value: string): string { + return `\`${value.replace(/[\r\n`<>]/gu, '').replace(/@/gu, '@\u200b').slice(0, 255)}\``; +} + +function safeCatalogSentence(value: string): string { + return sanitizeAgentMarkdown(value, 700).replace(/[\r\n]+/gu, ' ').trim(); +} + function replyIntent( context: SemanticPublicationContext, correlationId: string,