From fc17ee57ecbb9a532d4187d18e10725e295176dd Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:51:56 +0200 Subject: [PATCH 1/2] codex-inactivity-localization: localize inactivity UX --- build/cli/index.js | 288 ++++++++++++++---- build/github_action/index.js | 288 ++++++++++++++---- docs/issues/notifications-and-auto-close.mdx | 24 ++ scripts/coverage-budgets.json | 9 +- specs/CATALOG.md | 8 +- specs/catalog.json | 11 + specs/repository-locale-and-localization.md | 12 + ...tic-github-publication-and-notification.md | 9 + .../inactivity_message_catalog.test.ts | 103 +++++++ .../inactivity_notification_policy.test.ts | 61 ++++ .../policies/inactivity_message_catalog.ts | 127 ++++++++ .../inactivity_notification_policy.ts | 66 ++++ .../push_single_action_contexts.test.ts | 2 + .../close_inactive_issues_use_case.test.ts | 122 +++++++- .../actions/close_inactive_issues_use_case.ts | 3 + .../actions/close_inactive_issues_workflow.ts | 146 +++++---- .../usecases/push_single_action_contexts.ts | 4 + .../github_publication_boundaries.test.ts | 13 + .../issue_inactivity_composition_root.ts | 7 +- .../main_run_route_composition_root.ts | 2 +- 20 files changed, 1097 insertions(+), 208 deletions(-) create mode 100644 src/application/policies/__tests__/inactivity_message_catalog.test.ts create mode 100644 src/application/policies/__tests__/inactivity_notification_policy.test.ts create mode 100644 src/application/policies/inactivity_message_catalog.ts create mode 100644 src/application/policies/inactivity_notification_policy.ts diff --git a/build/cli/index.js b/build/cli/index.js index 4befa374d..68f7fb741 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -44365,6 +44365,159 @@ function neutralizeGithubControls(value) { } +/***/ }), + +/***/ 74902: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.INACTIVITY_CATALOG_DEFINITIONS = exports.SPANISH_INACTIVITY_DEFINITION = exports.ENGLISH_INACTIVITY_DEFINITION = exports.INACTIVITY_MESSAGE_IDS = void 0; +exports.resolveStaticInactivityCatalog = resolveStaticInactivityCatalog; +exports.resolveInactivityCatalog = resolveInactivityCatalog; +const message_catalog_1 = __nccwpck_require__(27097); +const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +exports.INACTIVITY_MESSAGE_IDS = Object.freeze([ + 'inactivity.closure.heading', + 'inactivity.closure.reason', + 'inactivity.closure.reopen', + 'inactivity.summary.scanned', + 'inactivity.summary.closed', + 'inactivity.summary.skipped', + 'inactivity.summary.none', + 'inactivity.error.scan', + 'inactivity.error.revalidate', + 'inactivity.error.close', + 'inactivity.error.comment', +]); +const ENGLISH_MESSAGES = Object.freeze({ + 'inactivity.closure.heading': 'Issue closed after inactivity', + 'inactivity.closure.reason': Object.freeze({ + one: 'No activity was detected for at least {count} hour while this issue was waiting for a response.', + other: 'No activity was detected for at least {count} hours while this issue was waiting for a response.', + }), + 'inactivity.closure.reopen': 'If it still needs attention, reopen it and add a comment with the current context.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Scanned {count} open issue waiting for a response.', + other: 'Scanned {count} open issues waiting for a response.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Closed {count} issue after the inactivity threshold.', + other: 'Closed {count} issues after the inactivity threshold.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Skipped {count} candidate because it was no longer eligible.', + other: 'Skipped {count} candidates because they were no longer eligible.', + }), + 'inactivity.summary.none': 'No issue was closed for inactivity.', + 'inactivity.error.scan': 'Unable to scan issues for inactivity closure.', + 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', + 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', + 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', +}); +const SPANISH_MESSAGES = Object.freeze({ + 'inactivity.closure.heading': 'Issue cerrada por inactividad', + 'inactivity.closure.reason': Object.freeze({ + one: 'No se detectó actividad durante al menos {count} hora mientras esta issue esperaba una respuesta.', + many: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + other: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + }), + 'inactivity.closure.reopen': 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Se revisó {count} issue abierta que esperaba una respuesta.', + many: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + other: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Se cerró {count} issue tras superar el límite de inactividad.', + many: 'Se cerraron {count} issues tras superar el límite de inactividad.', + other: 'Se cerraron {count} issues tras superar el límite de inactividad.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Se omitió {count} candidata porque ya no cumplía los requisitos.', + many: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + other: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + }), + 'inactivity.summary.none': 'No se cerró ninguna issue por inactividad.', + 'inactivity.error.scan': 'No se pudieron revisar las issues para aplicar el cierre por inactividad.', + 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', + 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', + 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', +}); +exports.ENGLISH_INACTIVITY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: ENGLISH_MESSAGES, +}); +exports.SPANISH_INACTIVITY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: SPANISH_MESSAGES, +}); +exports.INACTIVITY_CATALOG_DEFINITIONS = Object.freeze([ + exports.ENGLISH_INACTIVITY_DEFINITION, + exports.SPANISH_INACTIVITY_DEFINITION, +]); +function resolveStaticInactivityCatalog(locale) { + return (0, resolved_message_catalog_policy_1.resolveStaticMessageCatalogView)(locale, exports.ENGLISH_INACTIVITY_DEFINITION, exports.INACTIVITY_CATALOG_DEFINITIONS); +} +async function resolveInactivityCatalog(locale, configuration, resolver) { + return (0, resolved_message_catalog_policy_1.resolveMessageCatalogView)(locale, exports.INACTIVITY_MESSAGE_IDS, exports.ENGLISH_INACTIVITY_DEFINITION, exports.INACTIVITY_CATALOG_DEFINITIONS, configuration, resolver); +} + + +/***/ }), + +/***/ 1572: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildInactivityClosureComment = buildInactivityClosureComment; +exports.buildInactivitySummarySteps = buildInactivitySummarySteps; +const publication_identity_policy_1 = __nccwpck_require__(45403); +function buildInactivityClosureComment(input) { + const digest = (0, publication_identity_policy_1.createSemanticDigest)({ + updatedAt: input.candidate.updatedAt, + thresholdHours: input.thresholdHours, + }); + const marker = (0, publication_identity_policy_1.buildPublicationMarker)({ + identity: { + topic: 'inactivity', + target: { kind: 'issue', number: input.candidate.number }, + key: 'closure', + }, + sourceVersion: `policy:${digest}`, + digest, + }); + return [ + marker, + '', + `## ${input.messages.message('inactivity.closure.heading')}`, + '', + input.messages.message('inactivity.closure.reason', { count: input.thresholdHours }, input.thresholdHours), + '', + input.messages.message('inactivity.closure.reopen'), + ].join('\n'); +} +function buildInactivitySummarySteps(input) { + const steps = [input.messages.message('inactivity.summary.scanned', { count: input.scanned }, input.scanned)]; + if (input.closed > 0) { + steps.push(input.messages.message('inactivity.summary.closed', { count: input.closed }, input.closed)); + } + if (input.skipped > 0) { + steps.push(input.messages.message('inactivity.summary.skipped', { count: input.skipped }, input.skipped)); + } + if (input.closed === 0) + steps.push(input.messages.message('inactivity.summary.none')); + return steps; +} + + /***/ }), /***/ 73160: @@ -48231,10 +48384,11 @@ exports.CloseInactiveIssuesUseCase = void 0; const close_inactive_issues_workflow_1 = __nccwpck_require__(86288); /** Application boundary for the scheduled inactivity-maintenance action. */ class CloseInactiveIssuesUseCase { - constructor(issueQueryPort, issueClosurePort, clock) { + constructor(issueQueryPort, issueClosurePort, clock, catalogResolver) { this.issueQueryPort = issueQueryPort; this.issueClosurePort = issueClosurePort; this.clock = clock; + this.catalogResolver = catalogResolver; this.taskId = 'CloseInactiveIssuesUseCase'; } async invoke(param) { @@ -48242,6 +48396,7 @@ class CloseInactiveIssuesUseCase { issueQueryPort: this.issueQueryPort, issueClosurePort: this.issueClosurePort, clock: this.clock, + catalogResolver: this.catalogResolver, }); } } @@ -48261,8 +48416,8 @@ const result_1 = __nccwpck_require__(73817); const issue_inactivity_1 = __nccwpck_require__(38572); const logging_ports_1 = __nccwpck_require__(6152); const application_error_1 = __nccwpck_require__(75999); -const locale_1 = __nccwpck_require__(15386); -const publication_identity_policy_1 = __nccwpck_require__(45403); +const inactivity_message_catalog_1 = __nccwpck_require__(74902); +const inactivity_notification_policy_1 = __nccwpck_require__(1572); const TASK_ID = 'CloseInactiveIssuesUseCase'; /** Scans waiting issues and closes only candidates that remain inactive. */ async function runCloseInactiveIssuesWorkflow(param, dependencies) { @@ -48272,10 +48427,17 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { const activityLabel = param.activityLabel; const nowMilliseconds = dependencies.clock.nowMilliseconds(); const thresholdHours = param.thresholdHours; + let resultMessages = (0, inactivity_message_catalog_1.resolveStaticInactivityCatalog)(param.repositoryLocale); try { + const commentMessages = await (0, inactivity_message_catalog_1.resolveInactivityCatalog)(param.locale, param.agentConfiguration, dependencies.catalogResolver); + resultMessages = param.repositoryLocale === param.locale + ? commentMessages + : await (0, inactivity_message_catalog_1.resolveInactivityCatalog)(param.repositoryLocale, param.agentConfiguration, dependencies.catalogResolver); const candidates = await listCandidates(param, waitingLabels, dependencies.issueQueryPort); let eligibleCount = 0; let closedCount = 0; + let commentedCount = 0; + let commentFailureCount = 0; let skippedCount = 0; const errors = []; for (const candidate of candidates) { @@ -48291,34 +48453,63 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { continue; } eligibleCount++; + let current; try { // Re-read both labels and updated_at immediately before the // mutation so a comment or state transition during the scan // invalidates the stale list snapshot. - const current = await dependencies.issueQueryPort.getOpenIssue(candidate.number); - if (!current || (0, issue_inactivity_1.evaluateIssueInactivity)({ - issue: current, - waitingLabels, - agentActivityLabel: activityLabel, - thresholdHours, - nowMilliseconds: dependencies.clock.nowMilliseconds(), - }).kind !== 'close') { - skippedCount++; - continue; - } + current = await dependencies.issueQueryPort.getOpenIssue(candidate.number); + } + catch (error) { + const message = resultMessages.message('inactivity.error.revalidate', { + issueNumber: candidate.number, + }); + (0, logging_ports_1.logError)(message); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + continue; + } + if (!current || (0, issue_inactivity_1.evaluateIssueInactivity)({ + issue: current, + waitingLabels, + agentActivityLabel: activityLabel, + thresholdHours, + nowMilliseconds: dependencies.clock.nowMilliseconds(), + }).kind !== 'close') { + skippedCount++; + continue; + } + try { const closed = await dependencies.issueClosurePort.closeIssue(candidate.number); if (!closed) { skippedCount++; continue; } closedCount++; - await dependencies.issueClosurePort.addComment(candidate.number, buildInactivityExplanation(candidate, thresholdHours, param.locale)); + } + catch (error) { + const message = resultMessages.message('inactivity.error.close', { + issueNumber: candidate.number, + }); + (0, logging_ports_1.logError)(message); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + continue; + } + try { + await dependencies.issueClosurePort.addComment(candidate.number, (0, inactivity_notification_policy_1.buildInactivityClosureComment)({ + candidate, + thresholdHours, + messages: commentMessages, + })); + commentedCount++; (0, logging_ports_1.logInfo)(`Issue #${candidate.number} closed after inactivity.`); } catch (error) { - const message = `Unable to close issue #${candidate.number} after inactivity.`; + commentFailureCount++; + const message = resultMessages.message('inactivity.error.comment', { + issueNumber: candidate.number, + }); (0, logging_ports_1.logError)(message); - errors.push(new application_error_1.ApplicationError('provider.unavailable', `${message} ${safeErrorMessage(error)}`, { cause: error })); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); } } (0, logging_ports_1.logDebugInfo)(`${TASK_ID}: scanned=${candidates.length}, eligible=${eligibleCount}, closed=${closedCount}, skipped=${skippedCount}.`); @@ -48326,56 +48517,36 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { id: TASK_ID, success: errors.length === 0, executed: closedCount > 0 || eligibleCount > 0, - steps: buildSteps(candidates.length, closedCount, skippedCount), + steps: (0, inactivity_notification_policy_1.buildInactivitySummarySteps)({ + scanned: candidates.length, + closed: closedCount, + skipped: skippedCount, + messages: resultMessages, + }), payload: { scanned: candidates.length, eligible: eligibleCount, closed: closedCount, + commented: commentedCount, + commentFailures: commentFailureCount, + failures: errors.length, skipped: skippedCount, }, errors, })]; } catch (error) { - const message = 'Unable to scan issues for inactivity closure.'; + const message = resultMessages.message('inactivity.error.scan'); (0, logging_ports_1.logError)(message); return [new result_1.Result({ id: TASK_ID, success: false, executed: true, steps: [message], - errors: [(0, application_error_1.toApplicationError)(error, 'provider.unavailable', `${message} ${safeErrorMessage(error)}`)], + errors: [(0, application_error_1.toApplicationError)(error, 'provider.unavailable', message)], })]; } } -function buildInactivityExplanation(candidate, thresholdHours, locale) { - const digest = (0, publication_identity_policy_1.createSemanticDigest)({ updatedAt: candidate.updatedAt, thresholdHours }); - const marker = (0, publication_identity_policy_1.buildPublicationMarker)({ - identity: { topic: 'inactivity', target: { kind: 'issue', number: candidate.number }, key: 'closure' }, - sourceVersion: `policy:${digest}`, - digest, - }); - if ((0, locale_1.baseLanguage)(locale) === 'es') { - return [ - marker, - '', - '## Issue cerrada por inactividad', - '', - `No se detectó actividad durante al menos **${thresholdHours} horas** mientras esta issue esperaba una respuesta.`, - '', - 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', - ].join('\n'); - } - return [ - marker, - '', - '## Issue closed after inactivity', - '', - `No activity was detected for at least **${thresholdHours} hours** while this issue was waiting for a response.`, - '', - 'If it still needs attention, reopen it and add a comment with the current context.', - ].join('\n'); -} async function listCandidates(_param, waitingLabels, queryPort) { const candidates = []; for (const label of waitingLabels) { @@ -48386,22 +48557,9 @@ async function listCandidates(_param, waitingLabels, queryPort) { uniqueCandidates.set(candidate.number, candidate); return [...uniqueCandidates.values()]; } -function buildSteps(scanned, closed, skipped) { - const steps = [`Scanned ${scanned} open issue(s) waiting for a response.`]; - if (closed > 0) - steps.push(`Closed ${closed} issue(s) after the inactivity threshold.`); - if (skipped > 0) - steps.push(`Skipped ${skipped} candidate(s) because they were no longer eligible.`); - if (closed === 0) - steps.push('No issue was closed for inactivity.'); - return steps; -} function unique(values) { return [...new Set(values.map(value => value.trim()).filter(Boolean))]; } -function safeErrorMessage(_error) { - return 'The issue provider request failed.'; -} /***/ }), @@ -52752,6 +52910,8 @@ function projectInactivityContext(source) { activityLabel: source.labels.lifecycle.aiProcessing, thresholdHours: source.inactivityThresholdHours, locale: source.locale?.issue ?? 'en-US', + repositoryLocale: source.locale?.repository ?? 'en-US', + agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), }); } function projectBranchObservationContext(source) { @@ -76121,8 +76281,8 @@ const github_issue_client_factory_1 = __nccwpck_require__(95883); const issue_interaction_composition_root_1 = __nccwpck_require__(92503); const lifecycle_capability_port_binding_1 = __nccwpck_require__(85785); const push_single_action_capability_port_binding_1 = __nccwpck_require__(49417); -function createCloseInactiveIssuesUseCase(binding) { - return new close_inactive_issues_use_case_1.CloseInactiveIssuesUseCase((0, push_single_action_capability_port_binding_1.bindIssueInactivityQuery)(new issue_inactivity_repository_1.IssueInactivityRepository((0, github_issue_client_factory_1.createIssueInactivityClient)()), binding), (0, lifecycle_capability_port_binding_1.bindIssueClosure)((0, issue_interaction_composition_root_1.createIssueClosureRepository)(), binding), new system_issue_inactivity_clock_adapter_1.SystemIssueInactivityClockAdapter()); +function createCloseInactiveIssuesUseCase(binding, catalogResolver) { + return new close_inactive_issues_use_case_1.CloseInactiveIssuesUseCase((0, push_single_action_capability_port_binding_1.bindIssueInactivityQuery)(new issue_inactivity_repository_1.IssueInactivityRepository((0, github_issue_client_factory_1.createIssueInactivityClient)()), binding), (0, lifecycle_capability_port_binding_1.bindIssueClosure)((0, issue_interaction_composition_root_1.createIssueClosureRepository)(), binding), new system_issue_inactivity_clock_adapter_1.SystemIssueInactivityClockAdapter(), catalogResolver); } @@ -76514,7 +76674,7 @@ function createSingleActionUseCaseCompositionRoot(surface, binding) { : undefined; return new single_action_use_case_1.SingleActionUseCase(repositoryTagPort && repositoryReleasePort ? new publish_github_action_use_case_1.PublishGithubActionUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding), (0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) - : undefined, repositoryReleasePort ? new create_release_use_case_1.CreateReleaseUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) : undefined, repositoryTagPort ? new create_tag_use_case_1.CreateTagUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding)) : undefined, new think_use_case_1.ThinkUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, initial_setup_composition_root_1.createInitialSetupCompositionRoot)(binding), (0, check_progress_composition_root_1.createCheckProgressCompositionRoot)(binding), createDetectPotentialProblemsUseCase(binding), new recommend_steps_use_case_1.RecommendStepsUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, issue_inactivity_composition_root_1.createCloseInactiveIssuesUseCase)(binding), (0, actor_authorization_composition_root_1.createActorAuthorizationRepository)(), new publish_issue_comment_use_case_1.PublishIssueCommentUseCase((0, push_single_action_capability_port_binding_1.bindIssueCommentPublication)(issueDescriptionQueryPort, binding)), new observe_branch_sync_use_case_1.ObserveBranchSyncUseCase((0, push_single_action_capability_port_binding_1.bindBranchDependencies)(new branch_dependency_repository_1.BranchDependencyRepository((0, github_project_client_factory_1.createGraphqlTransportClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchComparison)(new branch_compare_repository_1.BranchCompareRepository((0, github_branch_client_factory_1.createBranchComparisonClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchSyncNotification)(issueDescriptionQueryPort, binding), catalogResolver), deploymentOrchestration); + : undefined, repositoryReleasePort ? new create_release_use_case_1.CreateReleaseUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) : undefined, repositoryTagPort ? new create_tag_use_case_1.CreateTagUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding)) : undefined, new think_use_case_1.ThinkUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, initial_setup_composition_root_1.createInitialSetupCompositionRoot)(binding), (0, check_progress_composition_root_1.createCheckProgressCompositionRoot)(binding), createDetectPotentialProblemsUseCase(binding), new recommend_steps_use_case_1.RecommendStepsUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, issue_inactivity_composition_root_1.createCloseInactiveIssuesUseCase)(binding, catalogResolver), (0, actor_authorization_composition_root_1.createActorAuthorizationRepository)(), new publish_issue_comment_use_case_1.PublishIssueCommentUseCase((0, push_single_action_capability_port_binding_1.bindIssueCommentPublication)(issueDescriptionQueryPort, binding)), new observe_branch_sync_use_case_1.ObserveBranchSyncUseCase((0, push_single_action_capability_port_binding_1.bindBranchDependencies)(new branch_dependency_repository_1.BranchDependencyRepository((0, github_project_client_factory_1.createGraphqlTransportClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchComparison)(new branch_compare_repository_1.BranchCompareRepository((0, github_branch_client_factory_1.createBranchComparisonClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchSyncNotification)(issueDescriptionQueryPort, binding), catalogResolver), deploymentOrchestration); } function createDeploymentOrchestrationUseCase(issueDescriptionQueryPort, publication, binding, catalogResolver) { const deploymentClient = new octokit_deployment_adapter_1.OctokitDeploymentClientAdapter(); diff --git a/build/github_action/index.js b/build/github_action/index.js index 7f3676b70..6034b249e 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -45460,6 +45460,159 @@ function resolveGithubExecutionAdmission(input) { } +/***/ }), + +/***/ 74902: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.INACTIVITY_CATALOG_DEFINITIONS = exports.SPANISH_INACTIVITY_DEFINITION = exports.ENGLISH_INACTIVITY_DEFINITION = exports.INACTIVITY_MESSAGE_IDS = void 0; +exports.resolveStaticInactivityCatalog = resolveStaticInactivityCatalog; +exports.resolveInactivityCatalog = resolveInactivityCatalog; +const message_catalog_1 = __nccwpck_require__(27097); +const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); +exports.INACTIVITY_MESSAGE_IDS = Object.freeze([ + 'inactivity.closure.heading', + 'inactivity.closure.reason', + 'inactivity.closure.reopen', + 'inactivity.summary.scanned', + 'inactivity.summary.closed', + 'inactivity.summary.skipped', + 'inactivity.summary.none', + 'inactivity.error.scan', + 'inactivity.error.revalidate', + 'inactivity.error.close', + 'inactivity.error.comment', +]); +const ENGLISH_MESSAGES = Object.freeze({ + 'inactivity.closure.heading': 'Issue closed after inactivity', + 'inactivity.closure.reason': Object.freeze({ + one: 'No activity was detected for at least {count} hour while this issue was waiting for a response.', + other: 'No activity was detected for at least {count} hours while this issue was waiting for a response.', + }), + 'inactivity.closure.reopen': 'If it still needs attention, reopen it and add a comment with the current context.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Scanned {count} open issue waiting for a response.', + other: 'Scanned {count} open issues waiting for a response.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Closed {count} issue after the inactivity threshold.', + other: 'Closed {count} issues after the inactivity threshold.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Skipped {count} candidate because it was no longer eligible.', + other: 'Skipped {count} candidates because they were no longer eligible.', + }), + 'inactivity.summary.none': 'No issue was closed for inactivity.', + 'inactivity.error.scan': 'Unable to scan issues for inactivity closure.', + 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', + 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', + 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', +}); +const SPANISH_MESSAGES = Object.freeze({ + 'inactivity.closure.heading': 'Issue cerrada por inactividad', + 'inactivity.closure.reason': Object.freeze({ + one: 'No se detectó actividad durante al menos {count} hora mientras esta issue esperaba una respuesta.', + many: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + other: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + }), + 'inactivity.closure.reopen': 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Se revisó {count} issue abierta que esperaba una respuesta.', + many: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + other: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Se cerró {count} issue tras superar el límite de inactividad.', + many: 'Se cerraron {count} issues tras superar el límite de inactividad.', + other: 'Se cerraron {count} issues tras superar el límite de inactividad.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Se omitió {count} candidata porque ya no cumplía los requisitos.', + many: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + other: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + }), + 'inactivity.summary.none': 'No se cerró ninguna issue por inactividad.', + 'inactivity.error.scan': 'No se pudieron revisar las issues para aplicar el cierre por inactividad.', + 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', + 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', + 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', +}); +exports.ENGLISH_INACTIVITY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: ENGLISH_MESSAGES, +}); +exports.SPANISH_INACTIVITY_DEFINITION = Object.freeze({ + version: message_catalog_1.MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: SPANISH_MESSAGES, +}); +exports.INACTIVITY_CATALOG_DEFINITIONS = Object.freeze([ + exports.ENGLISH_INACTIVITY_DEFINITION, + exports.SPANISH_INACTIVITY_DEFINITION, +]); +function resolveStaticInactivityCatalog(locale) { + return (0, resolved_message_catalog_policy_1.resolveStaticMessageCatalogView)(locale, exports.ENGLISH_INACTIVITY_DEFINITION, exports.INACTIVITY_CATALOG_DEFINITIONS); +} +async function resolveInactivityCatalog(locale, configuration, resolver) { + return (0, resolved_message_catalog_policy_1.resolveMessageCatalogView)(locale, exports.INACTIVITY_MESSAGE_IDS, exports.ENGLISH_INACTIVITY_DEFINITION, exports.INACTIVITY_CATALOG_DEFINITIONS, configuration, resolver); +} + + +/***/ }), + +/***/ 1572: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildInactivityClosureComment = buildInactivityClosureComment; +exports.buildInactivitySummarySteps = buildInactivitySummarySteps; +const publication_identity_policy_1 = __nccwpck_require__(45403); +function buildInactivityClosureComment(input) { + const digest = (0, publication_identity_policy_1.createSemanticDigest)({ + updatedAt: input.candidate.updatedAt, + thresholdHours: input.thresholdHours, + }); + const marker = (0, publication_identity_policy_1.buildPublicationMarker)({ + identity: { + topic: 'inactivity', + target: { kind: 'issue', number: input.candidate.number }, + key: 'closure', + }, + sourceVersion: `policy:${digest}`, + digest, + }); + return [ + marker, + '', + `## ${input.messages.message('inactivity.closure.heading')}`, + '', + input.messages.message('inactivity.closure.reason', { count: input.thresholdHours }, input.thresholdHours), + '', + input.messages.message('inactivity.closure.reopen'), + ].join('\n'); +} +function buildInactivitySummarySteps(input) { + const steps = [input.messages.message('inactivity.summary.scanned', { count: input.scanned }, input.scanned)]; + if (input.closed > 0) { + steps.push(input.messages.message('inactivity.summary.closed', { count: input.closed }, input.closed)); + } + if (input.skipped > 0) { + steps.push(input.messages.message('inactivity.summary.skipped', { count: input.skipped }, input.skipped)); + } + if (input.closed === 0) + steps.push(input.messages.message('inactivity.summary.none')); + return steps; +} + + /***/ }), /***/ 73160: @@ -48615,10 +48768,11 @@ exports.CloseInactiveIssuesUseCase = void 0; const close_inactive_issues_workflow_1 = __nccwpck_require__(86288); /** Application boundary for the scheduled inactivity-maintenance action. */ class CloseInactiveIssuesUseCase { - constructor(issueQueryPort, issueClosurePort, clock) { + constructor(issueQueryPort, issueClosurePort, clock, catalogResolver) { this.issueQueryPort = issueQueryPort; this.issueClosurePort = issueClosurePort; this.clock = clock; + this.catalogResolver = catalogResolver; this.taskId = 'CloseInactiveIssuesUseCase'; } async invoke(param) { @@ -48626,6 +48780,7 @@ class CloseInactiveIssuesUseCase { issueQueryPort: this.issueQueryPort, issueClosurePort: this.issueClosurePort, clock: this.clock, + catalogResolver: this.catalogResolver, }); } } @@ -48645,8 +48800,8 @@ const result_1 = __nccwpck_require__(73817); const issue_inactivity_1 = __nccwpck_require__(38572); const logging_ports_1 = __nccwpck_require__(6152); const application_error_1 = __nccwpck_require__(75999); -const locale_1 = __nccwpck_require__(15386); -const publication_identity_policy_1 = __nccwpck_require__(45403); +const inactivity_message_catalog_1 = __nccwpck_require__(74902); +const inactivity_notification_policy_1 = __nccwpck_require__(1572); const TASK_ID = 'CloseInactiveIssuesUseCase'; /** Scans waiting issues and closes only candidates that remain inactive. */ async function runCloseInactiveIssuesWorkflow(param, dependencies) { @@ -48656,10 +48811,17 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { const activityLabel = param.activityLabel; const nowMilliseconds = dependencies.clock.nowMilliseconds(); const thresholdHours = param.thresholdHours; + let resultMessages = (0, inactivity_message_catalog_1.resolveStaticInactivityCatalog)(param.repositoryLocale); try { + const commentMessages = await (0, inactivity_message_catalog_1.resolveInactivityCatalog)(param.locale, param.agentConfiguration, dependencies.catalogResolver); + resultMessages = param.repositoryLocale === param.locale + ? commentMessages + : await (0, inactivity_message_catalog_1.resolveInactivityCatalog)(param.repositoryLocale, param.agentConfiguration, dependencies.catalogResolver); const candidates = await listCandidates(param, waitingLabels, dependencies.issueQueryPort); let eligibleCount = 0; let closedCount = 0; + let commentedCount = 0; + let commentFailureCount = 0; let skippedCount = 0; const errors = []; for (const candidate of candidates) { @@ -48675,34 +48837,63 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { continue; } eligibleCount++; + let current; try { // Re-read both labels and updated_at immediately before the // mutation so a comment or state transition during the scan // invalidates the stale list snapshot. - const current = await dependencies.issueQueryPort.getOpenIssue(candidate.number); - if (!current || (0, issue_inactivity_1.evaluateIssueInactivity)({ - issue: current, - waitingLabels, - agentActivityLabel: activityLabel, - thresholdHours, - nowMilliseconds: dependencies.clock.nowMilliseconds(), - }).kind !== 'close') { - skippedCount++; - continue; - } + current = await dependencies.issueQueryPort.getOpenIssue(candidate.number); + } + catch (error) { + const message = resultMessages.message('inactivity.error.revalidate', { + issueNumber: candidate.number, + }); + (0, logging_ports_1.logError)(message); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + continue; + } + if (!current || (0, issue_inactivity_1.evaluateIssueInactivity)({ + issue: current, + waitingLabels, + agentActivityLabel: activityLabel, + thresholdHours, + nowMilliseconds: dependencies.clock.nowMilliseconds(), + }).kind !== 'close') { + skippedCount++; + continue; + } + try { const closed = await dependencies.issueClosurePort.closeIssue(candidate.number); if (!closed) { skippedCount++; continue; } closedCount++; - await dependencies.issueClosurePort.addComment(candidate.number, buildInactivityExplanation(candidate, thresholdHours, param.locale)); + } + catch (error) { + const message = resultMessages.message('inactivity.error.close', { + issueNumber: candidate.number, + }); + (0, logging_ports_1.logError)(message); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + continue; + } + try { + await dependencies.issueClosurePort.addComment(candidate.number, (0, inactivity_notification_policy_1.buildInactivityClosureComment)({ + candidate, + thresholdHours, + messages: commentMessages, + })); + commentedCount++; (0, logging_ports_1.logInfo)(`Issue #${candidate.number} closed after inactivity.`); } catch (error) { - const message = `Unable to close issue #${candidate.number} after inactivity.`; + commentFailureCount++; + const message = resultMessages.message('inactivity.error.comment', { + issueNumber: candidate.number, + }); (0, logging_ports_1.logError)(message); - errors.push(new application_error_1.ApplicationError('provider.unavailable', `${message} ${safeErrorMessage(error)}`, { cause: error })); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); } } (0, logging_ports_1.logDebugInfo)(`${TASK_ID}: scanned=${candidates.length}, eligible=${eligibleCount}, closed=${closedCount}, skipped=${skippedCount}.`); @@ -48710,56 +48901,36 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { id: TASK_ID, success: errors.length === 0, executed: closedCount > 0 || eligibleCount > 0, - steps: buildSteps(candidates.length, closedCount, skippedCount), + steps: (0, inactivity_notification_policy_1.buildInactivitySummarySteps)({ + scanned: candidates.length, + closed: closedCount, + skipped: skippedCount, + messages: resultMessages, + }), payload: { scanned: candidates.length, eligible: eligibleCount, closed: closedCount, + commented: commentedCount, + commentFailures: commentFailureCount, + failures: errors.length, skipped: skippedCount, }, errors, })]; } catch (error) { - const message = 'Unable to scan issues for inactivity closure.'; + const message = resultMessages.message('inactivity.error.scan'); (0, logging_ports_1.logError)(message); return [new result_1.Result({ id: TASK_ID, success: false, executed: true, steps: [message], - errors: [(0, application_error_1.toApplicationError)(error, 'provider.unavailable', `${message} ${safeErrorMessage(error)}`)], + errors: [(0, application_error_1.toApplicationError)(error, 'provider.unavailable', message)], })]; } } -function buildInactivityExplanation(candidate, thresholdHours, locale) { - const digest = (0, publication_identity_policy_1.createSemanticDigest)({ updatedAt: candidate.updatedAt, thresholdHours }); - const marker = (0, publication_identity_policy_1.buildPublicationMarker)({ - identity: { topic: 'inactivity', target: { kind: 'issue', number: candidate.number }, key: 'closure' }, - sourceVersion: `policy:${digest}`, - digest, - }); - if ((0, locale_1.baseLanguage)(locale) === 'es') { - return [ - marker, - '', - '## Issue cerrada por inactividad', - '', - `No se detectó actividad durante al menos **${thresholdHours} horas** mientras esta issue esperaba una respuesta.`, - '', - 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', - ].join('\n'); - } - return [ - marker, - '', - '## Issue closed after inactivity', - '', - `No activity was detected for at least **${thresholdHours} hours** while this issue was waiting for a response.`, - '', - 'If it still needs attention, reopen it and add a comment with the current context.', - ].join('\n'); -} async function listCandidates(_param, waitingLabels, queryPort) { const candidates = []; for (const label of waitingLabels) { @@ -48770,22 +48941,9 @@ async function listCandidates(_param, waitingLabels, queryPort) { uniqueCandidates.set(candidate.number, candidate); return [...uniqueCandidates.values()]; } -function buildSteps(scanned, closed, skipped) { - const steps = [`Scanned ${scanned} open issue(s) waiting for a response.`]; - if (closed > 0) - steps.push(`Closed ${closed} issue(s) after the inactivity threshold.`); - if (skipped > 0) - steps.push(`Skipped ${skipped} candidate(s) because they were no longer eligible.`); - if (closed === 0) - steps.push('No issue was closed for inactivity.'); - return steps; -} function unique(values) { return [...new Set(values.map(value => value.trim()).filter(Boolean))]; } -function safeErrorMessage(_error) { - return 'The issue provider request failed.'; -} /***/ }), @@ -53277,6 +53435,8 @@ function projectInactivityContext(source) { activityLabel: source.labels.lifecycle.aiProcessing, thresholdHours: source.inactivityThresholdHours, locale: source.locale?.issue ?? 'en-US', + repositoryLocale: source.locale?.repository ?? 'en-US', + agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), }); } function projectBranchObservationContext(source) { @@ -73861,8 +74021,8 @@ const github_issue_client_factory_1 = __nccwpck_require__(95883); const issue_interaction_composition_root_1 = __nccwpck_require__(92503); const lifecycle_capability_port_binding_1 = __nccwpck_require__(85785); const push_single_action_capability_port_binding_1 = __nccwpck_require__(49417); -function createCloseInactiveIssuesUseCase(binding) { - return new close_inactive_issues_use_case_1.CloseInactiveIssuesUseCase((0, push_single_action_capability_port_binding_1.bindIssueInactivityQuery)(new issue_inactivity_repository_1.IssueInactivityRepository((0, github_issue_client_factory_1.createIssueInactivityClient)()), binding), (0, lifecycle_capability_port_binding_1.bindIssueClosure)((0, issue_interaction_composition_root_1.createIssueClosureRepository)(), binding), new system_issue_inactivity_clock_adapter_1.SystemIssueInactivityClockAdapter()); +function createCloseInactiveIssuesUseCase(binding, catalogResolver) { + return new close_inactive_issues_use_case_1.CloseInactiveIssuesUseCase((0, push_single_action_capability_port_binding_1.bindIssueInactivityQuery)(new issue_inactivity_repository_1.IssueInactivityRepository((0, github_issue_client_factory_1.createIssueInactivityClient)()), binding), (0, lifecycle_capability_port_binding_1.bindIssueClosure)((0, issue_interaction_composition_root_1.createIssueClosureRepository)(), binding), new system_issue_inactivity_clock_adapter_1.SystemIssueInactivityClockAdapter(), catalogResolver); } @@ -74245,7 +74405,7 @@ function createSingleActionUseCaseCompositionRoot(surface, binding) { : undefined; return new single_action_use_case_1.SingleActionUseCase(repositoryTagPort && repositoryReleasePort ? new publish_github_action_use_case_1.PublishGithubActionUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding), (0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) - : undefined, repositoryReleasePort ? new create_release_use_case_1.CreateReleaseUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) : undefined, repositoryTagPort ? new create_tag_use_case_1.CreateTagUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding)) : undefined, new think_use_case_1.ThinkUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, initial_setup_composition_root_1.createInitialSetupCompositionRoot)(binding), (0, check_progress_composition_root_1.createCheckProgressCompositionRoot)(binding), createDetectPotentialProblemsUseCase(binding), new recommend_steps_use_case_1.RecommendStepsUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, issue_inactivity_composition_root_1.createCloseInactiveIssuesUseCase)(binding), (0, actor_authorization_composition_root_1.createActorAuthorizationRepository)(), new publish_issue_comment_use_case_1.PublishIssueCommentUseCase((0, push_single_action_capability_port_binding_1.bindIssueCommentPublication)(issueDescriptionQueryPort, binding)), new observe_branch_sync_use_case_1.ObserveBranchSyncUseCase((0, push_single_action_capability_port_binding_1.bindBranchDependencies)(new branch_dependency_repository_1.BranchDependencyRepository((0, github_project_client_factory_1.createGraphqlTransportClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchComparison)(new branch_compare_repository_1.BranchCompareRepository((0, github_branch_client_factory_1.createBranchComparisonClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchSyncNotification)(issueDescriptionQueryPort, binding), catalogResolver), deploymentOrchestration); + : undefined, repositoryReleasePort ? new create_release_use_case_1.CreateReleaseUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryRelease)(repositoryReleasePort, binding)) : undefined, repositoryTagPort ? new create_tag_use_case_1.CreateTagUseCase((0, push_single_action_capability_port_binding_1.bindRepositoryTag)(repositoryTagPort, binding)) : undefined, new think_use_case_1.ThinkUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, initial_setup_composition_root_1.createInitialSetupCompositionRoot)(binding), (0, check_progress_composition_root_1.createCheckProgressCompositionRoot)(binding), createDetectPotentialProblemsUseCase(binding), new recommend_steps_use_case_1.RecommendStepsUseCase((0, shared_capability_port_binding_1.bindIssueDescriptionQuery)(issueDescriptionQueryPort, binding), (0, agent_capability_composition_root_1.createFindingsQueryPort)()), (0, issue_inactivity_composition_root_1.createCloseInactiveIssuesUseCase)(binding, catalogResolver), (0, actor_authorization_composition_root_1.createActorAuthorizationRepository)(), new publish_issue_comment_use_case_1.PublishIssueCommentUseCase((0, push_single_action_capability_port_binding_1.bindIssueCommentPublication)(issueDescriptionQueryPort, binding)), new observe_branch_sync_use_case_1.ObserveBranchSyncUseCase((0, push_single_action_capability_port_binding_1.bindBranchDependencies)(new branch_dependency_repository_1.BranchDependencyRepository((0, github_project_client_factory_1.createGraphqlTransportClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchComparison)(new branch_compare_repository_1.BranchCompareRepository((0, github_branch_client_factory_1.createBranchComparisonClient)()), binding), (0, push_single_action_capability_port_binding_1.bindBranchSyncNotification)(issueDescriptionQueryPort, binding), catalogResolver), deploymentOrchestration); } function createDeploymentOrchestrationUseCase(issueDescriptionQueryPort, publication, binding, catalogResolver) { const deploymentClient = new octokit_deployment_adapter_1.OctokitDeploymentClientAdapter(); diff --git a/docs/issues/notifications-and-auto-close.mdx b/docs/issues/notifications-and-auto-close.mdx index 48920118c..7fee91214 100644 --- a/docs/issues/notifications-and-auto-close.mdx +++ b/docs/issues/notifications-and-auto-close.mdx @@ -65,6 +65,30 @@ repositories do not change behavior until `copilot setup` enables the - **Default threshold:** 168 hours (7 days), configurable with `inactivity-threshold-hours` or the `INACTIVITY_THRESHOLD_HOURS` Repository Variable. Valid values are 1–8760 hours. - **Safety:** the issue is fetched again immediately before closing. If its state, waiting label, activity timestamp, or processing marker changed, it is left open. One terminal explanation records the threshold and reopening path; it uses a stable semantic marker and the configured issue locale. +With the default `en-US` issue locale, that terminal message is intentionally +short: + +```markdown +## Issue closed after inactivity + +No activity was detected for at least 168 hours while this issue was waiting for a response. + +If it still needs attention, reopen it and add a comment with the current context. +``` + +`issues-locale` inherits `repository-locale`, which defaults to `en-US`. Reviewed +English and Spanish copy is bundled. Any other valid BCP-47 locale uses one +bounded catalog-localization request per distinct effective locale in the run; +the result is reused when issue and repository scopes match. If a request is +unavailable or invalid, its complete artifact falls back to English. Commands, +labels, issue numbers, hidden ownership markers, error codes, and result payload +keys are never translated. + +The workflow Job Summary reports locale-aware scanned, closed, and skipped +counts. Provider failures identify whether Copilot could not recheck the issue, +could not close it, or closed it successfully but could not publish the terminal +explanation. That last partial failure never claims that the close itself failed. + ## Summary | Behavior | Controlled by | Where it runs | diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 45e63cd2f..ff9c2b0f1 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -250,6 +250,8 @@ "src/domain/message_catalog.ts", "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/comment_translation_policy.ts", + "src/application/policies/inactivity_message_catalog.ts", + "src/application/policies/inactivity_notification_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", @@ -268,6 +270,8 @@ "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/comment_translation_policy.ts", "src/application/policies/copilot_interaction_policy.ts", + "src/application/policies/inactivity_message_catalog.ts", + "src/application/policies/inactivity_notification_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", @@ -277,6 +281,8 @@ "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/actions/close_inactive_issues_use_case.ts", + "src/application/usecases/actions/close_inactive_issues_workflow.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", @@ -288,7 +294,8 @@ "src/cli/setup_doctor_presenter.ts", "src/cli/setup_prompt_rendering.ts", "src/domain/locale.ts", - "src/domain/message_catalog.ts" + "src/domain/message_catalog.ts", + "src/infrastructure/composition/issue_inactivity_composition_root.ts" ], "mode": "aggregate", "thresholdProfile": "default" diff --git a/specs/CATALOG.md b/specs/CATALOG.md index e96a30108..e9ba30613 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,7 +10,7 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| -| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 149 paths · 2026-09-15 | +| `github-communication-experience` | Proposed | English-default, localized, semantic, bounded, and idempotent product messages across GitHub and repository-aware operator surfaces | [Semantic GitHub communication and repository localization](./semantic-github-publication-and-notification.md) + 1 companion | 160 paths · 2026-09-15 | | `release-orchestration` | Implemented | Release and hotfix promotion, publication, reconciliation, and durable recovery | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 52 paths · 2026-09-14 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 24 paths · 2026-09-15 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 56 paths · 2026-09-15 | @@ -32,10 +32,10 @@ debt or convert unknown historic intent into a design decision. - Owner: Copilot maintainers - Last verified: 2026-09-15 - Specifications: [`specs/semantic-github-publication-and-notification.md`](./semantic-github-publication-and-notification.md) · [`specs/repository-locale-and-localization.md`](./repository-locale-and-localization.md) -- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_review_state.yml`](../.github/workflows/copilot_pull_request_review_state.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) +- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_review_state.yml`](../.github/workflows/copilot_pull_request_review_state.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_close_inactive_issues.yml`](../.github/workflows/copilot_close_inactive_issues.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/cli.ts`](../src/cli.ts) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/locale.ts`](../src/domain/locale.ts) · [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) · [`src/data/model/locale.ts`](../src/data/model/locale.ts) · [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) · [`src/application/policies/action_summary_message_catalog.ts`](../src/application/policies/action_summary_message_catalog.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/policies/comment_translation_policy.ts`](../src/application/policies/comment_translation_policy.ts) · [`src/application/usecases/steps/common/think_request_policy.ts`](../src/application/usecases/steps/common/think_request_policy.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/think_answer_workflow.ts`](../src/application/usecases/steps/common/think_answer_workflow.ts) · [`src/application/usecases/steps/common/think_use_case.ts`](../src/application/usecases/steps/common/think_use_case.ts) · [`src/application/usecases/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/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/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/steps/issue/answer_issue_help_use_case.ts`](../src/application/usecases/steps/issue/answer_issue_help_use_case.ts) · [`src/application/usecases/steps/issue/answer_issue_help_workflow.ts`](../src/application/usecases/steps/issue/answer_issue_help_workflow.ts) · [`src/application/ports/issue_lifecycle_ports.ts`](../src/application/ports/issue_lifecycle_ports.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/actions/local_action.ts`](../src/actions/local_action.ts) · [`src/actions/local_action_output.ts`](../src/actions/local_action_output.ts) · [`src/cli/commands/think.ts`](../src/cli/commands/think.ts) · [`src/cli/commands/think_command_handler.ts`](../src/cli/commands/think_command_handler.ts) · [`src/infrastructure/composition/local_action_composition_root.ts`](../src/infrastructure/composition/local_action_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/issue_use_case_composition_root.ts`](../src/infrastructure/composition/issue_use_case_composition_root.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/policies/bugbot_message_catalog.ts`](../src/application/policies/bugbot_message_catalog.ts) · [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) -- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/policies/__tests__/bugbot_message_catalog.test.ts`](../src/application/policies/__tests__/bugbot_message_catalog.test.ts) · [`src/application/policies/__tests__/deployment_message_catalog.test.ts`](../src/application/policies/__tests__/deployment_message_catalog.test.ts) · [`src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts`](../src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/application/policies/__tests__/deployment_presentation_policy.test.ts`](../src/application/policies/__tests__/deployment_presentation_policy.test.ts) · [`src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/think_request_policy.test.ts`](../src/application/usecases/steps/common/__tests__/think_request_policy.test.ts) · [`src/application/usecases/steps/common/__tests__/think_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/think_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/actions/__tests__/local_action.test.ts`](../src/actions/__tests__/local_action.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/commands/__tests__/think_command_handler.test.ts`](../src/cli/commands/__tests__/think_command_handler.test.ts) · [`src/infrastructure/composition/__tests__/local_action_composition_root.test.ts`](../src/infrastructure/composition/__tests__/local_action_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts`](../src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts) · [`src/application/usecases/actions/__tests__/check_progress_use_case.test.ts`](../src/application/usecases/actions/__tests__/check_progress_use_case.test.ts) · [`src/application/usecases/__tests__/comment_automation_use_case.test.ts`](../src/application/usecases/__tests__/comment_automation_use_case.test.ts) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/domain/locale.ts`](../src/domain/locale.ts) · [`src/domain/message_catalog.ts`](../src/domain/message_catalog.ts) · [`src/data/model/locale.ts`](../src/data/model/locale.ts) · [`src/actions/github_action_locale_inputs.ts`](../src/actions/github_action_locale_inputs.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/policies/resolved_message_catalog_policy.ts`](../src/application/policies/resolved_message_catalog_policy.ts) · [`src/application/policies/action_summary_message_catalog.ts`](../src/application/policies/action_summary_message_catalog.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/inactivity_message_catalog.ts`](../src/application/policies/inactivity_message_catalog.ts) · [`src/application/policies/inactivity_notification_policy.ts`](../src/application/policies/inactivity_notification_policy.ts) · [`src/application/policies/merge_queue_message_catalog.ts`](../src/application/policies/merge_queue_message_catalog.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/policies/comment_translation_policy.ts`](../src/application/policies/comment_translation_policy.ts) · [`src/application/usecases/steps/common/think_request_policy.ts`](../src/application/usecases/steps/common/think_request_policy.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/think_answer_workflow.ts`](../src/application/usecases/steps/common/think_answer_workflow.ts) · [`src/application/usecases/steps/common/think_use_case.ts`](../src/application/usecases/steps/common/think_use_case.ts) · [`src/application/usecases/comment_automation_use_case.ts`](../src/application/usecases/comment_automation_use_case.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/domain/github_publication.ts`](../src/domain/github_publication.ts) · [`src/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/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/steps/issue/answer_issue_help_use_case.ts`](../src/application/usecases/steps/issue/answer_issue_help_use_case.ts) · [`src/application/usecases/steps/issue/answer_issue_help_workflow.ts`](../src/application/usecases/steps/issue/answer_issue_help_workflow.ts) · [`src/application/ports/issue_lifecycle_ports.ts`](../src/application/ports/issue_lifecycle_ports.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/actions/local_action.ts`](../src/actions/local_action.ts) · [`src/actions/local_action_output.ts`](../src/actions/local_action_output.ts) · [`src/cli/commands/think.ts`](../src/cli/commands/think.ts) · [`src/cli/commands/think_command_handler.ts`](../src/cli/commands/think_command_handler.ts) · [`src/infrastructure/composition/local_action_composition_root.ts`](../src/infrastructure/composition/local_action_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/issue_use_case_composition_root.ts`](../src/infrastructure/composition/issue_use_case_composition_root.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/policies/bugbot_message_catalog.ts`](../src/application/policies/bugbot_message_catalog.ts) · [`src/application/policies/deployment_message_catalog.ts`](../src/application/policies/deployment_message_catalog.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_use_case.ts`](../src/application/usecases/actions/close_inactive_issues_use_case.ts) · [`src/application/usecases/actions/close_inactive_issues_workflow.ts`](../src/application/usecases/actions/close_inactive_issues_workflow.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/infrastructure/composition/issue_inactivity_composition_root.ts`](../src/infrastructure/composition/issue_inactivity_composition_root.ts) · [`src/application/policies/bugbot_review_presentation_policy.ts`](../src/application/policies/bugbot_review_presentation_policy.ts) · [`src/application/usecases/steps/commit/detect_potential_problems_workflow.ts`](../src/application/usecases/steps/commit/detect_potential_problems_workflow.ts) · [`src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts`](../src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts) · [`src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts`](../src/application/usecases/steps/commit/bugbot/synchronize_bugbot_review_presentation_use_case.ts) · [`src/application/policies/deployment_presentation_policy.ts`](../src/application/policies/deployment_presentation_policy.ts) · [`src/application/usecases/actions/recommend_steps_workflow.ts`](../src/application/usecases/actions/recommend_steps_workflow.ts) · [`src/application/usecases/actions/check_progress_workflow.ts`](../src/application/usecases/actions/check_progress_workflow.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) +- Tests: [`src/domain/__tests__/locale.test.ts`](../src/domain/__tests__/locale.test.ts) · [`src/domain/__tests__/message_catalog.test.ts`](../src/domain/__tests__/message_catalog.test.ts) · [`src/actions/__tests__/configuration_builders.test.ts`](../src/actions/__tests__/configuration_builders.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/policies/__tests__/comment_translation_policy.test.ts`](../src/application/policies/__tests__/comment_translation_policy.test.ts) · [`src/application/policies/__tests__/action_summary_message_catalog.test.ts`](../src/application/policies/__tests__/action_summary_message_catalog.test.ts) · [`src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts`](../src/application/usecases/localization/__tests__/resolve_message_catalog_use_case.test.ts) · [`src/prompts/__tests__/localize_message_catalog.test.ts`](../src/prompts/__tests__/localize_message_catalog.test.ts) · [`src/domain/__tests__/github_publication.test.ts`](../src/domain/__tests__/github_publication.test.ts) · [`src/application/policies/__tests__/publication_identity_policy.test.ts`](../src/application/policies/__tests__/publication_identity_policy.test.ts) · [`src/application/policies/__tests__/publication_message_catalog.test.ts`](../src/application/policies/__tests__/publication_message_catalog.test.ts) · [`src/application/policies/__tests__/semantic_result_publication_policy.test.ts`](../src/application/policies/__tests__/semantic_result_publication_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/policies/__tests__/inactivity_message_catalog.test.ts`](../src/application/policies/__tests__/inactivity_message_catalog.test.ts) · [`src/application/policies/__tests__/inactivity_notification_policy.test.ts`](../src/application/policies/__tests__/inactivity_notification_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/policies/__tests__/bugbot_message_catalog.test.ts`](../src/application/policies/__tests__/bugbot_message_catalog.test.ts) · [`src/application/policies/__tests__/deployment_message_catalog.test.ts`](../src/application/policies/__tests__/deployment_message_catalog.test.ts) · [`src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts`](../src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/dismiss_bugbot_findings_use_case.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/synchronize_bugbot_review_presentation_use_case.test.ts) · [`src/application/policies/__tests__/deployment_presentation_policy.test.ts`](../src/application/policies/__tests__/deployment_presentation_policy.test.ts) · [`src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts`](../src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/think_request_policy.test.ts`](../src/application/usecases/steps/common/__tests__/think_request_policy.test.ts) · [`src/application/usecases/steps/common/__tests__/think_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/think_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/status_card_publication_workflow.test.ts) · [`src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/reply_publication_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/actions/__tests__/local_action.test.ts`](../src/actions/__tests__/local_action.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/commands/__tests__/think_command_handler.test.ts`](../src/cli/commands/__tests__/think_command_handler.test.ts) · [`src/infrastructure/composition/__tests__/local_action_composition_root.test.ts`](../src/infrastructure/composition/__tests__/local_action_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/pull_request_use_case_composition_root.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts`](../src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts) · [`src/application/usecases/actions/__tests__/check_progress_use_case.test.ts`](../src/application/usecases/actions/__tests__/check_progress_use_case.test.ts) · [`src/application/usecases/__tests__/comment_automation_use_case.test.ts`](../src/application/usecases/__tests__/comment_automation_use_case.test.ts) - User documentation: [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/issues/configuration.mdx`](../docs/issues/configuration.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/issues/branch-synchronization.mdx`](../docs/issues/branch-synchronization.mdx) · [`docs/issues/type/feature.mdx`](../docs/issues/type/feature.mdx) · [`docs/issues/type/bugfix.mdx`](../docs/issues/type/bugfix.mdx) · [`docs/issues/type/docs.mdx`](../docs/issues/type/docs.mdx) · [`docs/issues/type/chore.mdx`](../docs/issues/type/chore.mdx) · [`docs/issues/type/hotfix.mdx`](../docs/issues/type/hotfix.mdx) · [`docs/issues/type/release.mdx`](../docs/issues/type/release.mdx) · [`docs/issues/deployment-orchestration.mdx`](../docs/issues/deployment-orchestration.mdx) · [`docs/pull-requests/configuration.mdx`](../docs/pull-requests/configuration.mdx) · [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/pull-requests/examples.mdx`](../docs/pull-requests/examples.mdx) · [`docs/bugbot/configuration.mdx`](../docs/bugbot/configuration.mdx) · [`docs/bugbot/finding-publication.mdx`](../docs/bugbot/finding-publication.mdx) · [`docs/bugbot/detection.mdx`](../docs/bugbot/detection.mdx) · [`docs/bugbot/how-it-works.mdx`](../docs/bugbot/how-it-works.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/bugbot/examples.mdx`](../docs/bugbot/examples.mdx) · [`docs/single-actions/configuration.mdx`](../docs/single-actions/configuration.mdx) · [`docs/single-actions/available-actions.mdx`](../docs/single-actions/available-actions.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) · [`docs/single-actions/examples.mdx`](../docs/single-actions/examples.mdx) · [`docs/security-operations/security/prompt-injection.mdx`](../docs/security-operations/security/prompt-injection.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) ### `release-orchestration` — Configurable production-first release orchestration diff --git a/specs/catalog.json b/specs/catalog.json index 7e7309bb4..2555f6bcf 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -19,6 +19,7 @@ ".github/workflows/copilot_pull_request_review_state.yml", ".github/workflows/copilot_pull_request_comment.yml", ".github/workflows/copilot_commit.yml", + ".github/workflows/copilot_close_inactive_issues.yml", ".github/workflows/copilot_deployment_orchestration.yml" ], "entrypoints": [ @@ -37,6 +38,8 @@ "src/application/policies/resolved_message_catalog_policy.ts", "src/application/policies/action_summary_message_catalog.ts", "src/application/policies/branch_sync_message_catalog.ts", + "src/application/policies/inactivity_message_catalog.ts", + "src/application/policies/inactivity_notification_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", @@ -77,6 +80,10 @@ "src/application/policies/bugbot_message_catalog.ts", "src/application/policies/deployment_message_catalog.ts", "src/application/usecases/actions/observe_branch_sync_use_case.ts", + "src/application/usecases/actions/close_inactive_issues_use_case.ts", + "src/application/usecases/actions/close_inactive_issues_workflow.ts", + "src/application/usecases/push_single_action_contexts.ts", + "src/infrastructure/composition/issue_inactivity_composition_root.ts", "src/application/policies/bugbot_review_presentation_policy.ts", "src/application/usecases/steps/commit/detect_potential_problems_workflow.ts", "src/application/usecases/steps/commit/bugbot/publish_pr_review_comments.ts", @@ -101,6 +108,8 @@ "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__/inactivity_message_catalog.test.ts", + "src/application/policies/__tests__/inactivity_notification_policy.test.ts", "src/application/policies/__tests__/setup_doctor_message_catalog.test.ts", "src/application/policies/__tests__/setup_doctor_report_policy.test.ts", "src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts", @@ -113,6 +122,8 @@ "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/policies/__tests__/deployment_presentation_policy.test.ts", + "src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts", + "src/application/usecases/__tests__/push_single_action_contexts.test.ts", "src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts", "src/application/usecases/steps/common/__tests__/think_request_policy.test.ts", "src/application/usecases/steps/common/__tests__/think_use_case.test.ts", diff --git a/specs/repository-locale-and-localization.md b/specs/repository-locale-and-localization.md index bb1edd176..0ad2e731e 100644 --- a/specs/repository-locale-and-localization.md +++ b/specs/repository-locale-and-localization.md @@ -977,6 +977,18 @@ Local Think prints the semantic answer with repository-locale labels, does not write GitHub, and treats `--issue` as optional description context rather than silently requiring issue `#1`. +The inactivity-closure slice removes the last feature-local `en`/`es` branch +from its public path. It resolves complete issue-locale and repository-summary +catalog slices before the scan, reusing one slice when both scopes match. Each +slice uses reviewed English and Spanish catalogs or the shared bounded dynamic +resolver for any other valid BCP-47 locale, and falls back atomically to +English. One pure renderer owns the terminal explanation and locale-aware +summary plurals. Read, close, and explanation-publication failures remain +distinct; a closed issue is never reported as a failed close merely because its +explanation comment could not be published. Payload evidence records scanned, +eligible, closed, commented, skipped, and failed counts without localizing the +stable keys. + ### 13.5 Rollback Rollback MUST preserve the new input inheritance reader and legacy/new marker diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index bccc4f7f1..0bcafd16f 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -962,6 +962,15 @@ effective issue or pull-request catalog. Replays therefore reconcile the exact source-comment identity, and local CLI execution can render the answer without performing any GitHub publication. +Scheduled inactivity closure now emits exactly one terminal policy explanation +after a successful native close. Its fixed structure contains only outcome, +threshold reason, and reopen guidance; internal scan steps and provider details +remain in the Job Summary and errors. The issue-locale catalog supplies complete +plural messages for the run summary, while stable ownership markers and payload +keys remain machine-readable. Revalidation, close, and comment-publication +failures are classified separately so a partial publication failure cannot +rewrite the already-completed domain outcome. + No remote product flag is required. Each phase must be independently releasable and its compatibility adapter must fail closed to Job Summary, not fall back to generic comments. diff --git a/src/application/policies/__tests__/inactivity_message_catalog.test.ts b/src/application/policies/__tests__/inactivity_message_catalog.test.ts new file mode 100644 index 000000000..d78c2c9d8 --- /dev/null +++ b/src/application/policies/__tests__/inactivity_message_catalog.test.ts @@ -0,0 +1,103 @@ +import { ResolveMessageCatalogUseCase } from '../../usecases/localization/resolve_message_catalog_use_case'; +import { + ENGLISH_INACTIVITY_DEFINITION, + INACTIVITY_MESSAGE_IDS, + resolveInactivityCatalog, + resolveStaticInactivityCatalog, + SPANISH_INACTIVITY_DEFINITION, +} from '../inactivity_message_catalog'; + +const configuration = { provider: 'codex' as const, model: 'language-model' }; + +const germanMessages = Object.freeze({ + 'inactivity.closure.heading': 'Issue wegen Inaktivität geschlossen', + 'inactivity.closure.reason': Object.freeze({ + one: 'Mindestens {count} Stunde lang wurde keine Aktivität festgestellt, während dieses Issue auf eine Antwort wartete.', + other: 'Mindestens {count} Stunden lang wurde keine Aktivität festgestellt, während dieses Issue auf eine Antwort wartete.', + }), + 'inactivity.closure.reopen': 'Wenn es noch bearbeitet werden muss, öffnen Sie es erneut und ergänzen Sie den aktuellen Kontext.', + 'inactivity.summary.scanned': Object.freeze({ + one: '{count} offenes Issue wurde geprüft.', + other: '{count} offene Issues wurden geprüft.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: '{count} Issue wurde nach dem Inaktivitätslimit geschlossen.', + other: '{count} Issues wurden nach dem Inaktivitätslimit geschlossen.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: '{count} Kandidat wurde übersprungen, weil er nicht mehr berechtigt war.', + other: '{count} Kandidaten wurden übersprungen, weil sie nicht mehr berechtigt waren.', + }), + 'inactivity.summary.none': 'Kein Issue wurde wegen Inaktivität geschlossen.', + 'inactivity.error.scan': 'Issues konnten nicht auf Inaktivität geprüft werden.', + 'inactivity.error.revalidate': 'Issue #{issueNumber} konnte vor dem Schließen nicht erneut geprüft werden.', + 'inactivity.error.close': 'Issue #{issueNumber} konnte nicht wegen Inaktivität geschlossen werden.', + 'inactivity.error.comment': 'Issue #{issueNumber} wurde geschlossen, aber die Erklärung konnte nicht veröffentlicht werden.', +}); + +describe('inactivity message catalog', () => { + it.each([ + ['en-US', 'en-US', 'Issue closed after inactivity'], + ['en-GB', 'en-US', 'Issue closed after inactivity'], + ['es-ES', 'es-ES', 'Issue cerrada por inactividad'], + ['es-MX', 'es-ES', 'Issue cerrada por inactividad'], + ])('selects the reviewed catalog for %s', (requested, resolved, heading) => { + const catalog = resolveStaticInactivityCatalog(requested); + expect(catalog).toMatchObject({ requestedLocale: requested, locale: resolved }); + expect(catalog.message('inactivity.closure.heading')).toBe(heading); + }); + + it('keeps both bundled catalogs complete, frozen, and structurally identical', () => { + expect(Object.keys(ENGLISH_INACTIVITY_DEFINITION.messages).sort()).toEqual([...INACTIVITY_MESSAGE_IDS].sort()); + expect(Object.keys(SPANISH_INACTIVITY_DEFINITION.messages).sort()).toEqual([...INACTIVITY_MESSAGE_IDS].sort()); + expect(Object.isFrozen(ENGLISH_INACTIVITY_DEFINITION.messages)).toBe(true); + expect(Object.isFrozen(SPANISH_INACTIVITY_DEFINITION.messages)).toBe(true); + }); + + it('defaults empty input and unsupported static locales atomically to English', async () => { + expect(resolveStaticInactivityCatalog('')).toMatchObject({ + requestedLocale: 'en-US', locale: 'en-US', resolutionSource: 'exact', + }); + expect(resolveStaticInactivityCatalog('ja-JP')).toMatchObject({ + requestedLocale: 'ja-JP', locale: 'en-US', resolutionSource: 'fallback', + fallbackReason: 'dynamic-provider-unavailable', + }); + await expect(resolveInactivityCatalog('', configuration, undefined)).resolves.toMatchObject({ + requestedLocale: 'en-US', locale: 'en-US', resolutionSource: 'exact', + }); + }); + + it('resolves an arbitrary valid locale with one bounded catalog request', async () => { + const query = jest.fn().mockResolvedValue({ targetLocale: 'de-DE', messages: germanMessages }); + const catalog = await resolveInactivityCatalog( + 'de-DE', + configuration, + new ResolveMessageCatalogUseCase({ query }), + ); + + expect(catalog).toMatchObject({ + requestedLocale: 'de-DE', locale: 'de-DE', resolutionSource: 'dynamic', + }); + expect(catalog.message('inactivity.summary.closed', { count: 2 }, 2)).toBe( + '2 Issues wurden nach dem Inaktivitätslimit geschlossen.', + ); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('falls back as one English slice when dynamic output is incomplete', async () => { + const query = jest.fn().mockResolvedValue({ + targetLocale: 'de-DE', + messages: { 'inactivity.closure.heading': 'Geschlossen' }, + }); + const catalog = await resolveInactivityCatalog( + 'de-DE', + configuration, + new ResolveMessageCatalogUseCase({ query }), + ); + + expect(catalog).toMatchObject({ + locale: 'en-US', resolutionSource: 'fallback', fallbackReason: 'dynamic-response-invalid', + }); + expect(catalog.message('inactivity.error.scan')).toBe('Unable to scan issues for inactivity closure.'); + }); +}); diff --git a/src/application/policies/__tests__/inactivity_notification_policy.test.ts b/src/application/policies/__tests__/inactivity_notification_policy.test.ts new file mode 100644 index 000000000..fc54ecee3 --- /dev/null +++ b/src/application/policies/__tests__/inactivity_notification_policy.test.ts @@ -0,0 +1,61 @@ +import { resolveStaticInactivityCatalog } from '../inactivity_message_catalog'; +import { + buildInactivityClosureComment, + buildInactivitySummarySteps, +} from '../inactivity_notification_policy'; + +const candidate = Object.freeze({ + number: 42, + updatedAt: '2026-08-28T00:00:00.000Z', + isPullRequest: false, + labels: ['state:awaiting-maintainer'], +}); + +describe('inactivity notification policy', () => { + it('renders one concise English terminal explanation with stable ownership', () => { + const comment = buildInactivityClosureComment({ + candidate, + thresholdHours: 168, + messages: resolveStaticInactivityCatalog('en-US'), + }); + + expect(comment).toContain('topic="inactivity" target="issue:42" key="closure"'); + expect(comment).toContain('## Issue closed after inactivity'); + expect(comment).toContain('at least 168 hours'); + expect(comment).toContain('reopen it and add a comment'); + expect(comment).not.toMatch(/Automatic Actions|Feature Actions|issue\(s\)/u); + }); + + it('renders reviewed Spanish as one catalog without changing machine identity', () => { + const comment = buildInactivityClosureComment({ + candidate, + thresholdHours: 1, + messages: resolveStaticInactivityCatalog('es-MX'), + }); + + expect(comment).toContain('topic="inactivity" target="issue:42" key="closure"'); + expect(comment).toContain('## Issue cerrada por inactividad'); + expect(comment).toContain('al menos 1 hora'); + expect(comment).toContain('vuelve a abrirla'); + }); + + it('uses locale-aware complete plural messages for every useful result state', () => { + const messages = resolveStaticInactivityCatalog('en-US'); + expect(buildInactivitySummarySteps({ scanned: 1, closed: 1, skipped: 0, messages })).toEqual([ + 'Scanned 1 open issue waiting for a response.', + 'Closed 1 issue after the inactivity threshold.', + ]); + expect(buildInactivitySummarySteps({ scanned: 3, closed: 0, skipped: 2, messages })).toEqual([ + 'Scanned 3 open issues waiting for a response.', + 'Skipped 2 candidates because they were no longer eligible.', + 'No issue was closed for inactivity.', + ]); + }); + + it('changes the semantic marker when the source facts change', () => { + const messages = resolveStaticInactivityCatalog('en-US'); + const initial = buildInactivityClosureComment({ candidate, thresholdHours: 168, messages }); + const changed = buildInactivityClosureComment({ candidate, thresholdHours: 24, messages }); + expect(initial.split('\n')[0]).not.toBe(changed.split('\n')[0]); + }); +}); diff --git a/src/application/policies/inactivity_message_catalog.ts b/src/application/policies/inactivity_message_catalog.ts new file mode 100644 index 000000000..f108537d4 --- /dev/null +++ b/src/application/policies/inactivity_message_catalog.ts @@ -0,0 +1,127 @@ +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'; + +export const INACTIVITY_MESSAGE_IDS = Object.freeze([ + 'inactivity.closure.heading', + 'inactivity.closure.reason', + 'inactivity.closure.reopen', + 'inactivity.summary.scanned', + 'inactivity.summary.closed', + 'inactivity.summary.skipped', + 'inactivity.summary.none', + 'inactivity.error.scan', + 'inactivity.error.revalidate', + 'inactivity.error.close', + 'inactivity.error.comment', +] as const); + +export type InactivityMessageId = typeof INACTIVITY_MESSAGE_IDS[number]; +export type InactivityMessageCatalog = ResolvedMessageCatalogView; + +const ENGLISH_MESSAGES: Readonly> = Object.freeze({ + 'inactivity.closure.heading': 'Issue closed after inactivity', + 'inactivity.closure.reason': Object.freeze({ + one: 'No activity was detected for at least {count} hour while this issue was waiting for a response.', + other: 'No activity was detected for at least {count} hours while this issue was waiting for a response.', + }), + 'inactivity.closure.reopen': 'If it still needs attention, reopen it and add a comment with the current context.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Scanned {count} open issue waiting for a response.', + other: 'Scanned {count} open issues waiting for a response.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Closed {count} issue after the inactivity threshold.', + other: 'Closed {count} issues after the inactivity threshold.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Skipped {count} candidate because it was no longer eligible.', + other: 'Skipped {count} candidates because they were no longer eligible.', + }), + 'inactivity.summary.none': 'No issue was closed for inactivity.', + 'inactivity.error.scan': 'Unable to scan issues for inactivity closure.', + 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', + 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', + 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', +}); + +const SPANISH_MESSAGES: Readonly> = Object.freeze({ + 'inactivity.closure.heading': 'Issue cerrada por inactividad', + 'inactivity.closure.reason': Object.freeze({ + one: 'No se detectó actividad durante al menos {count} hora mientras esta issue esperaba una respuesta.', + many: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + other: 'No se detectó actividad durante al menos {count} horas mientras esta issue esperaba una respuesta.', + }), + 'inactivity.closure.reopen': 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', + 'inactivity.summary.scanned': Object.freeze({ + one: 'Se revisó {count} issue abierta que esperaba una respuesta.', + many: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + other: 'Se revisaron {count} issues abiertas que esperaban una respuesta.', + }), + 'inactivity.summary.closed': Object.freeze({ + one: 'Se cerró {count} issue tras superar el límite de inactividad.', + many: 'Se cerraron {count} issues tras superar el límite de inactividad.', + other: 'Se cerraron {count} issues tras superar el límite de inactividad.', + }), + 'inactivity.summary.skipped': Object.freeze({ + one: 'Se omitió {count} candidata porque ya no cumplía los requisitos.', + many: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + other: 'Se omitieron {count} candidatas porque ya no cumplían los requisitos.', + }), + 'inactivity.summary.none': 'No se cerró ninguna issue por inactividad.', + 'inactivity.error.scan': 'No se pudieron revisar las issues para aplicar el cierre por inactividad.', + 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', + 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', + 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', +}); + +export const ENGLISH_INACTIVITY_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'en-US', + compatibleBaseLanguage: 'en', + messages: ENGLISH_MESSAGES, +}); + +export const SPANISH_INACTIVITY_DEFINITION: MessageCatalogDefinition = Object.freeze({ + version: MESSAGE_CATALOG_VERSION, + locale: 'es-ES', + compatibleBaseLanguage: 'es', + messages: SPANISH_MESSAGES, +}); + +export const INACTIVITY_CATALOG_DEFINITIONS = Object.freeze([ + ENGLISH_INACTIVITY_DEFINITION, + SPANISH_INACTIVITY_DEFINITION, +]); + +export function resolveStaticInactivityCatalog(locale: string): InactivityMessageCatalog { + return resolveStaticMessageCatalogView( + locale, + ENGLISH_INACTIVITY_DEFINITION, + INACTIVITY_CATALOG_DEFINITIONS, + ); +} + +export async function resolveInactivityCatalog( + locale: string, + configuration: Readonly | undefined, + resolver: MessageCatalogResolutionPort | undefined, +): Promise { + return resolveMessageCatalogView( + locale, + INACTIVITY_MESSAGE_IDS, + ENGLISH_INACTIVITY_DEFINITION, + INACTIVITY_CATALOG_DEFINITIONS, + configuration, + resolver, + ); +} diff --git a/src/application/policies/inactivity_notification_policy.ts b/src/application/policies/inactivity_notification_policy.ts new file mode 100644 index 000000000..a53d49b81 --- /dev/null +++ b/src/application/policies/inactivity_notification_policy.ts @@ -0,0 +1,66 @@ +import type { IssueActivitySnapshot } from '../../domain/issue_inactivity'; +import type { InactivityMessageCatalog } from './inactivity_message_catalog'; +import { buildPublicationMarker, createSemanticDigest } from './publication_identity_policy'; + +export function buildInactivityClosureComment(input: { + readonly candidate: IssueActivitySnapshot; + readonly thresholdHours: number; + readonly messages: InactivityMessageCatalog; +}): string { + const digest = createSemanticDigest({ + updatedAt: input.candidate.updatedAt, + thresholdHours: input.thresholdHours, + }); + const marker = buildPublicationMarker({ + identity: { + topic: 'inactivity', + target: { kind: 'issue', number: input.candidate.number }, + key: 'closure', + }, + sourceVersion: `policy:${digest}`, + digest, + }); + + return [ + marker, + '', + `## ${input.messages.message('inactivity.closure.heading')}`, + '', + input.messages.message( + 'inactivity.closure.reason', + { count: input.thresholdHours }, + input.thresholdHours, + ), + '', + input.messages.message('inactivity.closure.reopen'), + ].join('\n'); +} + +export function buildInactivitySummarySteps(input: { + readonly scanned: number; + readonly closed: number; + readonly skipped: number; + readonly messages: InactivityMessageCatalog; +}): string[] { + const steps = [input.messages.message( + 'inactivity.summary.scanned', + { count: input.scanned }, + input.scanned, + )]; + if (input.closed > 0) { + steps.push(input.messages.message( + 'inactivity.summary.closed', + { count: input.closed }, + input.closed, + )); + } + if (input.skipped > 0) { + steps.push(input.messages.message( + 'inactivity.summary.skipped', + { count: input.skipped }, + input.skipped, + )); + } + if (input.closed === 0) steps.push(input.messages.message('inactivity.summary.none')); + return steps; +} diff --git a/src/application/usecases/__tests__/push_single_action_contexts.test.ts b/src/application/usecases/__tests__/push_single_action_contexts.test.ts index 0438dc0ec..779748a44 100644 --- a/src/application/usecases/__tests__/push_single_action_contexts.test.ts +++ b/src/application/usecases/__tests__/push_single_action_contexts.test.ts @@ -203,6 +203,8 @@ describe('push and single-action context projection', () => { activityLabel: 'state:ai-processing', thresholdHours: 168, locale: 'en-US', + repositoryLocale: 'en-US', + agentConfiguration: { provider: 'codex', model: 'planner-model' }, }); }); diff --git a/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts b/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts index b046678e2..4b07f44ed 100644 --- a/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts @@ -2,6 +2,7 @@ import { CloseInactiveIssuesUseCase } from '../close_inactive_issues_use_case'; import type { Execution } from '../../../../data/model/execution'; import type { IssueActivitySnapshot } from '../../../../domain/issue_inactivity'; import { projectInactivityContext } from '../../push_single_action_contexts'; +import { ResolveMessageCatalogUseCase } from '../../localization/resolve_message_catalog_use_case'; jest.mock('../../../../utils/logger', () => ({ logInfo: jest.fn(), @@ -22,6 +23,9 @@ function execution(overrides: Record = {}) { aiProcessing: 'state:ai-processing', }, }, + ai: { + getAgentConfiguration: () => ({ provider: 'codex', model: 'planner-model' }), + }, ...overrides, } as unknown as Execution); } @@ -43,11 +47,12 @@ describe('CloseInactiveIssuesUseCase', () => { const addComment = jest.fn(); const nowMilliseconds = Date.parse('2026-09-04T00:00:00.000Z'); - function createUseCase() { + function createUseCase(catalogResolver?: ResolveMessageCatalogUseCase) { return new CloseInactiveIssuesUseCase( { listOpenIssuesByLabel, getOpenIssue }, { closeIssue, addComment }, { nowMilliseconds: () => nowMilliseconds }, + catalogResolver, ); } @@ -78,17 +83,85 @@ describe('CloseInactiveIssuesUseCase', () => { id: 'CloseInactiveIssuesUseCase', success: true, executed: true, - payload: { scanned: 1, eligible: 1, closed: 1, skipped: 0 }, + payload: { + scanned: 1, eligible: 1, closed: 1, commented: 1, + commentFailures: 0, failures: 0, skipped: 0, + }, }); }); it('uses the configured issue locale for the terminal explanation', async () => { listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); - await createUseCase().invoke(execution({ locale: { issue: 'es-MX' } })); + const [result] = await createUseCase().invoke(execution({ + locale: { repository: 'es-MX', issue: 'es-MX' }, + })); expect(addComment.mock.calls[0][1]).toContain('## Issue cerrada por inactividad'); expect(addComment.mock.calls[0][1]).toContain('168 horas'); + expect(result.steps).toEqual([ + 'Se revisó 1 issue abierta que esperaba una respuesta.', + 'Se cerró 1 issue tras superar el límite de inactividad.', + ]); + }); + + it('uses issue locale for the comment and repository locale for result evidence', async () => { + listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); + + const [result] = await createUseCase().invoke(execution({ + locale: { repository: 'en-US', issue: 'es-ES' }, + })); + + expect(addComment.mock.calls[0][1]).toContain('## Issue cerrada por inactividad'); + expect(result.steps).toEqual([ + 'Scanned 1 open issue waiting for a response.', + 'Closed 1 issue after the inactivity threshold.', + ]); + }); + + it('resolves a non-bundled locale once and reuses it for every candidate', async () => { + listOpenIssuesByLabel.mockResolvedValueOnce([snapshot(), snapshot({ number: 43 })]); + const messages = { + 'inactivity.closure.heading': 'Issue wegen Inaktivität geschlossen', + 'inactivity.closure.reason': { + one: 'Seit {count} Stunde gab es keine Aktivität.', + other: 'Seit {count} Stunden gab es keine Aktivität.', + }, + 'inactivity.closure.reopen': 'Bei Bedarf erneut öffnen und den aktuellen Kontext ergänzen.', + 'inactivity.summary.scanned': { one: '{count} Issue geprüft.', other: '{count} Issues geprüft.' }, + 'inactivity.summary.closed': { one: '{count} Issue geschlossen.', other: '{count} Issues geschlossen.' }, + 'inactivity.summary.skipped': { one: '{count} Kandidat übersprungen.', other: '{count} Kandidaten übersprungen.' }, + 'inactivity.summary.none': 'Kein Issue wurde geschlossen.', + 'inactivity.error.scan': 'Issues konnten nicht geprüft werden.', + 'inactivity.error.revalidate': 'Issue #{issueNumber} konnte nicht erneut geprüft werden.', + 'inactivity.error.close': 'Issue #{issueNumber} konnte nicht geschlossen werden.', + 'inactivity.error.comment': 'Issue #{issueNumber} wurde geschlossen, aber die Erklärung fehlt.', + }; + const query = jest.fn().mockResolvedValue({ targetLocale: 'de-DE', messages }); + + const [result] = await createUseCase( + new ResolveMessageCatalogUseCase({ query }), + ).invoke(execution({ locale: { repository: 'de-DE', issue: 'de-DE' } })); + + expect(query).toHaveBeenCalledTimes(1); + expect(addComment).toHaveBeenCalledTimes(2); + expect(addComment.mock.calls[0][1]).toContain('## Issue wegen Inaktivität geschlossen'); + expect(result.steps).toContain('2 Issues geschlossen.'); + }); + + it('falls back atomically to English when a dynamic catalog is unsafe', async () => { + listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); + const query = jest.fn().mockResolvedValue({ + targetLocale: 'de-DE', + messages: { 'inactivity.closure.heading': '@team' }, + }); + + const [result] = await createUseCase( + new ResolveMessageCatalogUseCase({ query }), + ).invoke(execution({ locale: { repository: 'de-DE', issue: 'de-DE' } })); + + expect(addComment.mock.calls[0][1]).toContain('## Issue closed after inactivity'); + expect(result.steps[0]).toBe('Scanned 1 open issue waiting for a response.'); }); it('does not close pull requests or recently active issues', async () => { @@ -100,7 +173,10 @@ describe('CloseInactiveIssuesUseCase', () => { expect(closeIssue).not.toHaveBeenCalled(); expect(addComment).not.toHaveBeenCalled(); - expect(result.payload).toEqual({ scanned: 2, eligible: 0, closed: 0, skipped: 2 }); + expect(result.payload).toEqual({ + scanned: 2, eligible: 0, closed: 0, commented: 0, + commentFailures: 0, failures: 0, skipped: 2, + }); }); it('skips a candidate that becomes active before the mutation', async () => { @@ -110,7 +186,10 @@ describe('CloseInactiveIssuesUseCase', () => { const [result] = await createUseCase().invoke(execution()); expect(closeIssue).not.toHaveBeenCalled(); - expect(result.payload).toEqual({ scanned: 1, eligible: 1, closed: 0, skipped: 1 }); + expect(result.payload).toEqual({ + scanned: 1, eligible: 1, closed: 0, commented: 0, + commentFailures: 0, failures: 0, skipped: 1, + }); }); it('continues scanning when one candidate mutation fails and reports failure', async () => { @@ -125,6 +204,34 @@ describe('CloseInactiveIssuesUseCase', () => { expect(addComment).toHaveBeenCalledTimes(1); }); + it('reports a revalidation failure without claiming that closure failed', async () => { + listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); + getOpenIssue.mockRejectedValueOnce(new Error('provider unavailable')); + + const [result] = await createUseCase().invoke(execution()); + + expect(closeIssue).not.toHaveBeenCalled(); + expect(result.errors[0].message).toBe('Unable to recheck issue #42 before inactivity closure.'); + expect(result.payload).toMatchObject({ closed: 0, failures: 1 }); + }); + + it('keeps a successful close visible when only its explanation publication fails', async () => { + listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); + addComment.mockRejectedValueOnce(new Error('comments unavailable')); + + const [result] = await createUseCase().invoke(execution()); + + expect(closeIssue).toHaveBeenCalledWith(42); + expect(result.success).toBe(false); + expect(result.errors[0].message).toBe( + 'Issue #42 was closed, but its inactivity explanation could not be published.', + ); + expect(result.payload).toMatchObject({ + closed: 1, commented: 0, commentFailures: 1, failures: 1, + }); + expect(result.steps).toContain('Closed 1 issue after the inactivity threshold.'); + }); + it('does not comment when the idempotent close operation reports that the issue is already closed', async () => { listOpenIssuesByLabel.mockResolvedValueOnce([snapshot()]); closeIssue.mockResolvedValue(false); @@ -134,7 +241,10 @@ describe('CloseInactiveIssuesUseCase', () => { expect(addComment).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, - payload: { scanned: 1, eligible: 1, closed: 0, skipped: 1 }, + payload: { + scanned: 1, eligible: 1, closed: 0, commented: 0, + commentFailures: 0, failures: 0, skipped: 1, + }, }); }); diff --git a/src/application/usecases/actions/close_inactive_issues_use_case.ts b/src/application/usecases/actions/close_inactive_issues_use_case.ts index 848f61d3c..4325490bf 100644 --- a/src/application/usecases/actions/close_inactive_issues_use_case.ts +++ b/src/application/usecases/actions/close_inactive_issues_use_case.ts @@ -4,6 +4,7 @@ import type { BoundIssueClosurePort } from '../../ports/issue_lifecycle_ports'; import type { BoundIssueInactivityQueryPort, IssueInactivityClockPort } from '../../ports/issue_inactivity_ports'; import type { InactivityContext } from '../push_single_action_contexts'; import { runCloseInactiveIssuesWorkflow } from './close_inactive_issues_workflow'; +import type { MessageCatalogResolutionPort } from '../../ports/message_catalog_ports'; /** Application boundary for the scheduled inactivity-maintenance action. */ export class CloseInactiveIssuesUseCase implements ParamUseCase { @@ -13,6 +14,7 @@ export class CloseInactiveIssuesUseCase implements ParamUseCase { @@ -20,6 +22,7 @@ export class CloseInactiveIssuesUseCase implements ParamUseCase 0 || eligibleCount > 0, - steps: buildSteps(candidates.length, closedCount, skippedCount), + steps: buildInactivitySummarySteps({ + scanned: candidates.length, + closed: closedCount, + skipped: skippedCount, + messages: resultMessages, + }), payload: { scanned: candidates.length, eligible: eligibleCount, closed: closedCount, + commented: commentedCount, + commentFailures: commentFailureCount, + failures: errors.length, skipped: skippedCount, }, errors, })]; } catch (error) { - const message = 'Unable to scan issues for inactivity closure.'; + const message = resultMessages.message('inactivity.error.scan'); logError(message); return [new Result({ id: TASK_ID, success: false, executed: true, steps: [message], - errors: [toApplicationError(error, 'provider.unavailable', `${message} ${safeErrorMessage(error)}`)], + errors: [toApplicationError(error, 'provider.unavailable', message)], })]; } } -function buildInactivityExplanation( - candidate: IssueActivitySnapshot, - thresholdHours: number, - locale: string, -): string { - const digest = createSemanticDigest({ updatedAt: candidate.updatedAt, thresholdHours }); - const marker = buildPublicationMarker({ - identity: { topic: 'inactivity', target: { kind: 'issue', number: candidate.number }, key: 'closure' }, - sourceVersion: `policy:${digest}`, - digest, - }); - if (baseLanguage(locale) === 'es') { - return [ - marker, - '', - '## Issue cerrada por inactividad', - '', - `No se detectó actividad durante al menos **${thresholdHours} horas** mientras esta issue esperaba una respuesta.`, - '', - 'Si todavía necesita atención, vuelve a abrirla y añade un comentario con el contexto actualizado.', - ].join('\n'); - } - return [ - marker, - '', - '## Issue closed after inactivity', - '', - `No activity was detected for at least **${thresholdHours} hours** while this issue was waiting for a response.`, - '', - 'If it still needs attention, reopen it and add a comment with the current context.', - ].join('\n'); -} - async function listCandidates( _param: InactivityContext, waitingLabels: readonly string[], @@ -163,18 +187,6 @@ async function listCandidates( return [...uniqueCandidates.values()]; } -function buildSteps(scanned: number, closed: number, skipped: number): string[] { - const steps = [`Scanned ${scanned} open issue(s) waiting for a response.`]; - if (closed > 0) steps.push(`Closed ${closed} issue(s) after the inactivity threshold.`); - if (skipped > 0) steps.push(`Skipped ${skipped} candidate(s) because they were no longer eligible.`); - if (closed === 0) steps.push('No issue was closed for inactivity.'); - return steps; -} - function unique(values: readonly string[]): string[] { return [...new Set(values.map(value => value.trim()).filter(Boolean))]; } - -function safeErrorMessage(_error: unknown): string { - return 'The issue provider request failed.'; -} diff --git a/src/application/usecases/push_single_action_contexts.ts b/src/application/usecases/push_single_action_contexts.ts index 32a00f28d..c1c8d22e5 100644 --- a/src/application/usecases/push_single_action_contexts.ts +++ b/src/application/usecases/push_single_action_contexts.ts @@ -57,6 +57,8 @@ export interface InactivityContext { readonly activityLabel: string; readonly thresholdHours: number; readonly locale: string; + readonly repositoryLocale: string; + readonly agentConfiguration: Readonly; } export interface BranchObservationContext { @@ -308,6 +310,8 @@ export function projectInactivityContext(source: PushSingleActionContextSource): activityLabel: source.labels.lifecycle.aiProcessing, thresholdHours: source.inactivityThresholdHours, locale: source.locale?.issue ?? 'en-US', + repositoryLocale: source.locale?.repository ?? 'en-US', + agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), }); } diff --git a/src/architecture/__tests__/github_publication_boundaries.test.ts b/src/architecture/__tests__/github_publication_boundaries.test.ts index 934a98d25..1ce8010d6 100644 --- a/src/architecture/__tests__/github_publication_boundaries.test.ts +++ b/src/architecture/__tests__/github_publication_boundaries.test.ts @@ -91,8 +91,10 @@ describe('GitHub conversation publication boundaries', () => { 'src/application/policies/deployment_presentation_policy.ts', 'src/application/policies/deployment_plan_policy.ts', 'src/application/policies/setup_doctor_report_policy.ts', + 'src/application/policies/inactivity_notification_policy.ts', 'src/application/usecases/setup/doctor_use_case.ts', 'src/application/usecases/setup/merge_queue_readiness_use_case.ts', + 'src/application/usecases/actions/close_inactive_issues_workflow.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', @@ -127,4 +129,15 @@ describe('GitHub conversation publication boundaries', () => { const violations = files.filter(file => pseudoPlural.test(readFileSync(join(root, file), 'utf8'))); expect(violations).toEqual([]); }); + + it('keeps inactivity presentation free of pseudo-plural copy', () => { + const files = [ + 'src/application/policies/inactivity_message_catalog.ts', + 'src/application/policies/inactivity_notification_policy.ts', + 'src/application/usecases/actions/close_inactive_issues_workflow.ts', + ]; + const pseudoPlural = /\p{L}+\(s\)/u; + const violations = files.filter(file => pseudoPlural.test(readFileSync(join(root, file), 'utf8'))); + expect(violations).toEqual([]); + }); }); diff --git a/src/infrastructure/composition/issue_inactivity_composition_root.ts b/src/infrastructure/composition/issue_inactivity_composition_root.ts index 85b8d895b..547d31117 100644 --- a/src/infrastructure/composition/issue_inactivity_composition_root.ts +++ b/src/infrastructure/composition/issue_inactivity_composition_root.ts @@ -6,11 +6,16 @@ import { createIssueClosureRepository } from './issue_interaction_composition_ro import type { RepositoryCredentialBinding } from './shared_capability_port_binding'; import { bindIssueClosure } from './lifecycle_capability_port_binding'; import { bindIssueInactivityQuery } from './push_single_action_capability_port_binding'; +import type { MessageCatalogResolutionPort } from '../../application/ports/message_catalog_ports'; -export function createCloseInactiveIssuesUseCase(binding: RepositoryCredentialBinding): CloseInactiveIssuesUseCase { +export function createCloseInactiveIssuesUseCase( + binding: RepositoryCredentialBinding, + catalogResolver?: MessageCatalogResolutionPort, +): CloseInactiveIssuesUseCase { return new CloseInactiveIssuesUseCase( bindIssueInactivityQuery(new IssueInactivityRepository(createIssueInactivityClient()), binding), bindIssueClosure(createIssueClosureRepository(), binding), new SystemIssueInactivityClockAdapter(), + catalogResolver, ); } diff --git a/src/infrastructure/composition/main_run_route_composition_root.ts b/src/infrastructure/composition/main_run_route_composition_root.ts index e82124944..293f927a8 100644 --- a/src/infrastructure/composition/main_run_route_composition_root.ts +++ b/src/infrastructure/composition/main_run_route_composition_root.ts @@ -144,7 +144,7 @@ export function createSingleActionUseCaseCompositionRoot( bindIssueDescriptionQuery(issueDescriptionQueryPort, binding), createFindingsQueryPort(), ), - createCloseInactiveIssuesUseCase(binding), + createCloseInactiveIssuesUseCase(binding, catalogResolver), createActorAuthorizationRepository(), new PublishIssueCommentUseCase(bindIssueCommentPublication(issueDescriptionQueryPort, binding)), new ObserveBranchSyncUseCase( From ed840c09fa96d7f6fc52d596bd48916dc0f3e87c Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 15 Sep 2026 04:59:18 +0200 Subject: [PATCH 2/2] codex-inactivity-localization: preserve partial closure state --- build/cli/index.js | 21 ++++++++++++++++++- build/github_action/index.js | 21 ++++++++++++++++++- docs/issues/notifications-and-auto-close.mdx | 3 +++ specs/repository-locale-and-localization.md | 4 +++- ...tic-github-publication-and-notification.md | 4 +++- .../inactivity_message_catalog.test.ts | 3 +++ .../policies/inactivity_message_catalog.ts | 9 ++++++++ .../close_inactive_issues_use_case.test.ts | 9 ++++++++ .../actions/close_inactive_issues_workflow.ts | 15 ++++++++++++- 9 files changed, 84 insertions(+), 5 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 68f7fb741..e62e7f527 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -44390,6 +44390,9 @@ exports.INACTIVITY_MESSAGE_IDS = Object.freeze([ 'inactivity.error.revalidate', 'inactivity.error.close', 'inactivity.error.comment', + 'inactivity.error.commentImpact', + 'inactivity.error.commentAction', + 'inactivity.error.commentRetainedState', ]); const ENGLISH_MESSAGES = Object.freeze({ 'inactivity.closure.heading': 'Issue closed after inactivity', @@ -44415,6 +44418,9 @@ const ENGLISH_MESSAGES = Object.freeze({ 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', + 'inactivity.error.commentImpact': 'Issue #{issueNumber} was closed without its terminal inactivity explanation.', + 'inactivity.error.commentAction': 'Inspect issue #{issueNumber} and add the explanation manually if the missing context matters.', + 'inactivity.error.commentRetainedState': 'Issue #{issueNumber} remains closed; the completed close will not be repeated.', }); const SPANISH_MESSAGES = Object.freeze({ 'inactivity.closure.heading': 'Issue cerrada por inactividad', @@ -44444,6 +44450,9 @@ const SPANISH_MESSAGES = Object.freeze({ 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', + 'inactivity.error.commentImpact': 'La issue #{issueNumber} se cerró sin su explicación final sobre la inactividad.', + 'inactivity.error.commentAction': 'Revisa la issue #{issueNumber} y añade la explicación manualmente si falta contexto importante.', + 'inactivity.error.commentRetainedState': 'La issue #{issueNumber} permanece cerrada; el cierre completado no se repetirá.', }); exports.ENGLISH_INACTIVITY_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, @@ -48509,7 +48518,17 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { issueNumber: candidate.number, }); (0, logging_ports_1.logError)(message); - errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { + cause: error, + retryable: false, + impact: resultMessages.message('inactivity.error.commentImpact', { + issueNumber: candidate.number, + }), + action: resultMessages.message('inactivity.error.commentAction', { + issueNumber: candidate.number, + }), + retainedState: resultMessages.message('inactivity.error.commentRetainedState', { issueNumber: candidate.number }), + })); } } (0, logging_ports_1.logDebugInfo)(`${TASK_ID}: scanned=${candidates.length}, eligible=${eligibleCount}, closed=${closedCount}, skipped=${skippedCount}.`); diff --git a/build/github_action/index.js b/build/github_action/index.js index 6034b249e..4a37961ef 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -45485,6 +45485,9 @@ exports.INACTIVITY_MESSAGE_IDS = Object.freeze([ 'inactivity.error.revalidate', 'inactivity.error.close', 'inactivity.error.comment', + 'inactivity.error.commentImpact', + 'inactivity.error.commentAction', + 'inactivity.error.commentRetainedState', ]); const ENGLISH_MESSAGES = Object.freeze({ 'inactivity.closure.heading': 'Issue closed after inactivity', @@ -45510,6 +45513,9 @@ const ENGLISH_MESSAGES = Object.freeze({ 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', + 'inactivity.error.commentImpact': 'Issue #{issueNumber} was closed without its terminal inactivity explanation.', + 'inactivity.error.commentAction': 'Inspect issue #{issueNumber} and add the explanation manually if the missing context matters.', + 'inactivity.error.commentRetainedState': 'Issue #{issueNumber} remains closed; the completed close will not be repeated.', }); const SPANISH_MESSAGES = Object.freeze({ 'inactivity.closure.heading': 'Issue cerrada por inactividad', @@ -45539,6 +45545,9 @@ const SPANISH_MESSAGES = Object.freeze({ 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', + 'inactivity.error.commentImpact': 'La issue #{issueNumber} se cerró sin su explicación final sobre la inactividad.', + 'inactivity.error.commentAction': 'Revisa la issue #{issueNumber} y añade la explicación manualmente si falta contexto importante.', + 'inactivity.error.commentRetainedState': 'La issue #{issueNumber} permanece cerrada; el cierre completado no se repetirá.', }); exports.ENGLISH_INACTIVITY_DEFINITION = Object.freeze({ version: message_catalog_1.MESSAGE_CATALOG_VERSION, @@ -48893,7 +48902,17 @@ async function runCloseInactiveIssuesWorkflow(param, dependencies) { issueNumber: candidate.number, }); (0, logging_ports_1.logError)(message); - errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { cause: error })); + errors.push(new application_error_1.ApplicationError('provider.unavailable', message, { + cause: error, + retryable: false, + impact: resultMessages.message('inactivity.error.commentImpact', { + issueNumber: candidate.number, + }), + action: resultMessages.message('inactivity.error.commentAction', { + issueNumber: candidate.number, + }), + retainedState: resultMessages.message('inactivity.error.commentRetainedState', { issueNumber: candidate.number }), + })); } } (0, logging_ports_1.logDebugInfo)(`${TASK_ID}: scanned=${candidates.length}, eligible=${eligibleCount}, closed=${closedCount}, skipped=${skippedCount}.`); diff --git a/docs/issues/notifications-and-auto-close.mdx b/docs/issues/notifications-and-auto-close.mdx index 7fee91214..25e85924d 100644 --- a/docs/issues/notifications-and-auto-close.mdx +++ b/docs/issues/notifications-and-auto-close.mdx @@ -88,6 +88,9 @@ The workflow Job Summary reports locale-aware scanned, closed, and skipped counts. Provider failures identify whether Copilot could not recheck the issue, could not close it, or closed it successfully but could not publish the terminal explanation. That last partial failure never claims that the close itself failed. +Because a later scan ignores closed issues, this partial state is marked +non-retryable and directs the operator to add the explanation manually only when +the missing context matters. ## Summary diff --git a/specs/repository-locale-and-localization.md b/specs/repository-locale-and-localization.md index 0ad2e731e..12a1fe38c 100644 --- a/specs/repository-locale-and-localization.md +++ b/specs/repository-locale-and-localization.md @@ -987,7 +987,9 @@ summary plurals. Read, close, and explanation-publication failures remain distinct; a closed issue is never reported as a failed close merely because its explanation comment could not be published. Payload evidence records scanned, eligible, closed, commented, skipped, and failed counts without localizing the -stable keys. +stable keys. That partial publication state narrows retryability because later +scans ignore closed issues, preserves the confirmed close, and supplies a +localized manual-recovery action. ### 13.5 Rollback diff --git a/specs/semantic-github-publication-and-notification.md b/specs/semantic-github-publication-and-notification.md index 0bcafd16f..9158c2fe7 100644 --- a/specs/semantic-github-publication-and-notification.md +++ b/specs/semantic-github-publication-and-notification.md @@ -969,7 +969,9 @@ remain in the Job Summary and errors. The issue-locale catalog supplies complete plural messages for the run summary, while stable ownership markers and payload keys remain machine-readable. Revalidation, close, and comment-publication failures are classified separately so a partial publication failure cannot -rewrite the already-completed domain outcome. +rewrite the already-completed domain outcome. Because a later scan cannot replay +the missing comment for an already-closed issue, that state is explicitly +non-retryable and gives the operator a localized manual recovery action. No remote product flag is required. Each phase must be independently releasable and its compatibility adapter must fail closed to Job Summary, not fall back to diff --git a/src/application/policies/__tests__/inactivity_message_catalog.test.ts b/src/application/policies/__tests__/inactivity_message_catalog.test.ts index d78c2c9d8..26bbaaa23 100644 --- a/src/application/policies/__tests__/inactivity_message_catalog.test.ts +++ b/src/application/policies/__tests__/inactivity_message_catalog.test.ts @@ -33,6 +33,9 @@ const germanMessages = Object.freeze({ 'inactivity.error.revalidate': 'Issue #{issueNumber} konnte vor dem Schließen nicht erneut geprüft werden.', 'inactivity.error.close': 'Issue #{issueNumber} konnte nicht wegen Inaktivität geschlossen werden.', 'inactivity.error.comment': 'Issue #{issueNumber} wurde geschlossen, aber die Erklärung konnte nicht veröffentlicht werden.', + 'inactivity.error.commentImpact': 'Issue #{issueNumber} wurde ohne abschließende Erklärung geschlossen.', + 'inactivity.error.commentAction': 'Prüfen Sie Issue #{issueNumber} und ergänzen Sie die Erklärung bei Bedarf manuell.', + 'inactivity.error.commentRetainedState': 'Issue #{issueNumber} bleibt geschlossen; der Abschluss wird nicht wiederholt.', }); describe('inactivity message catalog', () => { diff --git a/src/application/policies/inactivity_message_catalog.ts b/src/application/policies/inactivity_message_catalog.ts index f108537d4..d0a512387 100644 --- a/src/application/policies/inactivity_message_catalog.ts +++ b/src/application/policies/inactivity_message_catalog.ts @@ -23,6 +23,9 @@ export const INACTIVITY_MESSAGE_IDS = Object.freeze([ 'inactivity.error.revalidate', 'inactivity.error.close', 'inactivity.error.comment', + 'inactivity.error.commentImpact', + 'inactivity.error.commentAction', + 'inactivity.error.commentRetainedState', ] as const); export type InactivityMessageId = typeof INACTIVITY_MESSAGE_IDS[number]; @@ -52,6 +55,9 @@ const ENGLISH_MESSAGES: Readonly> = 'inactivity.error.revalidate': 'Unable to recheck issue #{issueNumber} before inactivity closure.', 'inactivity.error.close': 'Unable to close issue #{issueNumber} after inactivity.', 'inactivity.error.comment': 'Issue #{issueNumber} was closed, but its inactivity explanation could not be published.', + 'inactivity.error.commentImpact': 'Issue #{issueNumber} was closed without its terminal inactivity explanation.', + 'inactivity.error.commentAction': 'Inspect issue #{issueNumber} and add the explanation manually if the missing context matters.', + 'inactivity.error.commentRetainedState': 'Issue #{issueNumber} remains closed; the completed close will not be repeated.', }); const SPANISH_MESSAGES: Readonly> = Object.freeze({ @@ -82,6 +88,9 @@ const SPANISH_MESSAGES: Readonly> = 'inactivity.error.revalidate': 'No se pudo volver a comprobar la issue #{issueNumber} antes de cerrarla por inactividad.', 'inactivity.error.close': 'No se pudo cerrar la issue #{issueNumber} por inactividad.', 'inactivity.error.comment': 'La issue #{issueNumber} se cerró, pero no se pudo publicar la explicación sobre su inactividad.', + 'inactivity.error.commentImpact': 'La issue #{issueNumber} se cerró sin su explicación final sobre la inactividad.', + 'inactivity.error.commentAction': 'Revisa la issue #{issueNumber} y añade la explicación manualmente si falta contexto importante.', + 'inactivity.error.commentRetainedState': 'La issue #{issueNumber} permanece cerrada; el cierre completado no se repetirá.', }); export const ENGLISH_INACTIVITY_DEFINITION: MessageCatalogDefinition = Object.freeze({ diff --git a/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts b/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts index 4b07f44ed..d7eb8f606 100644 --- a/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/close_inactive_issues_use_case.test.ts @@ -136,6 +136,9 @@ describe('CloseInactiveIssuesUseCase', () => { 'inactivity.error.revalidate': 'Issue #{issueNumber} konnte nicht erneut geprüft werden.', 'inactivity.error.close': 'Issue #{issueNumber} konnte nicht geschlossen werden.', 'inactivity.error.comment': 'Issue #{issueNumber} wurde geschlossen, aber die Erklärung fehlt.', + 'inactivity.error.commentImpact': 'Issue #{issueNumber} wurde ohne Erklärung geschlossen.', + 'inactivity.error.commentAction': 'Issue #{issueNumber} manuell prüfen.', + 'inactivity.error.commentRetainedState': 'Issue #{issueNumber} bleibt geschlossen.', }; const query = jest.fn().mockResolvedValue({ targetLocale: 'de-DE', messages }); @@ -226,6 +229,12 @@ describe('CloseInactiveIssuesUseCase', () => { expect(result.errors[0].message).toBe( 'Issue #42 was closed, but its inactivity explanation could not be published.', ); + expect(result.errors[0]).toMatchObject({ + retryable: false, + impact: 'Issue #42 was closed without its terminal inactivity explanation.', + action: 'Inspect issue #42 and add the explanation manually if the missing context matters.', + retainedState: 'Issue #42 remains closed; the completed close will not be repeated.', + }); expect(result.payload).toMatchObject({ closed: 1, commented: 0, commentFailures: 1, failures: 1, }); diff --git a/src/application/usecases/actions/close_inactive_issues_workflow.ts b/src/application/usecases/actions/close_inactive_issues_workflow.ts index 9c94ffe34..16316536a 100644 --- a/src/application/usecases/actions/close_inactive_issues_workflow.ts +++ b/src/application/usecases/actions/close_inactive_issues_workflow.ts @@ -131,7 +131,20 @@ export async function runCloseInactiveIssuesWorkflow( issueNumber: candidate.number, }); logError(message); - errors.push(new ApplicationError('provider.unavailable', message, { cause: error })); + errors.push(new ApplicationError('provider.unavailable', message, { + cause: error, + retryable: false, + impact: resultMessages.message('inactivity.error.commentImpact', { + issueNumber: candidate.number, + }), + action: resultMessages.message('inactivity.error.commentAction', { + issueNumber: candidate.number, + }), + retainedState: resultMessages.message( + 'inactivity.error.commentRetainedState', + { issueNumber: candidate.number }, + ), + })); } }