@marcomigozzi/trailhead 0.16.1 → 0.17.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.
Files changed (76) hide show
  1. package/README.it.md +57 -10
  2. package/README.md +57 -10
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/engine-route-docs.test.js +148 -0
  6. package/bin/grill-model-docs.test.js +155 -0
  7. package/bin/grilling-holistic-review-docs.test.js +7 -3
  8. package/bin/lib/codex-projection.js +22 -13
  9. package/bin/lib/codex-projection.test.js +58 -17
  10. package/bin/lib/host-descriptor.js +1 -1
  11. package/bin/lib/host-descriptor.test.js +6 -0
  12. package/bin/report-status-docs.test.js +10 -2
  13. package/bin/session-trust-docs.test.js +37 -0
  14. package/bin/surfaced-scope-docs.test.js +57 -0
  15. package/bin/trailhead.js +7 -5
  16. package/bin/trailhead.test.js +53 -11
  17. package/bin/unblock-docs.test.js +56 -0
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  21. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  22. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  23. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  24. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  25. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  26. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  27. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  28. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  29. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  30. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  31. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  32. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  33. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  34. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  35. package/plugins/trailhead/hooks/mod/README.md +16 -7
  36. package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
  37. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  38. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  40. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  41. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  42. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  43. package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
  44. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  45. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  46. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  47. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  48. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  49. package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
  50. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  51. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  52. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  53. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  54. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  55. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  56. package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
  57. package/plugins/trailhead/skills/_shared/techniques.md +6 -4
  58. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  59. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  60. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  61. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  62. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  63. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  64. package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
  65. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
  67. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  68. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  69. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  70. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  71. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  72. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  73. package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
  74. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  75. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  76. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-capture.js (#332). Run: node capture.test.js
3
+ // No framework: plain asserts, same style as needs-you.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. Seams: parseArgs, buildIssue, parseAllowlist, checkTrust, resolveDestination,
7
+ // findDuplicate.
8
+ // 2. main() against a fake tracker behind an injected `gh` and `sleep`.
9
+ // 3. End-to-end: the script itself with a fake `gh` on PATH.
10
+ const assert = require('assert');
11
+ const { spawnSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-capture.js');
17
+ const guardYml = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-label-guard.yml');
18
+ const cap = require(scriptPath);
19
+ const { main, parseArgs, buildIssue, parseAllowlist, checkTrust, resolveDestination, findDuplicate } = cap;
20
+
21
+ let passed = 0;
22
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
23
+
24
+ const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
25
+ const TYPE_LABELS = ['decision', 'research', 'prototype', 'build', 'bug', 'task'].map((t) => `trailhead:${t}`);
26
+ const URL_BASE = 'https://github.com/o/r/issues';
27
+
28
+ // --- fake tracker ------------------------------------------------------------
29
+ // issues: number -> { state, title, labels:[names] } for pre-existing issues (maps, origins).
30
+ // Options: login (null = unreadable identity), perms {login: permission}, allowlist (undefined =
31
+ // unreadable variable), labeller (actor of the labeled events), eventsFail, dropLabels (labels the
32
+ // server loses), eventsLag (count of leading event reads that return an empty list), eventsPartial
33
+ // (count of leading reads that show only the first label), lateLabeller (actor of the events
34
+ // after the partial reads, default labeller), edgeFail (count of failing edge POSTs), dashFail,
35
+ // searchCap, open [{number,body}].
36
+ function makeTracker(init = {}) {
37
+ const t = {
38
+ login: 'maint', perms: { maint: 'admin' }, allowlist: undefined, labeller: 'maint', eventsFail: false, eventsLag: 0, eventsPartial: 0, lateLabeller: null, eventReads: 0,
39
+ dropLabels: [], edgeFail: 0, dashFail: false, searchCap: false, open: [], permFail: false,
40
+ issues: { 5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] }, 6: { state: 'CLOSED', title: 'Map six', labels: ['trailhead:map'] } },
41
+ ...init, calls: [], created: [], edges: [], inputs: [], next: 100,
42
+ };
43
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
44
+ const j = (o) => res(JSON.stringify(o));
45
+ t.gh = (args, input) => {
46
+ const line = args.join(' ');
47
+ t.calls.push(line);
48
+ const [a, b] = args;
49
+ if (a === 'label' && b === 'create') return res('');
50
+ if (line.startsWith('api user')) return t.login === null ? res('', 1) : res(t.login + '\n');
51
+ let m = /^api repos\/o\/r\/collaborators\/([^/]+)\/permission/.exec(line);
52
+ if (m) return t.permFail ? res('', 1) : j({ permission: t.perms[m[1]] || 'none', role_name: t.perms[m[1]] || 'none' });
53
+ if (/^api repos\/o\/r\/actions\/variables\/TRAILHEAD_LABEL_ALLOWLIST/.test(line)) return t.allowlist === undefined ? res('', 1) : res(t.allowlist + '\n');
54
+ if (a === 'issue' && b === 'view') {
55
+ const n = Number(args[2]);
56
+ const pre = t.issues[n];
57
+ const made = t.created.find((c) => c.number === n);
58
+ if (made) return j({ labels: made.labels.filter((l) => !t.dropLabels.includes(l)).map((name) => ({ name })) });
59
+ if (!pre) return res('', 1);
60
+ return j({ state: pre.state, title: pre.title, url: `${URL_BASE}/${n}`, labels: pre.labels.map((name) => ({ name })) });
61
+ }
62
+ if (a === 'issue' && b === 'list' && args.includes('--search')) {
63
+ const items = t.searchCap ? Array.from({ length: 1000 }, (_, i) => ({ number: 9000 + i, url: `${URL_BASE}/${9000 + i}`, body: 'x' })) : t.open.map((o) => ({ number: o.number, url: `${URL_BASE}/${o.number}`, body: o.body }));
64
+ return j(items);
65
+ }
66
+ if (a === 'issue' && b === 'list') return t.dashFail ? res('', 1) : res('[]');
67
+ if (/^api --paginate repos\/o\/r\/issues\?state=open/.test(line)) {
68
+ return res(t.open.map((o) => JSON.stringify({ number: o.number, html_url: `${URL_BASE}/${o.number}`, body: o.body, pr: !!o.pr })).join('\n') + '\n');
69
+ }
70
+ m = /^api --paginate repos\/o\/r\/issues\/(\d+)\/events/.exec(line);
71
+ if (m) {
72
+ const read = t.eventReads++;
73
+ if (t.eventsFail) return res('', 1);
74
+ const made = t.created.find((c) => c.number === Number(m[1]));
75
+ if (read < t.eventsLag) return res('');
76
+ if (read < t.eventsLag + t.eventsPartial) return res(JSON.stringify({ label: made.labels[0], actor: t.labeller }) + '\n');
77
+ const late = t.lateLabeller || t.labeller;
78
+ return res(made.labels.map((l, i) => JSON.stringify({ label: l, actor: i === 0 ? t.labeller : late })).join('\n') + '\n');
79
+ }
80
+ if (a === 'issue' && b === 'create') {
81
+ const n = t.next++;
82
+ const labels = args[args.indexOf('--label') + 1].split(',');
83
+ t.created.push({ number: n, title: args[args.indexOf('--title') + 1], labels, body: input });
84
+ t.inputs.push(input);
85
+ return res(`${URL_BASE}/${n}\n`);
86
+ }
87
+ m = /^api repos\/o\/r\/issues\/(\d+) --jq \.id/.exec(line);
88
+ if (m) return res(String(9000 + Number(m[1])) + '\n');
89
+ m = /^api --method POST repos\/o\/r\/issues\/(\d+)\/sub_issues/.exec(line);
90
+ if (m) {
91
+ if (t.edgeFail > 0) { t.edgeFail--; return res('', 1); }
92
+ t.edges.push({ map: Number(m[1]), sub: line.split('sub_issue_id=')[1] });
93
+ return res('{}');
94
+ }
95
+ return res('', 1);
96
+ };
97
+ return t;
98
+ }
99
+
100
+ function run(t, argv, extra = {}) {
101
+ let out = '';
102
+ let errOut = '';
103
+ const code = main(argv, { gh: t.gh, guard: fakeGuard, env: {}, sleep: () => {}, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
104
+ let json = null;
105
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
106
+ return { code, out, json, errOut };
107
+ }
108
+
109
+ const base = (type, extra = []) => ['--type', type, '--title', 'Add login rate limit', '--body', 'Details here', '--repo', 'o/r', ...extra];
110
+ const creates = (t) => t.calls.filter((c) => c.startsWith('issue create'));
111
+
112
+ // The documented frontier filter, applied to the fake's created issues.
113
+ const onFrontier = (issue, destLabel) => issue.labels.includes('trailhead:ticket') && issue.labels.includes(destLabel)
114
+ && !['trailhead:blocked', 'trailhead:unverified', 'trailhead:paused'].some((l) => issue.labels.includes(l));
115
+
116
+ // --- parseArgs ---------------------------------------------------------------------
117
+ {
118
+ const o = parseArgs(base('bug', ['--map', '5', '--unverified', '--dedupe-key', 'a.b:c-1', '--no-dashboard']));
119
+ ok('parseArgs: full bug', !o.error && o.type === 'bug' && o.map === 5 && o.unverified && o.dedupeKey === 'a.b:c-1' && o.noDashboard && o.repo === 'o/r' && o.body === 'Details here');
120
+ ok('parseArgs: --unverified rejected for idea', /--unverified is only for ticket types/.test(parseArgs(base('idea', ['--unverified'])).error || ''));
121
+ ok('parseArgs: seed with trigger', parseArgs(base('seed', ['--trigger', 'when X'])).trigger === 'when X');
122
+ ok('parseArgs: --of with bug', parseArgs(base('bug', ['--of', '12'])).of === 12);
123
+ ok('parseArgs: --body-file -', parseArgs(['--type', 'todo', '--title', 't', '--body-file', '-', '--repo', 'o/r']).bodyFile === '-');
124
+ }
125
+ for (const [name, argv] of [
126
+ ['no args', []],
127
+ ['unknown type', base('epic')],
128
+ ['missing title', ['--type', 'bug', '--body', 'b', '--repo', 'o/r']],
129
+ ['empty title', ['--type', 'bug', '--title', ' ', '--body', 'b', '--repo', 'o/r']],
130
+ ['missing body', ['--type', 'bug', '--title', 't', '--repo', 'o/r']],
131
+ ['body and body-file', base('bug', ['--body-file', 'x'])],
132
+ ['missing repo', ['--type', 'bug', '--title', 't', '--body', 'b']],
133
+ ['bad repo', ['--type', 'bug', '--title', 't', '--body', 'b', '--repo', 'nope']],
134
+ ['seed without trigger', base('seed')],
135
+ ['trigger on a todo', base('todo', ['--trigger', 'x'])],
136
+ ['--of on a todo', base('todo', ['--of', '3'])],
137
+ ['--of with --map', base('bug', ['--of', '3', '--map', '5'])],
138
+ ['bad map', base('bug', ['--map', 'x'])],
139
+ ['bad dedupe key (space)', base('bug', ['--dedupe-key', 'a b'])],
140
+ ['bad dedupe key (too long)', base('bug', ['--dedupe-key', 'a'.repeat(101)])],
141
+ ['unknown flag', base('bug', ['--bogus', '1'])],
142
+ ['flag without value', ['--type']],
143
+ ]) {
144
+ ok(`parseArgs rejects: ${name}`, !!parseArgs(argv).error);
145
+ }
146
+
147
+ // --- buildIssue (pure) --------------------------------------------------------------
148
+ {
149
+ const map = { number: 5, title: 'Map five', url: `${URL_BASE}/5` };
150
+ const bug = buildIssue({ type: 'bug', title: 'T', body: 'B', map });
151
+ ok('buildIssue bug on a map', bug.labels.join() === 'trailhead:ticket,trailhead:bug,trailhead:map-5' && bug.destination === 'map-5');
152
+ ok('buildIssue map ticket: Parent is the first body line', bug.body.split('\n')[0] === `Parent: [Map five](${URL_BASE}/5)`);
153
+ const todo = buildIssue({ type: 'todo', title: 'T', body: 'B', map: null });
154
+ ok('buildIssue todo on the whiteboard', todo.labels.join() === 'trailhead:ticket,trailhead:build,trailhead:whiteboard' && todo.destination === 'whiteboard' && !/Parent:/.test(todo.body));
155
+ const seed = buildIssue({ type: 'seed', title: 'T', body: 'B', map: null, trigger: 'when X ships' });
156
+ ok('buildIssue seed labels', ['trailhead:ticket', 'trailhead:build', 'trailhead:seed', 'trailhead:blocked', 'trailhead:whiteboard'].every((l) => seed.labels.includes(l)));
157
+ ok('buildIssue seed body: Trigger line and Blocked by section', /^Trigger: when X ships$/m.test(seed.body) && /## Blocked by\n[^]*when X ships/.test(seed.body));
158
+ for (const [type, extra] of [['bug', {}], ['todo', {}], ['seed', { trigger: 'x' }]]) {
159
+ const b = buildIssue({ type, title: 'T', body: 'B', map: null, unverified: true, ...extra });
160
+ ok(`buildIssue ${type}: exactly one type label (state labels never count)`, b.labels.filter((l) => TYPE_LABELS.includes(l)).length === 1);
161
+ ok(`buildIssue ${type}: --unverified adds the label`, b.labels.includes('trailhead:unverified'));
162
+ }
163
+ const idea = buildIssue({ type: 'idea', title: 'T', body: 'B', map: null });
164
+ ok('buildIssue idea: only trailhead:fog, no Parent', idea.labels.join() === 'trailhead:fog' && !/Parent:/.test(idea.body));
165
+ const ideaMap = buildIssue({ type: 'idea', title: 'T', body: 'B', map });
166
+ ok('buildIssue idea with a map: Parent line, still only fog', ideaMap.labels.join() === 'trailhead:fog' && ideaMap.body.startsWith(`Parent: [Map five](${URL_BASE}/5)`));
167
+ const reg = buildIssue({ type: 'bug', title: 'T', body: 'B', map: null, of: { title: 'Old work', url: `${URL_BASE}/9` } });
168
+ ok('buildIssue --of: Regression of line', reg.body.includes(`Regression of: [Old work](${URL_BASE}/9)`));
169
+ const dd = buildIssue({ type: 'todo', title: 'T', body: 'B', map: null, dedupeKey: 'k1' });
170
+ ok('buildIssue dedupe marker', dd.body.includes('<!-- trailhead-dedupe: k1 -->'));
171
+ }
172
+
173
+ // --- allowlist parity with the guard Action ------------------------------------------
174
+ {
175
+ const yml = fs.readFileSync(guardYml, 'utf8');
176
+ const m = /const extra = (\(process\.env\.ALLOWLIST[^;]*?\.filter\(Boolean\));/.exec(yml);
177
+ ok('parity: the guard parsing expression is found', !!m);
178
+ const actionParse = (s) => new Function('process', `return ${m[1]};`)({ env: { ALLOWLIST: s } });
179
+ for (const s of ['', 'a,b', ' A , b ,, C', ',,', 'Mixed,CASE , spaced ', undefined]) {
180
+ assert.deepStrictEqual(parseAllowlist(s), actionParse(s));
181
+ passed++;
182
+ }
183
+ }
184
+
185
+ // --- checkTrust -----------------------------------------------------------------------
186
+ {
187
+ const t = makeTracker({ login: 'maint', perms: { maint: 'write' } });
188
+ ok('checkTrust: write -> ok', checkTrust(t.gh, 'o/r').trust === 'ok');
189
+ const tm = makeTracker({ login: 'm', perms: { m: 'maintain' } });
190
+ ok('checkTrust: maintain -> ok', checkTrust(tm.gh, 'o/r').trust === 'ok');
191
+ const ta = makeTracker({ login: 'bot', perms: {}, allowlist: 'Other, BOT' });
192
+ ok('checkTrust: repo allowlist (case-insensitive) -> allowlisted', checkTrust(ta.gh, 'o/r').trust === 'allowlisted');
193
+ const tn = makeTracker({ login: 'rando', perms: { rando: 'read' }, allowlist: 'someone' });
194
+ ok('checkTrust: neither -> insufficient', checkTrust(tn.gh, 'o/r').trust === 'insufficient');
195
+ const tu = makeTracker({ login: 'rando', perms: {}, allowlist: undefined });
196
+ ok('checkTrust: unreadable variable counts as not allowlisted', checkTrust(tu.gh, 'o/r').trust === 'insufficient');
197
+ const tp = makeTracker({ login: 'maint', permFail: true });
198
+ ok('checkTrust: failed permission read counts as none', checkTrust(tp.gh, 'o/r').trust === 'insufficient');
199
+ const ti = makeTracker({ login: null });
200
+ ok('checkTrust: unreadable identity -> pending', checkTrust(ti.gh, 'o/r').trust === 'pending');
201
+ const te = makeTracker({ login: 'rando', perms: {}, allowlist: undefined });
202
+ const r = checkTrust(te.gh, 'o/r', { TRAILHEAD_LABEL_ALLOWLIST: 'rando' });
203
+ ok('checkTrust: the local environment is never consulted', r.trust === 'insufficient');
204
+ }
205
+
206
+ // --- resolveDestination -----------------------------------------------------------------
207
+ {
208
+ const t = makeTracker({ issues: {
209
+ 5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] },
210
+ 6: { state: 'CLOSED', title: 'Map six', labels: ['trailhead:map'] },
211
+ 7: { state: 'OPEN', title: 'Not a map', labels: ['trailhead:ticket'] },
212
+ 10: { state: 'CLOSED', title: 'Origin on 5', labels: ['trailhead:ticket', 'trailhead:map-5'] },
213
+ 11: { state: 'CLOSED', title: 'Origin loose', labels: ['trailhead:ticket', 'trailhead:whiteboard'] },
214
+ 12: { state: 'CLOSED', title: 'Origin on closed', labels: ['trailhead:ticket', 'trailhead:map-6'] },
215
+ 13: { state: 'CLOSED', title: 'Origin bare', labels: ['trailhead:ticket'] },
216
+ 14: { state: 'CLOSED', title: 'Origin both', labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:map-5'] },
217
+ 15: { state: 'CLOSED', title: 'Origin two maps', labels: ['trailhead:ticket', 'trailhead:map-5', 'trailhead:map-6'] },
218
+ } });
219
+ const rd = (o) => resolveDestination(t.gh, 'o/r', o);
220
+ ok('resolveDestination: open map', rd({ map: 5 }).map.number === 5 && rd({ map: 5 }).map.title === 'Map five');
221
+ ok('resolveDestination: closed map is an error', !!rd({ map: 6 }).error);
222
+ ok('resolveDestination: non-map issue is an error', !!rd({ map: 7 }).error);
223
+ ok('resolveDestination: missing map is an error', !!rd({ map: 99 }).error);
224
+ ok('resolveDestination: none -> whiteboard', rd({}).map === null && !rd({}).error);
225
+ const of10 = rd({ of: 10 });
226
+ ok('resolveDestination --of: inherits the open map and returns the origin', of10.map.number === 5 && of10.of.title === 'Origin on 5');
227
+ const of11 = rd({ of: 11 });
228
+ ok('resolveDestination --of: inherits the whiteboard', of11.map === null && !of11.error && of11.of.title === 'Origin loose');
229
+ for (const n of [12, 13, 14, 15, 99]) {
230
+ ok(`resolveDestination --of ${n}: unresolved`, /origin destination unresolved/.test(rd({ of: n }).error || ''));
231
+ }
232
+ }
233
+
234
+ // --- findDuplicate ----------------------------------------------------------------------
235
+ {
236
+ const t = makeTracker({ open: [{ number: 3, body: 'x\n<!-- trailhead-dedupe: k1 -->' }, { number: 4, body: 'mentions trailhead-dedupe: k1 only in prose' }] });
237
+ const hit = findDuplicate(t.gh, 'o/r', 'k1');
238
+ ok('findDuplicate: exact marker hit', hit.issue === 3 && hit.url.endsWith('/3'));
239
+ ok('findDuplicate: prose mention is not a hit', findDuplicate(t.gh, 'o/r', 'k2') === null);
240
+ ok('findDuplicate: search uses the quoted phrase and the cap', t.calls[0].includes('"trailhead-dedupe: k1" in:body') && t.calls[0].includes('--limit 1000'));
241
+ const tc = makeTracker({ searchCap: true, open: [{ number: 40, body: 'a <!-- trailhead-dedupe: k9 --> b' }, { number: 41, body: 'pr', pr: true }] });
242
+ const capHit = findDuplicate(tc.gh, 'o/r', 'k9');
243
+ ok('findDuplicate: a capped search falls back to the full paginated scan', capHit && capHit.issue === 40 && tc.calls.some((c) => c.startsWith('api --paginate repos/o/r/issues?state=open')));
244
+ const tf = makeTracker({ searchCap: true, open: [{ number: 41, body: '<!-- trailhead-dedupe: k9 -->', pr: true }] });
245
+ ok('findDuplicate: pull requests are skipped in the scan', findDuplicate(tf.gh, 'o/r', 'k9') === null);
246
+ }
247
+
248
+ // --- main: type x destination ---------------------------------------------------------
249
+ for (const type of ['bug', 'todo', 'seed']) {
250
+ const extra = type === 'seed' ? ['--trigger', 'when X ships'] : [];
251
+ const tm = makeTracker();
252
+ const rm = run(tm, base(type, ['--map', '5', ...extra]));
253
+ const made = tm.created[0];
254
+ ok(`${type} on a map: created, exit 0, one JSON line`, rm.code === 0 && rm.json.status === 'created' && rm.out.trim().split('\n').length === 1);
255
+ ok(`${type} on a map: output fields`, rm.json.issue === 100 && rm.json.url.endsWith('/100') && rm.json.type === type && rm.json.destination === 'map-5' && rm.json.trust === 'ok' && rm.json.edge === 'ok' && rm.json.dashboard === 'not-applicable' && Array.isArray(rm.json.warnings));
256
+ ok(`${type} on a map: labels ride in the single create call`, creates(tm).length === 1 && made.labels.includes('trailhead:map-5') && made.labels.includes('trailhead:ticket'));
257
+ ok(`${type} on a map: Parent first line, body on stdin`, made.body.split('\n')[0] === `Parent: [Map five](${URL_BASE}/5)` && tm.calls[tm.calls.findIndex((c) => c.startsWith('issue create'))].includes('--body-file -'));
258
+ ok(`${type} on a map: native edge`, tm.edges.length === 1 && tm.edges[0].map === 5 && tm.edges[0].sub === '9100');
259
+ ok(`${type} on a map: no dashboard call`, !tm.calls.some((c) => c.includes('trailhead:dashboard')));
260
+ const onF = onFrontier(made, 'trailhead:map-5');
261
+ ok(`${type} on a map: frontier membership`, type === 'seed' ? (made.labels.includes('trailhead:map-5') && !onF) : onF);
262
+
263
+ const tw = makeTracker();
264
+ const dashCalls = [];
265
+ const rw = run(tw, base(type, extra), { dashboard: { main: (argv) => { dashCalls.push(argv); return 0; } } });
266
+ const madeW = tw.created[0];
267
+ ok(`${type} on the whiteboard: created, whiteboard label, no Parent, no edge`, rw.json.status === 'created' && rw.json.destination === 'whiteboard' && madeW.labels.includes('trailhead:whiteboard') && !/Parent:/.test(madeW.body) && tw.edges.length === 0 && !('edge' in rw.json));
268
+ ok(`${type} on the whiteboard: label ensured before create`, tw.calls.findIndex((c) => c.startsWith('label create trailhead:whiteboard')) >= 0 && tw.calls.findIndex((c) => c.startsWith('label create trailhead:whiteboard')) < tw.calls.findIndex((c) => c.startsWith('issue create')));
269
+ ok(`${type} on the whiteboard: dashboard refreshed update-only`, rw.json.dashboard === 'refreshed' && dashCalls.length === 1 && dashCalls[0].join(' ') === '--repo o/r --update-only');
270
+ const onW = onFrontier(madeW, 'trailhead:whiteboard');
271
+ ok(`${type} on the whiteboard: frontier membership`, type === 'seed' ? (madeW.labels.includes('trailhead:whiteboard') && !onW) : onW);
272
+ }
273
+ {
274
+ const t = makeTracker();
275
+ const r = run(t, base('todo', ['--unverified']));
276
+ ok('--unverified is held off the frontier', t.created[0].labels.includes('trailhead:unverified') && !onFrontier(t.created[0], 'trailhead:whiteboard') && r.json.status === 'created');
277
+ }
278
+ {
279
+ // The real dashboard helper in-process through the fake tracker: no dashboard issue -> skipped, exit 0.
280
+ const t = makeTracker();
281
+ const r = run(t, base('bug'));
282
+ ok('whiteboard capture runs the real dashboard helper update-only', r.json.dashboard === 'refreshed' && t.calls.some((c) => c.includes('--label trailhead:dashboard')) && !t.calls.some((c) => c.startsWith('issue create') && c.includes('trailhead:dashboard')));
283
+ const tn = makeTracker();
284
+ ok('--no-dashboard skips the refresh', run(tn, base('bug', ['--no-dashboard'])).json.dashboard === 'skipped' && !tn.calls.some((c) => c.includes('--label trailhead:dashboard')));
285
+ const tf = makeTracker({ dashFail: true });
286
+ const rf = run(tf, base('bug'));
287
+ ok('dashboard failure keeps the status, flags it, warns', rf.code === 0 && rf.json.status === 'created' && rf.json.dashboard === 'failed' && rf.json.warnings.length === 1);
288
+ }
289
+
290
+ // --- main: idea as fog -----------------------------------------------------------------
291
+ {
292
+ const t = makeTracker();
293
+ const r = run(t, base('idea'));
294
+ ok('idea: created as trailhead:fog only', r.code === 0 && r.json.status === 'created' && t.created[0].labels.join() === 'trailhead:fog' && r.json.labels.join() === 'trailhead:fog');
295
+ ok('idea: no edge, no dashboard, no map label', t.edges.length === 0 && r.json.dashboard === 'not-applicable' && !t.calls.some((c) => c.includes('trailhead:dashboard')) && !('edge' in r.json));
296
+ const tm = makeTracker();
297
+ const rm = run(tm, base('idea', ['--map', '5']));
298
+ ok('idea with a map: Parent line, still fog only, no edge', rm.json.status === 'created' && tm.created[0].body.startsWith('Parent: [Map five]') && tm.created[0].labels.join() === 'trailhead:fog' && tm.edges.length === 0 && rm.json.destination === 'map-5');
299
+ const tc = makeTracker();
300
+ ok('idea with a closed map is rejected, nothing created', run(tc, base('idea', ['--map', '6'])).code === 1 && creates(tc).length === 0);
301
+ }
302
+
303
+ // --- main: trust --------------------------------------------------------------------------
304
+ {
305
+ const t = makeTracker({ login: 'rando', perms: { rando: 'read' }, allowlist: 'x' });
306
+ const r = run(t, base('bug', ['--map', '5']));
307
+ ok('insufficient trust: exit 1, trust insufficient, no create call', r.code === 1 && r.json.status === 'error' && r.json.trust === 'insufficient' && creates(t).length === 0);
308
+ const ta = makeTracker({ login: 'bot', perms: {}, allowlist: 'bot' });
309
+ ok('allowlisted identity: created with trust allowlisted', run(ta, base('todo')).json.trust === 'allowlisted');
310
+ }
311
+ {
312
+ // Unreadable identity: decided from the labeled events after create.
313
+ const cases = [
314
+ ['github-actions[bot]', {}, 'actions-token', 0],
315
+ ['appbot', { allowlist: 'AppBot' }, 'allowlisted', 0],
316
+ ['writer', { perms: { writer: 'write' } }, 'ok', 0],
317
+ ['stranger', { perms: { stranger: 'read' }, allowlist: 'x' }, null, 1],
318
+ ];
319
+ for (const [labeller, extra, trust, code] of cases) {
320
+ const t = makeTracker({ login: null, labeller, ...extra });
321
+ const r = run(t, base('bug', ['--map', '5']));
322
+ if (code === 0) {
323
+ ok(`pending trust, labelled by ${labeller}: created as ${trust}`, r.code === 0 && r.json.status === 'created' && r.json.trust === trust && t.edges.length === 1);
324
+ } else {
325
+ ok(`pending trust, labelled by ${labeller}: error, no edge`, r.code === 1 && r.json.status === 'error' && r.json.trust === 'insufficient' && r.json.issue === 100 && /will be stripped/.test(r.json.reason) && t.edges.length === 0);
326
+ }
327
+ }
328
+ const te = makeTracker({ login: null, eventsFail: true });
329
+ const re = run(te, base('bug', ['--map', '5']));
330
+ ok('pending trust, events unreadable: error, no edge', re.code === 1 && re.json.trust === 'insufficient' && re.json.issue === 100 && /could not be read/.test(re.json.reason) && !/stripped/.test(re.json.reason) && te.edges.length === 0);
331
+ ok('events unreadable: retried on the backoff schedule', te.eventReads === 4);
332
+ const naps = [];
333
+ const tl = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsLag: 2 });
334
+ const rl = run(tl, base('bug', ['--map', '5']), { sleep: (ms) => naps.push(ms) });
335
+ ok('pending trust, events empty at first: created as actions-token after naps', rl.code === 0 && rl.json.status === 'created' && rl.json.trust === 'actions-token' && tl.edges.length === 1 && naps.slice(0, 2).join() === '1000,2000');
336
+ const tp = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsPartial: 2 });
337
+ const rp = run(tp, base('bug', ['--map', '5']));
338
+ ok('pending trust, partial events: created only once every label is visible', rp.code === 0 && rp.json.trust === 'actions-token' && tp.eventReads === 3);
339
+ const tn = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsLag: 99 });
340
+ const rn = run(tn, base('bug', ['--map', '5']));
341
+ ok('pending trust, labels never visible: distinct reason naming labels, issue kept, no edge', rn.code === 1 && rn.json.trust === 'insufficient' && rn.json.issue === 100 && /no visible labeled event/.test(rn.json.reason) && /trailhead:bug/.test(rn.json.reason) && !/stripped/.test(rn.json.reason) && tn.edges.length === 0 && tn.eventReads === 4);
342
+ const tb = makeTracker({ login: null, labeller: 'stranger', perms: { stranger: 'read' }, allowlist: 'x' });
343
+ const rb = run(tb, base('bug', ['--map', '5']));
344
+ ok('bad actor fails at once, without retries', rb.code === 1 && rb.json.trust === 'insufficient' && /stranger will be stripped/.test(rb.json.reason) && tb.edges.length === 0 && tb.eventReads === 1);
345
+ const tlb = makeTracker({ login: null, labeller: 'github-actions[bot]', lateLabeller: 'stranger', perms: { stranger: 'read' }, allowlist: 'x', eventsPartial: 1 });
346
+ const rlb = run(tlb, base('bug', ['--map', '5']));
347
+ ok('bad actor on a later read fails, though an earlier read was trusted', rlb.code === 1 && rlb.json.trust === 'insufficient' && /stranger will be stripped/.test(rlb.json.reason) && tlb.edges.length === 0 && tlb.eventReads === 2);
348
+ const empty = cap.verifyActors(() => ({ status: 0, stdout: '', stderr: '' }), 'o/r', 100, [], () => {});
349
+ ok('no expected trailhead:* label: fails closed, never trusts an empty events list', !empty.trust && /no trailhead:\* label to verify/.test(empty.error));
350
+ }
351
+
352
+ // --- main: read-back, dedupe, edge, --of, secrets ------------------------------------------
353
+ {
354
+ const t = makeTracker({ dropLabels: ['trailhead:map-5'] });
355
+ const r = run(t, base('bug', ['--map', '5']));
356
+ ok('labels missing at read-back: exit 1, issue named, no edge', r.code === 1 && r.json.status === 'error' && /labels missing/.test(r.json.reason) && r.json.issue === 100 && t.edges.length === 0);
357
+ }
358
+ {
359
+ const t = makeTracker({ open: [{ number: 3, body: '<!-- trailhead-dedupe: nightly-1 -->' }] });
360
+ const r = run(t, base('bug', ['--dedupe-key', 'nightly-1']));
361
+ ok('dedupe hit: existing, exit 0, no create call', r.code === 0 && r.json.status === 'existing' && r.json.issue === 3 && r.json.dedupeKey === 'nightly-1' && creates(t).length === 0);
362
+ const r2 = run(t, base('bug', ['--dedupe-key', 'nightly-2']));
363
+ ok('dedupe miss: created, marker in the body', r2.json.status === 'created' && t.created[0].body.includes('<!-- trailhead-dedupe: nightly-2 -->'));
364
+ }
365
+ {
366
+ const t1 = makeTracker({ edgeFail: 1 });
367
+ const r1 = run(t1, base('bug', ['--map', '5']));
368
+ ok('edge fails once then succeeds: created', r1.code === 0 && r1.json.status === 'created' && r1.json.edge === 'ok' && t1.edges.length === 1);
369
+ const t2 = makeTracker({ edgeFail: 2 });
370
+ const r2 = run(t2, base('bug', ['--map', '5']));
371
+ ok('edge fails twice: partial, exit 3, names prune', r2.code === 3 && r2.json.status === 'partial' && r2.json.edge === 'failed' && r2.json.issue === 100 && r2.json.warnings.some((w) => /trailhead prune command/.test(w)) && t2.edges.length === 0);
372
+ }
373
+ {
374
+ const t = makeTracker({ issues: { 5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] }, 10: { state: 'CLOSED', title: 'Origin on 5', labels: ['trailhead:map-5'] }, 12: { state: 'CLOSED', title: 'Origin closed map', labels: ['trailhead:map-6'] }, 6: { state: 'CLOSED', title: 'm6', labels: ['trailhead:map'] } } });
375
+ const r = run(t, base('bug', ['--of', '10']));
376
+ ok('--of inherits the map: label, Parent, Regression of, edge', r.json.destination === 'map-5' && t.created[0].labels.includes('trailhead:map-5') && t.created[0].body.includes('Regression of: [Origin on 5]') && t.edges.length === 1);
377
+ const tu = makeTracker({ issues: { 12: { state: 'CLOSED', title: 'x', labels: ['trailhead:map-6'] }, 6: { state: 'CLOSED', title: 'm6', labels: ['trailhead:map'] } } });
378
+ const ru = run(tu, base('bug', ['--of', '12']));
379
+ ok('--of unresolved: exit 1, nothing created', ru.code === 1 && /origin destination unresolved/.test(ru.json.reason) && creates(tu).length === 0);
380
+ }
381
+ {
382
+ const t = makeTracker();
383
+ const r = run(t, ['--type', 'todo', '--title', 'T', '--body', 'token ghp_abcdefghijklmnopqrstuvwxyz0123', '--repo', 'o/r']);
384
+ ok('secret in the body: exit 1, no create', r.code === 1 && /secret/.test(r.json.reason) && creates(t).length === 0);
385
+ const ts = makeTracker();
386
+ ok('secret in the title: exit 1, no create', run(ts, ['--type', 'todo', '--title', 'ghp_abcdefghijklmnopqrstuvwxyz0123', '--body', 'b', '--repo', 'o/r']).code === 1 && creates(ts).length === 0);
387
+ const tmx = makeTracker({ issues: { 5: { state: 'OPEN', title: 'Map ghp_abcdefghijklmnopqrstuvwxyz0123', labels: ['trailhead:map'] } } });
388
+ ok('secret in a map title (Parent line): exit 1, no create', run(tmx, base('bug', ['--map', '5'])).code === 1 && creates(tmx).length === 0);
389
+ const tg = makeTracker();
390
+ const rg = run(tg, base('bug'), { guard: null });
391
+ ok('guard missing fails closed', rg.code === 1 && rg.json.status === 'error' && creates(tg).length === 0);
392
+ const tb = makeTracker();
393
+ ok('closed map: exit 1, no create', run(tb, base('bug', ['--map', '6'])).code === 1 && creates(tb).length === 0);
394
+ const tbf = makeTracker({ login: 'x', perms: { x: 'admin' } });
395
+ const rf = run(tbf, ['--type', 'todo', '--title', 'T', '--body-file', '-', '--repo', 'o/r'], { stdin: 'body from stdin' });
396
+ ok('--body-file - reads stdin', rf.json.status === 'created' && tbf.created[0].body.includes('body from stdin'));
397
+ }
398
+
399
+ // --- usage ----------------------------------------------------------------------------------
400
+ for (const argv of [[], ['--type', 'epic'], base('seed'), base('todo', ['--of', '3'])]) {
401
+ const t = makeTracker();
402
+ const r = run(t, argv);
403
+ ok(`usage error exit 2 with a usage JSON line: ${argv.join(' ')}`, r.code === 2 && r.json.status === 'usage' && r.json.reason.length > 0 && r.errOut.length > 0 && t.calls.length === 0);
404
+ }
405
+
406
+ // --- end-to-end with a fake gh on PATH ---------------------------------------------------------
407
+ {
408
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'capture-'));
409
+ const binDir = path.join(tmp, 'bin');
410
+ fs.mkdirSync(binDir);
411
+ const log = path.join(tmp, 'calls.log');
412
+ const script = `#!/bin/sh
413
+ echo "$*" >> "${log}"
414
+ case "$*" in
415
+ "api user"*) echo maint ;;
416
+ "api repos/o/r/collaborators/maint/permission"*) echo '{"permission":"admin","role_name":"admin"}' ;;
417
+ "issue view 5 "*) echo '{"state":"OPEN","title":"Map five","url":"https://github.com/o/r/issues/5","labels":[{"name":"trailhead:map"}]}' ;;
418
+ "issue view 100 "*) echo '{"labels":[{"name":"trailhead:ticket"},{"name":"trailhead:bug"},{"name":"trailhead:map-5"}]}' ;;
419
+ "issue create"*) cat > "${path.join(tmp, 'body.txt')}"; echo https://github.com/o/r/issues/100 ;;
420
+ "api repos/o/r/issues/100 --jq .id") echo 9100 ;;
421
+ *) exit 0 ;;
422
+ esac
423
+ `;
424
+ fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
425
+ const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
426
+ const res = spawnSync('node', [scriptPath, ...base('bug', ['--map', '5'])], { encoding: 'utf8', env });
427
+ const j = JSON.parse(res.stdout.trim());
428
+ ok('e2e: prints one created JSON line, exit 0', res.status === 0 && j.status === 'created' && j.issue === 100 && j.edge === 'ok');
429
+ const calls = fs.readFileSync(log, 'utf8');
430
+ ok('e2e: create before read-back before edge', calls.indexOf('issue create') < calls.indexOf('issue view 100') && calls.indexOf('issue view 100') < calls.indexOf('sub_issues'));
431
+ ok('e2e: body reached gh on stdin with the Parent line', fs.readFileSync(path.join(tmp, 'body.txt'), 'utf8').startsWith('Parent: [Map five]'));
432
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
433
+ const bad = spawnSync('node', [scriptPath, ...base('bug', ['--map', '5'])], { encoding: 'utf8', env });
434
+ ok('e2e: gh failing exits 1 with an error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
435
+ ok('e2e: usage error exits 2', spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
436
+ fs.rmSync(tmp, { recursive: true, force: true });
437
+ }
438
+
439
+ console.log(`capture.test.js: ${passed} checks passed`);