pi-odd-worktree 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T2 (issues-panel S1,S3): tests for the migrated lib/gh-issues.js — the only
|
|
3
|
+
// place that runs `gh`. CommonJS, injectable runner, fixed argv (never a shell
|
|
4
|
+
// string), JSON parsed, never throws. The client moved whole from the source
|
|
5
|
+
// package's issues panel, so its contract is pinned here unchanged.
|
|
6
|
+
//
|
|
7
|
+
// Contract under test: every call returns `{ok:true, ...}` or
|
|
8
|
+
// `{ok:false, error}`; the runner seam means tests never invoke the real
|
|
9
|
+
// `gh` — injected runners return captured REAL `gh` JSON payloads saved
|
|
10
|
+
// under tools/fixtures/ (captured with `gh issue list/view`, `gh label
|
|
11
|
+
// list`, `gh api user` and `gh repo view` in the source repository).
|
|
12
|
+
|
|
13
|
+
const { test, describe, beforeEach, after } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
|
|
20
|
+
const ghIssues = require('../lib/gh-issues.js');
|
|
21
|
+
|
|
22
|
+
const FIXTURES = path.join(__dirname, 'fixtures');
|
|
23
|
+
function fixture(name) {
|
|
24
|
+
return fs.readFileSync(path.join(FIXTURES, name), 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Recorder: captures (bin, argv) and answers from a script of outcomes.
|
|
28
|
+
// An outcome is {status, stdout, stderr, error} like the real runner.
|
|
29
|
+
function makeRunner(script) {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const queue = Array.isArray(script) ? script.slice() : [script];
|
|
32
|
+
const runner = (bin, argv, runOpts) => {
|
|
33
|
+
calls.push({ bin, argv: argv.slice(), runOpts: { ...(runOpts || {}) } });
|
|
34
|
+
const next = queue.length > 1 ? queue.shift() : queue[0];
|
|
35
|
+
if (typeof next === 'function') return next(calls[calls.length - 1]);
|
|
36
|
+
return Promise.resolve({ status: 0, stdout: '', stderr: '', error: null, ...(next || {}) });
|
|
37
|
+
};
|
|
38
|
+
return { runner, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function okJson(stdout) {
|
|
42
|
+
return { status: 0, stdout, stderr: '', error: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
ghIssues.setGhRunner(null);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('happy path over captured real gh JSON', () => {
|
|
50
|
+
test('ghStatus reports the authenticated login', async () => {
|
|
51
|
+
const { runner, calls } = makeRunner([
|
|
52
|
+
okJson(fixture('gh-api-user.json')),
|
|
53
|
+
okJson(fixture('gh-repo-view.json')),
|
|
54
|
+
]);
|
|
55
|
+
ghIssues.setGhRunner(runner);
|
|
56
|
+
const res = await ghIssues.ghStatus();
|
|
57
|
+
assert.equal(res.ok, true);
|
|
58
|
+
assert.equal(res.login, 'jonasotoaguilar');
|
|
59
|
+
assert.equal(res.nameWithOwner, 'jonasotoaguilar/herdr-gentle-shell');
|
|
60
|
+
assert.equal(calls[0].bin, 'gh');
|
|
61
|
+
assert.deepEqual(calls[0].argv, ['api', 'user']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('repoInfo reports nameWithOwner and viewer', async () => {
|
|
65
|
+
const { runner } = makeRunner([
|
|
66
|
+
okJson(fixture('gh-repo-view.json')),
|
|
67
|
+
okJson(fixture('gh-api-user.json')),
|
|
68
|
+
]);
|
|
69
|
+
ghIssues.setGhRunner(runner);
|
|
70
|
+
const res = await ghIssues.repoInfo();
|
|
71
|
+
assert.equal(res.ok, true);
|
|
72
|
+
assert.equal(res.nameWithOwner, 'jonasotoaguilar/herdr-gentle-shell');
|
|
73
|
+
assert.equal(res.viewer, 'jonasotoaguilar');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('listIssues normalizes the captured list payload', async () => {
|
|
77
|
+
const { runner, calls } = makeRunner(okJson(fixture('gh-issue-list.json')));
|
|
78
|
+
ghIssues.setGhRunner(runner);
|
|
79
|
+
const res = await ghIssues.listIssues({ query: 'author:@me', page: 1, pageSize: 30 });
|
|
80
|
+
assert.equal(res.ok, true);
|
|
81
|
+
assert.equal(res.page, 1);
|
|
82
|
+
assert.equal(res.pageSize, 30);
|
|
83
|
+
assert.ok(res.total >= 1);
|
|
84
|
+
assert.equal(res.issues.length, res.total);
|
|
85
|
+
const issue = res.issues[0];
|
|
86
|
+
assert.equal(issue.number, 76);
|
|
87
|
+
assert.equal(issue.state, 'open');
|
|
88
|
+
assert.equal(issue.author, 'jonasotoaguilar');
|
|
89
|
+
assert.ok(Array.isArray(issue.labels));
|
|
90
|
+
assert.equal(typeof issue.commentsCount, 'number');
|
|
91
|
+
assert.equal(typeof issue.reactions.total, 'number');
|
|
92
|
+
assert.equal(typeof issue.url, 'string');
|
|
93
|
+
assert.equal(typeof issue.body, 'string');
|
|
94
|
+
assert.equal(calls[0].bin, 'gh');
|
|
95
|
+
assert.ok(calls[0].argv.includes('issue'));
|
|
96
|
+
assert.ok(calls[0].argv.includes('list'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('getIssue returns the issue with normalized comments', async () => {
|
|
100
|
+
const { runner, calls } = makeRunner(okJson(fixture('gh-issue-view.json')));
|
|
101
|
+
ghIssues.setGhRunner(runner);
|
|
102
|
+
const res = await ghIssues.getIssue(76);
|
|
103
|
+
assert.equal(res.ok, true);
|
|
104
|
+
assert.equal(res.issue.number, 76);
|
|
105
|
+
assert.equal(res.issue.state, 'open');
|
|
106
|
+
assert.ok(Array.isArray(res.comments));
|
|
107
|
+
assert.deepEqual(calls[0].argv.slice(0, 3), ['issue', 'view', '76']);
|
|
108
|
+
assert.ok(calls[0].argv.includes('--json'));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('listLabels normalizes the captured label payload', async () => {
|
|
112
|
+
const { runner } = makeRunner(okJson(fixture('gh-label-list.json')));
|
|
113
|
+
ghIssues.setGhRunner(runner);
|
|
114
|
+
const res = await ghIssues.listLabels();
|
|
115
|
+
assert.equal(res.ok, true);
|
|
116
|
+
assert.ok(res.labels.length >= 1);
|
|
117
|
+
assert.equal(typeof res.labels[0].name, 'string');
|
|
118
|
+
assert.ok('color' in res.labels[0]);
|
|
119
|
+
assert.ok('description' in res.labels[0]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('createLabel posts name, color and description', async () => {
|
|
123
|
+
const { runner, calls } = makeRunner(okJson('{"name":"triage"}'));
|
|
124
|
+
ghIssues.setGhRunner(runner);
|
|
125
|
+
const res = await ghIssues.createLabel({ name: 'triage', color: 'FBCA04', description: 'needs triage' });
|
|
126
|
+
assert.equal(res.ok, true);
|
|
127
|
+
assert.equal(res.label.name, 'triage');
|
|
128
|
+
assert.deepEqual(calls[0].argv.slice(0, 3), ['label', 'create', 'triage']);
|
|
129
|
+
assert.ok(calls[0].argv.includes('--color'));
|
|
130
|
+
assert.ok(calls[0].argv.includes('FBCA04'));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('editLabels passes add and remove label flags', async () => {
|
|
134
|
+
const { runner, calls } = makeRunner(okJson(''));
|
|
135
|
+
ghIssues.setGhRunner(runner);
|
|
136
|
+
const res = await ghIssues.editLabels(76, { add: ['bug'], remove: ['wontfix'] });
|
|
137
|
+
assert.equal(res.ok, true);
|
|
138
|
+
assert.deepEqual(calls[0].argv.slice(0, 3), ['issue', 'edit', '76']);
|
|
139
|
+
assert.ok(calls[0].argv.includes('--add-label'));
|
|
140
|
+
assert.ok(calls[0].argv.includes('bug'));
|
|
141
|
+
assert.ok(calls[0].argv.includes('--remove-label'));
|
|
142
|
+
assert.ok(calls[0].argv.includes('wontfix'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('commentIssue passes the body as one argv element', async () => {
|
|
146
|
+
const { runner, calls } = makeRunner(okJson(''));
|
|
147
|
+
ghIssues.setGhRunner(runner);
|
|
148
|
+
const res = await ghIssues.commentIssue(76, 'looks good to me');
|
|
149
|
+
assert.equal(res.ok, true);
|
|
150
|
+
assert.deepEqual(calls[0].argv.slice(0, 3), ['issue', 'comment', '76']);
|
|
151
|
+
assert.ok(calls[0].argv.includes('--body'));
|
|
152
|
+
assert.ok(calls[0].argv.includes('looks good to me'));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('setIssueState closes and reopens', async () => {
|
|
156
|
+
const { runner, calls } = makeRunner([okJson(''), okJson('')]);
|
|
157
|
+
ghIssues.setGhRunner(runner);
|
|
158
|
+
const closed = await ghIssues.setIssueState(76, 'closed');
|
|
159
|
+
assert.equal(closed.ok, true);
|
|
160
|
+
assert.deepEqual(calls[0].argv, ['issue', 'close', '76']);
|
|
161
|
+
const opened = await ghIssues.setIssueState(76, 'open');
|
|
162
|
+
assert.equal(opened.ok, true);
|
|
163
|
+
assert.deepEqual(calls[1].argv, ['issue', 'reopen', '76']);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Closing/reopening, editing labels and assigning are ONE triage-level
|
|
168
|
+
// capability; creating a repository label is the distinct WRITE one. The
|
|
169
|
+
// evidence is `repository.viewerPermission`, and only a literally permitting
|
|
170
|
+
// role yields `true` — an unknown role, a missing field or a failed read all
|
|
171
|
+
// fail closed, so the view never offers a capability this client cannot prove.
|
|
172
|
+
describe('repository permissions from viewerPermission', () => {
|
|
173
|
+
const PINNED = { scope: 'github.com/o/r' };
|
|
174
|
+
|
|
175
|
+
// The detail read plus the metadata reads `includeMetadata` issues, answered
|
|
176
|
+
// by role. `access` lets a caller fail the viewerPermission read instead.
|
|
177
|
+
function roleRunner(role, { access = null } = {}) {
|
|
178
|
+
const calls = [];
|
|
179
|
+
const graphql = (query) => {
|
|
180
|
+
if (query.includes('viewerPermission')) {
|
|
181
|
+
if (access) return access;
|
|
182
|
+
return okJson(JSON.stringify({ data: { repository: {
|
|
183
|
+
viewerPermission: role,
|
|
184
|
+
issue: {
|
|
185
|
+
assignees: { nodes: [], pageInfo: { hasNextPage: false } },
|
|
186
|
+
closedByPullRequestsReferences: { totalCount: 0 },
|
|
187
|
+
},
|
|
188
|
+
} } }));
|
|
189
|
+
}
|
|
190
|
+
if (query.includes('projectItems')) {
|
|
191
|
+
return okJson(JSON.stringify({ data: { repository: { issue: {
|
|
192
|
+
projectItems: { nodes: [], pageInfo: { hasNextPage: false } },
|
|
193
|
+
} } } }));
|
|
194
|
+
}
|
|
195
|
+
return { status: 1, stdout: '', stderr: 'unexpected query', error: null };
|
|
196
|
+
};
|
|
197
|
+
const runner = (bin, argv) => {
|
|
198
|
+
calls.push(argv.slice());
|
|
199
|
+
if (argv[0] === 'issue' && argv[1] === 'view') return okJson(fixture('gh-issue-view.json'));
|
|
200
|
+
if (argv[0] === 'api' && argv[1] === 'graphql') {
|
|
201
|
+
const query = argv.filter((value) => value.startsWith('query='))[0] || '';
|
|
202
|
+
return graphql(query);
|
|
203
|
+
}
|
|
204
|
+
return { status: 1, stdout: '', stderr: 'unexpected command', error: null };
|
|
205
|
+
};
|
|
206
|
+
return { runner, calls };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function permissionsFor(viewerPermission, options = {}) {
|
|
210
|
+
const { runner } = roleRunner(viewerPermission, options);
|
|
211
|
+
ghIssues.setGhRunner(runner);
|
|
212
|
+
const res = await ghIssues.getIssue(76, { ...PINNED, includeMetadata: true });
|
|
213
|
+
assert.equal(res.ok, true, res.error);
|
|
214
|
+
return res.issue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('TRIAGE may close, label and assign but may not create a label', async () => {
|
|
218
|
+
const issue = await permissionsFor('TRIAGE');
|
|
219
|
+
assert.deepEqual(issue.permissions, {
|
|
220
|
+
canEditLabels: true,
|
|
221
|
+
canEditAssignees: true,
|
|
222
|
+
canCloseIssues: true,
|
|
223
|
+
canCreateLabels: false,
|
|
224
|
+
}, 'triage carries every existing-label capability and no creation right');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('WRITE and above add the label-creation right', async () => {
|
|
228
|
+
for (const role of ['WRITE', 'MAINTAIN', 'ADMIN']) {
|
|
229
|
+
const issue = await permissionsFor(role);
|
|
230
|
+
assert.deepEqual(issue.permissions, {
|
|
231
|
+
canEditLabels: true,
|
|
232
|
+
canEditAssignees: true,
|
|
233
|
+
canCloseIssues: true,
|
|
234
|
+
canCreateLabels: true,
|
|
235
|
+
}, `${role} may create a label`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('READ, NONE, an unknown role and a failed read all leave every capability false', async () => {
|
|
240
|
+
const nobody = { canEditLabels: false, canEditAssignees: false, canCloseIssues: false, canCreateLabels: false };
|
|
241
|
+
for (const role of ['READ', 'NONE', 'write', 'TRIAGE ', undefined]) {
|
|
242
|
+
const issue = await permissionsFor(role);
|
|
243
|
+
assert.deepEqual(issue.permissions, nobody, `${JSON.stringify(role)} permits nothing`);
|
|
244
|
+
}
|
|
245
|
+
const failed = await permissionsFor(null, {
|
|
246
|
+
access: { status: 1, stdout: '', stderr: 'permission read failed', error: null },
|
|
247
|
+
});
|
|
248
|
+
assert.deepEqual(failed.permissions, nobody, 'an unreadable permission fails closed');
|
|
249
|
+
assert.match(String(failed.metadataError), /permission read failed/, 'and the failure is reported');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('failure shapes: nothing throws', () => {
|
|
254
|
+
test('missing gh binary (ENOENT) fails closed on every read call', async () => {
|
|
255
|
+
const { runner } = makeRunner({ status: null, stdout: '', stderr: '', error: 'spawn gh ENOENT' });
|
|
256
|
+
ghIssues.setGhRunner(runner);
|
|
257
|
+
for (const res of [
|
|
258
|
+
await ghIssues.ghStatus(),
|
|
259
|
+
await ghIssues.repoInfo(),
|
|
260
|
+
await ghIssues.listIssues({ page: 1, pageSize: 10 }),
|
|
261
|
+
await ghIssues.getIssue(1),
|
|
262
|
+
await ghIssues.listLabels(),
|
|
263
|
+
]) {
|
|
264
|
+
assert.equal(res.ok, false);
|
|
265
|
+
assert.match(String(res.error), /ENOENT/i);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('unauthenticated gh reports the auth failure', async () => {
|
|
270
|
+
const { runner } = makeRunner({
|
|
271
|
+
status: 1, stdout: '', stderr: 'To authenticate, please run `gh auth login`.', error: null,
|
|
272
|
+
});
|
|
273
|
+
ghIssues.setGhRunner(runner);
|
|
274
|
+
const status = await ghIssues.ghStatus();
|
|
275
|
+
assert.equal(status.ok, false);
|
|
276
|
+
assert.match(String(status.error), /gh auth login/);
|
|
277
|
+
const list = await ghIssues.listIssues({ page: 1, pageSize: 10 });
|
|
278
|
+
assert.equal(list.ok, false);
|
|
279
|
+
assert.match(String(list.error), /gh auth login/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('empty result reads as an empty list, not an error', async () => {
|
|
283
|
+
const { runner } = makeRunner(okJson('[]'));
|
|
284
|
+
ghIssues.setGhRunner(runner);
|
|
285
|
+
const list = await ghIssues.listIssues({ page: 1, pageSize: 30 });
|
|
286
|
+
assert.equal(list.ok, true);
|
|
287
|
+
assert.deepEqual(list.issues, []);
|
|
288
|
+
assert.equal(list.total, 0);
|
|
289
|
+
const labels = await ghIssues.listLabels();
|
|
290
|
+
assert.equal(labels.ok, true);
|
|
291
|
+
assert.deepEqual(labels.labels, []);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('malformed JSON fails closed', async () => {
|
|
295
|
+
const { runner } = makeRunner(okJson('this is not json{{{'));
|
|
296
|
+
ghIssues.setGhRunner(runner);
|
|
297
|
+
for (const res of [
|
|
298
|
+
await ghIssues.listIssues({ page: 1, pageSize: 10 }),
|
|
299
|
+
await ghIssues.getIssue(1),
|
|
300
|
+
await ghIssues.listLabels(),
|
|
301
|
+
await ghIssues.ghStatus(),
|
|
302
|
+
]) {
|
|
303
|
+
assert.equal(res.ok, false);
|
|
304
|
+
assert.ok(String(res.error).length > 0);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('empty body fails closed', async () => {
|
|
309
|
+
const { runner } = makeRunner(okJson(''));
|
|
310
|
+
ghIssues.setGhRunner(runner);
|
|
311
|
+
const list = await ghIssues.listIssues({ page: 1, pageSize: 10 });
|
|
312
|
+
assert.equal(list.ok, false);
|
|
313
|
+
assert.ok(String(list.error).length > 0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('a throwing or rejecting runner still returns {ok:false}', async () => {
|
|
317
|
+
ghIssues.setGhRunner(() => Promise.reject(new Error('boom')));
|
|
318
|
+
const rejected = await ghIssues.listIssues({ page: 1, pageSize: 10 });
|
|
319
|
+
assert.equal(rejected.ok, false);
|
|
320
|
+
assert.match(String(rejected.error), /boom/);
|
|
321
|
+
ghIssues.setGhRunner(() => { throw new Error('sync boom'); });
|
|
322
|
+
const thrown = await ghIssues.getIssue(1);
|
|
323
|
+
assert.equal(thrown.ok, false);
|
|
324
|
+
assert.match(String(thrown.error), /sync boom/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('non-zero exit on a write call reports the stderr', async () => {
|
|
328
|
+
const { runner } = makeRunner({ status: 1, stdout: '', stderr: 'Could not resolve to an Issue', error: null });
|
|
329
|
+
ghIssues.setGhRunner(runner);
|
|
330
|
+
const edited = await ghIssues.editLabels(999999, { add: ['bug'], remove: [] });
|
|
331
|
+
assert.equal(edited.ok, false);
|
|
332
|
+
assert.match(String(edited.error), /Could not resolve/);
|
|
333
|
+
const commented = await ghIssues.commentIssue(999999, 'hi');
|
|
334
|
+
assert.equal(commented.ok, false);
|
|
335
|
+
const state = await ghIssues.setIssueState(999999, 'closed');
|
|
336
|
+
assert.equal(state.ok, false);
|
|
337
|
+
const created = await ghIssues.createLabel({ name: 'x' });
|
|
338
|
+
assert.equal(created.ok, false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('invalid inputs fail closed without running gh', async () => {
|
|
342
|
+
const { runner, calls } = makeRunner(okJson('[]'));
|
|
343
|
+
ghIssues.setGhRunner(runner);
|
|
344
|
+
assert.equal((await ghIssues.getIssue('abc')).ok, false);
|
|
345
|
+
assert.equal((await ghIssues.commentIssue(1, '')).ok, false);
|
|
346
|
+
assert.equal((await ghIssues.commentIssue(1, ' ')).ok, false);
|
|
347
|
+
assert.equal((await ghIssues.setIssueState(1, 'bogus')).ok, false);
|
|
348
|
+
assert.equal((await ghIssues.createLabel({ name: '' })).ok, false);
|
|
349
|
+
assert.equal(calls.length, 0);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('paging', () => {
|
|
354
|
+
function manyIssues(n) {
|
|
355
|
+
const items = [];
|
|
356
|
+
for (let i = 1; i <= n; i += 1) {
|
|
357
|
+
items.push({
|
|
358
|
+
number: i, title: `issue ${i}`, state: 'OPEN', stateReason: '',
|
|
359
|
+
author: { login: 'someone' }, labels: [],
|
|
360
|
+
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
|
|
361
|
+
comments: [], reactionGroups: [],
|
|
362
|
+
url: `https://example.test/issues/${i}`, body: '',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return JSON.stringify(items);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
test('pages slice the in-memory fetch: page 3 of 65 at size 30', async () => {
|
|
369
|
+
const { runner, calls } = makeRunner(okJson(manyIssues(65)));
|
|
370
|
+
ghIssues.setGhRunner(runner);
|
|
371
|
+
const res = await ghIssues.listIssues({ query: '', page: 3, pageSize: 30 });
|
|
372
|
+
assert.equal(res.ok, true);
|
|
373
|
+
assert.equal(res.total, 65);
|
|
374
|
+
assert.equal(res.page, 3);
|
|
375
|
+
assert.equal(res.pageSize, 30);
|
|
376
|
+
assert.equal(res.issues.length, 5);
|
|
377
|
+
assert.equal(res.issues[0].number, 61);
|
|
378
|
+
// The fetch asks gh for a bounded superset, never an offset flag.
|
|
379
|
+
const limitAt = calls[0].argv.indexOf('--limit');
|
|
380
|
+
assert.ok(limitAt >= 0);
|
|
381
|
+
assert.ok(Number(calls[0].argv[limitAt + 1]) >= 65);
|
|
382
|
+
assert.ok(!calls[0].argv.includes('--offset'));
|
|
383
|
+
assert.ok(!calls[0].argv.includes('--page'));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('truncated is set exactly when the fetch bound is reached', async () => {
|
|
387
|
+
function manyIssues(n) {
|
|
388
|
+
const items = [];
|
|
389
|
+
for (let i = 1; i <= n; i += 1) {
|
|
390
|
+
items.push({
|
|
391
|
+
number: i, title: `issue ${i}`, state: 'OPEN', stateReason: '',
|
|
392
|
+
author: { login: 'someone' }, labels: [],
|
|
393
|
+
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
|
|
394
|
+
comments: [], reactionGroups: [],
|
|
395
|
+
url: `https://example.test/issues/${i}`, body: '',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return JSON.stringify(items);
|
|
399
|
+
}
|
|
400
|
+
const full = makeRunner(okJson(manyIssues(5)));
|
|
401
|
+
ghIssues.setGhRunner(full.runner);
|
|
402
|
+
const reached = await ghIssues.listIssues({ page: 1, pageSize: 5 }, { fetchMax: 5 });
|
|
403
|
+
assert.equal(reached.ok, true);
|
|
404
|
+
assert.equal(reached.truncated, true, 'fetch count reaching the bound marks truncation');
|
|
405
|
+
const limitAt = full.calls[0].argv.indexOf('--limit');
|
|
406
|
+
assert.equal(full.calls[0].argv[limitAt + 1], '5', 'the injectable bound drives --limit');
|
|
407
|
+
const short = makeRunner(okJson(manyIssues(3)));
|
|
408
|
+
ghIssues.setGhRunner(short.runner);
|
|
409
|
+
const notReached = await ghIssues.listIssues({ page: 1, pageSize: 5 }, { fetchMax: 5 });
|
|
410
|
+
assert.equal(notReached.ok, true);
|
|
411
|
+
assert.ok(!notReached.truncated, 'a short fetch carries no truncation marker');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('a page past the end reads as empty, not an error', async () => {
|
|
415
|
+
const { runner } = makeRunner(okJson(manyIssues(10)));
|
|
416
|
+
ghIssues.setGhRunner(runner);
|
|
417
|
+
const res = await ghIssues.listIssues({ query: '', page: 4, pageSize: 30 });
|
|
418
|
+
assert.equal(res.ok, true);
|
|
419
|
+
assert.deepEqual(res.issues, []);
|
|
420
|
+
assert.equal(res.total, 10);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('listAllOpenIssues: cursor pagination of the whole open set', () => {
|
|
425
|
+
function pageNode(number) {
|
|
426
|
+
return {
|
|
427
|
+
number,
|
|
428
|
+
title: `issue ${number}`,
|
|
429
|
+
state: 'OPEN',
|
|
430
|
+
stateReason: '',
|
|
431
|
+
author: { login: 'octo' },
|
|
432
|
+
labels: { nodes: [] },
|
|
433
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
434
|
+
updatedAt: '2024-01-02T00:00:00Z',
|
|
435
|
+
url: `https://example.test/issues/${number}`,
|
|
436
|
+
comments: { totalCount: 0 },
|
|
437
|
+
reactionGroups: [],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function page(numbers, { totalCount = numbers.length, hasNextPage = false, endCursor = null } = {}) {
|
|
442
|
+
return okJson(JSON.stringify({
|
|
443
|
+
data: {
|
|
444
|
+
repository: {
|
|
445
|
+
issues: {
|
|
446
|
+
totalCount,
|
|
447
|
+
pageInfo: { hasNextPage, endCursor },
|
|
448
|
+
nodes: numbers.map(pageNode),
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const repoView = okJson('{"nameWithOwner":"octo/repo"}\n');
|
|
456
|
+
const numbers = (from, to) => {
|
|
457
|
+
const list = [];
|
|
458
|
+
for (let n = from; n <= to; n += 1) list.push(n);
|
|
459
|
+
return list;
|
|
460
|
+
};
|
|
461
|
+
const graphqlCalls = (calls) => calls.filter((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql');
|
|
462
|
+
const variable = (call, name) => call.argv.find((entry) => entry.startsWith(`${name}=`));
|
|
463
|
+
|
|
464
|
+
test('250 open issues take three cursor pages, one request each', async () => {
|
|
465
|
+
const { runner, calls } = makeRunner([
|
|
466
|
+
repoView,
|
|
467
|
+
page(numbers(1, 100), { totalCount: 250, hasNextPage: true, endCursor: 'c1' }),
|
|
468
|
+
page(numbers(101, 200), { totalCount: 250, hasNextPage: true, endCursor: 'c2' }),
|
|
469
|
+
page(numbers(201, 250), { totalCount: 250 }),
|
|
470
|
+
]);
|
|
471
|
+
ghIssues.setGhRunner(runner);
|
|
472
|
+
|
|
473
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
474
|
+
|
|
475
|
+
assert.equal(res.ok, true);
|
|
476
|
+
assert.equal(res.issues.length, 250, 'every open issue, from a 250-issue repository');
|
|
477
|
+
assert.equal(res.total, 250);
|
|
478
|
+
assert.equal(res.pages, 3);
|
|
479
|
+
assert.equal(calls.length, 4, 'one repository lookup plus one request per page');
|
|
480
|
+
const pages = graphqlCalls(calls);
|
|
481
|
+
assert.equal(pages.length, 3);
|
|
482
|
+
for (const call of pages) {
|
|
483
|
+
assert.equal(variable(call, 'owner'), 'owner=octo');
|
|
484
|
+
assert.equal(variable(call, 'name'), 'name=repo');
|
|
485
|
+
assert.equal(variable(call, 'first'), 'first=100');
|
|
486
|
+
assert.ok(!call.argv.includes('--search'), 'server pagination, never the search API');
|
|
487
|
+
const queryAt = call.argv.indexOf('-f');
|
|
488
|
+
assert.ok(queryAt >= 0, 'the query travels as a raw -f field');
|
|
489
|
+
const query = call.argv[queryAt + 1];
|
|
490
|
+
assert.match(query, /^query=/);
|
|
491
|
+
assert.match(query, /issues\(states: OPEN/);
|
|
492
|
+
assert.match(query, /after: \$cursor/);
|
|
493
|
+
assert.match(query, /pageInfo/);
|
|
494
|
+
assert.match(query, /totalCount/);
|
|
495
|
+
}
|
|
496
|
+
assert.equal(variable(pages[0], 'cursor'), undefined, 'the first page starts the connection');
|
|
497
|
+
assert.equal(variable(pages[1], 'cursor'), 'cursor=c1');
|
|
498
|
+
assert.equal(variable(pages[2], 'cursor'), 'cursor=c2');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('more than 600 open issues page to the end and all of them arrive', async () => {
|
|
502
|
+
const script = [repoView];
|
|
503
|
+
for (let start = 1; start <= 601; start += 100) {
|
|
504
|
+
const end = Math.min(start + 99, 620);
|
|
505
|
+
const hasNextPage = end < 620;
|
|
506
|
+
script.push(page(numbers(start, end), {
|
|
507
|
+
totalCount: 620,
|
|
508
|
+
hasNextPage,
|
|
509
|
+
endCursor: hasNextPage ? `c${end}` : null,
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
const { runner } = makeRunner(script);
|
|
513
|
+
ghIssues.setGhRunner(runner);
|
|
514
|
+
|
|
515
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
516
|
+
|
|
517
|
+
assert.equal(res.ok, true);
|
|
518
|
+
assert.equal(res.issues.length, 620, 'no fixed issue cap: the whole connection arrives');
|
|
519
|
+
assert.equal(res.total, 620);
|
|
520
|
+
assert.equal(res.pages, 7);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('a repeated cursor fails the whole snapshot', async () => {
|
|
524
|
+
const { runner } = makeRunner([
|
|
525
|
+
repoView,
|
|
526
|
+
page(numbers(1, 100), { totalCount: 250, hasNextPage: true, endCursor: 'stuck' }),
|
|
527
|
+
page(numbers(101, 200), { totalCount: 250, hasNextPage: true, endCursor: 'stuck' }),
|
|
528
|
+
]);
|
|
529
|
+
ghIssues.setGhRunner(runner);
|
|
530
|
+
|
|
531
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
532
|
+
|
|
533
|
+
assert.equal(res.ok, false);
|
|
534
|
+
assert.match(String(res.error), /repeated the cursor/);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('a page carrying GraphQL errors fails the whole snapshot', async () => {
|
|
538
|
+
const { runner } = makeRunner([
|
|
539
|
+
repoView,
|
|
540
|
+
okJson(JSON.stringify({ errors: [{ message: 'API rate limit exceeded' }] })),
|
|
541
|
+
]);
|
|
542
|
+
ghIssues.setGhRunner(runner);
|
|
543
|
+
|
|
544
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
545
|
+
|
|
546
|
+
assert.equal(res.ok, false);
|
|
547
|
+
assert.match(String(res.error), /API rate limit exceeded/);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('a terminal page short of the reported total fails: no partial snapshot', async () => {
|
|
551
|
+
const { runner } = makeRunner([
|
|
552
|
+
repoView,
|
|
553
|
+
page(numbers(1, 100), { totalCount: 250, hasNextPage: true, endCursor: 'c1' }),
|
|
554
|
+
page(numbers(101, 200), { totalCount: 250, hasNextPage: false, endCursor: null }),
|
|
555
|
+
]);
|
|
556
|
+
ghIssues.setGhRunner(runner);
|
|
557
|
+
|
|
558
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
559
|
+
|
|
560
|
+
assert.equal(res.ok, false);
|
|
561
|
+
assert.match(String(res.error), /200 of 250 open issues/);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('hasNextPage without a cursor fails instead of looping', async () => {
|
|
565
|
+
const { runner } = makeRunner([
|
|
566
|
+
repoView,
|
|
567
|
+
page(numbers(1, 100), { totalCount: 250, hasNextPage: true, endCursor: null }),
|
|
568
|
+
]);
|
|
569
|
+
ghIssues.setGhRunner(runner);
|
|
570
|
+
|
|
571
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
572
|
+
|
|
573
|
+
assert.equal(res.ok, false);
|
|
574
|
+
assert.match(String(res.error), /without a cursor/);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('a malformed connection and an unresolved repository both fail closed', async () => {
|
|
578
|
+
const malformed = makeRunner([repoView, okJson(JSON.stringify({ data: { repository: { issues: {} } } }))]);
|
|
579
|
+
ghIssues.setGhRunner(malformed.runner);
|
|
580
|
+
const bad = await ghIssues.listAllOpenIssues();
|
|
581
|
+
assert.equal(bad.ok, false);
|
|
582
|
+
assert.match(String(bad.error), /unexpected shape/);
|
|
583
|
+
|
|
584
|
+
const noRepo = makeRunner({ status: 1, stdout: '', stderr: 'no git remotes found', error: null });
|
|
585
|
+
ghIssues.setGhRunner(noRepo.runner);
|
|
586
|
+
const unresolved = await ghIssues.listAllOpenIssues();
|
|
587
|
+
assert.equal(unresolved.ok, false);
|
|
588
|
+
assert.match(String(unresolved.error), /no git remotes found/);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('a failing page request reports the transport error', async () => {
|
|
592
|
+
const { runner } = makeRunner([
|
|
593
|
+
repoView,
|
|
594
|
+
{ status: null, stdout: '', stderr: '', error: 'gh timed out after 30000ms' },
|
|
595
|
+
]);
|
|
596
|
+
ghIssues.setGhRunner(runner);
|
|
597
|
+
|
|
598
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
599
|
+
|
|
600
|
+
assert.equal(res.ok, false);
|
|
601
|
+
assert.match(String(res.error), /timed out/);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('the row shape matches the list rows the rest of the client returns', async () => {
|
|
605
|
+
const { runner } = makeRunner([repoView, page([7], { totalCount: 1 })]);
|
|
606
|
+
ghIssues.setGhRunner(runner);
|
|
607
|
+
|
|
608
|
+
const res = await ghIssues.listAllOpenIssues();
|
|
609
|
+
|
|
610
|
+
assert.equal(res.ok, true);
|
|
611
|
+
assert.equal(res.issues[0].number, 7);
|
|
612
|
+
assert.equal(res.issues[0].state, 'open');
|
|
613
|
+
assert.equal(res.issues[0].author, 'octo');
|
|
614
|
+
assert.deepEqual(res.issues[0].labels, []);
|
|
615
|
+
assert.equal(res.issues[0].commentsCount, 0);
|
|
616
|
+
assert.equal(res.issues[0].url, 'https://example.test/issues/7');
|
|
617
|
+
assert.equal(res.issues[0].body, '', 'the snapshot never asks gh for bodies');
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe('honest totals: one bounded fetch, page sliced in memory', () => {
|
|
622
|
+
function honestIssues(n) {
|
|
623
|
+
const items = [];
|
|
624
|
+
for (let i = 1; i <= n; i += 1) {
|
|
625
|
+
items.push({
|
|
626
|
+
number: i, title: `issue ${i}`, state: 'OPEN', stateReason: '',
|
|
627
|
+
author: { login: 'someone' }, labels: [],
|
|
628
|
+
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
|
|
629
|
+
comments: [], reactionGroups: [],
|
|
630
|
+
url: `https://example.test/issues/${i}`, body: `body ${i}`,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return JSON.stringify(items);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
test('page 1 of a 25-item set reports total 25 with 20 rows, fetched once up to the bound', async () => {
|
|
637
|
+
const { runner, calls } = makeRunner(okJson(honestIssues(25)));
|
|
638
|
+
ghIssues.setGhRunner(runner);
|
|
639
|
+
const res = await ghIssues.listIssues({ query: 'involves:@me', page: 1, pageSize: 20 });
|
|
640
|
+
assert.equal(res.ok, true);
|
|
641
|
+
assert.equal(res.total, 25, 'the total is what was fetched, never the page size');
|
|
642
|
+
assert.equal(res.issues.length, 20);
|
|
643
|
+
assert.equal(res.issues[0].number, 1);
|
|
644
|
+
assert.equal(calls.length, 1, 'exactly one fetch for the page');
|
|
645
|
+
const limitAt = calls[0].argv.indexOf('--limit');
|
|
646
|
+
assert.ok(limitAt >= 0);
|
|
647
|
+
assert.equal(calls[0].argv[limitAt + 1], '500', 'one fetch up to the bound, never page * pageSize');
|
|
648
|
+
assert.equal(res.truncated, false);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('page 2 of the same 25-item set reports the same total 25 with 5 rows', async () => {
|
|
652
|
+
const { runner } = makeRunner(okJson(honestIssues(25)));
|
|
653
|
+
ghIssues.setGhRunner(runner);
|
|
654
|
+
const res = await ghIssues.listIssues({ query: 'involves:@me', page: 2, pageSize: 20 });
|
|
655
|
+
assert.equal(res.ok, true);
|
|
656
|
+
assert.equal(res.total, 25, 'every page of one fetch reports the same total');
|
|
657
|
+
assert.equal(res.issues.length, 5);
|
|
658
|
+
assert.equal(res.issues[0].number, 21);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test('a bound-reached fetch reports truncated:true while an under-bound one reports false', async () => {
|
|
662
|
+
const full = makeRunner(okJson(honestIssues(25)));
|
|
663
|
+
ghIssues.setGhRunner(full.runner);
|
|
664
|
+
const reached = await ghIssues.listIssues({ page: 1, pageSize: 20 }, { fetchMax: 25 });
|
|
665
|
+
assert.equal(reached.ok, true);
|
|
666
|
+
assert.equal(reached.total, 25);
|
|
667
|
+
assert.equal(reached.truncated, true, 'a fetch that reached the bound is a lower bound');
|
|
668
|
+
const limitAt = full.calls[0].argv.indexOf('--limit');
|
|
669
|
+
assert.equal(full.calls[0].argv[limitAt + 1], '25', 'the injectable bound drives --limit, not the page');
|
|
670
|
+
const short = makeRunner(okJson(honestIssues(24)));
|
|
671
|
+
ghIssues.setGhRunner(short.runner);
|
|
672
|
+
const notReached = await ghIssues.listIssues({ page: 1, pageSize: 20 }, { fetchMax: 25 });
|
|
673
|
+
assert.equal(notReached.ok, true);
|
|
674
|
+
assert.equal(notReached.total, 24);
|
|
675
|
+
assert.equal(notReached.truncated, false, 'a short fetch is exact');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('wholeResult returns the one bounded fetch instead of a page of it', async () => {
|
|
679
|
+
const { runner, calls } = makeRunner(okJson(honestIssues(250)));
|
|
680
|
+
ghIssues.setGhRunner(runner);
|
|
681
|
+
const res = await ghIssues.listIssues({ state: 'open', wholeResult: true }, { fetchMax: 251 });
|
|
682
|
+
assert.equal(res.ok, true);
|
|
683
|
+
assert.equal(calls.length, 1, 'one bounded read, never one per logical page');
|
|
684
|
+
assert.equal(res.issues.length, 250, 'the whole fetched result, not a page of it');
|
|
685
|
+
assert.equal(res.total, 250);
|
|
686
|
+
assert.equal(res.truncated, false);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('a wholeResult fetch that fills the bound is reported truncated', async () => {
|
|
690
|
+
const { runner } = makeRunner(okJson(honestIssues(45)));
|
|
691
|
+
ghIssues.setGhRunner(runner);
|
|
692
|
+
const res = await ghIssues.listIssues({ state: 'open', wholeResult: true }, { fetchMax: 45 });
|
|
693
|
+
assert.equal(res.ok, true);
|
|
694
|
+
assert.equal(res.issues.length, 45);
|
|
695
|
+
assert.equal(res.truncated, true, 'a bound that filled proves nothing about the rest');
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('the list fetch omits body while the detail fetch keeps it', async () => {
|
|
699
|
+
const { runner, calls } = makeRunner(okJson(honestIssues(3)));
|
|
700
|
+
ghIssues.setGhRunner(runner);
|
|
701
|
+
const listed = await ghIssues.listIssues({ page: 1, pageSize: 20 });
|
|
702
|
+
assert.equal(listed.ok, true);
|
|
703
|
+
const jsonAt = calls[0].argv.indexOf('--json');
|
|
704
|
+
assert.ok(jsonAt >= 0);
|
|
705
|
+
assert.ok(!calls[0].argv[jsonAt + 1].split(',').includes('body'), 'the list stays light: no body');
|
|
706
|
+
const view = makeRunner(okJson(fixture('gh-issue-view.json')));
|
|
707
|
+
ghIssues.setGhRunner(view.runner);
|
|
708
|
+
const detail = await ghIssues.getIssue(76);
|
|
709
|
+
assert.equal(detail.ok, true);
|
|
710
|
+
const detailAt = view.calls[0].argv.indexOf('--json');
|
|
711
|
+
assert.ok(view.calls[0].argv[detailAt + 1].split(',').includes('body'), 'the detail pane keeps body');
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe('proof of a missing issue (S7d)', () => {
|
|
716
|
+
test('issue-specific "could not resolve to an Issue" is proof', async () => {
|
|
717
|
+
const { runner } = makeRunner({
|
|
718
|
+
status: 1,
|
|
719
|
+
stdout: '',
|
|
720
|
+
stderr: 'GraphQL: Could not resolve to an Issue with the number of 999. (repository.issue)',
|
|
721
|
+
error: null,
|
|
722
|
+
});
|
|
723
|
+
ghIssues.setGhRunner(runner);
|
|
724
|
+
const res = await ghIssues.getIssue(999);
|
|
725
|
+
assert.equal(res.ok, false);
|
|
726
|
+
assert.equal(res.notFound, true);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('a repository-level "Not Found (HTTP 404)" is not proof', async () => {
|
|
730
|
+
const { runner } = makeRunner({
|
|
731
|
+
status: 1,
|
|
732
|
+
stdout: '',
|
|
733
|
+
stderr: 'HTTP 404: Not Found (https://api.github.com/graphql)',
|
|
734
|
+
error: null,
|
|
735
|
+
});
|
|
736
|
+
ghIssues.setGhRunner(runner);
|
|
737
|
+
const res = await ghIssues.getIssue(999);
|
|
738
|
+
assert.equal(res.ok, false);
|
|
739
|
+
assert.equal(res.notFound, false, 'an inaccessible repository says nothing about this number');
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe('argv shell safety', () => {
|
|
744
|
+
test('a hostile query arrives as ONE argv element, never a command string', async () => {
|
|
745
|
+
const hostile = 'hello; rm -rf / $(evil) `id` | cat /etc/passwd && echo pwned';
|
|
746
|
+
const { runner, calls } = makeRunner(okJson('[]'));
|
|
747
|
+
ghIssues.setGhRunner(runner);
|
|
748
|
+
const res = await ghIssues.listIssues({ query: hostile, page: 1, pageSize: 10 });
|
|
749
|
+
assert.equal(res.ok, true);
|
|
750
|
+
assert.equal(calls.length, 1);
|
|
751
|
+
const { bin, argv } = calls[0];
|
|
752
|
+
assert.equal(bin, 'gh');
|
|
753
|
+
assert.ok(Array.isArray(argv));
|
|
754
|
+
assert.ok(argv.every((entry) => typeof entry === 'string'));
|
|
755
|
+
const searchAt = argv.indexOf('--search');
|
|
756
|
+
assert.ok(searchAt >= 0);
|
|
757
|
+
// The whole search string, ordering qualifier included, is still ONE
|
|
758
|
+
// argv element.
|
|
759
|
+
assert.equal(argv[searchAt + 1], `${hostile} sort:updated-desc`);
|
|
760
|
+
// No shell join anywhere: the hostile text is one element, and no
|
|
761
|
+
// element contains shell glue joining it with gh syntax.
|
|
762
|
+
assert.equal(argv.filter((entry) => entry.includes('rm -rf')).length, 1);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('a hostile comment body arrives as ONE argv element', async () => {
|
|
766
|
+
const hostile = 'nice $(touch /tmp/pwned); drop it';
|
|
767
|
+
const { runner, calls } = makeRunner(okJson(''));
|
|
768
|
+
ghIssues.setGhRunner(runner);
|
|
769
|
+
const res = await ghIssues.commentIssue(7, hostile);
|
|
770
|
+
assert.equal(res.ok, true);
|
|
771
|
+
const bodyAt = calls[0].argv.indexOf('--body');
|
|
772
|
+
assert.ok(bodyAt >= 0);
|
|
773
|
+
assert.equal(calls[0].argv[bodyAt + 1], hostile);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('the module source never builds a shell string for gh', () => {
|
|
777
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'gh-issues.js'), 'utf8');
|
|
778
|
+
assert.ok(!/exec\s*\(/.test(src), 'no child_process.exec with a shell string');
|
|
779
|
+
assert.ok(!/execSync\s*\(/.test(src), 'no child_process.execSync with a shell string');
|
|
780
|
+
assert.ok(!/shell\s*:\s*true/.test(src), 'no shell:true spawn option');
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
describe('list ordering: the query carries it (S10)', () => {
|
|
785
|
+
// Payload rows whose updatedAt rises with the number, so a JavaScript
|
|
786
|
+
// re-sort by updatedAt desc would visibly change the order.
|
|
787
|
+
function rowsInOrder(numbers) {
|
|
788
|
+
return JSON.stringify(numbers.map((n) => ({
|
|
789
|
+
number: n, title: `issue ${n}`, state: 'OPEN', stateReason: '',
|
|
790
|
+
author: { login: 'someone' }, labels: [],
|
|
791
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
792
|
+
updatedAt: `2026-01-0${n}T00:00:00Z`,
|
|
793
|
+
comments: [], reactionGroups: [],
|
|
794
|
+
url: `https://example.test/issues/${n}`, body: '',
|
|
795
|
+
})));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
test('asks gh for last-update ordering through the search query', async () => {
|
|
799
|
+
const bare = makeRunner(okJson('[]'));
|
|
800
|
+
ghIssues.setGhRunner(bare.runner);
|
|
801
|
+
await ghIssues.listIssues({ page: 1, pageSize: 10 });
|
|
802
|
+
const bareAt = bare.calls[0].argv.indexOf('--search');
|
|
803
|
+
assert.ok(bareAt >= 0, 'the ordering travels in the search query, not in a JS sort');
|
|
804
|
+
assert.equal(bare.calls[0].argv[bareAt + 1], 'sort:updated-desc');
|
|
805
|
+
|
|
806
|
+
const withQuery = makeRunner(okJson('[]'));
|
|
807
|
+
ghIssues.setGhRunner(withQuery.runner);
|
|
808
|
+
await ghIssues.listIssues({ query: 'author:@me', page: 1, pageSize: 10 });
|
|
809
|
+
const queryAt = withQuery.calls[0].argv.indexOf('--search');
|
|
810
|
+
assert.equal(withQuery.calls[0].argv[queryAt + 1], 'author:@me sort:updated-desc');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test('an explicit caller sort is respected, never doubled', async () => {
|
|
814
|
+
for (const query of ['sort:created-asc', 'author:@me sort:created-asc']) {
|
|
815
|
+
const { runner, calls } = makeRunner(okJson('[]'));
|
|
816
|
+
ghIssues.setGhRunner(runner);
|
|
817
|
+
await ghIssues.listIssues({ query, page: 1, pageSize: 10 });
|
|
818
|
+
const searchAt = calls[0].argv.indexOf('--search');
|
|
819
|
+
const search = calls[0].argv[searchAt + 1];
|
|
820
|
+
assert.equal(search, query, 'the caller sort survives verbatim');
|
|
821
|
+
assert.equal((search.match(/sort:/gi) || []).length, 1, 'exactly one sort qualifier');
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test('rows keep the order the runner returned: no re-sort in JavaScript', async () => {
|
|
826
|
+
const { runner } = makeRunner(okJson(rowsInOrder([3, 1, 2])));
|
|
827
|
+
ghIssues.setGhRunner(runner);
|
|
828
|
+
const res = await ghIssues.listIssues({ page: 1, pageSize: 10 });
|
|
829
|
+
assert.equal(res.ok, true);
|
|
830
|
+
assert.deepEqual(res.issues.map((issue) => issue.number), [3, 1, 2]);
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
describe('detail assignees (adopted reference idea 1)', () => {
|
|
835
|
+
test('getIssue exposes assignees from the detail payload', async () => {
|
|
836
|
+
const doc = JSON.parse(fixture('gh-issue-view.json'));
|
|
837
|
+
doc.assignees = [{ login: 'alice' }, { login: 'bob' }];
|
|
838
|
+
const { runner, calls } = makeRunner(okJson(JSON.stringify(doc)));
|
|
839
|
+
ghIssues.setGhRunner(runner);
|
|
840
|
+
const res = await ghIssues.getIssue(76);
|
|
841
|
+
assert.equal(res.ok, true);
|
|
842
|
+
assert.deepEqual(res.issue.assignees, ['alice', 'bob']);
|
|
843
|
+
const jsonAt = calls[0].argv.indexOf('--json');
|
|
844
|
+
assert.ok(jsonAt >= 0);
|
|
845
|
+
assert.ok(calls[0].argv[jsonAt + 1].split(',').includes('assignees'), 'the detail fetch asks gh for assignees');
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test('getIssue defaults assignees to [] when gh omits them', async () => {
|
|
849
|
+
const { runner } = makeRunner(okJson(fixture('gh-issue-view.json')));
|
|
850
|
+
ghIssues.setGhRunner(runner);
|
|
851
|
+
const res = await ghIssues.getIssue(76);
|
|
852
|
+
assert.equal(res.ok, true);
|
|
853
|
+
assert.deepEqual(res.issue.assignees, []);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('listIssues rows keep their shape: no assignees key', async () => {
|
|
857
|
+
const { runner } = makeRunner(okJson(fixture('gh-issue-list.json')));
|
|
858
|
+
ghIssues.setGhRunner(runner);
|
|
859
|
+
const res = await ghIssues.listIssues({ page: 1, pageSize: 30 });
|
|
860
|
+
assert.equal(res.ok, true);
|
|
861
|
+
assert.ok(res.issues.length >= 1);
|
|
862
|
+
assert.ok(!('assignees' in res.issues[0]), 'the list row shape is unchanged');
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// Fork scope (worktree reliability S5): a fork checkout configures `origin`
|
|
868
|
+
// (the fork) and `upstream` (the original), and `gh repo view` resolves the
|
|
869
|
+
// BASE — the original — for exactly that layout. Every tab used to read the
|
|
870
|
+
// original's issues. The checkout's own repository is the one it pushes to, so
|
|
871
|
+
// resolution and every gh call are scoped to it.
|
|
872
|
+
//
|
|
873
|
+
// The remote configuration is real, isolated git configuration in a temporary
|
|
874
|
+
// checkout: no network, no other repository, and the injected runner models
|
|
875
|
+
// gh's own answers (base from `repo view`, GH_REPO for repo-scoped commands).
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
describe('fork scope (S5)', () => {
|
|
878
|
+
const roots = [];
|
|
879
|
+
after(() => {
|
|
880
|
+
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
function git(cwd, args) {
|
|
884
|
+
return execFileSync('git', args, {
|
|
885
|
+
cwd,
|
|
886
|
+
encoding: 'utf8',
|
|
887
|
+
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// A real checkout whose remotes model a fork: origin (the fork) plus
|
|
892
|
+
// upstream (the original), or whatever layout the case names.
|
|
893
|
+
function makeCheckout({ remotes = [['origin', 'myfork/project'], ['upstream', 'original/project']], pushRemote = null } = {}) {
|
|
894
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gh-fork-')));
|
|
895
|
+
roots.push(dir);
|
|
896
|
+
git(dir, ['init']);
|
|
897
|
+
git(dir, ['config', 'user.email', 'test@example.com']);
|
|
898
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
899
|
+
git(dir, ['commit', '--allow-empty', '-m', 'init']);
|
|
900
|
+
// A bare `owner/name` becomes a GitHub URL; a full URL or a local path is
|
|
901
|
+
// used verbatim, so a case can model a non-GitHub remote.
|
|
902
|
+
for (const [name, repo] of remotes) {
|
|
903
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(repo) || repo.includes(':') || repo.startsWith('/')
|
|
904
|
+
? repo
|
|
905
|
+
: `git@github.com:${repo}.git`;
|
|
906
|
+
git(dir, ['remote', 'add', name, url]);
|
|
907
|
+
}
|
|
908
|
+
if (pushRemote) git(dir, ['config', `branch.${git(dir, ['branch', '--show-current']).trim()}.pushRemote`, pushRemote]);
|
|
909
|
+
return dir;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function okJson(stdout) {
|
|
913
|
+
return { status: 0, stdout, stderr: '', error: null };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// gh's own behavior: `repo view` answers the BASE repository for a fork
|
|
917
|
+
// layout (probed against gh 2.102.1 with origin=fork + upstream=original),
|
|
918
|
+
// while repo-scoped commands honour GH_REPO.
|
|
919
|
+
function github({ base = 'original/project', fork = 'myfork/project' } = {}) {
|
|
920
|
+
const calls = [];
|
|
921
|
+
const runner = async (bin, argv, runOpts) => {
|
|
922
|
+
const env = (runOpts && runOpts.env) || {};
|
|
923
|
+
const repo = typeof env.GH_REPO === 'string' && env.GH_REPO !== '' ? env.GH_REPO : base;
|
|
924
|
+
calls.push({ bin, argv: argv.slice(), repo, scoped: typeof env.GH_REPO === 'string' && env.GH_REPO !== '' });
|
|
925
|
+
if (argv[0] === 'repo' && argv[1] === 'view') return okJson(JSON.stringify({ nameWithOwner: base }));
|
|
926
|
+
if (argv[0] === 'api' && argv[1] === 'user') return okJson('{"login":"me"}');
|
|
927
|
+
if (argv[0] === 'api' && argv[1] === 'graphql') {
|
|
928
|
+
const queryAt = argv.indexOf('-f');
|
|
929
|
+
const query = queryAt >= 0 ? argv[queryAt + 1] : '';
|
|
930
|
+
if (query.includes('type: ISSUE')) {
|
|
931
|
+
return okJson(JSON.stringify({
|
|
932
|
+
data: { search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, edges: [] } },
|
|
933
|
+
}));
|
|
934
|
+
}
|
|
935
|
+
if (query.includes('edges { cursor node')) {
|
|
936
|
+
return okJson(JSON.stringify({
|
|
937
|
+
data: { repository: { issues: {
|
|
938
|
+
totalCount: 0,
|
|
939
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
940
|
+
edges: [],
|
|
941
|
+
} } },
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
return okJson(JSON.stringify({
|
|
945
|
+
data: { repository: { issues: {
|
|
946
|
+
totalCount: 0,
|
|
947
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
948
|
+
nodes: [],
|
|
949
|
+
} } },
|
|
950
|
+
}));
|
|
951
|
+
}
|
|
952
|
+
if (argv[0] === 'issue' && argv[1] === 'list') return okJson('[]');
|
|
953
|
+
if (argv[0] === 'issue' && argv[1] === 'view') return okJson(fixture('gh-issue-view.json'));
|
|
954
|
+
if (argv[0] === 'label' && argv[1] === 'list') return okJson('[]');
|
|
955
|
+
return okJson('');
|
|
956
|
+
};
|
|
957
|
+
const graphqlOwner = (call) => {
|
|
958
|
+
for (let at = 0; at < call.argv.length - 1; at += 1) {
|
|
959
|
+
if (call.argv[at] === '-F' && call.argv[at + 1].startsWith('owner=')) return call.argv[at + 1].slice('owner='.length);
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
};
|
|
963
|
+
const repoScoped = calls.filter((call) => !(call.argv[0] === 'repo' && call.argv[1] === 'view'));
|
|
964
|
+
return { runner, calls, repoScoped, graphqlOwner, fork };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
test('every scoped tab and the All connection read the checkout fork, not the base gh resolves', async () => {
|
|
968
|
+
const checkout = makeCheckout();
|
|
969
|
+
const gh = github();
|
|
970
|
+
ghIssues.setGhRunner(gh.runner);
|
|
971
|
+
|
|
972
|
+
const searched = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
973
|
+
assert.equal(searched.ok, true);
|
|
974
|
+
const connection = await ghIssues.getIssueConnectionPage({ first: 5 }, { cwd: checkout });
|
|
975
|
+
assert.equal(connection.ok, true);
|
|
976
|
+
|
|
977
|
+
const graphql = gh.calls.filter((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql');
|
|
978
|
+
assert.equal(graphql.length, 2, 'one search read and one connection read');
|
|
979
|
+
for (const call of graphql) {
|
|
980
|
+
const owner = gh.graphqlOwner(call);
|
|
981
|
+
if (owner !== null) assert.equal(owner, 'myfork', 'the connection read names the fork');
|
|
982
|
+
assert.ok(!call.argv.some((value) => value.includes('original/project')),
|
|
983
|
+
`no read carries the upstream repository (${call.argv.join(' ')})`);
|
|
984
|
+
}
|
|
985
|
+
const searchField = gh.calls
|
|
986
|
+
.flatMap((call) => call.argv)
|
|
987
|
+
.find((value) => value.startsWith('search=repo:'));
|
|
988
|
+
assert.match(searchField, /search=repo:myfork\/project is:issue is:open/, 'the scoped search names the fork');
|
|
989
|
+
assert.ok(!gh.calls.some((call) => call.argv.some((value) => value.includes('repo:original/project'))),
|
|
990
|
+
'no read is scoped to the upstream repo');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('the session snapshot and margin list read the fork through GH_REPO', async () => {
|
|
994
|
+
const checkout = makeCheckout();
|
|
995
|
+
const gh = github();
|
|
996
|
+
ghIssues.setGhRunner(gh.runner);
|
|
997
|
+
|
|
998
|
+
const snapshot = await ghIssues.listAllOpenIssues({ cwd: checkout });
|
|
999
|
+
assert.equal(snapshot.ok, true);
|
|
1000
|
+
const margin = await ghIssues.listIssues({ state: 'all', page: 1, pageSize: 20 }, { cwd: checkout, fetchMax: 20 });
|
|
1001
|
+
assert.equal(margin.ok, true);
|
|
1002
|
+
|
|
1003
|
+
const list = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'list');
|
|
1004
|
+
assert.ok(list, 'the margin reached gh issue list');
|
|
1005
|
+
// The scope is HOST-QUALIFIED: the same owner/name on another server can
|
|
1006
|
+
// never be reached by an environment change.
|
|
1007
|
+
assert.equal(list.repo, 'github.com/myfork/project', 'gh issue list is scoped to the fork, not the upstream');
|
|
1008
|
+
const snapshotGraphql = gh.calls.find((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql');
|
|
1009
|
+
assert.equal(gh.graphqlOwner(snapshotGraphql), 'myfork', 'the open snapshot reads the fork');
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test('detail, labels and every repo-scoped write command carry the fork scope', async () => {
|
|
1013
|
+
const checkout = makeCheckout();
|
|
1014
|
+
const gh = github();
|
|
1015
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1016
|
+
|
|
1017
|
+
assert.equal((await ghIssues.getIssue(76, { cwd: checkout })).ok, true);
|
|
1018
|
+
assert.equal((await ghIssues.listLabels({ cwd: checkout })).ok, true);
|
|
1019
|
+
|
|
1020
|
+
const detail = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'view');
|
|
1021
|
+
const labels = gh.calls.find((call) => call.argv[0] === 'label' && call.argv[1] === 'list');
|
|
1022
|
+
assert.equal(detail.repo, 'github.com/myfork/project', 'the detail reads the fork issue');
|
|
1023
|
+
assert.equal(labels.repo, 'github.com/myfork/project', 'the repository labels are the fork\'s labels');
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test('the branch push remote decides the fork when origin is the original', async () => {
|
|
1027
|
+
const checkout = makeCheckout({
|
|
1028
|
+
remotes: [['origin', 'original/project'], ['fork', 'myfork/project']],
|
|
1029
|
+
pushRemote: 'fork',
|
|
1030
|
+
});
|
|
1031
|
+
const gh = github();
|
|
1032
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1033
|
+
|
|
1034
|
+
const info = await ghIssues.repoInfo({ cwd: checkout });
|
|
1035
|
+
assert.equal(info.ok, true);
|
|
1036
|
+
assert.equal(info.nameWithOwner, 'myfork/project', 'the branch push remote is the checkout\'s own repository');
|
|
1037
|
+
|
|
1038
|
+
await ghIssues.listIssues({ page: 1, pageSize: 5 }, { cwd: checkout, fetchMax: 5 });
|
|
1039
|
+
const list = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'list');
|
|
1040
|
+
assert.equal(list.repo, 'github.com/myfork/project');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test('a branch that only tracks upstream still scopes to origin, the fork', async () => {
|
|
1044
|
+
// The common fork clone: `origin` is the fork and the local branch tracks
|
|
1045
|
+
// `upstream` only to stay current, so the tracking remote is NOT the scope.
|
|
1046
|
+
const checkout = makeCheckout();
|
|
1047
|
+
const branch = git(checkout, ['branch', '--show-current']).trim();
|
|
1048
|
+
git(checkout, ['config', `branch.${branch}.remote`, 'upstream']);
|
|
1049
|
+
git(checkout, ['config', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
1050
|
+
const gh = github();
|
|
1051
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1052
|
+
|
|
1053
|
+
const info = await ghIssues.repoInfo({ cwd: checkout });
|
|
1054
|
+
assert.equal(info.nameWithOwner, 'myfork/project', 'the tracked upstream never becomes the scope');
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test('a single-remote checkout keeps gh resolution exactly, with no scope injected', async () => {
|
|
1058
|
+
const checkout = makeCheckout({ remotes: [['origin', 'myfork/project']] });
|
|
1059
|
+
const gh = github({ base: 'myfork/project', fork: 'myfork/project' });
|
|
1060
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1061
|
+
|
|
1062
|
+
const info = await ghIssues.repoInfo({ cwd: checkout });
|
|
1063
|
+
assert.equal(info.ok, true);
|
|
1064
|
+
assert.equal(info.nameWithOwner, 'myfork/project', 'gh\'s answer is unchanged');
|
|
1065
|
+
await ghIssues.listIssues({ page: 1, pageSize: 5 }, { cwd: checkout, fetchMax: 5 });
|
|
1066
|
+
const list = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'list');
|
|
1067
|
+
assert.equal(list.scoped, false, 'a non-fork checkout runs gh exactly as before');
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('a repository lookup failure is reported unchanged, never masked by the fork override', async () => {
|
|
1071
|
+
const checkout = makeCheckout();
|
|
1072
|
+
const ok = (stdout) => ({ status: 0, stdout, stderr: '', error: null });
|
|
1073
|
+
const calls = [];
|
|
1074
|
+
ghIssues.setGhRunner(async (bin, argv, runOpts) => {
|
|
1075
|
+
calls.push({ argv: argv.slice(), env: (runOpts && runOpts.env) || {} });
|
|
1076
|
+
if (argv[0] === 'repo' && argv[1] === 'view') {
|
|
1077
|
+
return { status: 1, stdout: '', stderr: 'not authenticated: run gh auth login', error: null };
|
|
1078
|
+
}
|
|
1079
|
+
return ok('[]');
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1083
|
+
assert.equal(page.ok, false);
|
|
1084
|
+
assert.match(page.error, /cannot resolve the repository: not authenticated: run gh auth login/);
|
|
1085
|
+
assert.ok(!calls.some((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql'),
|
|
1086
|
+
'a failed resolution never falls through to a scoped read');
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// A head remote that cannot be PROVEN to name a GitHub repository must never
|
|
1090
|
+
// become GH_REPO: a guessed owner/name would send a read — or worse a write —
|
|
1091
|
+
// to an unrelated repository. Resolution fails closed and no gh command runs.
|
|
1092
|
+
test('a local or file checkout remote fails closed: no gh read or write runs', async () => {
|
|
1093
|
+
const checkout = makeCheckout({
|
|
1094
|
+
remotes: [['origin', 'file:///srv/mirror/project.git'], ['upstream', 'original/project']],
|
|
1095
|
+
});
|
|
1096
|
+
const gh = github();
|
|
1097
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1098
|
+
|
|
1099
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1100
|
+
const list = await ghIssues.listIssues({ state: 'open', page: 1, pageSize: 5 }, { cwd: checkout, fetchMax: 5 });
|
|
1101
|
+
const detail = await ghIssues.getIssue(7, { cwd: checkout });
|
|
1102
|
+
const labels = await ghIssues.listLabels({ cwd: checkout });
|
|
1103
|
+
const comment = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1104
|
+
const close = await ghIssues.setIssueState(7, 'closed', { cwd: checkout });
|
|
1105
|
+
const edit = await ghIssues.editLabels(7, { add: ['bug'] }, { cwd: checkout });
|
|
1106
|
+
|
|
1107
|
+
for (const outcome of [page, list, detail, labels, comment, close, edit]) {
|
|
1108
|
+
assert.equal(outcome.ok, false, 'the read or write is refused');
|
|
1109
|
+
assert.match(outcome.error, /checkout remote "origin" is a local path, not a GitHub repository/);
|
|
1110
|
+
}
|
|
1111
|
+
assert.equal(gh.calls.length, 0,
|
|
1112
|
+
'no gh read and no gh write ran, so nothing can be redirected to another repository');
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('a checkout remote on a non-GitHub host fails closed instead of being read as owner/name', async () => {
|
|
1116
|
+
const checkout = makeCheckout({
|
|
1117
|
+
remotes: [['origin', 'https://gitlab.com/myfork/project.git'], ['upstream', 'original/project']],
|
|
1118
|
+
});
|
|
1119
|
+
const gh = github();
|
|
1120
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1121
|
+
|
|
1122
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1123
|
+
assert.equal(page.ok, false);
|
|
1124
|
+
assert.match(page.error, /checkout remote "origin" is on gitlab\.com, which is not a GitHub host/);
|
|
1125
|
+
const comment = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1126
|
+
assert.equal(comment.ok, false, 'a write is refused too');
|
|
1127
|
+
assert.equal(gh.calls.length, 0, 'the GitLab path never becomes a GitHub owner/name');
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test('the selected head remote is never swapped for a supported one', async () => {
|
|
1131
|
+
// `origin` is a valid GitHub fork, but this branch pushes to a GitLab
|
|
1132
|
+
// remote: the SELECTED remote decides, and its failure is final.
|
|
1133
|
+
const checkout = makeCheckout({
|
|
1134
|
+
remotes: [['origin', 'myfork/project'], ['fork', 'https://gitlab.com/myfork/project.git']],
|
|
1135
|
+
pushRemote: 'fork',
|
|
1136
|
+
});
|
|
1137
|
+
const gh = github();
|
|
1138
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1139
|
+
|
|
1140
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1141
|
+
assert.equal(page.ok, false);
|
|
1142
|
+
assert.match(page.error, /checkout remote "fork" is on gitlab\.com/,
|
|
1143
|
+
'the refusal names the selected remote, never a substituted one');
|
|
1144
|
+
assert.equal(gh.calls.length, 0, 'the GitHub origin is not used as a fallback');
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
test('encoded or malformed repository identifiers are rejected', async () => {
|
|
1148
|
+
const cases = [
|
|
1149
|
+
['https://github.com/my%20fork/project.git', 'a percent-encoded owner'],
|
|
1150
|
+
['https://github.com/-bad-/project.git', 'an owner with a leading hyphen'],
|
|
1151
|
+
['https://github.com/myfork/project/extra.git', 'a path with more than owner/name'],
|
|
1152
|
+
];
|
|
1153
|
+
for (const [url, label] of cases) {
|
|
1154
|
+
const checkout = makeCheckout({ remotes: [['origin', url], ['upstream', 'original/project']] });
|
|
1155
|
+
const gh = github();
|
|
1156
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1157
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1158
|
+
assert.equal(page.ok, false, label);
|
|
1159
|
+
assert.match(page.error, /checkout remote "origin" names an invalid GitHub repository/, label);
|
|
1160
|
+
assert.equal(gh.calls.length, 0, `no gh command runs for ${label}`);
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test('a self-hosted GitHub host is accepted only when GH_HOST names it', async () => {
|
|
1165
|
+
const remotes = [['origin', 'git@github.mycorp.com:myfork/project.git'], ['upstream', 'git@github.mycorp.com:original/project.git']];
|
|
1166
|
+
const deniedCheckout = makeCheckout({ remotes });
|
|
1167
|
+
const denied = await ghIssues.checkoutRepoScope({ cwd: deniedCheckout, env: { ...process.env, GH_HOST: '' } });
|
|
1168
|
+
assert.equal(denied.ok, false, 'a self-hosted server without GH_HOST is not consistently reachable');
|
|
1169
|
+
assert.match(denied.error, /gh reaches only when GH_HOST selects it/);
|
|
1170
|
+
|
|
1171
|
+
// A fresh checkout: the scope answer is cached per checkout.
|
|
1172
|
+
const allowedCheckout = makeCheckout({ remotes });
|
|
1173
|
+
const allowed = await ghIssues.checkoutRepoScope({
|
|
1174
|
+
cwd: allowedCheckout,
|
|
1175
|
+
env: { ...process.env, GH_HOST: 'github.mycorp.com' },
|
|
1176
|
+
});
|
|
1177
|
+
assert.equal(allowed.ok, true);
|
|
1178
|
+
assert.equal(allowed.nameWithOwner, 'myfork/project');
|
|
1179
|
+
assert.equal(allowed.identity, 'github.mycorp.com/myfork/project');
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test('a single-remote checkout keeps the unchanged baseline, even for a local remote', async () => {
|
|
1183
|
+
const checkout = makeCheckout({ remotes: [['origin', '/srv/mirror/project.git']] });
|
|
1184
|
+
const gh = github({ base: 'someone/project' });
|
|
1185
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1186
|
+
|
|
1187
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1188
|
+
assert.equal(page.ok, true, 'no scope is invented for a checkout that names one remote');
|
|
1189
|
+
assert.ok(gh.calls.length > 0, 'gh resolves the repository exactly as before');
|
|
1190
|
+
assert.ok(gh.calls.every((call) => call.scoped === false), 'no GH_REPO is injected');
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// A cached scope must never outlive a changed remote: the same owner/name on
|
|
1194
|
+
// a different account is a different repository, and a write would land there.
|
|
1195
|
+
test('a changed checkout remote is picked up before the next read and the next write', async () => {
|
|
1196
|
+
const checkout = makeCheckout();
|
|
1197
|
+
const gh = github();
|
|
1198
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1199
|
+
|
|
1200
|
+
const first = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1201
|
+
assert.equal(first.ok, true);
|
|
1202
|
+
assert.ok(gh.calls.some((call) => call.argv.some((value) => value.includes('repo:myfork/project'))),
|
|
1203
|
+
'the first read targets the original fork');
|
|
1204
|
+
|
|
1205
|
+
// The fork moves mid-session (a rename, a transfer, a corrected remote).
|
|
1206
|
+
git(checkout, ['remote', 'set-url', 'origin', 'git@github.com:otherfork/project.git']);
|
|
1207
|
+
|
|
1208
|
+
const beforeWrite = gh.calls.length;
|
|
1209
|
+
const comment = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1210
|
+
assert.equal(comment.ok, true);
|
|
1211
|
+
const writes = gh.calls.slice(beforeWrite);
|
|
1212
|
+
assert.ok(writes.length > 0, 'the write reached gh');
|
|
1213
|
+
for (const call of writes) {
|
|
1214
|
+
assert.equal(call.repo, 'github.com/otherfork/project', 'the write follows the changed remote');
|
|
1215
|
+
}
|
|
1216
|
+
assert.ok(!writes.some((call) => call.repo === 'github.com/myfork/project'), 'no write targets the old fork');
|
|
1217
|
+
|
|
1218
|
+
const beforeRead = gh.calls.length;
|
|
1219
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1220
|
+
assert.equal(page.ok, true);
|
|
1221
|
+
const reads = gh.calls.slice(beforeRead);
|
|
1222
|
+
assert.ok(reads.some((call) => call.argv.some((value) => value.includes('repo:otherfork/project'))),
|
|
1223
|
+
'the next read follows the changed remote');
|
|
1224
|
+
assert.ok(!reads.some((call) => call.argv.some((value) => value.includes('repo:myfork/project'))),
|
|
1225
|
+
'no read targets the old fork');
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test('GH_HOST and the checkout remote must name the same server', async () => {
|
|
1229
|
+
const checkout = makeCheckout();
|
|
1230
|
+
const gh = github();
|
|
1231
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1232
|
+
|
|
1233
|
+
const page = await ghIssues.getIssuePage(
|
|
1234
|
+
{ first: 5 },
|
|
1235
|
+
{ cwd: checkout, env: { ...process.env, GH_HOST: 'acme.ghe.com' } },
|
|
1236
|
+
);
|
|
1237
|
+
assert.equal(page.ok, false);
|
|
1238
|
+
assert.match(page.error, /is on github\.com, but GH_HOST selects acme\.ghe\.com/);
|
|
1239
|
+
assert.equal(gh.calls.length, 0, 'the same owner/name is never sent to another server');
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
test('a ghe.com checkout remote requires GH_HOST and must match it', async () => {
|
|
1243
|
+
const remotes = [
|
|
1244
|
+
['origin', 'https://acme.ghe.com/myfork/project.git'],
|
|
1245
|
+
['upstream', 'https://acme.ghe.com/original/project.git'],
|
|
1246
|
+
];
|
|
1247
|
+
const mismatched = makeCheckout({ remotes });
|
|
1248
|
+
const gh = github();
|
|
1249
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1250
|
+
const denied = await ghIssues.getIssuePage(
|
|
1251
|
+
{ first: 5 },
|
|
1252
|
+
{ cwd: mismatched, env: { ...process.env, GH_HOST: 'other.ghe.com' } },
|
|
1253
|
+
);
|
|
1254
|
+
assert.equal(denied.ok, false);
|
|
1255
|
+
assert.match(denied.error, /is on acme\.ghe\.com, but GH_HOST selects other\.ghe\.com/);
|
|
1256
|
+
assert.equal(gh.calls.length, 0);
|
|
1257
|
+
|
|
1258
|
+
// Without GH_HOST, gh's API host is github.com while the repository lives
|
|
1259
|
+
// on the enterprise server, so the same owner/name would read two different
|
|
1260
|
+
// repositories. The scope fails closed instead.
|
|
1261
|
+
const derived = makeCheckout({ remotes });
|
|
1262
|
+
const derivedScope = await ghIssues.checkoutRepoScope({ cwd: derived, env: { ...process.env, GH_HOST: '' } });
|
|
1263
|
+
assert.equal(derivedScope.ok, false, 'an enterprise remote needs its host named');
|
|
1264
|
+
assert.match(derivedScope.error, /gh reaches only when GH_HOST selects it/);
|
|
1265
|
+
|
|
1266
|
+
// When GH_HOST is set, it must be the same server — then the scope holds.
|
|
1267
|
+
const matched = makeCheckout({ remotes });
|
|
1268
|
+
const matchedScope = await ghIssues.checkoutRepoScope({
|
|
1269
|
+
cwd: matched,
|
|
1270
|
+
env: { ...process.env, GH_HOST: 'acme.ghe.com' },
|
|
1271
|
+
});
|
|
1272
|
+
assert.equal(matchedScope.ok, true);
|
|
1273
|
+
assert.equal(matchedScope.nameWithOwner, 'myfork/project');
|
|
1274
|
+
assert.equal(matchedScope.identity, 'acme.ghe.com/myfork/project');
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('a git resolution error fails closed instead of trusting gh base resolution', async () => {
|
|
1278
|
+
const checkout = makeCheckout();
|
|
1279
|
+
const gh = github();
|
|
1280
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1281
|
+
ghIssues.setGitRunner((argv) => {
|
|
1282
|
+
if (argv[0] === 'config') {
|
|
1283
|
+
return { status: null, stdout: '', stderr: '', error: 'spawn git ENOENT: git is not available' };
|
|
1284
|
+
}
|
|
1285
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
1286
|
+
});
|
|
1287
|
+
try {
|
|
1288
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1289
|
+
assert.equal(page.ok, false);
|
|
1290
|
+
assert.match(page.error, /cannot read this checkout's git configuration/);
|
|
1291
|
+
const write = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1292
|
+
assert.equal(write.ok, false, 'the write is refused too');
|
|
1293
|
+
assert.equal(gh.calls.length, 0, 'no gh command falls back to gh base resolution');
|
|
1294
|
+
} finally {
|
|
1295
|
+
ghIssues.setGitRunner(null);
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test('an unexpected resolution throw fails closed', async () => {
|
|
1300
|
+
const checkout = makeCheckout();
|
|
1301
|
+
const gh = github();
|
|
1302
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1303
|
+
ghIssues.setGitRunner(() => {
|
|
1304
|
+
throw new Error('unexpected resolver failure');
|
|
1305
|
+
});
|
|
1306
|
+
try {
|
|
1307
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1308
|
+
assert.equal(page.ok, false);
|
|
1309
|
+
assert.match(page.error, /cannot resolve the checkout's own repository/);
|
|
1310
|
+
const write = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1311
|
+
assert.equal(write.ok, false, 'the write is refused too');
|
|
1312
|
+
assert.equal(gh.calls.length, 0, 'a throw never degrades to gh base resolution');
|
|
1313
|
+
} finally {
|
|
1314
|
+
ghIssues.setGitRunner(null);
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
// B1: a caller that already displayed rows from one repository passes that
|
|
1319
|
+
// identity explicitly, so reads and writes keep using it even after the
|
|
1320
|
+
// checkout's remote moves. The viewer relies on this to bind its actions.
|
|
1321
|
+
test('an explicit scope binds reads and writes to the displayed repository', async () => {
|
|
1322
|
+
const checkout = makeCheckout();
|
|
1323
|
+
const gh = github();
|
|
1324
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1325
|
+
const scope = 'myfork/project';
|
|
1326
|
+
git(checkout, ['remote', 'set-url', 'origin', 'git@github.com:otherfork/project.git']);
|
|
1327
|
+
|
|
1328
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout, scope });
|
|
1329
|
+
assert.equal(page.ok, true);
|
|
1330
|
+
const searches = gh.calls.flatMap((call) => call.argv).filter((value) => value.startsWith('search=repo:'));
|
|
1331
|
+
assert.ok(searches.length > 0);
|
|
1332
|
+
assert.ok(searches.every((value) => value.startsWith('search=repo:myfork/project')),
|
|
1333
|
+
'the pinned identity answers the read, never the moved remote');
|
|
1334
|
+
|
|
1335
|
+
const comment = await ghIssues.commentIssue(7, 'hello', { cwd: checkout, scope });
|
|
1336
|
+
assert.equal(comment.ok, true);
|
|
1337
|
+
const commentCall = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'comment');
|
|
1338
|
+
assert.equal(commentCall.repo, 'github.com/myfork/project', 'the pinned identity receives the write');
|
|
1339
|
+
assert.ok(!gh.calls.some((call) => call.repo === 'github.com/otherfork/project'),
|
|
1340
|
+
'nothing was sent to the new remote');
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
// B2: a config read that exits nonzero because the configuration is corrupt
|
|
1344
|
+
// or unreadable is NOT "no repository". It must fail closed instead of
|
|
1345
|
+
// falling back to gh's base resolution.
|
|
1346
|
+
test('a corrupt or unreadable checkout configuration fails closed', async () => {
|
|
1347
|
+
const checkout = makeCheckout();
|
|
1348
|
+
fs.appendFileSync(path.join(checkout, '.git', 'config'), '\n[broken\n');
|
|
1349
|
+
const gh = github();
|
|
1350
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1351
|
+
|
|
1352
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1353
|
+
assert.equal(page.ok, false);
|
|
1354
|
+
assert.match(page.error, /cannot read this checkout's git configuration/);
|
|
1355
|
+
const list = await ghIssues.listIssues({ page: 1, pageSize: 5 }, { cwd: checkout, fetchMax: 5 });
|
|
1356
|
+
assert.equal(list.ok, false, 'the list read is refused too');
|
|
1357
|
+
const write = await ghIssues.commentIssue(7, 'hello', { cwd: checkout });
|
|
1358
|
+
assert.equal(write.ok, false, 'the write is refused too');
|
|
1359
|
+
assert.equal(gh.calls.length, 0, 'no gh command falls back to gh base resolution');
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// The distinguishable clean case: a directory that is NOT a git repository
|
|
1363
|
+
// stays on the unchanged baseline (gh resolves for itself).
|
|
1364
|
+
test('a plain directory keeps gh resolution and injects no scope', async () => {
|
|
1365
|
+
const plain = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gh-plain-scope-')));
|
|
1366
|
+
roots.push(plain);
|
|
1367
|
+
const gh = github({ base: 'someone/project' });
|
|
1368
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1369
|
+
|
|
1370
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: plain });
|
|
1371
|
+
assert.equal(page.ok, true, 'not a repository is not a resolution failure');
|
|
1372
|
+
assert.ok(gh.calls.length > 0, 'gh resolves the repository exactly as before');
|
|
1373
|
+
assert.ok(gh.calls.every((call) => call.scoped === false), 'no GH_REPO is injected');
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// B2: the fork clone that adds the upstream under its own name and never
|
|
1377
|
+
// uses `origin` (remotes: upstream + myfork, branch tracking upstream). The
|
|
1378
|
+
// tracking remote is the BASE, so reading it as the scope listed the
|
|
1379
|
+
// original's issues. The only NON-BASE remote is the fork, and that is the
|
|
1380
|
+
// checkout's own repository.
|
|
1381
|
+
test('a fork with myfork and upstream, tracking upstream, scopes to myfork', async () => {
|
|
1382
|
+
const checkout = makeCheckout({
|
|
1383
|
+
remotes: [['upstream', 'original/project'], ['myfork', 'myfork/project']],
|
|
1384
|
+
});
|
|
1385
|
+
const branch = git(checkout, ['branch', '--show-current']).trim();
|
|
1386
|
+
git(checkout, ['config', `branch.${branch}.remote`, 'upstream']);
|
|
1387
|
+
git(checkout, ['config', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
1388
|
+
const gh = github();
|
|
1389
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1390
|
+
|
|
1391
|
+
const info = await ghIssues.repoInfo({ cwd: checkout });
|
|
1392
|
+
assert.equal(info.ok, true);
|
|
1393
|
+
assert.equal(info.nameWithOwner, 'myfork/project', 'the tracked upstream is never the scope');
|
|
1394
|
+
await ghIssues.listIssues({ page: 1, pageSize: 5 }, { cwd: checkout, fetchMax: 5 });
|
|
1395
|
+
const list = gh.calls.find((call) => call.argv[0] === 'issue' && call.argv[1] === 'list');
|
|
1396
|
+
assert.equal(list.repo, 'github.com/myfork/project', 'the only non-base remote is the scope');
|
|
1397
|
+
assert.ok(!gh.calls.some((call) => call.repo === 'github.com/original/project'),
|
|
1398
|
+
'nothing reads the base repository');
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
// B2: two non-base remotes and no attach point is AMBIGUOUS. Guessing one
|
|
1402
|
+
// could read or write another fork, and the base is never chosen silently.
|
|
1403
|
+
test('two non-base remotes with no attach point fail closed instead of guessing', async () => {
|
|
1404
|
+
const checkout = makeCheckout({
|
|
1405
|
+
remotes: [
|
|
1406
|
+
['upstream', 'original/project'],
|
|
1407
|
+
['fork-a', 'forka/project'],
|
|
1408
|
+
['fork-b', 'forkb/project'],
|
|
1409
|
+
],
|
|
1410
|
+
});
|
|
1411
|
+
const branch = git(checkout, ['branch', '--show-current']).trim();
|
|
1412
|
+
git(checkout, ['config', `branch.${branch}.remote`, 'upstream']);
|
|
1413
|
+
git(checkout, ['config', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
1414
|
+
const gh = github();
|
|
1415
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1416
|
+
|
|
1417
|
+
const page = await ghIssues.getIssuePage({ first: 5 }, { cwd: checkout });
|
|
1418
|
+
assert.equal(page.ok, false, 'an ambiguous fork selection fails closed');
|
|
1419
|
+
assert.match(page.error, /cannot tell which checkout remote/);
|
|
1420
|
+
assert.match(page.error, /"fork-a", "fork-b"/);
|
|
1421
|
+
assert.equal(gh.calls.length, 0, 'no gh command runs for an ambiguous scope');
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// B1: the pinned identity carries its HOST. A pin for github.com cannot be
|
|
1425
|
+
// redirected to an enterprise server by an ambient GH_HOST, and a pin that
|
|
1426
|
+
// ever selects another server fails closed before gh runs.
|
|
1427
|
+
test('a host-qualified pin fails closed when GH_HOST selects another server', async () => {
|
|
1428
|
+
const checkout = makeCheckout();
|
|
1429
|
+
const gh = github();
|
|
1430
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1431
|
+
|
|
1432
|
+
const page = await ghIssues.getIssuePage(
|
|
1433
|
+
{ first: 5 },
|
|
1434
|
+
{ cwd: checkout, scope: 'github.com/myfork/project', env: { GH_HOST: 'acme.ghe.com' } },
|
|
1435
|
+
);
|
|
1436
|
+
assert.equal(page.ok, false, 'the pin is refused, never redirected');
|
|
1437
|
+
assert.match(page.error, /GH_HOST selects acme\.ghe\.com/);
|
|
1438
|
+
assert.equal(gh.calls.length, 0, 'no gh command ran for the redirected pin');
|
|
1439
|
+
|
|
1440
|
+
const comment = await ghIssues.commentIssue(
|
|
1441
|
+
7,
|
|
1442
|
+
'hello',
|
|
1443
|
+
{ cwd: checkout, scope: 'github.com/myfork/project', env: { GH_HOST: 'acme.ghe.com' } },
|
|
1444
|
+
);
|
|
1445
|
+
assert.equal(comment.ok, false, 'the same refusal covers writes');
|
|
1446
|
+
assert.equal(gh.calls.length, 0, 'no write ran either');
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
test('an ambient GH_REPO cannot override the pinned identity', async () => {
|
|
1450
|
+
const checkout = makeCheckout();
|
|
1451
|
+
const gh = github();
|
|
1452
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1453
|
+
|
|
1454
|
+
const page = await ghIssues.getIssuePage(
|
|
1455
|
+
{ first: 5 },
|
|
1456
|
+
{ cwd: checkout, scope: 'github.com/myfork/project', env: { GH_REPO: 'ambient/other' } },
|
|
1457
|
+
);
|
|
1458
|
+
assert.equal(page.ok, true);
|
|
1459
|
+
assert.ok(gh.calls.length > 0);
|
|
1460
|
+
assert.ok(gh.calls.every((call) => call.repo === 'github.com/myfork/project'),
|
|
1461
|
+
'every call carries the pin, never the ambient GH_REPO');
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test('a pin naming a server that is not GitHub fails closed', async () => {
|
|
1465
|
+
const checkout = makeCheckout();
|
|
1466
|
+
const gh = github();
|
|
1467
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1468
|
+
|
|
1469
|
+
const page = await ghIssues.getIssuePage(
|
|
1470
|
+
{ first: 5 },
|
|
1471
|
+
{ cwd: checkout, scope: 'gitlab.com/myfork/project', env: {} },
|
|
1472
|
+
);
|
|
1473
|
+
assert.equal(page.ok, false);
|
|
1474
|
+
assert.match(page.error, /not a GitHub host/);
|
|
1475
|
+
assert.equal(gh.calls.length, 0, 'no gh command runs for an unprovable host');
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
// B1: an enterprise pin is only consistent when GH_HOST names its server,
|
|
1479
|
+
// because gh api/GraphQL (searches and connections) use the default host.
|
|
1480
|
+
test('an enterprise pin needs GH_HOST, and then travels host-qualified', async () => {
|
|
1481
|
+
const checkout = makeCheckout();
|
|
1482
|
+
const gh = github();
|
|
1483
|
+
ghIssues.setGhRunner(gh.runner);
|
|
1484
|
+
|
|
1485
|
+
const denied = await ghIssues.getIssuePage(
|
|
1486
|
+
{ first: 5 },
|
|
1487
|
+
{ cwd: checkout, scope: 'acme.ghe.com/myfork/project', env: {} },
|
|
1488
|
+
);
|
|
1489
|
+
assert.equal(denied.ok, false, 'an enterprise pin without GH_HOST fails closed');
|
|
1490
|
+
assert.match(denied.error, /gh reaches only when GH_HOST selects it/);
|
|
1491
|
+
assert.equal(gh.calls.length, 0);
|
|
1492
|
+
|
|
1493
|
+
const allowed = await ghIssues.getIssuePage(
|
|
1494
|
+
{ first: 5 },
|
|
1495
|
+
{ cwd: checkout, scope: 'acme.ghe.com/myfork/project', env: { GH_HOST: 'acme.ghe.com' } },
|
|
1496
|
+
);
|
|
1497
|
+
assert.equal(allowed.ok, true, 'the matching host is accepted');
|
|
1498
|
+
assert.ok(gh.calls.length > 0);
|
|
1499
|
+
assert.ok(gh.calls.every((call) => call.repo === 'acme.ghe.com/myfork/project'),
|
|
1500
|
+
'every call carries the enterprise host');
|
|
1501
|
+
});
|
|
1502
|
+
});
|