pi-odd-worktree 0.0.0-stage → 1.0.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 +234 -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,1217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T3 (S1,S3,S4) public command tests: the `/issues` command in
|
|
3
|
+
// extensions/odd-worktree-tools.ts, driven through the registered handler.
|
|
4
|
+
//
|
|
5
|
+
// Contract under test (observable behavior only — registration, the notified
|
|
6
|
+
// text, the overlay options, the rendered marks, the registry bytes, the argv
|
|
7
|
+
// reaching the injected `gh` runner; never internal names):
|
|
8
|
+
// - the command is registered eagerly at load, and the session-start `gh`
|
|
9
|
+
// check only gates the INVOCATION: a missing or unauthenticated `gh`
|
|
10
|
+
// notifies once per session with what is missing and the command that fixes
|
|
11
|
+
// it, then refuses on invocation instead of opening;
|
|
12
|
+
// - no argument opens the four-bucket overlay through ctx.ui.custom with the
|
|
13
|
+
// odd viewer's full-screen options, fed by cursor pages: the three scoped
|
|
14
|
+
// searches (author:@me, assignee:@me, involves:@me -author:@me), the
|
|
15
|
+
// unfiltered `All` connection, the detail, repo labels and assignable users;
|
|
16
|
+
// - the repository identity is PINNED host-qualified when the list loads:
|
|
17
|
+
// every page, detail, label and assignee read binds it, and the `o` opener
|
|
18
|
+
// opens `gh issue view <n> --web -R <pinned>` (fixed argv, terminal
|
|
19
|
+
// suspended for the open) rather than whatever the checkout resolves to now;
|
|
20
|
+
// - a mutation runs only while the checkout still resolves to the displayed
|
|
21
|
+
// repository, and otherwise refuses without running `gh`; a client failure
|
|
22
|
+
// keeps the draft, shows the error and claims no success;
|
|
23
|
+
// - no worktree↔issue association exists: the view is handed no `assigned`,
|
|
24
|
+
// no `saveAssignment` and no `assignmentEnabled`, and reading writes nothing.
|
|
25
|
+
//
|
|
26
|
+
// The TypeScript extension is plain JS under the hood, so the shared fixture
|
|
27
|
+
// copies it to a temporary .mjs with the package imports stubbed; `gh` is
|
|
28
|
+
// injected through the real lib/gh-issues.js `setGhRunner` seam (same module
|
|
29
|
+
// instance the extension loads via GENTLE_WORKTREE_LIB_DIR), so no live `gh`
|
|
30
|
+
// runs and no real GitHub mutation is possible.
|
|
31
|
+
|
|
32
|
+
const { test, describe, after, afterEach } = require('node:test');
|
|
33
|
+
const assert = require('node:assert/strict');
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const { execFileSync } = require('node:child_process');
|
|
38
|
+
const { pathToFileURL } = require('node:url');
|
|
39
|
+
const { LIB_DIR, stubbedSource } = require('./fixtures/load-extension.js');
|
|
40
|
+
|
|
41
|
+
const ghIssues = require('../lib/gh-issues.js');
|
|
42
|
+
const worktreeGit = require('../lib/worktree-git.js');
|
|
43
|
+
|
|
44
|
+
const roots = [];
|
|
45
|
+
after(() => {
|
|
46
|
+
for (const dir of roots) {
|
|
47
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function track(dir) {
|
|
52
|
+
roots.push(dir);
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function git(cwd, args) {
|
|
57
|
+
return execFileSync('git', args, {
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeRepo({ tag = 'issues' } = {}) {
|
|
65
|
+
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `odd-issues-${tag}-`)));
|
|
66
|
+
git(dir, ['init']);
|
|
67
|
+
git(dir, ['config', 'user.email', 'test@example.com']);
|
|
68
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
69
|
+
git(dir, ['commit', '--allow-empty', '-m', 'init']);
|
|
70
|
+
return fs.realpathSync(dir);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const savedEnv = { ...process.env };
|
|
74
|
+
after(() => {
|
|
75
|
+
for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
|
|
76
|
+
for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
ghIssues.setGhRunner(null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const FIXTURES = path.join(__dirname, 'fixtures');
|
|
84
|
+
function fixture(name) {
|
|
85
|
+
return fs.readFileSync(path.join(FIXTURES, name), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A fresh copy per load: module-level gate state belongs to one runtime load,
|
|
89
|
+
// exactly like the host's rebuilt command registry.
|
|
90
|
+
async function loadExtension() {
|
|
91
|
+
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-ext-')));
|
|
92
|
+
const copy = path.join(dir, 'odd-worktree-tools.mjs');
|
|
93
|
+
fs.writeFileSync(copy, stubbedSource());
|
|
94
|
+
const commands = new Map();
|
|
95
|
+
const hooks = [];
|
|
96
|
+
const api = {
|
|
97
|
+
registerTool: () => {},
|
|
98
|
+
registerCommand: (name, definition) => { commands.set(name, definition); },
|
|
99
|
+
registerMessageRenderer: () => {},
|
|
100
|
+
on: (event, handler) => { hooks.push([event, handler]); return () => {}; },
|
|
101
|
+
};
|
|
102
|
+
process.env.GENTLE_WORKTREE_LIB_DIR = LIB_DIR;
|
|
103
|
+
// The worktree plan's own Herdr calls stay isolated from a live workspace.
|
|
104
|
+
process.env.HERDR_BIN_PATH = path.join(dir, 'no-such-herdr-binary');
|
|
105
|
+
const mod = await import(pathToFileURL(copy).href);
|
|
106
|
+
const factory = mod.default;
|
|
107
|
+
factory(api);
|
|
108
|
+
return { commands, hooks, api, mod, factory };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sessionStartHandlers(hooks) {
|
|
112
|
+
return hooks.filter(([event]) => event === 'session_start').map(([, handler]) => handler);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fireSessionStart(hooks, ctx) {
|
|
116
|
+
for (const handler of sessionStartHandlers(hooks)) {
|
|
117
|
+
const out = handler({}, ctx);
|
|
118
|
+
if (out && typeof out.then === 'function') await out;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function gateCtx(cwd, notes) {
|
|
123
|
+
return { cwd, ui: { notify: (message, level) => notes.push([message, level]) } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const stubTui = {
|
|
127
|
+
visibleWidth: (text) => [...String(text)].length,
|
|
128
|
+
truncateToWidth: (text, width) => {
|
|
129
|
+
const chars = [...String(text)];
|
|
130
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : String(text);
|
|
131
|
+
},
|
|
132
|
+
matchesKey: (key, name) => key === name,
|
|
133
|
+
isKeyRelease: (key) => key === 'release',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function makeHostTui(rows = 24) {
|
|
137
|
+
const paints = [];
|
|
138
|
+
const lifecycle = [];
|
|
139
|
+
return {
|
|
140
|
+
paints,
|
|
141
|
+
lifecycle,
|
|
142
|
+
tui: {
|
|
143
|
+
terminal: { rows },
|
|
144
|
+
stop: () => lifecycle.push('stop'),
|
|
145
|
+
start: () => lifecycle.push('start'),
|
|
146
|
+
requestRender: (force) => { lifecycle.push('render'); paints.push(force === true); },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function makeCtx({ cwd, customImpl } = {}) {
|
|
152
|
+
const notes = [];
|
|
153
|
+
const customs = [];
|
|
154
|
+
const selects = [];
|
|
155
|
+
const ctx = {
|
|
156
|
+
cwd,
|
|
157
|
+
ui: {
|
|
158
|
+
notify: (message, level) => notes.push([message, level]),
|
|
159
|
+
select: async (title, options) => { selects.push([title, options]); return undefined; },
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
if (customImpl !== undefined) {
|
|
163
|
+
ctx.ui.custom = async (factory, options) => {
|
|
164
|
+
customs.push([factory, options]);
|
|
165
|
+
return customImpl(factory, options);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { ctx, notes, customs, selects };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function openComponent(factory, host) {
|
|
172
|
+
let settled = 'pending';
|
|
173
|
+
const component = factory(host.tui, {}, {}, (result) => { settled = result; });
|
|
174
|
+
assert.equal(typeof component.render, 'function');
|
|
175
|
+
assert.equal(typeof component.handleInput, 'function');
|
|
176
|
+
assert.equal(typeof component.invalidate, 'function');
|
|
177
|
+
return { component, done: () => settled };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
181
|
+
async function waitFor(predicate, timeoutMs = 2000) {
|
|
182
|
+
const deadline = Date.now() + timeoutMs;
|
|
183
|
+
for (;;) {
|
|
184
|
+
if (predicate()) return true;
|
|
185
|
+
if (Date.now() >= deadline) return false;
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const clean = (value) => String(value).replace(/\x1b\[[0-9;]*m/g, '');
|
|
190
|
+
|
|
191
|
+
// Captures the options the extension mounts the real view with, leaving every
|
|
192
|
+
// client call real and injectable.
|
|
193
|
+
function captureIssuesView() {
|
|
194
|
+
const lib = require('../lib/issues-view.js');
|
|
195
|
+
const original = lib.createIssuesView;
|
|
196
|
+
const holder = { options: null };
|
|
197
|
+
lib.createIssuesView = (options) => {
|
|
198
|
+
holder.options = options;
|
|
199
|
+
return { render: () => [], handleInput: () => {}, invalidate: () => {} };
|
|
200
|
+
};
|
|
201
|
+
return { holder, restore: () => { lib.createIssuesView = original; } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// One issue node in the shape both GraphQL page queries ask for.
|
|
205
|
+
function graphqlIssueNode(row) {
|
|
206
|
+
return {
|
|
207
|
+
number: row.number,
|
|
208
|
+
title: row.title,
|
|
209
|
+
state: String(row.state || 'OPEN').toUpperCase(),
|
|
210
|
+
stateReason: '',
|
|
211
|
+
url: row.url,
|
|
212
|
+
createdAt: row.createdAt,
|
|
213
|
+
updatedAt: row.updatedAt,
|
|
214
|
+
author: { login: (row.author && row.author.login) || 'someone' },
|
|
215
|
+
labels: { nodes: [] },
|
|
216
|
+
comments: { totalCount: 0 },
|
|
217
|
+
reactionGroups: [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function graphqlField(argv, name, flag = '-F') {
|
|
222
|
+
for (let index = 0; index < argv.length - 1; index += 1) {
|
|
223
|
+
if (argv[index] === flag && argv[index + 1].startsWith(`${name}=`)) return argv[index + 1].slice(name.length + 1);
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The cursor-paged SEARCH snapshot the scoped tabs read through
|
|
229
|
+
// `getIssuePage`: every edge carries its own opaque cursor.
|
|
230
|
+
function graphqlSearchPage(argv, rows) {
|
|
231
|
+
const first = Number(graphqlField(argv, 'first'));
|
|
232
|
+
const after = graphqlField(argv, 'cursor');
|
|
233
|
+
const start = after === null ? 0 : Number(String(after).replace('row-', '')) + 1;
|
|
234
|
+
const page = rows.slice(start, start + (Number.isFinite(first) ? first : rows.length));
|
|
235
|
+
const hasNextPage = start + page.length < rows.length;
|
|
236
|
+
return JSON.stringify({
|
|
237
|
+
data: {
|
|
238
|
+
search: {
|
|
239
|
+
issueCount: rows.length,
|
|
240
|
+
pageInfo: { hasNextPage, endCursor: hasNextPage ? `row-${start + page.length - 1}` : null },
|
|
241
|
+
edges: page.map((row, at) => ({ cursor: `row-${start + at}`, node: graphqlIssueNode(row) })),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The cursor-paged CONNECTION snapshot the unfiltered `All` bucket reads.
|
|
248
|
+
function graphqlConnectionPage(argv, rows) {
|
|
249
|
+
const first = Number(graphqlField(argv, 'first'));
|
|
250
|
+
const after = graphqlField(argv, 'cursor');
|
|
251
|
+
const start = after === null ? 0 : Number(String(after).replace('conn-', '')) + 1;
|
|
252
|
+
const page = rows.slice(start, start + (Number.isFinite(first) ? first : rows.length));
|
|
253
|
+
const hasNextPage = start + page.length < rows.length;
|
|
254
|
+
return JSON.stringify({
|
|
255
|
+
data: {
|
|
256
|
+
repository: {
|
|
257
|
+
issues: {
|
|
258
|
+
totalCount: rows.length,
|
|
259
|
+
pageInfo: { hasNextPage, endCursor: hasNextPage ? `conn-${start + page.length - 1}` : null },
|
|
260
|
+
edges: page.map((row, at) => ({ cursor: `conn-${start + at}`, node: graphqlIssueNode(row) })),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Every call that read the repository issue CONNECTION — the `All` bucket's
|
|
268
|
+
// source. A search call is not one of them.
|
|
269
|
+
function connectionReads(calls) {
|
|
270
|
+
return calls
|
|
271
|
+
.filter((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql')
|
|
272
|
+
.map((call) => ({
|
|
273
|
+
query: graphqlField(call.argv, 'query', '-f') || '',
|
|
274
|
+
first: graphqlField(call.argv, 'first'),
|
|
275
|
+
cursor: graphqlField(call.argv, 'cursor'),
|
|
276
|
+
argv: call.argv,
|
|
277
|
+
}))
|
|
278
|
+
.filter((read) => read.query.includes('issues(states: [') && read.query.includes('edges { cursor node'));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The exact source query each fresh search page asked for, with the client's
|
|
282
|
+
// own `repo:… is:issue is:open` prefix removed.
|
|
283
|
+
function searchQueries(calls) {
|
|
284
|
+
return calls
|
|
285
|
+
.filter((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql')
|
|
286
|
+
.map((call) => graphqlField(call.argv, 'search'))
|
|
287
|
+
.filter((value) => value !== null)
|
|
288
|
+
.map((value) => value.replace(/^repo:\S+ is:issue is:open /, ''));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Pattern-matching `gh` runner over the captured real-`gh` fixtures: answers by
|
|
292
|
+
// argv shape and records every call, so the action tests pin the exact fixed
|
|
293
|
+
// argv each write reaches `gh` with. `WRITE` is the default role because the
|
|
294
|
+
// label test creates a label, which needs canCreateLabels.
|
|
295
|
+
function makeIssuesRunner({ failComment = false, role = 'WRITE', connectionRows = null } = {}) {
|
|
296
|
+
const calls = [];
|
|
297
|
+
const ok = (stdout) => ({ status: 0, stdout, stderr: '', error: null });
|
|
298
|
+
const rows = JSON.parse(fixture('gh-issue-list.json'));
|
|
299
|
+
const runner = async (bin, argv, opts) => {
|
|
300
|
+
calls.push({ bin, argv: argv.slice(), cwd: opts ? opts.cwd : undefined });
|
|
301
|
+
if (argv[0] === 'api' && argv[1] === 'graphql') {
|
|
302
|
+
const query = graphqlField(argv, 'query', '-f') || '';
|
|
303
|
+
if (query.includes('assignableUsers')) {
|
|
304
|
+
return ok(JSON.stringify({ data: { repository: { assignableUsers: {
|
|
305
|
+
nodes: [{ login: 'jonasotoaguilar', name: 'Jonathan Soto' }, { login: 'octocat', name: 'Octo Cat' }],
|
|
306
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
307
|
+
} } } }));
|
|
308
|
+
}
|
|
309
|
+
if (query.includes('projectItems')) {
|
|
310
|
+
return ok(JSON.stringify({ data: { repository: { issue: {
|
|
311
|
+
projectItems: { nodes: [], pageInfo: { hasNextPage: false } },
|
|
312
|
+
} } } }));
|
|
313
|
+
}
|
|
314
|
+
if (query.includes('viewerPermission')) {
|
|
315
|
+
return ok(JSON.stringify({ data: { repository: {
|
|
316
|
+
viewerPermission: role,
|
|
317
|
+
issue: {
|
|
318
|
+
assignees: { nodes: [{ login: 'jonasotoaguilar' }], pageInfo: { hasNextPage: false } },
|
|
319
|
+
closedByPullRequestsReferences: { totalCount: 2 },
|
|
320
|
+
},
|
|
321
|
+
} } }));
|
|
322
|
+
}
|
|
323
|
+
if (query.includes('type: ISSUE')) return ok(graphqlSearchPage(argv, rows));
|
|
324
|
+
if (query.includes('edges { cursor node')) return ok(graphqlConnectionPage(argv, connectionRows || rows));
|
|
325
|
+
return ok(JSON.stringify({ data: { repository: { issues: { totalCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, edges: [] } } } }));
|
|
326
|
+
}
|
|
327
|
+
if (argv[0] === 'api') return ok(fixture('gh-api-user.json'));
|
|
328
|
+
if (argv[0] === 'repo') return ok(fixture('gh-repo-view.json'));
|
|
329
|
+
if (argv[0] === 'issue' && argv[1] === 'list') return ok(fixture('gh-issue-list.json'));
|
|
330
|
+
if (argv[0] === 'issue' && argv[1] === 'view' && argv.includes('--json')) {
|
|
331
|
+
return ok(fixture('gh-issue-view.json'));
|
|
332
|
+
}
|
|
333
|
+
if (argv[0] === 'label' && argv[1] === 'list') return ok(fixture('gh-label-list.json'));
|
|
334
|
+
if (argv[0] === 'label' && argv[1] === 'create') return ok('{}');
|
|
335
|
+
if (argv[0] === 'issue' && argv[1] === 'comment') {
|
|
336
|
+
if (failComment) return { status: 1, stdout: '', stderr: 'rate limited', error: null };
|
|
337
|
+
return ok('');
|
|
338
|
+
}
|
|
339
|
+
if (argv[0] === 'issue' && (argv[1] === 'edit' || argv[1] === 'close' || argv[1] === 'reopen')) {
|
|
340
|
+
return ok('');
|
|
341
|
+
}
|
|
342
|
+
return ok('');
|
|
343
|
+
};
|
|
344
|
+
return { runner, calls };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function registryFile(root) {
|
|
348
|
+
const common = git(root, ['rev-parse', '--git-common-dir']).trim();
|
|
349
|
+
const commonDir = path.isAbsolute(common) ? common : path.resolve(root, common);
|
|
350
|
+
return path.join(commonDir, 'pi-worktrees.json');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Gate the extension through session_start with a scratch cwd (so the
|
|
354
|
+
// unrelated worktree discovery never touches the fixture repo), then run the
|
|
355
|
+
// command handler against the fixture checkout.
|
|
356
|
+
async function openIssues(root, { runner, rows = 24 } = {}) {
|
|
357
|
+
const loaded = await loadExtension();
|
|
358
|
+
ghIssues.setGhRunner(runner || makeIssuesRunner().runner);
|
|
359
|
+
const scratch = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-gate-')));
|
|
360
|
+
await fireSessionStart(loaded.hooks, gateCtx(scratch, []));
|
|
361
|
+
const def = loaded.commands.get('issues');
|
|
362
|
+
assert.ok(def, 'expected the issues command to be registered');
|
|
363
|
+
loaded.mod.setIssuesTuiLoader(async () => stubTui);
|
|
364
|
+
const host = makeHostTui(rows);
|
|
365
|
+
let factory = null;
|
|
366
|
+
const { ctx, notes, customs } = makeCtx({
|
|
367
|
+
cwd: root,
|
|
368
|
+
customImpl: async (captured) => { factory = captured; return null; },
|
|
369
|
+
});
|
|
370
|
+
await def.handler('', ctx);
|
|
371
|
+
assert.ok(factory, 'the overlay opens');
|
|
372
|
+
const { component, done } = openComponent(factory, host);
|
|
373
|
+
await tick();
|
|
374
|
+
return { component, notes, customs, commands: loaded.commands, done, host };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The options the command mounts the real view with, over the real injected
|
|
378
|
+
// `gh`, while every client call the extension makes stays real and injectable.
|
|
379
|
+
async function capturedIssuesOptions(root, options = {}) {
|
|
380
|
+
const injected = options.runner
|
|
381
|
+
? { runner: options.runner, calls: options.calls || [] }
|
|
382
|
+
: makeIssuesRunner(options);
|
|
383
|
+
const capture = captureIssuesView();
|
|
384
|
+
try {
|
|
385
|
+
const loaded = await loadExtension();
|
|
386
|
+
ghIssues.setGhRunner(injected.runner);
|
|
387
|
+
const scratch = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-gate-')));
|
|
388
|
+
await fireSessionStart(loaded.hooks, gateCtx(scratch, []));
|
|
389
|
+
loaded.mod.setIssuesTuiLoader(async () => stubTui);
|
|
390
|
+
const host = makeHostTui(options.rows || 24);
|
|
391
|
+
const { ctx } = makeCtx({ cwd: root });
|
|
392
|
+
ctx.ui.custom = async (factory) => {
|
|
393
|
+
factory(host.tui, {}, {}, () => {});
|
|
394
|
+
return null;
|
|
395
|
+
};
|
|
396
|
+
await loaded.commands.get('issues').handler('', ctx);
|
|
397
|
+
await tick();
|
|
398
|
+
assert.ok(capture.holder.options, 'the command mounts the view');
|
|
399
|
+
return {
|
|
400
|
+
options: capture.holder.options,
|
|
401
|
+
calls: injected.calls,
|
|
402
|
+
host,
|
|
403
|
+
restore: capture.restore,
|
|
404
|
+
};
|
|
405
|
+
} catch (err) {
|
|
406
|
+
capture.restore();
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
describe('/issues gate (S3)', () => {
|
|
412
|
+
test('registers the command eagerly, independent of the gh check', async () => {
|
|
413
|
+
const { commands } = await loadExtension();
|
|
414
|
+
const def = commands.get('issues');
|
|
415
|
+
assert.ok(def, 'the command is registered at load');
|
|
416
|
+
assert.equal(typeof def.handler, 'function');
|
|
417
|
+
assert.match(def.description, /GitHub issue/i);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('an authenticated gate keeps the command and notifies nothing', async () => {
|
|
421
|
+
const { commands, hooks } = await loadExtension();
|
|
422
|
+
ghIssues.setGhRunner(makeIssuesRunner().runner);
|
|
423
|
+
const notes = [];
|
|
424
|
+
await fireSessionStart(hooks, gateCtx(makeRepo({ tag: 'gate-ok' }), notes));
|
|
425
|
+
assert.ok(commands.has('issues'));
|
|
426
|
+
assert.deepEqual(notes, []);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a missing gh notifies the fix once at startup and again on invocation, opening nothing', async () => {
|
|
430
|
+
const { commands, hooks, mod } = await loadExtension();
|
|
431
|
+
ghIssues.setGhRunner(async (bin) => ({
|
|
432
|
+
status: null, stdout: '', stderr: '', error: `spawn ${bin} ENOENT: gh CLI is not available`,
|
|
433
|
+
}));
|
|
434
|
+
const root = makeRepo({ tag: 'gate-missing' });
|
|
435
|
+
const notes = [];
|
|
436
|
+
await fireSessionStart(hooks, gateCtx(root, notes));
|
|
437
|
+
await fireSessionStart(hooks, gateCtx(root, notes));
|
|
438
|
+
assert.ok(commands.has('issues'), 'the command stays discoverable');
|
|
439
|
+
assert.equal(notes.length, 1, 'exactly one startup notification per session');
|
|
440
|
+
assert.match(notes[0][0], /gh CLI is not available/, 'the note names what is missing');
|
|
441
|
+
assert.match(notes[0][0], /gh auth login/, 'the note names the command that fixes it');
|
|
442
|
+
assert.ok(!/unregistered/.test(notes[0][0]), 'the command is registered eagerly');
|
|
443
|
+
assert.match(notes[0][0], /cannot open/, 'the note names the real consequence');
|
|
444
|
+
|
|
445
|
+
mod.setIssuesTuiLoader(async () => stubTui);
|
|
446
|
+
const { ctx, customs, notes: invokeNotes } = makeCtx({ cwd: root, customImpl: async () => null });
|
|
447
|
+
await commands.get('issues').handler('', ctx);
|
|
448
|
+
assert.equal(customs.length, 0, 'invocation without gh opens nothing');
|
|
449
|
+
assert.equal(invokeNotes.length, 1, 'invocation reports the honest dependency error');
|
|
450
|
+
assert.match(invokeNotes[0][0], /gh auth login/);
|
|
451
|
+
assert.equal(invokeNotes[0][1], 'error');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('an unauthenticated gh notifies the login fix on invocation and opens nothing', async () => {
|
|
455
|
+
const { commands, mod } = await loadExtension();
|
|
456
|
+
ghIssues.setGhRunner(async () => ({
|
|
457
|
+
status: 1, stdout: '', stderr: 'Failed to log in: authentication required', error: null,
|
|
458
|
+
}));
|
|
459
|
+
mod.setIssuesTuiLoader(async () => stubTui);
|
|
460
|
+
const { ctx, customs, notes } = makeCtx({ cwd: makeRepo({ tag: 'gate-auth' }), customImpl: async () => null });
|
|
461
|
+
await commands.get('issues').handler('', ctx);
|
|
462
|
+
assert.equal(customs.length, 0, 'no overlay while unauthenticated');
|
|
463
|
+
assert.equal(notes.length, 1);
|
|
464
|
+
assert.match(notes[0][0], /gh auth login/, 'the note names the login fix');
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('/issues overlay and arguments', () => {
|
|
469
|
+
test('opens full screen fed by the scoped searches and the All connection', async () => {
|
|
470
|
+
const root = makeRepo({ tag: 'issues-open' });
|
|
471
|
+
const { runner, calls } = makeIssuesRunner();
|
|
472
|
+
const { component, customs, notes } = await openIssues(root, { runner });
|
|
473
|
+
assert.equal(customs.length, 1, 'the overlay opens once');
|
|
474
|
+
assert.deepEqual(customs[0][1], {
|
|
475
|
+
overlay: true,
|
|
476
|
+
overlayOptions: { anchor: 'center', width: '100%', maxHeight: '100%', margin: 0 },
|
|
477
|
+
});
|
|
478
|
+
assert.deepEqual(notes, [], 'opening writes no notification');
|
|
479
|
+
const listSearches = calls
|
|
480
|
+
.filter((call) => call.argv[0] === 'issue' && call.argv[1] === 'list')
|
|
481
|
+
.map((call) => call.argv[call.argv.indexOf('--search') + 1]);
|
|
482
|
+
assert.deepEqual(listSearches, [], 'the viewer reads fresh pages, never a list cache');
|
|
483
|
+
assert.deepEqual(searchQueries(calls), [
|
|
484
|
+
'author:@me sort:updated-desc',
|
|
485
|
+
'assignee:@me sort:updated-desc',
|
|
486
|
+
'involves:@me -author:@me sort:updated-desc',
|
|
487
|
+
], 'every scoped tab is read once with its own exact query');
|
|
488
|
+
assert.equal(connectionReads(calls).length, 1, 'the All tab is read once through the issue connection');
|
|
489
|
+
const body = clean(component.render(110).join('\n'));
|
|
490
|
+
assert.match(body, /My issues/);
|
|
491
|
+
assert.match(body, /Assigned/);
|
|
492
|
+
assert.match(body, /Involved/);
|
|
493
|
+
assert.match(body, /76/, 'the listed issue number');
|
|
494
|
+
assert.match(body, /chore: update/, 'the listed issue title');
|
|
495
|
+
assert.match(body, /◈ Issues/, 'the header names the surface');
|
|
496
|
+
assert.ok(!body.includes(path.basename(root)), 'the header never repeats the checkout name');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('an argument notifies and opens nothing', async () => {
|
|
500
|
+
const root = makeRepo({ tag: 'issues-args' });
|
|
501
|
+
const { commands, mod, hooks } = await loadExtension();
|
|
502
|
+
ghIssues.setGhRunner(makeIssuesRunner().runner);
|
|
503
|
+
const scratch = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-gate-')));
|
|
504
|
+
await fireSessionStart(hooks, gateCtx(scratch, []));
|
|
505
|
+
mod.setIssuesTuiLoader(async () => stubTui);
|
|
506
|
+
const { ctx, customs, notes } = makeCtx({ cwd: root, customImpl: async () => null });
|
|
507
|
+
await commands.get('issues').handler('extra', ctx);
|
|
508
|
+
assert.equal(customs.length, 0, 'no overlay for an argument');
|
|
509
|
+
assert.equal(notes.length, 1);
|
|
510
|
+
assert.deepEqual(notes[0], ['Unknown option "extra". Use /issues.', 'error']);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('outside a Git checkout notifies the shared line at info and opens nothing', async () => {
|
|
514
|
+
const { commands, mod, hooks } = await loadExtension();
|
|
515
|
+
ghIssues.setGhRunner(makeIssuesRunner().runner);
|
|
516
|
+
const scratch = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-gate-')));
|
|
517
|
+
await fireSessionStart(hooks, gateCtx(scratch, []));
|
|
518
|
+
mod.setIssuesTuiLoader(async () => stubTui);
|
|
519
|
+
const outside = fs.realpathSync(track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-plain-'))));
|
|
520
|
+
const { ctx, customs, selects, notes } = makeCtx({ cwd: outside, customImpl: async () => null });
|
|
521
|
+
await commands.get('issues').handler('', ctx);
|
|
522
|
+
assert.equal(customs.length, 0, 'no overlay outside a checkout');
|
|
523
|
+
assert.equal(selects.length, 0, 'no fallback selector outside a checkout');
|
|
524
|
+
assert.deepEqual(notes, [[`not a git repository: ${outside}; run 'git init' first`, 'info']]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe('/issues is read-only about the worktree association', () => {
|
|
529
|
+
test('the mounted view is handed no association option and reading writes nothing', async () => {
|
|
530
|
+
const root = makeRepo({ tag: 'issues-readonly' });
|
|
531
|
+
const file = registryFile(root);
|
|
532
|
+
fs.writeFileSync(file, `${JSON.stringify({
|
|
533
|
+
version: 1,
|
|
534
|
+
worktrees: [{ branch: git(root, ['branch', '--show-current']).trim(), path: root, thread: 't', issues: [76], odd: null }],
|
|
535
|
+
}, null, 2)}\n`);
|
|
536
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
537
|
+
const { runner } = makeIssuesRunner();
|
|
538
|
+
const capture = captureIssuesView();
|
|
539
|
+
try {
|
|
540
|
+
await openIssues(root, { runner });
|
|
541
|
+
const options = capture.holder.options;
|
|
542
|
+
for (const key of ['assigned', 'assignedOdd', 'saveAssignment', 'assignmentEnabled']) {
|
|
543
|
+
assert.equal(key in options, false, `the view is handed no ${key}: ${JSON.stringify(Object.keys(options))}`);
|
|
544
|
+
}
|
|
545
|
+
} finally {
|
|
546
|
+
capture.restore();
|
|
547
|
+
}
|
|
548
|
+
assert.equal(fs.readFileSync(file, 'utf8'), before, 'reading writes nothing');
|
|
549
|
+
|
|
550
|
+
const { component } = await openIssues(root, { runner });
|
|
551
|
+
assert.match(clean(component.render(110).join('\n')), /76/, 'the stored issue still lists');
|
|
552
|
+
component.handleInput('down');
|
|
553
|
+
component.handleInput('space');
|
|
554
|
+
component.handleInput('ctrl+s');
|
|
555
|
+
await tick();
|
|
556
|
+
assert.equal(fs.readFileSync(file, 'utf8'), before, 'the removed association keys write nothing');
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('/issues actions (S3,S4)', () => {
|
|
561
|
+
test('label toggle, create-new, comment, browser and close reach gh with fixed argv', async () => {
|
|
562
|
+
const root = makeRepo({ tag: 'issues-actions' });
|
|
563
|
+
const { runner, calls } = makeIssuesRunner();
|
|
564
|
+
const { component } = await openIssues(root, { runner });
|
|
565
|
+
const ghCalls = () => calls.filter((call) => call.bin === 'gh');
|
|
566
|
+
|
|
567
|
+
component.handleInput('L');
|
|
568
|
+
assert.equal(component.state.mode, 'labels');
|
|
569
|
+
await tick();
|
|
570
|
+
component.handleInput('down');
|
|
571
|
+
component.handleInput('space');
|
|
572
|
+
component.handleInput('ctrl+s');
|
|
573
|
+
assert.ok(await waitFor(() => component.state.mode === 'list'), 'a successful label apply closes the editor');
|
|
574
|
+
const edit = ghCalls().find((call) => call.argv[1] === 'edit');
|
|
575
|
+
assert.ok(edit, 'the label toggle reaches gh issue edit');
|
|
576
|
+
assert.equal(edit.cwd, root, 'the write runs in this checkout');
|
|
577
|
+
assert.deepEqual(edit.argv.slice(0, 3), ['issue', 'edit', '76']);
|
|
578
|
+
assert.ok(edit.argv.includes('--add-label'), 'additions ride --add-label');
|
|
579
|
+
assert.ok(edit.argv.includes('bug'), 'the toggled label is applied');
|
|
580
|
+
assert.ok(!edit.argv.join(' ').includes('&&'), 'fixed argv: no shell string');
|
|
581
|
+
|
|
582
|
+
component.handleInput('L');
|
|
583
|
+
component.handleInput('n');
|
|
584
|
+
for (const ch of 'triage') component.handleInput(ch);
|
|
585
|
+
component.handleInput('enter');
|
|
586
|
+
component.handleInput('ctrl+s');
|
|
587
|
+
assert.ok(await waitFor(() => ghCalls().some((call) => call.argv[1] === 'create')), 'a new label reaches gh label create');
|
|
588
|
+
const create = ghCalls().find((call) => call.argv[1] === 'create');
|
|
589
|
+
assert.deepEqual(create.argv.slice(0, 3), ['label', 'create', 'triage']);
|
|
590
|
+
assert.ok(await waitFor(() => component.state.mode === 'list'), 'creating a label applies and closes the editor');
|
|
591
|
+
|
|
592
|
+
component.handleInput('c');
|
|
593
|
+
assert.equal(component.state.mode, 'comment');
|
|
594
|
+
for (const ch of 'hi') component.handleInput(ch);
|
|
595
|
+
component.handleInput('ctrl+s');
|
|
596
|
+
assert.ok(await waitFor(() => component.state.mode === 'list'), 'a posted comment closes the input');
|
|
597
|
+
const comment = ghCalls().find((call) => call.argv[1] === 'comment');
|
|
598
|
+
assert.deepEqual(comment.argv, ['issue', 'comment', '76', '--body', 'hi']);
|
|
599
|
+
|
|
600
|
+
component.handleInput('x');
|
|
601
|
+
assert.equal(component.state.mode, 'confirm');
|
|
602
|
+
assert.match(clean(component.render(110).join('\n')), /Confirm close #76/);
|
|
603
|
+
component.handleInput('enter');
|
|
604
|
+
assert.ok(await waitFor(() => component.state.mode === 'list'), 'the close settles');
|
|
605
|
+
assert.ok(!clean(component.render(110).join('\n')).includes('#76'), 'the closed issue leaves the open list at once');
|
|
606
|
+
const close = ghCalls().find((call) => call.argv[1] === 'close');
|
|
607
|
+
assert.deepEqual(close.argv, ['issue', 'close', '76']);
|
|
608
|
+
assert.equal(ghCalls().some((call) => call.argv[1] === 'reopen'), false, 'the view has no reopen path');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('the mounted view refuses close, labels and assign on READ, and offers no label creation on TRIAGE', async () => {
|
|
612
|
+
const mutations = (calls) => calls.filter((call) => call.argv[0] === 'issue'
|
|
613
|
+
&& ['edit', 'close', 'reopen'].includes(call.argv[1]))
|
|
614
|
+
.concat(calls.filter((call) => call.argv[0] === 'label' && call.argv[1] === 'create'));
|
|
615
|
+
|
|
616
|
+
const readRoot = makeRepo({ tag: 'issues-role-read' });
|
|
617
|
+
const read = makeIssuesRunner({ role: 'READ' });
|
|
618
|
+
const { component: readOnly } = await openIssues(readRoot, { runner: read.runner });
|
|
619
|
+
const readFrame = () => clean(readOnly.render(200).join('\n'));
|
|
620
|
+
assert.ok(await waitFor(() => readFrame().includes('c comment')), 'the reader is painted');
|
|
621
|
+
assert.ok(!readFrame().includes('L labels'), 'a read-only viewer is offered no label editor');
|
|
622
|
+
assert.ok(!readFrame().includes('a assign'), 'no assignee editor either');
|
|
623
|
+
assert.ok(!readFrame().includes('x/X close'), 'and no close key');
|
|
624
|
+
|
|
625
|
+
for (const key of ['x', 'X', 'L', 'a']) readOnly.handleInput(key);
|
|
626
|
+
await tick();
|
|
627
|
+
assert.equal(readOnly.state.mode, 'list', 'no editor opens without the capability');
|
|
628
|
+
assert.equal(readOnly.state.confirm, null, 'no close confirmation is staged');
|
|
629
|
+
assert.match(readFrame(), /permission/, 'the refusal names the missing permission');
|
|
630
|
+
assert.deepEqual(mutations(read.calls), [], 'no mutation was even attempted');
|
|
631
|
+
|
|
632
|
+
const triageRoot = makeRepo({ tag: 'issues-role-triage' });
|
|
633
|
+
const triage = makeIssuesRunner({ role: 'TRIAGE' });
|
|
634
|
+
const { component: triaged } = await openIssues(triageRoot, { runner: triage.runner });
|
|
635
|
+
const triageFrame = () => clean(triaged.render(200).join('\n'));
|
|
636
|
+
assert.ok(await waitFor(() => triageFrame().includes('l labels')), 'triage may open the label editor');
|
|
637
|
+
|
|
638
|
+
triaged.handleInput('L');
|
|
639
|
+
assert.equal(triaged.state.mode, 'labels', 'the triage viewer reaches the editor');
|
|
640
|
+
await tick();
|
|
641
|
+
assert.ok(!triageFrame().includes('create new'), 'no create row without the WRITE right');
|
|
642
|
+
assert.ok(!triageFrame().includes('n new'), 'and no create keybinding either');
|
|
643
|
+
triaged.handleInput('n');
|
|
644
|
+
assert.equal(triaged.state.labelCreating, false, 'n starts no creation');
|
|
645
|
+
triaged.handleInput('escape');
|
|
646
|
+
assert.equal(triaged.state.mode, 'list');
|
|
647
|
+
|
|
648
|
+
triaged.handleInput('x');
|
|
649
|
+
assert.equal(triaged.state.mode, 'confirm', 'triage may close');
|
|
650
|
+
triaged.handleInput('enter');
|
|
651
|
+
assert.ok(await waitFor(() => triage.calls.some((call) => call.argv[1] === 'close')), 'the close reached gh');
|
|
652
|
+
assert.deepEqual(triage.calls.filter((call) => call.argv[1] === 'close').map((call) => call.argv),
|
|
653
|
+
[['issue', 'close', '76']], 'one fixed-argv close, and no label creation');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('the browser opener runs fixed argv and suspends the terminal for the open', async () => {
|
|
657
|
+
const root = makeRepo({ tag: 'issues-open-browser' });
|
|
658
|
+
const { runner } = makeIssuesRunner();
|
|
659
|
+
const run = await capturedIssuesOptions(root, { runner });
|
|
660
|
+
const gitCalls = [];
|
|
661
|
+
const original = worktreeGit.runArgvAsync;
|
|
662
|
+
worktreeGit.runArgvAsync = async (bin, argv, opts) => {
|
|
663
|
+
gitCalls.push({ bin, argv: argv.slice(), cwd: opts ? opts.cwd : undefined });
|
|
664
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
665
|
+
};
|
|
666
|
+
try {
|
|
667
|
+
const opened = await run.options.actions.openBrowser(76);
|
|
668
|
+
assert.equal(opened.ok, true, 'the opener reports success');
|
|
669
|
+
} finally {
|
|
670
|
+
worktreeGit.runArgvAsync = original;
|
|
671
|
+
run.restore();
|
|
672
|
+
}
|
|
673
|
+
assert.equal(gitCalls.length, 1, 'the browser opener runs once');
|
|
674
|
+
assert.deepEqual(gitCalls[0].argv,
|
|
675
|
+
['issue', 'view', '76', '--web', '-R', 'github.com/jonasotoaguilar/herdr-gentle-shell']);
|
|
676
|
+
assert.equal(gitCalls[0].cwd, root, 'the opener runs in this checkout');
|
|
677
|
+
assert.ok(!gitCalls[0].argv.join(' ').includes('xdg-open'), 'never an invented opener');
|
|
678
|
+
assert.deepEqual(run.host.lifecycle, ['stop', 'start', 'render'],
|
|
679
|
+
'the TUI is suspended for the browser open and taken back afterwards');
|
|
680
|
+
assert.deepEqual(run.host.paints, [true], 'the terminal is forced to repaint on resume');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('r re-reads the current page fresh, and the removed state toggle reads nothing', async () => {
|
|
684
|
+
const root = makeRepo({ tag: 'issues-reload' });
|
|
685
|
+
const { runner, calls } = makeIssuesRunner();
|
|
686
|
+
const { component } = await openIssues(root, { runner });
|
|
687
|
+
await tick();
|
|
688
|
+
const primed = searchQueries(calls);
|
|
689
|
+
assert.equal(primed.length, 3, 'the mount read every scoped bucket once');
|
|
690
|
+
const connections = connectionReads(calls).length;
|
|
691
|
+
assert.equal(connections, 1, 'and the All bucket once, through the connection');
|
|
692
|
+
|
|
693
|
+
const before = clean(component.render(110).join('\n'));
|
|
694
|
+
component.handleInput('f');
|
|
695
|
+
await tick();
|
|
696
|
+
assert.deepEqual(searchQueries(calls), primed, 'f fetches nothing: no other state is asked for');
|
|
697
|
+
assert.equal(clean(component.render(110).join('\n')), before, 'f repaints nothing');
|
|
698
|
+
|
|
699
|
+
component.handleInput('r');
|
|
700
|
+
await tick();
|
|
701
|
+
assert.deepEqual(searchQueries(calls).slice(primed.length), ['author:@me sort:updated-desc'],
|
|
702
|
+
'a reload re-reads the current page fresh');
|
|
703
|
+
assert.equal(connectionReads(calls).length, connections, 'the reload left the All connection page alone');
|
|
704
|
+
assert.equal(calls.some((call) => call.argv.includes('--state')), false,
|
|
705
|
+
'the list API is never asked for any state');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('a failing comment keeps the draft, shows the error and changes nothing', async () => {
|
|
709
|
+
const root = makeRepo({ tag: 'issues-fail' });
|
|
710
|
+
const { runner, calls } = makeIssuesRunner({ failComment: true });
|
|
711
|
+
const { component } = await openIssues(root, { runner });
|
|
712
|
+
component.handleInput('c');
|
|
713
|
+
for (const ch of 'hey') component.handleInput(ch);
|
|
714
|
+
component.handleInput('ctrl+s');
|
|
715
|
+
assert.ok(await waitFor(() => calls.some((call) => call.argv[1] === 'comment')), 'the comment reached gh');
|
|
716
|
+
await tick();
|
|
717
|
+
assert.equal(component.state.mode, 'comment', 'the failure keeps the input open');
|
|
718
|
+
assert.equal(component.state.commentText, 'hey', 'the draft survives the failure');
|
|
719
|
+
assert.match(clean(component.render(110).join('\n')), /rate limited/, 'the error surfaces honestly');
|
|
720
|
+
assert.equal(calls.filter((call) => call.argv[1] === 'comment').length, 1, 'one attempt, no retry');
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
describe('/issues page seam (S5,D1)', () => {
|
|
725
|
+
async function mountCapturing(root, rows = 24) {
|
|
726
|
+
const base = makeIssuesRunner();
|
|
727
|
+
const lib = require('../lib/issues-view.js');
|
|
728
|
+
const realCreate = lib.createIssuesView;
|
|
729
|
+
const realPage = ghIssues.getIssuePage;
|
|
730
|
+
const order = [];
|
|
731
|
+
const pages = [];
|
|
732
|
+
const holder = { options: null };
|
|
733
|
+
ghIssues.getIssuePage = (params, opts) => {
|
|
734
|
+
order.push('page');
|
|
735
|
+
pages.push({ params, opts });
|
|
736
|
+
return realPage(params, opts);
|
|
737
|
+
};
|
|
738
|
+
lib.createIssuesView = (options) => {
|
|
739
|
+
holder.options = options;
|
|
740
|
+
order.push('view');
|
|
741
|
+
return realCreate(options);
|
|
742
|
+
};
|
|
743
|
+
const restore = () => {
|
|
744
|
+
lib.createIssuesView = realCreate;
|
|
745
|
+
ghIssues.getIssuePage = realPage;
|
|
746
|
+
};
|
|
747
|
+
let mounted = null;
|
|
748
|
+
let loaded = null;
|
|
749
|
+
try {
|
|
750
|
+
loaded = await loadExtension();
|
|
751
|
+
ghIssues.setGhRunner(base.runner);
|
|
752
|
+
const scratch = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-issues-gate-')));
|
|
753
|
+
await fireSessionStart(loaded.hooks, gateCtx(scratch, []));
|
|
754
|
+
loaded.mod.setIssuesTuiLoader(async () => stubTui);
|
|
755
|
+
const { ctx } = makeCtx({ cwd: root });
|
|
756
|
+
ctx.ui.custom = async (factory) => {
|
|
757
|
+
order.push('custom');
|
|
758
|
+
mounted = openComponent(factory, makeHostTui(rows));
|
|
759
|
+
return null;
|
|
760
|
+
};
|
|
761
|
+
await loaded.commands.get('issues').handler('', ctx);
|
|
762
|
+
await tick();
|
|
763
|
+
} catch (err) {
|
|
764
|
+
restore();
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
return { order, pages, holder, base, mounted, restore };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
test('the overlay mounts before any page read, with bucket metadata only', async () => {
|
|
771
|
+
const root = makeRepo({ tag: 'issues-order' });
|
|
772
|
+
const run = await mountCapturing(root);
|
|
773
|
+
try {
|
|
774
|
+
assert.equal(run.order[0], 'custom', 'the overlay opens before anything is read');
|
|
775
|
+
assert.deepEqual(run.order.slice(0, 3), ['custom', 'view', 'page'],
|
|
776
|
+
'the view mounts first, and its own mount asks for the first page');
|
|
777
|
+
const options = run.holder.options;
|
|
778
|
+
assert.deepEqual(options.pages.map((page) => [page.id, page.label, page.issues]), [
|
|
779
|
+
['mine', 'My issues', null],
|
|
780
|
+
['assigned', 'Assigned', null],
|
|
781
|
+
['involved', 'Involved', null],
|
|
782
|
+
['all', 'All', null],
|
|
783
|
+
], 'bucket metadata only: no accumulated rows are handed to the view');
|
|
784
|
+
assert.equal(options.loadPages, undefined, 'no eager/lazy-list loader is passed beside the fresh seam');
|
|
785
|
+
assert.equal(typeof options.loadIssuePage, 'function');
|
|
786
|
+
const first = run.pages[0];
|
|
787
|
+
assert.deepEqual(first.params, { query: 'author:@me sort:updated-desc', after: null, first: first.params.first });
|
|
788
|
+
assert.ok(Number.isInteger(first.params.first) && first.params.first >= 1, 'the limit is a row budget');
|
|
789
|
+
assert.equal(first.opts.cwd, root);
|
|
790
|
+
} finally {
|
|
791
|
+
run.restore();
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test('the loader passes the exact bucket query, cursor, dynamic limit and pinned scope', async () => {
|
|
796
|
+
const root = makeRepo({ tag: 'issues-seam' });
|
|
797
|
+
const narrow = await mountCapturing(root, 24);
|
|
798
|
+
let narrowLimit = null;
|
|
799
|
+
try {
|
|
800
|
+
narrowLimit = narrow.pages[0].params.first;
|
|
801
|
+
const asked = await narrow.holder.options.loadIssuePage({ bucketId: 'assigned', cursor: 'row-3', limit: 7 });
|
|
802
|
+
assert.equal(asked.ok, true, 'the real client answered the page');
|
|
803
|
+
const call = narrow.pages[narrow.pages.length - 1];
|
|
804
|
+
assert.deepEqual(call.params, { query: 'assignee:@me sort:updated-desc', after: 'row-3', first: 7 },
|
|
805
|
+
'the bucket query, the caller cursor and the caller limit pass straight through');
|
|
806
|
+
assert.deepEqual(call.opts, { cwd: root, scope: 'github.com/jonasotoaguilar/herdr-gentle-shell' },
|
|
807
|
+
'the page read is scoped to this checkout and bound to the pinned identity');
|
|
808
|
+
|
|
809
|
+
const before = narrow.pages.length;
|
|
810
|
+
await narrow.holder.options.loadIssuePage({ bucketId: 'involved', cursor: null, limit: 5 });
|
|
811
|
+
await narrow.holder.options.loadIssuePage({ bucketId: 'involved', cursor: null, limit: 5 });
|
|
812
|
+
assert.deepEqual(narrow.pages.slice(before).map((entry) => entry.params), [
|
|
813
|
+
{ query: 'involves:@me -author:@me sort:updated-desc', after: null, first: 5 },
|
|
814
|
+
{ query: 'involves:@me -author:@me sort:updated-desc', after: null, first: 5 },
|
|
815
|
+
], 'identical requests read again instead of answering from a cache');
|
|
816
|
+
|
|
817
|
+
const subprocesses = narrow.base.calls.length;
|
|
818
|
+
assert.deepEqual(narrow.holder.options.loadIssuePage({ bucketId: 'bogus', cursor: null, limit: 5 }),
|
|
819
|
+
{ ok: false, error: 'unknown issues bucket "bogus"' }, 'an unknown bucket fails explicitly');
|
|
820
|
+
assert.equal(narrow.base.calls.length, subprocesses, 'an unknown bucket never runs gh');
|
|
821
|
+
} finally {
|
|
822
|
+
narrow.restore();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const wide = await mountCapturing(root, 60);
|
|
826
|
+
try {
|
|
827
|
+
const wideLimit = wide.pages[0].params.first;
|
|
828
|
+
assert.ok(wideLimit > narrowLimit,
|
|
829
|
+
`the limit follows the viewport row budget: ${narrowLimit} at 24 rows, ${wideLimit} at 60`);
|
|
830
|
+
} finally {
|
|
831
|
+
wide.restore();
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test('the All bucket reads the open connection, and a typed query stays on the search API', async () => {
|
|
836
|
+
const root = makeRepo({ tag: 'issues-all' });
|
|
837
|
+
const run = await capturedIssuesOptions(root);
|
|
838
|
+
try {
|
|
839
|
+
const before = run.calls.length;
|
|
840
|
+
const asked = await run.options.loadIssuePage({ bucketId: 'all', cursor: null, limit: 5 });
|
|
841
|
+
assert.equal(asked.ok, true, 'the real client answered the All page');
|
|
842
|
+
const reads = connectionReads(run.calls.slice(before));
|
|
843
|
+
assert.equal(reads.length, 1, 'one connection read answered the All bucket');
|
|
844
|
+
assert.deepEqual(searchQueries(run.calls.slice(before)), [], 'the All bucket never used the search API');
|
|
845
|
+
assert.equal(reads[0].cursor, null, 'a head read starts the connection at its first row');
|
|
846
|
+
assert.equal(reads[0].first, '5', 'the caller limit bounds the connection page');
|
|
847
|
+
assert.match(reads[0].query, /issues\(states: \[OPEN\], first: \$first, after: \$cursor/);
|
|
848
|
+
assert.match(reads[0].query, /orderBy: \{field: UPDATED_AT, direction: DESC\}/);
|
|
849
|
+
assert.ok(!reads[0].query.includes('search('), 'never the 1000-match search API');
|
|
850
|
+
assert.ok(!reads[0].argv.some((entry) => entry.startsWith('search=')), 'no search text reaches the connection');
|
|
851
|
+
const rows = JSON.parse(fixture('gh-issue-list.json')).length;
|
|
852
|
+
assert.equal(asked.total, rows, 'the whole open set is the bucket total');
|
|
853
|
+
for (const issue of asked.issues) assert.equal(typeof issue.cursor, 'string', 'every row keeps its own cursor');
|
|
854
|
+
|
|
855
|
+
const typedBefore = run.calls.length;
|
|
856
|
+
const typed = await run.options.loadIssuePage({ bucketId: 'all', cursor: 'row-2', limit: 4, query: ' label:bug ' });
|
|
857
|
+
assert.equal(typed.ok, true, 'the typed All search answered');
|
|
858
|
+
assert.deepEqual(searchQueries(run.calls.slice(typedBefore)), ['sort:updated-desc label:bug'],
|
|
859
|
+
'a typed query keeps the bucket ordering and rides the search API');
|
|
860
|
+
assert.equal(connectionReads(run.calls.slice(typedBefore)).length, 0,
|
|
861
|
+
'a typed query never reaches the connection, which takes no search text');
|
|
862
|
+
|
|
863
|
+
const scopedBefore = run.calls.length;
|
|
864
|
+
await run.options.loadIssuePage({ bucketId: 'mine', cursor: 'row-2', limit: 7, query: ' label:bug is:open ' });
|
|
865
|
+
assert.deepEqual(searchQueries(run.calls.slice(scopedBefore)), ['author:@me sort:updated-desc label:bug is:open'],
|
|
866
|
+
'the bucket scope stays and the typed operators ride along');
|
|
867
|
+
const bareBefore = run.calls.length;
|
|
868
|
+
await run.options.loadIssuePage({ bucketId: 'assigned', cursor: null, limit: 5 });
|
|
869
|
+
assert.deepEqual(searchQueries(run.calls.slice(bareBefore)), ['assignee:@me sort:updated-desc'],
|
|
870
|
+
'no query means no extra search text');
|
|
871
|
+
const blankBefore = run.calls.length;
|
|
872
|
+
await run.options.loadIssuePage({ bucketId: 'involved', cursor: null, limit: 5, query: ' ' });
|
|
873
|
+
assert.deepEqual(searchQueries(run.calls.slice(blankBefore)), ['involves:@me -author:@me sort:updated-desc'],
|
|
874
|
+
'a blank query is absent, never a trailing space');
|
|
875
|
+
} finally {
|
|
876
|
+
run.restore();
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test('more than 1000 open issues page to the end through the All bucket', async () => {
|
|
881
|
+
const root = makeRepo({ tag: 'issues-all-1200' });
|
|
882
|
+
const total = 1200;
|
|
883
|
+
const rows = Array.from({ length: total }, (_, at) => ({
|
|
884
|
+
number: at + 1,
|
|
885
|
+
title: `synthetic issue ${at + 1}`,
|
|
886
|
+
state: 'OPEN',
|
|
887
|
+
createdAt: '2026-02-01T00:00:00Z',
|
|
888
|
+
updatedAt: '2026-02-01T00:00:00Z',
|
|
889
|
+
url: `https://github.com/o/r/issues/${at + 1}`,
|
|
890
|
+
}));
|
|
891
|
+
const run = await capturedIssuesOptions(root, { connectionRows: rows });
|
|
892
|
+
try {
|
|
893
|
+
const before = run.calls.length;
|
|
894
|
+
const seen = [];
|
|
895
|
+
let cursor = null;
|
|
896
|
+
let pages = 0;
|
|
897
|
+
for (;;) {
|
|
898
|
+
const page = await run.options.loadIssuePage({ bucketId: 'all', cursor, limit: 100 });
|
|
899
|
+
assert.equal(page.ok, true, `page ${pages + 1} loaded: ${page.error || ''}`);
|
|
900
|
+
assert.equal(page.total, total, 'every page reports the exact server total');
|
|
901
|
+
assert.ok(page.issues.length > 0, 'a page never comes back empty mid-walk');
|
|
902
|
+
cursor = page.issues[page.issues.length - 1].cursor;
|
|
903
|
+
seen.push(...page.issues.map((issue) => issue.number));
|
|
904
|
+
pages += 1;
|
|
905
|
+
if (page.hasNextPage !== true) break;
|
|
906
|
+
assert.ok(pages <= 12, 'the walk ends: 1200 rows at 100 per page is 12 pages');
|
|
907
|
+
}
|
|
908
|
+
assert.equal(pages, 12, 'exactly the pages the 1200 rows need');
|
|
909
|
+
assert.deepEqual(seen, rows.map((row) => row.number), 'every open issue arrives once, in order');
|
|
910
|
+
const reads = connectionReads(run.calls.slice(before));
|
|
911
|
+
assert.equal(reads.length, 12, 'every page was a connection read');
|
|
912
|
+
assert.equal(reads[1].cursor, 'conn-99', 'each continuation resumes after the last row shown');
|
|
913
|
+
assert.deepEqual(searchQueries(run.calls.slice(before)), [], 'no page read the capped search');
|
|
914
|
+
} finally {
|
|
915
|
+
run.restore();
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test('the active bucket names the real total, not the rows one page fetched', async () => {
|
|
920
|
+
const root = makeRepo({ tag: 'issues-totals' });
|
|
921
|
+
const rows = Array.from({ length: 45 }, (_, at) => ({
|
|
922
|
+
number: at + 1,
|
|
923
|
+
title: `issue ${at + 1} chore: update`,
|
|
924
|
+
state: 'OPEN',
|
|
925
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
926
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
927
|
+
url: `https://example.test/issues/${at + 1}`,
|
|
928
|
+
}));
|
|
929
|
+
const { runner } = makeIssuesRunner({ connectionRows: null });
|
|
930
|
+
// The scoped tabs read the search API, so the fixture rows are replaced by
|
|
931
|
+
// a search page carrying the same 45 rows the connection would.
|
|
932
|
+
const wrapped = async (bin, argv, opts) => {
|
|
933
|
+
if (argv[0] === 'api' && argv[1] === 'graphql') {
|
|
934
|
+
const query = graphqlField(argv, 'query', '-f') || '';
|
|
935
|
+
if (query.includes('type: ISSUE')) {
|
|
936
|
+
return { status: 0, stdout: graphqlSearchPage(argv, rows), stderr: '', error: null };
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return runner(bin, argv, opts);
|
|
940
|
+
};
|
|
941
|
+
const { component } = await openIssues(root, { runner: wrapped });
|
|
942
|
+
const body = clean(component.render(110).join('\n'));
|
|
943
|
+
assert.match(body, /My issues \(45/);
|
|
944
|
+
assert.ok(!body.includes('+ issues'), 'the total is never repeated below the list');
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe('/issues repository identity (S5)', () => {
|
|
949
|
+
const forkRoots = [];
|
|
950
|
+
after(() => {
|
|
951
|
+
for (const dir of forkRoots) {
|
|
952
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
function makeForkRepo({ tag = 'issues-fork', remotes = [['origin', 'myfork/project'], ['upstream', 'original/project']] } = {}) {
|
|
957
|
+
const root = makeRepo({ tag });
|
|
958
|
+
forkRoots.push(root);
|
|
959
|
+
for (const [name, repo] of remotes) {
|
|
960
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(repo) || repo.includes(':') || repo.startsWith('/')
|
|
961
|
+
? repo
|
|
962
|
+
: `git@github.com:${repo}.git`;
|
|
963
|
+
git(root, ['remote', 'add', name, url]);
|
|
964
|
+
}
|
|
965
|
+
return root;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function forkRunner({ repoView = 'original/project' } = {}) {
|
|
969
|
+
const base = makeIssuesRunner();
|
|
970
|
+
const runner = async (bin, argv, opts) => {
|
|
971
|
+
const env = (opts && opts.env) || {};
|
|
972
|
+
const repo = typeof env.GH_REPO === 'string' && env.GH_REPO !== '' ? env.GH_REPO : repoView;
|
|
973
|
+
base.calls.push({ bin, argv: argv.slice(), cwd: opts ? opts.cwd : undefined, repo });
|
|
974
|
+
if (argv[0] === 'repo' && argv[1] === 'view') {
|
|
975
|
+
return { status: 0, stdout: JSON.stringify({ nameWithOwner: repoView }), stderr: '', error: null };
|
|
976
|
+
}
|
|
977
|
+
const before = base.calls.length;
|
|
978
|
+
const out = await base.runner(bin, argv, opts);
|
|
979
|
+
base.calls.length = before;
|
|
980
|
+
return out;
|
|
981
|
+
};
|
|
982
|
+
return { runner, calls: base.calls };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function searchScopes(calls) {
|
|
986
|
+
return calls
|
|
987
|
+
.flatMap((call) => call.argv)
|
|
988
|
+
.filter((value) => value.startsWith('search=repo:'))
|
|
989
|
+
.map((value) => value.slice('search=repo:'.length).split(' ')[0]);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
test('every tab, the detail, labels and assignees read the checkout fork', async () => {
|
|
993
|
+
const root = makeForkRepo();
|
|
994
|
+
const gh = forkRunner();
|
|
995
|
+
const run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
996
|
+
try {
|
|
997
|
+
const before = gh.calls.length;
|
|
998
|
+
for (const bucketId of ['mine', 'assigned', 'involved', 'all']) {
|
|
999
|
+
const asked = await run.options.loadIssuePage({ bucketId, cursor: null, limit: 5 });
|
|
1000
|
+
assert.equal(asked.ok, true, `the ${bucketId} tab answered`);
|
|
1001
|
+
}
|
|
1002
|
+
const reads = gh.calls.slice(before);
|
|
1003
|
+
assert.deepEqual(
|
|
1004
|
+
searchScopes(reads.filter((call) => call.argv[0] === 'api' && call.argv[1] === 'graphql')),
|
|
1005
|
+
['myfork/project', 'myfork/project', 'myfork/project'],
|
|
1006
|
+
'each scoped tab searches the fork, never the upstream',
|
|
1007
|
+
);
|
|
1008
|
+
for (const read of connectionReads(reads)) {
|
|
1009
|
+
assert.equal(graphqlField(read.argv, 'owner'), 'myfork', 'the All tab walks the fork issue connection');
|
|
1010
|
+
assert.equal(graphqlField(read.argv, 'name'), 'project');
|
|
1011
|
+
}
|
|
1012
|
+
assert.ok(!reads.some((call) => call.argv.some((value) => value.includes('original/project'))),
|
|
1013
|
+
'no tab read carries the upstream repository');
|
|
1014
|
+
|
|
1015
|
+
const scopedBefore = gh.calls.length;
|
|
1016
|
+
assert.equal((await run.options.loadIssue(76)).ok, true, 'the detail opened through the real client');
|
|
1017
|
+
assert.equal((await run.options.loadLabels()).ok, true);
|
|
1018
|
+
assert.equal((await run.options.loadAssignees()).ok, true);
|
|
1019
|
+
const scoped = gh.calls.slice(scopedBefore);
|
|
1020
|
+
assert.ok(scoped.length >= 3, 'the detail, label and assignee reads ran');
|
|
1021
|
+
for (const call of scoped) {
|
|
1022
|
+
assert.equal(call.repo, 'github.com/myfork/project',
|
|
1023
|
+
`the read is scoped to the fork, host included (${call.argv.join(' ')})`);
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
run.restore();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test('the browser opener opens the fork issue, not the upstream one', async () => {
|
|
1031
|
+
const root = makeForkRepo({ tag: 'issues-fork-open' });
|
|
1032
|
+
const gh = forkRunner();
|
|
1033
|
+
const run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1034
|
+
const gitCalls = [];
|
|
1035
|
+
const original = worktreeGit.runArgvAsync;
|
|
1036
|
+
worktreeGit.runArgvAsync = async (bin, argv, opts) => {
|
|
1037
|
+
gitCalls.push({ bin, argv: argv.slice(), cwd: opts ? opts.cwd : undefined });
|
|
1038
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
1039
|
+
};
|
|
1040
|
+
try {
|
|
1041
|
+
const opened = await run.options.actions.openBrowser(76);
|
|
1042
|
+
assert.equal(opened.ok, true);
|
|
1043
|
+
} finally {
|
|
1044
|
+
worktreeGit.runArgvAsync = original;
|
|
1045
|
+
run.restore();
|
|
1046
|
+
}
|
|
1047
|
+
assert.deepEqual(gitCalls[0].argv, ['issue', 'view', '76', '--web', '-R', 'github.com/myfork/project'],
|
|
1048
|
+
'the browser opens the checkout fork issue on the fork server');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test('a single-remote checkout binds the opener to the repository gh resolves', async () => {
|
|
1052
|
+
const root = makeForkRepo({ tag: 'issues-nofork', remotes: [['origin', 'myfork/project']] });
|
|
1053
|
+
const gh = forkRunner({ repoView: 'myfork/project' });
|
|
1054
|
+
const run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1055
|
+
const gitCalls = [];
|
|
1056
|
+
const original = worktreeGit.runArgvAsync;
|
|
1057
|
+
worktreeGit.runArgvAsync = async (bin, argv) => {
|
|
1058
|
+
gitCalls.push({ bin, argv: argv.slice() });
|
|
1059
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
1060
|
+
};
|
|
1061
|
+
try {
|
|
1062
|
+
assert.equal((await run.options.actions.openBrowser(76)).ok, true);
|
|
1063
|
+
} finally {
|
|
1064
|
+
worktreeGit.runArgvAsync = original;
|
|
1065
|
+
run.restore();
|
|
1066
|
+
}
|
|
1067
|
+
assert.deepEqual(gitCalls[0].argv, ['issue', 'view', '76', '--web', '-R', 'github.com/myfork/project'],
|
|
1068
|
+
'the opener binds the one repository gh resolved for this checkout');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('a non-GitHub checkout remote fails every read and the opener without running gh', async () => {
|
|
1072
|
+
const root = makeForkRepo({
|
|
1073
|
+
tag: 'issues-fork-local',
|
|
1074
|
+
remotes: [['origin', 'file:///srv/mirror/project.git'], ['upstream', 'original/project']],
|
|
1075
|
+
});
|
|
1076
|
+
const gh = forkRunner();
|
|
1077
|
+
const run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1078
|
+
const gitCalls = [];
|
|
1079
|
+
const original = worktreeGit.runArgvAsync;
|
|
1080
|
+
worktreeGit.runArgvAsync = async (bin, argv) => {
|
|
1081
|
+
gitCalls.push({ argv: argv.slice() });
|
|
1082
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
1083
|
+
};
|
|
1084
|
+
try {
|
|
1085
|
+
for (const bucketId of ['mine', 'assigned', 'involved', 'all']) {
|
|
1086
|
+
const asked = await run.options.loadIssuePage({ bucketId, cursor: null, limit: 5 });
|
|
1087
|
+
assert.equal(asked.ok, false, `the ${bucketId} tab is refused`);
|
|
1088
|
+
assert.match(asked.error, /is a local path, not a GitHub repository/);
|
|
1089
|
+
}
|
|
1090
|
+
const labels = await run.options.loadLabels();
|
|
1091
|
+
assert.equal(labels.ok, false, 'the label editor read is refused');
|
|
1092
|
+
const opened = await run.options.actions.openBrowser(76);
|
|
1093
|
+
assert.equal(opened.ok, false, 'the opener refuses instead of opening another repository');
|
|
1094
|
+
assert.match(opened.error, /is a local path, not a GitHub repository/);
|
|
1095
|
+
assert.deepEqual(gitCalls, [], 'the browser opener never ran');
|
|
1096
|
+
assert.ok(gh.calls.every((call) => call.cwd !== root), 'no gh command ran in this checkout');
|
|
1097
|
+
} finally {
|
|
1098
|
+
worktreeGit.runArgvAsync = original;
|
|
1099
|
+
run.restore();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test('after the remote moves, every action refuses and reads keep the displayed repository', async () => {
|
|
1104
|
+
const root = makeForkRepo({ tag: 'issues-fork-drift' });
|
|
1105
|
+
const gh = forkRunner();
|
|
1106
|
+
const run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1107
|
+
const gitCalls = [];
|
|
1108
|
+
const original = worktreeGit.runArgvAsync;
|
|
1109
|
+
worktreeGit.runArgvAsync = async (bin, argv) => {
|
|
1110
|
+
gitCalls.push({ argv: argv.slice() });
|
|
1111
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
1112
|
+
};
|
|
1113
|
+
try {
|
|
1114
|
+
const bound = await run.options.actions.postComment(76, 'bound');
|
|
1115
|
+
assert.equal(bound.ok, true, 'a write before the move succeeds');
|
|
1116
|
+
const boundCall = gh.calls.filter((call) => call.argv[0] === 'issue' && call.argv[1] === 'comment').pop();
|
|
1117
|
+
assert.equal(boundCall.repo, 'github.com/myfork/project', 'and it binds the displayed repository');
|
|
1118
|
+
|
|
1119
|
+
git(root, ['remote', 'set-url', 'origin', 'git@github.com:otherfork/project.git']);
|
|
1120
|
+
const before = gh.calls.length;
|
|
1121
|
+
|
|
1122
|
+
const comment = await run.options.actions.postComment(76, 'hello');
|
|
1123
|
+
assert.equal(comment.ok, false, 'the comment is refused');
|
|
1124
|
+
assert.match(comment.error, /changed since this issue list loaded/);
|
|
1125
|
+
assert.equal((await run.options.actions.editLabels(76, ['bug'], [], [])).ok, false, 'the label edit is refused');
|
|
1126
|
+
assert.equal((await run.options.actions.setState(76, 'closed')).ok, false, 'the close is refused');
|
|
1127
|
+
assert.equal((await run.options.actions.editAssignees(76, { add: ['bob'], remove: [] })).ok, false,
|
|
1128
|
+
'the assignee edit is refused');
|
|
1129
|
+
const opened = await run.options.actions.openBrowser(76);
|
|
1130
|
+
assert.equal(opened.ok, true, 'the opener still opens the repository the rows came from');
|
|
1131
|
+
assert.deepEqual(gitCalls[0].argv, ['issue', 'view', '76', '--web', '-R', 'github.com/myfork/project'],
|
|
1132
|
+
'the browser opens the displayed repository, never the one the checkout resolves to now');
|
|
1133
|
+
|
|
1134
|
+
const mutations = gh.calls.slice(before).filter((call) => call.argv[0] === 'issue'
|
|
1135
|
+
&& ['edit', 'comment', 'close', 'reopen'].includes(call.argv[1]));
|
|
1136
|
+
assert.deepEqual(mutations, [], 'no mutating gh command ran after the drift');
|
|
1137
|
+
assert.ok(!gh.calls.slice(before).some((call) => call.argv[0] === 'label' && call.argv[1] === 'create'),
|
|
1138
|
+
'no label was created on the new fork');
|
|
1139
|
+
|
|
1140
|
+
for (const bucketId of ['mine', 'assigned', 'involved', 'all']) {
|
|
1141
|
+
const asked = await run.options.loadIssuePage({ bucketId, cursor: null, limit: 5 });
|
|
1142
|
+
assert.equal(asked.ok, true, `the ${bucketId} tab still reads the loaded repository`);
|
|
1143
|
+
}
|
|
1144
|
+
const reads = gh.calls.slice(before);
|
|
1145
|
+
assert.ok(reads.some((call) => call.argv.some((value) => value.includes('repo:myfork/project'))),
|
|
1146
|
+
'the reads carry the displayed repository');
|
|
1147
|
+
assert.ok(!reads.some((call) => call.repo === 'github.com/otherfork/project'), 'nothing reached the new fork');
|
|
1148
|
+
} finally {
|
|
1149
|
+
worktreeGit.runArgvAsync = original;
|
|
1150
|
+
run.restore();
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
test('an ambient GH_REPO cannot override the pinned identity', async () => {
|
|
1155
|
+
const root = makeForkRepo({ tag: 'issues-fork-env-repo' });
|
|
1156
|
+
const gh = forkRunner();
|
|
1157
|
+
const savedRepo = process.env.GH_REPO;
|
|
1158
|
+
delete process.env.GH_REPO;
|
|
1159
|
+
let run = null;
|
|
1160
|
+
try {
|
|
1161
|
+
run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1162
|
+
process.env.GH_REPO = 'ambient/other';
|
|
1163
|
+
const refused = await run.options.actions.postComment(76, 'hello');
|
|
1164
|
+
assert.equal(refused.ok, false, 'the write is refused on the drift');
|
|
1165
|
+
assert.match(refused.error, /changed since this issue list loaded/);
|
|
1166
|
+
|
|
1167
|
+
const before = gh.calls.length;
|
|
1168
|
+
const page = await run.options.loadIssuePage({ bucketId: 'mine', cursor: null, limit: 5 });
|
|
1169
|
+
assert.equal(page.ok, true);
|
|
1170
|
+
assert.ok(gh.calls.slice(before).every((call) => call.repo === 'github.com/myfork/project'),
|
|
1171
|
+
'the read carries the pin, not the ambient GH_REPO');
|
|
1172
|
+
} finally {
|
|
1173
|
+
if (run) run.restore();
|
|
1174
|
+
if (savedRepo === undefined) delete process.env.GH_REPO;
|
|
1175
|
+
else process.env.GH_REPO = savedRepo;
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
test('an explicit GH_REPO is pinned host-qualified and used', async () => {
|
|
1180
|
+
const root = makeForkRepo({ tag: 'issues-fork-env-pin' });
|
|
1181
|
+
const gh = forkRunner();
|
|
1182
|
+
const savedRepo = process.env.GH_REPO;
|
|
1183
|
+
process.env.GH_REPO = 'ambient/repo';
|
|
1184
|
+
let run = null;
|
|
1185
|
+
try {
|
|
1186
|
+
run = await capturedIssuesOptions(root, { runner: gh.runner, calls: gh.calls });
|
|
1187
|
+
const before = gh.calls.length;
|
|
1188
|
+
const page = await run.options.loadIssuePage({ bucketId: 'mine', cursor: null, limit: 5 });
|
|
1189
|
+
assert.equal(page.ok, true);
|
|
1190
|
+
assert.ok(gh.calls.slice(before).every((call) => call.repo === 'github.com/ambient/repo'),
|
|
1191
|
+
'the pinned GH_REPO is host-qualified and used');
|
|
1192
|
+
} finally {
|
|
1193
|
+
if (run) run.restore();
|
|
1194
|
+
if (savedRepo === undefined) delete process.env.GH_REPO;
|
|
1195
|
+
else process.env.GH_REPO = savedRepo;
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
describe('/issues registration survives a reused module', () => {
|
|
1201
|
+
test('each runtime load registers the command', async () => {
|
|
1202
|
+
const { commands, factory } = await loadExtension();
|
|
1203
|
+
assert.ok(commands.has('issues'));
|
|
1204
|
+
const reloaded = new Map();
|
|
1205
|
+
const api = {
|
|
1206
|
+
registerTool: () => {},
|
|
1207
|
+
registerCommand: (name, definition) => reloaded.set(name, definition),
|
|
1208
|
+
registerMessageRenderer: () => {},
|
|
1209
|
+
on: () => () => {},
|
|
1210
|
+
};
|
|
1211
|
+
factory(api);
|
|
1212
|
+
assert.ok(reloaded.has('issues'), 'a reload must not inherit a registered flag from the old runtime');
|
|
1213
|
+
reloaded.clear();
|
|
1214
|
+
factory(api);
|
|
1215
|
+
assert.ok(reloaded.has('issues'), 'a rebuilt registry on the same API must also regain the command');
|
|
1216
|
+
});
|
|
1217
|
+
});
|