From 0b8f2762f5efefe18872e0cda5a102d23ec718fa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:32:28 +0000 Subject: [PATCH] feat(changelog): Allow disabling the AI summary total timeout Setting aiSummary.timeout to 0 removes the total budget, leaving the 30-second per-request and 64-request limits. Finite values still need at least 30 seconds; the 1800-second cap goes away since unlimited is now allowed. --- docs/src/content/docs/ai-summaries.md | 5 +- src/__tests__/config.test.ts | 8 ++-- src/schemas/project_config.ts | 12 +++-- src/utils/__tests__/ai-summary.test.ts | 63 ++++++++++++++------------ src/utils/ai-summary.ts | 11 +++-- 5 files changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/src/content/docs/ai-summaries.md b/docs/src/content/docs/ai-summaries.md index 46d454b7..1900b306 100644 --- a/docs/src/content/docs/ai-summaries.md +++ b/docs/src/content/docs/ai-summaries.md @@ -25,7 +25,7 @@ changelog: | --- | --- | --- | | `levels` | `[release]` | Select visible summaries; omitted or empty arrays use `[release]`. | | `minItems` | `5` | Strict entry-count threshold. `0` includes every nonempty selected group. Category counts include descendants; release counts include the whole changelog. | -| `timeout` | `120` | Total generation budget in seconds (30–1800). Raise it for long changelogs, especially with `scope` and `category` levels; a run that runs out reports `reason=deadline` and keeps the original entries. | +| `timeout` | `120` | Total generation budget in seconds: `0` for no total limit, otherwise at least `30`. Raise it for long changelogs, especially with `scope` and `category` levels; a run that runs out reports `reason=deadline` and keeps the original entries. | | `style` | Neutral | Optional writing guidance (1–500 characters), applied at every level. Facts, coverage, length and plain-text safeguards still apply. | | `llm.endpoint` | `https://api.githubcopilot.com/responses` | Full POST URL; HTTPS required except loopback HTTP. | | `llm.model` | `gpt-5.4-nano` | Model ID supported by the endpoint and credential. | @@ -182,7 +182,8 @@ category/release summaries. Unscoped entries contribute to both parent levels. Generation sends changelog content and critical PR titles to the chosen provider. Breaking/security facts retain source IDs. Generated prose is escaped; review it for accuracy. Requests are bounded to 64 calls, 3 concurrent calls, -30 seconds per request, `timeout` seconds total (120 by default), 24,000 input +30 seconds per request, `timeout` seconds total (120 by default; `0` for no +total limit), 24,000 input bytes and 8,192 output tokens. Any failure preserves the entire original changelog without retries or provider switching. Existing leftover-entry limits still apply. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index e80c1b3b..704543a4 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -171,14 +171,14 @@ describe('validateConfiguration', () => { }); }); - test('accepts a longer AI summary timeout', () => { + test.each([0, 30, 600, 3600])('accepts AI summary timeout %s', timeout => { loadConfigurationFromString( - 'changelog:\n policy: auto\n aiSummary:\n timeout: 600', + `changelog:\n policy: auto\n aiSummary:\n timeout: ${timeout}`, ); - expect(getChangelogConfig().aiSummary).toMatchObject({ timeout: 600 }); + expect(getChangelogConfig().aiSummary).toMatchObject({ timeout }); }); - test.each([29, 1801, 1.5, '600'])( + test.each([-1, 29, 1.5, '600'])( 'rejects invalid AI summary timeout %j', timeout => { expect(() => diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index e899bd0a..22f0f911 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -9,7 +9,6 @@ import { literal, looseObject, maxLength, - maxValue, minLength, minValue, number, @@ -380,9 +379,16 @@ type SummaryLevel = 'release' | 'category' | 'scope'; export const AiSummaryConfigSchema = strictObject({ llm: optional(nonArray(AiSummaryLlmSchema), {}), minItems: optional(pipe(number(), integer(), minValue(0)), 5), - /** Total generation budget in seconds; large changelogs need more time. */ + /** Total generation budget in seconds; `0` removes the total limit. */ timeout: optional( - pipe(number(), integer(), minValue(30), maxValue(1800)), + pipe( + number(), + integer(), + check( + timeout => timeout === 0 || timeout >= 30, + 'AI summary timeout must be 0 (no limit) or at least 30 seconds', + ), + ), 120, ), style: optional(pipe(string(), trim(), minLength(1), maxLength(500))), diff --git a/src/utils/__tests__/ai-summary.test.ts b/src/utils/__tests__/ai-summary.test.ts index 680a66ae..7a5bb3ad 100644 --- a/src/utils/__tests__/ai-summary.test.ts +++ b/src/utils/__tests__/ai-summary.test.ts @@ -1462,36 +1462,39 @@ describe('hierarchical HTTP changelog summaries', () => { expect(requestMock.mock.calls.at(-1)![0]!.signal!.aborted).toBe(true); }); - it('uses the configured total timeout for long-running changelogs', async () => { - vi.useFakeTimers(); - requestMock.mockImplementation( - async options => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve(modelResponse(options)), - 25000, - ); - options!.signal!.addEventListener('abort', () => { - clearTimeout(timer); - reject(new Error('aborted')); - }); - }), - ); - const groups = Array.from({ length: 50 }, (_, i) => ({ - id: `g${i}`, - title: 'G', - items: ['entry'], - })); - // Five sequential waves of 25-second calls outlast the default 120 seconds. - const pending = summarizeChangelog([{ id: 'a', title: 'A', groups }], { - ...config, - minItems: 0, - timeout: 600, - }); - await vi.advanceTimersByTimeAsync(200000); - expect((await pending)?.release).toBeTruthy(); - expect(logger.warn).not.toHaveBeenCalled(); - }); + it.each([600, 0])( + 'uses the configured total timeout %s for long-running changelogs', + async timeout => { + vi.useFakeTimers(); + requestMock.mockImplementation( + async options => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve(modelResponse(options)), + 25000, + ); + options!.signal!.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('aborted')); + }); + }), + ); + const groups = Array.from({ length: 50 }, (_, i) => ({ + id: `g${i}`, + title: 'G', + items: ['entry'], + })); + // Five sequential waves of 25-second calls outlast the default 120 seconds. + const pending = summarizeChangelog([{ id: 'a', title: 'A', groups }], { + ...config, + minItems: 0, + timeout, + }); + await vi.advanceTimersByTimeAsync(200000); + expect((await pending)?.release).toBeTruthy(); + expect(logger.warn).not.toHaveBeenCalled(); + }, + ); it.each(['scope', 'release'] as const)( 'rejects array-shaped %s summary maps without coercing them to objects', diff --git a/src/utils/ai-summary.ts b/src/utils/ai-summary.ts index f4d1ca3c..e98fe420 100644 --- a/src/utils/ai-summary.ts +++ b/src/utils/ai-summary.ts @@ -356,16 +356,19 @@ function fragments(text: string): string[] { class SummaryAttempt { private requests = 0; private controller = new AbortController(); - private deadline: number; - private timer: ReturnType; + private deadline = Infinity; + private timer?: ReturnType; + /** A zero timeout leaves only the per-request and request-count limits. */ constructor( private provider: SummaryProvider, timeout: number, private style?: string, ) { - this.deadline = Date.now() + timeout; - this.timer = setTimeout(() => this.controller.abort(), timeout); + if (timeout > 0) { + this.deadline = Date.now() + timeout; + this.timer = setTimeout(() => this.controller.abort(), timeout); + } } async close(): Promise {