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
5 changes: 3 additions & 2 deletions docs/src/content/docs/ai-summaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
12 changes: 9 additions & 3 deletions src/schemas/project_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
literal,
looseObject,
maxLength,
maxValue,
minLength,
minValue,
number,
Expand Down Expand Up @@ -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))),
Expand Down
63 changes: 33 additions & 30 deletions src/utils/__tests__/ai-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 7 additions & 4 deletions src/utils/ai-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,16 +356,19 @@ function fragments(text: string): string[] {
class SummaryAttempt {
private requests = 0;
private controller = new AbortController();
private deadline: number;
private timer: ReturnType<typeof setTimeout>;
private deadline = Infinity;
private timer?: ReturnType<typeof setTimeout>;

/** 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<void> {
Expand Down
Loading