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
14 changes: 9 additions & 5 deletions docs/src/content/docs/ai-summaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +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. |
| `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 @@ -129,7 +130,10 @@ inherited object. Endpoints ending in `/responses` use Responses; others use
Chat Completions. Both require structured JSON output. Invalid settings are
rejected; missing tokens and provider errors retain the original output. Failure
warnings identify the generation stage and a fixed reason code, plus HTTP status
for rejected requests. Credentials, source content and provider bodies are omitted.
for rejected requests. `reason=request-timeout` means one call took longer than
30 seconds; `reason=deadline` means the whole run used up its `timeout`. Debug
logs (`CRAFT_LOG_LEVEL=Debug`) show the duration of each model call.
Credentials, source content and provider bodies are omitted.

## How summaries are generated

Expand Down Expand Up @@ -164,8 +168,8 @@ The model also receives each node's character budget as planning context.
At every level, summaries without a complete sentence ending or with malformed
control text are rejected and get the same bounded repair attempt.
Invalid structured model output gets one repair attempt within the same
64-request and 120-second budgets. Transport and authentication failures do not
trigger retries or provider switching. Source-linked critical facts are carried
64-request and total `timeout` budgets. Transport and authentication failures do
not trigger retries or provider switching. Source-linked critical facts are carried
and validated internally without appending their text to every displayed summary.
All original entries remain expanded beneath their summaries, including links
and nested content. Craft does not add accordion wrappers; any expandable
Expand All @@ -178,8 +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, 120 seconds total, 24,000 input bytes and 8,192 output
tokens. Any failure preserves the entire original changelog without retries or
30 seconds per request, `timeout` seconds total (120 by default), 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.

Dry runs, highlighted PR previews, `prepare --no-changelog` and version analysis
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,27 @@ describe('validateConfiguration', () => {
apiKeyEnv: 'GITHUB_TOKEN',
},
minItems: 5,
timeout: 120,
levels: ['release'],
});
});

test('accepts a longer AI summary timeout', () => {
loadConfigurationFromString(
'changelog:\n policy: auto\n aiSummary:\n timeout: 600',
);
expect(getChangelogConfig().aiSummary).toMatchObject({ timeout: 600 });
});

test.each([29, 1801, 1.5, '600'])(
'rejects invalid AI summary timeout %j',
timeout => {
expect(() =>
validateConfiguration({ changelog: { aiSummary: { timeout } } }),
).toThrow(/changelog/);
},
);

test('normalizes optional AI writing style', () => {
loadConfigurationFromString(
'changelog:\n policy: auto\n aiSummary:\n style: " Concise and friendly. "',
Expand Down
6 changes: 6 additions & 0 deletions src/schemas/project_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
literal,
looseObject,
maxLength,
maxValue,
minLength,
minValue,
number,
Expand Down Expand Up @@ -379,6 +380,11 @@ 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. */
timeout: optional(
pipe(number(), integer(), minValue(30), maxValue(1800)),
120,
),
style: optional(pipe(string(), trim(), minLength(1), maxLength(500))),
levels: optional(
pipe(
Expand Down
4 changes: 2 additions & 2 deletions src/utils/__tests__/ai-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ test('HTTP rejection reports its status even if response cancellation stalls', a
}
}, 1000);

test('the full summarizer reports its request deadline without exposing the abort error', async () => {
test('the full summarizer reports its request timeout without exposing the abort error', async () => {
vi.useFakeTimers();
vi.stubGlobal(
'fetch',
Expand Down Expand Up @@ -662,7 +662,7 @@ test('the full summarizer reports its request deadline without exposing the abor
await vi.advanceTimersByTimeAsync(30001);
expect(await result).toBeNull();
const log = warn.mock.calls.flat().join(' ');
expect(log).toContain('stage=scope; reason=deadline');
expect(log).toContain('stage=scope; reason=request-timeout');
expect(log).not.toContain('private-source-token');
} finally {
vi.useRealTimers();
Expand Down
34 changes: 33 additions & 1 deletion src/utils/__tests__/ai-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const config = {
apiKeyEnv: 'AI_SUMMARY_TOKEN',
},
minItems: 5,
timeout: 120,
levels: ['release', 'category', 'scope'] as (
| 'release'
| 'category'
Expand Down Expand Up @@ -1400,7 +1401,7 @@ describe('hierarchical HTTP changelog summaries', () => {
),
).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(`stage=${level}; reason=deadline`),
expect.stringContaining(`stage=${level}; reason=request-timeout`),
);
},
);
Expand Down Expand Up @@ -1461,6 +1462,37 @@ 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(['scope', 'release'] as const)(
'rejects array-shaped %s summary maps without coercing them to objects',
async level => {
Expand Down
1 change: 1 addition & 0 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ describe('generateChangesetFromGit', () => {
apiKeyEnv: 'AI_SUMMARY_TOKEN',
},
minItems: 5,
timeout: 120,
levels: ['release', 'category', 'scope'] as (
| 'release'
| 'category'
Expand Down
1 change: 1 addition & 0 deletions src/utils/ai-summary-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type SummaryFailureReason =
| 'http'
| 'network'
| 'deadline'
| 'request-timeout'
| 'response-size'
| 'response-body'
| 'response-envelope'
Expand Down
33 changes: 26 additions & 7 deletions src/utils/ai-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,6 @@ function summaryResponseSchema(level: SummaryLevel, nodes: Node[]) {
const MAX_INPUT_BYTES = 24000;
const MAX_NODES = 8;
const MAX_REQUESTS = 64;
const TOTAL_TIMEOUT = 120000;
const REQUEST_TIMEOUT = 30000;
const CONCURRENCY = 3;
const size = (value: unknown) =>
Expand Down Expand Up @@ -357,13 +356,17 @@ function fragments(text: string): string[] {
class SummaryAttempt {
private requests = 0;
private controller = new AbortController();
private deadline = Date.now() + TOTAL_TIMEOUT;
private timer = setTimeout(() => this.controller.abort(), TOTAL_TIMEOUT);
private deadline: number;
private timer: ReturnType<typeof setTimeout>;

constructor(
private provider: SummaryProvider,
timeout: number,
private style?: string,
) {}
) {
this.deadline = Date.now() + timeout;
this.timer = setTimeout(() => this.controller.abort(), timeout);
}

async close(): Promise<void> {
clearTimeout(this.timer);
Expand All @@ -384,8 +387,14 @@ class SummaryAttempt {
const requestController = new AbortController();
const abort = () => requestController.abort();
this.controller.signal.addEventListener('abort', abort, { once: true });
const deadline = Math.min(Date.now() + REQUEST_TIMEOUT, this.deadline);
const timer = setTimeout(abort, Math.max(0, deadline - Date.now()));
const started = Date.now();
const deadline = Math.min(started + REQUEST_TIMEOUT, this.deadline);
const timer = setTimeout(abort, Math.max(0, deadline - started));
// Tell a slow single call apart from running out of the total budget.
const expired = () =>
this.controller.signal.aborted || Date.now() >= this.deadline
? 'deadline'
: 'request-timeout';
try {
const input = summaryInput(level, nodes);
if (size(input) > MAX_INPUT_BYTES)
Expand All @@ -411,8 +420,11 @@ class SummaryAttempt {
: instructions,
schema: summaryResponseSchema(level, nodes),
});
logger.debug(
`AI ${level} summary request ${this.requests} (${nodes.length} nodes${repair ? ', repair' : ''}) took ${Date.now() - started}ms.`,
);
if (requestController.signal.aborted || Date.now() >= deadline)
throw new SummaryFailure('deadline');
throw new SummaryFailure(expired());
const parsed = safeParse(
level === 'release' ? releaseResponseSchema : responseSchema,
output,
Expand Down Expand Up @@ -502,6 +514,12 @@ class SummaryAttempt {
.parts.reduce((n, part) => n + part.count, 0),
}));
} catch (error) {
if (
error instanceof SummaryFailure &&
error.reason === 'deadline' &&
requestController.signal.aborted
)
throw new SummaryFailure(expired(), level);
// Retry malformed model output once, never transport/auth failures. The
// retry consumes the same global request/deadline budgets as any call.
if (
Expand Down Expand Up @@ -696,6 +714,7 @@ export async function summarizeChangelog(
return null;
const attempt = new SummaryAttempt(
createSummaryProvider(config),
config.timeout * 1000,
config.style,
);
try {
Expand Down
Loading