Skip to content

Commit eeba546

Browse files
authored
Merge branch 'main' into localization
2 parents c260381 + 4b95f40 commit eeba546

6 files changed

Lines changed: 377 additions & 22 deletions

File tree

‎.github/actions/api/octokit.js‎

Lines changed: 14 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
const assert = require('node:assert/strict');
7+
const fs = require('node:fs');
8+
const path = require('node:path');
9+
const test = require('node:test');
10+
const vm = require('node:vm');
11+
const ts = require('typescript');
12+
13+
const params = { owner: 'microsoft', repo: 'vscode-cpptools' };
14+
const timestamp = '2026-01-02T03:04:05Z';
15+
const commitUrl = 'https://api.github.com/repos/microsoft/vscode-cpptools/commits/abcdef0';
16+
const source = fs.readFileSync(path.join(__dirname, 'octokit.js'), 'utf8');
17+
18+
function issue(overrides = {}) {
19+
return {
20+
number: 1,
21+
user: { login: 'author', type: 'User' },
22+
body: 'Issue body',
23+
title: 'Issue title',
24+
labels: ['bug', { name: 'triage' }, {}],
25+
state: 'closed',
26+
state_reason: 'duplicate',
27+
locked: false,
28+
comments: 2,
29+
reactions: { '+1': 1, '-1': 0, laugh: 0, hooray: 0, confused: 0, heart: 0, rocket: 0, eyes: 0 },
30+
assignee: null,
31+
assignees: [{ login: 'first' }, { login: 'second' }],
32+
milestone: null,
33+
created_at: timestamp,
34+
updated_at: timestamp,
35+
closed_at: timestamp,
36+
...overrides,
37+
};
38+
}
39+
40+
function adapter({ issues = { 1: issue() }, timelines = {}, search = [] } = {}) {
41+
const requests = [];
42+
const timelineEndpoint = () => assert.fail('Timeline endpoint must be paginated');
43+
const searchEndpoint = () => assert.fail('Search endpoint must be paginated');
44+
const client = {
45+
rest: {
46+
issues: {
47+
get: async ({ owner, repo, issue_number }) => {
48+
assert.deepEqual({ owner, repo }, params);
49+
requests.push(['get', issue_number]);
50+
assert.ok(issues[issue_number], `Unexpected issue read: ${issue_number}`);
51+
return { data: issues[issue_number] };
52+
},
53+
listEventsForTimeline: timelineEndpoint,
54+
},
55+
search: { issuesAndPullRequests: searchEndpoint },
56+
},
57+
paginate: {
58+
async *iterator(endpoint, options) {
59+
assert.ok(endpoint === timelineEndpoint || endpoint === searchEndpoint);
60+
const pages = endpoint === timelineEndpoint ? timelines[options.issue_number] ?? [] : search;
61+
if (endpoint === timelineEndpoint) {
62+
assert.deepEqual({ owner: options.owner, repo: options.repo }, params);
63+
} else {
64+
assert.equal(options.q, 'is:issue repo:microsoft/vscode-cpptools');
65+
assert.equal(options.per_page, 100);
66+
}
67+
for (let page = 0; page < pages.length; page++) {
68+
requests.push([endpoint === timelineEndpoint ? 'timeline' : 'search', options.issue_number, page]);
69+
yield { data: pages[page] };
70+
}
71+
},
72+
},
73+
};
74+
const exports = {};
75+
const dependencies = {
76+
'@actions/github': { getOctokit: () => client },
77+
'../common/utils': { safeLog: () => { } },
78+
child_process: { exec: () => assert.fail('Unexpected subprocess') },
79+
};
80+
vm.runInThisContext(`(function(require, exports, setTimeout) {\n${source}\n})`, {
81+
filename: path.join(__dirname, 'octokit.js'),
82+
})(
83+
(name) => {
84+
assert.ok(Object.hasOwn(dependencies, name), `Unexpected dependency: ${name}`);
85+
return dependencies[name];
86+
},
87+
exports,
88+
() => assert.fail('Unexpected timer'),
89+
);
90+
return {
91+
repo: new exports.OctoKit('offline-test-token', params, { readonly: true }),
92+
getIssue: (number = 1) => new exports.OctoKitIssue('offline-test-token', params, { number }, { readonly: true }),
93+
requests,
94+
};
95+
}
96+
97+
function closed(event = 'closed', overrides = {}) {
98+
return { event, created_at: timestamp, commit_id: 'abcdef0', commit_url: commitUrl, ...overrides };
99+
}
100+
101+
function reference(number) {
102+
return {
103+
event: 'cross-referenced',
104+
source: { issue: { number, pull_request: { url: `https://api.github.com/repos/microsoft/vscode-cpptools/pulls/${number}` } } },
105+
};
106+
}
107+
108+
test('checked-in adapter JavaScript matches the locked TypeScript compiler', () => {
109+
const fileName = path.join(__dirname, 'octokit.ts');
110+
const configPath = path.join(__dirname, '../tsconfig.json');
111+
const config = ts.readConfigFile(configPath, ts.sys.readFile);
112+
assert.equal(config.error, undefined);
113+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath));
114+
assert.deepEqual(parsed.errors, []);
115+
const generated = ts.transpileModule(fs.readFileSync(fileName, 'utf8'), {
116+
fileName,
117+
compilerOptions: parsed.options,
118+
}).outputText;
119+
const printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed });
120+
const normalize = (text) => printer.printFile(ts.createSourceFile('octokit.js', text, ts.ScriptTarget.ES2019, true, ts.ScriptKind.JS));
121+
assert.equal(normalize(source), normalize(generated));
122+
});
123+
124+
test('getIssue normalizes the current issue response, including duplicate state reasons', async () => {
125+
const data = issue();
126+
const result = await adapter({ issues: { 1: data } }).getIssue().getIssue();
127+
assert.deepEqual(result, {
128+
author: { name: 'author', isGitHubApp: false },
129+
body: 'Issue body',
130+
number: 1,
131+
title: 'Issue title',
132+
isPr: false,
133+
labels: ['bug', 'triage', ''],
134+
open: false,
135+
locked: false,
136+
numComments: 2,
137+
reactions: data.reactions,
138+
assignee: 'first',
139+
assignees: ['first', 'second'],
140+
milestone: null,
141+
createdAt: Date.parse(timestamp),
142+
updatedAt: Date.parse(timestamp),
143+
closedAt: Date.parse(timestamp),
144+
});
145+
});
146+
147+
test('getIssue preserves nullable fields and milestone date normalization', async () => {
148+
const data = issue({
149+
user: null, body: null, assignees: null, assignee: { login: 'assigned' }, closed_at: null,
150+
state: 'open', pull_request: { html_url: 'https://github.com/microsoft/vscode-cpptools/pull/1' },
151+
milestone: {
152+
number: 7, title: 'Milestone', created_at: timestamp, due_on: timestamp, closed_at: null,
153+
description: null, closed_issues: 2, open_issues: 3, state: 'open',
154+
},
155+
});
156+
const result = await adapter({ issues: { 1: data } }).getIssue().getIssue();
157+
assert.deepEqual(result.author, { name: 'unkown', isGitHubApp: false });
158+
assert.equal(result.body, '');
159+
assert.equal(result.assignee, 'assigned');
160+
assert.deepEqual(result.assignees, []);
161+
assert.equal(result.closedAt, undefined);
162+
assert.equal(result.open, true);
163+
assert.equal(result.isPr, true);
164+
assert.deepEqual(result.milestone, {
165+
title: 'Milestone', milestoneId: 7, createdAt: new Date('2026-01-02'), dueOn: new Date('2026-01-02'),
166+
closedAt: null, description: '', numClosedIssues: 2, numOpenIssues: 3, state: 'open',
167+
});
168+
});
169+
170+
test('query accepts search responses with broader state reasons', async () => {
171+
const data = issue({ state_reason: 'search-specific-reason' });
172+
const fixture = adapter({ search: [[data]] });
173+
const pages = [];
174+
for await (const page of fixture.repo.query({ q: 'is:issue' })) pages.push(page);
175+
assert.equal(pages.length, 1);
176+
assert.equal(pages[0].length, 1);
177+
assert.equal((await pages[0][0].getIssue()).number, 1);
178+
assert.equal(fixture.requests[0][0], 'search');
179+
});
180+
181+
test('getAssigner keeps the last matching actor in the first matching page', async () => {
182+
const assigned = (login, actor, event = 'assigned') => ({ event, assignee: { login }, actor: { login: actor } });
183+
const fixture = adapter({
184+
timelines: {
185+
1: [
186+
[{ event: 'committed', sha: 'abcdef0' }, assigned('other', 'ignored'), assigned('target', 'ignored', 'unassigned')],
187+
[assigned('target', 'first'), assigned('target', 'last')],
188+
[assigned('target', 'unvisited')],
189+
]
190+
}
191+
});
192+
assert.equal(await fixture.getIssue().getAssigner('target'), 'last');
193+
assert.deepEqual(fixture.requests, [['timeline', 1, 0], ['timeline', 1, 1]]);
194+
});
195+
196+
test('getAssigner rejects timelines without a matching actor', async () => {
197+
const fixture = adapter({
198+
timelines: {
199+
1: [[
200+
{ event: 'assigned' }, { event: 'assigned', assignee: { login: 'target' }, actor: null },
201+
{ event: 'reviewed', body: 'review' },
202+
]]
203+
}
204+
});
205+
await assert.rejects(fixture.getIssue().getAssigner('target'), /Expected to find target in issue timeline/);
206+
});
207+
208+
for (const event of ['closed', 'merged']) {
209+
test(`getClosingInfo accepts ${event} commits from the same repository`, async () => {
210+
const fixture = adapter({ timelines: { 1: [[closed(event, { commit_url: commitUrl.toUpperCase() })]] } });
211+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), { hash: 'abcdef0', timestamp: Date.parse(timestamp) });
212+
});
213+
}
214+
215+
test('getClosingInfo ignores unrelated, incomplete and foreign-repository events', async () => {
216+
const fixture = adapter({
217+
timelines: {
218+
1: [[
219+
{ event: 'committed', sha: 'abcdef0' }, { event: 'closed', created_at: timestamp, commit_id: 'abcdef0' },
220+
closed('closed', { created_at: '' }), closed('closed', { commit_id: null }), closed('closed', { commit_url: null }),
221+
closed('closed', { commit_url: commitUrl.replace('/vscode-cpptools/', '/other/') }), closed('labeled'),
222+
]]
223+
}
224+
});
225+
assert.equal(await fixture.getIssue().getClosingInfo(), undefined);
226+
});
227+
228+
test('reopening clears earlier closing information across pages', async () => {
229+
const fixture = adapter({ timelines: { 1: [[closed()], [{ event: 'reopened' }]] } });
230+
assert.equal(await fixture.getIssue().getClosingInfo(), undefined);
231+
});
232+
233+
test('a later close replaces earlier closing information', async () => {
234+
const fixture = adapter({ timelines: { 1: [[closed(), { event: 'reopened' }], [closed('merged', { commit_id: '1234567' })]] } });
235+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), { hash: '1234567', timestamp: Date.parse(timestamp) });
236+
});
237+
238+
for (const body of ['/closedWith abcdef0', '\\closedWith abcdef0', '/closedWith https://github.com/microsoft/vscode/commit/abcdef0']) {
239+
test(`getClosingInfo honors ${body}`, async () => {
240+
const fixture = adapter({ timelines: { 1: [[{ event: 'commented', created_at: timestamp, body }]] } });
241+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), { hash: 'abcdef0', timestamp: Date.parse(timestamp) });
242+
});
243+
}
244+
245+
test('empty, suppressed and non-comment bodies do not overwrite closing information', async () => {
246+
const comments = [undefined, null, '', 'ordinary comment', 'UNABLE_TO_LOCATE_COMMIT_MESSAGE /closedWith 1234567'];
247+
const fixture = adapter({
248+
timelines: {
249+
1: [[
250+
closed(), ...comments.map((body) => ({ event: 'commented', created_at: timestamp, body })),
251+
{ event: 'commented', body: '/closedWith 1234567' },
252+
{ event: 'reviewed', created_at: timestamp, body: '/closedWith 1234567' },
253+
]]
254+
}
255+
});
256+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), { hash: 'abcdef0', timestamp: Date.parse(timestamp) });
257+
});
258+
259+
test('open issues and already-checked issues do not read timelines', async () => {
260+
const fixture = adapter({ issues: { 1: issue({ state: 'open' }) } });
261+
assert.equal(await fixture.getIssue().getClosingInfo([1]), undefined);
262+
assert.deepEqual(fixture.requests, []);
263+
assert.equal(await fixture.getIssue().getClosingInfo(), undefined);
264+
assert.deepEqual(fixture.requests, [['get', 1]]);
265+
});
266+
267+
test('linked pull requests are checked newest first', async () => {
268+
const fixture = adapter({
269+
issues: { 1: issue(), 2: issue({ number: 2 }), 3: issue({ number: 3 }) },
270+
timelines: { 1: [[reference(2), reference(3)]], 2: [[closed()]], 3: [[closed('merged', { commit_id: '1234567' })]] },
271+
});
272+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), { hash: '1234567', timestamp: Date.parse(timestamp) });
273+
assert.equal(fixture.requests.some(([kind, number]) => kind === 'get' && number === 2), false);
274+
});
275+
276+
for (const offset of [-5000, -4999, 4999, 5000]) {
277+
test(`linked closing timestamps preserve the five-second boundary (${offset}ms)`, async () => {
278+
const linkedTime = Date.parse(timestamp) + offset;
279+
const fixture = adapter({
280+
issues: { 1: issue(), 2: issue({ number: 2 }) },
281+
timelines: { 1: [[reference(2)]], 2: [[closed('merged', { created_at: new Date(linkedTime).toISOString() })]] },
282+
});
283+
assert.deepEqual(await fixture.getIssue().getClosingInfo(), Math.abs(offset) < 5000 ? { hash: 'abcdef0', timestamp: linkedTime } : undefined);
284+
});
285+
}
286+
287+
test('cyclic cross references terminate without rereading the original issue', async () => {
288+
const fixture = adapter({ issues: { 1: issue(), 2: issue({ number: 2 }) }, timelines: { 1: [[reference(2)]], 2: [[reference(1)]] } });
289+
assert.equal(await fixture.getIssue().getClosingInfo(), undefined);
290+
assert.deepEqual(fixture.requests.filter(([kind]) => kind === 'get'), [['get', 1], ['get', 2]]);
291+
});

0 commit comments

Comments
 (0)