From 96352f176ae7bf9834968834750640dd5453df70 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 23:15:48 +0200 Subject: [PATCH 1/3] codex-localized-setup-doctor: localize setup doctor diagnostics --- build/cli/index.js | 991 ++++++++++++++---- build/github_action/index.js | 225 +++- docs/configuration-checklist.mdx | 1 + docs/configuration.mdx | 2 + docs/development/architecture.mdx | 11 + .../operations/verification.mdx | 2 + docs/single-actions/workflow-and-cli.mdx | 17 + scripts/coverage-budgets.json | 8 + specs/CATALOG.md | 26 +- specs/catalog.json | 39 +- specs/merge-queue-readiness.md | 43 +- specs/repository-locale-and-localization.md | 12 +- ...up-configuration-credentials-and-doctor.md | 51 +- specs/setup-doctor-architecture-hardening.md | 84 +- src/__tests__/cli.test.ts | 1 + .../__tests__/deployment_plan_policy.test.ts | 30 +- .../setup_doctor_message_catalog.test.ts | 90 ++ .../setup_doctor_report_policy.test.ts | 2 +- .../policies/deployment_message_catalog.ts | 28 +- .../policies/deployment_plan_policy.ts | 104 +- .../policies/merge_queue_message_catalog.ts | 148 +++ .../policies/setup_doctor_message_catalog.ts | 373 +++++++ .../policies/setup_doctor_report_policy.ts | 51 +- src/application/ports/setup_wizard_ports.ts | 3 + .../deployment_orchestration_runtime.ts | 13 +- .../deployment_orchestration_use_case.test.ts | 5 +- .../setup/__tests__/doctor_use_case.test.ts | 96 +- .../merge_queue_readiness_use_case.test.ts | 29 +- .../__tests__/setup_wizard_use_case.test.ts | 6 +- .../usecases/setup/doctor_use_case.ts | 247 +++-- .../setup/merge_queue_readiness_use_case.ts | 81 +- .../usecases/setup/setup_wizard_use_case.ts | 4 + .../github_publication_boundaries.test.ts | 5 + .../__tests__/setup_doctor_boundaries.test.ts | 26 + src/cli/__tests__/setup_presenters.test.ts | 26 + .../__tests__/setup_prompt_rendering.test.ts | 11 + src/cli/commands/doctor.ts | 6 +- src/cli/setup_doctor_presenter.ts | 82 +- src/cli/setup_prompt_rendering.ts | 57 +- .../setup_doctor_composition_root.ts | 12 +- 40 files changed, 2455 insertions(+), 593 deletions(-) create mode 100644 src/application/policies/__tests__/setup_doctor_message_catalog.test.ts create mode 100644 src/application/policies/merge_queue_message_catalog.ts create mode 100644 src/application/policies/setup_doctor_message_catalog.ts diff --git a/build/cli/index.js b/build/cli/index.js index 88b79ff03..1f2c4f504 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -43511,6 +43511,7 @@ exports.deploymentCopy = deploymentCopy; const deployment_operation_1 = __nccwpck_require__(92730); const message_catalog_1 = __nccwpck_require__(27097); const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'release', 'hotfix', 'currentStatus', 'noAction', 'actionRequired', 'progress', 'currentTransition', 'whatNext', 'links', 'technical', 'alreadyPublished', @@ -43562,6 +43563,7 @@ exports.DEPLOYMENT_MESSAGE_IDS = Object.freeze([ ...deployment_operation_1.DEPLOYMENT_PHASES.map(phase => `deployment.phase.${phase}`), ...DIAGRAM_KEYS.map(key => `deployment.diagram.${key}`), ...TEMPLATE_MESSAGE_IDS, + ...merge_queue_message_catalog_1.MERGE_QUEUE_MESSAGE_IDS, ]); const ENGLISH_SIMPLE = Object.freeze({ release: 'Release', hotfix: 'Hotfix', currentStatus: 'Current status', @@ -43695,25 +43697,26 @@ const SPANISH_TEMPLATES = Object.freeze({ 'deployment.milestone.reconciliationBlocked': '❌ Despliegue bloqueado: {reason}', 'deployment.milestone.complete': '✅ El despliegue {tag} y todos los destinos de reconciliación configurados se han completado.', }); -function catalogMessages(simple, phases, diagram, templates) { +function catalogMessages(simple, phases, diagram, templates, mergeQueue) { return Object.freeze({ ...Object.fromEntries(SIMPLE_MESSAGE_KEYS.map(key => [`deployment.${key}`, simple[key]])), ...Object.fromEntries(deployment_operation_1.DEPLOYMENT_PHASES.map(phase => [`deployment.phase.${phase}`, phases[phase]])), ...Object.fromEntries(DIAGRAM_KEYS.map(key => [`deployment.diagram.${key}`, diagram[key]])), ...templates, + ...mergeQueue, }); } exports.ENGLISH_DEPLOYMENT_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, locale: 'en-US', compatibleBaseLanguage: 'en', - messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_PHASES, ENGLISH_DIAGRAM, ENGLISH_TEMPLATES), + messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_PHASES, ENGLISH_DIAGRAM, ENGLISH_TEMPLATES, merge_queue_message_catalog_1.ENGLISH_MERGE_QUEUE_MESSAGES), }); exports.SPANISH_DEPLOYMENT_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, locale: 'es-ES', compatibleBaseLanguage: 'es', - messages: catalogMessages(SPANISH_SIMPLE, SPANISH_PHASES, SPANISH_DIAGRAM, SPANISH_TEMPLATES), + messages: catalogMessages(SPANISH_SIMPLE, SPANISH_PHASES, SPANISH_DIAGRAM, SPANISH_TEMPLATES, merge_queue_message_catalog_1.SPANISH_MERGE_QUEUE_MESSAGES), }); exports.DEPLOYMENT_CATALOG_DEFINITIONS = Object.freeze([ exports.ENGLISH_DEPLOYMENT_DEFINITION, @@ -43744,6 +43747,7 @@ function deploymentCopy(catalog) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildInitialDeploymentOperation = buildInitialDeploymentOperation; exports.selectPullRequestMode = selectPullRequestMode; +exports.pullRequestModeDecisionMessage = pullRequestModeDecisionMessage; exports.mergeQueueReadinessFailureMessage = mergeQueueReadinessFailureMessage; exports.selectBackmergeMode = selectBackmergeMode; exports.selectReconciliationTargetBranches = selectReconciliationTargetBranches; @@ -43752,7 +43756,7 @@ exports.buildReconciliationTarget = buildReconciliationTarget; exports.buildReconciliationBranchName = buildReconciliationBranchName; exports.validateInitialDeploymentInput = validateInitialDeploymentInput; const deployment_operation_1 = __nccwpck_require__(92730); -const sensitive_text_1 = __nccwpck_require__(47122); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); function buildInitialDeploymentOperation(input) { const strategy = input.kind === "release" ? input.configuration.releaseReconciliationStrategy @@ -43792,67 +43796,51 @@ function buildInitialDeploymentOperation(input) { } function selectPullRequestMode(configured, capabilities) { if (configured === "create-only") { - return { kind: "mode", mode: configured, reason: "Explicitly configured." }; + return { kind: "mode", mode: configured, reasonCode: 'explicit-create-only' }; } if (capabilities.mergeQueueObservationProblems.length > 0) { return { kind: "unsupported", - reason: `The target merge policy could not be verified: ${boundedDiagnostic(capabilities.mergeQueueObservationProblems[0].message)}`, + reasonCode: 'policy-observation-failed', }; } if (capabilities.mergeQueueRequired) { return configured === "auto-merge" - ? { kind: "unsupported", reason: "Auto-merge mode was selected, but the target requires its merge queue." } - : { kind: "mode", mode: "merge-queue", reason: "The target requires its merge queue." }; + ? { kind: "unsupported", reasonCode: 'auto-merge-rejected' } + : { kind: "mode", mode: "merge-queue", reasonCode: 'queue-required' }; } if (configured === "merge-queue") { - return { kind: "unsupported", reason: "The target does not expose a required merge queue." }; + return { kind: "unsupported", reasonCode: 'queue-not-exposed' }; } if (configured === "auto-merge") { return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "Native auto-merge was explicitly configured." } - : { kind: "unsupported", reason: "Native auto-merge is disabled for this repository." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-configured' } + : { kind: "unsupported", reasonCode: 'auto-merge-disabled' }; } if (capabilities.immediatelyMergeable) { - return { kind: "mode", mode: "auto-merge", reason: "GitHub reports the PR ready; native auto-merge preserves branch protection." }; + return { kind: "mode", mode: "auto-merge", reasonCode: 'immediately-mergeable' }; } return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "GitHub will merge after checks and reviews complete." } - : { kind: "mode", mode: "create-only", reason: "Repository auto-merge is unavailable; maintainer merge is required." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-available' } + : { kind: "mode", mode: "create-only", reasonCode: 'create-only-required' }; +} +function pullRequestModeDecisionMessage(decision, catalog) { + const ids = { + 'explicit-create-only': 'mergeQueue.decision.explicitCreateOnly', + 'policy-observation-failed': 'mergeQueue.decision.policyObservationFailed', + 'auto-merge-rejected': 'mergeQueue.decision.autoMergeRejected', + 'queue-required': 'mergeQueue.decision.queueRequired', + 'queue-not-exposed': 'mergeQueue.decision.queueNotExposed', + 'auto-merge-configured': 'mergeQueue.decision.autoMergeConfigured', + 'auto-merge-disabled': 'mergeQueue.decision.autoMergeDisabled', + 'immediately-mergeable': 'mergeQueue.decision.immediatelyMergeable', + 'auto-merge-available': 'mergeQueue.decision.autoMergeAvailable', + 'create-only-required': 'mergeQueue.decision.createOnlyRequired', + }; + return catalog.message(ids[decision.reasonCode]); } -function mergeQueueReadinessFailureMessage(readiness, locale = "en-US") { - const spanish = locale.toLowerCase().startsWith("es"); - const failed = readiness.producers.filter((producer) => producer.verdict === "unsupported" || producer.verdict === "unknown"); - const producerDetails = failed.slice(0, 5) - .map((producer) => `${boundedDiagnostic(producer.name)} [${producer.verdict}]: ${boundedDiagnostic(producer.reason)}`) - .join("; "); - const problemDetails = readiness.problems.slice(0, 3) - .map((problem) => `${problem.area}: ${boundedDiagnostic(problem.message)}`) - .join("; "); - const details = [producerDetails, problemDetails].filter(Boolean).join("; "); - const hasUnsupportedProducer = failed.some((producer) => producer.verdict === "unsupported"); - const hasObservationProblem = readiness.problems.length > 0; - if (spanish) { - const action = hasUnsupportedProducer - ? "Añade merge_group: checks_requested al workflow requerido y vuelve a intentarlo." - : hasObservationProblem - ? "Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo." - : "Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo."; - return `La preparación de la merge queue está en estado ${readiness.verdict} para el destino ${readiness.targetRole} ${boundedDiagnostic(readiness.targetBranch)}. ${details || "La evidencia del productor requerido está incompleta."} ${action}`; - } - const action = hasUnsupportedProducer - ? "Add merge_group: checks_requested to the required workflow, then retry." - : hasObservationProblem - ? "Restore read access and a valid response for the target policy and workflows, then retry." - : "Make the required producer support merge groups or add an exact reviewed check attestation, then retry."; - return `Merge queue readiness is ${readiness.verdict} for ${readiness.targetRole} target ${boundedDiagnostic(readiness.targetBranch)}. ${details || "Required producer evidence is incomplete."} ${action}`; -} -function boundedDiagnostic(value) { - return (0, sensitive_text_1.redactSensitiveText)(value) - .replace(/[\r\n<>]/g, " ") - .replace(/::/g, "﹕﹕") - .replace(/@/g, "@\u200b") - .slice(0, 500); +function mergeQueueReadinessFailureMessage(readiness, catalog) { + return (0, merge_queue_message_catalog_1.renderMergeQueueReadinessFailure)(readiness, catalog); } function selectBackmergeMode(configured, requiresStrictStatusChecks, directHeadIsUpToDate, directSourceIsExact = true) { const directIsUnsafe = !directSourceIsExact @@ -44533,6 +44521,139 @@ function buildManagedBranchPresentation(input) { } +/***/ }), + +/***/ 56033: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_MERGE_QUEUE_MESSAGES = exports.ENGLISH_MERGE_QUEUE_MESSAGES = exports.MERGE_QUEUE_MESSAGE_IDS = void 0; +exports.renderMergeQueueReadinessFailure = renderMergeQueueReadinessFailure; +exports.producerStateMessageId = producerStateMessageId; +exports.boundedMergeQueueDiagnostic = boundedMergeQueueDiagnostic; +const sensitive_text_1 = __nccwpck_require__(47122); +exports.MERGE_QUEUE_MESSAGE_IDS = Object.freeze([ + 'mergeQueue.readiness.failure', + 'mergeQueue.readiness.incompleteEvidence', + 'mergeQueue.readiness.action.unsupported', + 'mergeQueue.readiness.action.observation', + 'mergeQueue.readiness.action.unknown', + 'mergeQueue.producerState.verified', + 'mergeQueue.producerState.attested', + 'mergeQueue.producerState.unknown', + 'mergeQueue.producerState.unsupported', + 'mergeQueue.problem.classicProtection', + 'mergeQueue.problem.effectiveRules', + 'mergeQueue.problem.workflowContract', + 'mergeQueue.problem.queueMembership', + 'mergeQueue.decision.explicitCreateOnly', + 'mergeQueue.decision.policyObservationFailed', + 'mergeQueue.decision.autoMergeRejected', + 'mergeQueue.decision.queueRequired', + 'mergeQueue.decision.queueNotExposed', + 'mergeQueue.decision.autoMergeConfigured', + 'mergeQueue.decision.autoMergeDisabled', + 'mergeQueue.decision.immediatelyMergeable', + 'mergeQueue.decision.autoMergeAvailable', + 'mergeQueue.decision.createOnlyRequired', +]); +exports.ENGLISH_MERGE_QUEUE_MESSAGES = Object.freeze({ + 'mergeQueue.readiness.failure': 'Merge queue readiness is {verdict} for {role} target {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'Required producer evidence is incomplete.', + 'mergeQueue.readiness.action.unsupported': 'Add {event} to the required workflow, then retry.', + 'mergeQueue.readiness.action.observation': 'Restore read access and a valid response for the target policy and workflows, then retry.', + 'mergeQueue.readiness.action.unknown': 'Make the required producer support merge groups or add an exact reviewed check attestation, then retry.', + 'mergeQueue.producerState.verified': 'supports merge groups', + 'mergeQueue.producerState.attested': 'covered by an exact reviewed attestation', + 'mergeQueue.producerState.unknown': 'support could not be verified', + 'mergeQueue.producerState.unsupported': 'does not support merge groups', + 'mergeQueue.problem.classicProtection': 'Classic branch protection could not be inspected.', + 'mergeQueue.problem.effectiveRules': 'Effective repository rules could not be inspected.', + 'mergeQueue.problem.workflowContract': 'Required workflow contracts could not be inspected.', + 'mergeQueue.problem.queueMembership': 'Merge-queue membership could not be inspected.', + 'mergeQueue.decision.explicitCreateOnly': 'Create-only mode was explicitly configured.', + 'mergeQueue.decision.policyObservationFailed': 'The target merge policy could not be verified.', + 'mergeQueue.decision.autoMergeRejected': 'Auto-merge mode was selected, but the target requires its merge queue.', + 'mergeQueue.decision.queueRequired': 'The target requires its merge queue.', + 'mergeQueue.decision.queueNotExposed': 'The target does not expose a required merge queue.', + 'mergeQueue.decision.autoMergeConfigured': 'Native auto-merge was explicitly configured.', + 'mergeQueue.decision.autoMergeDisabled': 'Native auto-merge is disabled for this repository.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub reports the pull request ready; native auto-merge preserves branch protection.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub will merge after checks and reviews complete.', + 'mergeQueue.decision.createOnlyRequired': 'Repository auto-merge is unavailable; maintainer merge is required.', +}); +exports.SPANISH_MERGE_QUEUE_MESSAGES = Object.freeze({ + 'mergeQueue.readiness.failure': 'La preparación de la merge queue está en estado {verdict} para el destino {role} {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'La evidencia del productor requerido está incompleta.', + 'mergeQueue.readiness.action.unsupported': 'Añade {event} al workflow requerido y vuelve a intentarlo.', + 'mergeQueue.readiness.action.observation': 'Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo.', + 'mergeQueue.readiness.action.unknown': 'Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo.', + 'mergeQueue.producerState.verified': 'admite merge groups', + 'mergeQueue.producerState.attested': 'está cubierto por una atestación exacta revisada', + 'mergeQueue.producerState.unknown': 'no se ha podido verificar su compatibilidad', + 'mergeQueue.producerState.unsupported': 'no admite merge groups', + 'mergeQueue.problem.classicProtection': 'No se ha podido inspeccionar la protección de rama clásica.', + 'mergeQueue.problem.effectiveRules': 'No se han podido inspeccionar las reglas efectivas del repositorio.', + 'mergeQueue.problem.workflowContract': 'No se han podido inspeccionar los contratos de los workflows requeridos.', + 'mergeQueue.problem.queueMembership': 'No se ha podido inspeccionar la pertenencia a la merge queue.', + 'mergeQueue.decision.explicitCreateOnly': 'El modo de solo creación se ha configurado explícitamente.', + 'mergeQueue.decision.policyObservationFailed': 'No se ha podido verificar la política de merge del destino.', + 'mergeQueue.decision.autoMergeRejected': 'Se ha seleccionado auto-merge, pero el destino requiere su merge queue.', + 'mergeQueue.decision.queueRequired': 'El destino requiere su merge queue.', + 'mergeQueue.decision.queueNotExposed': 'El destino no expone una merge queue obligatoria.', + 'mergeQueue.decision.autoMergeConfigured': 'El auto-merge nativo se ha configurado explícitamente.', + 'mergeQueue.decision.autoMergeDisabled': 'El auto-merge nativo está deshabilitado en este repositorio.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub indica que la pull request está lista; el auto-merge nativo preserva la protección de rama.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub hará merge cuando terminen los checks y las revisiones.', + 'mergeQueue.decision.createOnlyRequired': 'El auto-merge del repositorio no está disponible; se requiere el merge de una persona mantenedora.', +}); +function renderMergeQueueReadinessFailure(readiness, catalog) { + const failed = readiness.producers.filter((producer) => producer.verdict === 'unsupported' || producer.verdict === 'unknown'); + const producerDetails = failed.slice(0, 5) + .map((producer) => `${boundedMergeQueueDiagnostic(producer.name)} [${producer.verdict}]: ${catalog.message(producerStateMessageId(producer.verdict))}`) + .join('; '); + const problemDetails = readiness.problems.slice(0, 3) + .map((problem) => `${problem.area}: ${catalog.message(problemMessageId(problem.area))}`) + .join('; '); + const details = [producerDetails, problemDetails].filter(Boolean).join('; ') + || catalog.message('mergeQueue.readiness.incompleteEvidence'); + const unsupported = failed.some((producer) => producer.verdict === 'unsupported'); + const action = catalog.message(unsupported + ? 'mergeQueue.readiness.action.unsupported' + : readiness.problems.length > 0 + ? 'mergeQueue.readiness.action.observation' + : 'mergeQueue.readiness.action.unknown', unsupported ? { event: 'merge_group: checks_requested' } : {}); + return catalog.message('mergeQueue.readiness.failure', { + verdict: readiness.verdict, + role: readiness.targetRole, + branch: boundedMergeQueueDiagnostic(readiness.targetBranch), + details, + action, + }); +} +function producerStateMessageId(verdict) { + return `mergeQueue.producerState.${verdict}`; +} +function problemMessageId(area) { + const ids = { + 'classic-protection': 'mergeQueue.problem.classicProtection', + 'effective-rules': 'mergeQueue.problem.effectiveRules', + 'workflow-contract': 'mergeQueue.problem.workflowContract', + 'queue-membership': 'mergeQueue.problem.queueMembership', + }; + return ids[area]; +} +function boundedMergeQueueDiagnostic(value) { + return (0, sensitive_text_1.redactSensitiveText)(value) + .replace(/[\r\n<>]/gu, ' ') + .replace(/::/gu, '﹕﹕') + .replace(/@/gu, '@\u200b') + .slice(0, 500); +} + + /***/ }), /***/ 97890: @@ -45983,6 +46104,345 @@ function uniqueDefined(current, next) { } +/***/ }), + +/***/ 80226: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SETUP_DOCTOR_CATALOG_DEFINITIONS = exports.SPANISH_SETUP_DOCTOR_DEFINITION = exports.ENGLISH_SETUP_DOCTOR_DEFINITION = exports.SETUP_DOCTOR_MESSAGE_IDS = void 0; +exports.resolveStaticSetupDoctorCatalog = resolveStaticSetupDoctorCatalog; +exports.resolveSetupDoctorCatalog = resolveSetupDoctorCatalog; +const message_catalog_1 = __nccwpck_require__(27097); +const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); +const DOCTOR_ONLY_MESSAGE_IDS = Object.freeze([ + 'doctor.title', + 'doctor.title.partial', + 'doctor.summary', + 'doctor.noMutation', + 'doctor.blockedBy', + 'doctor.action', + 'doctor.status.pass', + 'doctor.status.warn', + 'doctor.status.fail', + 'doctor.status.skipped', + 'doctor.label.configuration', + 'doctor.label.repositoryRoot', + 'doctor.label.setupPat', + 'doctor.label.githubScopes', + 'doctor.label.repositorySecrets', + 'doctor.label.repositoryVariables', + 'doctor.label.mergeQueue', + 'doctor.label.localeProfile', + 'doctor.label.repositoryLocale', + 'doctor.label.issueLocale', + 'doctor.label.pullRequestLocale', + 'doctor.label.workflow', + 'doctor.label.variable', + 'doctor.label.credential', + 'doctor.label.mergeQueueTarget', + 'doctor.locale.invalid', + 'doctor.locale.invalidAction', + 'doctor.locale.fallback', + 'doctor.locale.fallbackInherited', + 'doctor.locale.resolved', + 'doctor.locale.resolvedInherited', + 'doctor.locale.separatorAction', + 'doctor.locale.dynamicAction', + 'doctor.configuration.valid', + 'doctor.configuration.invalid', + 'doctor.configuration.action', + 'doctor.repositoryRoot.valid', + 'doctor.repositoryRoot.invalid', + 'doctor.repositoryRoot.unverified', + 'doctor.repositoryRoot.action', + 'doctor.workflow.skipped', + 'doctor.workflow.matches', + 'doctor.workflow.drift', + 'doctor.workflow.unverified', + 'doctor.workflow.configurationAction', + 'doctor.workflow.repairAction', + 'doctor.workflow.unverifiedAction', + 'doctor.setupPat.valid', + 'doctor.setupPat.invalid', + 'doctor.setupPat.unverified', + 'doctor.setupPat.replaceAction', + 'doctor.setupPat.unverifiedAction', + 'doctor.mergeQueue.skipped', + 'doctor.mergeQueue.skippedAction', + 'doctor.mergeQueue.unverified', + 'doctor.mergeQueue.unverifiedAction', + 'doctor.scope.unverified', + 'doctor.scope.unverifiedAction', + 'doctor.scope.readable', + 'doctor.scope.unavailable', + 'doctor.scope.grantAction', + 'doctor.variable.missing', + 'doctor.variable.configured', + 'doctor.variable.preserved', + 'doctor.variable.different', + 'doctor.variable.action', + 'doctor.secretMetadata.readable', + 'doctor.credential.group.runnerRequired', + 'doctor.credential.group.missing', + 'doctor.credential.group.healthy', + 'doctor.credential.group.invalid', + 'doctor.credential.group.unverified', + 'doctor.credential.group.action', + 'doctor.credential.missing', + 'doctor.credential.valid', + 'doctor.credential.invalid', + 'doctor.credential.unverifiable', + 'doctor.credential.unverified', + 'doctor.credential.action', + 'doctor.skipped.scope', + 'doctor.skipped.scopeAction', + 'doctor.skipped.variables', + 'doctor.skipped.secrets', + 'doctor.skipped.credentials', + 'doctor.skipped.resourceAction', + 'doctor.mergeQueue.configureSupportedAction', + 'doctor.mergeQueue.repairOrAttestAction', + 'doctor.mergeQueue.selectedMode', + 'doctor.mergeQueue.ready', + 'doctor.mergeQueue.targetUnverified', + 'doctor.mergeQueue.targetUnverifiedAction', + 'doctor.mergeQueue.activeRelease', + 'doctor.mergeQueue.staleAttestation', + 'doctor.mergeQueue.staleAttestationAction', + 'doctor.mergeQueue.producer', + 'doctor.mergeQueue.producerAction', +]); +exports.SETUP_DOCTOR_MESSAGE_IDS = Object.freeze([ + ...DOCTOR_ONLY_MESSAGE_IDS, + ...merge_queue_message_catalog_1.MERGE_QUEUE_MESSAGE_IDS, +]); +const ENGLISH_DOCTOR_MESSAGES = Object.freeze({ + 'doctor.title': 'Copilot Doctor', + 'doctor.title.partial': 'Copilot Doctor — partial diagnosis', + 'doctor.summary': 'Checks: {pass} pass, {warn} warn, {fail} fail, {skipped} skipped.', + 'doctor.noMutation': 'No repository configuration was changed.', + 'doctor.blockedBy': 'Blocked by: {checks}', + 'doctor.action': 'Action: {action}', + 'doctor.status.pass': 'PASS', + 'doctor.status.warn': 'WARN', + 'doctor.status.fail': 'FAIL', + 'doctor.status.skipped': 'SKIP', + 'doctor.label.configuration': 'Configuration', + 'doctor.label.repositoryRoot': 'Repository root', + 'doctor.label.setupPat': 'Setup PAT', + 'doctor.label.githubScopes': 'GitHub Actions scopes', + 'doctor.label.repositorySecrets': 'Repository Secrets', + 'doctor.label.repositoryVariables': 'Repository Variables', + 'doctor.label.mergeQueue': 'Merge queue', + 'doctor.label.localeProfile': 'Locale profile', + 'doctor.label.repositoryLocale': 'Repository locale', + 'doctor.label.issueLocale': 'Issue locale', + 'doctor.label.pullRequestLocale': 'Pull-request locale', + 'doctor.label.workflow': 'Workflow {id}', + 'doctor.label.variable': 'Variable {name}', + 'doctor.label.credential': 'Credential {name}', + 'doctor.label.mergeQueueTarget': 'Merge queue {target}', + 'doctor.locale.invalid': 'Locale capability could not be inspected because locale configuration is invalid.', + 'doctor.locale.invalidAction': 'Correct the locale values and run doctor again.', + 'doctor.locale.fallback': 'Effective locale {locale} could not resolve a complete safe catalog; product copy fell back atomically to en-US.', + 'doctor.locale.fallbackInherited': 'Effective locale {locale} could not resolve a complete safe catalog; product copy fell back atomically to en-US. It inherits the repository locale.', + 'doctor.locale.resolved': 'Effective locale {locale} resolves through the {source} catalog path.', + 'doctor.locale.resolvedInherited': 'Effective locale {locale} resolves through the {source} catalog path. It inherits the repository locale.', + 'doctor.locale.separatorAction': 'Replace legacy underscore separators with canonical BCP-47 hyphens.', + 'doctor.locale.dynamicAction': 'Check that the planner/language agent is ready and returns valid localized copy, then run doctor again.', + 'doctor.configuration.valid': 'Setup configuration is valid.', + 'doctor.configuration.invalid': 'Setup configuration has {count} validation error(s).', + 'doctor.configuration.action': 'Fix the setup configuration and run doctor again.', + 'doctor.repositoryRoot.valid': 'Current directory is the repository root.', + 'doctor.repositoryRoot.invalid': 'Current directory is not the repository root.', + 'doctor.repositoryRoot.unverified': 'Repository root could not be verified.', + 'doctor.repositoryRoot.action': 'Run doctor from the root of the target Git repository.', + 'doctor.workflow.skipped': 'Workflow comparison was skipped because setup configuration is invalid.', + 'doctor.workflow.matches': 'Matches the installed setup template.', + 'doctor.workflow.drift': 'Local workflow is {state}.', + 'doctor.workflow.unverified': 'Managed workflows could not be compared.', + 'doctor.workflow.configurationAction': 'Fix setup configuration, then run doctor again.', + 'doctor.workflow.repairAction': 'Run setup to repair this managed workflow.', + 'doctor.workflow.unverifiedAction': 'Check local workflow files and run doctor again.', + 'doctor.setupPat.valid': 'The setup PAT is valid.', + 'doctor.setupPat.invalid': 'The setup PAT is invalid.', + 'doctor.setupPat.unverified': 'The setup PAT could not be validated.', + 'doctor.setupPat.replaceAction': 'Replace the setup PAT and run doctor again.', + 'doctor.setupPat.unverifiedAction': 'Check the setup PAT and network access, then run doctor again.', + 'doctor.mergeQueue.skipped': 'Merge-queue readiness was not inspected because setup configuration is invalid.', + 'doctor.mergeQueue.skippedAction': 'Fix the reported configuration errors, then run doctor again.', + 'doctor.mergeQueue.unverified': 'Merge-queue readiness could not be inspected.', + 'doctor.mergeQueue.unverifiedAction': 'Check branch policy access and run doctor again.', + 'doctor.scope.unverified': 'GitHub Actions resource scopes could not be inspected.', + 'doctor.scope.unverifiedAction': 'Check setup PAT access to repository and organization Actions metadata, then retry.', + 'doctor.scope.readable': 'GitHub Actions resource scopes are readable.', + 'doctor.scope.unavailable': 'Some organization-level GitHub Actions resource scopes are unavailable.', + 'doctor.scope.grantAction': 'Grant the setup PAT the required organization Actions metadata access.', + 'doctor.variable.missing': 'Variable is missing.', + 'doctor.variable.configured': 'Variable is configured at {scope} scope.', + 'doctor.variable.preserved': 'Variable differs but is intentionally preserved at {scope} scope.', + 'doctor.variable.different': 'Variable differs from the expected setup configuration.', + 'doctor.variable.action': 'Run setup to reconcile this Variable.', + 'doctor.secretMetadata.readable': 'GitHub Actions Secret metadata is readable.', + 'doctor.credential.group.runnerRequired': 'No fallback Secret is configured; runner authentication must satisfy this group.', + 'doctor.credential.group.missing': 'No alternative credential is present.', + 'doctor.credential.group.healthy': 'At least one alternative credential is healthy.', + 'doctor.credential.group.invalid': 'Every available alternative credential is invalid.', + 'doctor.credential.group.unverified': 'An alternative credential is present, but remote health is unavailable.', + 'doctor.credential.group.action': 'Configure and validate one credential for this requirement group.', + 'doctor.credential.missing': 'Required Secret is missing.', + 'doctor.credential.valid': 'Credential health is valid.', + 'doctor.credential.invalid': 'Credential health is invalid.', + 'doctor.credential.unverifiable': 'Credential health cannot be verified automatically.', + 'doctor.credential.unverified': 'Secret is present, but remote health is unavailable.', + 'doctor.credential.action': 'Configure or replace {name}, then rerun credential health.', + 'doctor.skipped.scope': 'Resource-scope inspection requires a valid setup PAT.', + 'doctor.skipped.scopeAction': 'Replace the setup PAT.', + 'doctor.skipped.variables': 'Variable checks could not run.', + 'doctor.skipped.secrets': 'Secret metadata checks could not run.', + 'doctor.skipped.credentials': 'Credential health could not run.', + 'doctor.skipped.resourceAction': 'Resolve the blocking check and rerun doctor.', + 'doctor.mergeQueue.configureSupportedAction': 'Configure a supported merge-queue producer or choose another reconciliation mode.', + 'doctor.mergeQueue.repairOrAttestAction': 'Repair or attest every required merge-queue producer.', + 'doctor.mergeQueue.selectedMode': 'Selected mode is {mode}; merge-queue producer evidence is not required for this target.', + 'doctor.mergeQueue.ready': 'Ready. {verified} required producer(s) verified automatically and {attested} covered by exact attestation.', + 'doctor.mergeQueue.targetUnverified': 'Target policy could not be inspected because the provider request failed.', + 'doctor.mergeQueue.targetUnverifiedAction': 'Check the setup PAT permissions and target branch policy, then retry.', + 'doctor.mergeQueue.activeRelease': 'Active release branches are discovered dynamically and are revalidated before a hotfix reconciliation branch or pull request is created.', + 'doctor.mergeQueue.staleAttestation': 'This exact attestation does not match a required check observed on production or development.', + 'doctor.mergeQueue.staleAttestationAction': 'Remove the stale attestation or correct its exact check identity.', + 'doctor.mergeQueue.producer': '{verdict}: {state}', + 'doctor.mergeQueue.producerAction': 'Configure or exactly attest this required producer.', +}); +const SPANISH_DOCTOR_MESSAGES = Object.freeze({ + 'doctor.title': 'Diagnóstico de Copilot', + 'doctor.title.partial': 'Diagnóstico de Copilot — diagnóstico parcial', + 'doctor.summary': 'Checks: {pass} correctos, {warn} avisos, {fail} fallos, {skipped} omitidos.', + 'doctor.noMutation': 'No se ha cambiado la configuración del repositorio.', + 'doctor.blockedBy': 'Bloqueado por: {checks}', + 'doctor.action': 'Acción: {action}', + 'doctor.status.pass': 'OK', + 'doctor.status.warn': 'AVISO', + 'doctor.status.fail': 'FALLO', + 'doctor.status.skipped': 'OMITIDO', + 'doctor.label.configuration': 'Configuración', + 'doctor.label.repositoryRoot': 'Raíz del repositorio', + 'doctor.label.setupPat': 'PAT de setup', + 'doctor.label.githubScopes': 'Permisos de GitHub Actions', + 'doctor.label.repositorySecrets': 'Secrets del repositorio', + 'doctor.label.repositoryVariables': 'Variables del repositorio', + 'doctor.label.mergeQueue': 'Merge queue', + 'doctor.label.localeProfile': 'Perfil de locale', + 'doctor.label.repositoryLocale': 'Locale del repositorio', + 'doctor.label.issueLocale': 'Locale de issues', + 'doctor.label.pullRequestLocale': 'Locale de pull requests', + 'doctor.label.workflow': 'Workflow {id}', + 'doctor.label.variable': 'Variable {name}', + 'doctor.label.credential': 'Credencial {name}', + 'doctor.label.mergeQueueTarget': 'Merge queue {target}', + 'doctor.locale.invalid': 'No se ha podido inspeccionar la localización porque la configuración de locale no es válida.', + 'doctor.locale.invalidAction': 'Corrige los valores de locale y vuelve a ejecutar doctor.', + 'doctor.locale.fallback': 'El locale efectivo {locale} no ha podido resolver un catálogo completo y seguro; el texto de producto ha usado en-US de forma atómica.', + 'doctor.locale.fallbackInherited': 'El locale efectivo {locale} no ha podido resolver un catálogo completo y seguro; el texto de producto ha usado en-US de forma atómica. Hereda el locale del repositorio.', + 'doctor.locale.resolved': 'El locale efectivo {locale} se resuelve mediante la ruta de catálogo {source}.', + 'doctor.locale.resolvedInherited': 'El locale efectivo {locale} se resuelve mediante la ruta de catálogo {source}. Hereda el locale del repositorio.', + 'doctor.locale.separatorAction': 'Sustituye los separadores bajos heredados por guiones BCP-47 canónicos.', + 'doctor.locale.dynamicAction': 'Comprueba que el agente de planificación/idioma está disponible y devuelve texto localizado válido; después, vuelve a ejecutar doctor.', + 'doctor.configuration.valid': 'La configuración de setup es válida.', + 'doctor.configuration.invalid': 'La configuración de setup tiene {count} error(es) de validación.', + 'doctor.configuration.action': 'Corrige la configuración de setup y vuelve a ejecutar doctor.', + 'doctor.repositoryRoot.valid': 'El directorio actual es la raíz del repositorio.', + 'doctor.repositoryRoot.invalid': 'El directorio actual no es la raíz del repositorio.', + 'doctor.repositoryRoot.unverified': 'No se ha podido verificar la raíz del repositorio.', + 'doctor.repositoryRoot.action': 'Ejecuta doctor desde la raíz del repositorio Git de destino.', + 'doctor.workflow.skipped': 'Se ha omitido la comparación de workflows porque la configuración de setup no es válida.', + 'doctor.workflow.matches': 'Coincide con la plantilla de setup instalada.', + 'doctor.workflow.drift': 'El workflow local está en estado {state}.', + 'doctor.workflow.unverified': 'No se han podido comparar los workflows gestionados.', + 'doctor.workflow.configurationAction': 'Corrige la configuración de setup y vuelve a ejecutar doctor.', + 'doctor.workflow.repairAction': 'Ejecuta setup para reparar este workflow gestionado.', + 'doctor.workflow.unverifiedAction': 'Comprueba los workflows locales y vuelve a ejecutar doctor.', + 'doctor.setupPat.valid': 'El PAT de setup es válido.', + 'doctor.setupPat.invalid': 'El PAT de setup no es válido.', + 'doctor.setupPat.unverified': 'No se ha podido validar el PAT de setup.', + 'doctor.setupPat.replaceAction': 'Sustituye el PAT de setup y vuelve a ejecutar doctor.', + 'doctor.setupPat.unverifiedAction': 'Comprueba el PAT de setup y el acceso de red, y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.skipped': 'No se ha inspeccionado la preparación de merge queue porque la configuración de setup no es válida.', + 'doctor.mergeQueue.skippedAction': 'Corrige los errores de configuración indicados y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.unverified': 'No se ha podido inspeccionar la preparación de merge queue.', + 'doctor.mergeQueue.unverifiedAction': 'Comprueba el acceso a la política de rama y vuelve a ejecutar doctor.', + 'doctor.scope.unverified': 'No se han podido inspeccionar los ámbitos de recursos de GitHub Actions.', + 'doctor.scope.unverifiedAction': 'Comprueba el acceso del PAT de setup a los metadatos de Actions del repositorio y la organización, y vuelve a intentarlo.', + 'doctor.scope.readable': 'Los ámbitos de recursos de GitHub Actions son legibles.', + 'doctor.scope.unavailable': 'Algunos ámbitos de recursos de GitHub Actions de la organización no están disponibles.', + 'doctor.scope.grantAction': 'Concede al PAT de setup el acceso necesario a los metadatos de Actions de la organización.', + 'doctor.variable.missing': 'Falta la Variable.', + 'doctor.variable.configured': 'La Variable está configurada en el ámbito {scope}.', + 'doctor.variable.preserved': 'La Variable difiere, pero se conserva intencionadamente en el ámbito {scope}.', + 'doctor.variable.different': 'La Variable difiere de la configuración de setup esperada.', + 'doctor.variable.action': 'Ejecuta setup para reconciliar esta Variable.', + 'doctor.secretMetadata.readable': 'Los metadatos de Secrets de GitHub Actions son legibles.', + 'doctor.credential.group.runnerRequired': 'No hay un Secret alternativo configurado; la autenticación del runner debe satisfacer este grupo.', + 'doctor.credential.group.missing': 'No hay ninguna credencial alternativa disponible.', + 'doctor.credential.group.healthy': 'Al menos una credencial alternativa es válida.', + 'doctor.credential.group.invalid': 'Todas las credenciales alternativas disponibles son inválidas.', + 'doctor.credential.group.unverified': 'Hay una credencial alternativa, pero su estado remoto no está disponible.', + 'doctor.credential.group.action': 'Configura y valida una credencial para este grupo de requisitos.', + 'doctor.credential.missing': 'Falta el Secret requerido.', + 'doctor.credential.valid': 'El estado de la credencial es válido.', + 'doctor.credential.invalid': 'El estado de la credencial es inválido.', + 'doctor.credential.unverifiable': 'El estado de la credencial no se puede verificar automáticamente.', + 'doctor.credential.unverified': 'El Secret está presente, pero su estado remoto no está disponible.', + 'doctor.credential.action': 'Configura o sustituye {name} y vuelve a ejecutar la comprobación de credenciales.', + 'doctor.skipped.scope': 'La inspección de ámbitos requiere un PAT de setup válido.', + 'doctor.skipped.scopeAction': 'Sustituye el PAT de setup.', + 'doctor.skipped.variables': 'No se han podido comprobar las Variables.', + 'doctor.skipped.secrets': 'No se han podido comprobar los metadatos de Secrets.', + 'doctor.skipped.credentials': 'No se ha podido comprobar el estado de las credenciales.', + 'doctor.skipped.resourceAction': 'Resuelve el check bloqueante y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.configureSupportedAction': 'Configura un productor de merge queue compatible o elige otro modo de reconciliación.', + 'doctor.mergeQueue.repairOrAttestAction': 'Repara o atestigua cada productor obligatorio de merge queue.', + 'doctor.mergeQueue.selectedMode': 'El modo seleccionado es {mode}; este destino no necesita evidencia de productores de merge queue.', + 'doctor.mergeQueue.ready': 'Listo. {verified} productor(es) obligatorio(s) verificados automáticamente y {attested} cubiertos por atestación exacta.', + 'doctor.mergeQueue.targetUnverified': 'No se ha podido inspeccionar la política del destino porque ha fallado la petición al proveedor.', + 'doctor.mergeQueue.targetUnverifiedAction': 'Comprueba los permisos del PAT de setup y la política de la rama de destino, y vuelve a intentarlo.', + 'doctor.mergeQueue.activeRelease': 'Las ramas de release activas se descubren dinámicamente y se revalidan antes de crear una rama o pull request de reconciliación de hotfix.', + 'doctor.mergeQueue.staleAttestation': 'Esta atestación exacta no coincide con ningún check obligatorio observado en producción o desarrollo.', + 'doctor.mergeQueue.staleAttestationAction': 'Elimina la atestación obsoleta o corrige la identidad exacta del check.', + 'doctor.mergeQueue.producer': '{verdict}: {state}', + 'doctor.mergeQueue.producerAction': 'Configura o atestigua exactamente este productor obligatorio.', +}); +function messages(doctor, mergeQueue) { + return Object.freeze({ ...doctor, ...mergeQueue }); +} +exports.ENGLISH_SETUP_DOCTOR_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: messages(ENGLISH_DOCTOR_MESSAGES, merge_queue_message_catalog_1.ENGLISH_MERGE_QUEUE_MESSAGES), +}); +exports.SPANISH_SETUP_DOCTOR_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: messages(SPANISH_DOCTOR_MESSAGES, merge_queue_message_catalog_1.SPANISH_MERGE_QUEUE_MESSAGES), +}); +exports.SETUP_DOCTOR_CATALOG_DEFINITIONS = Object.freeze([ + exports.ENGLISH_SETUP_DOCTOR_DEFINITION, + exports.SPANISH_SETUP_DOCTOR_DEFINITION, +]); +function resolveStaticSetupDoctorCatalog(locale = 'en-US') { + return (0, resolved_message_catalog_policy_1.resolveStaticMessageCatalogView)(locale, exports.ENGLISH_SETUP_DOCTOR_DEFINITION, exports.SETUP_DOCTOR_CATALOG_DEFINITIONS); +} +function resolveSetupDoctorCatalog(locale, configuration, resolver) { + return (0, resolved_message_catalog_policy_1.resolveMessageCatalogView)(locale, exports.SETUP_DOCTOR_MESSAGE_IDS, exports.ENGLISH_SETUP_DOCTOR_DEFINITION, exports.SETUP_DOCTOR_CATALOG_DEFINITIONS, configuration, resolver); +} + + /***/ }), /***/ 67615: @@ -45998,8 +46458,8 @@ exports.normalizedDoctorPathId = normalizedDoctorPathId; exports.buildLocaleDoctorChecks = buildLocaleDoctorChecks; const locale_1 = __nccwpck_require__(15386); const message_catalog_1 = __nccwpck_require__(27097); -const publication_message_catalog_1 = __nccwpck_require__(34223); const agent_1 = __nccwpck_require__(89040); +const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); const EMPTY_TOTALS = { pass: 0, warn: 0, @@ -46041,39 +46501,45 @@ function normalizedDoctorPathId(path) { .slice(0, 120); return normalized || 'unknown'; } -function buildLocaleDoctorChecks(configuration) { +function buildLocaleDoctorChecks(configuration, catalog = (0, setup_doctor_message_catalog_1.resolveStaticSetupDoctorCatalog)()) { try { const profile = (0, locale_1.resolveLocaleProfile)(configuration.repository.repositoryLocale, configuration.repository.issueLocale, configuration.repository.pullRequestLocale); const dynamicReady = (0, agent_1.isAgentConfigurationReady)(configuration.agents.planner); + const repositoryCatalogSource = catalog.requestedLocale === profile.repository + ? catalog.resolutionSource + : undefined; return Object.freeze([ - localeDoctorCheck('repository', configuration.repository.repositoryLocale, profile.repository, false, dynamicReady), - localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady), - localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady), + localeDoctorCheck('repository', configuration.repository.repositoryLocale, profile.repository, false, dynamicReady, catalog, repositoryCatalogSource), + localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady, catalog), + localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady, catalog), ]); } catch { return Object.freeze([ - skippedDoctorCheck('locale.profile', ['configuration.valid'], 'Locale capability could not be inspected because locale configuration is invalid.', 'Correct the locale values and run doctor again.'), + skippedDoctorCheck('locale.profile', ['configuration.valid'], catalog.message('doctor.locale.invalid'), catalog.message('doctor.locale.invalidAction')), ]); } } -function localeDoctorCheck(scope, configured, effective, inheritedWhenEmpty, dynamicReady) { - const bundled = (0, message_catalog_1.selectBundledMessageCatalog)(effective, publication_message_catalog_1.PUBLICATION_CATALOG_DEFINITIONS); - const catalogSource = bundled?.source ?? (dynamicReady ? 'dynamic' : 'fallback'); +function localeDoctorCheck(scope, configured, effective, inheritedWhenEmpty, dynamicReady, catalog, resolvedCatalogSource) { + const bundled = (0, message_catalog_1.selectBundledMessageCatalog)(effective, setup_doctor_message_catalog_1.SETUP_DOCTOR_CATALOG_DEFINITIONS); + const catalogSource = resolvedCatalogSource ?? bundled?.source ?? (dynamicReady ? 'dynamic' : 'fallback'); const legacySeparator = configured.includes('_'); const fallback = catalogSource === 'fallback'; const status = legacySeparator || fallback ? 'warn' : 'pass'; - const inheritance = inheritedWhenEmpty && !configured.trim() ? ' It inherits the repository locale.' : ''; + const inherited = inheritedWhenEmpty && !configured.trim(); return doctorCheck({ id: `locale.${scope}`, status, summary: fallback - ? `Effective locale ${effective} has no bundled catalog and no ready language agent; product copy will fall back atomically to en-US.${inheritance}` - : `Effective locale ${effective} resolves through the ${catalogSource} catalog path.${inheritance}`, + ? catalog.message(inherited ? 'doctor.locale.fallbackInherited' : 'doctor.locale.fallback', { locale: effective }) + : catalog.message(inherited ? 'doctor.locale.resolvedInherited' : 'doctor.locale.resolved', { + locale: effective, + source: catalogSource, + }), ...(legacySeparator - ? { action: 'Replace legacy underscore separators with canonical BCP-47 hyphens.' } + ? { action: catalog.message('doctor.locale.separatorAction') } : fallback - ? { action: 'Configure a ready language agent if localized non-English product copy is required.' } + ? { action: catalog.message('doctor.locale.dynamicAction') } : {}), evidence: { configured: configured || '(inherit)', @@ -46789,9 +47255,10 @@ class DeploymentOrchestrationRuntime { attestations: context.deployment.mergeQueueCheckAttestations, }); if (readiness.verdict !== "ready") { + const catalog = await this.presentationCatalog('issue', context, operation); return { kind: "blocked", - reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, effectiveLocale(context, operation).issue), + reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), }; } } @@ -46843,9 +47310,10 @@ class DeploymentOrchestrationRuntime { await this.dependencies.pullRequests.enableAutoMerge(pullRequest.nodeId); } } - unsupportedMergeBehavior(context, operation, targetRole, targetBranch, capabilities, decision) { + async unsupportedMergeBehavior(context, operation, targetRole, targetBranch, capabilities, decision) { + const catalog = await this.presentationCatalog('issue', context, operation); if (capabilities.mergeQueueObservationProblems.length === 0) { - return { kind: "blocked", reason: decision.reason }; + return { kind: "blocked", reason: (0, deployment_plan_policy_1.pullRequestModeDecisionMessage)(decision, catalog) }; } const readiness = (0, merge_queue_readiness_1.evaluateMergeQueueReadiness)({ queueRequired: true, @@ -46857,7 +47325,7 @@ class DeploymentOrchestrationRuntime { }); return { kind: "blocked", - reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, effectiveLocale(context, operation).issue), + reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), }; } presentationCatalog(scope, context, operation) { @@ -51846,26 +52314,29 @@ function deepFreezeCopy(value) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupDoctorUseCase = void 0; +const locale_1 = __nccwpck_require__(15386); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_doctor_report_policy_1 = __nccwpck_require__(67615); const bounded_concurrency_policy_1 = __nccwpck_require__(35596); +const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); class SetupDoctorUseCase { constructor(dependencies) { this.dependencies = dependencies; } async execute(request) { + const catalog = await (0, setup_doctor_message_catalog_1.resolveSetupDoctorCatalog)(doctorCatalogLocale(request.configuration), request.configuration.agents.planner, this.dependencies.catalogResolver); const configurationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(request.configuration); const checks = [ - configurationCheck(configurationErrors), - ...(0, setup_doctor_report_policy_1.buildLocaleDoctorChecks)(request.configuration), - repositoryRootCheck(this.dependencies.workspace), - ...workflowChecks(request.configuration, configurationErrors, this.dependencies.workspace), + configurationCheck(configurationErrors, catalog), + ...(0, setup_doctor_report_policy_1.buildLocaleDoctorChecks)(request.configuration, catalog), + repositoryRootCheck(this.dependencies.workspace, catalog), + ...workflowChecks(request.configuration, configurationErrors, this.dependencies.workspace, catalog), ]; - const pat = await this.validatePat(request); + const pat = await this.validatePat(request, catalog); checks.push(pat); if (pat.status !== 'pass') { - checks.push(...skippedRemoteChecks(request.configuration, pat.id)); - return (0, setup_doctor_report_policy_1.buildDoctorReport)(checks); + checks.push(...skippedRemoteChecks(request.configuration, pat.id, catalog)); + return { report: (0, setup_doctor_report_policy_1.buildDoctorReport)(checks), catalog }; } const remote = await (0, bounded_concurrency_policy_1.runWithConcurrencyLimit)([ async () => { @@ -51883,7 +52354,7 @@ class SetupDoctorUseCase { if (configurationErrors.length > 0) { return { kind: 'merge-queue', - value: [(0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.merge-queue', ['configuration.valid'], 'Merge-queue readiness was not inspected because setup configuration is invalid.', 'Fix the reported configuration errors, then run doctor again.')], + value: [(0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.merge-queue', ['configuration.valid'], catalog.message('doctor.mergeQueue.skipped'), catalog.message('doctor.mergeQueue.skippedAction'))], }; } try { @@ -51894,6 +52365,7 @@ class SetupDoctorUseCase { repository: request.repository, token: request.setupToken, configuration: request.configuration, + catalog, }), }; } @@ -51906,29 +52378,29 @@ class SetupDoctorUseCase { const mergeQueueChecks = remote.find((result) => result.kind === 'merge-queue')?.value ?? [(0, setup_doctor_report_policy_1.doctorCheck)({ id: 'github.merge-queue', status: 'fail', - summary: 'Merge-queue readiness could not be inspected.', - action: 'Check branch policy access and run doctor again.', + summary: catalog.message('doctor.mergeQueue.unverified'), + action: catalog.message('doctor.mergeQueue.unverifiedAction'), })]; if (!remoteConfiguration) { - checks.push(remoteScopeFailure(request.configuration)); - checks.push(...mergeQueueChecks, ...skippedResourceChecks(request.configuration, 'github.resource-scopes')); - return (0, setup_doctor_report_policy_1.buildDoctorReport)(checks); + checks.push(remoteScopeFailure(request.configuration, catalog)); + checks.push(...mergeQueueChecks, ...skippedResourceChecks(request.configuration, 'github.resource-scopes', catalog)); + return { report: (0, setup_doctor_report_policy_1.buildDoctorReport)(checks), catalog }; } - checks.push(resourceScopeCheck(request.configuration, remoteConfiguration)); + checks.push(resourceScopeCheck(request.configuration, remoteConfiguration, catalog)); checks.push(...mergeQueueChecks); - checks.push(...variableChecks(request.configuration, remoteConfiguration)); - checks.push(secretNamesCheck(remoteConfiguration)); - checks.push(...await this.credentialChecks(request, remoteConfiguration)); - return (0, setup_doctor_report_policy_1.buildDoctorReport)(checks); + checks.push(...variableChecks(request.configuration, remoteConfiguration, catalog)); + checks.push(secretNamesCheck(remoteConfiguration, catalog)); + checks.push(...await this.credentialChecks(request, remoteConfiguration, catalog)); + return { report: (0, setup_doctor_report_policy_1.buildDoctorReport)(checks), catalog }; } - async validatePat(request) { + async validatePat(request, catalog) { try { const result = await this.dependencies.validation.validateSetupPat(request.owner, request.repository, request.setupToken); return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'credentials.setup-pat', status: result.status === 'valid' ? 'pass' : 'fail', - summary: result.message, - ...(result.status === 'valid' ? {} : { action: 'Replace the setup PAT and run doctor again.' }), + summary: catalog.message(result.status === 'valid' ? 'doctor.setupPat.valid' : 'doctor.setupPat.invalid'), + ...(result.status === 'valid' ? {} : { action: catalog.message('doctor.setupPat.replaceAction') }), evidence: { credential: 'SETUP_PAT', ...(result.account ? { account: result.account } : {}), @@ -51939,13 +52411,13 @@ class SetupDoctorUseCase { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'credentials.setup-pat', status: 'fail', - summary: 'The setup PAT could not be validated.', - action: 'Check the setup PAT and network access, then run doctor again.', + summary: catalog.message('doctor.setupPat.unverified'), + action: catalog.message('doctor.setupPat.unverifiedAction'), evidence: { credential: 'SETUP_PAT' }, }); } } - async credentialChecks(request, remote) { + async credentialChecks(request, remote, catalog) { const requirements = (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(request.configuration); const remoteSecrets = new Set([...remote.repositorySecrets, ...remote.organizationSecrets]); const present = requirements.filter((requirement) => remoteSecrets.has(requirement.name)); @@ -51958,43 +52430,43 @@ class SetupDoctorUseCase { health = undefined; } } - return buildCredentialChecks(requirements, remoteSecrets, health); + return buildCredentialChecks(requirements, remoteSecrets, health, catalog); } } exports.SetupDoctorUseCase = SetupDoctorUseCase; -function configurationCheck(errors) { +function configurationCheck(errors, catalog) { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'configuration.valid', status: errors.length === 0 ? 'pass' : 'fail', summary: errors.length === 0 - ? 'Setup configuration is valid.' - : `Setup configuration has ${errors.length} validation error(s).`, - ...(errors.length === 0 ? {} : { action: 'Fix the setup configuration and run doctor again.' }), + ? catalog.message('doctor.configuration.valid') + : catalog.message('doctor.configuration.invalid', { count: errors.length }), + ...(errors.length === 0 ? {} : { action: catalog.message('doctor.configuration.action') }), evidence: { errorCount: errors.length }, }); } -function repositoryRootCheck(workspace) { +function repositoryRootCheck(workspace, catalog) { try { const valid = workspace.isRepositoryRoot(); return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'workspace.repository-root', status: valid ? 'pass' : 'fail', - summary: valid ? 'Current directory is the repository root.' : 'Current directory is not the repository root.', - ...(valid ? {} : { action: 'Run doctor from the root of the target Git repository.' }), + summary: catalog.message(valid ? 'doctor.repositoryRoot.valid' : 'doctor.repositoryRoot.invalid'), + ...(valid ? {} : { action: catalog.message('doctor.repositoryRoot.action') }), }); } catch { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'workspace.repository-root', status: 'fail', - summary: 'Repository root could not be verified.', - action: 'Run doctor from the root of the target Git repository.', + summary: catalog.message('doctor.repositoryRoot.unverified'), + action: catalog.message('doctor.repositoryRoot.action'), }); } } -function workflowChecks(configuration, configurationErrors, workspace) { +function workflowChecks(configuration, configurationErrors, workspace, catalog) { if (configurationErrors.length > 0) { - return [(0, setup_doctor_report_policy_1.skippedDoctorCheck)('workflow.comparison', ['configuration.valid'], 'Workflow comparison was skipped because setup configuration is invalid.', 'Fix setup configuration, then run doctor again.')]; + return [(0, setup_doctor_report_policy_1.skippedDoctorCheck)('workflow.comparison', ['configuration.valid'], catalog.message('doctor.workflow.skipped'), catalog.message('doctor.workflow.configurationAction'))]; } try { return [...workspace.compareWorkflows(configuration.features)] @@ -52003,9 +52475,9 @@ function workflowChecks(configuration, configurationErrors, workspace) { id: `workflow.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(comparison.destination)}`, status: comparison.status === 'unchanged' ? 'pass' : 'fail', summary: comparison.status === 'unchanged' - ? 'Matches the installed setup template.' - : `Local workflow is ${comparison.status}.`, - ...(comparison.status === 'unchanged' ? {} : { action: 'Run setup to repair this managed workflow.' }), + ? catalog.message('doctor.workflow.matches') + : catalog.message('doctor.workflow.drift', { state: comparison.status }), + ...(comparison.status === 'unchanged' ? {} : { action: catalog.message('doctor.workflow.repairAction') }), evidence: { path: comparison.destination, state: comparison.status }, })); } @@ -52013,20 +52485,20 @@ function workflowChecks(configuration, configurationErrors, workspace) { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id: 'workflow.comparison', status: 'fail', - summary: 'Managed workflows could not be compared.', - action: 'Check local workflow files and run doctor again.', + summary: catalog.message('doctor.workflow.unverified'), + action: catalog.message('doctor.workflow.unverifiedAction'), })]; } } -function remoteScopeFailure(configuration) { +function remoteScopeFailure(configuration, catalog) { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'github.resource-scopes', status: (0, setup_configuration_policy_1.usesOrganizationStorage)(configuration) ? 'fail' : 'warn', - summary: 'GitHub Actions resource scopes could not be inspected.', - action: 'Check setup PAT access to repository and organization Actions metadata, then retry.', + summary: catalog.message('doctor.scope.unverified'), + action: catalog.message('doctor.scope.unverifiedAction'), }); } -function resourceScopeCheck(configuration, remote) { +function resourceScopeCheck(configuration, remote, catalog) { const unavailable = remote.organizationAccess === 'unavailable' || remote.organizationSecretsAccess === 'unavailable' || remote.organizationVariablesAccess === 'unavailable'; @@ -52035,10 +52507,8 @@ function resourceScopeCheck(configuration, remote) { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'github.resource-scopes', status, - summary: status === 'pass' - ? 'GitHub Actions resource scopes are readable.' - : 'Some organization-level GitHub Actions resource scopes are unavailable.', - ...(status === 'pass' ? {} : { action: 'Grant the setup PAT the required organization Actions metadata access.' }), + summary: catalog.message(status === 'pass' ? 'doctor.scope.readable' : 'doctor.scope.unavailable'), + ...(status === 'pass' ? {} : { action: catalog.message('doctor.scope.grantAction') }), evidence: { ownerType: remote.ownerType, repositoryVisibility: remote.repositoryVisibility, @@ -52046,7 +52516,7 @@ function resourceScopeCheck(configuration, remote) { }, }); } -function variableChecks(configuration, remote) { +function variableChecks(configuration, remote, catalog) { const remoteVariables = new Map(); for (const variable of remote.organizationVariables) remoteVariables.set(variable.name, { value: variable.value, source: 'organization' }); @@ -52066,29 +52536,29 @@ function variableChecks(configuration, remote) { id: `github.variables.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(variable.name)}`, status, summary: !observed - ? 'Variable is missing.' + ? catalog.message('doctor.variable.missing') : matches - ? `Variable is configured at ${observed.source} scope.` + ? catalog.message('doctor.variable.configured', { scope: observed.source }) : preserveExisting - ? `Variable differs but is intentionally preserved at ${observed.source} scope.` - : 'Variable differs from the expected setup configuration.', - ...(status === 'pass' || status === 'warn' ? {} : { action: 'Run setup to reconcile this Variable.' }), + ? catalog.message('doctor.variable.preserved', { scope: observed.source }) + : catalog.message('doctor.variable.different'), + ...(status === 'pass' || status === 'warn' ? {} : { action: catalog.message('doctor.variable.action') }), evidence: { name: variable.name, present: observed !== undefined, matches, ...(observed ? { scope: observed.source } : {}) }, }); }); } -function secretNamesCheck(remote) { +function secretNamesCheck(remote, catalog) { return (0, setup_doctor_report_policy_1.doctorCheck)({ id: 'github.secret-names', status: 'pass', - summary: 'GitHub Actions Secret metadata is readable.', + summary: catalog.message('doctor.secretMetadata.readable'), evidence: { repositorySecretCount: remote.repositorySecrets.length, organizationSecretCount: remote.organizationSecrets.length, }, }); } -function buildCredentialChecks(requirements, remoteSecrets, health) { +function buildCredentialChecks(requirements, remoteSecrets, health, catalog) { const healthByName = new Map((health ?? []).map((check) => [check.name, check])); const reportedGroups = new Set(); const checks = []; @@ -52107,16 +52577,16 @@ function buildCredentialChecks(requirements, remoteSecrets, health) { checks.push((0, setup_doctor_report_policy_1.doctorCheck)({ id: `credential.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(group)}`, status, - summary: available.length === 0 + summary: catalog.message(available.length === 0 ? runnerAllowed - ? 'No fallback Secret is configured; runner authentication must satisfy this group.' - : 'No alternative credential is present.' + ? 'doctor.credential.group.runnerRequired' + : 'doctor.credential.group.missing' : healthy - ? 'At least one alternative credential is healthy.' + ? 'doctor.credential.group.healthy' : invalid - ? 'Every available alternative credential is invalid.' - : 'An alternative credential is present, but remote health is unavailable.', - ...(status === 'pass' ? {} : { action: 'Configure and validate one credential for this requirement group.' }), + ? 'doctor.credential.group.invalid' + : 'doctor.credential.group.unverified'), + ...(status === 'pass' ? {} : { action: catalog.message('doctor.credential.group.action') }), evidence: { group, availableCount: available.length, runnerAuthenticationAllowed: runnerAllowed }, })); continue; @@ -52127,27 +52597,48 @@ function buildCredentialChecks(requirements, remoteSecrets, health) { checks.push((0, setup_doctor_report_policy_1.doctorCheck)({ id: `credential.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(requirement.name)}`, status, - summary: !present ? 'Required Secret is missing.' : check?.message ?? 'Secret is present, but remote health is unavailable.', - ...(status === 'pass' ? {} : { action: `Configure or replace ${requirement.name}, then rerun credential health.` }), + summary: !present + ? catalog.message('doctor.credential.missing') + : catalog.message(check ? credentialHealthMessageId(check.status) : 'doctor.credential.unverified'), + ...(status === 'pass' ? {} : { + action: catalog.message('doctor.credential.action', { name: requirement.name }), + }), evidence: { name: requirement.name, present }, })); } return checks; } -function skippedRemoteChecks(configuration, blocker) { +function skippedRemoteChecks(configuration, blocker, catalog) { return [ - (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.resource-scopes', [blocker], 'Resource-scope inspection requires a valid setup PAT.', 'Replace the setup PAT.'), - (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.merge-queue', [blocker], 'Merge-queue inspection requires a valid setup PAT.', 'Replace the setup PAT.'), - ...skippedResourceChecks(configuration, blocker), + (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.resource-scopes', [blocker], catalog.message('doctor.skipped.scope'), catalog.message('doctor.skipped.scopeAction')), + (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.merge-queue', [blocker], catalog.message('doctor.mergeQueue.skipped'), catalog.message('doctor.skipped.scopeAction')), + ...skippedResourceChecks(configuration, blocker, catalog), ]; } -function skippedResourceChecks(configuration, blocker) { +function skippedResourceChecks(configuration, blocker, catalog) { return [ - (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.variables', [blocker], 'Variable checks could not run.', 'Resolve the blocking check and rerun doctor.'), - (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.secret-names', [blocker], 'Secret metadata checks could not run.', 'Resolve the blocking check and rerun doctor.'), - ...(0, setup_configuration_policy_1.buildSetupCredentialRequirements)(configuration).map((requirement) => (0, setup_doctor_report_policy_1.skippedDoctorCheck)(`credential.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(requirement.alternativeGroups?.[0] ?? requirement.name)}`, [blocker], 'Credential health could not run.', 'Resolve the blocking check and rerun doctor.')).filter((check, index, all) => all.findIndex((candidate) => candidate.id === check.id) === index), + (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.variables', [blocker], catalog.message('doctor.skipped.variables'), catalog.message('doctor.skipped.resourceAction')), + (0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.secret-names', [blocker], catalog.message('doctor.skipped.secrets'), catalog.message('doctor.skipped.resourceAction')), + ...(0, setup_configuration_policy_1.buildSetupCredentialRequirements)(configuration).map((requirement) => (0, setup_doctor_report_policy_1.skippedDoctorCheck)(`credential.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(requirement.alternativeGroups?.[0] ?? requirement.name)}`, [blocker], catalog.message('doctor.skipped.credentials'), catalog.message('doctor.skipped.resourceAction'))).filter((check, index, all) => all.findIndex((candidate) => candidate.id === check.id) === index), ]; } +function credentialHealthMessageId(status) { + if (status === 'valid') + return 'doctor.credential.valid'; + if (status === 'invalid') + return 'doctor.credential.invalid'; + if (status === 'unverifiable') + return 'doctor.credential.unverifiable'; + return 'doctor.credential.unverified'; +} +function doctorCatalogLocale(configuration) { + try { + return (0, locale_1.resolveLocaleProfile)(configuration.repository.repositoryLocale, configuration.repository.issueLocale, configuration.repository.pullRequestLocale).repository; + } + catch { + return locale_1.DEFAULT_REPOSITORY_LOCALE; + } +} /***/ }), @@ -52178,17 +52669,19 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupMergeQueueReadinessUseCase = void 0; const deployment_plan_policy_1 = __nccwpck_require__(8352); const merge_queue_readiness_1 = __nccwpck_require__(12515); -const sensitive_text_1 = __nccwpck_require__(47122); const setup_doctor_report_policy_1 = __nccwpck_require__(67615); +const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); class SetupMergeQueueReadinessUseCase { - constructor(targets) { + constructor(targets, catalogResolver) { this.targets = targets; + this.catalogResolver = catalogResolver; } async inspect(request) { if (request.configuration.features.release === false && request.configuration.features.hotfix === false) return []; + const catalog = request.catalog ?? await (0, setup_doctor_message_catalog_1.resolveSetupDoctorCatalog)(request.configuration.repository.repositoryLocale, request.configuration.agents.planner, this.catalogResolver); const configuredMode = request.configuration.repository.reconciliationPullRequestMode; - const spanish = request.configuration.repository.issueLocale.toLowerCase().startsWith("es"); const targets = uniqueTargets([ { role: "production", branch: request.configuration.repository.mainBranch }, { role: "development", branch: request.configuration.repository.developmentBranch }, @@ -52209,8 +52702,8 @@ class SetupMergeQueueReadinessUseCase { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "fail", - summary: decision.reason, - action: "Configure a supported merge-queue producer or choose another reconciliation mode.", + summary: (0, deployment_plan_policy_1.pullRequestModeDecisionMessage)(decision, catalog), + action: catalog.message('doctor.mergeQueue.configureSupportedAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, })]; } @@ -52225,18 +52718,16 @@ class SetupMergeQueueReadinessUseCase { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "fail", - summary: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, request.configuration.repository.issueLocale), - action: "Repair or attest every required merge-queue producer.", + summary: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), + action: catalog.message('doctor.mergeQueue.repairOrAttestAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } if (decision.mode !== "merge-queue") { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "pass", - summary: spanish - ? `El modo seleccionado es ${decision.mode}; este destino no necesita evidencia de productores de merge queue.` - : `Selected mode is ${decision.mode}; merge-queue producer evidence is not required for this target.`, + summary: catalog.message('doctor.mergeQueue.selectedMode', { mode: decision.mode }), evidence: { targetRole: target.role, targetBranch: target.branch, selectedMode: decision.mode }, })]; } @@ -52252,28 +52743,26 @@ class SetupMergeQueueReadinessUseCase { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "fail", - summary: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, request.configuration.repository.issueLocale), - action: "Repair or attest every required merge-queue producer.", + summary: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), + action: catalog.message('doctor.mergeQueue.repairOrAttestAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } const verified = readiness.producers.filter((producer) => producer.verdict === "verified").length; const attested = readiness.producers.filter((producer) => producer.verdict === "attested").length; return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "pass", - summary: spanish - ? `Listo. ${verified} productor(es) requerido(s) verificados automáticamente y ${attested} cubiertos por atestación exacta.` - : `Ready. ${verified} required producer(s) verified automatically and ${attested} covered by exact attestation.`, + summary: catalog.message('doctor.mergeQueue.ready', { verified, attested }), evidence: { targetRole: target.role, targetBranch: target.branch, verified, attested }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } catch { return [(0, setup_doctor_report_policy_1.doctorCheck)({ id, status: "fail", - summary: "Target policy could not be inspected because the provider request failed.", - action: "Check the setup PAT permissions and target branch policy, then retry.", + summary: catalog.message('doctor.mergeQueue.targetUnverified'), + action: catalog.message('doctor.mergeQueue.targetUnverifiedAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, })]; } @@ -52283,9 +52772,7 @@ class SetupMergeQueueReadinessUseCase { checks.push((0, setup_doctor_report_policy_1.doctorCheck)({ id: "github.merge-queue.active-release", status: "warn", - summary: spanish - ? "Las ramas de release activas se descubren dinámicamente y se revalidan antes de crear una rama o PR de reconciliación de hotfix." - : "Active release branches are discovered dynamically and are revalidated before a hotfix reconciliation branch or PR is created.", + summary: catalog.message('doctor.mergeQueue.activeRelease'), evidence: { dynamicTarget: true }, })); } @@ -52294,10 +52781,8 @@ class SetupMergeQueueReadinessUseCase { checks.push((0, setup_doctor_report_policy_1.doctorCheck)({ id: `github.merge-queue.attestation.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(`${attestation.context}-${attestation.integrationId}`)}`, status: "warn", - summary: spanish - ? "Esta atestación exacta no coincide con ningún check requerido observado en producción o desarrollo." - : "This exact attestation does not match a required check observed on production or development.", - action: "Remove the stale attestation or correct its exact check identity.", + summary: catalog.message('doctor.mergeQueue.staleAttestation'), + action: catalog.message('doctor.mergeQueue.staleAttestationAction'), evidence: { context: attestation.context, integrationId: String(attestation.integrationId) }, })); } @@ -52306,14 +52791,17 @@ class SetupMergeQueueReadinessUseCase { } } exports.SetupMergeQueueReadinessUseCase = SetupMergeQueueReadinessUseCase; -function producerChecks(producers, role, spanish) { +function producerChecks(producers, role, catalog) { return producers.map((producer) => (0, setup_doctor_report_policy_1.doctorCheck)({ id: `github.merge-queue.${role}.producer.${(0, setup_doctor_report_policy_1.normalizedDoctorPathId)(`${producer.name}-${producer.integrationId ?? 'workflow'}`)}`, status: producer.verdict === "verified" || producer.verdict === "attested" ? "pass" : "fail", - summary: `${producer.verdict}: ${safeDiagnostic(producer.reason)}${spanish && producer.verdict === "attested" ? " (atestación exacta revisada)" : ""}`, + summary: catalog.message('doctor.mergeQueue.producer', { + verdict: producer.verdict, + state: catalog.message((0, merge_queue_message_catalog_1.producerStateMessageId)(producer.verdict)), + }), ...(producer.verdict === "verified" || producer.verdict === "attested" ? {} - : { action: "Configure or exactly attest this required producer." }), + : { action: catalog.message('doctor.mergeQueue.producerAction') }), evidence: { targetRole: role, producer: producer.name, verdict: producer.verdict }, })); } @@ -52327,13 +52815,6 @@ function uniqueTargets(targets) { return true; }); } -function safeDiagnostic(message) { - return (0, sensitive_text_1.redactSensitiveText)(message) - .replace(/[\r\n<>]/g, " ") - .replace(/::/g, "﹕﹕") - .replace(/@/g, "@\u200b") - .slice(0, 240); -} /***/ }), @@ -52544,6 +53025,7 @@ const application_error_1 = __nccwpck_require__(75999); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_questionnaire_policy_1 = __nccwpck_require__(6009); const setup_configuration_clone_policy_1 = __nccwpck_require__(85881); +const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); class SetupWizardUseCase { constructor(dependencies) { this.dependencies = dependencies; @@ -52591,6 +53073,9 @@ class SetupWizardUseCase { repository: request.remoteTarget.repository, token: request.remoteTarget.token, configuration, + // Setup is the profile-creation surface, so its one artifact remains + // authoritative English until the repository profile is installed. + catalog: (0, setup_doctor_message_catalog_1.resolveStaticSetupDoctorCatalog)(), }) : []; const plan = (0, setup_configuration_policy_1.buildSetupPlan)(configuration, readiness, migrationWarnings); @@ -61315,14 +61800,14 @@ function registerDoctorCommand(program) { const overrides = options.config ? (0, setup_config_file_1.loadSetupConfigurationOverrides)(options.config) : {}; const expected = (0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.createDefaultSetupConfiguration)(), overrides); (0, logger_1.logInfo)(`🩺 Checking Copilot configuration for ${gitInfo.owner}/${gitInfo.repo}...`); - const report = await (0, setup_doctor_composition_root_1.createSetupDoctorUseCase)().execute({ + const diagnosis = await (0, setup_doctor_composition_root_1.createSetupDoctorUseCase)().execute({ owner: gitInfo.owner, repository: gitInfo.repo, setupToken: token, configuration: expected, }); - new setup_doctor_presenter_1.SetupDoctorPresenter().present(report); - if (!report.healthy) + new setup_doctor_presenter_1.SetupDoctorPresenter(diagnosis.catalog).present(diagnosis.report); + if (!diagnosis.report.healthy) process.exitCode = 1; } catch (error) { @@ -62344,61 +62829,76 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupDoctorPresenter = void 0; exports.renderDoctorReport = renderDoctorReport; exports.doctorCheckLabel = doctorCheckLabel; +const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); const setup_prompt_rendering_1 = __nccwpck_require__(83434); class SetupDoctorPresenter { + constructor(catalog = (0, setup_doctor_message_catalog_1.resolveStaticSetupDoctorCatalog)()) { + this.catalog = catalog; + } present(report) { - console.log(renderDoctorReport(report)); + console.log(renderDoctorReport(report, this.catalog)); } } exports.SetupDoctorPresenter = SetupDoctorPresenter; -function renderDoctorReport(report) { +function renderDoctorReport(report, catalog = (0, setup_doctor_message_catalog_1.resolveStaticSetupDoctorCatalog)()) { const partial = report.totals.skipped > 0 || report.totals.warn > 0; - const title = partial ? 'Copilot Doctor — partial diagnosis' : 'Copilot Doctor'; - const checks = report.checks.flatMap((check) => renderCheck(check)); - const summary = `Checks: ${report.totals.pass} pass, ${report.totals.warn} warn, ${report.totals.fail} fail, ${report.totals.skipped} skipped.`; - const mutation = 'No repository configuration was changed.'; + const title = catalog.message(partial ? 'doctor.title.partial' : 'doctor.title'); + const checks = report.checks.flatMap((check) => renderCheck(check, catalog)); + const summary = catalog.message('doctor.summary', report.totals); + const mutation = catalog.message('doctor.noMutation'); return (0, setup_prompt_rendering_1.renderBox)([...checks, '', summary, mutation].join('\n'), title, report.healthy ? 32 : 31); } -function renderCheck(check) { - const status = check.status === 'skipped' ? 'SKIP' : check.status.toUpperCase(); - const lines = [` ${(0, setup_prompt_rendering_1.doctorIcon)(check.status)} ${status.padEnd(4)} ${doctorCheckLabel(check.id)} — ${check.summary}`]; - if (check.blockedBy.length > 0) - lines.push(` Blocked by: ${check.blockedBy.join(', ')}`); +function renderCheck(check, catalog) { + const status = catalog.message(`doctor.status.${check.status}`); + const lines = [` ${(0, setup_prompt_rendering_1.doctorIcon)(check.status)} ${status.padEnd(7)} ${doctorCheckLabel(check.id, catalog)} — ${check.summary}`]; + if (check.blockedBy.length > 0) { + lines.push(` ${catalog.message('doctor.blockedBy', { checks: check.blockedBy.join(', ') })}`); + } if (check.action) - lines.push(` Action: ${check.action}`); + lines.push(` ${catalog.message('doctor.action', { action: check.action })}`); return lines; } -function doctorCheckLabel(id) { +function doctorCheckLabel(id, catalog = (0, setup_doctor_message_catalog_1.resolveStaticSetupDoctorCatalog)()) { if (id === 'configuration.valid') - return 'Configuration'; + return catalog.message('doctor.label.configuration'); if (id === 'workspace.repository-root') - return 'Repository root'; + return catalog.message('doctor.label.repositoryRoot'); if (id === 'credentials.setup-pat') - return 'Setup PAT'; + return catalog.message('doctor.label.setupPat'); if (id === 'github.resource-scopes') - return 'GitHub Actions scopes'; + return catalog.message('doctor.label.githubScopes'); if (id === 'github.secret-names') - return 'Repository Secrets'; + return catalog.message('doctor.label.repositorySecrets'); if (id === 'github.variables') - return 'Repository Variables'; + return catalog.message('doctor.label.repositoryVariables'); if (id === 'github.merge-queue') - return 'Merge queue'; + return catalog.message('doctor.label.mergeQueue'); if (id === 'locale.profile') - return 'Locale profile'; + return catalog.message('doctor.label.localeProfile'); if (id === 'locale.repository') - return 'Repository locale'; + return catalog.message('doctor.label.repositoryLocale'); if (id === 'locale.issue') - return 'Issue locale'; + return catalog.message('doctor.label.issueLocale'); if (id === 'locale.pull-request') - return 'Pull-request locale'; - if (id.startsWith('workflow.')) - return `Workflow ${id.slice('workflow.'.length)}`; - if (id.startsWith('github.variables.')) - return `Variable ${id.slice('github.variables.'.length).toUpperCase().replace(/-/g, '_')}`; - if (id.startsWith('credential.')) - return `Credential ${id.slice('credential.'.length).toUpperCase().replace(/-/g, '_')}`; - if (id.startsWith('github.merge-queue.')) - return `Merge queue ${id.slice('github.merge-queue.'.length)}`; + return catalog.message('doctor.label.pullRequestLocale'); + if (id.startsWith('workflow.')) { + return catalog.message('doctor.label.workflow', { id: id.slice('workflow.'.length) }); + } + if (id.startsWith('github.variables.')) { + return catalog.message('doctor.label.variable', { + name: id.slice('github.variables.'.length).toUpperCase().replace(/-/g, '_'), + }); + } + if (id.startsWith('credential.')) { + return catalog.message('doctor.label.credential', { + name: id.slice('credential.'.length).toUpperCase().replace(/-/g, '_'), + }); + } + if (id.startsWith('github.merge-queue.')) { + return catalog.message('doctor.label.mergeQueueTarget', { + target: id.slice('github.merge-queue.'.length), + }); + } return id; } @@ -62468,6 +62968,7 @@ exports.doctorIcon = doctorIcon; exports.formatTask = formatTask; exports.color = color; exports.renderBox = renderBox; +exports.displayWidth = displayWidth; exports.renderRemoteConfiguration = renderRemoteConfiguration; const node_process_1 = __nccwpck_require__(97742); function statusIcon(status) { @@ -62496,17 +62997,17 @@ function renderBox(content, title, borderCode = 36, maximumWidth = node_process_ const contentWidth = Math.max(20, Math.min(120, maximumWidth) - 4); const wrapped = content.split('\n').flatMap((line) => wrapLine(line, contentWidth)); const lines = [` ${title} `, ...wrapped.map(line => ` ${line}`)]; - const width = Math.max(...lines.map(line => stripAnsi(line).length)) + 1; + const width = Math.max(...lines.map(displayWidth)) + 1; const border = color(`╭${'─'.repeat(width)}╮`, borderCode); const bottom = color(`╰${'─'.repeat(width)}╯`, borderCode); return [ border, - ...lines.map(line => `${color('│', borderCode)}${line}${' '.repeat(Math.max(0, width - stripAnsi(line).length))}${color('│', borderCode)}`), + ...lines.map(line => `${color('│', borderCode)}${line}${' '.repeat(Math.max(0, width - displayWidth(line)))}${color('│', borderCode)}`), bottom, ].join('\n'); } function wrapLine(line, maximumWidth) { - if (stripAnsi(line).length <= maximumWidth) + if (displayWidth(line) <= maximumWidth) return [line]; const indent = line.match(/^\s*/)?.[0] ?? ''; const words = line.trim().split(/\s+/); @@ -62514,18 +63015,64 @@ function wrapLine(line, maximumWidth) { let current = indent; for (const word of words) { const candidate = current.trim() ? `${current} ${word}` : `${indent}${word}`; - if (stripAnsi(candidate).length <= maximumWidth) { + if (displayWidth(candidate) <= maximumWidth) { current = candidate; continue; } if (current.trim()) lines.push(current); - current = `${indent}${word}`; + const chunks = splitVisibleToken(word, Math.max(1, maximumWidth - displayWidth(indent))); + for (const chunk of chunks.slice(0, -1)) + lines.push(`${indent}${chunk}`); + current = `${indent}${chunks.at(-1) ?? ''}`; } if (current.trim() || lines.length === 0) lines.push(current); return lines; } +function splitVisibleToken(value, maximumWidth) { + const characters = [...stripAnsi(value)]; + const chunks = []; + let current = ''; + let width = 0; + for (const character of characters) { + const characterWidth = displayWidth(character); + if (current && width + characterWidth > maximumWidth) { + chunks.push(current); + current = ''; + width = 0; + } + current += character; + width += characterWidth; + } + if (current || chunks.length === 0) + chunks.push(current); + return chunks; +} +function displayWidth(value) { + return [...stripAnsi(value)].reduce((width, character) => { + if (/\p{Mark}/u.test(character)) + return width; + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 31 || (codePoint >= 127 && codePoint <= 159)) + return width; + return width + (isWideCodePoint(codePoint) ? 2 : 1); + }, 0); +} +function isWideCodePoint(codePoint) { + return codePoint >= 0x1100 && (codePoint <= 0x115f + || codePoint === 0x2329 + || codePoint === 0x232a + || (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) + || (codePoint >= 0xac00 && codePoint <= 0xd7a3) + || (codePoint >= 0xf900 && codePoint <= 0xfaff) + || (codePoint >= 0xfe10 && codePoint <= 0xfe19) + || (codePoint >= 0xfe30 && codePoint <= 0xfe6f) + || (codePoint >= 0xff00 && codePoint <= 0xff60) + || (codePoint >= 0xffe0 && codePoint <= 0xffe6) + || (codePoint >= 0x1f300 && codePoint <= 0x1faff) + || (codePoint >= 0x20000 && codePoint <= 0x3fffd)); +} function renderRemoteConfiguration(remote, variables, requirements) { const lines = [ `Target owner: ${remote.ownerType}; repository visibility: ${remote.repositoryVisibility}; repository ID: ${remote.repositoryId ?? 'unknown'}`, @@ -75851,17 +76398,21 @@ const octokit_credential_health_adapter_1 = __nccwpck_require__(41760); const github_target_merge_capabilities_inspector_1 = __nccwpck_require__(55527); const octokit_deployment_adapter_1 = __nccwpck_require__(46819); const merge_queue_readiness_use_case_1 = __nccwpck_require__(9890); -function createSetupMergeQueueReadinessUseCase() { - return new merge_queue_readiness_use_case_1.SetupMergeQueueReadinessUseCase(new github_target_merge_capabilities_inspector_1.GithubTargetMergeCapabilitiesInspector(new octokit_deployment_adapter_1.OctokitDeploymentClientAdapter())); +const resolve_message_catalog_use_case_1 = __nccwpck_require__(99961); +const agent_capability_composition_root_1 = __nccwpck_require__(85079); +function createSetupMergeQueueReadinessUseCase(catalogResolver = new resolve_message_catalog_use_case_1.ResolveMessageCatalogUseCase((0, agent_capability_composition_root_1.createLanguageQueryPort)())) { + return new merge_queue_readiness_use_case_1.SetupMergeQueueReadinessUseCase(new github_target_merge_capabilities_inspector_1.GithubTargetMergeCapabilitiesInspector(new octokit_deployment_adapter_1.OctokitDeploymentClientAdapter()), catalogResolver); } function createSetupDoctorUseCase() { const repositoryConfiguration = new repository_variables_repository_1.SetupRemoteConfigurationQueryRepository((0, github_identity_client_factory_1.createRepositoryVariablesClient)()); + const catalogResolver = new resolve_message_catalog_use_case_1.ResolveMessageCatalogUseCase((0, agent_capability_composition_root_1.createLanguageQueryPort)()); return new doctor_use_case_1.SetupDoctorUseCase({ validation: new setup_credential_validation_adapter_1.SetupCredentialValidationAdapter(), workspace: new setup_workspace_adapter_1.SetupDoctorWorkspaceQueryAdapter(), remoteConfiguration: repositoryConfiguration, remoteHealth: new setup_remote_credential_health_adapter_1.SetupRemoteCredentialHealthQueryAdapter(new octokit_credential_health_adapter_1.OctokitCredentialHealthClientAdapter()), - mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(), + mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(catalogResolver), + catalogResolver, }); } diff --git a/build/github_action/index.js b/build/github_action/index.js index 0cb006a99..c4d4e0462 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -44381,6 +44381,7 @@ exports.deploymentCopy = deploymentCopy; const deployment_operation_1 = __nccwpck_require__(92730); const message_catalog_1 = __nccwpck_require__(27097); const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'release', 'hotfix', 'currentStatus', 'noAction', 'actionRequired', 'progress', 'currentTransition', 'whatNext', 'links', 'technical', 'alreadyPublished', @@ -44432,6 +44433,7 @@ exports.DEPLOYMENT_MESSAGE_IDS = Object.freeze([ ...deployment_operation_1.DEPLOYMENT_PHASES.map(phase => `deployment.phase.${phase}`), ...DIAGRAM_KEYS.map(key => `deployment.diagram.${key}`), ...TEMPLATE_MESSAGE_IDS, + ...merge_queue_message_catalog_1.MERGE_QUEUE_MESSAGE_IDS, ]); const ENGLISH_SIMPLE = Object.freeze({ release: 'Release', hotfix: 'Hotfix', currentStatus: 'Current status', @@ -44565,25 +44567,26 @@ const SPANISH_TEMPLATES = Object.freeze({ 'deployment.milestone.reconciliationBlocked': '❌ Despliegue bloqueado: {reason}', 'deployment.milestone.complete': '✅ El despliegue {tag} y todos los destinos de reconciliación configurados se han completado.', }); -function catalogMessages(simple, phases, diagram, templates) { +function catalogMessages(simple, phases, diagram, templates, mergeQueue) { return Object.freeze({ ...Object.fromEntries(SIMPLE_MESSAGE_KEYS.map(key => [`deployment.${key}`, simple[key]])), ...Object.fromEntries(deployment_operation_1.DEPLOYMENT_PHASES.map(phase => [`deployment.phase.${phase}`, phases[phase]])), ...Object.fromEntries(DIAGRAM_KEYS.map(key => [`deployment.diagram.${key}`, diagram[key]])), ...templates, + ...mergeQueue, }); } exports.ENGLISH_DEPLOYMENT_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, locale: 'en-US', compatibleBaseLanguage: 'en', - messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_PHASES, ENGLISH_DIAGRAM, ENGLISH_TEMPLATES), + messages: catalogMessages(ENGLISH_SIMPLE, ENGLISH_PHASES, ENGLISH_DIAGRAM, ENGLISH_TEMPLATES, merge_queue_message_catalog_1.ENGLISH_MERGE_QUEUE_MESSAGES), }); exports.SPANISH_DEPLOYMENT_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, locale: 'es-ES', compatibleBaseLanguage: 'es', - messages: catalogMessages(SPANISH_SIMPLE, SPANISH_PHASES, SPANISH_DIAGRAM, SPANISH_TEMPLATES), + messages: catalogMessages(SPANISH_SIMPLE, SPANISH_PHASES, SPANISH_DIAGRAM, SPANISH_TEMPLATES, merge_queue_message_catalog_1.SPANISH_MERGE_QUEUE_MESSAGES), }); exports.DEPLOYMENT_CATALOG_DEFINITIONS = Object.freeze([ exports.ENGLISH_DEPLOYMENT_DEFINITION, @@ -44614,6 +44617,7 @@ function deploymentCopy(catalog) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildInitialDeploymentOperation = buildInitialDeploymentOperation; exports.selectPullRequestMode = selectPullRequestMode; +exports.pullRequestModeDecisionMessage = pullRequestModeDecisionMessage; exports.mergeQueueReadinessFailureMessage = mergeQueueReadinessFailureMessage; exports.selectBackmergeMode = selectBackmergeMode; exports.selectReconciliationTargetBranches = selectReconciliationTargetBranches; @@ -44622,7 +44626,7 @@ exports.buildReconciliationTarget = buildReconciliationTarget; exports.buildReconciliationBranchName = buildReconciliationBranchName; exports.validateInitialDeploymentInput = validateInitialDeploymentInput; const deployment_operation_1 = __nccwpck_require__(92730); -const sensitive_text_1 = __nccwpck_require__(47122); +const merge_queue_message_catalog_1 = __nccwpck_require__(56033); function buildInitialDeploymentOperation(input) { const strategy = input.kind === "release" ? input.configuration.releaseReconciliationStrategy @@ -44662,67 +44666,51 @@ function buildInitialDeploymentOperation(input) { } function selectPullRequestMode(configured, capabilities) { if (configured === "create-only") { - return { kind: "mode", mode: configured, reason: "Explicitly configured." }; + return { kind: "mode", mode: configured, reasonCode: 'explicit-create-only' }; } if (capabilities.mergeQueueObservationProblems.length > 0) { return { kind: "unsupported", - reason: `The target merge policy could not be verified: ${boundedDiagnostic(capabilities.mergeQueueObservationProblems[0].message)}`, + reasonCode: 'policy-observation-failed', }; } if (capabilities.mergeQueueRequired) { return configured === "auto-merge" - ? { kind: "unsupported", reason: "Auto-merge mode was selected, but the target requires its merge queue." } - : { kind: "mode", mode: "merge-queue", reason: "The target requires its merge queue." }; + ? { kind: "unsupported", reasonCode: 'auto-merge-rejected' } + : { kind: "mode", mode: "merge-queue", reasonCode: 'queue-required' }; } if (configured === "merge-queue") { - return { kind: "unsupported", reason: "The target does not expose a required merge queue." }; + return { kind: "unsupported", reasonCode: 'queue-not-exposed' }; } if (configured === "auto-merge") { return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "Native auto-merge was explicitly configured." } - : { kind: "unsupported", reason: "Native auto-merge is disabled for this repository." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-configured' } + : { kind: "unsupported", reasonCode: 'auto-merge-disabled' }; } if (capabilities.immediatelyMergeable) { - return { kind: "mode", mode: "auto-merge", reason: "GitHub reports the PR ready; native auto-merge preserves branch protection." }; + return { kind: "mode", mode: "auto-merge", reasonCode: 'immediately-mergeable' }; } return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "GitHub will merge after checks and reviews complete." } - : { kind: "mode", mode: "create-only", reason: "Repository auto-merge is unavailable; maintainer merge is required." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-available' } + : { kind: "mode", mode: "create-only", reasonCode: 'create-only-required' }; +} +function pullRequestModeDecisionMessage(decision, catalog) { + const ids = { + 'explicit-create-only': 'mergeQueue.decision.explicitCreateOnly', + 'policy-observation-failed': 'mergeQueue.decision.policyObservationFailed', + 'auto-merge-rejected': 'mergeQueue.decision.autoMergeRejected', + 'queue-required': 'mergeQueue.decision.queueRequired', + 'queue-not-exposed': 'mergeQueue.decision.queueNotExposed', + 'auto-merge-configured': 'mergeQueue.decision.autoMergeConfigured', + 'auto-merge-disabled': 'mergeQueue.decision.autoMergeDisabled', + 'immediately-mergeable': 'mergeQueue.decision.immediatelyMergeable', + 'auto-merge-available': 'mergeQueue.decision.autoMergeAvailable', + 'create-only-required': 'mergeQueue.decision.createOnlyRequired', + }; + return catalog.message(ids[decision.reasonCode]); } -function mergeQueueReadinessFailureMessage(readiness, locale = "en-US") { - const spanish = locale.toLowerCase().startsWith("es"); - const failed = readiness.producers.filter((producer) => producer.verdict === "unsupported" || producer.verdict === "unknown"); - const producerDetails = failed.slice(0, 5) - .map((producer) => `${boundedDiagnostic(producer.name)} [${producer.verdict}]: ${boundedDiagnostic(producer.reason)}`) - .join("; "); - const problemDetails = readiness.problems.slice(0, 3) - .map((problem) => `${problem.area}: ${boundedDiagnostic(problem.message)}`) - .join("; "); - const details = [producerDetails, problemDetails].filter(Boolean).join("; "); - const hasUnsupportedProducer = failed.some((producer) => producer.verdict === "unsupported"); - const hasObservationProblem = readiness.problems.length > 0; - if (spanish) { - const action = hasUnsupportedProducer - ? "Añade merge_group: checks_requested al workflow requerido y vuelve a intentarlo." - : hasObservationProblem - ? "Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo." - : "Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo."; - return `La preparación de la merge queue está en estado ${readiness.verdict} para el destino ${readiness.targetRole} ${boundedDiagnostic(readiness.targetBranch)}. ${details || "La evidencia del productor requerido está incompleta."} ${action}`; - } - const action = hasUnsupportedProducer - ? "Add merge_group: checks_requested to the required workflow, then retry." - : hasObservationProblem - ? "Restore read access and a valid response for the target policy and workflows, then retry." - : "Make the required producer support merge groups or add an exact reviewed check attestation, then retry."; - return `Merge queue readiness is ${readiness.verdict} for ${readiness.targetRole} target ${boundedDiagnostic(readiness.targetBranch)}. ${details || "Required producer evidence is incomplete."} ${action}`; -} -function boundedDiagnostic(value) { - return (0, sensitive_text_1.redactSensitiveText)(value) - .replace(/[\r\n<>]/g, " ") - .replace(/::/g, "﹕﹕") - .replace(/@/g, "@\u200b") - .slice(0, 500); +function mergeQueueReadinessFailureMessage(readiness, catalog) { + return (0, merge_queue_message_catalog_1.renderMergeQueueReadinessFailure)(readiness, catalog); } function selectBackmergeMode(configured, requiresStrictStatusChecks, directHeadIsUpToDate, directSourceIsExact = true) { const directIsUnsafe = !directSourceIsExact @@ -45550,6 +45538,139 @@ function buildManagedBranchPresentation(input) { } +/***/ }), + +/***/ 56033: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_MERGE_QUEUE_MESSAGES = exports.ENGLISH_MERGE_QUEUE_MESSAGES = exports.MERGE_QUEUE_MESSAGE_IDS = void 0; +exports.renderMergeQueueReadinessFailure = renderMergeQueueReadinessFailure; +exports.producerStateMessageId = producerStateMessageId; +exports.boundedMergeQueueDiagnostic = boundedMergeQueueDiagnostic; +const sensitive_text_1 = __nccwpck_require__(47122); +exports.MERGE_QUEUE_MESSAGE_IDS = Object.freeze([ + 'mergeQueue.readiness.failure', + 'mergeQueue.readiness.incompleteEvidence', + 'mergeQueue.readiness.action.unsupported', + 'mergeQueue.readiness.action.observation', + 'mergeQueue.readiness.action.unknown', + 'mergeQueue.producerState.verified', + 'mergeQueue.producerState.attested', + 'mergeQueue.producerState.unknown', + 'mergeQueue.producerState.unsupported', + 'mergeQueue.problem.classicProtection', + 'mergeQueue.problem.effectiveRules', + 'mergeQueue.problem.workflowContract', + 'mergeQueue.problem.queueMembership', + 'mergeQueue.decision.explicitCreateOnly', + 'mergeQueue.decision.policyObservationFailed', + 'mergeQueue.decision.autoMergeRejected', + 'mergeQueue.decision.queueRequired', + 'mergeQueue.decision.queueNotExposed', + 'mergeQueue.decision.autoMergeConfigured', + 'mergeQueue.decision.autoMergeDisabled', + 'mergeQueue.decision.immediatelyMergeable', + 'mergeQueue.decision.autoMergeAvailable', + 'mergeQueue.decision.createOnlyRequired', +]); +exports.ENGLISH_MERGE_QUEUE_MESSAGES = Object.freeze({ + 'mergeQueue.readiness.failure': 'Merge queue readiness is {verdict} for {role} target {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'Required producer evidence is incomplete.', + 'mergeQueue.readiness.action.unsupported': 'Add {event} to the required workflow, then retry.', + 'mergeQueue.readiness.action.observation': 'Restore read access and a valid response for the target policy and workflows, then retry.', + 'mergeQueue.readiness.action.unknown': 'Make the required producer support merge groups or add an exact reviewed check attestation, then retry.', + 'mergeQueue.producerState.verified': 'supports merge groups', + 'mergeQueue.producerState.attested': 'covered by an exact reviewed attestation', + 'mergeQueue.producerState.unknown': 'support could not be verified', + 'mergeQueue.producerState.unsupported': 'does not support merge groups', + 'mergeQueue.problem.classicProtection': 'Classic branch protection could not be inspected.', + 'mergeQueue.problem.effectiveRules': 'Effective repository rules could not be inspected.', + 'mergeQueue.problem.workflowContract': 'Required workflow contracts could not be inspected.', + 'mergeQueue.problem.queueMembership': 'Merge-queue membership could not be inspected.', + 'mergeQueue.decision.explicitCreateOnly': 'Create-only mode was explicitly configured.', + 'mergeQueue.decision.policyObservationFailed': 'The target merge policy could not be verified.', + 'mergeQueue.decision.autoMergeRejected': 'Auto-merge mode was selected, but the target requires its merge queue.', + 'mergeQueue.decision.queueRequired': 'The target requires its merge queue.', + 'mergeQueue.decision.queueNotExposed': 'The target does not expose a required merge queue.', + 'mergeQueue.decision.autoMergeConfigured': 'Native auto-merge was explicitly configured.', + 'mergeQueue.decision.autoMergeDisabled': 'Native auto-merge is disabled for this repository.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub reports the pull request ready; native auto-merge preserves branch protection.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub will merge after checks and reviews complete.', + 'mergeQueue.decision.createOnlyRequired': 'Repository auto-merge is unavailable; maintainer merge is required.', +}); +exports.SPANISH_MERGE_QUEUE_MESSAGES = Object.freeze({ + 'mergeQueue.readiness.failure': 'La preparación de la merge queue está en estado {verdict} para el destino {role} {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'La evidencia del productor requerido está incompleta.', + 'mergeQueue.readiness.action.unsupported': 'Añade {event} al workflow requerido y vuelve a intentarlo.', + 'mergeQueue.readiness.action.observation': 'Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo.', + 'mergeQueue.readiness.action.unknown': 'Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo.', + 'mergeQueue.producerState.verified': 'admite merge groups', + 'mergeQueue.producerState.attested': 'está cubierto por una atestación exacta revisada', + 'mergeQueue.producerState.unknown': 'no se ha podido verificar su compatibilidad', + 'mergeQueue.producerState.unsupported': 'no admite merge groups', + 'mergeQueue.problem.classicProtection': 'No se ha podido inspeccionar la protección de rama clásica.', + 'mergeQueue.problem.effectiveRules': 'No se han podido inspeccionar las reglas efectivas del repositorio.', + 'mergeQueue.problem.workflowContract': 'No se han podido inspeccionar los contratos de los workflows requeridos.', + 'mergeQueue.problem.queueMembership': 'No se ha podido inspeccionar la pertenencia a la merge queue.', + 'mergeQueue.decision.explicitCreateOnly': 'El modo de solo creación se ha configurado explícitamente.', + 'mergeQueue.decision.policyObservationFailed': 'No se ha podido verificar la política de merge del destino.', + 'mergeQueue.decision.autoMergeRejected': 'Se ha seleccionado auto-merge, pero el destino requiere su merge queue.', + 'mergeQueue.decision.queueRequired': 'El destino requiere su merge queue.', + 'mergeQueue.decision.queueNotExposed': 'El destino no expone una merge queue obligatoria.', + 'mergeQueue.decision.autoMergeConfigured': 'El auto-merge nativo se ha configurado explícitamente.', + 'mergeQueue.decision.autoMergeDisabled': 'El auto-merge nativo está deshabilitado en este repositorio.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub indica que la pull request está lista; el auto-merge nativo preserva la protección de rama.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub hará merge cuando terminen los checks y las revisiones.', + 'mergeQueue.decision.createOnlyRequired': 'El auto-merge del repositorio no está disponible; se requiere el merge de una persona mantenedora.', +}); +function renderMergeQueueReadinessFailure(readiness, catalog) { + const failed = readiness.producers.filter((producer) => producer.verdict === 'unsupported' || producer.verdict === 'unknown'); + const producerDetails = failed.slice(0, 5) + .map((producer) => `${boundedMergeQueueDiagnostic(producer.name)} [${producer.verdict}]: ${catalog.message(producerStateMessageId(producer.verdict))}`) + .join('; '); + const problemDetails = readiness.problems.slice(0, 3) + .map((problem) => `${problem.area}: ${catalog.message(problemMessageId(problem.area))}`) + .join('; '); + const details = [producerDetails, problemDetails].filter(Boolean).join('; ') + || catalog.message('mergeQueue.readiness.incompleteEvidence'); + const unsupported = failed.some((producer) => producer.verdict === 'unsupported'); + const action = catalog.message(unsupported + ? 'mergeQueue.readiness.action.unsupported' + : readiness.problems.length > 0 + ? 'mergeQueue.readiness.action.observation' + : 'mergeQueue.readiness.action.unknown', unsupported ? { event: 'merge_group: checks_requested' } : {}); + return catalog.message('mergeQueue.readiness.failure', { + verdict: readiness.verdict, + role: readiness.targetRole, + branch: boundedMergeQueueDiagnostic(readiness.targetBranch), + details, + action, + }); +} +function producerStateMessageId(verdict) { + return `mergeQueue.producerState.${verdict}`; +} +function problemMessageId(area) { + const ids = { + 'classic-protection': 'mergeQueue.problem.classicProtection', + 'effective-rules': 'mergeQueue.problem.effectiveRules', + 'workflow-contract': 'mergeQueue.problem.workflowContract', + 'queue-membership': 'mergeQueue.problem.queueMembership', + }; + return ids[area]; +} +function boundedMergeQueueDiagnostic(value) { + return (0, sensitive_text_1.redactSensitiveText)(value) + .replace(/[\r\n<>]/gu, ' ') + .replace(/::/gu, '﹕﹕') + .replace(/@/gu, '@\u200b') + .slice(0, 500); +} + + /***/ }), /***/ 97890: @@ -47538,9 +47659,10 @@ class DeploymentOrchestrationRuntime { attestations: context.deployment.mergeQueueCheckAttestations, }); if (readiness.verdict !== "ready") { + const catalog = await this.presentationCatalog('issue', context, operation); return { kind: "blocked", - reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, effectiveLocale(context, operation).issue), + reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), }; } } @@ -47592,9 +47714,10 @@ class DeploymentOrchestrationRuntime { await this.dependencies.pullRequests.enableAutoMerge(pullRequest.nodeId); } } - unsupportedMergeBehavior(context, operation, targetRole, targetBranch, capabilities, decision) { + async unsupportedMergeBehavior(context, operation, targetRole, targetBranch, capabilities, decision) { + const catalog = await this.presentationCatalog('issue', context, operation); if (capabilities.mergeQueueObservationProblems.length === 0) { - return { kind: "blocked", reason: decision.reason }; + return { kind: "blocked", reason: (0, deployment_plan_policy_1.pullRequestModeDecisionMessage)(decision, catalog) }; } const readiness = (0, merge_queue_readiness_1.evaluateMergeQueueReadiness)({ queueRequired: true, @@ -47606,7 +47729,7 @@ class DeploymentOrchestrationRuntime { }); return { kind: "blocked", - reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, effectiveLocale(context, operation).issue), + reason: (0, deployment_plan_policy_1.mergeQueueReadinessFailureMessage)(readiness, catalog), }; } presentationCatalog(scope, context, operation) { diff --git a/docs/configuration-checklist.mdx b/docs/configuration-checklist.mdx index a470a17f0..f791c88d2 100644 --- a/docs/configuration-checklist.mdx +++ b/docs/configuration-checklist.mdx @@ -39,6 +39,7 @@ description: Required checks before enabling Copilot automation. - [ ] Empty `issues-locale` and `pull-requests-locale` values intentionally inherit the repository locale; explicit overrides are documented. - [ ] Every non-bundled target locale has an available planner/language agent, or operators accept the observable atomic English fallback. - [ ] `copilot doctor` reports the effective repository, issue, and pull-request locales plus `exact`, `base`, `dynamic`, or `fallback` catalog paths. +- [ ] Doctor headings, summaries, states, and actions use one repository-locale catalog; raw PAT, credential-health, rule, and workflow-provider prose is absent, and any dynamic-catalog failure produces a complete English report. - [ ] Legacy underscore tags such as `pt_BR` have been rewritten to canonical tags such as `pt-BR` after the one-time setup warning. - [ ] Unattended setup uses `--non-interactive --yes` plus every required external credential; `--yes` is treated only as plan approval. - [ ] Cancellation (`Ctrl-C` or EOF) has been observed to exit 130 with no writes; declining the final plan exits 0 with no writes. diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 1da0b29fc..9c80ff2cd 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -35,6 +35,8 @@ Copilot provides extensive configuration options to customize your workflow. Use - `pull-requests-locale`: Optional pull-request-surface override. Its empty default inherits `repository-locale`; an explicit existing value remains authoritative. 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` output uses `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. Raw provider and credential diagnostics are replaced with semantic state and recovery copy. Setup remains English while it creates this profile. - `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/development/architecture.mdx b/docs/development/architecture.mdx index c5fee224a..f32fc69ba 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -25,6 +25,17 @@ cached language-agent request. A missing or invalid dynamic slice falls back atomically to English and records only locale, source, descriptor count, and a stable reason in the Job Summary. +`copilot doctor` applies that boundary before constructing any visible check. +The doctor use case resolves one complete catalog from the repository locale, +passes the same immutable view into merge-queue readiness, returns it with the +semantic report, and the CLI presenter uses that exact view. Setup passes an +explicit English catalog to embedded readiness because setup is still creating +the profile. Stable check IDs, evidence keys, verdicts, refs, and credential +names remain machine facts; raw provider and credential-health prose is never +rendered. Pull-request-mode selection returns reason codes rather than localized +strings, so neither application use cases nor composition roots branch on `en` +or `es`. + Product-facing agent calls cross a second closed locale boundary. The fixed inventory is Think, initial help, recommendations, progress, pull-request descriptions, and Bugbot review. Each call uses the common structured-query diff --git a/docs/security-operations/operations/verification.mdx b/docs/security-operations/operations/verification.mdx index 1f8a43c63..c204687fd 100644 --- a/docs/security-operations/operations/verification.mdx +++ b/docs/security-operations/operations/verification.mdx @@ -13,3 +13,5 @@ node scripts/verify-agent-clis.cjs The verifier MUST report the selected binary, version/help result, credential-reference status, and failures without secret values. A successful binary check does not prove a successful model request. For a provider smoke test, use a bounded prompt that requests exactly `READY`, read-only workspace permissions, and the explicit qualified model. Record exit status and sanitized diagnostics only. + +Run `copilot doctor --config ` to verify repository configuration and locale behavior without changing repository configuration. The human report uses `repository-locale` (`en-US` by default); issue and pull-request locale overrides appear as diagnosed facts but do not select terminal language. A non-bundled valid locale requires a ready planner/language agent and otherwise produces one complete English fallback report. Verify that stable check IDs and enums remain unchanged and that no raw provider response or credential value appears. diff --git a/docs/single-actions/workflow-and-cli.mdx b/docs/single-actions/workflow-and-cli.mdx index a4502d7e3..4a5eb5704 100644 --- a/docs/single-actions/workflow-and-cli.mdx +++ b/docs/single-actions/workflow-and-cli.mdx @@ -148,6 +148,8 @@ All commands support `-h, --help`. The `-d, --debug` option enables additional d Initializes the current GitHub repository through an interactive English-language wizard. It can select the workflows and templates to install, route each Copilot task to Codex, OpenCode, or Cursor, configure repository behavior and AI policy, inspect and provision GitHub Actions Variables and Secrets at repository or organization scope, verify access, and create the configured labels and issue types. If the repository has no version tags, setup asks whether it should create `v1.0.0`. +Setup is the profile-creation surface, so its questionnaire, plan, confirmation, and embedded readiness checks stay in authoritative English until the repository configuration is installed. The selected `repository-locale` governs subsequent repository-aware output, including `copilot doctor`; issue and pull-request overrides do not change setup or doctor language. + | Option | Required | Description | | --- | --- | --- | | `-t, --token ` | No | Setup PAT override. Otherwise uses the hidden prompt interactively or `PERSONAL_ACCESS_TOKEN` from the environment. | @@ -207,6 +209,21 @@ The explicit `--workflow-pat` and `--secret` options are provided for automation Checks the setup PAT, selected workflow templates, Repository Variables, Secret presence, remote credential health, and live merge-queue readiness for production and development targets. It combines classic branch protection with effective rulesets and emits stable, ordered checks with `pass`, `warn`, `fail`, or dependency-blocked `skipped` status. Active release branches are discovered and revalidated at hotfix runtime. A failed setup PAT does not hide independent local drift: local checks continue and remote dependants name the blocking check. Independent remote probes use a fixed maximum concurrency of four, but their completion order never changes report order. Doctor exits non-zero exactly when at least one check fails. +The complete human-readable report uses the configured repository locale and defaults to English (`en-US`). English and Spanish use reviewed bundled catalogs with exact or compatible-base matching. Any other valid BCP-47 locale uses one schema-constrained planner/language-agent request for the complete report catalog. If that request is unavailable or invalid, the whole report falls back atomically to English—never a mixture of translated headings and English diagnostics. Check IDs, status enums, branch names, credential names, and other machine facts remain stable. Raw provider and credential-health messages are not echoed into the report. + +This reduced two-check fixture shows the default English shape: + +```text +Copilot Doctor + +✓ PASS Configuration — Setup configuration is valid. +✗ FAIL Merge queue production — Target policy could not be inspected because the provider request failed. + Action: Check the setup PAT permissions and target branch policy, then retry. + +Checks: 1 pass, 0 warn, 1 fail, 0 skipped. +No repository configuration was changed. +``` + Doctor's composition contains query ports only: it never creates, updates, deletes, or overwrites repository configuration. The credential health query may dispatch the already-installed `copilot_credential_health.yml` workflow, which creates an Actions run but does not modify repository configuration or expose Secret values. Only setup owns the separate temporary health-workflow bootstrap capability. ```bash diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 36d7dc6cb..530cd9a55 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -249,7 +249,10 @@ "src/domain/locale.ts", "src/domain/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", + "src/application/policies/setup_doctor_message_catalog.ts", + "src/cli/setup_doctor_presenter.ts", "src/application/usecases/localization/resolve_message_catalog_use_case.ts" ], "mode": "each", @@ -265,18 +268,23 @@ "src/application/policies/copilot_interaction_policy.ts", "src/application/policies/publication_message_catalog.ts", "src/application/policies/semantic_result_publication_policy.ts", + "src/application/policies/merge_queue_message_catalog.ts", "src/application/policies/setup_configuration_defaults.ts", "src/application/policies/setup_configuration_plan.ts", + "src/application/policies/setup_doctor_message_catalog.ts", "src/application/policies/setup_doctor_report_policy.ts", "src/application/policies/status_command_policy.ts", "src/application/usecases/localization/resolve_message_catalog_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/setup_wizard_use_case.ts", "src/application/usecases/steps/common/comment_language_translation_workflow.ts", "src/application/usecases/steps/common/publish_resume_use_case.ts", "src/application/usecases/steps/common/publish_resume_workflow.ts", "src/application/usecases/steps/common/reply_publication_workflow.ts", "src/application/usecases/steps/common/status_card_publication_workflow.ts", + "src/cli/setup_doctor_presenter.ts", + "src/cli/setup_prompt_rendering.ts", "src/domain/locale.ts", "src/domain/message_catalog.ts" ], diff --git a/specs/CATALOG.md b/specs/CATALOG.md index f5e8bea8c..e2db33e85 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,13 +10,13 @@ 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 | 103 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 | 114 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) | 15 paths · 2026-09-11 | +| `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 | | `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 82 paths · 2026-09-14 | | `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 71 paths · 2026-09-14 | -| `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 1 companion | 39 paths · 2026-09-12 | +| `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 1 companion | 51 paths · 2026-09-14 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 21 paths · 2026-09-13 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 46 paths · 2026-09-13 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, analyze bounded evidence, publish stable findings, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 1 companion | 63 paths · 2026-09-13 | @@ -34,9 +34,9 @@ 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/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_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/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_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/development/architecture.mdx`](../docs/development/architecture.mdx) +- 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) +- 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 @@ -52,12 +52,12 @@ debt or convert unknown historic intent into a design decision. ### `merge-queue-readiness` — Merge queue readiness and effective target rules - Owner: Copilot maintainers -- Last verified: 2026-09-11 +- Last verified: 2026-09-14 - Specifications: [`specs/merge-queue-readiness.md`](./merge-queue-readiness.md) - Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`.github/workflows/repowise.yml`](../.github/workflows/repowise.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) -- Core code: [`src/domain/merge_queue_readiness.ts`](../src/domain/merge_queue_readiness.ts) · [`src/data/repository/deployment/github_target_merge_capabilities_inspector.ts`](../src/data/repository/deployment/github_target_merge_capabilities_inspector.ts) · [`src/infrastructure/github/octokit_checks_adapters.ts`](../src/infrastructure/github/octokit_checks_adapters.ts) · [`scripts/validate-workflow-contract.cjs`](../scripts/validate-workflow-contract.cjs) -- Tests: [`src/domain/__tests__/merge_queue_readiness.test.ts`](../src/domain/__tests__/merge_queue_readiness.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/data/repository/deployment/__tests__/github_deployment_adapters.test.ts`](../src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/merge_queue_readiness.ts`](../src/domain/merge_queue_readiness.ts) · [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) · [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) · [`src/application/policies/deployment_plan_policy.ts`](../src/application/policies/deployment_plan_policy.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/data/repository/deployment/github_target_merge_capabilities_inspector.ts`](../src/data/repository/deployment/github_target_merge_capabilities_inspector.ts) · [`src/infrastructure/github/octokit_checks_adapters.ts`](../src/infrastructure/github/octokit_checks_adapters.ts) · [`scripts/validate-workflow-contract.cjs`](../scripts/validate-workflow-contract.cjs) +- Tests: [`src/domain/__tests__/merge_queue_readiness.test.ts`](../src/domain/__tests__/merge_queue_readiness.test.ts) · [`src/application/policies/__tests__/deployment_plan_policy.test.ts`](../src/application/policies/__tests__/deployment_plan_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/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts`](../src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) - User documentation: [`docs/issues/deployment-orchestration.mdx`](../docs/issues/deployment-orchestration.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) ### `bugbot-review-state-reconciliation` — Bugbot review-state reconciliation @@ -96,13 +96,13 @@ debt or convert unknown historic intent into a design decision. ### `setup-and-doctor` — Setup, configuration, credentials, and doctor - Owner: Copilot maintainers -- Last verified: 2026-09-12 +- Last verified: 2026-09-14 - Specifications: [`specs/setup-configuration-credentials-and-doctor.md`](./setup-configuration-credentials-and-doctor.md) · [`specs/setup-doctor-architecture-hardening.md`](./setup-doctor-architecture-hardening.md) - Workflows: [`setup/workflows/agent-cli-provisioning.yml`](../setup/workflows/agent-cli-provisioning.yml) · [`setup/workflows/copilot_credential_health.yml`](../setup/workflows/copilot_credential_health.yml) - Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) · [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) -- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/setup_questionnaire.ts`](../src/domain/setup_questionnaire.ts) · [`src/application/ports/setup_terminal_ports.ts`](../src/application/ports/setup_terminal_ports.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_questionnaire_controller.ts`](../src/application/usecases/setup/setup_questionnaire_controller.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) · [`src/infrastructure/setup_remote_credential_health_adapter.ts`](../src/infrastructure/setup_remote_credential_health_adapter.ts) · [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) · [`src/cli/setup_question_renderer.ts`](../src/cli/setup_question_renderer.ts) · [`src/cli/setup_plan_presenter.ts`](../src/cli/setup_plan_presenter.ts) · [`src/cli/setup_doctor_presenter.ts`](../src/cli/setup_doctor_presenter.ts) -- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.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/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_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/infrastructure/__tests__/setup_workspace_adapter.test.ts`](../src/infrastructure/__tests__/setup_workspace_adapter.test.ts) · [`src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts`](../src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) -- User documentation: [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/security-operations/operations/provisioning.mdx`](../docs/security-operations/operations/provisioning.mdx) · [`docs/security-operations/security/credentials.mdx`](../docs/security-operations/security/credentials.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) +- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/setup_questionnaire.ts`](../src/domain/setup_questionnaire.ts) · [`src/application/ports/setup_terminal_ports.ts`](../src/application/ports/setup_terminal_ports.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.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/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_questionnaire_controller.ts`](../src/application/usecases/setup/setup_questionnaire_controller.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_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/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) · [`src/infrastructure/setup_remote_credential_health_adapter.ts`](../src/infrastructure/setup_remote_credential_health_adapter.ts) · [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) · [`src/cli/setup_question_renderer.ts`](../src/cli/setup_question_renderer.ts) · [`src/cli/setup_plan_presenter.ts`](../src/cli/setup_plan_presenter.ts) · [`src/cli/setup_doctor_presenter.ts`](../src/cli/setup_doctor_presenter.ts) · [`src/cli/setup_prompt_rendering.ts`](../src/cli/setup_prompt_rendering.ts) · [`src/infrastructure/composition/setup_doctor_composition_root.ts`](../src/infrastructure/composition/setup_doctor_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) +- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_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/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_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/infrastructure/__tests__/setup_workspace_adapter.test.ts`](../src/infrastructure/__tests__/setup_workspace_adapter.test.ts) · [`src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts`](../src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_prompt_rendering.test.ts`](../src/cli/__tests__/setup_prompt_rendering.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) +- User documentation: [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/security-operations/operations/provisioning.mdx`](../docs/security-operations/operations/provisioning.mdx) · [`docs/security-operations/security/credentials.mdx`](../docs/security-operations/security/credentials.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) ### `managed-issue-lifecycle` — Managed issue and branch lifecycle diff --git a/specs/catalog.json b/specs/catalog.json index b27a53478..f15a8fe71 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -35,7 +35,12 @@ "src/application/ports/message_catalog_ports.ts", "src/application/policies/resolved_message_catalog_policy.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", + "src/application/policies/setup_doctor_report_policy.ts", "src/application/usecases/localization/resolve_message_catalog_use_case.ts", + "src/application/usecases/setup/doctor_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/policies/comment_translation_policy.ts", "src/application/usecases/comment_automation_use_case.ts", @@ -75,7 +80,11 @@ "src/application/policies/__tests__/semantic_result_publication_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__/setup_doctor_message_catalog.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/setup/__tests__/doctor_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__/deployment_message_catalog.test.ts", "src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts", @@ -123,6 +132,8 @@ "docs/bugbot/examples.mdx", "docs/single-actions/configuration.mdx", "docs/single-actions/available-actions.mdx", + "docs/single-actions/workflow-and-cli.mdx", + "docs/security-operations/operations/verification.mdx", "docs/development/architecture.mdx" ] }, @@ -207,7 +218,7 @@ "status": "implemented", "scope": "Fail-closed validation of required checks and merge-group workflow support", "owner": "Copilot maintainers", - "lastVerified": "2026-09-11", + "lastVerified": "2026-09-14", "specs": [ "specs/merge-queue-readiness.md" ], @@ -221,13 +232,21 @@ "src/application/usecases/setup/merge_queue_readiness_use_case.ts" ], "code": [ + "scripts/coverage-budgets.json", "src/domain/merge_queue_readiness.ts", + "src/application/policies/merge_queue_message_catalog.ts", + "src/application/policies/deployment_message_catalog.ts", + "src/application/policies/deployment_plan_policy.ts", + "src/application/policies/setup_doctor_message_catalog.ts", + "src/application/usecases/setup/merge_queue_readiness_use_case.ts", "src/data/repository/deployment/github_target_merge_capabilities_inspector.ts", "src/infrastructure/github/octokit_checks_adapters.ts", "scripts/validate-workflow-contract.cjs" ], "tests": [ "src/domain/__tests__/merge_queue_readiness.test.ts", + "src/application/policies/__tests__/deployment_plan_policy.test.ts", + "src/application/policies/__tests__/setup_doctor_message_catalog.test.ts", "src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts", "src/data/repository/deployment/__tests__/github_deployment_adapters.test.ts", "src/tooling/__tests__/validate_workflow_contract.test.ts" @@ -519,7 +538,7 @@ "status": "implemented", "scope": "Plan, validate, provision, and audit a repository installation without exposing credentials", "owner": "Copilot maintainers", - "lastVerified": "2026-09-12", + "lastVerified": "2026-09-14", "specs": [ "specs/setup-configuration-credentials-and-doctor.md", "specs/setup-doctor-architecture-hardening.md" @@ -537,11 +556,15 @@ "src/domain/setup_questionnaire.ts", "src/application/ports/setup_terminal_ports.ts", "src/application/policies/setup_questionnaire_policy.ts", + "src/application/policies/setup_doctor_message_catalog.ts", "src/application/policies/setup_doctor_report_policy.ts", "src/application/usecases/setup/setup_wizard_use_case.ts", "src/application/usecases/setup/setup_questionnaire_controller.ts", "src/application/usecases/setup/setup_credentials_use_case.ts", "src/application/usecases/setup/doctor_use_case.ts", + "src/application/usecases/setup/merge_queue_readiness_use_case.ts", + "src/application/ports/message_catalog_ports.ts", + "src/application/usecases/localization/resolve_message_catalog_use_case.ts", "src/application/policies/setup_configuration_validation.ts", "src/infrastructure/setup_workspace_adapter.ts", "src/data/repository/repository_variables_repository.ts", @@ -549,19 +572,26 @@ "src/cli/setup_terminal_driver.ts", "src/cli/setup_question_renderer.ts", "src/cli/setup_plan_presenter.ts", - "src/cli/setup_doctor_presenter.ts" + "src/cli/setup_doctor_presenter.ts", + "src/cli/setup_prompt_rendering.ts", + "src/infrastructure/composition/setup_doctor_composition_root.ts", + "scripts/coverage-budgets.json" ], "tests": [ "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", + "src/application/policies/__tests__/setup_doctor_message_catalog.test.ts", "src/application/policies/__tests__/setup_doctor_report_policy.test.ts", "src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts", "src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts", "src/application/usecases/setup/__tests__/setup_credentials_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/infrastructure/__tests__/setup_workspace_adapter.test.ts", "src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts", "src/data/repository/__tests__/repository_variables_repository.test.ts", "src/cli/__tests__/setup_presenters.test.ts", + "src/cli/__tests__/setup_prompt_rendering.test.ts", + "src/__tests__/cli.test.ts", "src/cli/__tests__/setup_terminal_driver.test.ts", "src/architecture/__tests__/setup_doctor_boundaries.test.ts" ], @@ -571,7 +601,8 @@ "docs/configuration-checklist.mdx", "docs/security-operations/operations/provisioning.mdx", "docs/security-operations/security/credentials.mdx", - "docs/single-actions/workflow-and-cli.mdx" + "docs/single-actions/workflow-and-cli.mdx", + "docs/security-operations/operations/verification.mdx" ] }, { diff --git a/specs/merge-queue-readiness.md b/specs/merge-queue-readiness.md index 2a9ff5b39..9522e85fa 100644 --- a/specs/merge-queue-readiness.md +++ b/specs/merge-queue-readiness.md @@ -4,6 +4,7 @@ complete; clean-tree bundle validation and controlled live queue validation remain external completion gates - Date: 2026-09-10 +- Last updated: 2026-09-14 - Owners: `vypdev/copilot` product and engineering maintainers - Scope: discover the effective merge policy of every deployment target and prevent a managed pull request from entering an unusable merge queue. @@ -563,13 +564,21 @@ queue mutation was attempted. same comment policy rather than a separate unbounded channel. - The dashboard is published as GitHub-owned only after auto-merge/queue mutation succeeds or authoritative queue membership already exists. -- English and Spanish renderers MUST carry the same facts and action. Unknown - provider text falls back to English. +- Repository-aware readiness UI uses the repository locale and authoritative + English by default. Reviewed English and Spanish catalogs resolve through + exact or compatible-base matching; any other valid BCP-47 locale resolves one + complete schema-constrained catalog through the configured language agent or + falls back atomically to English. +- Readiness and pull-request-mode policies expose stable semantic reason codes. + Renderers translate those reasons; raw provider or workflow inspection prose + is not copied into product UI. Technical verdicts, target roles, branch names, + check names, and integration identities remain locale-independent facts. - Emoji supplements text and is never the sole status signal. Tables use text verdicts, logical headings, and descriptive links. Mobile rendering keeps the primary status/action above tables and details. -- Contexts, branch names, provider messages, Markdown, markers, mentions, and - URLs are sanitized with the existing deployment presentation rules. +- Contexts, branch names, Markdown, markers, mentions, and URLs are sanitized + with the existing deployment presentation rules. Untrusted provider messages + remain bounded internal diagnostics and cannot create mixed-language output. ## 10. Failure, recovery, and cleanup @@ -635,7 +644,7 @@ republish on retry. ## 14. Testing strategy and numeric budget -The minimum is **46 distinct cases**, derived from the rule-source, evidence, +The minimum is **50 distinct cases**, derived from the rule-source, evidence, ordering, race, recovery, and presentation risks below. | Area | Minimum distinct cases | Behaviors/risks covered | @@ -644,9 +653,9 @@ ordering, race, recovery, and presentation risks below. | State/application/idempotency/races | 10 | promotion and reconciliation preflight ordering; no side effects on block; final reread; changed rules; moved head; already queued; ambiguous enqueue retry; post-publication retained facts; create-only bypass of readiness | | Adapters/provider contracts | 11 | classic present/404/403; effective rules parsing; repository/org source aggregation; queue/strict/status/workflow rule mapping; GitHub Actions identity; workflow exact match; dynamic/ambiguous job; inaccessible workflow/error mapping | | Workflows/setup/schema | 6 | active/setup `merge_group`; structured generated input; setup ready/unknown; doctor drift; no obsolete boolean; least-privilege API contract | -| UI/UX/localization/sanitization | 5 | ready, pre-mutation block, post-publication partial, recovered/queued, Spanish plus hostile provider values | +| UI/UX/localization/sanitization | 9 | ready, pre-mutation block, post-publication partial, recovered/queued, English default, Spanish exact/base, arbitrary dynamic locale, atomic fallback, hostile provider suppression | | Integration/security/migration | 2 | ruleset-only repository end-to-end fake; existing operation with new unknown required check | -| **Total** | **46** | No double counting | +| **Total** | **50** | No double counting | Repository thresholds remain lines/statements 90%, functions 88%, and branches 82%. New pure readiness and attestation policies require 100% statement/function @@ -708,6 +717,17 @@ human-facing name. rendered, then mentions, workflow commands, Markdown, and markers are inert. 14. Given Spanish locale, when readiness blocks, then status, action, retained facts, and technical evidence are semantically equivalent to English. +15. Given no locale configuration, when setup or deployment renders readiness, + then all product prose is English. +16. Given `es-MX`, when readiness renders, then the complete Spanish base + catalog is used and stable verdicts and target identities remain unchanged. +17. Given a valid non-bundled locale and a valid dynamic catalog, when doctor + inspects two targets, then one catalog request serves the complete artifact. +18. Given an unavailable or structurally invalid dynamic catalog, when doctor + renders, then every heading, state, reason, and action uses English. +19. Given hostile or foreign-language provider diagnostic text, when readiness + renders, then the raw text is absent and a bounded semantic reason/action is + shown in the resolved artifact locale. ## 17. Requirements traceability @@ -736,9 +756,10 @@ human-facing name. 6. Add pre-mutation and pre-enqueue evaluations, expected-head enqueue, and idempotent membership recovery. 7. Integrate structured setup/doctor inspection and generated Action input. -8. Add English/Spanish readiness recovery, target/producer rows in setup and - doctor, and actionable dashboard/Job Summary failures. -9. Update public docs and workflow contracts, satisfy the 46-case budget, +8. Add English-default generic locale resolution, target/producer rows in setup + and doctor, and actionable dashboard/Job Summary failures without rendering + raw provider prose. +9. Update public docs and workflow contracts, satisfy the 50-case budget, coverage, architecture, build, and documentation gates. 10. Run a controlled repository test with a temporary non-required fixture workflow first; enable a real merge-queue ruleset only through an explicit @@ -753,7 +774,7 @@ human-facing name. - [x] Queue mutation is head-bound and idempotently recoverable. - [x] No legacy optimistic mode or obsolete boolean remains. - [x] Architecture boundaries and the no-polling contract are executable. -- [x] More than 46 distinct feature cases and repository coverage thresholds pass. +- [x] More than 50 distinct feature cases and repository coverage thresholds pass. - [x] Setup, doctor, issue dashboard, Job Summary, English, Spanish, sanitization, and comment-budget automated acceptance pass. - [x] User, setup, operator, contributor, and parent-spec documentation agree. diff --git a/specs/repository-locale-and-localization.md b/specs/repository-locale-and-localization.md index af18bdf65..6520fa065 100644 --- a/specs/repository-locale-and-localization.md +++ b/specs/repository-locale-and-localization.md @@ -937,7 +937,7 @@ pull-requests-locale: "" 7. Update all named docs, generated workflows/action bundles, and related SDD clauses before announcing support. -Implementation evidence as of 2026-09-14: PRs #366–#373 deliver the canonical +Implementation evidence as of 2026-09-14: PRs #366–#374 deliver the canonical locale profile, typed and validated catalog resolution, English-default shared publication, non-mutating addressed-language adaptation, localized agent response contracts, branch synchronization, and bounded review context. The @@ -953,9 +953,13 @@ four bounded milestones, and removes internal `Result.steps` from deployment operator UI. The installed release/hotfix templates pass `repository-locale` and empty inheriting issue/PR overrides. Legacy version-1 operations without a locale remain readable and use the current effective profile until completion. -Merge-readiness diagnostics, -lifecycle, setup/doctor, and the remaining public surfaces are not claimed -complete by this evidence. +The setup/doctor follow-up adds one English-default repository-locale catalog for +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. ### 13.5 Rollback diff --git a/specs/setup-configuration-credentials-and-doctor.md b/specs/setup-configuration-credentials-and-doctor.md index 3992cbc27..a97566e0b 100644 --- a/specs/setup-configuration-credentials-and-doctor.md +++ b/specs/setup-configuration-credentials-and-doctor.md @@ -2,9 +2,9 @@ - Status: Implemented — automated architecture, UX, documentation, and coverage gates complete; controlled live GitHub permission-path evidence remains external - Date: 2026-09-11 -- Last updated: 2026-09-12 +- Last updated: 2026-09-14 - Catalog capability ID: `setup-and-doctor` -- Last verified: 2026-09-12 +- Last verified: 2026-09-14 - Owners: Copilot maintainers - Scope: interactive/non-interactive installation planning, file and resource provisioning, credential validation, and read-only diagnosis - Related issues/PRs: merge-queue readiness SDD; architecture quality and @@ -47,7 +47,9 @@ but unusable, overwrite hand-maintained files, or expose credentials. managed files require approval and backups. 6. Doctor compares the repository to the same expected configuration and emits stable, ordered `pass`, `warn`, `fail`, or dependency-blocked `skipped` - checks without mutation. Local checks continue after setup-PAT failure. + checks without mutation. Local checks continue after setup-PAT failure. One + resolved repository-locale catalog supplies every human heading, summary, + state, and action in the report; the default and atomic fallback are English. ### 2.3 Evidence and contract classification @@ -102,6 +104,11 @@ organization value that GitHub Actions will expose. 3. `.env` is not a supported credential source. 4. Organization storage MUST be permission-checked and safely scoped. 5. Merge-queue mode fails closed unless workflow support is proven or attested. +6. Setup remains authoritative English while it creates the repository profile; + subsequent doctor output uses the configured repository locale. Issue and + pull-request overrides never select setup or doctor language. +7. Raw PAT, credential-health, rule, and workflow-provider messages never enter + a setup plan or doctor report. ## 5. Current versus proposed product journey @@ -160,7 +167,7 @@ existing resources and avoid duplicate shadowing. | features | all except inactivity closure | named `SetupFeature` booleans | config file → prompts | | branches | `master`, `develop`, standard prefixes | non-empty, no whitespace | repository Variables | | assignment | 1 assignee, 1 reviewer | 0–10 / 0–15 | Variables | -| locales | `en-US` | supported locale strings | Variables | +| locales | repository `en-US`; issue/PR inherit | any valid canonical BCP-47 tag; reviewed `en`/`es`, dynamic otherwise | Variables; repository → issue/PR inheritance | | agent roles | `codex` / `openai/gpt-5.6-luna` | `codex`, `opencode`, `cursor` + allowed model | Variables | | Bugbot | low, smart in setup, non-blocking | bounded enums/1–100 comments | Variables | | storage | repository, preserve existing | repository/org per resource | remote GitHub | @@ -177,8 +184,8 @@ rules, secret serialization, backups, and confirmation are not configurable. | Boundary | Owns | Must not own/import | |---|---|---| | Domain | setup plan/check/value types | prompts/Octokit/fs | -| Policies | defaults, immutable clone/questionnaire, validation, storage, plans, doctor report | terminal UI | -| Use cases | drive questionnaire, credential decisions, provision, doctor probes | provider DTOs | +| Policies | defaults, immutable clone/questionnaire, validation, storage, plans, typed message catalogs, doctor report | terminal I/O or language/provider selection | +| Use cases | drive questionnaire, credential decisions, provision, doctor probes, resolve one complete doctor catalog | provider DTOs or feature-local language branches | | Ports | raw terminal, render/present/confirm, workspace, narrow remote queries/commands, health | provider implementation | | Adapters | terminal mechanics, presenters, filesystem, narrow Octokit reads/writes, health query/bootstrap | product defaults/question order | | CLI composition | command flags and concrete wiring | duplicated validation | @@ -209,8 +216,12 @@ Complete: **Copilot setup is healthy.** 12 workflows, 8 templates, Variables, an The plan MUST group files, Variables, Secrets by name only, credentials by status/source scope, warnings, and merge-queue readiness. One confirmation is the primary action. Secret input is masked. Text and status words accompany -icons; output remains readable at narrow terminal widths. English is the CLI -fallback. Provider messages are summarized and technical causes disclosed later. +icons; output remains readable at narrow terminal widths. Setup is English while +creating the profile. Doctor uses the repository locale: English and Spanish are +bundled, other valid locales use one bounded complete-catalog request, and any +unavailable or invalid dynamic response falls back atomically to English. +Provider messages are replaced by stable semantic reasons and one concrete +recovery action rather than echoed or partially translated. ## 10. Failure, recovery, and cleanup @@ -238,7 +249,9 @@ one check is `fail`; `warn` and `skipped` preserve a successful exit when no failure exists. Every check has a stable ID, status, summary, safe evidence, blocker IDs, and an action where useful. Reports render in declared order even when independent read-only probes complete out of order. Logs correlate -owner/repository but redact values. No telemetry service is required. +owner/repository but redact values. Catalog resolution records requested locale, +resolved locale, exact/base/dynamic/fallback source, descriptor count, and a +stable fallback reason without storing prose. No telemetry service is required. ## 13. Compatibility, migration, rollout, and rollback @@ -259,12 +272,13 @@ manual reversal. | Questionnaire/wizard/idempotency | 18 | transitions, immutability, cancel, preserve, replace | | Credentials/provider adapters | 18 | valid/invalid/missing/unverifiable/groups | | Workflows/assets/schema | 14 | selection, parity, readiness, permissions | -| Prompt/CLI UX/sanitization | 12 | masking, status order, non-interactive | +| Prompt/CLI UX/sanitization/localization | 18 | masking, status order, non-interactive, English default, Spanish exact/base, arbitrary locale, atomic fallback, hostile diagnostic suppression | | Integration/security/cutover | 12 | backup, org scope, doctor, no `.env` | -| **Total** | **98** | no double counting | +| **Total** | **104** | no double counting | -Global coverage thresholds remain; questionnaire and doctor report policies -MUST reach 100% statements/branches/functions/lines, and changed setup +Global coverage thresholds remain; questionnaire, doctor catalog/report, shared +merge-readiness message, and doctor presenter policies MUST reach 100% +statements/branches/functions/lines, and changed setup application modules MUST reach at least 90% branch coverage. Tests use temporary directories, fake prompts and GitHub adapters, no live services. Workflow checks parse YAML. Manual evidence covers terminal widths, canceled prompts, secret masking, and GitHub permission variants. @@ -289,6 +303,15 @@ widths, canceled prompts, secret masking, and GitHub permission variants. 7. Given doctor, no mutation port is called and unhealthy state returns non-zero. 8. Given merge-queue without proven support, setup/doctor reports fail closed. 9. Given output inspection, no secret value appears. +10. Given no explicit locale, doctor renders one complete English report. +11. Given `es-MX`, doctor resolves the reviewed Spanish base catalog while + keeping check IDs, enums, branch names, and credential names unchanged. +12. Given a valid non-bundled locale, doctor requests one complete catalog and + reuses it for report checks, merge readiness, and terminal presentation. +13. Given an invalid dynamic catalog response, every human string in the doctor + artifact falls back to English rather than mixing languages. +14. Given hostile PAT, credential-health, or rule-provider prose, doctor omits + the raw value and renders only the catalogued reason and recovery action. ## 17. Requirements traceability @@ -311,7 +334,7 @@ widths, canceled prompts, secret masking, and GitHub permission variants. ## 19. Definition of Done - [x] Every new option has default, bounds, precedence, persistence, retirement/rejection, and security rules. -- [x] The 98-case budget and coverage thresholds pass. +- [x] The 104-case budget and coverage thresholds pass. - [x] Setup cancel/retry/partial state and doctor read-only behavior pass. - [x] Secrets are absent from plans, config, logs, errors, and backups. - [x] Workflow/assets, documentation, and catalog checks pass. diff --git a/specs/setup-doctor-architecture-hardening.md b/specs/setup-doctor-architecture-hardening.md index 333f72051..79196d73f 100644 --- a/specs/setup-doctor-architecture-hardening.md +++ b/specs/setup-doctor-architecture-hardening.md @@ -2,9 +2,9 @@ - Status: Implemented — automated gates complete; controlled live GitHub permission-path evidence remains external - Date: 2026-09-11 -- Last updated: 2026-09-12 +- Last updated: 2026-09-14 - Catalog capability ID: `setup-and-doctor` -- Last verified: 2026-09-12 in the P1-B implementation worktree +- Last verified: 2026-09-14 - Owners: Copilot maintainers and setup operators - Scope: separate setup decisions from terminal mechanics, execute doctor as a deterministic read-only check graph, and split remote resource responsibilities @@ -24,7 +24,9 @@ collection remains a separate secret-aware flow after configuration is approved. when the setup PAT is invalid; remote dependants are marked `skipped`. Independent read-only probes run with a fixed maximum concurrency of four, but the report is always rendered in declared plan order. Doctor composition exposes no mutation -port. +port and resolves one complete repository-locale catalog for checks, merge +readiness, and terminal presentation. English remains the authoritative default +and atomic fallback. ```text defaults + overrides -> questionnaire -> immutable config -> plan -> confirm -> apply @@ -101,6 +103,8 @@ as pass. 3. Return the maximum safe doctor diagnosis even after a failed dependency. 4. Guarantee stable report order under parallel completion. 5. Make read-only doctor authority structurally impossible to weaken. +6. Keep each doctor artifact in one resolved locale without feature-local + language branching or raw provider prose. ### 4.2 Non-goals @@ -123,6 +127,10 @@ as pass. 5. Secrets are masked on entry, memory-only, excluded from questionnaire state, plan, errors, backups, report, and logs. 6. Fixed check order and maximum concurrency four are not user-configurable. +7. The repository locale alone selects doctor language; issue/PR overrides are + diagnosed facts. Check IDs, enums, names, refs, and evidence keys never localize. +8. One missing or invalid dynamic descriptor falls the complete report back to + English before presentation. ## 5. Current versus proposed product journey @@ -247,16 +255,19 @@ check caused by a failed dependency does not add a second failure. | Plan order | Check group/ID | Dependency | Execution | |---:|---|---|---| | 1 | `configuration.valid` | none | pure/local | -| 2 | `workspace.repository-root` | none | local | -| 3 | `workflow.` sorted by path | configuration | local, sequential projection | -| 4 | `credentials.setup-pat` | repository coordinates | remote gate | -| 5 | `github.resource-scopes` | valid PAT | remote group, max four | -| 6 | `github.merge-queue` | valid PAT + valid config | remote group, max four | -| 7 | `github.variables` | valid PAT + scopes | remote group, max four | -| 8 | `github.secret-names` | valid PAT + scopes | remote group, max four | -| 9 | `credential.` in requirement order | secret names + valid PAT | health group, max four | - -Stage 0 runs configuration, root, and workflow comparisons even when PAT +| 2 | `locale.repository`, `locale.issue`, `locale.pull-request` or `locale.profile` | configuration | pure/catalog capability | +| 3 | `workspace.repository-root` | none | local | +| 4 | `workflow.` sorted by path | configuration | local, sequential projection | +| 5 | `credentials.setup-pat` | repository coordinates | remote gate | +| 6 | `github.resource-scopes` | valid PAT | remote group, max four | +| 7 | `github.merge-queue` | valid PAT + valid config | remote group, max four | +| 8 | `github.variables` | valid PAT + scopes | remote group, max four | +| 9 | `github.secret-names` | valid PAT + scopes | remote group, max four | +| 10 | `credential.` in requirement order | secret names + valid PAT | health group, max four | + +Before stage 0, doctor validates the locale profile and resolves one complete +catalog through exact, base, dynamic, or fallback selection. Stage 0 runs +configuration, locale capability, root, and workflow comparisons even when PAT validation fails. Stage 1 validates PAT. Stage 2 starts applicable resource, queue, Variables, and Secret-name queries with a shared four-slot limiter. Stage 3 queries credential health only for present required credentials after Secret @@ -295,7 +306,9 @@ to one semantic check result rather than aborting unrelated checks. ## 7. User-facing configuration All existing setup options/defaults/precedence remain defined by the baseline -SDD. This refactor adds no public option. The questionnaire order, question/check +SDD. This refactor adds no public option. `repository-locale` defaults to +`en-US`; empty issue and pull-request overrides inherit it but never select +terminal language. The questionnaire order, question/check IDs, cancellation semantics, doctor statuses, DAG, concurrency four, report order, and mutation-port prohibition are fixed. @@ -310,8 +323,8 @@ config keys/values remain errors, never ignored forward compatibility. | Layer/boundary | Owns | Must not own/import | |---|---|---| -| Domain/pure policies | questionnaire transitions, config validation, check plan/report ordering | TTY, Octokit, fs mutation | -| Application | controllers, setup plan, doctor scheduler, semantic read/command ports | console, provider DTOs | +| Domain/pure policies | questionnaire transitions, config validation, check plan/report ordering, locale/catalog contracts | TTY, Octokit, fs mutation | +| Application | controllers, setup plan, doctor scheduler, typed catalogs, semantic read/command/language ports | console, provider DTOs, hard-coded feature copy | | Adapters | terminal events, local workflow comparison, narrow GitHub reads/writes | question/check policy | | Infrastructure | fixed composition, auth binding, four-slot limiter | product defaults/status decisions | | Entrypoints | flags/config/repo target and exit mapping | questions/check algorithms | @@ -328,6 +341,9 @@ config keys/values remain errors, never ignored forward compatibility. 5. A concurrency instrumentation test proves maximum four and stable plan order. 6. Each remote adapter implements one catalogued semantic port; broad method bags and service locators fail architecture review/checks. +7. Doctor resolves one catalog before constructing visible checks, passes the + same immutable view to merge readiness and the presenter, and never branches + on a concrete language tag. ## 9. UI/UX and content contract @@ -338,13 +354,16 @@ stays adjacent to the question. Secrets render no characters and no default. ```text Copilot Doctor — partial diagnosis -FAIL Setup PAT Credential was rejected by GitHub. -PASS Workflow copilot_issue Matches the installed template. -FAIL Workflow release Local workflow differs from the template. -SKIP Repository Variables Requires a valid setup PAT. -SKIP Workflow credentials Requires repository Secret metadata. +FAIL Setup PAT — The setup PAT is invalid. + Action: Replace the setup PAT and run doctor again. +PASS Workflow copilot-issue-yml — Matches the installed setup template. +FAIL Workflow release-yml — Local workflow is changed. + Action: Run setup to repair this managed workflow. +SKIP Repository Variables — Variable checks could not run. + Blocked by: credentials.setup-pat + Action: Resolve the blocking check and rerun doctor. -Action: replace the setup PAT, repair the release workflow, then run `copilot doctor` again. +Checks: 1 pass, 0 warn, 2 fail, 1 skipped. No repository configuration was changed. ``` @@ -352,8 +371,11 @@ Pending setup shows current section and position; action-required shows one validation/confirmation; blocked shows impact/cause/action/no-write; partial doctor distinguishes warn/skipped/fail; complete says how many checks passed and that no configuration was changed. Status always has text in addition to icon or -color. Width 40/80/120 fixtures must remain readable; `NO_COLOR` and English -fallback remain supported. +color. Width 40/80/120 fixtures must remain readable; `NO_COLOR`, Spanish +exact/base resolution, arbitrary BCP-47 dynamic resolution, and whole-artifact +English fallback remain supported. Raw PAT, credential-health, workflow, and +rule-provider messages never appear in the report; stable semantic copy states +the impact and one recovery action. ## 10. Failure, recovery, and cleanup @@ -404,7 +426,7 @@ report per command avoids interleaved parallel output. ## 14. Testing strategy and numeric budget -This SDD owns at least **24 distinct cases**. +This SDD owns at least **30 distinct cases**. | Area | Minimum cases | Required risks | |---|---:|---| @@ -413,9 +435,9 @@ This SDD owns at least **24 distinct cases**. | Application controllers/scheduler | 4 | interactive, non-interactive, dry-run, partial doctor | | Adapters/provider mapping | 3 | TTY secret, remote scope/error, health mapping | | Workflow/schema/setup contract | 2 | active/template comparison and sole config schema | -| UX/accessibility/sanitization | 4 | pending, blocked, partial, complete at widths/no-color | +| UX/accessibility/sanitization/localization | 10 | pending, blocked, partial, complete at widths/no-color; English default; Spanish exact/base; arbitrary locale; atomic fallback; hostile diagnostic suppression | | Integration/security | 3 | no-write composition, max four, no secret/reference sharing | -| **Total** | **24** | no double counting | +| **Total** | **30** | no double counting | Questionnaire transition, validation, check-plan, and report policies require 100% enumerated branch coverage. Terminal adapters require 90% lines and 85% @@ -452,6 +474,12 @@ required inputs, exit codes, skipped semantics, read-only guarantee, and recover 10. Secret values are absent from every state, plan, error, backup, log, fixture, and report. 11. All primary views are text-readable at narrow width and without color. +12. One doctor run resolves exactly one complete repository-locale catalog and + passes that same view through merge readiness and terminal presentation. +13. Spanish base and arbitrary dynamic locale fixtures preserve the stable check + graph while localizing every human heading, state, summary, and action. +14. A missing descriptor, provider error, or unsafe dynamic response yields a + complete English report with no raw provider or credential diagnostic. ## 17. Requirements traceability @@ -480,7 +508,7 @@ required inputs, exit codes, skipped semantics, read-only guarantee, and recover - [x] All cancel/non-interactive paths prove no write and correct exit code. - [x] Doctor runs the fixed DAG, max concurrency four, stable order, and skipped semantics. - [x] Doctor composition exposes only read ports; secret safety tests pass. -- [x] At least 24 distinct cases and all coverage/architecture gates pass. +- [x] At least 30 distinct cases and all coverage/architecture gates pass. - [x] CLI UX, docs, config schema, active/setup assets, SDD, and catalog agree. - [x] No open decision, legacy state/result, compatibility adapter, in-place mutation, broad adapter, or service registry remains. diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 4486b4c22..044e11aae 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -32,6 +32,7 @@ jest.mock('../data/repository/issue/issue_metadata_repository', () => ({ const mockFix = jest.fn(); jest.mock('../infrastructure/composition/agent_capability_composition_root', () => ({ createFixerQueryPort: () => ({ fix: mockFix }), + createLanguageQueryPort: () => ({ query: jest.fn() }), })); const mockGetSetupToken = jest.fn(); diff --git a/src/application/policies/__tests__/deployment_plan_policy.test.ts b/src/application/policies/__tests__/deployment_plan_policy.test.ts index d638a5c0d..bf9136f8b 100644 --- a/src/application/policies/__tests__/deployment_plan_policy.test.ts +++ b/src/application/policies/__tests__/deployment_plan_policy.test.ts @@ -12,6 +12,7 @@ import { validateInitialDeploymentInput, type TargetMergeCapabilities, } from "../deployment_plan_policy"; +import { resolveStaticDeploymentCatalog } from '../deployment_message_catalog'; const sha = (letter: string) => letter.repeat(40); const locale = Object.freeze({ @@ -139,10 +140,11 @@ describe("deployment plan policy", () => { message: "Forbidden\n::error::@team github_pat_abcdefghijklmnopqrstuvwxyz123456", }], })); - expect(decision).toEqual(expect.objectContaining({ kind: "unsupported", reason: expect.stringContaining("Forbidden") })); - expect(decision.reason).not.toContain("github_pat_"); - expect(decision.reason).not.toContain("::error::"); - expect(decision.reason).not.toContain("@team"); + expect(decision).toEqual(expect.objectContaining({ + kind: "unsupported", + reasonCode: 'policy-observation-failed', + })); + expect(decision).not.toHaveProperty('diagnostic'); }); it("rejects explicit auto-merge when the target requires its queue", () => { @@ -156,7 +158,7 @@ describe("deployment plan policy", () => { targetBranch: "master", producers: [{ kind: "check", name: "CI Check", integrationId: 15368, verdict: "unsupported", reason: "Falta el trigger." }], problems: [], - }, "es-ES"); + }, resolveStaticDeploymentCatalog("es-ES")); expect(message).toContain("preparación de la merge queue"); expect(message).toContain("Añade merge_group: checks_requested"); expect(message).toContain("CI Check [unsupported]"); @@ -169,12 +171,26 @@ describe("deployment plan policy", () => { targetBranch: "develop", producers: [], problems: [{ area: "effective-rules", message: "Forbidden" }], - }, "es-ES"); - expect(message).toContain("effective-rules: Forbidden"); + }, resolveStaticDeploymentCatalog("es-ES")); + expect(message).toContain("effective-rules: No se han podido inspeccionar las reglas efectivas"); + expect(message).not.toContain("Forbidden"); expect(message).toContain("Restaura el acceso de lectura"); expect(message).not.toContain("Añade merge_group"); }); + it('renders bounded generic evidence when readiness has no producer or observation detail', () => { + const message = mergeQueueReadinessFailureMessage({ + verdict: 'unknown', + targetRole: 'production', + targetBranch: 'master', + producers: [], + problems: [], + }, resolveStaticDeploymentCatalog('en-US')); + + expect(message).toContain('Required producer evidence is incomplete.'); + expect(message).toContain('Make the required producer support merge groups'); + }); + it("uses a sync branch for a strict target whose source is stale", () => { expect(selectBackmergeMode("auto", true, false)).toEqual(expect.objectContaining({ kind: "mode", mode: "sync-branch" })); }); diff --git a/src/application/policies/__tests__/setup_doctor_message_catalog.test.ts b/src/application/policies/__tests__/setup_doctor_message_catalog.test.ts new file mode 100644 index 000000000..4e5f85f61 --- /dev/null +++ b/src/application/policies/__tests__/setup_doctor_message_catalog.test.ts @@ -0,0 +1,90 @@ +import { + catalogPlaceholders, + validateCatalogDefinition, + type CatalogMessage, +} from '../../../domain/message_catalog'; +import { ResolveMessageCatalogUseCase } from '../../usecases/localization/resolve_message_catalog_use_case'; +import { + ENGLISH_SETUP_DOCTOR_DEFINITION, + SETUP_DOCTOR_CATALOG_DEFINITIONS, + SETUP_DOCTOR_MESSAGE_IDS, + SPANISH_SETUP_DOCTOR_DEFINITION, + resolveSetupDoctorCatalog, + resolveStaticSetupDoctorCatalog, +} from '../setup_doctor_message_catalog'; + +const configuration = { provider: 'codex' as const, model: 'model' }; + +function translatedMessages(): Record { + return Object.fromEntries(Object.entries(ENGLISH_SETUP_DOCTOR_DEFINITION.messages).map(([id, message]) => [ + id, + `FR ${message as string}`, + ])); +} + +describe('setup doctor message catalog', () => { + it('ships complete, unique, immutable English and Spanish definitions', () => { + expect(new Set(SETUP_DOCTOR_MESSAGE_IDS).size).toBe(SETUP_DOCTOR_MESSAGE_IDS.length); + for (const definition of SETUP_DOCTOR_CATALOG_DEFINITIONS) { + expect(validateCatalogDefinition(definition, SETUP_DOCTOR_MESSAGE_IDS)).toEqual([]); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.messages)).toBe(true); + } + for (const id of SETUP_DOCTOR_MESSAGE_IDS) { + expect(catalogPlaceholders(SPANISH_SETUP_DOCTOR_DEFINITION.messages[id])) + .toEqual(catalogPlaceholders(ENGLISH_SETUP_DOCTOR_DEFINITION.messages[id])); + } + }); + + it.each([ + ['en-US', 'en-US', 'exact'], + ['en-GB', 'en-US', 'base'], + ['es-ES', 'es-ES', 'exact'], + ['es-MX', 'es-ES', 'base'], + ['fr-FR', 'en-US', 'fallback'], + ] as const)('resolves %s deterministically as %s', (requested, resolved, source) => { + expect(resolveStaticSetupDoctorCatalog(requested)).toMatchObject({ + requestedLocale: requested, + locale: resolved, + resolutionSource: source, + }); + }); + + it('renders the complete Spanish presenter and diagnostic vocabulary', () => { + const catalog = resolveStaticSetupDoctorCatalog('es-MX'); + expect(catalog.message('doctor.title.partial')).toContain('diagnóstico parcial'); + expect(catalog.message('doctor.repositoryRoot.action')).toContain('raíz del repositorio'); + expect(catalog.message('doctor.mergeQueue.ready', { verified: 2, attested: 1 })) + .toContain('1 cubiertos por atestación exacta'); + }); + + it('resolves and caches one complete dynamic catalog for an arbitrary locale', async () => { + const query = jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages: translatedMessages() }); + const resolver = new ResolveMessageCatalogUseCase({ query }); + const first = await resolveSetupDoctorCatalog('fr-FR', configuration, resolver); + const replay = await resolveSetupDoctorCatalog('fr-FR', configuration, resolver); + + expect(first).toMatchObject({ requestedLocale: 'fr-FR', locale: 'fr-FR', resolutionSource: 'dynamic' }); + expect(first.message('doctor.configuration.invalid', { count: 2 })) + .toBe('FR Setup configuration has 2 validation error(s).'); + expect(replay.message('doctor.title')).toBe('FR Copilot Doctor'); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('falls back atomically to English when one dynamic descriptor is missing', async () => { + const messages = translatedMessages(); + delete messages['doctor.title']; + const catalog = await resolveSetupDoctorCatalog('fr-FR', configuration, new ResolveMessageCatalogUseCase({ + query: jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages }), + })); + + expect(catalog).toMatchObject({ + requestedLocale: 'fr-FR', + locale: 'en-US', + resolutionSource: 'fallback', + fallbackReason: 'dynamic-response-invalid', + }); + expect(catalog.message('doctor.title')).toBe('Copilot Doctor'); + expect(catalog.message('doctor.noMutation')).not.toContain('FR '); + }); +}); diff --git a/src/application/policies/__tests__/setup_doctor_report_policy.test.ts b/src/application/policies/__tests__/setup_doctor_report_policy.test.ts index 99fc752ce..1a1c736b0 100644 --- a/src/application/policies/__tests__/setup_doctor_report_policy.test.ts +++ b/src/application/policies/__tests__/setup_doctor_report_policy.test.ts @@ -97,7 +97,7 @@ describe('setup doctor report policy', () => { expect(buildLocaleDoctorChecks(configuration)[0]).toMatchObject({ status: 'warn', evidence: { catalogSource: 'fallback', effective: 'fr-FR' }, - action: expect.stringContaining('Configure a ready language agent'), + action: expect.stringContaining('planner/language agent is ready'), }); }); diff --git a/src/application/policies/deployment_message_catalog.ts b/src/application/policies/deployment_message_catalog.ts index 74d23b811..5c7b02425 100644 --- a/src/application/policies/deployment_message_catalog.ts +++ b/src/application/policies/deployment_message_catalog.ts @@ -14,6 +14,12 @@ import { resolveStaticMessageCatalogView, type ResolvedMessageCatalogView, } from './resolved_message_catalog_policy'; +import { + ENGLISH_MERGE_QUEUE_MESSAGES, + MERGE_QUEUE_MESSAGE_IDS, + SPANISH_MERGE_QUEUE_MESSAGES, + type MergeQueueMessageId, +} from './merge_queue_message_catalog'; const SIMPLE_MESSAGE_KEYS = Object.freeze([ 'release', 'hotfix', 'currentStatus', 'noAction', 'actionRequired', 'progress', @@ -75,7 +81,8 @@ export type DeploymentMessageId = | SimpleMessageId | PhaseMessageId | DiagramMessageId - | TemplateMessageId; + | TemplateMessageId + | MergeQueueMessageId; export type DeploymentMessageCatalog = ResolvedMessageCatalogView; export interface DeploymentCopy extends Readonly> { @@ -88,6 +95,7 @@ export const DEPLOYMENT_MESSAGE_IDS: readonly DeploymentMessageId[] = Object.fre ...DEPLOYMENT_PHASES.map(phase => `deployment.phase.${phase}` as const), ...DIAGRAM_KEYS.map(key => `deployment.diagram.${key}` as const), ...TEMPLATE_MESSAGE_IDS, + ...MERGE_QUEUE_MESSAGE_IDS, ]); const ENGLISH_SIMPLE: Readonly> = Object.freeze({ @@ -235,12 +243,14 @@ function catalogMessages( phases: Readonly>, diagram: Readonly>, templates: Readonly>, + mergeQueue: Readonly>, ): Readonly> { return Object.freeze({ ...Object.fromEntries(SIMPLE_MESSAGE_KEYS.map(key => [`deployment.${key}`, simple[key]])), ...Object.fromEntries(DEPLOYMENT_PHASES.map(phase => [`deployment.phase.${phase}`, phases[phase]])), ...Object.fromEntries(DIAGRAM_KEYS.map(key => [`deployment.diagram.${key}`, diagram[key]])), ...templates, + ...mergeQueue, }) as Readonly>; } @@ -248,14 +258,26 @@ export const ENGLISH_DEPLOYMENT_DEFINITION: MessageCatalogDefinition = Object.freeze({ version: MESSAGE_CATALOG_VERSION, locale: 'es-ES', compatibleBaseLanguage: 'es', - messages: catalogMessages(SPANISH_SIMPLE, SPANISH_PHASES, SPANISH_DIAGRAM, SPANISH_TEMPLATES), + messages: catalogMessages( + SPANISH_SIMPLE, + SPANISH_PHASES, + SPANISH_DIAGRAM, + SPANISH_TEMPLATES, + SPANISH_MERGE_QUEUE_MESSAGES, + ), }); export const DEPLOYMENT_CATALOG_DEFINITIONS = Object.freeze([ diff --git a/src/application/policies/deployment_plan_policy.ts b/src/application/policies/deployment_plan_policy.ts index 50320c49b..50e268eb2 100644 --- a/src/application/policies/deployment_plan_policy.ts +++ b/src/application/policies/deployment_plan_policy.ts @@ -10,8 +10,12 @@ import type { MergeQueueProducerEvidence, MergeQueueReadiness, } from "../../domain/merge_queue_readiness"; -import { redactSensitiveText } from "../../domain/security/sensitive_text"; import type { LocaleProfile } from '../../domain/locale'; +import { + renderMergeQueueReadinessFailure, + type MergeQueueMessageId, + type MergeQueueMessageView, +} from './merge_queue_message_catalog'; export interface InitialDeploymentOperationInput { readonly operationId: string; @@ -83,79 +87,87 @@ export interface TargetMergeCapabilities { readonly mergeQueueObservationProblems: readonly MergeQueueObservationProblem[]; } +export type PullRequestModeDecisionReason = + | 'explicit-create-only' + | 'policy-observation-failed' + | 'auto-merge-rejected' + | 'queue-required' + | 'queue-not-exposed' + | 'auto-merge-configured' + | 'auto-merge-disabled' + | 'immediately-mergeable' + | 'auto-merge-available' + | 'create-only-required'; + +type PullRequestModeDecisionContext = { + readonly reasonCode: PullRequestModeDecisionReason; +}; + export type PullRequestModeDecision = - | { readonly kind: "mode"; readonly mode: Exclude; readonly reason: string } - | { readonly kind: "unsupported"; readonly reason: string }; + | (PullRequestModeDecisionContext & { + readonly kind: "mode"; + readonly mode: Exclude; + }) + | (PullRequestModeDecisionContext & { readonly kind: "unsupported" }); export function selectPullRequestMode( configured: ReconciliationPullRequestMode, capabilities: TargetMergeCapabilities, ): PullRequestModeDecision { if (configured === "create-only") { - return { kind: "mode", mode: configured, reason: "Explicitly configured." }; + return { kind: "mode", mode: configured, reasonCode: 'explicit-create-only' }; } if (capabilities.mergeQueueObservationProblems.length > 0) { return { kind: "unsupported", - reason: `The target merge policy could not be verified: ${boundedDiagnostic(capabilities.mergeQueueObservationProblems[0].message)}`, + reasonCode: 'policy-observation-failed', }; } if (capabilities.mergeQueueRequired) { return configured === "auto-merge" - ? { kind: "unsupported", reason: "Auto-merge mode was selected, but the target requires its merge queue." } - : { kind: "mode", mode: "merge-queue", reason: "The target requires its merge queue." }; + ? { kind: "unsupported", reasonCode: 'auto-merge-rejected' } + : { kind: "mode", mode: "merge-queue", reasonCode: 'queue-required' }; } if (configured === "merge-queue") { - return { kind: "unsupported", reason: "The target does not expose a required merge queue." }; + return { kind: "unsupported", reasonCode: 'queue-not-exposed' }; } if (configured === "auto-merge") { return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "Native auto-merge was explicitly configured." } - : { kind: "unsupported", reason: "Native auto-merge is disabled for this repository." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-configured' } + : { kind: "unsupported", reasonCode: 'auto-merge-disabled' }; } if (capabilities.immediatelyMergeable) { - return { kind: "mode", mode: "auto-merge", reason: "GitHub reports the PR ready; native auto-merge preserves branch protection." }; + return { kind: "mode", mode: "auto-merge", reasonCode: 'immediately-mergeable' }; } return capabilities.autoMergeAllowed - ? { kind: "mode", mode: "auto-merge", reason: "GitHub will merge after checks and reviews complete." } - : { kind: "mode", mode: "create-only", reason: "Repository auto-merge is unavailable; maintainer merge is required." }; + ? { kind: "mode", mode: "auto-merge", reasonCode: 'auto-merge-available' } + : { kind: "mode", mode: "create-only", reasonCode: 'create-only-required' }; } -export function mergeQueueReadinessFailureMessage(readiness: MergeQueueReadiness, locale: string = "en-US"): string { - const spanish = locale.toLowerCase().startsWith("es"); - const failed = readiness.producers.filter((producer) => - producer.verdict === "unsupported" || producer.verdict === "unknown"); - const producerDetails = failed.slice(0, 5) - .map((producer) => `${boundedDiagnostic(producer.name)} [${producer.verdict}]: ${boundedDiagnostic(producer.reason)}`) - .join("; "); - const problemDetails = readiness.problems.slice(0, 3) - .map((problem) => `${problem.area}: ${boundedDiagnostic(problem.message)}`) - .join("; "); - const details = [producerDetails, problemDetails].filter(Boolean).join("; "); - const hasUnsupportedProducer = failed.some((producer) => producer.verdict === "unsupported"); - const hasObservationProblem = readiness.problems.length > 0; - if (spanish) { - const action = hasUnsupportedProducer - ? "Añade merge_group: checks_requested al workflow requerido y vuelve a intentarlo." - : hasObservationProblem - ? "Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo." - : "Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo."; - return `La preparación de la merge queue está en estado ${readiness.verdict} para el destino ${readiness.targetRole} ${boundedDiagnostic(readiness.targetBranch)}. ${details || "La evidencia del productor requerido está incompleta."} ${action}`; - } - const action = hasUnsupportedProducer - ? "Add merge_group: checks_requested to the required workflow, then retry." - : hasObservationProblem - ? "Restore read access and a valid response for the target policy and workflows, then retry." - : "Make the required producer support merge groups or add an exact reviewed check attestation, then retry."; - return `Merge queue readiness is ${readiness.verdict} for ${readiness.targetRole} target ${boundedDiagnostic(readiness.targetBranch)}. ${details || "Required producer evidence is incomplete."} ${action}`; +export function pullRequestModeDecisionMessage( + decision: PullRequestModeDecision, + catalog: MergeQueueMessageView, +): string { + const ids: Readonly> = { + 'explicit-create-only': 'mergeQueue.decision.explicitCreateOnly', + 'policy-observation-failed': 'mergeQueue.decision.policyObservationFailed', + 'auto-merge-rejected': 'mergeQueue.decision.autoMergeRejected', + 'queue-required': 'mergeQueue.decision.queueRequired', + 'queue-not-exposed': 'mergeQueue.decision.queueNotExposed', + 'auto-merge-configured': 'mergeQueue.decision.autoMergeConfigured', + 'auto-merge-disabled': 'mergeQueue.decision.autoMergeDisabled', + 'immediately-mergeable': 'mergeQueue.decision.immediatelyMergeable', + 'auto-merge-available': 'mergeQueue.decision.autoMergeAvailable', + 'create-only-required': 'mergeQueue.decision.createOnlyRequired', + }; + return catalog.message(ids[decision.reasonCode]); } -function boundedDiagnostic(value: string): string { - return redactSensitiveText(value) - .replace(/[\r\n<>]/g, " ") - .replace(/::/g, "﹕﹕") - .replace(/@/g, "@\u200b") - .slice(0, 500); +export function mergeQueueReadinessFailureMessage( + readiness: MergeQueueReadiness, + catalog: MergeQueueMessageView, +): string { + return renderMergeQueueReadinessFailure(readiness, catalog); } export type BackmergeModeDecision = diff --git a/src/application/policies/merge_queue_message_catalog.ts b/src/application/policies/merge_queue_message_catalog.ts new file mode 100644 index 000000000..07d699276 --- /dev/null +++ b/src/application/policies/merge_queue_message_catalog.ts @@ -0,0 +1,148 @@ +import type { CatalogMessage } from '../../domain/message_catalog'; +import type { MergeQueueReadiness } from '../../domain/merge_queue_readiness'; +import { redactSensitiveText } from '../../domain/security/sensitive_text'; + +export const MERGE_QUEUE_MESSAGE_IDS = Object.freeze([ + 'mergeQueue.readiness.failure', + 'mergeQueue.readiness.incompleteEvidence', + 'mergeQueue.readiness.action.unsupported', + 'mergeQueue.readiness.action.observation', + 'mergeQueue.readiness.action.unknown', + 'mergeQueue.producerState.verified', + 'mergeQueue.producerState.attested', + 'mergeQueue.producerState.unknown', + 'mergeQueue.producerState.unsupported', + 'mergeQueue.problem.classicProtection', + 'mergeQueue.problem.effectiveRules', + 'mergeQueue.problem.workflowContract', + 'mergeQueue.problem.queueMembership', + 'mergeQueue.decision.explicitCreateOnly', + 'mergeQueue.decision.policyObservationFailed', + 'mergeQueue.decision.autoMergeRejected', + 'mergeQueue.decision.queueRequired', + 'mergeQueue.decision.queueNotExposed', + 'mergeQueue.decision.autoMergeConfigured', + 'mergeQueue.decision.autoMergeDisabled', + 'mergeQueue.decision.immediatelyMergeable', + 'mergeQueue.decision.autoMergeAvailable', + 'mergeQueue.decision.createOnlyRequired', +] as const); + +export type MergeQueueMessageId = typeof MERGE_QUEUE_MESSAGE_IDS[number]; + +export interface MergeQueueMessageView { + message( + id: MergeQueueMessageId, + variables?: Readonly>, + ): string; +} + +export const ENGLISH_MERGE_QUEUE_MESSAGES: Readonly> = Object.freeze({ + 'mergeQueue.readiness.failure': 'Merge queue readiness is {verdict} for {role} target {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'Required producer evidence is incomplete.', + 'mergeQueue.readiness.action.unsupported': 'Add {event} to the required workflow, then retry.', + 'mergeQueue.readiness.action.observation': 'Restore read access and a valid response for the target policy and workflows, then retry.', + 'mergeQueue.readiness.action.unknown': 'Make the required producer support merge groups or add an exact reviewed check attestation, then retry.', + 'mergeQueue.producerState.verified': 'supports merge groups', + 'mergeQueue.producerState.attested': 'covered by an exact reviewed attestation', + 'mergeQueue.producerState.unknown': 'support could not be verified', + 'mergeQueue.producerState.unsupported': 'does not support merge groups', + 'mergeQueue.problem.classicProtection': 'Classic branch protection could not be inspected.', + 'mergeQueue.problem.effectiveRules': 'Effective repository rules could not be inspected.', + 'mergeQueue.problem.workflowContract': 'Required workflow contracts could not be inspected.', + 'mergeQueue.problem.queueMembership': 'Merge-queue membership could not be inspected.', + 'mergeQueue.decision.explicitCreateOnly': 'Create-only mode was explicitly configured.', + 'mergeQueue.decision.policyObservationFailed': 'The target merge policy could not be verified.', + 'mergeQueue.decision.autoMergeRejected': 'Auto-merge mode was selected, but the target requires its merge queue.', + 'mergeQueue.decision.queueRequired': 'The target requires its merge queue.', + 'mergeQueue.decision.queueNotExposed': 'The target does not expose a required merge queue.', + 'mergeQueue.decision.autoMergeConfigured': 'Native auto-merge was explicitly configured.', + 'mergeQueue.decision.autoMergeDisabled': 'Native auto-merge is disabled for this repository.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub reports the pull request ready; native auto-merge preserves branch protection.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub will merge after checks and reviews complete.', + 'mergeQueue.decision.createOnlyRequired': 'Repository auto-merge is unavailable; maintainer merge is required.', +}); + +export const SPANISH_MERGE_QUEUE_MESSAGES: Readonly> = Object.freeze({ + 'mergeQueue.readiness.failure': 'La preparación de la merge queue está en estado {verdict} para el destino {role} {branch}. {details} {action}', + 'mergeQueue.readiness.incompleteEvidence': 'La evidencia del productor requerido está incompleta.', + 'mergeQueue.readiness.action.unsupported': 'Añade {event} al workflow requerido y vuelve a intentarlo.', + 'mergeQueue.readiness.action.observation': 'Restaura el acceso de lectura y una respuesta válida para la política y los workflows del destino, y vuelve a intentarlo.', + 'mergeQueue.readiness.action.unknown': 'Haz que el productor requerido soporte merge groups o añade una atestación exacta revisada y vuelve a intentarlo.', + 'mergeQueue.producerState.verified': 'admite merge groups', + 'mergeQueue.producerState.attested': 'está cubierto por una atestación exacta revisada', + 'mergeQueue.producerState.unknown': 'no se ha podido verificar su compatibilidad', + 'mergeQueue.producerState.unsupported': 'no admite merge groups', + 'mergeQueue.problem.classicProtection': 'No se ha podido inspeccionar la protección de rama clásica.', + 'mergeQueue.problem.effectiveRules': 'No se han podido inspeccionar las reglas efectivas del repositorio.', + 'mergeQueue.problem.workflowContract': 'No se han podido inspeccionar los contratos de los workflows requeridos.', + 'mergeQueue.problem.queueMembership': 'No se ha podido inspeccionar la pertenencia a la merge queue.', + 'mergeQueue.decision.explicitCreateOnly': 'El modo de solo creación se ha configurado explícitamente.', + 'mergeQueue.decision.policyObservationFailed': 'No se ha podido verificar la política de merge del destino.', + 'mergeQueue.decision.autoMergeRejected': 'Se ha seleccionado auto-merge, pero el destino requiere su merge queue.', + 'mergeQueue.decision.queueRequired': 'El destino requiere su merge queue.', + 'mergeQueue.decision.queueNotExposed': 'El destino no expone una merge queue obligatoria.', + 'mergeQueue.decision.autoMergeConfigured': 'El auto-merge nativo se ha configurado explícitamente.', + 'mergeQueue.decision.autoMergeDisabled': 'El auto-merge nativo está deshabilitado en este repositorio.', + 'mergeQueue.decision.immediatelyMergeable': 'GitHub indica que la pull request está lista; el auto-merge nativo preserva la protección de rama.', + 'mergeQueue.decision.autoMergeAvailable': 'GitHub hará merge cuando terminen los checks y las revisiones.', + 'mergeQueue.decision.createOnlyRequired': 'El auto-merge del repositorio no está disponible; se requiere el merge de una persona mantenedora.', +}); + +export function renderMergeQueueReadinessFailure( + readiness: MergeQueueReadiness, + catalog: MergeQueueMessageView, +): string { + const failed = readiness.producers.filter((producer) => + producer.verdict === 'unsupported' || producer.verdict === 'unknown'); + const producerDetails = failed.slice(0, 5) + .map((producer) => `${boundedMergeQueueDiagnostic(producer.name)} [${producer.verdict}]: ${catalog.message(producerStateMessageId(producer.verdict))}`) + .join('; '); + const problemDetails = readiness.problems.slice(0, 3) + .map((problem) => `${problem.area}: ${catalog.message(problemMessageId(problem.area))}`) + .join('; '); + const details = [producerDetails, problemDetails].filter(Boolean).join('; ') + || catalog.message('mergeQueue.readiness.incompleteEvidence'); + const unsupported = failed.some((producer) => producer.verdict === 'unsupported'); + const action = catalog.message( + unsupported + ? 'mergeQueue.readiness.action.unsupported' + : readiness.problems.length > 0 + ? 'mergeQueue.readiness.action.observation' + : 'mergeQueue.readiness.action.unknown', + unsupported ? { event: 'merge_group: checks_requested' } : {}, + ); + return catalog.message('mergeQueue.readiness.failure', { + verdict: readiness.verdict, + role: readiness.targetRole, + branch: boundedMergeQueueDiagnostic(readiness.targetBranch), + details, + action, + }); +} + +export function producerStateMessageId( + verdict: MergeQueueReadiness['producers'][number]['verdict'], +): MergeQueueMessageId { + return `mergeQueue.producerState.${verdict}`; +} + +function problemMessageId( + area: MergeQueueReadiness['problems'][number]['area'], +): MergeQueueMessageId { + const ids: Readonly> = { + 'classic-protection': 'mergeQueue.problem.classicProtection', + 'effective-rules': 'mergeQueue.problem.effectiveRules', + 'workflow-contract': 'mergeQueue.problem.workflowContract', + 'queue-membership': 'mergeQueue.problem.queueMembership', + }; + return ids[area]; +} + +export function boundedMergeQueueDiagnostic(value: string): string { + return redactSensitiveText(value) + .replace(/[\r\n<>]/gu, ' ') + .replace(/::/gu, '﹕﹕') + .replace(/@/gu, '@\u200b') + .slice(0, 500); +} diff --git a/src/application/policies/setup_doctor_message_catalog.ts b/src/application/policies/setup_doctor_message_catalog.ts new file mode 100644 index 000000000..d86a7c6b0 --- /dev/null +++ b/src/application/policies/setup_doctor_message_catalog.ts @@ -0,0 +1,373 @@ +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'; +import { + ENGLISH_MERGE_QUEUE_MESSAGES, + MERGE_QUEUE_MESSAGE_IDS, + SPANISH_MERGE_QUEUE_MESSAGES, + type MergeQueueMessageId, +} from './merge_queue_message_catalog'; + +const DOCTOR_ONLY_MESSAGE_IDS = Object.freeze([ + 'doctor.title', + 'doctor.title.partial', + 'doctor.summary', + 'doctor.noMutation', + 'doctor.blockedBy', + 'doctor.action', + 'doctor.status.pass', + 'doctor.status.warn', + 'doctor.status.fail', + 'doctor.status.skipped', + 'doctor.label.configuration', + 'doctor.label.repositoryRoot', + 'doctor.label.setupPat', + 'doctor.label.githubScopes', + 'doctor.label.repositorySecrets', + 'doctor.label.repositoryVariables', + 'doctor.label.mergeQueue', + 'doctor.label.localeProfile', + 'doctor.label.repositoryLocale', + 'doctor.label.issueLocale', + 'doctor.label.pullRequestLocale', + 'doctor.label.workflow', + 'doctor.label.variable', + 'doctor.label.credential', + 'doctor.label.mergeQueueTarget', + 'doctor.locale.invalid', + 'doctor.locale.invalidAction', + 'doctor.locale.fallback', + 'doctor.locale.fallbackInherited', + 'doctor.locale.resolved', + 'doctor.locale.resolvedInherited', + 'doctor.locale.separatorAction', + 'doctor.locale.dynamicAction', + 'doctor.configuration.valid', + 'doctor.configuration.invalid', + 'doctor.configuration.action', + 'doctor.repositoryRoot.valid', + 'doctor.repositoryRoot.invalid', + 'doctor.repositoryRoot.unverified', + 'doctor.repositoryRoot.action', + 'doctor.workflow.skipped', + 'doctor.workflow.matches', + 'doctor.workflow.drift', + 'doctor.workflow.unverified', + 'doctor.workflow.configurationAction', + 'doctor.workflow.repairAction', + 'doctor.workflow.unverifiedAction', + 'doctor.setupPat.valid', + 'doctor.setupPat.invalid', + 'doctor.setupPat.unverified', + 'doctor.setupPat.replaceAction', + 'doctor.setupPat.unverifiedAction', + 'doctor.mergeQueue.skipped', + 'doctor.mergeQueue.skippedAction', + 'doctor.mergeQueue.unverified', + 'doctor.mergeQueue.unverifiedAction', + 'doctor.scope.unverified', + 'doctor.scope.unverifiedAction', + 'doctor.scope.readable', + 'doctor.scope.unavailable', + 'doctor.scope.grantAction', + 'doctor.variable.missing', + 'doctor.variable.configured', + 'doctor.variable.preserved', + 'doctor.variable.different', + 'doctor.variable.action', + 'doctor.secretMetadata.readable', + 'doctor.credential.group.runnerRequired', + 'doctor.credential.group.missing', + 'doctor.credential.group.healthy', + 'doctor.credential.group.invalid', + 'doctor.credential.group.unverified', + 'doctor.credential.group.action', + 'doctor.credential.missing', + 'doctor.credential.valid', + 'doctor.credential.invalid', + 'doctor.credential.unverifiable', + 'doctor.credential.unverified', + 'doctor.credential.action', + 'doctor.skipped.scope', + 'doctor.skipped.scopeAction', + 'doctor.skipped.variables', + 'doctor.skipped.secrets', + 'doctor.skipped.credentials', + 'doctor.skipped.resourceAction', + 'doctor.mergeQueue.configureSupportedAction', + 'doctor.mergeQueue.repairOrAttestAction', + 'doctor.mergeQueue.selectedMode', + 'doctor.mergeQueue.ready', + 'doctor.mergeQueue.targetUnverified', + 'doctor.mergeQueue.targetUnverifiedAction', + 'doctor.mergeQueue.activeRelease', + 'doctor.mergeQueue.staleAttestation', + 'doctor.mergeQueue.staleAttestationAction', + 'doctor.mergeQueue.producer', + 'doctor.mergeQueue.producerAction', +] as const); + +type DoctorOnlyMessageId = typeof DOCTOR_ONLY_MESSAGE_IDS[number]; +export type SetupDoctorMessageId = DoctorOnlyMessageId | MergeQueueMessageId; +export type SetupDoctorMessageCatalog = ResolvedMessageCatalogView; + +export const SETUP_DOCTOR_MESSAGE_IDS: readonly SetupDoctorMessageId[] = Object.freeze([ + ...DOCTOR_ONLY_MESSAGE_IDS, + ...MERGE_QUEUE_MESSAGE_IDS, +]); + +const ENGLISH_DOCTOR_MESSAGES: Readonly> = Object.freeze({ + 'doctor.title': 'Copilot Doctor', + 'doctor.title.partial': 'Copilot Doctor — partial diagnosis', + 'doctor.summary': 'Checks: {pass} pass, {warn} warn, {fail} fail, {skipped} skipped.', + 'doctor.noMutation': 'No repository configuration was changed.', + 'doctor.blockedBy': 'Blocked by: {checks}', + 'doctor.action': 'Action: {action}', + 'doctor.status.pass': 'PASS', + 'doctor.status.warn': 'WARN', + 'doctor.status.fail': 'FAIL', + 'doctor.status.skipped': 'SKIP', + 'doctor.label.configuration': 'Configuration', + 'doctor.label.repositoryRoot': 'Repository root', + 'doctor.label.setupPat': 'Setup PAT', + 'doctor.label.githubScopes': 'GitHub Actions scopes', + 'doctor.label.repositorySecrets': 'Repository Secrets', + 'doctor.label.repositoryVariables': 'Repository Variables', + 'doctor.label.mergeQueue': 'Merge queue', + 'doctor.label.localeProfile': 'Locale profile', + 'doctor.label.repositoryLocale': 'Repository locale', + 'doctor.label.issueLocale': 'Issue locale', + 'doctor.label.pullRequestLocale': 'Pull-request locale', + 'doctor.label.workflow': 'Workflow {id}', + 'doctor.label.variable': 'Variable {name}', + 'doctor.label.credential': 'Credential {name}', + 'doctor.label.mergeQueueTarget': 'Merge queue {target}', + 'doctor.locale.invalid': 'Locale capability could not be inspected because locale configuration is invalid.', + 'doctor.locale.invalidAction': 'Correct the locale values and run doctor again.', + 'doctor.locale.fallback': 'Effective locale {locale} could not resolve a complete safe catalog; product copy fell back atomically to en-US.', + 'doctor.locale.fallbackInherited': 'Effective locale {locale} could not resolve a complete safe catalog; product copy fell back atomically to en-US. It inherits the repository locale.', + 'doctor.locale.resolved': 'Effective locale {locale} resolves through the {source} catalog path.', + 'doctor.locale.resolvedInherited': 'Effective locale {locale} resolves through the {source} catalog path. It inherits the repository locale.', + 'doctor.locale.separatorAction': 'Replace legacy underscore separators with canonical BCP-47 hyphens.', + 'doctor.locale.dynamicAction': 'Check that the planner/language agent is ready and returns valid localized copy, then run doctor again.', + 'doctor.configuration.valid': 'Setup configuration is valid.', + 'doctor.configuration.invalid': 'Setup configuration has {count} validation error(s).', + 'doctor.configuration.action': 'Fix the setup configuration and run doctor again.', + 'doctor.repositoryRoot.valid': 'Current directory is the repository root.', + 'doctor.repositoryRoot.invalid': 'Current directory is not the repository root.', + 'doctor.repositoryRoot.unverified': 'Repository root could not be verified.', + 'doctor.repositoryRoot.action': 'Run doctor from the root of the target Git repository.', + 'doctor.workflow.skipped': 'Workflow comparison was skipped because setup configuration is invalid.', + 'doctor.workflow.matches': 'Matches the installed setup template.', + 'doctor.workflow.drift': 'Local workflow is {state}.', + 'doctor.workflow.unverified': 'Managed workflows could not be compared.', + 'doctor.workflow.configurationAction': 'Fix setup configuration, then run doctor again.', + 'doctor.workflow.repairAction': 'Run setup to repair this managed workflow.', + 'doctor.workflow.unverifiedAction': 'Check local workflow files and run doctor again.', + 'doctor.setupPat.valid': 'The setup PAT is valid.', + 'doctor.setupPat.invalid': 'The setup PAT is invalid.', + 'doctor.setupPat.unverified': 'The setup PAT could not be validated.', + 'doctor.setupPat.replaceAction': 'Replace the setup PAT and run doctor again.', + 'doctor.setupPat.unverifiedAction': 'Check the setup PAT and network access, then run doctor again.', + 'doctor.mergeQueue.skipped': 'Merge-queue readiness was not inspected because setup configuration is invalid.', + 'doctor.mergeQueue.skippedAction': 'Fix the reported configuration errors, then run doctor again.', + 'doctor.mergeQueue.unverified': 'Merge-queue readiness could not be inspected.', + 'doctor.mergeQueue.unverifiedAction': 'Check branch policy access and run doctor again.', + 'doctor.scope.unverified': 'GitHub Actions resource scopes could not be inspected.', + 'doctor.scope.unverifiedAction': 'Check setup PAT access to repository and organization Actions metadata, then retry.', + 'doctor.scope.readable': 'GitHub Actions resource scopes are readable.', + 'doctor.scope.unavailable': 'Some organization-level GitHub Actions resource scopes are unavailable.', + 'doctor.scope.grantAction': 'Grant the setup PAT the required organization Actions metadata access.', + 'doctor.variable.missing': 'Variable is missing.', + 'doctor.variable.configured': 'Variable is configured at {scope} scope.', + 'doctor.variable.preserved': 'Variable differs but is intentionally preserved at {scope} scope.', + 'doctor.variable.different': 'Variable differs from the expected setup configuration.', + 'doctor.variable.action': 'Run setup to reconcile this Variable.', + 'doctor.secretMetadata.readable': 'GitHub Actions Secret metadata is readable.', + 'doctor.credential.group.runnerRequired': 'No fallback Secret is configured; runner authentication must satisfy this group.', + 'doctor.credential.group.missing': 'No alternative credential is present.', + 'doctor.credential.group.healthy': 'At least one alternative credential is healthy.', + 'doctor.credential.group.invalid': 'Every available alternative credential is invalid.', + 'doctor.credential.group.unverified': 'An alternative credential is present, but remote health is unavailable.', + 'doctor.credential.group.action': 'Configure and validate one credential for this requirement group.', + 'doctor.credential.missing': 'Required Secret is missing.', + 'doctor.credential.valid': 'Credential health is valid.', + 'doctor.credential.invalid': 'Credential health is invalid.', + 'doctor.credential.unverifiable': 'Credential health cannot be verified automatically.', + 'doctor.credential.unverified': 'Secret is present, but remote health is unavailable.', + 'doctor.credential.action': 'Configure or replace {name}, then rerun credential health.', + 'doctor.skipped.scope': 'Resource-scope inspection requires a valid setup PAT.', + 'doctor.skipped.scopeAction': 'Replace the setup PAT.', + 'doctor.skipped.variables': 'Variable checks could not run.', + 'doctor.skipped.secrets': 'Secret metadata checks could not run.', + 'doctor.skipped.credentials': 'Credential health could not run.', + 'doctor.skipped.resourceAction': 'Resolve the blocking check and rerun doctor.', + 'doctor.mergeQueue.configureSupportedAction': 'Configure a supported merge-queue producer or choose another reconciliation mode.', + 'doctor.mergeQueue.repairOrAttestAction': 'Repair or attest every required merge-queue producer.', + 'doctor.mergeQueue.selectedMode': 'Selected mode is {mode}; merge-queue producer evidence is not required for this target.', + 'doctor.mergeQueue.ready': 'Ready. {verified} required producer(s) verified automatically and {attested} covered by exact attestation.', + 'doctor.mergeQueue.targetUnverified': 'Target policy could not be inspected because the provider request failed.', + 'doctor.mergeQueue.targetUnverifiedAction': 'Check the setup PAT permissions and target branch policy, then retry.', + 'doctor.mergeQueue.activeRelease': 'Active release branches are discovered dynamically and are revalidated before a hotfix reconciliation branch or pull request is created.', + 'doctor.mergeQueue.staleAttestation': 'This exact attestation does not match a required check observed on production or development.', + 'doctor.mergeQueue.staleAttestationAction': 'Remove the stale attestation or correct its exact check identity.', + 'doctor.mergeQueue.producer': '{verdict}: {state}', + 'doctor.mergeQueue.producerAction': 'Configure or exactly attest this required producer.', +}); + +const SPANISH_DOCTOR_MESSAGES: Readonly> = Object.freeze({ + 'doctor.title': 'Diagnóstico de Copilot', + 'doctor.title.partial': 'Diagnóstico de Copilot — diagnóstico parcial', + 'doctor.summary': 'Checks: {pass} correctos, {warn} avisos, {fail} fallos, {skipped} omitidos.', + 'doctor.noMutation': 'No se ha cambiado la configuración del repositorio.', + 'doctor.blockedBy': 'Bloqueado por: {checks}', + 'doctor.action': 'Acción: {action}', + 'doctor.status.pass': 'OK', + 'doctor.status.warn': 'AVISO', + 'doctor.status.fail': 'FALLO', + 'doctor.status.skipped': 'OMITIDO', + 'doctor.label.configuration': 'Configuración', + 'doctor.label.repositoryRoot': 'Raíz del repositorio', + 'doctor.label.setupPat': 'PAT de setup', + 'doctor.label.githubScopes': 'Permisos de GitHub Actions', + 'doctor.label.repositorySecrets': 'Secrets del repositorio', + 'doctor.label.repositoryVariables': 'Variables del repositorio', + 'doctor.label.mergeQueue': 'Merge queue', + 'doctor.label.localeProfile': 'Perfil de locale', + 'doctor.label.repositoryLocale': 'Locale del repositorio', + 'doctor.label.issueLocale': 'Locale de issues', + 'doctor.label.pullRequestLocale': 'Locale de pull requests', + 'doctor.label.workflow': 'Workflow {id}', + 'doctor.label.variable': 'Variable {name}', + 'doctor.label.credential': 'Credencial {name}', + 'doctor.label.mergeQueueTarget': 'Merge queue {target}', + 'doctor.locale.invalid': 'No se ha podido inspeccionar la localización porque la configuración de locale no es válida.', + 'doctor.locale.invalidAction': 'Corrige los valores de locale y vuelve a ejecutar doctor.', + 'doctor.locale.fallback': 'El locale efectivo {locale} no ha podido resolver un catálogo completo y seguro; el texto de producto ha usado en-US de forma atómica.', + 'doctor.locale.fallbackInherited': 'El locale efectivo {locale} no ha podido resolver un catálogo completo y seguro; el texto de producto ha usado en-US de forma atómica. Hereda el locale del repositorio.', + 'doctor.locale.resolved': 'El locale efectivo {locale} se resuelve mediante la ruta de catálogo {source}.', + 'doctor.locale.resolvedInherited': 'El locale efectivo {locale} se resuelve mediante la ruta de catálogo {source}. Hereda el locale del repositorio.', + 'doctor.locale.separatorAction': 'Sustituye los separadores bajos heredados por guiones BCP-47 canónicos.', + 'doctor.locale.dynamicAction': 'Comprueba que el agente de planificación/idioma está disponible y devuelve texto localizado válido; después, vuelve a ejecutar doctor.', + 'doctor.configuration.valid': 'La configuración de setup es válida.', + 'doctor.configuration.invalid': 'La configuración de setup tiene {count} error(es) de validación.', + 'doctor.configuration.action': 'Corrige la configuración de setup y vuelve a ejecutar doctor.', + 'doctor.repositoryRoot.valid': 'El directorio actual es la raíz del repositorio.', + 'doctor.repositoryRoot.invalid': 'El directorio actual no es la raíz del repositorio.', + 'doctor.repositoryRoot.unverified': 'No se ha podido verificar la raíz del repositorio.', + 'doctor.repositoryRoot.action': 'Ejecuta doctor desde la raíz del repositorio Git de destino.', + 'doctor.workflow.skipped': 'Se ha omitido la comparación de workflows porque la configuración de setup no es válida.', + 'doctor.workflow.matches': 'Coincide con la plantilla de setup instalada.', + 'doctor.workflow.drift': 'El workflow local está en estado {state}.', + 'doctor.workflow.unverified': 'No se han podido comparar los workflows gestionados.', + 'doctor.workflow.configurationAction': 'Corrige la configuración de setup y vuelve a ejecutar doctor.', + 'doctor.workflow.repairAction': 'Ejecuta setup para reparar este workflow gestionado.', + 'doctor.workflow.unverifiedAction': 'Comprueba los workflows locales y vuelve a ejecutar doctor.', + 'doctor.setupPat.valid': 'El PAT de setup es válido.', + 'doctor.setupPat.invalid': 'El PAT de setup no es válido.', + 'doctor.setupPat.unverified': 'No se ha podido validar el PAT de setup.', + 'doctor.setupPat.replaceAction': 'Sustituye el PAT de setup y vuelve a ejecutar doctor.', + 'doctor.setupPat.unverifiedAction': 'Comprueba el PAT de setup y el acceso de red, y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.skipped': 'No se ha inspeccionado la preparación de merge queue porque la configuración de setup no es válida.', + 'doctor.mergeQueue.skippedAction': 'Corrige los errores de configuración indicados y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.unverified': 'No se ha podido inspeccionar la preparación de merge queue.', + 'doctor.mergeQueue.unverifiedAction': 'Comprueba el acceso a la política de rama y vuelve a ejecutar doctor.', + 'doctor.scope.unverified': 'No se han podido inspeccionar los ámbitos de recursos de GitHub Actions.', + 'doctor.scope.unverifiedAction': 'Comprueba el acceso del PAT de setup a los metadatos de Actions del repositorio y la organización, y vuelve a intentarlo.', + 'doctor.scope.readable': 'Los ámbitos de recursos de GitHub Actions son legibles.', + 'doctor.scope.unavailable': 'Algunos ámbitos de recursos de GitHub Actions de la organización no están disponibles.', + 'doctor.scope.grantAction': 'Concede al PAT de setup el acceso necesario a los metadatos de Actions de la organización.', + 'doctor.variable.missing': 'Falta la Variable.', + 'doctor.variable.configured': 'La Variable está configurada en el ámbito {scope}.', + 'doctor.variable.preserved': 'La Variable difiere, pero se conserva intencionadamente en el ámbito {scope}.', + 'doctor.variable.different': 'La Variable difiere de la configuración de setup esperada.', + 'doctor.variable.action': 'Ejecuta setup para reconciliar esta Variable.', + 'doctor.secretMetadata.readable': 'Los metadatos de Secrets de GitHub Actions son legibles.', + 'doctor.credential.group.runnerRequired': 'No hay un Secret alternativo configurado; la autenticación del runner debe satisfacer este grupo.', + 'doctor.credential.group.missing': 'No hay ninguna credencial alternativa disponible.', + 'doctor.credential.group.healthy': 'Al menos una credencial alternativa es válida.', + 'doctor.credential.group.invalid': 'Todas las credenciales alternativas disponibles son inválidas.', + 'doctor.credential.group.unverified': 'Hay una credencial alternativa, pero su estado remoto no está disponible.', + 'doctor.credential.group.action': 'Configura y valida una credencial para este grupo de requisitos.', + 'doctor.credential.missing': 'Falta el Secret requerido.', + 'doctor.credential.valid': 'El estado de la credencial es válido.', + 'doctor.credential.invalid': 'El estado de la credencial es inválido.', + 'doctor.credential.unverifiable': 'El estado de la credencial no se puede verificar automáticamente.', + 'doctor.credential.unverified': 'El Secret está presente, pero su estado remoto no está disponible.', + 'doctor.credential.action': 'Configura o sustituye {name} y vuelve a ejecutar la comprobación de credenciales.', + 'doctor.skipped.scope': 'La inspección de ámbitos requiere un PAT de setup válido.', + 'doctor.skipped.scopeAction': 'Sustituye el PAT de setup.', + 'doctor.skipped.variables': 'No se han podido comprobar las Variables.', + 'doctor.skipped.secrets': 'No se han podido comprobar los metadatos de Secrets.', + 'doctor.skipped.credentials': 'No se ha podido comprobar el estado de las credenciales.', + 'doctor.skipped.resourceAction': 'Resuelve el check bloqueante y vuelve a ejecutar doctor.', + 'doctor.mergeQueue.configureSupportedAction': 'Configura un productor de merge queue compatible o elige otro modo de reconciliación.', + 'doctor.mergeQueue.repairOrAttestAction': 'Repara o atestigua cada productor obligatorio de merge queue.', + 'doctor.mergeQueue.selectedMode': 'El modo seleccionado es {mode}; este destino no necesita evidencia de productores de merge queue.', + 'doctor.mergeQueue.ready': 'Listo. {verified} productor(es) obligatorio(s) verificados automáticamente y {attested} cubiertos por atestación exacta.', + 'doctor.mergeQueue.targetUnverified': 'No se ha podido inspeccionar la política del destino porque ha fallado la petición al proveedor.', + 'doctor.mergeQueue.targetUnverifiedAction': 'Comprueba los permisos del PAT de setup y la política de la rama de destino, y vuelve a intentarlo.', + 'doctor.mergeQueue.activeRelease': 'Las ramas de release activas se descubren dinámicamente y se revalidan antes de crear una rama o pull request de reconciliación de hotfix.', + 'doctor.mergeQueue.staleAttestation': 'Esta atestación exacta no coincide con ningún check obligatorio observado en producción o desarrollo.', + 'doctor.mergeQueue.staleAttestationAction': 'Elimina la atestación obsoleta o corrige la identidad exacta del check.', + 'doctor.mergeQueue.producer': '{verdict}: {state}', + 'doctor.mergeQueue.producerAction': 'Configura o atestigua exactamente este productor obligatorio.', +}); + +function messages( + doctor: Readonly>, + mergeQueue: Readonly>, +): Readonly> { + return Object.freeze({ ...doctor, ...mergeQueue }); +} + +export const ENGLISH_SETUP_DOCTOR_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: messages(ENGLISH_DOCTOR_MESSAGES, ENGLISH_MERGE_QUEUE_MESSAGES), +}); + +export const SPANISH_SETUP_DOCTOR_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: messages(SPANISH_DOCTOR_MESSAGES, SPANISH_MERGE_QUEUE_MESSAGES), +}); + +export const SETUP_DOCTOR_CATALOG_DEFINITIONS = Object.freeze([ + ENGLISH_SETUP_DOCTOR_DEFINITION, + SPANISH_SETUP_DOCTOR_DEFINITION, +]); + +export function resolveStaticSetupDoctorCatalog(locale = 'en-US'): SetupDoctorMessageCatalog { + return resolveStaticMessageCatalogView( + locale, + ENGLISH_SETUP_DOCTOR_DEFINITION, + SETUP_DOCTOR_CATALOG_DEFINITIONS, + ); +} + +export function resolveSetupDoctorCatalog( + locale: string, + configuration: Readonly | undefined, + resolver: MessageCatalogResolutionPort | undefined, +): Promise { + return resolveMessageCatalogView( + locale, + SETUP_DOCTOR_MESSAGE_IDS, + ENGLISH_SETUP_DOCTOR_DEFINITION, + SETUP_DOCTOR_CATALOG_DEFINITIONS, + configuration, + resolver, + ); +} diff --git a/src/application/policies/setup_doctor_report_policy.ts b/src/application/policies/setup_doctor_report_policy.ts index df7ee3f09..5e79d7bbf 100644 --- a/src/application/policies/setup_doctor_report_policy.ts +++ b/src/application/policies/setup_doctor_report_policy.ts @@ -2,8 +2,12 @@ import type { DoctorCheck, DoctorCheckStatus, DoctorReport } from '../../domain/ import type { SetupConfiguration } from '../../domain/setup'; import { resolveLocaleProfile } from '../../domain/locale'; import { selectBundledMessageCatalog } from '../../domain/message_catalog'; -import { PUBLICATION_CATALOG_DEFINITIONS } from './publication_message_catalog'; import { isAgentConfigurationReady } from '../../domain/agent'; +import { + resolveStaticSetupDoctorCatalog, + SETUP_DOCTOR_CATALOG_DEFINITIONS, + type SetupDoctorMessageCatalog, +} from './setup_doctor_message_catalog'; const EMPTY_TOTALS: Readonly> = { pass: 0, @@ -66,7 +70,10 @@ export function normalizedDoctorPathId(path: string): string { return normalized || 'unknown'; } -export function buildLocaleDoctorChecks(configuration: SetupConfiguration): readonly DoctorCheck[] { +export function buildLocaleDoctorChecks( + configuration: SetupConfiguration, + catalog: SetupDoctorMessageCatalog = resolveStaticSetupDoctorCatalog(), +): readonly DoctorCheck[] { try { const profile = resolveLocaleProfile( configuration.repository.repositoryLocale, @@ -74,18 +81,29 @@ export function buildLocaleDoctorChecks(configuration: SetupConfiguration): read configuration.repository.pullRequestLocale, ); const dynamicReady = isAgentConfigurationReady(configuration.agents.planner); + const repositoryCatalogSource = catalog.requestedLocale === profile.repository + ? catalog.resolutionSource + : undefined; return Object.freeze([ - localeDoctorCheck('repository', configuration.repository.repositoryLocale, profile.repository, false, dynamicReady), - localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady), - localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady), + localeDoctorCheck( + 'repository', + configuration.repository.repositoryLocale, + profile.repository, + false, + dynamicReady, + catalog, + repositoryCatalogSource, + ), + localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady, catalog), + localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady, catalog), ]); } catch { return Object.freeze([ skippedDoctorCheck( 'locale.profile', ['configuration.valid'], - 'Locale capability could not be inspected because locale configuration is invalid.', - 'Correct the locale values and run doctor again.', + catalog.message('doctor.locale.invalid'), + catalog.message('doctor.locale.invalidAction'), ), ]); } @@ -97,23 +115,28 @@ function localeDoctorCheck( effective: string, inheritedWhenEmpty: boolean, dynamicReady: boolean, + catalog: SetupDoctorMessageCatalog, + resolvedCatalogSource?: 'exact' | 'base' | 'dynamic' | 'fallback', ): DoctorCheck { - const bundled = selectBundledMessageCatalog(effective, PUBLICATION_CATALOG_DEFINITIONS); - const catalogSource = bundled?.source ?? (dynamicReady ? 'dynamic' : 'fallback'); + const bundled = selectBundledMessageCatalog(effective, SETUP_DOCTOR_CATALOG_DEFINITIONS); + const catalogSource = resolvedCatalogSource ?? bundled?.source ?? (dynamicReady ? 'dynamic' : 'fallback'); const legacySeparator = configured.includes('_'); const fallback = catalogSource === 'fallback'; const status: DoctorCheckStatus = legacySeparator || fallback ? 'warn' : 'pass'; - const inheritance = inheritedWhenEmpty && !configured.trim() ? ' It inherits the repository locale.' : ''; + const inherited = inheritedWhenEmpty && !configured.trim(); return doctorCheck({ id: `locale.${scope}`, status, summary: fallback - ? `Effective locale ${effective} has no bundled catalog and no ready language agent; product copy will fall back atomically to en-US.${inheritance}` - : `Effective locale ${effective} resolves through the ${catalogSource} catalog path.${inheritance}`, + ? catalog.message(inherited ? 'doctor.locale.fallbackInherited' : 'doctor.locale.fallback', { locale: effective }) + : catalog.message(inherited ? 'doctor.locale.resolvedInherited' : 'doctor.locale.resolved', { + locale: effective, + source: catalogSource, + }), ...(legacySeparator - ? { action: 'Replace legacy underscore separators with canonical BCP-47 hyphens.' } + ? { action: catalog.message('doctor.locale.separatorAction') } : fallback - ? { action: 'Configure a ready language agent if localized non-English product copy is required.' } + ? { action: catalog.message('doctor.locale.dynamicAction') } : {}), evidence: { configured: configured || '(inherit)', diff --git a/src/application/ports/setup_wizard_ports.ts b/src/application/ports/setup_wizard_ports.ts index f3448903e..e11aca8fd 100644 --- a/src/application/ports/setup_wizard_ports.ts +++ b/src/application/ports/setup_wizard_ports.ts @@ -9,6 +9,7 @@ import type { SetupResourceTarget, SetupRemoteConfiguration, } from '../../domain/setup'; +import type { SetupDoctorMessageCatalog } from '../policies/setup_doctor_message_catalog'; export interface SetupRemoteConfigurationReadPort { inspect(owner: string, repository: string, token: string): Promise; @@ -52,6 +53,8 @@ export interface SetupMergeQueueReadinessRequest { repository: string; token: string; configuration: SetupConfiguration; + /** Reuse the artifact catalog when doctor already resolved it. */ + catalog?: SetupDoctorMessageCatalog; } export interface SetupMergeQueueReadinessPort { diff --git a/src/application/services/deployment_orchestration_runtime.ts b/src/application/services/deployment_orchestration_runtime.ts index a8f8ebcf2..5c1bdf291 100644 --- a/src/application/services/deployment_orchestration_runtime.ts +++ b/src/application/services/deployment_orchestration_runtime.ts @@ -5,6 +5,7 @@ import type { } from "../ports/deployment_orchestration_ports"; import { mergeQueueReadinessFailureMessage, + pullRequestModeDecisionMessage, selectPullRequestMode, type PullRequestModeDecision, type TargetMergeCapabilities, @@ -218,9 +219,10 @@ export class DeploymentOrchestrationRuntime { attestations: context.deployment.mergeQueueCheckAttestations, }); if (readiness.verdict !== "ready") { + const catalog = await this.presentationCatalog('issue', context, operation); return { kind: "blocked", - reason: mergeQueueReadinessFailureMessage(readiness, effectiveLocale(context, operation).issue), + reason: mergeQueueReadinessFailureMessage(readiness, catalog), }; } } @@ -302,16 +304,17 @@ export class DeploymentOrchestrationRuntime { } } - private unsupportedMergeBehavior( + private async unsupportedMergeBehavior( context: DeploymentOrchestrationContext, operation: DeploymentOperationSnapshot, targetRole: MergeQueueTargetRole, targetBranch: string, capabilities: TargetMergeCapabilities, decision: Extract, - ): { readonly kind: "blocked"; readonly reason: string } { + ): Promise<{ readonly kind: "blocked"; readonly reason: string }> { + const catalog = await this.presentationCatalog('issue', context, operation); if (capabilities.mergeQueueObservationProblems.length === 0) { - return { kind: "blocked", reason: decision.reason }; + return { kind: "blocked", reason: pullRequestModeDecisionMessage(decision, catalog) }; } const readiness = evaluateMergeQueueReadiness({ queueRequired: true, @@ -323,7 +326,7 @@ export class DeploymentOrchestrationRuntime { }); return { kind: "blocked", - reason: mergeQueueReadinessFailureMessage(readiness, effectiveLocale(context, operation).issue), + reason: mergeQueueReadinessFailureMessage(readiness, catalog), }; } diff --git a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts index 7c8309b46..d54cea097 100644 --- a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts @@ -409,7 +409,10 @@ describe("DeploymentOrchestrationUseCase", () => { expect(result[0].success).toBe(false); expect(value.pullRequests.createManagedPullRequest).toHaveBeenCalledTimes(1); expect(value.pullRequests.enqueuePullRequest).not.toHaveBeenCalled(); - expect(input.currentConfiguration.deploymentOrchestration?.lastFailure?.message).toContain("candidate workflow"); + expect(input.currentConfiguration.deploymentOrchestration?.lastFailure?.message) + .toContain("CI Check [unsupported]: does not support merge groups"); + expect(input.currentConfiguration.deploymentOrchestration?.lastFailure?.message) + .not.toContain("candidate workflow"); }); it("does not enqueue a PR twice when GitHub already reports queue membership", async () => { diff --git a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts index ac70b9b04..499f6aac4 100644 --- a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts @@ -5,6 +5,9 @@ import { createDefaultSetupConfiguration, } from '../../../policies/setup_configuration_policy'; import type { SetupConfiguration, SetupRemoteConfiguration } from '../../../../domain/setup'; +import type { CatalogMessage } from '../../../../domain/message_catalog'; +import { ResolveMessageCatalogUseCase } from '../../localization/resolve_message_catalog_use_case'; +import { ENGLISH_SETUP_DOCTOR_DEFINITION } from '../../../policies/setup_doctor_message_catalog'; function completeRemote(configuration: SetupConfiguration): SetupRemoteConfiguration { return { @@ -51,7 +54,7 @@ describe('SetupDoctorUseCase', () => { ]), }, }); - const report = await new SetupDoctorUseCase(deps).execute(request(configuration)); + const { report } = await new SetupDoctorUseCase(deps).execute(request(configuration)); expect(report.healthy).toBe(false); expect(report.checks).toEqual(expect.arrayContaining([ @@ -68,7 +71,7 @@ describe('SetupDoctorUseCase', () => { it('returns an ordered healthy report when all required facts match', async () => { const configuration = createDefaultSetupConfiguration(); - const report = await new SetupDoctorUseCase(dependencies(configuration)).execute(request(configuration)); + const { report } = await new SetupDoctorUseCase(dependencies(configuration)).execute(request(configuration)); expect(report.healthy).toBe(true); expect(report.checks.slice(0, 5).map((check) => check.id)).toEqual([ @@ -81,6 +84,65 @@ describe('SetupDoctorUseCase', () => { expect(report.totals.fail).toBe(0); }); + it('uses repository locale for the whole report and reuses its catalog in merge readiness', async () => { + const configuration = createDefaultSetupConfiguration(); + configuration.repository.repositoryLocale = 'es-ES'; + configuration.repository.issueLocale = 'en-US'; + const deps = dependencies(configuration); + + const diagnosis = await new SetupDoctorUseCase(deps).execute(request(configuration)); + + expect(diagnosis.catalog).toMatchObject({ locale: 'es-ES', resolutionSource: 'exact' }); + expect(diagnosis.report.checks.find((check) => check.id === 'configuration.valid')?.summary) + .toBe('La configuración de setup es válida.'); + expect(deps.mergeQueueReadiness.inspect).toHaveBeenCalledWith(expect.objectContaining({ + catalog: diagnosis.catalog, + })); + }); + + it('resolves an arbitrary repository locale once and returns the same dynamic catalog', async () => { + const configuration = createDefaultSetupConfiguration(); + configuration.repository.repositoryLocale = 'fr-FR'; + const messages = Object.fromEntries(Object.entries(ENGLISH_SETUP_DOCTOR_DEFINITION.messages) + .map(([id, message]) => [id, `FR ${message as string}`])) as Record; + const query = jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages }); + const deps = dependencies(configuration, { + catalogResolver: new ResolveMessageCatalogUseCase({ query }), + }); + + const diagnosis = await new SetupDoctorUseCase(deps).execute(request(configuration)); + + expect(diagnosis.catalog).toMatchObject({ locale: 'fr-FR', resolutionSource: 'dynamic' }); + expect(diagnosis.report.checks.find((check) => check.id === 'configuration.valid')?.summary) + .toBe('FR Setup configuration is valid.'); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('reports the actual atomic English fallback when dynamic catalog validation fails', async () => { + const configuration = createDefaultSetupConfiguration(); + configuration.repository.repositoryLocale = 'fr-FR'; + const messages = Object.fromEntries(Object.entries(ENGLISH_SETUP_DOCTOR_DEFINITION.messages) + .map(([id, message]) => [id, `FR ${message as string}`])) as Record; + delete messages['doctor.title']; + const query = jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages }); + + const diagnosis = await new SetupDoctorUseCase(dependencies(configuration, { + catalogResolver: new ResolveMessageCatalogUseCase({ query }), + })).execute(request(configuration)); + + expect(diagnosis.catalog).toMatchObject({ + requestedLocale: 'fr-FR', + locale: 'en-US', + resolutionSource: 'fallback', + fallbackReason: 'dynamic-response-invalid', + }); + expect(diagnosis.report.checks.find((check) => check.id === 'locale.repository')) + .toMatchObject({ status: 'warn', evidence: { catalogSource: 'fallback' } }); + const productCopy = diagnosis.report.checks.flatMap((check) => [check.summary, check.action ?? '']); + expect(productCopy.some((message) => message.startsWith('FR '))).toBe(false); + expect(query).toHaveBeenCalledTimes(1); + }); + it('preserves an independent merge-queue result when resource inspection fails', async () => { const configuration = createDefaultSetupConfiguration(); const queueCheck = { @@ -94,7 +156,7 @@ describe('SetupDoctorUseCase', () => { remoteConfiguration: { inspect: jest.fn().mockRejectedValue(new Error('forbidden')) }, mergeQueueReadiness: { inspect: jest.fn().mockResolvedValue([queueCheck]) }, }); - const report = await new SetupDoctorUseCase(deps).execute(request(configuration)); + const { report } = await new SetupDoctorUseCase(deps).execute(request(configuration)); expect(report.checks).toEqual(expect.arrayContaining([ queueCheck, @@ -108,7 +170,7 @@ describe('SetupDoctorUseCase', () => { const deps = dependencies(configuration, { mergeQueueReadiness: { inspect: jest.fn().mockRejectedValue(new Error('provider details')) }, }); - const report = await new SetupDoctorUseCase(deps).execute(request(configuration)); + const { report } = await new SetupDoctorUseCase(deps).execute(request(configuration)); expect(report.checks.find((check) => check.id === 'github.merge-queue')).toEqual(expect.objectContaining({ status: 'fail', @@ -125,7 +187,7 @@ describe('SetupDoctorUseCase', () => { organizationAccess: 'unavailable' as const, organizationVariablesAccess: 'unavailable' as const, }; - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { remoteConfiguration: { inspect: jest.fn().mockResolvedValue(unavailable) }, })).execute(request(configuration)); @@ -135,7 +197,7 @@ describe('SetupDoctorUseCase', () => { it('reports unavailable health as warnings rather than passes', async () => { const configuration = createDefaultSetupConfiguration(); - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { remoteHealth: { validateExisting: jest.fn().mockRejectedValue(new Error('unavailable')) }, })).execute(request(configuration)); @@ -146,7 +208,7 @@ describe('SetupDoctorUseCase', () => { it('reports unchanged workflow comparisons as passing facts', async () => { const configuration = createDefaultSetupConfiguration(); - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { workspace: { isRepositoryRoot: jest.fn(() => true), compareWorkflows: jest.fn(() => [ @@ -172,7 +234,7 @@ describe('SetupDoctorUseCase', () => { const pending = new SetupDoctorUseCase(deps).execute(request(configuration)); releaseQueue([]); releaseRemote(completeRemote(configuration)); - const report = await pending; + const { report } = await pending; expect(report.checks.findIndex((check) => check.id === 'github.resource-scopes')) .toBeLessThan(report.checks.findIndex((check) => check.id.startsWith('github.variables.'))); @@ -182,7 +244,7 @@ describe('SetupDoctorUseCase', () => { const configuration = createDefaultSetupConfiguration(); configuration.repository.mainBranch = ''; const deps = dependencies(configuration); - const report = await new SetupDoctorUseCase(deps).execute(request(configuration)); + const { report } = await new SetupDoctorUseCase(deps).execute(request(configuration)); expect(report.checks).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'configuration.valid', status: 'fail' }), @@ -196,7 +258,7 @@ describe('SetupDoctorUseCase', () => { it('maps local probe exceptions and non-root workspaces to bounded failures', async () => { const configuration = createDefaultSetupConfiguration(); - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { workspace: { isRepositoryRoot: jest.fn(() => false), compareWorkflows: jest.fn(() => { throw new Error('/private/path'); }), @@ -208,7 +270,7 @@ describe('SetupDoctorUseCase', () => { expect.objectContaining({ id: 'workflow.comparison', status: 'fail', summary: 'Managed workflows could not be compared.' }), ])); - const thrownRoot = await new SetupDoctorUseCase(dependencies(configuration, { + const { report: thrownRoot } = await new SetupDoctorUseCase(dependencies(configuration, { workspace: { isRepositoryRoot: jest.fn(() => { throw new Error('private'); }), compareWorkflows: jest.fn(() => []), @@ -219,7 +281,7 @@ describe('SetupDoctorUseCase', () => { it('maps a thrown PAT validation to a safe failure', async () => { const configuration = createDefaultSetupConfiguration(); - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { validation: { validateSetupPat: jest.fn().mockRejectedValue(new Error('github_pat_private')) }, })).execute(request(configuration)); @@ -241,7 +303,7 @@ describe('SetupDoctorUseCase', () => { repositoryVariables, organizationVariables: [{ name: 'AGENT_PROVIDER', value: 'different' }], }; - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { remoteConfiguration: { inspect: jest.fn().mockResolvedValue(remote) }, })).execute(request(configuration)); @@ -256,7 +318,7 @@ describe('SetupDoctorUseCase', () => { const configuration = createDefaultSetupConfiguration(); for (const task of Object.values(configuration.agents)) task.provider = 'cursor'; const remoteHealth = { validateExisting: jest.fn() }; - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { remoteConfiguration: { inspect: jest.fn().mockResolvedValue({ ...completeRemote(configuration), repositorySecrets: [] }), }, @@ -274,13 +336,13 @@ describe('SetupDoctorUseCase', () => { const configuration = createDefaultSetupConfiguration(); for (const task of Object.values(configuration.agents)) task.provider = 'opencode'; const requirements = buildSetupCredentialRequirements(configuration); - const report = await new SetupDoctorUseCase(dependencies(configuration, { + const { report } = await new SetupDoctorUseCase(dependencies(configuration, { remoteConfiguration: { inspect: jest.fn().mockResolvedValue(completeRemote(configuration)) }, remoteHealth: { validateExisting: jest.fn().mockResolvedValue(requirements.map((requirement) => ({ name: requirement.name, status: 'invalid', - message: 'Invalid.', + message: 'Invalid github_pat_private credential from provider.', }))), }, })).execute(request(configuration)); @@ -289,6 +351,8 @@ describe('SetupDoctorUseCase', () => { status: 'fail', summary: 'Every available alternative credential is invalid.', })); + expect(JSON.stringify(report)).not.toContain('github_pat_private'); + expect(JSON.stringify(report)).not.toContain('from provider'); }); }); diff --git a/src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts b/src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts index aad3fbf5d..a5dc58dc4 100644 --- a/src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts @@ -1,5 +1,8 @@ import type { TargetMergeCapabilities } from "../../../policies/deployment_plan_policy"; +import type { CatalogMessage } from '../../../../domain/message_catalog'; import { createDefaultSetupConfiguration } from "../../../policies/setup_configuration_policy"; +import { ENGLISH_SETUP_DOCTOR_DEFINITION } from '../../../policies/setup_doctor_message_catalog'; +import { ResolveMessageCatalogUseCase } from '../../../usecases/localization/resolve_message_catalog_use_case'; import { SetupMergeQueueReadinessUseCase } from "../merge_queue_readiness_use_case"; const capabilities = (overrides: Partial = {}): TargetMergeCapabilities => ({ @@ -76,7 +79,7 @@ describe("SetupMergeQueueReadinessUseCase", () => { value.configuration.repository.mergeQueueCheckAttestations = [ { context: "External CI", integrationId: 999, targets: ["production"] }, ]; - value.configuration.repository.issueLocale = "es-ES"; + value.configuration.repository.repositoryLocale = "es-ES"; const checks = await new SetupMergeQueueReadinessUseCase(targets).inspect(value); expect(checks[0]).toEqual(expect.objectContaining({ status: "pass", summary: expect.stringContaining("1 cubiertos por atestación exacta") })); expect(checks.find((check) => check.id.includes("producer.external-ci-999"))) @@ -104,12 +107,34 @@ describe("SetupMergeQueueReadinessUseCase", () => { mergeQueueObservationProblems: [{ area: "effective-rules", message: "Forbidden" }], })) }; const value = request(); - value.configuration.repository.issueLocale = "es-ES"; + value.configuration.repository.repositoryLocale = "es-ES"; const checks = await new SetupMergeQueueReadinessUseCase(targets).inspect(value); expect(checks[0]).toEqual(expect.objectContaining({ status: "fail", summary: expect.stringContaining("Restaura el acceso de lectura"), })); + expect(checks[0].summary).not.toContain('Forbidden'); + }); + + it('resolves one complete dynamic catalog for a standalone arbitrary-locale inspection', async () => { + const targets = { getTargetCapabilities: jest.fn().mockResolvedValue(capabilities({ + mergeQueueRequired: true, + mergeQueueProducers: [{ + kind: 'check', name: 'External CI', integrationId: 999, support: 'unknown', reason: 'Provider detail.', + }], + })) }; + const messages = Object.fromEntries(Object.entries(ENGLISH_SETUP_DOCTOR_DEFINITION.messages) + .map(([id, message]) => [id, `ZH ${message as string}`])) as Record; + const query = jest.fn().mockResolvedValue({ targetLocale: 'zh-Hant-TW', messages }); + const resolver = new ResolveMessageCatalogUseCase({ query }); + const value = request(); + value.configuration.repository.repositoryLocale = 'zh-Hant-TW'; + + const checks = await new SetupMergeQueueReadinessUseCase(targets, resolver).inspect(value); + + expect(checks[0].summary.startsWith('ZH ')).toBe(true); + expect(JSON.stringify(checks)).not.toContain('Provider detail.'); + expect(query).toHaveBeenCalledTimes(1); }); it("warns about attestations that match no currently required producer", async () => { diff --git a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts index f47f3bbc5..f4250e356 100644 --- a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts @@ -151,7 +151,11 @@ describe('SetupWizardUseCase', () => { remoteTarget: { owner: 'owner', repository: 'repo', token: 'token' }, }); - expect(readiness.inspect).toHaveBeenCalledWith(expect.objectContaining({ owner: 'owner', repository: 'repo' })); + expect(readiness.inspect).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'owner', + repository: 'repo', + catalog: expect.objectContaining({ locale: 'en-US', resolutionSource: 'exact' }), + })); expect(deps.planPresenter.present).toHaveBeenCalledWith(expect.objectContaining({ mergeQueueReadiness: [check] })); }); diff --git a/src/application/usecases/setup/doctor_use_case.ts b/src/application/usecases/setup/doctor_use_case.ts index 84a6ef0a7..b77dc31e8 100644 --- a/src/application/usecases/setup/doctor_use_case.ts +++ b/src/application/usecases/setup/doctor_use_case.ts @@ -6,6 +6,7 @@ import type { SetupCredentialRequirement, SetupRemoteConfiguration, } from '../../../domain/setup'; +import { DEFAULT_REPOSITORY_LOCALE, resolveLocaleProfile } from '../../../domain/locale'; import { buildSetupCredentialRequirements, buildSetupRepositoryVariables, @@ -30,6 +31,11 @@ import type { SetupRemoteCredentialHealthPort, } from '../../ports/setup_wizard_ports'; import type { SetupDoctorWorkspaceQueryPort } from '../../ports/setup_workspace_ports'; +import type { MessageCatalogResolutionPort } from '../../ports/message_catalog_ports'; +import { + resolveSetupDoctorCatalog, + type SetupDoctorMessageCatalog, +} from '../../policies/setup_doctor_message_catalog'; export interface DoctorRequest { owner: string; @@ -44,6 +50,12 @@ export interface SetupDoctorDependencies { remoteConfiguration: SetupRemoteConfigurationReadPort; remoteHealth: SetupRemoteCredentialHealthPort; mergeQueueReadiness: SetupMergeQueueReadinessPort; + catalogResolver?: MessageCatalogResolutionPort; +} + +export interface SetupDoctorExecutionResult { + readonly report: DoctorReport; + readonly catalog: SetupDoctorMessageCatalog; } type RemoteResult = @@ -55,20 +67,25 @@ type RemoteResult = export class SetupDoctorUseCase { constructor(private readonly dependencies: SetupDoctorDependencies) {} - async execute(request: DoctorRequest): Promise { + async execute(request: DoctorRequest): Promise { + const catalog = await resolveSetupDoctorCatalog( + doctorCatalogLocale(request.configuration), + request.configuration.agents.planner, + this.dependencies.catalogResolver, + ); const configurationErrors = validateSetupConfiguration(request.configuration); const checks: DoctorCheck[] = [ - configurationCheck(configurationErrors), - ...buildLocaleDoctorChecks(request.configuration), - repositoryRootCheck(this.dependencies.workspace), - ...workflowChecks(request.configuration, configurationErrors, this.dependencies.workspace), + configurationCheck(configurationErrors, catalog), + ...buildLocaleDoctorChecks(request.configuration, catalog), + repositoryRootCheck(this.dependencies.workspace, catalog), + ...workflowChecks(request.configuration, configurationErrors, this.dependencies.workspace, catalog), ]; - const pat = await this.validatePat(request); + const pat = await this.validatePat(request, catalog); checks.push(pat); if (pat.status !== 'pass') { - checks.push(...skippedRemoteChecks(request.configuration, pat.id)); - return buildDoctorReport(checks); + checks.push(...skippedRemoteChecks(request.configuration, pat.id, catalog)); + return { report: buildDoctorReport(checks), catalog }; } const remote = await runWithConcurrencyLimit([ @@ -93,8 +110,8 @@ export class SetupDoctorUseCase { value: [skippedDoctorCheck( 'github.merge-queue', ['configuration.valid'], - 'Merge-queue readiness was not inspected because setup configuration is invalid.', - 'Fix the reported configuration errors, then run doctor again.', + catalog.message('doctor.mergeQueue.skipped'), + catalog.message('doctor.mergeQueue.skippedAction'), )], }; } @@ -106,6 +123,7 @@ export class SetupDoctorUseCase { repository: request.repository, token: request.setupToken, configuration: request.configuration, + catalog, }), }; } catch { @@ -120,24 +138,27 @@ export class SetupDoctorUseCase { result.kind === 'merge-queue')?.value ?? [doctorCheck({ id: 'github.merge-queue', status: 'fail', - summary: 'Merge-queue readiness could not be inspected.', - action: 'Check branch policy access and run doctor again.', + summary: catalog.message('doctor.mergeQueue.unverified'), + action: catalog.message('doctor.mergeQueue.unverifiedAction'), })]; if (!remoteConfiguration) { - checks.push(remoteScopeFailure(request.configuration)); - checks.push(...mergeQueueChecks, ...skippedResourceChecks(request.configuration, 'github.resource-scopes')); - return buildDoctorReport(checks); + checks.push(remoteScopeFailure(request.configuration, catalog)); + checks.push(...mergeQueueChecks, ...skippedResourceChecks(request.configuration, 'github.resource-scopes', catalog)); + return { report: buildDoctorReport(checks), catalog }; } - checks.push(resourceScopeCheck(request.configuration, remoteConfiguration)); + checks.push(resourceScopeCheck(request.configuration, remoteConfiguration, catalog)); checks.push(...mergeQueueChecks); - checks.push(...variableChecks(request.configuration, remoteConfiguration)); - checks.push(secretNamesCheck(remoteConfiguration)); - checks.push(...await this.credentialChecks(request, remoteConfiguration)); - return buildDoctorReport(checks); + checks.push(...variableChecks(request.configuration, remoteConfiguration, catalog)); + checks.push(secretNamesCheck(remoteConfiguration, catalog)); + checks.push(...await this.credentialChecks(request, remoteConfiguration, catalog)); + return { report: buildDoctorReport(checks), catalog }; } - private async validatePat(request: DoctorRequest): Promise { + private async validatePat( + request: DoctorRequest, + catalog: SetupDoctorMessageCatalog, + ): Promise { try { const result = await this.dependencies.validation.validateSetupPat( request.owner, @@ -147,8 +168,8 @@ export class SetupDoctorUseCase { return doctorCheck({ id: 'credentials.setup-pat', status: result.status === 'valid' ? 'pass' : 'fail', - summary: result.message, - ...(result.status === 'valid' ? {} : { action: 'Replace the setup PAT and run doctor again.' }), + summary: catalog.message(result.status === 'valid' ? 'doctor.setupPat.valid' : 'doctor.setupPat.invalid'), + ...(result.status === 'valid' ? {} : { action: catalog.message('doctor.setupPat.replaceAction') }), evidence: { credential: 'SETUP_PAT', ...(result.account ? { account: result.account } : {}), @@ -158,8 +179,8 @@ export class SetupDoctorUseCase { return doctorCheck({ id: 'credentials.setup-pat', status: 'fail', - summary: 'The setup PAT could not be validated.', - action: 'Check the setup PAT and network access, then run doctor again.', + summary: catalog.message('doctor.setupPat.unverified'), + action: catalog.message('doctor.setupPat.unverifiedAction'), evidence: { credential: 'SETUP_PAT' }, }); } @@ -168,6 +189,7 @@ export class SetupDoctorUseCase { private async credentialChecks( request: DoctorRequest, remote: SetupRemoteConfiguration, + catalog: SetupDoctorMessageCatalog, ): Promise { const requirements = buildSetupCredentialRequirements(request.configuration); const remoteSecrets = new Set([...remote.repositorySecrets, ...remote.organizationSecrets]); @@ -186,37 +208,43 @@ export class SetupDoctorUseCase { health = undefined; } } - return buildCredentialChecks(requirements, remoteSecrets, health); + return buildCredentialChecks(requirements, remoteSecrets, health, catalog); } } -function configurationCheck(errors: readonly string[]): DoctorCheck { +function configurationCheck( + errors: readonly string[], + catalog: SetupDoctorMessageCatalog, +): DoctorCheck { return doctorCheck({ id: 'configuration.valid', status: errors.length === 0 ? 'pass' : 'fail', summary: errors.length === 0 - ? 'Setup configuration is valid.' - : `Setup configuration has ${errors.length} validation error(s).`, - ...(errors.length === 0 ? {} : { action: 'Fix the setup configuration and run doctor again.' }), + ? catalog.message('doctor.configuration.valid') + : catalog.message('doctor.configuration.invalid', { count: errors.length }), + ...(errors.length === 0 ? {} : { action: catalog.message('doctor.configuration.action') }), evidence: { errorCount: errors.length }, }); } -function repositoryRootCheck(workspace: SetupDoctorWorkspaceQueryPort): DoctorCheck { +function repositoryRootCheck( + workspace: SetupDoctorWorkspaceQueryPort, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck { try { const valid = workspace.isRepositoryRoot(); return doctorCheck({ id: 'workspace.repository-root', status: valid ? 'pass' : 'fail', - summary: valid ? 'Current directory is the repository root.' : 'Current directory is not the repository root.', - ...(valid ? {} : { action: 'Run doctor from the root of the target Git repository.' }), + summary: catalog.message(valid ? 'doctor.repositoryRoot.valid' : 'doctor.repositoryRoot.invalid'), + ...(valid ? {} : { action: catalog.message('doctor.repositoryRoot.action') }), }); } catch { return doctorCheck({ id: 'workspace.repository-root', status: 'fail', - summary: 'Repository root could not be verified.', - action: 'Run doctor from the root of the target Git repository.', + summary: catalog.message('doctor.repositoryRoot.unverified'), + action: catalog.message('doctor.repositoryRoot.action'), }); } } @@ -225,13 +253,14 @@ function workflowChecks( configuration: SetupConfiguration, configurationErrors: readonly string[], workspace: SetupDoctorWorkspaceQueryPort, + catalog: SetupDoctorMessageCatalog, ): DoctorCheck[] { if (configurationErrors.length > 0) { return [skippedDoctorCheck( 'workflow.comparison', ['configuration.valid'], - 'Workflow comparison was skipped because setup configuration is invalid.', - 'Fix setup configuration, then run doctor again.', + catalog.message('doctor.workflow.skipped'), + catalog.message('doctor.workflow.configurationAction'), )]; } try { @@ -241,31 +270,38 @@ function workflowChecks( id: `workflow.${normalizedDoctorPathId(comparison.destination)}`, status: comparison.status === 'unchanged' ? 'pass' : 'fail', summary: comparison.status === 'unchanged' - ? 'Matches the installed setup template.' - : `Local workflow is ${comparison.status}.`, - ...(comparison.status === 'unchanged' ? {} : { action: 'Run setup to repair this managed workflow.' }), + ? catalog.message('doctor.workflow.matches') + : catalog.message('doctor.workflow.drift', { state: comparison.status }), + ...(comparison.status === 'unchanged' ? {} : { action: catalog.message('doctor.workflow.repairAction') }), evidence: { path: comparison.destination, state: comparison.status }, })); } catch { return [doctorCheck({ id: 'workflow.comparison', status: 'fail', - summary: 'Managed workflows could not be compared.', - action: 'Check local workflow files and run doctor again.', + summary: catalog.message('doctor.workflow.unverified'), + action: catalog.message('doctor.workflow.unverifiedAction'), })]; } } -function remoteScopeFailure(configuration: SetupConfiguration): DoctorCheck { +function remoteScopeFailure( + configuration: SetupConfiguration, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck { return doctorCheck({ id: 'github.resource-scopes', status: usesOrganizationStorage(configuration) ? 'fail' : 'warn', - summary: 'GitHub Actions resource scopes could not be inspected.', - action: 'Check setup PAT access to repository and organization Actions metadata, then retry.', + summary: catalog.message('doctor.scope.unverified'), + action: catalog.message('doctor.scope.unverifiedAction'), }); } -function resourceScopeCheck(configuration: SetupConfiguration, remote: SetupRemoteConfiguration): DoctorCheck { +function resourceScopeCheck( + configuration: SetupConfiguration, + remote: SetupRemoteConfiguration, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck { const unavailable = remote.organizationAccess === 'unavailable' || remote.organizationSecretsAccess === 'unavailable' || remote.organizationVariablesAccess === 'unavailable'; @@ -274,10 +310,8 @@ function resourceScopeCheck(configuration: SetupConfiguration, remote: SetupRemo return doctorCheck({ id: 'github.resource-scopes', status, - summary: status === 'pass' - ? 'GitHub Actions resource scopes are readable.' - : 'Some organization-level GitHub Actions resource scopes are unavailable.', - ...(status === 'pass' ? {} : { action: 'Grant the setup PAT the required organization Actions metadata access.' }), + summary: catalog.message(status === 'pass' ? 'doctor.scope.readable' : 'doctor.scope.unavailable'), + ...(status === 'pass' ? {} : { action: catalog.message('doctor.scope.grantAction') }), evidence: { ownerType: remote.ownerType, repositoryVisibility: remote.repositoryVisibility, @@ -286,7 +320,11 @@ function resourceScopeCheck(configuration: SetupConfiguration, remote: SetupRemo }); } -function variableChecks(configuration: SetupConfiguration, remote: SetupRemoteConfiguration): DoctorCheck[] { +function variableChecks( + configuration: SetupConfiguration, + remote: SetupRemoteConfiguration, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck[] { const remoteVariables = new Map(); for (const variable of remote.organizationVariables) remoteVariables.set(variable.name, { value: variable.value, source: 'organization' }); for (const variable of remote.repositoryVariables) remoteVariables.set(variable.name, { value: variable.value, source: 'repository' }); @@ -304,23 +342,26 @@ function variableChecks(configuration: SetupConfiguration, remote: SetupRemoteCo id: `github.variables.${normalizedDoctorPathId(variable.name)}`, status, summary: !observed - ? 'Variable is missing.' + ? catalog.message('doctor.variable.missing') : matches - ? `Variable is configured at ${observed.source} scope.` + ? catalog.message('doctor.variable.configured', { scope: observed.source }) : preserveExisting - ? `Variable differs but is intentionally preserved at ${observed.source} scope.` - : 'Variable differs from the expected setup configuration.', - ...(status === 'pass' || status === 'warn' ? {} : { action: 'Run setup to reconcile this Variable.' }), + ? catalog.message('doctor.variable.preserved', { scope: observed.source }) + : catalog.message('doctor.variable.different'), + ...(status === 'pass' || status === 'warn' ? {} : { action: catalog.message('doctor.variable.action') }), evidence: { name: variable.name, present: observed !== undefined, matches, ...(observed ? { scope: observed.source } : {}) }, }); }); } -function secretNamesCheck(remote: SetupRemoteConfiguration): DoctorCheck { +function secretNamesCheck( + remote: SetupRemoteConfiguration, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck { return doctorCheck({ id: 'github.secret-names', status: 'pass', - summary: 'GitHub Actions Secret metadata is readable.', + summary: catalog.message('doctor.secretMetadata.readable'), evidence: { repositorySecretCount: remote.repositorySecrets.length, organizationSecretCount: remote.organizationSecrets.length, @@ -332,6 +373,7 @@ function buildCredentialChecks( requirements: readonly SetupCredentialRequirement[], remoteSecrets: ReadonlySet, health: readonly SetupCredentialCheck[] | undefined, + catalog: SetupDoctorMessageCatalog, ): DoctorCheck[] { const healthByName = new Map((health ?? []).map((check) => [check.name, check])); const reportedGroups = new Set(); @@ -350,16 +392,16 @@ function buildCredentialChecks( checks.push(doctorCheck({ id: `credential.${normalizedDoctorPathId(group)}`, status, - summary: available.length === 0 + summary: catalog.message(available.length === 0 ? runnerAllowed - ? 'No fallback Secret is configured; runner authentication must satisfy this group.' - : 'No alternative credential is present.' + ? 'doctor.credential.group.runnerRequired' + : 'doctor.credential.group.missing' : healthy - ? 'At least one alternative credential is healthy.' + ? 'doctor.credential.group.healthy' : invalid - ? 'Every available alternative credential is invalid.' - : 'An alternative credential is present, but remote health is unavailable.', - ...(status === 'pass' ? {} : { action: 'Configure and validate one credential for this requirement group.' }), + ? 'doctor.credential.group.invalid' + : 'doctor.credential.group.unverified'), + ...(status === 'pass' ? {} : { action: catalog.message('doctor.credential.group.action') }), evidence: { group, availableCount: available.length, runnerAuthenticationAllowed: runnerAllowed }, })); continue; @@ -370,31 +412,84 @@ function buildCredentialChecks( checks.push(doctorCheck({ id: `credential.${normalizedDoctorPathId(requirement.name)}`, status, - summary: !present ? 'Required Secret is missing.' : check?.message ?? 'Secret is present, but remote health is unavailable.', - ...(status === 'pass' ? {} : { action: `Configure or replace ${requirement.name}, then rerun credential health.` }), + summary: !present + ? catalog.message('doctor.credential.missing') + : catalog.message(check ? credentialHealthMessageId(check.status) : 'doctor.credential.unverified'), + ...(status === 'pass' ? {} : { + action: catalog.message('doctor.credential.action', { name: requirement.name }), + }), evidence: { name: requirement.name, present }, })); } return checks; } -function skippedRemoteChecks(configuration: SetupConfiguration, blocker: string): DoctorCheck[] { +function skippedRemoteChecks( + configuration: SetupConfiguration, + blocker: string, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck[] { return [ - skippedDoctorCheck('github.resource-scopes', [blocker], 'Resource-scope inspection requires a valid setup PAT.', 'Replace the setup PAT.'), - skippedDoctorCheck('github.merge-queue', [blocker], 'Merge-queue inspection requires a valid setup PAT.', 'Replace the setup PAT.'), - ...skippedResourceChecks(configuration, blocker), + skippedDoctorCheck( + 'github.resource-scopes', + [blocker], + catalog.message('doctor.skipped.scope'), + catalog.message('doctor.skipped.scopeAction'), + ), + skippedDoctorCheck( + 'github.merge-queue', + [blocker], + catalog.message('doctor.mergeQueue.skipped'), + catalog.message('doctor.skipped.scopeAction'), + ), + ...skippedResourceChecks(configuration, blocker, catalog), ]; } -function skippedResourceChecks(configuration: SetupConfiguration, blocker: string): DoctorCheck[] { +function skippedResourceChecks( + configuration: SetupConfiguration, + blocker: string, + catalog: SetupDoctorMessageCatalog, +): DoctorCheck[] { return [ - skippedDoctorCheck('github.variables', [blocker], 'Variable checks could not run.', 'Resolve the blocking check and rerun doctor.'), - skippedDoctorCheck('github.secret-names', [blocker], 'Secret metadata checks could not run.', 'Resolve the blocking check and rerun doctor.'), + skippedDoctorCheck( + 'github.variables', + [blocker], + catalog.message('doctor.skipped.variables'), + catalog.message('doctor.skipped.resourceAction'), + ), + skippedDoctorCheck( + 'github.secret-names', + [blocker], + catalog.message('doctor.skipped.secrets'), + catalog.message('doctor.skipped.resourceAction'), + ), ...buildSetupCredentialRequirements(configuration).map((requirement) => skippedDoctorCheck( `credential.${normalizedDoctorPathId(requirement.alternativeGroups?.[0] ?? requirement.name)}`, [blocker], - 'Credential health could not run.', - 'Resolve the blocking check and rerun doctor.', + catalog.message('doctor.skipped.credentials'), + catalog.message('doctor.skipped.resourceAction'), )).filter((check, index, all) => all.findIndex((candidate) => candidate.id === check.id) === index), ]; } + +function credentialHealthMessageId( + status: SetupCredentialCheck['status'], +): 'doctor.credential.valid' | 'doctor.credential.invalid' | 'doctor.credential.unverifiable' | 'doctor.credential.unverified' { + if (status === 'valid') return 'doctor.credential.valid'; + if (status === 'invalid') return 'doctor.credential.invalid'; + if (status === 'unverifiable') return 'doctor.credential.unverifiable'; + return 'doctor.credential.unverified'; +} + +function doctorCatalogLocale(configuration: SetupConfiguration): string { + try { + return resolveLocaleProfile( + configuration.repository.repositoryLocale, + configuration.repository.issueLocale, + configuration.repository.pullRequestLocale, + ).repository; + } catch { + return DEFAULT_REPOSITORY_LOCALE; + } +} diff --git a/src/application/usecases/setup/merge_queue_readiness_use_case.ts b/src/application/usecases/setup/merge_queue_readiness_use_case.ts index 77d4f4d24..663f1c01b 100644 --- a/src/application/usecases/setup/merge_queue_readiness_use_case.ts +++ b/src/application/usecases/setup/merge_queue_readiness_use_case.ts @@ -1,18 +1,34 @@ import type { TargetMergePolicyInspectionPort } from "../../ports/deployment_orchestration_ports"; import type { SetupMergeQueueReadinessPort, SetupMergeQueueReadinessRequest } from "../../ports/setup_wizard_ports"; -import { mergeQueueReadinessFailureMessage, selectPullRequestMode } from "../../policies/deployment_plan_policy"; +import { + mergeQueueReadinessFailureMessage, + pullRequestModeDecisionMessage, + selectPullRequestMode, +} from "../../policies/deployment_plan_policy"; import { evaluateMergeQueueReadiness, type MergeQueueTargetRole } from "../../../domain/merge_queue_readiness"; -import { redactSensitiveText } from "../../../domain/security/sensitive_text"; import type { DoctorCheck } from "../../../domain/setup"; import { doctorCheck, normalizedDoctorPathId } from "../../policies/setup_doctor_report_policy"; +import type { MessageCatalogResolutionPort } from '../../ports/message_catalog_ports'; +import { + resolveSetupDoctorCatalog, + type SetupDoctorMessageCatalog, +} from '../../policies/setup_doctor_message_catalog'; +import { producerStateMessageId } from '../../policies/merge_queue_message_catalog'; export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadinessPort { - constructor(private readonly targets: TargetMergePolicyInspectionPort) {} + constructor( + private readonly targets: TargetMergePolicyInspectionPort, + private readonly catalogResolver?: MessageCatalogResolutionPort, + ) {} async inspect(request: SetupMergeQueueReadinessRequest): Promise { if (request.configuration.features.release === false && request.configuration.features.hotfix === false) return []; + const catalog = request.catalog ?? await resolveSetupDoctorCatalog( + request.configuration.repository.repositoryLocale, + request.configuration.agents.planner, + this.catalogResolver, + ); const configuredMode = request.configuration.repository.reconciliationPullRequestMode; - const spanish = request.configuration.repository.issueLocale.toLowerCase().startsWith("es"); const targets = uniqueTargets([ { role: "production", branch: request.configuration.repository.mainBranch }, { role: "development", branch: request.configuration.repository.developmentBranch }, @@ -38,8 +54,8 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness return [doctorCheck({ id, status: "fail", - summary: decision.reason, - action: "Configure a supported merge-queue producer or choose another reconciliation mode.", + summary: pullRequestModeDecisionMessage(decision, catalog), + action: catalog.message('doctor.mergeQueue.configureSupportedAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, })]; } @@ -54,18 +70,16 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness return [doctorCheck({ id, status: "fail", - summary: mergeQueueReadinessFailureMessage(readiness, request.configuration.repository.issueLocale), - action: "Repair or attest every required merge-queue producer.", + summary: mergeQueueReadinessFailureMessage(readiness, catalog), + action: catalog.message('doctor.mergeQueue.repairOrAttestAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } if (decision.mode !== "merge-queue") { return [doctorCheck({ id, status: "pass", - summary: spanish - ? `El modo seleccionado es ${decision.mode}; este destino no necesita evidencia de productores de merge queue.` - : `Selected mode is ${decision.mode}; merge-queue producer evidence is not required for this target.`, + summary: catalog.message('doctor.mergeQueue.selectedMode', { mode: decision.mode }), evidence: { targetRole: target.role, targetBranch: target.branch, selectedMode: decision.mode }, })]; } @@ -81,27 +95,25 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness return [doctorCheck({ id, status: "fail", - summary: mergeQueueReadinessFailureMessage(readiness, request.configuration.repository.issueLocale), - action: "Repair or attest every required merge-queue producer.", + summary: mergeQueueReadinessFailureMessage(readiness, catalog), + action: catalog.message('doctor.mergeQueue.repairOrAttestAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } const verified = readiness.producers.filter((producer) => producer.verdict === "verified").length; const attested = readiness.producers.filter((producer) => producer.verdict === "attested").length; return [doctorCheck({ id, status: "pass", - summary: spanish - ? `Listo. ${verified} productor(es) requerido(s) verificados automáticamente y ${attested} cubiertos por atestación exacta.` - : `Ready. ${verified} required producer(s) verified automatically and ${attested} covered by exact attestation.`, + summary: catalog.message('doctor.mergeQueue.ready', { verified, attested }), evidence: { targetRole: target.role, targetBranch: target.branch, verified, attested }, - }), ...producerChecks(readiness.producers, target.role, spanish)]; + }), ...producerChecks(readiness.producers, target.role, catalog)]; } catch { return [doctorCheck({ id, status: "fail", - summary: "Target policy could not be inspected because the provider request failed.", - action: "Check the setup PAT permissions and target branch policy, then retry.", + summary: catalog.message('doctor.mergeQueue.targetUnverified'), + action: catalog.message('doctor.mergeQueue.targetUnverifiedAction'), evidence: { targetRole: target.role, targetBranch: target.branch }, })]; } @@ -111,9 +123,7 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness checks.push(doctorCheck({ id: "github.merge-queue.active-release", status: "warn", - summary: spanish - ? "Las ramas de release activas se descubren dinámicamente y se revalidan antes de crear una rama o PR de reconciliación de hotfix." - : "Active release branches are discovered dynamically and are revalidated before a hotfix reconciliation branch or PR is created.", + summary: catalog.message('doctor.mergeQueue.activeRelease'), evidence: { dynamicTarget: true }, })); } @@ -122,10 +132,8 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness checks.push(doctorCheck({ id: `github.merge-queue.attestation.${normalizedDoctorPathId(`${attestation.context}-${attestation.integrationId}`)}`, status: "warn", - summary: spanish - ? "Esta atestación exacta no coincide con ningún check requerido observado en producción o desarrollo." - : "This exact attestation does not match a required check observed on production or development.", - action: "Remove the stale attestation or correct its exact check identity.", + summary: catalog.message('doctor.mergeQueue.staleAttestation'), + action: catalog.message('doctor.mergeQueue.staleAttestationAction'), evidence: { context: attestation.context, integrationId: String(attestation.integrationId) }, })); } @@ -137,15 +145,18 @@ export class SetupMergeQueueReadinessUseCase implements SetupMergeQueueReadiness function producerChecks( producers: readonly import("../../../domain/merge_queue_readiness").MergeQueueEvaluatedProducer[], role: MergeQueueTargetRole, - spanish: boolean, + catalog: SetupDoctorMessageCatalog, ): DoctorCheck[] { return producers.map((producer) => doctorCheck({ id: `github.merge-queue.${role}.producer.${normalizedDoctorPathId(`${producer.name}-${producer.integrationId ?? 'workflow'}`)}`, status: producer.verdict === "verified" || producer.verdict === "attested" ? "pass" : "fail", - summary: `${producer.verdict}: ${safeDiagnostic(producer.reason)}${spanish && producer.verdict === "attested" ? " (atestación exacta revisada)" : ""}`, + summary: catalog.message('doctor.mergeQueue.producer', { + verdict: producer.verdict, + state: catalog.message(producerStateMessageId(producer.verdict)), + }), ...(producer.verdict === "verified" || producer.verdict === "attested" ? {} - : { action: "Configure or exactly attest this required producer." }), + : { action: catalog.message('doctor.mergeQueue.producerAction') }), evidence: { targetRole: role, producer: producer.name, verdict: producer.verdict }, })); } @@ -159,11 +170,3 @@ function uniqueTargets(targets: readonly { role: MergeQueueTargetRole; branch: s return true; }); } - -function safeDiagnostic(message: string): string { - return redactSensitiveText(message) - .replace(/[\r\n<>]/g, " ") - .replace(/::/g, "﹕﹕") - .replace(/@/g, "@\u200b") - .slice(0, 240); -} diff --git a/src/application/usecases/setup/setup_wizard_use_case.ts b/src/application/usecases/setup/setup_wizard_use_case.ts index aa6d42b0c..6d9dff559 100644 --- a/src/application/usecases/setup/setup_wizard_use_case.ts +++ b/src/application/usecases/setup/setup_wizard_use_case.ts @@ -28,6 +28,7 @@ import { finishSetupQuestionnaire, } from '../../policies/setup_questionnaire_policy'; import { cloneSetupConfiguration } from '../../policies/setup_configuration_clone_policy'; +import { resolveStaticSetupDoctorCatalog } from '../../policies/setup_doctor_message_catalog'; export interface SetupWizardRequest { mode: 'interactive' | 'non-interactive'; @@ -122,6 +123,9 @@ export class SetupWizardUseCase { repository: request.remoteTarget.repository, token: request.remoteTarget.token, configuration, + // Setup is the profile-creation surface, so its one artifact remains + // authoritative English until the repository profile is installed. + catalog: resolveStaticSetupDoctorCatalog(), }) : []; const plan = buildSetupPlan(configuration, readiness, migrationWarnings); diff --git a/src/architecture/__tests__/github_publication_boundaries.test.ts b/src/architecture/__tests__/github_publication_boundaries.test.ts index 89cbe0094..814cd6ed5 100644 --- a/src/architecture/__tests__/github_publication_boundaries.test.ts +++ b/src/architecture/__tests__/github_publication_boundaries.test.ts @@ -88,12 +88,17 @@ describe('GitHub conversation publication boundaries', () => { 'src/application/policies/semantic_result_publication_policy.ts', 'src/application/policies/status_command_policy.ts', 'src/application/policies/deployment_presentation_policy.ts', + 'src/application/policies/deployment_plan_policy.ts', + 'src/application/policies/setup_doctor_report_policy.ts', + 'src/application/usecases/setup/doctor_use_case.ts', + 'src/application/usecases/setup/merge_queue_readiness_use_case.ts', 'src/application/usecases/steps/common/reply_publication_workflow.ts', 'src/application/usecases/steps/common/status_card_publication_workflow.ts', 'src/application/usecases/steps/commit/bugbot/publish_overflow_comment.ts', 'src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts', 'src/application/usecases/steps/commit/bugbot/resolve_issue_finding.ts', 'src/application/usecases/steps/commit/bugbot/resolve_pull_request_finding.ts', + 'src/cli/setup_doctor_presenter.ts', ]; const featureLocalLocaleBranch = /baseLanguage|startsWith\(['"](?:en|es)|===?\s*['"](?:en|es|en-US|es-ES)['"]/u; const violations = files.filter(file => featureLocalLocaleBranch.test(readFileSync(join(root, file), 'utf8'))); diff --git a/src/architecture/__tests__/setup_doctor_boundaries.test.ts b/src/architecture/__tests__/setup_doctor_boundaries.test.ts index d4643373c..1cc4ce161 100644 --- a/src/architecture/__tests__/setup_doctor_boundaries.test.ts +++ b/src/architecture/__tests__/setup_doctor_boundaries.test.ts @@ -33,12 +33,38 @@ describe('setup and doctor architecture boundaries', () => { it('keeps questionnaire and report policies free of runtime/provider imports', () => { for (const file of [ 'src/application/policies/setup_questionnaire_policy.ts', + 'src/application/policies/merge_queue_message_catalog.ts', + 'src/application/policies/setup_doctor_message_catalog.ts', 'src/application/policies/setup_doctor_report_policy.ts', ]) { const source = read(file); expect(source).not.toMatch(/from ['"]node:|\/cli\/|\/infrastructure\/|octokit|Execution/); } }); + + it('resolves one doctor catalog and reuses it through readiness and presentation', () => { + const doctor = read('src/application/usecases/setup/doctor_use_case.ts'); + const command = read('src/cli/commands/doctor.ts'); + const readiness = read('src/application/usecases/setup/merge_queue_readiness_use_case.ts'); + const setup = read('src/application/usecases/setup/setup_wizard_use_case.ts'); + + expect(doctor).toContain('resolveSetupDoctorCatalog('); + expect(doctor).toMatch(/mergeQueueReadiness\.inspect\([\s\S]*?catalog,/u); + expect(command).toContain('new SetupDoctorPresenter(diagnosis.catalog)'); + expect(readiness).toContain('request.catalog ?? await resolveSetupDoctorCatalog('); + expect(setup).toMatch(/catalog: resolveStaticSetupDoctorCatalog\(\)/u); + }); + + it('keeps raw provider diagnostics out of doctor and readiness presentation', () => { + const sources = [ + 'src/application/usecases/setup/doctor_use_case.ts', + 'src/application/usecases/setup/merge_queue_readiness_use_case.ts', + 'src/application/policies/merge_queue_message_catalog.ts', + 'src/cli/setup_doctor_presenter.ts', + ].map(read).join('\n'); + + expect(sources).not.toMatch(/producer\.reason|problem\.message|result\.message|check\.message/u); + }); }); function read(relativePath: string): string { diff --git a/src/cli/__tests__/setup_presenters.test.ts b/src/cli/__tests__/setup_presenters.test.ts index 9eff7f236..586bde6db 100644 --- a/src/cli/__tests__/setup_presenters.test.ts +++ b/src/cli/__tests__/setup_presenters.test.ts @@ -7,6 +7,7 @@ import { SetupDoctorPresenter, renderDoctorReport, doctorCheckLabel } from '../s import { ConsoleSetupPlanPresenter, renderSetupPlan } from '../setup_plan_presenter'; import { ConsoleSetupQuestionRenderer } from '../setup_question_renderer'; import { SetupWorkflowUpdatePromptAdapter } from '../setup_workflow_update_prompt_adapter'; +import { resolveStaticSetupDoctorCatalog } from '../../application/policies/setup_doctor_message_catalog'; function terminal(results: readonly TerminalReadResult[]): jest.Mocked { let index = 0; @@ -42,6 +43,31 @@ describe('setup presenters and prompt-specific adapters', () => { log.mockRestore(); }); + it('renders every doctor presentation label in the resolved repository locale', () => { + const catalog = resolveStaticSetupDoctorCatalog('es-ES'); + const rendered = renderDoctorReport(buildDoctorReport([ + doctorCheck({ + id: 'credentials.setup-pat', + status: 'fail', + summary: catalog.message('doctor.setupPat.invalid'), + action: catalog.message('doctor.setupPat.replaceAction'), + }), + skippedDoctorCheck( + 'github.variables', + ['credentials.setup-pat'], + catalog.message('doctor.skipped.variables'), + ), + ]), catalog); + + expect(rendered).toContain('Diagnóstico de Copilot — diagnóstico parcial'); + expect(rendered).toContain('FALLO'); + expect(rendered).toContain('OMITIDO'); + expect(rendered).toContain('Bloqueado por: credentials.setup-pat'); + expect(rendered).toContain('Acción: Sustituye el PAT de setup'); + expect(rendered).toContain('No se ha cambiado la configuración del repositorio.'); + expect(doctorCheckLabel('locale.repository', catalog)).toBe('Locale del repositorio'); + }); + it.each([ ['configuration.valid', 'Configuration'], ['workspace.repository-root', 'Repository root'], diff --git a/src/cli/__tests__/setup_prompt_rendering.test.ts b/src/cli/__tests__/setup_prompt_rendering.test.ts index 6101e753b..4b41d95a5 100644 --- a/src/cli/__tests__/setup_prompt_rendering.test.ts +++ b/src/cli/__tests__/setup_prompt_rendering.test.ts @@ -1,5 +1,6 @@ import { color, + displayWidth, doctorIcon, formatTask, renderBox, @@ -54,6 +55,16 @@ describe('setup prompt rendering', () => { expect(rendered).toContain('diagnostic sentence'); }); + it('wraps unspaced CJK and spaced right-to-left copy by visible terminal width', () => { + const cjk = renderBox('診断結果を安全に表示して次の操作を明確に案内します', '診断', 32, 32); + const rtl = renderBox('حالة التشخيص الحالية تتطلب إجراء واضحا وآمنا', 'التشخيص', 32, 32); + + expect(cjk.split('\n').every((line) => displayWidth(line) <= 32)).toBe(true); + expect(rtl.split('\n').every((line) => displayWidth(line) <= 32)).toBe(true); + expect(cjk.split('\n').length).toBeGreaterThan(3); + expect(rtl.split('\n').length).toBeGreaterThan(3); + }); + it('renders remote metadata without exposing credential values', () => { const rendered = renderRemoteConfiguration( { diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index ebff73db3..b6e0a7933 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -34,14 +34,14 @@ export function registerDoctorCommand(program: Command): void { const overrides = options.config ? loadSetupConfigurationOverrides(options.config) : {}; const expected = mergeSetupConfiguration(createDefaultSetupConfiguration(), overrides); logInfo(`🩺 Checking Copilot configuration for ${gitInfo.owner}/${gitInfo.repo}...`); - const report = await createSetupDoctorUseCase().execute({ + const diagnosis = await createSetupDoctorUseCase().execute({ owner: gitInfo.owner, repository: gitInfo.repo, setupToken: token, configuration: expected, }); - new SetupDoctorPresenter().present(report); - if (!report.healthy) process.exitCode = 1; + new SetupDoctorPresenter(diagnosis.catalog).present(diagnosis.report); + if (!diagnosis.report.healthy) process.exitCode = 1; } catch (error) { if (error instanceof SetupTerminalCancelledError) { logInfo('Doctor cancelled. No repository configuration was changed.'); diff --git a/src/cli/setup_doctor_presenter.ts b/src/cli/setup_doctor_presenter.ts index 9d3ccdc2a..937d6c56c 100644 --- a/src/cli/setup_doctor_presenter.ts +++ b/src/cli/setup_doctor_presenter.ts @@ -1,44 +1,72 @@ import type { DoctorCheck, DoctorReport } from '../domain/setup'; +import type { SetupDoctorMessageCatalog } from '../application/policies/setup_doctor_message_catalog'; +import { resolveStaticSetupDoctorCatalog } from '../application/policies/setup_doctor_message_catalog'; import { doctorIcon, renderBox } from './setup_prompt_rendering'; export class SetupDoctorPresenter { + constructor( + private readonly catalog: SetupDoctorMessageCatalog = resolveStaticSetupDoctorCatalog(), + ) {} + present(report: DoctorReport): void { - console.log(renderDoctorReport(report)); + console.log(renderDoctorReport(report, this.catalog)); } } -export function renderDoctorReport(report: DoctorReport): string { +export function renderDoctorReport( + report: DoctorReport, + catalog: SetupDoctorMessageCatalog = resolveStaticSetupDoctorCatalog(), +): string { const partial = report.totals.skipped > 0 || report.totals.warn > 0; - const title = partial ? 'Copilot Doctor — partial diagnosis' : 'Copilot Doctor'; - const checks = report.checks.flatMap((check) => renderCheck(check)); - const summary = `Checks: ${report.totals.pass} pass, ${report.totals.warn} warn, ${report.totals.fail} fail, ${report.totals.skipped} skipped.`; - const mutation = 'No repository configuration was changed.'; + const title = catalog.message(partial ? 'doctor.title.partial' : 'doctor.title'); + const checks = report.checks.flatMap((check) => renderCheck(check, catalog)); + const summary = catalog.message('doctor.summary', report.totals); + const mutation = catalog.message('doctor.noMutation'); return renderBox([...checks, '', summary, mutation].join('\n'), title, report.healthy ? 32 : 31); } -function renderCheck(check: DoctorCheck): string[] { - const status = check.status === 'skipped' ? 'SKIP' : check.status.toUpperCase(); - const lines = [` ${doctorIcon(check.status)} ${status.padEnd(4)} ${doctorCheckLabel(check.id)} — ${check.summary}`]; - if (check.blockedBy.length > 0) lines.push(` Blocked by: ${check.blockedBy.join(', ')}`); - if (check.action) lines.push(` Action: ${check.action}`); +function renderCheck(check: DoctorCheck, catalog: SetupDoctorMessageCatalog): string[] { + const status = catalog.message(`doctor.status.${check.status}`); + const lines = [` ${doctorIcon(check.status)} ${status.padEnd(7)} ${doctorCheckLabel(check.id, catalog)} — ${check.summary}`]; + if (check.blockedBy.length > 0) { + lines.push(` ${catalog.message('doctor.blockedBy', { checks: check.blockedBy.join(', ') })}`); + } + if (check.action) lines.push(` ${catalog.message('doctor.action', { action: check.action })}`); return lines; } -export function doctorCheckLabel(id: string): string { - if (id === 'configuration.valid') return 'Configuration'; - if (id === 'workspace.repository-root') return 'Repository root'; - if (id === 'credentials.setup-pat') return 'Setup PAT'; - if (id === 'github.resource-scopes') return 'GitHub Actions scopes'; - if (id === 'github.secret-names') return 'Repository Secrets'; - if (id === 'github.variables') return 'Repository Variables'; - if (id === 'github.merge-queue') return 'Merge queue'; - if (id === 'locale.profile') return 'Locale profile'; - if (id === 'locale.repository') return 'Repository locale'; - if (id === 'locale.issue') return 'Issue locale'; - if (id === 'locale.pull-request') return 'Pull-request locale'; - if (id.startsWith('workflow.')) return `Workflow ${id.slice('workflow.'.length)}`; - if (id.startsWith('github.variables.')) return `Variable ${id.slice('github.variables.'.length).toUpperCase().replace(/-/g, '_')}`; - if (id.startsWith('credential.')) return `Credential ${id.slice('credential.'.length).toUpperCase().replace(/-/g, '_')}`; - if (id.startsWith('github.merge-queue.')) return `Merge queue ${id.slice('github.merge-queue.'.length)}`; +export function doctorCheckLabel( + id: string, + catalog: SetupDoctorMessageCatalog = resolveStaticSetupDoctorCatalog(), +): string { + if (id === 'configuration.valid') return catalog.message('doctor.label.configuration'); + if (id === 'workspace.repository-root') return catalog.message('doctor.label.repositoryRoot'); + if (id === 'credentials.setup-pat') return catalog.message('doctor.label.setupPat'); + if (id === 'github.resource-scopes') return catalog.message('doctor.label.githubScopes'); + if (id === 'github.secret-names') return catalog.message('doctor.label.repositorySecrets'); + if (id === 'github.variables') return catalog.message('doctor.label.repositoryVariables'); + if (id === 'github.merge-queue') return catalog.message('doctor.label.mergeQueue'); + if (id === 'locale.profile') return catalog.message('doctor.label.localeProfile'); + if (id === 'locale.repository') return catalog.message('doctor.label.repositoryLocale'); + if (id === 'locale.issue') return catalog.message('doctor.label.issueLocale'); + if (id === 'locale.pull-request') return catalog.message('doctor.label.pullRequestLocale'); + if (id.startsWith('workflow.')) { + return catalog.message('doctor.label.workflow', { id: id.slice('workflow.'.length) }); + } + if (id.startsWith('github.variables.')) { + return catalog.message('doctor.label.variable', { + name: id.slice('github.variables.'.length).toUpperCase().replace(/-/g, '_'), + }); + } + if (id.startsWith('credential.')) { + return catalog.message('doctor.label.credential', { + name: id.slice('credential.'.length).toUpperCase().replace(/-/g, '_'), + }); + } + if (id.startsWith('github.merge-queue.')) { + return catalog.message('doctor.label.mergeQueueTarget', { + target: id.slice('github.merge-queue.'.length), + }); + } return id; } diff --git a/src/cli/setup_prompt_rendering.ts b/src/cli/setup_prompt_rendering.ts index 395ae7a37..f4a94cc1c 100644 --- a/src/cli/setup_prompt_rendering.ts +++ b/src/cli/setup_prompt_rendering.ts @@ -32,35 +32,82 @@ export function renderBox(content: string, title: string, borderCode = 36, maxim const contentWidth = Math.max(20, Math.min(120, maximumWidth) - 4); const wrapped = content.split('\n').flatMap((line) => wrapLine(line, contentWidth)); const lines = [` ${title} `, ...wrapped.map(line => ` ${line}`)]; - const width = Math.max(...lines.map(line => stripAnsi(line).length)) + 1; + const width = Math.max(...lines.map(displayWidth)) + 1; const border = color(`╭${'─'.repeat(width)}╮`, borderCode); const bottom = color(`╰${'─'.repeat(width)}╯`, borderCode); return [ border, - ...lines.map(line => `${color('│', borderCode)}${line}${' '.repeat(Math.max(0, width - stripAnsi(line).length))}${color('│', borderCode)}`), + ...lines.map(line => `${color('│', borderCode)}${line}${' '.repeat(Math.max(0, width - displayWidth(line)))}${color('│', borderCode)}`), bottom, ].join('\n'); } function wrapLine(line: string, maximumWidth: number): string[] { - if (stripAnsi(line).length <= maximumWidth) return [line]; + if (displayWidth(line) <= maximumWidth) return [line]; const indent = line.match(/^\s*/)?.[0] ?? ''; const words = line.trim().split(/\s+/); const lines: string[] = []; let current = indent; for (const word of words) { const candidate = current.trim() ? `${current} ${word}` : `${indent}${word}`; - if (stripAnsi(candidate).length <= maximumWidth) { + if (displayWidth(candidate) <= maximumWidth) { current = candidate; continue; } if (current.trim()) lines.push(current); - current = `${indent}${word}`; + const chunks = splitVisibleToken(word, Math.max(1, maximumWidth - displayWidth(indent))); + for (const chunk of chunks.slice(0, -1)) lines.push(`${indent}${chunk}`); + current = `${indent}${chunks.at(-1) ?? ''}`; } if (current.trim() || lines.length === 0) lines.push(current); return lines; } +function splitVisibleToken(value: string, maximumWidth: number): string[] { + const characters = [...stripAnsi(value)]; + const chunks: string[] = []; + let current = ''; + let width = 0; + for (const character of characters) { + const characterWidth = displayWidth(character); + if (current && width + characterWidth > maximumWidth) { + chunks.push(current); + current = ''; + width = 0; + } + current += character; + width += characterWidth; + } + if (current || chunks.length === 0) chunks.push(current); + return chunks; +} + +export function displayWidth(value: string): number { + return [...stripAnsi(value)].reduce((width, character) => { + if (/\p{Mark}/u.test(character)) return width; + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 31 || (codePoint >= 127 && codePoint <= 159)) return width; + return width + (isWideCodePoint(codePoint) ? 2 : 1); + }, 0); +} + +function isWideCodePoint(codePoint: number): boolean { + return codePoint >= 0x1100 && ( + codePoint <= 0x115f + || codePoint === 0x2329 + || codePoint === 0x232a + || (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) + || (codePoint >= 0xac00 && codePoint <= 0xd7a3) + || (codePoint >= 0xf900 && codePoint <= 0xfaff) + || (codePoint >= 0xfe10 && codePoint <= 0xfe19) + || (codePoint >= 0xfe30 && codePoint <= 0xfe6f) + || (codePoint >= 0xff00 && codePoint <= 0xff60) + || (codePoint >= 0xffe0 && codePoint <= 0xffe6) + || (codePoint >= 0x1f300 && codePoint <= 0x1faff) + || (codePoint >= 0x20000 && codePoint <= 0x3fffd) + ); +} + export function renderRemoteConfiguration( remote: SetupRemoteConfiguration, variables: readonly SetupVariable[], diff --git a/src/infrastructure/composition/setup_doctor_composition_root.ts b/src/infrastructure/composition/setup_doctor_composition_root.ts index 7f6382cde..a41d68746 100644 --- a/src/infrastructure/composition/setup_doctor_composition_root.ts +++ b/src/infrastructure/composition/setup_doctor_composition_root.ts @@ -8,20 +8,28 @@ import { OctokitCredentialHealthClientAdapter } from '../github/octokit_credenti import { GithubTargetMergeCapabilitiesInspector } from '../../data/repository/deployment/github_target_merge_capabilities_inspector'; import { OctokitDeploymentClientAdapter } from '../github/octokit_deployment_adapter'; import { SetupMergeQueueReadinessUseCase } from '../../application/usecases/setup/merge_queue_readiness_use_case'; +import { ResolveMessageCatalogUseCase } from '../../application/usecases/localization/resolve_message_catalog_use_case'; +import { createLanguageQueryPort } from './agent_capability_composition_root'; +import type { MessageCatalogResolutionPort } from '../../application/ports/message_catalog_ports'; -export function createSetupMergeQueueReadinessUseCase(): SetupMergeQueueReadinessUseCase { +export function createSetupMergeQueueReadinessUseCase( + catalogResolver: MessageCatalogResolutionPort = new ResolveMessageCatalogUseCase(createLanguageQueryPort()), +): SetupMergeQueueReadinessUseCase { return new SetupMergeQueueReadinessUseCase( new GithubTargetMergeCapabilitiesInspector(new OctokitDeploymentClientAdapter()), + catalogResolver, ); } export function createSetupDoctorUseCase(): SetupDoctorUseCase { const repositoryConfiguration = new SetupRemoteConfigurationQueryRepository(createRepositoryVariablesClient()); + const catalogResolver = new ResolveMessageCatalogUseCase(createLanguageQueryPort()); return new SetupDoctorUseCase({ validation: new SetupCredentialValidationAdapter(), workspace: new SetupDoctorWorkspaceQueryAdapter(), remoteConfiguration: repositoryConfiguration, remoteHealth: new SetupRemoteCredentialHealthQueryAdapter(new OctokitCredentialHealthClientAdapter()), - mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(), + mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(catalogResolver), + catalogResolver, }); } From 34a69845e136fadf6c7879c757dee4052c9bed8b Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 23:37:18 +0200 Subject: [PATCH 2/3] codex-localized-setup-doctor: harden localized doctor diagnostics --- build/cli/index.js | 4 +- src/__tests__/cli.test.ts | 44 +++++++++++++++++++ .../deployment_orchestration_use_case.test.ts | 15 +++++++ .../setup/__tests__/doctor_use_case.test.ts | 16 +++++++ .../usecases/setup/doctor_use_case.ts | 9 +++- .../__tests__/setup_prompt_rendering.test.ts | 18 ++++++++ .../setup_doctor_composition_root.test.ts | 20 +++++++++ 7 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/infrastructure/composition/__tests__/setup_doctor_composition_root.test.ts diff --git a/build/cli/index.js b/build/cli/index.js index 1f2c4f504..4db1eab5a 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -52388,7 +52388,9 @@ class SetupDoctorUseCase { } checks.push(resourceScopeCheck(request.configuration, remoteConfiguration, catalog)); checks.push(...mergeQueueChecks); - checks.push(...variableChecks(request.configuration, remoteConfiguration, catalog)); + checks.push(...(configurationErrors.length > 0 + ? [(0, setup_doctor_report_policy_1.skippedDoctorCheck)('github.variables', ['configuration.valid'], catalog.message('doctor.skipped.variables'), catalog.message('doctor.skipped.resourceAction'))] + : variableChecks(request.configuration, remoteConfiguration, catalog))); checks.push(secretNamesCheck(remoteConfiguration, catalog)); checks.push(...await this.credentialChecks(request, remoteConfiguration, catalog)); return { report: (0, setup_doctor_report_policy_1.buildDoctorReport)(checks), catalog }; diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 044e11aae..38f62507b 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -46,6 +46,20 @@ jest.mock('../utils/setup_files', () => { }; }); +const mockDoctorExecute = jest.fn(); +const mockDoctorPresent = jest.fn(); +const mockDoctorPresenter = jest.fn(); +jest.mock('../infrastructure/composition/setup_doctor_composition_root', () => ({ + createSetupDoctorUseCase: () => ({ execute: mockDoctorExecute }), + createSetupMergeQueueReadinessUseCase: () => ({ inspect: jest.fn().mockResolvedValue([]) }), +})); +jest.mock('../cli/setup_doctor_presenter', () => ({ + SetupDoctorPresenter: jest.fn().mockImplementation((...args: unknown[]) => { + mockDoctorPresenter(...args); + return { present: mockDoctorPresent }; + }), +})); + jest.mock('../infrastructure/composition/setup_credentials_composition_root', () => ({ createSetupCredentialsUseCase: () => ({ collect: jest.fn().mockResolvedValue({ collection: { apiKeys: [] }, checks: [], existingSecretNames: [] }) }), createSetupRemoteConfigurationReadPort: () => ({ @@ -87,6 +101,10 @@ describe('CLI', () => { override?.trim() && override.trim().length >= 20 ? override.trim() : undefined ); mockSetupEnvFileExists.mockReturnValue(false); + mockDoctorExecute.mockResolvedValue({ + catalog: { locale: 'en-US', message: jest.fn() }, + report: { healthy: true, checks: [], totals: { pass: 0, warn: 0, fail: 0, skipped: 0 } }, + }); }); afterEach(() => { @@ -145,6 +163,32 @@ describe('CLI', () => { }); }); + describe('doctor', () => { + it('presents the report with its resolved catalog and returns a failing exit code when unhealthy', async () => { + const catalog = { locale: 'es-ES', message: jest.fn() }; + const report = { healthy: false, checks: [], totals: { pass: 0, warn: 0, fail: 1, skipped: 0 } }; + mockDoctorExecute.mockResolvedValueOnce({ catalog, report }); + + await program.parseAsync([ + 'node', + 'cli', + 'doctor', + '--non-interactive', + '--token', + 'github_pat_doctor_test_token', + ]); + + expect(mockDoctorExecute).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'test-owner', + repository: 'test-repo', + setupToken: 'github_pat_doctor_test_token', + })); + expect(mockDoctorPresenter).toHaveBeenCalledWith(catalog); + expect(mockDoctorPresent).toHaveBeenCalledWith(report); + expect(process.exitCode).toBe(1); + }); + }); + describe('do', () => { it('calls the fixer port and logs response', async () => { const logSpy = jest.spyOn(console, 'log').mockImplementation(); diff --git a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts index d54cea097..6526601c4 100644 --- a/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/deployment_orchestration_use_case.test.ts @@ -488,6 +488,21 @@ describe("DeploymentOrchestrationUseCase", () => { .toContain("Restaura el acceso de lectura"); }); + it('publishes a semantic reason when explicitly requested auto-merge is unavailable', async () => { + const value = harness(); + value.targetRules.getTargetCapabilities.mockResolvedValue(capabilities({ autoMergeAllowed: false })); + const input = execution('prepare'); + input.deployment = { ...input.deployment, reconciliationPullRequestMode: 'auto-merge' }; + + const result = await value.useCase.invoke(input); + + expect(result[0].success).toBe(false); + expect(value.pullRequests.createManagedPullRequest).not.toHaveBeenCalled(); + expect(input.currentConfiguration.deploymentOrchestration?.lastFailure?.message) + .toBe('Native auto-merge is disabled for this repository.'); + expect(value.catalogResolve).toHaveBeenCalledTimes(1); + }); + it("blocks a promotion PR closed without merge and never dispatches publication", async () => { const value = harness(); value.pullRequests.findManagedPullRequests.mockResolvedValue([pr({ state: "closed" })]); diff --git a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts index 499f6aac4..0d27be6b0 100644 --- a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts @@ -143,6 +143,22 @@ describe('SetupDoctorUseCase', () => { expect(query).toHaveBeenCalledTimes(1); }); + it('uses the English catalog when the configured repository locale is invalid', async () => { + const configuration = createDefaultSetupConfiguration(); + const deps = dependencies(configuration); + configuration.repository.repositoryLocale = 'x-private'; + + const diagnosis = await new SetupDoctorUseCase(deps).execute(request(configuration)); + + expect(diagnosis.catalog).toMatchObject({ locale: 'en-US', resolutionSource: 'exact' }); + expect(diagnosis.report.checks.find((check) => check.id === 'locale.profile')) + .toEqual(expect.objectContaining({ status: 'skipped', blockedBy: ['configuration.valid'] })); + expect(diagnosis.report.checks.find((check) => check.id === 'github.variables')) + .toEqual(expect.objectContaining({ status: 'skipped', blockedBy: ['configuration.valid'] })); + expect(diagnosis.report.checks.find((check) => check.id === 'configuration.valid')?.summary) + .toMatch(/^Setup configuration has \d+ validation error/); + }); + it('preserves an independent merge-queue result when resource inspection fails', async () => { const configuration = createDefaultSetupConfiguration(); const queueCheck = { diff --git a/src/application/usecases/setup/doctor_use_case.ts b/src/application/usecases/setup/doctor_use_case.ts index b77dc31e8..d53289090 100644 --- a/src/application/usecases/setup/doctor_use_case.ts +++ b/src/application/usecases/setup/doctor_use_case.ts @@ -149,7 +149,14 @@ export class SetupDoctorUseCase { checks.push(resourceScopeCheck(request.configuration, remoteConfiguration, catalog)); checks.push(...mergeQueueChecks); - checks.push(...variableChecks(request.configuration, remoteConfiguration, catalog)); + checks.push(...(configurationErrors.length > 0 + ? [skippedDoctorCheck( + 'github.variables', + ['configuration.valid'], + catalog.message('doctor.skipped.variables'), + catalog.message('doctor.skipped.resourceAction'), + )] + : variableChecks(request.configuration, remoteConfiguration, catalog))); checks.push(secretNamesCheck(remoteConfiguration, catalog)); checks.push(...await this.credentialChecks(request, remoteConfiguration, catalog)); return { report: buildDoctorReport(checks), catalog }; diff --git a/src/cli/__tests__/setup_prompt_rendering.test.ts b/src/cli/__tests__/setup_prompt_rendering.test.ts index 4b41d95a5..340e9b370 100644 --- a/src/cli/__tests__/setup_prompt_rendering.test.ts +++ b/src/cli/__tests__/setup_prompt_rendering.test.ts @@ -7,6 +7,7 @@ import { renderRemoteConfiguration, statusIcon, } from '../setup_prompt_rendering'; +import { stdout } from 'node:process'; describe('setup prompt rendering', () => { it.each([ @@ -34,6 +35,23 @@ describe('setup prompt rendering', () => { expect(color('text', 36)).toBe('text'); }); + it('colors TTY output while visible width ignores ANSI, combining marks, and controls', () => { + const descriptor = Object.getOwnPropertyDescriptor(stdout, 'isTTY'); + const previousNoColor = process.env.NO_COLOR; + Object.defineProperty(stdout, 'isTTY', { configurable: true, value: true }); + delete process.env.NO_COLOR; + + try { + expect(color('text', 36)).toBe('\u001b[36mtext\u001b[0m'); + expect(displayWidth('\u001b[36me\u0301🙂\u0007\u001b[0m')).toBe(3); + } finally { + if (descriptor) Object.defineProperty(stdout, 'isTTY', descriptor); + else Reflect.deleteProperty(stdout, 'isTTY'); + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + } + }); + it('renders a bordered box with a title and content', () => { const rendered = renderBox('first\nsecond', 'Setup', 32); diff --git a/src/infrastructure/composition/__tests__/setup_doctor_composition_root.test.ts b/src/infrastructure/composition/__tests__/setup_doctor_composition_root.test.ts new file mode 100644 index 000000000..81d0e7684 --- /dev/null +++ b/src/infrastructure/composition/__tests__/setup_doctor_composition_root.test.ts @@ -0,0 +1,20 @@ +import { SetupDoctorUseCase } from '../../../application/usecases/setup/doctor_use_case'; +import { SetupMergeQueueReadinessUseCase } from '../../../application/usecases/setup/merge_queue_readiness_use_case'; +import { + createSetupDoctorUseCase, + createSetupMergeQueueReadinessUseCase, +} from '../setup_doctor_composition_root'; + +describe('setup doctor composition root', () => { + it('constructs the doctor and default merge-queue readiness application services', () => { + expect(createSetupDoctorUseCase()).toBeInstanceOf(SetupDoctorUseCase); + expect(createSetupMergeQueueReadinessUseCase()).toBeInstanceOf(SetupMergeQueueReadinessUseCase); + }); + + it('accepts an injected catalog resolver for merge-queue readiness', () => { + const resolver = { resolve: jest.fn() }; + + expect(createSetupMergeQueueReadinessUseCase(resolver)) + .toBeInstanceOf(SetupMergeQueueReadinessUseCase); + }); +}); From 4d86d650a4183d3ac2b3aacec59a63159f381efb Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 23:40:00 +0200 Subject: [PATCH 3/3] codex-localized-setup-doctor: propagate inherited locale fallback --- build/cli/index.js | 7 ++++-- .../setup_doctor_report_policy.test.ts | 16 +++++++++++++ .../policies/setup_doctor_report_policy.ts | 23 +++++++++++++++++-- .../setup/__tests__/doctor_use_case.test.ts | 8 +++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 4db1eab5a..8ef3fb8fc 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -46508,10 +46508,13 @@ function buildLocaleDoctorChecks(configuration, catalog = (0, setup_doctor_messa const repositoryCatalogSource = catalog.requestedLocale === profile.repository ? catalog.resolutionSource : undefined; + const inheritedCatalogSource = (configured) => configured.trim() + ? undefined + : repositoryCatalogSource; return Object.freeze([ localeDoctorCheck('repository', configuration.repository.repositoryLocale, profile.repository, false, dynamicReady, catalog, repositoryCatalogSource), - localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady, catalog), - localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady, catalog), + localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady, catalog, inheritedCatalogSource(configuration.repository.issueLocale)), + localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady, catalog, inheritedCatalogSource(configuration.repository.pullRequestLocale)), ]); } catch { diff --git a/src/application/policies/__tests__/setup_doctor_report_policy.test.ts b/src/application/policies/__tests__/setup_doctor_report_policy.test.ts index 1a1c736b0..be818e662 100644 --- a/src/application/policies/__tests__/setup_doctor_report_policy.test.ts +++ b/src/application/policies/__tests__/setup_doctor_report_policy.test.ts @@ -6,6 +6,7 @@ import { skippedDoctorCheck, } from '../setup_doctor_report_policy'; import { createDefaultSetupConfiguration } from '../setup_configuration_policy'; +import { resolveStaticSetupDoctorCatalog } from '../setup_doctor_message_catalog'; describe('setup doctor report policy', () => { it('aggregates every status and is unhealthy only when a failure exists', () => { @@ -101,6 +102,21 @@ describe('setup doctor report policy', () => { }); }); + it('projects the actual repository fallback onto inherited issue and pull-request locales', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.repository.repositoryLocale = 'fr-FR'; + const checks = buildLocaleDoctorChecks( + configuration, + resolveStaticSetupDoctorCatalog('fr-FR'), + ); + + expect(checks.map((check) => check.status)).toEqual(['warn', 'warn', 'warn']); + expect(checks.map((check) => check.evidence.catalogSource)) + .toEqual(['fallback', 'fallback', 'fallback']); + expect(checks.slice(1).every((check) => check.summary.includes('fell back atomically to en-US'))) + .toBe(true); + }); + it('skips locale capability detail when the profile is invalid', () => { const configuration = createDefaultSetupConfiguration(); configuration.repository.repositoryLocale = 'und'; diff --git a/src/application/policies/setup_doctor_report_policy.ts b/src/application/policies/setup_doctor_report_policy.ts index 5e79d7bbf..3381ecdca 100644 --- a/src/application/policies/setup_doctor_report_policy.ts +++ b/src/application/policies/setup_doctor_report_policy.ts @@ -84,6 +84,9 @@ export function buildLocaleDoctorChecks( const repositoryCatalogSource = catalog.requestedLocale === profile.repository ? catalog.resolutionSource : undefined; + const inheritedCatalogSource = (configured: string) => configured.trim() + ? undefined + : repositoryCatalogSource; return Object.freeze([ localeDoctorCheck( 'repository', @@ -94,8 +97,24 @@ export function buildLocaleDoctorChecks( catalog, repositoryCatalogSource, ), - localeDoctorCheck('issue', configuration.repository.issueLocale, profile.issue, true, dynamicReady, catalog), - localeDoctorCheck('pull-request', configuration.repository.pullRequestLocale, profile.pullRequest, true, dynamicReady, catalog), + localeDoctorCheck( + 'issue', + configuration.repository.issueLocale, + profile.issue, + true, + dynamicReady, + catalog, + inheritedCatalogSource(configuration.repository.issueLocale), + ), + localeDoctorCheck( + 'pull-request', + configuration.repository.pullRequestLocale, + profile.pullRequest, + true, + dynamicReady, + catalog, + inheritedCatalogSource(configuration.repository.pullRequestLocale), + ), ]); } catch { return Object.freeze([ diff --git a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts index 0d27be6b0..a59eaf9c0 100644 --- a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts @@ -136,8 +136,12 @@ describe('SetupDoctorUseCase', () => { resolutionSource: 'fallback', fallbackReason: 'dynamic-response-invalid', }); - expect(diagnosis.report.checks.find((check) => check.id === 'locale.repository')) - .toMatchObject({ status: 'warn', evidence: { catalogSource: 'fallback' } }); + expect(diagnosis.report.checks.filter((check) => check.id.startsWith('locale.'))) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'locale.repository', status: 'warn', evidence: expect.objectContaining({ catalogSource: 'fallback' }) }), + expect.objectContaining({ id: 'locale.issue', status: 'warn', evidence: expect.objectContaining({ catalogSource: 'fallback' }) }), + expect.objectContaining({ id: 'locale.pull-request', status: 'warn', evidence: expect.objectContaining({ catalogSource: 'fallback' }) }), + ])); const productCopy = diagnosis.report.checks.flatMap((check) => [check.summary, check.action ?? '']); expect(productCopy.some((message) => message.startsWith('FR '))).toBe(false); expect(query).toHaveBeenCalledTimes(1);