Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 243 additions & 64 deletions build/cli/index.js

Large diffs are not rendered by default.

307 changes: 243 additions & 64 deletions build/github_action/index.js

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions docs/issues/notifications-and-auto-close.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ 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.
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

| Behavior | Controlled by | Where it runs |
Expand Down
9 changes: 8 additions & 1 deletion scripts/coverage-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions specs/CATALOG.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions specs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions specs/repository-locale-and-localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,20 @@ 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. 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

Rollback MUST preserve the new input inheritance reader and legacy/new marker
Expand Down
11 changes: 11 additions & 0 deletions specs/semantic-github-publication-and-notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,17 @@ 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. 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
generic comments.
Expand Down
106 changes: 106 additions & 0 deletions src/application/policies/__tests__/inactivity_message_catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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.',
'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', () => {
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.');
});
});
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Loading