|
| 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