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,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// S4 commands: `/warp` and `/odd-list` are driven here through the registered
|
|
3
|
+
// handlers. `/issues` has its own suite (tools/issues-command.test.js), where
|
|
4
|
+
// the `gh` runner is injected. The warp case also proves the agent route end to
|
|
5
|
+
// end: `worktree_warp` queues the move as `/warp @<token>` and that exact
|
|
6
|
+
// argument is the handoff the command decodes.
|
|
7
|
+
const { test, describe, before, after } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawnSync } = require('node:child_process');
|
|
13
|
+
const { loadExtension } = require('./fixtures/load-extension.js');
|
|
14
|
+
|
|
15
|
+
const scratch = [];
|
|
16
|
+
function track(dir) {
|
|
17
|
+
scratch.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const savedEnv = { ...process.env };
|
|
22
|
+
after(() => {
|
|
23
|
+
for (const key of Object.keys(process.env)) {
|
|
24
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
25
|
+
}
|
|
26
|
+
for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
|
|
27
|
+
for (const dir of scratch) {
|
|
28
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const HOOK_BIN = path.resolve(track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-cmd-hookbin-'))));
|
|
33
|
+
fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
34
|
+
const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
|
|
35
|
+
|
|
36
|
+
function git(cwd, args) {
|
|
37
|
+
const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
38
|
+
if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
|
|
39
|
+
return out.stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeRepo() {
|
|
43
|
+
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-cmd-repo-')));
|
|
44
|
+
git(dir, ['init', '-b', 'main']);
|
|
45
|
+
git(dir, ['config', 'user.email', 'test@example.test']);
|
|
46
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
|
|
48
|
+
git(dir, ['add', 'README.md']);
|
|
49
|
+
git(dir, ['commit', '-m', 'init']);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const notifications = [];
|
|
54
|
+
function makeCtx(cwd, extra = {}) {
|
|
55
|
+
return {
|
|
56
|
+
cwd,
|
|
57
|
+
hasUI: false,
|
|
58
|
+
mode: 'tui',
|
|
59
|
+
sessionManager: { getSessionId: () => 'thread-1' },
|
|
60
|
+
ui: {
|
|
61
|
+
notify: (text, level) => { notifications.push([level, text]); },
|
|
62
|
+
...extra,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resultText(result) {
|
|
68
|
+
return (result.content || []).map((part) => (part && part.text) || '').join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let host;
|
|
72
|
+
let commands;
|
|
73
|
+
let tools;
|
|
74
|
+
before(async () => {
|
|
75
|
+
process.env.HERDR_BIN_PATH = NO_HERDR;
|
|
76
|
+
process.env.GENTLE_WORKTREE_THREAD = 'thread-1';
|
|
77
|
+
process.env.PATH = `${HOOK_BIN}${path.delimiter}${savedEnv.PATH || ''}`;
|
|
78
|
+
const sent = [];
|
|
79
|
+
const loaded = await loadExtension({ sendUserMessage: (text, options) => { sent.push([text, options]); } });
|
|
80
|
+
host = loaded.host;
|
|
81
|
+
commands = loaded.commands;
|
|
82
|
+
tools = loaded.tools;
|
|
83
|
+
host.sent = sent;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('/warp', () => {
|
|
87
|
+
test('refuses more than one name and says which form to use', async () => {
|
|
88
|
+
notifications.length = 0;
|
|
89
|
+
await commands.get('warp').handler('feat-x principal', makeCtx(makeRepo()));
|
|
90
|
+
assert.deepEqual(notifications, [
|
|
91
|
+
['error', 'worktree warp needs exactly one name: use /warp <name> (the flattened branch slug, or principal).'],
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('outside a repository asks for git init instead of opening the selector', async () => {
|
|
96
|
+
const loose = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-cmd-loose-')));
|
|
97
|
+
notifications.length = 0;
|
|
98
|
+
await commands.get('warp').handler('', makeCtx(loose));
|
|
99
|
+
assert.deepEqual(notifications, [['info', `not a git repository: ${loose}; run 'git init' first`]]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a warp outside a session reports that it needs a session, not a crash', async () => {
|
|
103
|
+
const repo = makeRepo();
|
|
104
|
+
notifications.length = 0;
|
|
105
|
+
await commands.get('warp').handler('feat-none', makeCtx(repo));
|
|
106
|
+
assert.deepEqual(notifications, [
|
|
107
|
+
['error', 'worktree warp is only available as a session command: run /warp <name> from a Pi session'],
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the agent tool queues the move as the /warp handoff the command reads', async () => {
|
|
112
|
+
const repo = makeRepo();
|
|
113
|
+
const ctx = makeCtx(repo);
|
|
114
|
+
const created = await tools.get('worktree_create').execute(
|
|
115
|
+
'call-1',
|
|
116
|
+
{ branch: 'feat/warp-me', changes: 'none' },
|
|
117
|
+
undefined,
|
|
118
|
+
undefined,
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
assert.notEqual(created.isError, true, resultText(created));
|
|
122
|
+
|
|
123
|
+
host.sent.length = 0;
|
|
124
|
+
const queued = await tools.get('worktree_warp').execute(
|
|
125
|
+
'call-2',
|
|
126
|
+
{ name: 'feat-warp-me' },
|
|
127
|
+
undefined,
|
|
128
|
+
undefined,
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
assert.notEqual(queued.isError, true, resultText(queued));
|
|
132
|
+
assert.match(resultText(queued), /^worktree warp: handoff queued/);
|
|
133
|
+
assert.equal(host.sent.length, 1);
|
|
134
|
+
const [message, options] = host.sent[0];
|
|
135
|
+
assert.deepEqual(options, { expandPromptTemplates: true });
|
|
136
|
+
assert.ok(message.startsWith('/warp @'), `the queued command is /warp (${message})`);
|
|
137
|
+
|
|
138
|
+
// The command accepts exactly that argument: the handoff is decoded (the
|
|
139
|
+
// session-control refusal is the NEXT gate, so nothing about the token is
|
|
140
|
+
// rejected) instead of falling through to the name path.
|
|
141
|
+
notifications.length = 0;
|
|
142
|
+
await commands.get('warp').handler(message.slice('/warp '.length), ctx);
|
|
143
|
+
assert.deepEqual(notifications, [
|
|
144
|
+
['error', 'worktree warp is only available as a session command: run /warp <name> from a Pi session'],
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('/odd-list', () => {
|
|
150
|
+
test('an empty checkout says there is nothing to read', async () => {
|
|
151
|
+
const repo = makeRepo();
|
|
152
|
+
notifications.length = 0;
|
|
153
|
+
await commands.get('odd-list').handler('', makeCtx(repo));
|
|
154
|
+
assert.deepEqual(notifications, [['info', 'No odd documents under odd/tasks/ in this checkout.']]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('lists odd/tasks documents through the degraded select menu', async () => {
|
|
158
|
+
const repo = makeRepo();
|
|
159
|
+
fs.mkdirSync(path.join(repo, 'odd', 'tasks'), { recursive: true });
|
|
160
|
+
fs.writeFileSync(path.join(repo, 'odd', 'tasks', 'feature.md'), '# Feature\n');
|
|
161
|
+
let offered = null;
|
|
162
|
+
notifications.length = 0;
|
|
163
|
+
await commands.get('odd-list').handler(
|
|
164
|
+
'',
|
|
165
|
+
makeCtx(repo, { select: async (title, options) => { offered = options; return options[0]; } }),
|
|
166
|
+
);
|
|
167
|
+
assert.deepEqual(offered, ['feature']);
|
|
168
|
+
assert.deepEqual(notifications, [['info', path.join(repo, 'odd', 'tasks', 'feature.md')]]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('an unknown option names the command that is valid', async () => {
|
|
172
|
+
notifications.length = 0;
|
|
173
|
+
await commands.get('odd-list').handler('nope', makeCtx(makeRepo()));
|
|
174
|
+
assert.deepEqual(notifications, [['error', 'Unknown option "nope". Use /odd-list.']]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"login":"jonasotoaguilar","name":"Jonathan Soto"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"author":{"id":"U_kgDOBXYt8A","is_bot":false,"login":"jonasotoaguilar","name":"Jonathan Soto"},"body":"The GitHub repository was renamed from `jonasotoaguilar/herdr-gentle-agents` to `jonasotoaguilar/herdr-gentle-shell`, and the local `origin` already points at the new name.\n\nThe rename is deliberately partial. `herdr-gentle-agents` appears in 33 files, but most of those occurrences are contracts rather than names:\n\n- **Repository identity (update):** `package.json` metadata URLs, `README.md` and `DESIGN.md` links and badges, `docs/*.md` links, the repository slug `install.sh` downloads from, and the install command string the plugin version check prints.\n- **Contracts (keep byte-identical):** the state directory segment and plugin ids (`STATE_PLUGIN_ID`, `PLUGIN_ID`, `LEGACY_PLUGIN_ID`, `SIDEBAR_STATE_SEGMENT`, `SIDEBAR_LEGACY_PLUGIN_ID`), the managed-block markers `# BEGIN/# END herdr-gentle-agents (managed; do not edit)` already written into the user's Pi settings, state file names, `repo_key` values, the Herdr plugin directory name, and every test that pins them.\n\nRenaming a contract would orphan the installed state on disk and make the tooling stop recognising the managed block it wrote, which is why S15 of `odd/tasks/customize-command.md` forbids it.\n\nAcceptance: the full suite green, and every changed occurrence reported as a URL/identity reference with its reason, alongside every occurrence deliberately kept.","comments":[],"createdAt":"2026-10-18T04:21:27Z","labels":[],"number":76,"reactionGroups":[],"state":"OPEN","stateReason":"","title":"chore: update the repository references after the rename to herdr-gentle-shell","updatedAt":"2026-10-18T04:21:27Z","url":"https://github.com/jonasotoaguilar/herdr-gentle-shell/issues/76"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"author":{"id":"U_kgDOBXYt8A","is_bot":false,"login":"jonasotoaguilar","name":"Jonathan Soto"},"body":"The GitHub repository was renamed from `jonasotoaguilar/herdr-gentle-agents` to `jonasotoaguilar/herdr-gentle-shell`, and the local `origin` already points at the new name.\n\nThe rename is deliberately partial. `herdr-gentle-agents` appears in 33 files, but most of those occurrences are contracts rather than names:\n\n- **Repository identity (update):** `package.json` metadata URLs, `README.md` and `DESIGN.md` links and badges, `docs/*.md` links, the repository slug `install.sh` downloads from, and the install command string the plugin version check prints.\n- **Contracts (keep byte-identical):** the state directory segment and plugin ids (`STATE_PLUGIN_ID`, `PLUGIN_ID`, `LEGACY_PLUGIN_ID`, `SIDEBAR_STATE_SEGMENT`, `SIDEBAR_LEGACY_PLUGIN_ID`), the managed-block markers `# BEGIN/# END herdr-gentle-agents (managed; do not edit)` already written into the user's Pi settings, state file names, `repo_key` values, the Herdr plugin directory name, and every test that pins them.\n\nRenaming a contract would orphan the installed state on disk and make the tooling stop recognising the managed block it wrote, which is why S15 of `odd/tasks/customize-command.md` forbids it.\n\nAcceptance: the full suite green, and every changed occurrence reported as a URL/identity reference with its reason, alongside every occurrence deliberately kept.","comments":[],"createdAt":"2026-10-18T04:21:27Z","labels":[],"number":76,"reactionGroups":[],"state":"OPEN","stateReason":"","title":"chore: update the repository references after the rename to herdr-gentle-shell","updatedAt":"2026-10-18T04:21:27Z","url":"https://github.com/jonasotoaguilar/herdr-gentle-shell/issues/76"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"color":"f143ab","description":"Barrier affecting people with disabilities","name":"accessibility"},{"color":"d73a4a","description":"Something isn't working","name":"bug"},{"color":"0075ca","description":"Improvements or additions to documentation","name":"documentation"},{"color":"cfd3d7","description":"This issue or pull request already exists","name":"duplicate"},{"color":"a2eeef","description":"New feature or request","name":"enhancement"},{"color":"7057ff","description":"Good for newcomers","name":"good first issue"},{"color":"008672","description":"Extra attention is needed","name":"help wanted"},{"color":"e4e669","description":"This doesn't seem right","name":"invalid"},{"color":"d876e3","description":"Further information is requested","name":"question"},{"color":"ffffff","description":"This will not be worked on","name":"wontfix"},{"color":"FBCA04","description":"Needs maintainer review","name":"status:needs-review"},{"color":"D73A4A","description":"Bug fix","name":"type:bug"},{"color":"A2EEEF","description":"New feature","name":"type:feature"},{"color":"0075CA","description":"Documentation only","name":"type:docs"},{"color":"5319E7","description":"Code refactoring","name":"type:refactor"},{"color":"BFDADC","description":"Build CI or tooling","name":"type:chore"},{"color":"B60205","description":"Breaking change","name":"type:breaking-change"},{"color":"B60205","description":"Maintainer-approved exception for an oversized review slice","name":"size:exception"},{"color":"0E8A16","description":"Maintainer-approved issue ready for implementation","name":"status:approved"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"nameWithOwner":"jonasotoaguilar/herdr-gentle-shell"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared loader for the standalone extension: the extension is plain JS under
|
|
3
|
+
// a .ts name, so a test copies it to a scratch .mjs with the two Pi package
|
|
4
|
+
// imports stubbed and imports it with a fake `pi` object. The lib override
|
|
5
|
+
// points the copied module at this package's own lib/ directory.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { pathToFileURL } = require('node:url');
|
|
10
|
+
|
|
11
|
+
const LIB_DIR = path.resolve(__dirname, '../../lib');
|
|
12
|
+
const SRC = path.resolve(__dirname, '../../extensions/odd-worktree-tools.ts');
|
|
13
|
+
|
|
14
|
+
const TYPE_STUB = [
|
|
15
|
+
'const Type = {',
|
|
16
|
+
' Object: (props) => ({ type: "object", properties: props }),',
|
|
17
|
+
' String: (opts) => ({ type: "string", ...(opts || {}) }),',
|
|
18
|
+
' Number: (opts) => ({ type: "number", ...(opts || {}) }),',
|
|
19
|
+
' Array: (items, opts) => ({ type: "array", items, ...(opts || {}) }),',
|
|
20
|
+
' Boolean: (opts) => ({ type: "boolean", ...(opts || {}) }),',
|
|
21
|
+
' Optional: (schema) => ({ ...schema, optional: true }),',
|
|
22
|
+
'};',
|
|
23
|
+
].join('\n');
|
|
24
|
+
|
|
25
|
+
// The stubbed source is read once per process; a test that asserts on the
|
|
26
|
+
// source itself can read SRC directly.
|
|
27
|
+
let cached = null;
|
|
28
|
+
function stubbedSource() {
|
|
29
|
+
if (cached !== null) return cached;
|
|
30
|
+
const srcText = fs.readFileSync(SRC, 'utf8');
|
|
31
|
+
const stubbed = srcText
|
|
32
|
+
.replace('import { Type } from "@earendil-works/pi-ai";', TYPE_STUB)
|
|
33
|
+
.replace('import { defineTool } from "@earendil-works/pi-coding-agent";', 'const defineTool = (def) => def;');
|
|
34
|
+
if (stubbed === srcText) throw new Error('expected both Pi package import lines to be stubbed');
|
|
35
|
+
cached = stubbed;
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Load the extension with the injected `pi` double. Returns the registered
|
|
40
|
+
// commands and tools keyed by name, exactly as the host received them.
|
|
41
|
+
async function loadExtension(pi) {
|
|
42
|
+
process.env.GENTLE_WORKTREE_LIB_DIR = LIB_DIR;
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'odd-worktree-ext-'));
|
|
44
|
+
try {
|
|
45
|
+
const copy = path.join(dir, 'odd-worktree-tools.mjs');
|
|
46
|
+
fs.writeFileSync(copy, stubbedSource());
|
|
47
|
+
const factory = (await import(pathToFileURL(copy).href)).default;
|
|
48
|
+
const commands = new Map();
|
|
49
|
+
const tools = new Map();
|
|
50
|
+
const messageRenderers = new Map();
|
|
51
|
+
const hooks = [];
|
|
52
|
+
const host = {
|
|
53
|
+
registerCommand: (name, handler) => { commands.set(name, handler); },
|
|
54
|
+
registerTool: (tool) => { tools.set(tool.name, tool); },
|
|
55
|
+
registerMessageRenderer: (type, renderer) => { messageRenderers.set(type, renderer); },
|
|
56
|
+
on: (event, handler) => { hooks.push([event, handler]); },
|
|
57
|
+
...pi,
|
|
58
|
+
};
|
|
59
|
+
factory(host);
|
|
60
|
+
return { commands, tools, messageRenderers, hooks, host };
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { LIB_DIR, SRC, loadExtension, stubbedSource };
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test, afterEach } = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const gh = require('../lib/gh-issues.js');
|
|
5
|
+
afterEach(() => gh.setGhRunner(null));
|
|
6
|
+
const ok = (doc) => ({ status: 0, stdout: JSON.stringify(doc), stderr: '' });
|
|
7
|
+
const field = (argv, name) => argv.find((value) => value.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
8
|
+
const node = (number) => ({ number, title: `Issue ${number}`, state: 'OPEN', author: { login: 'author' }, labels: { nodes: [] }, comments: { totalCount: 0 } });
|
|
9
|
+
function runner(handler) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
gh.setGhRunner(async (_bin, argv) => {
|
|
12
|
+
calls.push(argv);
|
|
13
|
+
if (argv[0] === 'repo') return ok({ nameWithOwner: 'owner/repo' });
|
|
14
|
+
return handler(argv);
|
|
15
|
+
});
|
|
16
|
+
return calls;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('fresh pages preserve the server query, per-row cursor and requested capacity', async () => {
|
|
20
|
+
let round = 0;
|
|
21
|
+
const calls = runner((argv) => {
|
|
22
|
+
assert.equal(argv[0], 'api');
|
|
23
|
+
assert.match(field(argv, 'search'), /repo:owner\/repo/);
|
|
24
|
+
assert.match(field(argv, 'search'), /assignee:@me/);
|
|
25
|
+
assert.equal(field(argv, 'first'), '7');
|
|
26
|
+
assert.equal(field(argv, 'cursor'), 'previous');
|
|
27
|
+
return ok({ data: { search: { issueCount: 12, pageInfo: { hasNextPage: true, endCursor: 'next' }, edges: [{ cursor: 'next', node: { ...node(4), title: `Fresh ${++round}` } }] } } });
|
|
28
|
+
});
|
|
29
|
+
const params = { query: 'assignee:@me', after: 'previous', first: 7 };
|
|
30
|
+
const first = await gh.getIssuePage(params);
|
|
31
|
+
const second = await gh.getIssuePage(params);
|
|
32
|
+
assert.equal(first.ok, true);
|
|
33
|
+
assert.equal(first.issues[0].cursor, 'next');
|
|
34
|
+
assert.equal(first.total, 12);
|
|
35
|
+
assert.equal(first.hasNextPage, true);
|
|
36
|
+
assert.equal(second.issues[0].title, 'Fresh 2');
|
|
37
|
+
assert.equal(calls.filter((argv) => argv[0] === 'api').length, 2, 'each page request is fresh');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('page budgets larger than one API page are fetched without dropping cursors', async () => {
|
|
41
|
+
const calls = runner((argv) => {
|
|
42
|
+
const after = field(argv, 'cursor');
|
|
43
|
+
const count = Number(field(argv, 'first'));
|
|
44
|
+
const start = after ? 101 : 1;
|
|
45
|
+
return ok({ data: { search: { issueCount: 160, pageInfo: { hasNextPage: start + count <= 160, endCursor: `c${start + count - 1}` }, edges: Array.from({ length: count }, (_, i) => ({ cursor: `c${start + i}`, node: node(start + i) })) } } });
|
|
46
|
+
});
|
|
47
|
+
const result = await gh.getIssuePage({ first: 145 });
|
|
48
|
+
assert.equal(result.ok, true);
|
|
49
|
+
assert.equal(result.issues.length, 145);
|
|
50
|
+
assert.equal(result.issues[144].cursor, 'c145');
|
|
51
|
+
assert.deepEqual(calls.filter((argv) => argv[0] === 'api').map((argv) => field(argv, 'first')), ['100', '45']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('invalid page inputs produce no subprocess calls', async () => {
|
|
55
|
+
const calls = runner(() => ok({}));
|
|
56
|
+
for (const first of [0, -1, 1.5, NaN, 1001]) assert.equal((await gh.getIssuePage({ first })).ok, false);
|
|
57
|
+
for (const params of [null, [], 'bad']) assert.equal((await gh.getIssuePage(params)).ok, false);
|
|
58
|
+
assert.equal(calls.length, 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('GraphQL errors and malformed page cursors are failures, never fake empty pages', async () => {
|
|
62
|
+
runner(() => ok({ errors: [{ message: 'Forbidden' }] }));
|
|
63
|
+
assert.equal((await gh.getIssuePage({ first: 5 })).ok, false);
|
|
64
|
+
runner(() => ok({ data: { search: { issueCount: 1, pageInfo: { hasNextPage: true }, edges: [{ node: node(1) }] } } }));
|
|
65
|
+
assert.equal((await gh.getIssuePage({ first: 5 })).ok, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('assignable users paginate and deduplicate actual repository candidates', async () => {
|
|
69
|
+
runner((argv) => {
|
|
70
|
+
const after = field(argv, 'cursor');
|
|
71
|
+
return ok({ data: { repository: { assignableUsers: { nodes: after ? [{ login: 'bob', name: 'Bob' }, { login: 'alice' }] : [{ login: 'alice', name: 'Alice' }], pageInfo: { hasNextPage: !after, endCursor: after ? null : 'users-next' } } } } });
|
|
72
|
+
});
|
|
73
|
+
const result = await gh.listAssignableUsers();
|
|
74
|
+
assert.equal(result.ok, true);
|
|
75
|
+
assert.deepEqual(result.users.map((user) => user.login), ['alice', 'bob']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('assignee toggles add and remove using fixed argv with confirmed triage rights', async () => {
|
|
79
|
+
const calls = runner((argv) => {
|
|
80
|
+
if (argv[0] === 'api') return ok({ data: { repository: { viewerPermission: 'TRIAGE' } } });
|
|
81
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
82
|
+
});
|
|
83
|
+
const result = await gh.editAssignees(3, { add: ['alice', 'bob'], remove: ['charlie'] });
|
|
84
|
+
assert.equal(result.ok, true);
|
|
85
|
+
assert.deepEqual(calls.find((argv) => argv[0] === 'issue'), ['issue', 'edit', '3', '--add-assignee', 'alice', '--add-assignee', 'bob', '--remove-assignee', 'charlie']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('denied and unknown repository roles cannot issue an assignee mutation', async () => {
|
|
89
|
+
for (const role of ['READ', null, 'UNKNOWN_ROLE']) {
|
|
90
|
+
const calls = runner(() => ok({ data: { repository: { viewerPermission: role } } }));
|
|
91
|
+
const result = await gh.editAssignees(3, { add: ['alice'], remove: [] });
|
|
92
|
+
assert.equal(result.ok, false);
|
|
93
|
+
assert.equal(calls.some((argv) => argv[0] === 'issue'), false);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('malformed assignee changes are rejected before any read or write', async () => {
|
|
98
|
+
const calls = runner(() => ok({}));
|
|
99
|
+
for (const args of [null, [], 'bad', { add: ['--help'] }, { remove: ['bad,user'] }, { add: 'alice' }, { add: ['alice'], remove: ['alice'] }]) {
|
|
100
|
+
assert.equal((await gh.editAssignees(3, args)).ok, false);
|
|
101
|
+
}
|
|
102
|
+
assert.equal((await gh.editAssignees(0, { add: ['alice'] })).ok, false);
|
|
103
|
+
assert.equal(calls.length, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('permission and relation metadata is explicit and omits absent project/PR data', async () => {
|
|
107
|
+
runner(() => ok({ data: { repository: { viewerPermission: 'TRIAGE', issue: { assignees: { nodes: [{ login: 'alice' }, { login: 'bob' }] }, projectItems: { nodes: [{ project: { title: 'Roadmap', url: 'https://example.invalid/project' } }] }, closedByPullRequestsReferences: { totalCount: 2 } } } } }));
|
|
108
|
+
const result = await gh.getIssueMetadata(3);
|
|
109
|
+
assert.equal(result.ok, true);
|
|
110
|
+
assert.deepEqual(result.permissions, { canEditLabels: true, canEditAssignees: true, canCreateLabels: false, canCloseIssues: true });
|
|
111
|
+
assert.deepEqual(result.assignees, ['alice', 'bob']);
|
|
112
|
+
assert.equal(result.projects[0].title, 'Roadmap');
|
|
113
|
+
assert.equal(result.linkedPullRequestCount, 2);
|
|
114
|
+
runner(() => ok({ data: { repository: { viewerPermission: 'READ', issue: { assignees: { nodes: [] }, projectItems: { nodes: [] }, closedByPullRequestsReferences: { totalCount: 0 } } } } }));
|
|
115
|
+
const empty = await gh.getIssueMetadata(3);
|
|
116
|
+
assert.equal(empty.permissions.canEditLabels, false);
|
|
117
|
+
assert.equal(empty.permissions.canCloseIssues, false);
|
|
118
|
+
assert.equal('projects' in empty, false);
|
|
119
|
+
assert.equal('linkedPullRequestCount' in empty, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('project scope failures preserve confirmed triage rights, assignees and linked PRs', async () => {
|
|
123
|
+
runner((argv) => {
|
|
124
|
+
if (field(argv, 'query').includes('projectItems')) return ok({ errors: [{ message: 'read:project scope required' }] });
|
|
125
|
+
return ok({ data: { repository: { viewerPermission: 'WRITE', issue: { assignees: { nodes: [{ login: 'alice' }] }, closedByPullRequestsReferences: { totalCount: 2 } } } } });
|
|
126
|
+
});
|
|
127
|
+
const result = await gh.getIssueMetadata(3);
|
|
128
|
+
assert.equal(result.ok, true);
|
|
129
|
+
assert.equal(result.permissions.canEditAssignees, true);
|
|
130
|
+
assert.equal(result.permissions.canCreateLabels, true);
|
|
131
|
+
assert.deepEqual(result.assignees, ['alice']);
|
|
132
|
+
assert.equal(result.linkedPullRequestCount, 2);
|
|
133
|
+
assert.equal('projects' in result, false);
|
|
134
|
+
assert.match(result.metadataError, /read:project/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('assignee mutation failures are not converted to success', async () => {
|
|
138
|
+
runner((argv) => argv[0] === 'api' ? ok({ data: { repository: { viewerPermission: 'WRITE' } } }) : { status: 1, stdout: '', stderr: 'Forbidden' });
|
|
139
|
+
const result = await gh.editAssignees(3, { add: ['alice'], remove: [] });
|
|
140
|
+
assert.equal(result.ok, false);
|
|
141
|
+
assert.match(result.error, /Forbidden/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// T2 (S2): the unfiltered `All` bucket reads the repository's own issue
|
|
145
|
+
// CONNECTION, which is cursor-paged with no 1000-match search ceiling and
|
|
146
|
+
// reports the exact total. `getIssuePage` (the typed-search path) keeps its
|
|
147
|
+
// search semantics and its honest cap.
|
|
148
|
+
const connection = (edges, { totalCount = edges.length, hasNextPage = false, endCursor = null } = {}) => ok({
|
|
149
|
+
data: {
|
|
150
|
+
repository: {
|
|
151
|
+
issues: { totalCount, pageInfo: { hasNextPage, endCursor }, edges },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const connectionRunner = (handler) => {
|
|
156
|
+
const calls = [];
|
|
157
|
+
gh.setGhRunner(async (_bin, argv) => {
|
|
158
|
+
calls.push(argv);
|
|
159
|
+
if (argv[0] === 'repo') return ok({ nameWithOwner: 'owner/repo' });
|
|
160
|
+
return handler(argv);
|
|
161
|
+
});
|
|
162
|
+
// A live view: the array fills as the call runs, after this returns.
|
|
163
|
+
return () => calls.filter((argv) => argv[0] === 'api');
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
test('unfiltered All reads the issue connection with an exact total and per-row cursors', async () => {
|
|
167
|
+
let seen = null;
|
|
168
|
+
const calls = connectionRunner((argv) => {
|
|
169
|
+
seen = field(argv, 'query');
|
|
170
|
+
assert.equal(field(argv, 'first'), '30');
|
|
171
|
+
assert.equal(field(argv, 'cursor'), undefined, 'a head read starts the connection');
|
|
172
|
+
return connection([
|
|
173
|
+
{ cursor: 'c1', node: node(1) },
|
|
174
|
+
{ cursor: 'c2', node: node(2) },
|
|
175
|
+
{ cursor: 'c3', node: node(3) },
|
|
176
|
+
], { totalCount: 1500, hasNextPage: true, endCursor: 'c3' });
|
|
177
|
+
});
|
|
178
|
+
const result = await gh.getIssueConnectionPage({ first: 30 });
|
|
179
|
+
assert.equal(result.ok, true);
|
|
180
|
+
assert.equal(calls().length, 1);
|
|
181
|
+
assert.doesNotMatch(seen, /search\(/, 'never the capped search API');
|
|
182
|
+
assert.match(seen, /issues\(states: \[OPEN\], first: \$first, after: \$cursor/);
|
|
183
|
+
assert.match(seen, /orderBy: \{field: UPDATED_AT, direction: DESC\}/, 'ordering belongs to the query');
|
|
184
|
+
assert.match(seen, /totalCount/);
|
|
185
|
+
assert.match(seen, /edges \{\s*cursor\s+node \{/, 'every edge carries its own cursor');
|
|
186
|
+
assert.equal(result.total, 1500, 'the exact server total, never a page length');
|
|
187
|
+
assert.equal(result.hasNextPage, true);
|
|
188
|
+
assert.deepEqual(result.issues.map((row) => row.cursor), ['c1', 'c2', 'c3']);
|
|
189
|
+
assert.deepEqual(result.issues.map((row) => row.number), [1, 2, 3]);
|
|
190
|
+
assert.equal(result.issues[0].state, 'open');
|
|
191
|
+
assert.equal(result.issues[0].body, '', 'the connection never asks for bodies');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('more than 1000 issues page past the search cap through the connection', async () => {
|
|
195
|
+
const served = [];
|
|
196
|
+
const calls = connectionRunner((argv) => {
|
|
197
|
+
const after = field(argv, 'cursor');
|
|
198
|
+
const count = Number(field(argv, 'first'));
|
|
199
|
+
const start = (after ? Number(after.slice(1)) : 0) + 1;
|
|
200
|
+
const end = start + count - 1;
|
|
201
|
+
served.push(`${start}-${end}`);
|
|
202
|
+
return connection(
|
|
203
|
+
Array.from({ length: count }, (_, i) => ({ cursor: `c${start + i}`, node: node(start + i) })),
|
|
204
|
+
{ totalCount: 1500, hasNextPage: end < 1500, endCursor: `c${end}` },
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
const head = await gh.getIssueConnectionPage({ first: 1000 });
|
|
208
|
+
assert.equal(head.ok, true, '1000 rows is a page size here, not a result cap');
|
|
209
|
+
assert.equal(head.issues.length, 1000);
|
|
210
|
+
assert.equal(head.total, 1500);
|
|
211
|
+
assert.equal(head.hasNextPage, true);
|
|
212
|
+
assert.equal(head.issues[999].cursor, 'c1000');
|
|
213
|
+
const tail = await gh.getIssueConnectionPage({ first: 500, after: head.issues[999].cursor });
|
|
214
|
+
assert.equal(tail.ok, true);
|
|
215
|
+
assert.equal(tail.issues.length, 500);
|
|
216
|
+
assert.equal(tail.issues[0].number, 1001, 'the continuation crosses the 1000-match search ceiling');
|
|
217
|
+
assert.equal(tail.issues[499].number, 1500);
|
|
218
|
+
assert.equal(tail.total, 1500);
|
|
219
|
+
assert.equal(tail.hasNextPage, false);
|
|
220
|
+
const requested = calls().map((argv) => field(argv, 'first'));
|
|
221
|
+
assert.equal(requested.length, 15, 'ten server pages for the head request, five for the continuation');
|
|
222
|
+
assert.deepEqual(requested.slice(0, 10), Array(10).fill('100'));
|
|
223
|
+
assert.deepEqual(requested.slice(10), Array(5).fill('100'));
|
|
224
|
+
assert.ok(calls()[10].includes('cursor=c1000'), 'the continuation resumes at the last delivered row');
|
|
225
|
+
assert.equal(served[0], '1-100');
|
|
226
|
+
assert.equal(served[9], '901-1000');
|
|
227
|
+
assert.equal(served[10], '1001-1100');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the state filter is explicit and never collapses closed into open', async () => {
|
|
231
|
+
const cases = [
|
|
232
|
+
['open', 'states: [OPEN]'],
|
|
233
|
+
['closed', 'states: [CLOSED]'],
|
|
234
|
+
['all', 'states: [OPEN, CLOSED]'],
|
|
235
|
+
['CLOSED', 'states: [CLOSED]'],
|
|
236
|
+
];
|
|
237
|
+
for (const [state, literal] of cases) {
|
|
238
|
+
const queries = [];
|
|
239
|
+
connectionRunner((argv) => {
|
|
240
|
+
queries.push(field(argv, 'query'));
|
|
241
|
+
return connection([], { totalCount: 0 });
|
|
242
|
+
});
|
|
243
|
+
const result = await gh.getIssueConnectionPage({ first: 5, state });
|
|
244
|
+
assert.equal(result.ok, true);
|
|
245
|
+
assert.equal(queries.length, 1);
|
|
246
|
+
assert.ok(queries[0].includes(literal), `${state} asks for ${literal}`);
|
|
247
|
+
if (state.toLowerCase() === 'closed') assert.doesNotMatch(queries[0], /OPEN/, 'a closed read never widens to open');
|
|
248
|
+
}
|
|
249
|
+
const calls = connectionRunner(() => connection([]));
|
|
250
|
+
for (const state of ['archived', 'OPEN,CLOSED', 0, true]) {
|
|
251
|
+
const bad = await gh.getIssueConnectionPage({ first: 5, state });
|
|
252
|
+
assert.equal(bad.ok, false);
|
|
253
|
+
assert.match(bad.error, /invalid issue state/);
|
|
254
|
+
}
|
|
255
|
+
assert.equal(calls().length, 0, 'an unknown state never reaches a subprocess');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a connection request refuses search text instead of silently ignoring it', async () => {
|
|
259
|
+
const calls = connectionRunner(() => connection([]));
|
|
260
|
+
for (const query of ['assignee:@me', 'sort:created-asc', 'is:closed']) {
|
|
261
|
+
const result = await gh.getIssueConnectionPage({ first: 5, query });
|
|
262
|
+
assert.equal(result.ok, false);
|
|
263
|
+
assert.match(result.error, /no search text/);
|
|
264
|
+
}
|
|
265
|
+
assert.equal(calls().length, 0, 'a refused request never reaches a subprocess');
|
|
266
|
+
const blank = await gh.getIssueConnectionPage({ first: 5, query: ' ' });
|
|
267
|
+
assert.equal(blank.ok, true, 'blank text is no text');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('invalid connection page inputs produce no subprocess calls', async () => {
|
|
271
|
+
const calls = connectionRunner(() => connection([]));
|
|
272
|
+
for (const first of [0, -1, 1.5, NaN, 1001]) assert.equal((await gh.getIssueConnectionPage({ first })).ok, false);
|
|
273
|
+
for (const after of ['', 5, {}, []]) assert.equal((await gh.getIssueConnectionPage({ first: 5, after })).ok, false);
|
|
274
|
+
for (const params of [null, [], 'bad']) assert.equal((await gh.getIssueConnectionPage(params)).ok, false);
|
|
275
|
+
assert.equal(calls().length, 0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('GraphQL errors, malformed connections and duplicate rows are failures, never fake pages', async () => {
|
|
279
|
+
const failures = [
|
|
280
|
+
[ok({ errors: [{ message: 'Forbidden' }] }), /Forbidden/],
|
|
281
|
+
[ok({ data: { repository: { issues: { pageInfo: { hasNextPage: false }, edges: [] } } } }), /invalid issue page/],
|
|
282
|
+
[ok({ data: { repository: { issues: { totalCount: -1, pageInfo: { hasNextPage: false }, edges: [] } } } }), /invalid issue page/],
|
|
283
|
+
[connection([{ node: node(1) }]), /invalid issue cursor or duplicate issue/],
|
|
284
|
+
[connection([{ cursor: 'c1', node: node(1) }, { cursor: 'c1', node: node(2) }]), /invalid issue cursor or duplicate issue/],
|
|
285
|
+
[connection([{ cursor: 'c1', node: node(1) }, { cursor: 'c2', node: node(1) }]), /invalid issue cursor or duplicate issue/],
|
|
286
|
+
[connection([], { totalCount: 4, hasNextPage: true, endCursor: null }), /did not advance/],
|
|
287
|
+
];
|
|
288
|
+
for (const [response, pattern] of failures) {
|
|
289
|
+
runner(() => response);
|
|
290
|
+
const result = await gh.getIssueConnectionPage({ first: 5 });
|
|
291
|
+
assert.equal(result.ok, false, `expected a failure for ${pattern}`);
|
|
292
|
+
assert.match(String(result.error), pattern);
|
|
293
|
+
}
|
|
294
|
+
let round = 0;
|
|
295
|
+
runner(() => {
|
|
296
|
+
round += 1;
|
|
297
|
+
const start = (round - 1) * 100 + 1;
|
|
298
|
+
return connection(
|
|
299
|
+
Array.from({ length: 100 }, (_, i) => ({ cursor: `c${start + i}`, node: node(start + i) })),
|
|
300
|
+
{ totalCount: 500, hasNextPage: true, endCursor: 'same' },
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
const stalled = await gh.getIssueConnectionPage({ first: 200 });
|
|
304
|
+
assert.equal(stalled.ok, false);
|
|
305
|
+
assert.match(String(stalled.error), /did not advance/);
|
|
306
|
+
runner(() => connection(
|
|
307
|
+
[{ cursor: 'c1', node: node(1) }, { cursor: 'c2', node: node(2) }],
|
|
308
|
+
{ totalCount: 10, hasNextPage: true, endCursor: 'c2' },
|
|
309
|
+
));
|
|
310
|
+
const oversized = await gh.getIssueConnectionPage({ first: 1 });
|
|
311
|
+
assert.equal(oversized.ok, false);
|
|
312
|
+
assert.match(String(oversized.error), /exceeded the requested issue page size/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a failed continuation page is a failure, never a short page', async () => {
|
|
316
|
+
let round = 0;
|
|
317
|
+
runner(() => {
|
|
318
|
+
round += 1;
|
|
319
|
+
if (round === 1) return connection(Array.from({ length: 100 }, (_, i) => ({ cursor: `c${i + 1}`, node: node(i + 1) })), { totalCount: 260, hasNextPage: true, endCursor: 'c100' });
|
|
320
|
+
return { status: 1, stdout: '', stderr: 'API rate limit exceeded', error: null };
|
|
321
|
+
});
|
|
322
|
+
const result = await gh.getIssueConnectionPage({ first: 150 });
|
|
323
|
+
assert.equal(result.ok, false);
|
|
324
|
+
assert.match(String(result.error), /API rate limit exceeded/);
|
|
325
|
+
assert.equal('issues' in result, false, 'no partial page is handed out as data');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('a short server page stops the fill and keeps the server hasNextPage', async () => {
|
|
329
|
+
let round = 0;
|
|
330
|
+
runner(() => {
|
|
331
|
+
round += 1;
|
|
332
|
+
if (round === 1) return connection(Array.from({ length: 100 }, (_, i) => ({ cursor: `c${i + 1}`, node: node(i + 1) })), { totalCount: 400, hasNextPage: true, endCursor: 'c100' });
|
|
333
|
+
return connection(Array.from({ length: 20 }, (_, i) => ({ cursor: `c${101 + i}`, node: node(101 + i) })), { totalCount: 400, hasNextPage: true, endCursor: 'c120' });
|
|
334
|
+
});
|
|
335
|
+
const result = await gh.getIssueConnectionPage({ first: 150 });
|
|
336
|
+
assert.equal(result.ok, true);
|
|
337
|
+
assert.equal(result.issues.length, 120, 'a short page is honored, never refilled');
|
|
338
|
+
assert.equal(result.issues[119].cursor, 'c120', 'the caller continues from the last delivered row');
|
|
339
|
+
assert.equal(result.hasNextPage, true);
|
|
340
|
+
assert.equal(result.total, 400);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the typed-search path keeps its search semantics and its honest cap', async () => {
|
|
344
|
+
const calls = runner((argv) => {
|
|
345
|
+
assert.match(field(argv, 'search'), /is:open/);
|
|
346
|
+
assert.match(field(argv, 'search'), /sort:updated-desc/);
|
|
347
|
+
return ok({ data: { search: { issueCount: 12, pageInfo: { hasNextPage: true, endCursor: 'next' }, edges: [{ cursor: 'next', node: node(4) }] } } });
|
|
348
|
+
});
|
|
349
|
+
const result = await gh.getIssuePage({ query: 'assignee:@me', first: 7 });
|
|
350
|
+
assert.equal(result.ok, true);
|
|
351
|
+
assert.equal(result.total, 12);
|
|
352
|
+
assert.equal(result.issues[0].cursor, 'next');
|
|
353
|
+
runner(() => ok({ data: { search: { issueCount: 1001, pageInfo: { hasNextPage: true, endCursor: 'next' }, edges: [{ cursor: 'next', node: node(4) }] } } }));
|
|
354
|
+
const capped = await gh.getIssuePage({ first: 7 });
|
|
355
|
+
assert.equal(capped.ok, false);
|
|
356
|
+
assert.equal(capped.truncated, true);
|
|
357
|
+
assert.match(String(capped.error), /1000/);
|
|
358
|
+
assert.equal(calls.filter((argv) => argv[0] === 'api').length, 1);
|
|
359
|
+
});
|