Skip to content

Commit 030803c

Browse files
refactor(changelog): prose Speakeasy summaries + skip empty releases (#511)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 60619e0 commit 030803c

4 files changed

Lines changed: 121 additions & 70 deletions

File tree

packages/changelog-generator/src/pipeline.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RawRelease, ReleaseResult } from './types.js';
55
import { fetchNewReleases } from './release-fetcher.js';
66
import { summarizeRelease } from './summarizer.js';
77
import { renderEntry } from './entry-renderer.js';
8+
import { preProcessRelease } from './preprocessors/index.js';
89

910
export async function processRelease(
1011
release: RawRelease,
@@ -15,6 +16,22 @@ export async function processRelease(
1516
},
1617
): Promise<ReleaseResult> {
1718
try {
19+
// Skip Speakeasy releases that have no structured API changes — these are
20+
// SDK rebuilds (e.g. "Publishing Completed") with nothing user-facing to
21+
// describe, and would otherwise produce noise entries in the changelog.
22+
const preProcessed = preProcessRelease(release);
23+
if (
24+
preProcessed.format === 'speakeasy' &&
25+
preProcessed.structuredChanges.length === 0
26+
) {
27+
return {
28+
status: 'skipped',
29+
owner: release.owner,
30+
repo: release.repo,
31+
reason: `${release.tag} has no structured API changes`,
32+
};
33+
}
34+
1835
const result = await summarizeRelease(release.body, {
1936
mode: opts.summarization.mode,
2037
maxBullets: opts.summarization.maxBullets,

packages/changelog-generator/src/preprocessors/__tests__/speakeasy.test.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ Generated with [Speakeasy CLI 1.759.3](https://github.com/speakeasy-api/speakeas
5454
5555
Publishing Completed`;
5656

57+
const FIXTURE_EMPTY_SPEAKEASY = `# Generated by Speakeasy CLI
58+
59+
## 2026-04-20 17:34:02
60+
### Changes
61+
Based on:
62+
- OpenAPI Doc 0.9.0
63+
- Speakeasy CLI 1.761.8 (2.881.2) https://github.com/speakeasy-api/speakeasy
64+
### Generated
65+
- [typescript v0.14.19] .
66+
### Releases
67+
- [NPM v0.14.19] https://www.npmjs.com/package/@gleanwork/api-client/v/0.14.19 - .
68+
69+
Publishing Completed`;
70+
5771
const FIXTURE_NOT_SPEAKEASY = `## What's Changed
5872
* Add installable skills for SDK usage
5973
* Add injectable GleanContext
@@ -91,6 +105,10 @@ describe('isSpeakeasyFormat', () => {
91105
false,
92106
);
93107
});
108+
109+
it('returns true for empty Speakeasy releases (Publishing Completed only)', () => {
110+
expect(isSpeakeasyFormat(FIXTURE_EMPTY_SPEAKEASY)).toBe(true);
111+
});
94112
});
95113

96114
describe('parseSpeakeasyNotes', () => {
@@ -180,6 +198,11 @@ describe('parseSpeakeasyNotes', () => {
180198
});
181199
});
182200

201+
it('returns no changes for empty Speakeasy releases (signal for pipeline skip)', () => {
202+
const changes = parseSpeakeasyNotes(FIXTURE_EMPTY_SPEAKEASY);
203+
expect(changes).toHaveLength(0);
204+
});
205+
183206
it('returns correct method and action for each change', () => {
184207
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_40);
185208

@@ -199,34 +222,45 @@ describe('formatSpeakeasySummary', () => {
199222
expect(result).toBe('Maintenance updates and improvements.');
200223
});
201224

202-
it('produces a single string without markdown bullets at line start', () => {
225+
it('produces prose with no markdown bullets', () => {
203226
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_41);
204227
const result = formatSpeakeasySummary(changes);
205228

206-
// No lines starting with "- " (markdown bullet)
207-
const lines = result.split('\n');
208-
for (const line of lines) {
229+
for (const line of result.split('\n')) {
209230
expect(line).not.toMatch(/^- /);
210231
}
211232
});
212233

213-
it('uses " - " separator for details', () => {
234+
it('groups changes by action verb into one sentence each', () => {
235+
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_40);
236+
const result = formatSpeakeasySummary(changes);
237+
238+
// FIXTURE_GO_v0_11_40 has one 'changed' and one 'added'
239+
expect(result).toMatch(/^Added\b/);
240+
expect(result).toContain('Changed ');
241+
});
242+
243+
it('backticks both field and method names', () => {
214244
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_41);
215245
const result = formatSpeakeasySummary(changes);
216-
expect(result).toContain(' - ');
246+
247+
// Each phrase should look like `field` to|from|on `method`
248+
expect(result).toMatch(/`[A-Za-z]+`\s+(to|from|on)\s+`[a-z.()]+`/);
217249
});
218250

219-
it('does not produce garbage like "- : - **Added**"', () => {
251+
it('preserves dotted method paths', () => {
220252
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_41);
221253
const result = formatSpeakeasySummary(changes);
222-
expect(result).not.toMatch(/- :\s*-?\s*\*\*/);
254+
255+
// "Glean.Governance.Createfindingsexport()" -> "governance.createfindingsexport()"
256+
expect(result).toContain('governance.createfindingsexport()');
257+
expect(result).toContain('governance.listfindingsexports()');
223258
});
224259

225260
it('mentions actual field/method names from the changes', () => {
226261
const changes = parseSpeakeasyNotes(FIXTURE_GO_v0_11_40);
227262
const result = formatSpeakeasySummary(changes);
228263

229-
// Should reference method names or fields
230264
expect(result.toLowerCase()).toMatch(
231265
/insights|retrieve|search|retrievefeed|agentsresponse|spreadsheettype/,
232266
);
@@ -243,7 +277,17 @@ describe('formatSpeakeasySummary', () => {
243277
const result = formatSpeakeasySummary(changes);
244278

245279
expect(result.length).toBeGreaterThan(20);
246-
// Should start with an uppercase letter or digit
247-
expect(result[0]).toMatch(/[A-Z0-9]/);
280+
expect(result[0]).toMatch(/[A-Z]/);
281+
expect(result.endsWith('.') || result.endsWith('...')).toBe(true);
282+
});
283+
284+
it('caps items per verb with "and N more" when there are many changes', () => {
285+
const changes = parseSpeakeasyNotes(FIXTURE_TS_v0_14_14);
286+
// FIXTURE_TS_v0_14_14 has 9 'added' changes, default cap is 4
287+
const result = formatSpeakeasySummary(changes, {
288+
maxBullets: 4,
289+
maxChars: 1000,
290+
});
291+
expect(result).toMatch(/and \d+ more/);
248292
});
249293
});

packages/changelog-generator/src/preprocessors/speakeasy.ts

Lines changed: 42 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -70,44 +70,48 @@ export function parseSpeakeasyNotes(text: string): ParsedChange[] {
7070
return changes;
7171
}
7272

73-
/**
74-
* Simplify a deep field path for human-readable summary.
75-
* e.g. "request.chatRequest.messages[].citations[].sourceFile.metadata.status.enum(partiallyProcessed)"
76-
* -> "partiallyProcessed"
77-
*
78-
* But for simple paths: "request.document.nativeAppUrl" -> "nativeAppUrl"
79-
*/
8073
function simplifyFieldPath(field: string): string {
81-
// Extract enum value if present: .enum(value) -> "value enum"
8274
const enumMatch = field.match(/\.enum\((\w+)\)$/i);
8375
if (enumMatch) {
8476
return enumMatch[1];
8577
}
8678

87-
// Get the last meaningful segment
8879
const segments = field.split('.');
8980
const last = segments[segments.length - 1]
9081
.replace(/\[\]/g, '')
9182
.replace(/Map<\w+>/g, '');
9283
return last;
9384
}
9485

95-
/**
96-
* Extract a short method name from the full SDK path.
97-
* e.g. "Glean.Client.Chat.Create()" -> "chat create"
98-
* e.g. "glean.client.chat.create()" -> "chat create"
99-
* e.g. "Glean.Governance.Createfindingsexport()" -> "governance createfindingsexport"
100-
*/
10186
function shortMethodName(method: string): string {
102-
const parts = method.replace(/\(\)$/, '').split('.');
103-
// Skip "Glean"/"glean" and "Client"/"client" prefixes
104-
const meaningful = parts.filter((p) => !/^(glean|client)$/i.test(p));
105-
return meaningful.join(' ').toLowerCase();
87+
// Strip the leading "Glean." namespace but preserve everything else,
88+
// so we keep meaningful paths like "client.insights.retrieve()" or
89+
// "governance.createfindingsexport()".
90+
return method.replace(/^[Gg]lean\./, '').toLowerCase();
10691
}
10792

93+
function joinList(items: string[]): string {
94+
if (items.length === 0) return '';
95+
if (items.length === 1) return items[0];
96+
if (items.length === 2) return `${items[0]} and ${items[1]}`;
97+
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
98+
}
99+
100+
const VERBS: Array<{
101+
action: ParsedChange['action'];
102+
verb: string;
103+
preposition: string;
104+
}> = [
105+
{ action: 'added', verb: 'Added', preposition: 'to' },
106+
{ action: 'removed', verb: 'Removed', preposition: 'from' },
107+
{ action: 'changed', verb: 'Changed', preposition: 'on' },
108+
{ action: 'deprecated', verb: 'Deprecated', preposition: 'on' },
109+
];
110+
108111
/**
109-
* Format parsed Speakeasy changes into a human-readable summary
110-
* matching the repo's established entry format.
112+
* Format parsed Speakeasy changes into prose: one sentence per action verb,
113+
* with backticked field and method names. Deterministic — only emits
114+
* identifiers that appear in the parsed changes.
111115
*/
112116
export function formatSpeakeasySummary(
113117
changes: ParsedChange[],
@@ -117,47 +121,26 @@ export function formatSpeakeasySummary(
117121
return 'Maintenance updates and improvements.';
118122
}
119123

120-
const maxBullets = opts.maxBullets ?? 3;
121124
const maxChars = opts.maxChars ?? 300;
122-
123-
// Group changes by action type
124-
const byAction = new Map<string, ParsedChange[]>();
125-
for (const c of changes) {
126-
const existing = byAction.get(c.action) || [];
127-
existing.push(c);
128-
byAction.set(c.action, existing);
125+
const itemsPerVerb = Math.max(1, opts.maxBullets ?? 4);
126+
127+
const sentences: string[] = [];
128+
for (const { action, verb, preposition } of VERBS) {
129+
const items = changes.filter((c) => c.action === action);
130+
if (items.length === 0) continue;
131+
132+
const phrases = items.slice(0, itemsPerVerb).map((c) => {
133+
const field = simplifyFieldPath(c.field);
134+
const method = shortMethodName(c.method);
135+
return `\`${field}\` ${preposition} \`${method}\``;
136+
});
137+
138+
const overflow = items.length - itemsPerVerb;
139+
const tail = overflow > 0 ? ` and ${overflow} more` : '';
140+
sentences.push(`${verb} ${joinList(phrases)}${tail}.`);
129141
}
130142

131-
// Group changes by simplified method for the intro
132-
const methodNames = new Set(changes.map((c) => shortMethodName(c.method)));
133-
const methodList = [...methodNames].slice(0, 4);
134-
const methodSuffix =
135-
methodNames.size > 4 ? ` and ${methodNames.size - 4} more` : '';
136-
137-
// Build intro sentence
138-
const actionCounts: string[] = [];
139-
for (const [action, items] of byAction) {
140-
actionCounts.push(
141-
`${items.length} field${items.length > 1 ? 's' : ''} ${action}`,
142-
);
143-
}
144-
145-
const intro = `${actionCounts.join(', ')} across ${methodList.join(', ')}${methodSuffix} endpoints.`;
146-
147-
// Build detail bullets (up to maxBullets) using ` - ` separator format
148-
const details: string[] = [];
149-
for (const c of changes.slice(0, maxBullets)) {
150-
const fieldName = simplifyFieldPath(c.field);
151-
const method = shortMethodName(c.method);
152-
details.push(`${c.action} ${fieldName} in ${method}`);
153-
}
154-
155-
let result = details.length > 0 ? intro + ' - ' + details.join(' - ') : intro;
156-
157-
// Capitalize first letter
158-
result = result.charAt(0).toUpperCase() + result.slice(1);
159-
160-
// Truncate if needed
143+
let result = sentences.join(' ');
161144

162145
if (result.length > maxChars) {
163146
result =

packages/changelog-generator/src/shared/github.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ export async function analyze(repoRoot: string): Promise<AnalyzeOutput> {
9898
content: result.entry.content,
9999
commitMessage: result.entry.commitMessage,
100100
});
101+
} else if (result.status === 'skipped') {
102+
skipped.push({
103+
owner: result.owner,
104+
repo: result.repo,
105+
decision: 'skip',
106+
reason: result.reason,
107+
});
101108
} else if (result.status === 'error') {
102109
const parts = result.error.release.split('/');
103110
errors.push({

0 commit comments

Comments
 (0)