Skip to content

Commit fd8ac2f

Browse files
authored
Merge pull request #22057 from wireapp/include-pr-titles-beta-preview
Include pull request titles in Beta preview [WPB-26469]
2 parents 0a10314 + 845743f commit fd8ac2f

5 files changed

Lines changed: 174 additions & 23 deletions

File tree

tools/release-appearance/githubClient.test.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ function readPage(request: HttpRequest): number {
130130
}
131131

132132
describe('GitHub client', () => {
133-
it('maps GitHub pull requests into stripped domain records', async () => {
133+
it('maps GitHub pull requests into stripped domain records with titles', async () => {
134134
const fakeHttpClient = createFakeHttpClient({
135135
responseForRequest() {
136136
return [githubPullRequestResponseFactory.build({number: 7})];
@@ -144,8 +144,9 @@ describe('GitHub client', () => {
144144
expect(actualResult.value).toHaveLength(1);
145145
const pullRequest = Maybe.of(actualResult.value[0]);
146146
assert(pullRequest.isJust);
147-
expect(Object.keys(pullRequest.value)).toEqual(['number', 'baseBranch', 'mergedAt']);
147+
expect(Object.keys(pullRequest.value)).toEqual(['number', 'title', 'baseBranch', 'mergedAt']);
148148
expect(pullRequest.value.number).toBe(7);
149+
expect(pullRequest.value.title).toBe('Pull request');
149150
expect(pullRequest.value.baseBranch).toBe('main');
150151
assert(pullRequest.value.mergedAt.isJust);
151152
expect(pullRequest.value.mergedAt.value).toBe('2026-07-21T00:00:00Z');
@@ -165,7 +166,7 @@ describe('GitHub client', () => {
165166
});
166167
const responsesByPage = new Map<number, unknown>([
167168
[1, firstPage],
168-
[2, [githubPullRequestResponseFactory.build({number: 200})]],
169+
[2, [githubPullRequestResponseFactory.build({number: 200, title: 'Second page pull request'})]],
169170
]);
170171
const fakeHttpClient = createFakeHttpClient({
171172
responseForRequest(request) {
@@ -187,6 +188,16 @@ describe('GitHub client', () => {
187188
return pullRequest.number === 200;
188189
}),
189190
).toBe(true);
191+
expect(
192+
actualResult.value.some(pullRequest => {
193+
return pullRequest.number === 200 && pullRequest.title === 'Second page pull request';
194+
}),
195+
).toBe(true);
196+
expect(
197+
actualResult.value.some(pullRequest => {
198+
return pullRequest.number === 1 || pullRequest.number === 2;
199+
}),
200+
).toBe(false);
190201
});
191202

192203
it('paginates issue comments and strips unused properties', async () => {
@@ -249,10 +260,52 @@ describe('GitHub client', () => {
249260
expect(updateRequest.value.url.toString()).toMatch(/issues\/comments\/11$/);
250261
});
251262

252-
it('rejects malformed GitHub responses', async () => {
263+
it('rejects a GitHub response without a title as malformed', async () => {
264+
const fakeHttpClient = createFakeHttpClient({
265+
responseForRequest() {
266+
return [{number: 1, merged_at: '2026-07-21T00:00:00Z', base: {ref: 'main'}}];
267+
},
268+
});
269+
const githubClient = createClient(fakeHttpClient.httpClient);
270+
271+
const actualResult = await githubClient.listPullRequestsForCommit({commitSha: 'commit-sha'});
272+
273+
assert(actualResult.isErr);
274+
expect(actualResult.error.message).toBe('Malformed GitHub pull request response');
275+
});
276+
277+
it('rejects a GitHub response with a non-string title as malformed', async () => {
278+
const fakeHttpClient = createFakeHttpClient({
279+
responseForRequest() {
280+
return [{number: 1, title: 42, merged_at: '2026-07-21T00:00:00Z', base: {ref: 'main'}}];
281+
},
282+
});
283+
const githubClient = createClient(fakeHttpClient.httpClient);
284+
285+
const actualResult = await githubClient.listPullRequestsForCommit({commitSha: 'commit-sha'});
286+
287+
assert(actualResult.isErr);
288+
expect(actualResult.error.message).toBe('Malformed GitHub pull request response');
289+
});
290+
291+
it('rejects a GitHub response with an empty title as malformed', async () => {
292+
const fakeHttpClient = createFakeHttpClient({
293+
responseForRequest() {
294+
return [{number: 1, title: '', merged_at: '2026-07-21T00:00:00Z', base: {ref: 'main'}}];
295+
},
296+
});
297+
const githubClient = createClient(fakeHttpClient.httpClient);
298+
299+
const actualResult = await githubClient.listPullRequestsForCommit({commitSha: 'commit-sha'});
300+
301+
assert(actualResult.isErr);
302+
expect(actualResult.error.message).toBe('Malformed GitHub pull request response');
303+
});
304+
305+
it('rejects other malformed GitHub responses', async () => {
253306
const fakeHttpClient = createFakeHttpClient({
254307
responseForRequest() {
255-
return [{number: 1, merged_at: 42, base: {ref: 'main'}}];
308+
return [{number: 1, title: 'Pull request', merged_at: 42, base: {ref: 'main'}}];
256309
},
257310
});
258311
const githubClient = createClient(fakeHttpClient.httpClient);

tools/release-appearance/githubClient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {HttpClient, HttpMethod, HttpRequest} from './httpClient.ts';
2525

2626
export type PullRequestRecord = {
2727
readonly number: number;
28+
readonly title: string;
2829
readonly baseBranch: string;
2930
readonly mergedAt: Maybe<string>;
3031
};
@@ -80,6 +81,7 @@ const githubApiVersion = '2022-11-28';
8081

8182
const githubPullRequestResponseSchema = z.object({
8283
number: z.number().int().positive(),
84+
title: z.string().min(1),
8385
merged_at: z.string().nullable(),
8486
base: z.object({
8587
ref: z.string(),
@@ -172,6 +174,7 @@ function parsePullRequestPage(githubResponse: unknown): Result<ParsedPullRequest
172174
const pullRequests = validationResult.data.map(pullRequest => {
173175
return {
174176
number: pullRequest.number,
177+
title: pullRequest.title,
175178
baseBranch: pullRequest.base.ref,
176179
mergedAt: Maybe.of(pullRequest.merged_at),
177180
};

tools/release-appearance/previewNextBetaCommand.test.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type CreatePullRequestOptions = {
7575
readonly number: number;
7676
readonly baseBranch?: string;
7777
readonly merged?: boolean;
78+
readonly title?: string;
7879
};
7980

8081
type RunCommandOptions = {
@@ -99,10 +100,11 @@ const previewHistoryPlan: NextBetaPreviewHistoryPlan = {
99100
};
100101

101102
function createPullRequest(createPullRequestOptions: CreatePullRequestOptions): PullRequestRecord {
102-
const {number, baseBranch = 'main', merged = true} = createPullRequestOptions;
103+
const {number, baseBranch = 'main', merged = true, title = `Pull request #${number}`} = createPullRequestOptions;
103104

104105
return {
105106
number,
107+
title,
106108
baseBranch,
107109
mergedAt: merged ? Maybe.just('2026-07-28T00:00:00Z') : Maybe.nothing<string>(),
108110
};
@@ -302,12 +304,12 @@ describe('executePreviewNextBetaCommand', () => {
302304
[
303305
firstMainCommit,
304306
[
305-
createPullRequest({number: 22052}),
307+
createPullRequest({number: 22052, title: 'Add next Beta change preview [WPB-26469]'}),
306308
createPullRequest({number: 22040, baseBranch: 'release/2026-07-27'}),
307309
createPullRequest({number: 22041, merged: false}),
308310
],
309311
],
310-
[secondMainCommit, [createPullRequest({number: 22052})]],
312+
[secondMainCommit, [createPullRequest({number: 22052, title: 'A duplicate title that must be ignored'})]],
311313
[thirdMainCommit, [createPullRequest({number: 22051, baseBranch: 'release/2026-07-27'})]],
312314
]),
313315
});
@@ -325,10 +327,73 @@ describe('executePreviewNextBetaCommand', () => {
325327
expect(commandRun.result.summary).toContain(`Current main commit: \`${targetMainCommit}\``);
326328
expect(commandRun.result.summary).toContain(`Merge base: \`${mergeBaseCommit}\``);
327329
expect(commandRun.result.summary).toContain('Merged pull requests waiting for Beta: 1');
328-
expect(commandRun.result.summary).toContain('[#22052](https://github.com/wireapp/wire-webapp/pull/22052)');
330+
expect(commandRun.result.summary).toContain(
331+
'- [#22052](https://github.com/wireapp/wire-webapp/pull/22052) Add next Beta change preview \\[WPB-26469\\]',
332+
);
333+
expect(commandRun.result.summary).not.toContain('A duplicate title that must be ignored');
329334
expect(commandRun.result.summary).toContain(`- \`${thirdMainCommit}\``);
330335
expect(commandRun.result.summary).toContain('This is an advisory preview. These changes are on main');
331336
expect(commandRun.writerState.informationMessages[0]).toContain('Merged pull requests waiting for Beta: 1');
337+
expect(commandRun.writerState.informationMessages[0]).not.toContain('Add next Beta change preview');
338+
});
339+
340+
test('sorts multiple merged main pull requests numerically', async () => {
341+
const githubClientFixture = createFakeGitHubClient({
342+
pullRequestsByCommit: new Map([
343+
[
344+
firstMainCommit,
345+
[
346+
createPullRequest({number: 22056, title: 'Add next Beta change preview [WPB-26469]'}),
347+
createPullRequest({number: 22050, title: 'Add first-appearance release comments [WPB-26469]'}),
348+
],
349+
],
350+
[
351+
secondMainCommit,
352+
[createPullRequest({number: 22052, title: 'Add release appearance comment dry run [WPB-26469]'})],
353+
],
354+
]),
355+
});
356+
357+
const commandRun = await runCommand({
358+
commandLineArguments: [targetMainCommit],
359+
executeGitCommand: createFakeGitCommand(),
360+
githubClient: githubClientFixture.githubClient,
361+
historyPlanResult: Result.ok({...previewHistoryPlan, commits: [firstMainCommit, secondMainCommit]}),
362+
});
363+
364+
expect(commandRun.result.exitCode).toBe(0);
365+
expect(commandRun.result.summary.indexOf('#22050')).toBeLessThan(commandRun.result.summary.indexOf('#22052'));
366+
expect(commandRun.result.summary.indexOf('#22052')).toBeLessThan(commandRun.result.summary.indexOf('#22056'));
367+
});
368+
369+
test('normalizes and escapes hostile pull request titles into one safe list item', async () => {
370+
const githubClientFixture = createFakeGitHubClient({
371+
pullRequestsByCommit: new Map([
372+
[
373+
firstMainCommit,
374+
[
375+
createPullRequest({
376+
number: 22060,
377+
title:
378+
' Fix [preview] *output*\nfor <Beta>\t_with_ `code`\\path ~draft~\n- forged list\n# forged heading ',
379+
}),
380+
],
381+
],
382+
]),
383+
});
384+
385+
const commandRun = await runCommand({
386+
commandLineArguments: [targetMainCommit],
387+
executeGitCommand: createFakeGitCommand(),
388+
githubClient: githubClientFixture.githubClient,
389+
historyPlanResult: Result.ok({...previewHistoryPlan, commits: [firstMainCommit]}),
390+
});
391+
392+
const expectedHostilePullRequestLine =
393+
'- [#22060](https://github.com/wireapp/wire-webapp/pull/22060) Fix \\[preview\\] \\*output\\* for \\<Beta\\> \\_with\\_ \\`code\\`\\\\path \\~draft\\~ - forged list # forged heading';
394+
expect(commandRun.result.summary).toContain(expectedHostilePullRequestLine);
395+
expect(commandRun.result.summary).not.toContain('\n- forged list');
396+
expect(commandRun.result.summary).not.toContain('\n# forged heading');
332397
});
333398

334399
test('continues after a discovery failure, returns non-zero, and sanitizes the failure', async () => {

tools/release-appearance/previewNextBetaCommand.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,24 @@ type PreviewNextBetaState = {
6565
readonly targetMainCommit: string;
6666
readonly mergeBase: Maybe<string>;
6767
readonly commitsInspected: readonly string[];
68-
readonly pullRequestNumbers: readonly number[];
68+
readonly pullRequests: readonly PreviewPullRequest[];
6969
readonly commitsWithoutMergedMainPullRequest: readonly string[];
7070
readonly failureMessages: readonly string[];
7171
};
7272

73+
type PreviewPullRequest = {
74+
readonly number: number;
75+
readonly title: string;
76+
};
77+
7378
type DiscoverPullRequestsOptions = {
7479
readonly commits: readonly string[];
7580
readonly githubClient: GitHubClient;
7681
readonly githubToken: string;
7782
};
7883

7984
type DiscoverPullRequestsResult = {
80-
readonly pullRequestNumbers: readonly number[];
85+
readonly pullRequests: readonly PreviewPullRequest[];
8186
readonly commitsWithoutMergedMainPullRequest: readonly string[];
8287
readonly failureMessages: readonly string[];
8388
};
@@ -114,6 +119,7 @@ type WriteSummarySafelyOptions = {
114119

115120
const processArgumentStartIndex = 2;
116121
const fullGitCommitPattern = /^[0-9a-f]{40}$/i;
122+
const markdownSpecialCharacterPattern = /[\\`*_\[\]<>~]/gu;
117123
const executeFile = promisify(execFile);
118124
const advisoryMessage =
119125
'This is an advisory preview. These changes are on main but have not been deployed or verified in Beta.';
@@ -189,7 +195,7 @@ async function discoverPullRequests(
189195
discoverPullRequestsOptions: DiscoverPullRequestsOptions,
190196
): Promise<DiscoverPullRequestsResult> {
191197
const {commits, githubClient, githubToken} = discoverPullRequestsOptions;
192-
const pullRequestNumbersByNumber = new Set<number>();
198+
const pullRequestsByNumber = new Map<number, PreviewPullRequest>();
193199
const commitsWithoutMergedMainPullRequest: string[] = [];
194200
const failureMessages: string[] = [];
195201

@@ -219,13 +225,18 @@ async function discoverPullRequests(
219225
}
220226

221227
for (const pullRequest of mergedMainPullRequests) {
222-
pullRequestNumbersByNumber.add(pullRequest.number);
228+
if (!pullRequestsByNumber.has(pullRequest.number)) {
229+
pullRequestsByNumber.set(pullRequest.number, {
230+
number: pullRequest.number,
231+
title: pullRequest.title,
232+
});
233+
}
223234
}
224235
}
225236

226237
return {
227-
pullRequestNumbers: [...pullRequestNumbersByNumber].toSorted((leftNumber, rightNumber) => {
228-
return leftNumber - rightNumber;
238+
pullRequests: [...pullRequestsByNumber.values()].toSorted((leftPullRequest, rightPullRequest) => {
239+
return leftPullRequest.number - rightPullRequest.number;
229240
}),
230241
commitsWithoutMergedMainPullRequest,
231242
failureMessages,
@@ -241,7 +252,7 @@ function createUnavailableState(
241252
targetMainCommit,
242253
mergeBase: Maybe.nothing<string>(),
243254
commitsInspected: [],
244-
pullRequestNumbers: [],
255+
pullRequests: [],
245256
commitsWithoutMergedMainPullRequest: [],
246257
failureMessages,
247258
};
@@ -279,7 +290,7 @@ async function createPreviewState(createPreviewStateOptions: CreatePreviewStateO
279290
targetMainCommit: resolvedTargetMainCommit,
280291
mergeBase: Maybe.just(mergeBase),
281292
commitsInspected: commits,
282-
pullRequestNumbers: discoveryResult.pullRequestNumbers,
293+
pullRequests: discoveryResult.pullRequests,
283294
commitsWithoutMergedMainPullRequest: discoveryResult.commitsWithoutMergedMainPullRequest,
284295
failureMessages: discoveryResult.failureMessages,
285296
};
@@ -302,13 +313,24 @@ function formatPullRequestLines(state: PreviewNextBetaState, githubRepository: s
302313
return ['Not inspected.'];
303314
}
304315

305-
return is.emptyArray(state.pullRequestNumbers)
316+
return is.emptyArray(state.pullRequests)
306317
? ['No merged pull requests are currently waiting for the next Beta.']
307-
: state.pullRequestNumbers.map(pullRequestNumber => {
308-
return `- [#${pullRequestNumber}](https://github.com/${githubRepository}/pull/${pullRequestNumber})`;
318+
: state.pullRequests.map(pullRequest => {
319+
return `- [#${pullRequest.number}](https://github.com/${githubRepository}/pull/${pullRequest.number}) ${normalizePullRequestTitle(
320+
pullRequest.title,
321+
)}`;
309322
});
310323
}
311324

325+
function normalizePullRequestTitle(title: string): string {
326+
return title
327+
.trim()
328+
.replace(/\s+/gu, ' ')
329+
.replace(markdownSpecialCharacterPattern, character => {
330+
return `\\${character}`;
331+
});
332+
}
333+
312334
function formatDetailValue(value: string, markdown: boolean): string {
313335
return markdown ? `\`${value}\`` : value;
314336
}
@@ -329,7 +351,7 @@ function createDetailLines(state: PreviewNextBetaState, markdown: boolean): read
329351
const inspectionValues = state.mergeBase.isJust
330352
? {
331353
commitsInspected: state.commitsInspected.length.toString(),
332-
pullRequests: state.pullRequestNumbers.length.toString(),
354+
pullRequests: state.pullRequests.length.toString(),
333355
commitsWithoutPullRequests: state.commitsWithoutMergedMainPullRequest.length.toString(),
334356
}
335357
: {
@@ -390,7 +412,7 @@ function createReport(createReportOptions: CreateReportOptions): string {
390412
'Preview next Beta changes',
391413
...createDetailLines(state, false),
392414
...(is.emptyString(unavailableMessage) ? [] : ['', unavailableMessage]),
393-
...(state.mergeBase.isJust && is.emptyArray(state.pullRequestNumbers)
415+
...(state.mergeBase.isJust && is.emptyArray(state.pullRequests)
394416
? ['No merged pull requests are currently waiting for the next Beta.']
395417
: []),
396418
...(is.emptyArray(failureLines) ? [] : ['Failures:', ...failureLines]),

tools/release-appearance/releaseAppearanceCommand.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ function createFailure<valueType>(message: string): Result<valueType, Error> {
124124
function createPullRequest(number: number): PullRequestRecord {
125125
return {
126126
number,
127+
title: 'Add release appearance test coverage',
127128
baseBranch: 'main',
128129
mergedAt: Maybe.just('2026-01-02T00:00:00Z'),
129130
};
@@ -559,7 +560,14 @@ describe('executeReleaseAppearanceCommand', () => {
559560
requestJson: async function requestJson(request): Promise<unknown> {
560561
githubRequests.push(request);
561562
if (request.url.pathname.endsWith('/pulls')) {
562-
return [{number: 7, merged_at: '2026-01-02T00:00:00Z', base: {ref: 'main'}}];
563+
return [
564+
{
565+
number: 7,
566+
title: 'Add release appearance test coverage',
567+
merged_at: '2026-01-02T00:00:00Z',
568+
base: {ref: 'main'},
569+
},
570+
];
563571
}
564572
if (request.method !== 'get') {
565573
throw new Error('Dry run attempted a mutation request');

0 commit comments

Comments
 (0)