@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-merge-ticket.js (#287). Run: node merge-ticket.test.js
3
+ // No framework: plain asserts. A fake tracker sits behind an injected `gh` runner;
4
+ // one end-to-end run uses a fake `gh` on PATH.
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-merge-ticket.js');
12
+ const { main, decodeMarker, encodeMarker } = require(scriptPath);
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
18
+ const EM_DASH = String.fromCharCode(0x2014);
19
+
20
+ // Fake tracker: open issues with labels, ticket titles, edit history, GraphQL pages.
21
+ function makeTracker(init = {}) {
22
+ const t = {
23
+ issues: init.issues || [], // { number, title, body, url, labels, updatedAt, edits }
24
+ titles: init.titles || { 5: 'Ticket five', 7: 'Ticket seven', 9: 'Ticket nine', 40: 'The map' },
25
+ next: init.next || 100,
26
+ calls: [], created: [], edits: [], edges: [],
27
+ listFail: !!init.listFail, listRaw: init.listRaw, listCalls: 0, pageSize: init.pageSize || 100,
28
+ respond: init.respond, edgeFail: !!init.edgeFail, secondListSeesTwo: !!init.secondListSeesTwo,
29
+ onEdit: init.onEdit || null, onView: init.onView || null, views: 0, writeDelta: init.writeDelta === undefined ? 1 : init.writeDelta,
30
+ garble: !!init.garble,
31
+ };
32
+ const find = (n) => t.issues.find((i) => i.number === n);
33
+ t.add = (issue) => { t.issues.push({ url: `https://github.com/o/r/issues/${issue.number}`, updatedAt: 'T1', edits: 3, labels: [], ...issue }); };
34
+ t.touch = (n, body) => { const i = find(n); i.body = body; i.edits += 1; i.updatedAt = 'T' + (Number(i.updatedAt.slice(1)) + 1); };
35
+ t.gh = (args) => {
36
+ const line = args.join(' ');
37
+ t.calls.push(line);
38
+ const sub2 = args.slice(0, 2).join(' ');
39
+ if (sub2 === 'api graphql') {
40
+ const q = args.find((a) => a.startsWith('query=')) || '';
41
+ if (q.includes('userContentEdits')) {
42
+ const n = Number(args[args.indexOf('-F') + 1].split('=')[1]);
43
+ return { status: 0, stdout: JSON.stringify({ data: { repository: { issue: { userContentEdits: { totalCount: find(n).edits } } } } }), stderr: '' };
44
+ }
45
+ t.listCalls += 1;
46
+ if (t.listFail) return { status: 1, stdout: '', stderr: 'forced failure' };
47
+ if (t.listRaw !== undefined) return { status: 0, stdout: t.listRaw, stderr: '' };
48
+ const afterArg = args.find((a) => a.startsWith('after='));
49
+ const page = afterArg ? Number(afterArg.slice(6)) : 0;
50
+ if (t.respond) return { status: 0, stdout: JSON.stringify(t.respond(page)), stderr: '' };
51
+ const label = /labels:\["([^"]+)"\]/.exec(q)[1];
52
+ let rows = t.issues.filter((i) => i.labels.includes(label) && !i.closed);
53
+ if (t.secondListSeesTwo && t.listCalls >= 2 && t.created.length) {
54
+ rows = rows.concat([{ number: 99, title: 'older twin', body: '<!-- trailhead:merge-ticket v1 e30= -->', url: 'https://github.com/o/r/issues/99' }]);
55
+ }
56
+ const slice = rows.slice(page * t.pageSize, (page + 1) * t.pageSize);
57
+ const more = (page + 1) * t.pageSize < rows.length;
58
+ const nodes = slice.map(({ number, title, body, url }) => ({ number, title, body, url }));
59
+ return { status: 0, stdout: JSON.stringify({ data: { repository: { issues: { nodes, pageInfo: { hasNextPage: more, endCursor: more ? String(page + 1) : null } } } } }), stderr: '' };
60
+ }
61
+ if (sub2 === 'issue view') {
62
+ const n = Number(args[2]);
63
+ if (args.includes('title,url')) {
64
+ if (!t.titles[n]) return { status: 1, stdout: '', stderr: 'not found' };
65
+ return { status: 0, stdout: JSON.stringify({ title: t.titles[n], url: `https://github.com/o/r/issues/${n}` }), stderr: '' };
66
+ }
67
+ t.views += 1;
68
+ if (t.onView) t.onView(t, t.views);
69
+ const i = find(n);
70
+ return { status: 0, stdout: JSON.stringify({ body: i.body, updatedAt: i.updatedAt }), stderr: '' };
71
+ }
72
+ if (sub2 === 'issue edit') {
73
+ const n = Number(args[2]);
74
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
75
+ if (t.onEdit) t.onEdit(t);
76
+ const i = find(n);
77
+ i.body = t.garble ? text + '\nextra' : text;
78
+ i.edits += t.writeDelta;
79
+ i.updatedAt = 'T' + (Number(i.updatedAt.slice(1)) + 1);
80
+ t.edits.push({ n, text });
81
+ return { status: 0, stdout: '', stderr: '' };
82
+ }
83
+ if (sub2 === 'label create') return { status: 0, stdout: '', stderr: '' };
84
+ if (sub2 === 'issue create') {
85
+ const n = t.next++;
86
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
87
+ const labels = args.flatMap((a, k) => (args[k - 1] === '--label' ? [a] : []));
88
+ const title = args[args.indexOf('--title') + 1];
89
+ t.created.push({ n, title, text, labels, line });
90
+ t.add({ number: n, title, body: text, labels });
91
+ return { status: 0, stdout: `https://github.com/o/r/issues/${n}\n`, stderr: '' };
92
+ }
93
+ if (args[0] === 'api' && /^repos\/o\/r\/issues\/\d+$/.test(args[1])) {
94
+ return { status: 0, stdout: JSON.stringify({ id: 5000 + Number(args[1].split('/').pop()), number: Number(args[1].split('/').pop()) }), stderr: '' };
95
+ }
96
+ if (args[0] === 'api' && args.includes('--method') && /sub_issues$/.test(args[args.indexOf('--method') + 2])) {
97
+ t.edges.push(line);
98
+ return t.edgeFail ? { status: 1, stdout: '', stderr: 'edge denied' } : { status: 0, stdout: '{}', stderr: '' };
99
+ }
100
+ return { status: 1, stdout: '', stderr: 'unexpected: ' + line };
101
+ };
102
+ return t;
103
+ }
104
+
105
+ function run(t, argv, extra = {}) {
106
+ let out = '';
107
+ let errOut = '';
108
+ const code = main(argv, { gh: t.gh, guard: fakeGuard, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
109
+ let json = null;
110
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
111
+ return { code, out, errOut, json };
112
+ }
113
+
114
+ const add = (ticket, files, over = {}) => {
115
+ const o = { scope: 'map-40', workspace: `/w/t${ticket}`, ...over };
116
+ return ['add', '--scope', o.scope, '--ticket', String(ticket), ...files.flatMap((f) => ['--file', f]), '--workspace', o.workspace, '--repo', 'o/r'];
117
+ };
118
+ const writes = (t) => t.calls.filter((c) => /^issue (edit|create)/.test(c));
119
+ const MAPLABEL = 'trailhead:map-40';
120
+
121
+ // --- usage ---------------------------------------------------------------------
122
+ for (const argv of [
123
+ [], ['bogus'], ['add'],
124
+ add(5, []),
125
+ add(5, ['a.js'], { scope: 'map-0' }), add(5, ['a.js'], { scope: 'map-x' }), add(5, ['a.js'], { scope: 'maps' }),
126
+ ['add', '--scope', 'map-40', '--ticket', '0', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
127
+ ['add', '--scope', 'map-40', '--ticket', 'x', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
128
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'nope'],
129
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--repo', 'o/r'],
130
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w'],
131
+ ['add', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
132
+ ['add', '--scope', 'map-40', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
133
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file'],
134
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'o/r', '--bogus', 'x'],
135
+ ]) {
136
+ const t = makeTracker();
137
+ const r = run(t, argv);
138
+ ok(`usage error exit 2: ${argv.join(' ') || '(none)'}`, r.code === 2 && t.calls.length === 0 && r.errOut.length > 0 && r.out === '');
139
+ }
140
+
141
+ // --- create on a map ------------------------------------------------------------
142
+ {
143
+ const t = makeTracker();
144
+ const r = run(t, add(5, ['src/a.js', 'src/b.js']));
145
+ ok('create: ok, created, one JSON line', r.code === 0 && r.json.status === 'ok' && r.json.action === 'created' && r.json.entries === 1 && r.out.trim().split('\n').length === 1);
146
+ ok('create: scope and issue reported', r.json.scope === 'map-40' && r.json.issue === 100 && r.json.url === 'https://github.com/o/r/issues/100' && r.json.warnings.length === 0);
147
+ const c = t.created[0];
148
+ ok('create: canonical title with the map title', c.title === 'Merge the branches left unmerged: The map');
149
+ ok('create: labels ticket, task, needs-you and the map label', ['trailhead:ticket', 'trailhead:task', 'trailhead:needs-you', MAPLABEL].every((l) => c.labels.includes(l)) && c.labels.length === 4);
150
+ ok('create: Parent line, sections and branch', /^Parent: \[The map\]\(https:\/\/github.com\/o\/r\/issues\/40\)\n/.test(c.text) && c.text.includes('## Question') && c.text.includes('## Unmerged branches') && c.text.includes('## Done when') && c.text.includes('`trailhead/t5`') && c.text.includes('[#5 Ticket five](https://github.com/o/r/issues/5)') && c.text.includes('`/w/t5`') && c.text.includes('`src/a.js`'));
151
+ ok('create: the needs-you label is ensured first', t.calls.findIndex((x) => x.startsWith('label create trailhead:needs-you')) < t.calls.findIndex((x) => x.startsWith('issue create')) && /--color D93F0B/.test(t.calls.find((x) => x.startsWith('label create'))) && /--force/.test(t.calls.find((x) => x.startsWith('label create'))));
152
+ ok('create: sub-issue edge wired with the new id', t.edges.length === 1 && t.edges[0] === 'api --method POST repos/o/r/issues/40/sub_issues -F sub_issue_id=5100');
153
+ const dec = decodeMarker(c.text);
154
+ ok('create: the marker decodes to the entry', !dec.error && dec.entries.length === 1 && dec.entries[0].ticket === 5 && dec.entries[0].branch === 'trailhead/t5' && dec.entries[0].workspace === '/w/t5' && dec.entries[0].files.join('|') === 'src/a.js|src/b.js');
155
+ ok('create: no em-dash in the body', !c.text.includes(EM_DASH));
156
+ ok('create: the find reads OPEN issues with the map label through graphql', t.calls[0].includes('api graphql') && t.calls[0].includes(`labels:["${MAPLABEL}"]`) && t.calls[0].includes('states:[OPEN]') && !t.calls.some((x) => x.startsWith('issue list')));
157
+ }
158
+
159
+ // --- create on the whiteboard ---------------------------------------------------
160
+ {
161
+ const t = makeTracker();
162
+ const r = run(t, add(7, ['x.js'], { scope: 'whiteboard' }));
163
+ const c = t.created[0];
164
+ ok('whiteboard: created with the whiteboard title and label', r.code === 0 && r.json.action === 'created' && r.json.scope === 'whiteboard' && c.title === 'Merge the branches left unmerged: whiteboard' && c.labels.includes('trailhead:whiteboard') && !c.labels.some((l) => l.startsWith('trailhead:map-')));
165
+ ok('whiteboard: no Parent line and no edge', !/^Parent:/m.test(c.text) && t.edges.length === 0 && !t.calls.some((x) => x.includes('sub_issues')));
166
+ }
167
+
168
+ // --- update ----------------------------------------------------------------------
169
+ {
170
+ const t = makeTracker();
171
+ run(t, add(9, ['m.js', 'n.js']));
172
+ const r2 = run(t, add(5, ['n.js', 'k.js']));
173
+ ok('update: a second entry is appended', r2.code === 0 && r2.json.action === 'updated' && r2.json.entries === 2 && r2.json.issue === 100 && t.created.length === 1 && t.edits.length === 1);
174
+ const body = t.edits[0].text;
175
+ ok('update: oldest ticket first', body.indexOf('`trailhead/t5`') < body.indexOf('`trailhead/t9`') && /Suggested merge order: oldest ticket first/.test(body));
176
+ ok('update: worded as a suggestion with a re-check note', /a suggestion, not the order the run followed/.test(body) && /re-check each later one against the trunk/.test(body));
177
+ ok('update: also-touched-by names the other branch and the shared file', /Also touched by: `trailhead\/t9` \(`n\.js`\)/.test(body) && /Also touched by: `trailhead\/t5` \(`n\.js`\)/.test(body));
178
+ ok('update: the Parent line is kept and the marker holds both', body.startsWith('Parent: [The map]') && decodeMarker(body).entries.map((e) => e.ticket).join() === '5,9');
179
+ ok('update: the title is never changed', !t.calls.some((x) => /issue edit .*--title/.test(x)));
180
+ ok('update: the guarded write ran (edit count read)', t.calls.some((x) => x.includes('userContentEdits')));
181
+ // same ticket, same files and workspace -> unchanged
182
+ const before = t.edits.length;
183
+ const r3 = run(t, add(5, ['n.js', 'k.js']));
184
+ ok('unchanged: same ticket, files and workspace writes nothing', r3.code === 0 && r3.json.action === 'unchanged' && r3.json.entries === 2 && t.edits.length === before);
185
+ // same ticket, new files -> updated in place
186
+ const r4 = run(t, add(5, ['n.js', 'z.js']));
187
+ const ents = decodeMarker(t.edits[t.edits.length - 1].text).entries;
188
+ ok('in place: new files replace the entry, no duplicate', r4.json.action === 'updated' && r4.json.entries === 2 && ents.length === 2 && ents.find((e) => e.ticket === 5).files.join() === 'n.js,z.js');
189
+ ok('in place: the ticket is not re-read on a replace', t.calls.filter((x) => x.startsWith('issue view 5 --json title,url')).length === 1);
190
+ }
191
+
192
+ // --- find rules ------------------------------------------------------------------
193
+ {
194
+ const e = (n) => [{ ticket: n, title: `T${n}`, url: `https://github.com/o/r/issues/${n}`, branch: `trailhead/t${n}`, workspace: `/w/${n}`, files: ['f'] }];
195
+ const body = (n) => `Parent: [The map](https://github.com/o/r/issues/40)\n\n## Question\nq\n\n## Unmerged branches\n1. x\n\n## Done when\n- d\n\n${encodeMarker(e(n))}\n`;
196
+ const t = makeTracker({ issues: [
197
+ { number: 60, title: 'plain', body: 'a human note about merging, no marker at all here', labels: [MAPLABEL], url: 'https://github.com/o/r/issues/60' },
198
+ { number: 70, title: 'merge b', body: body(9), labels: [MAPLABEL], url: 'https://github.com/o/r/issues/70' },
199
+ { number: 80, title: 'merge c', body: body(7), labels: [MAPLABEL], url: 'https://github.com/o/r/issues/80' },
200
+ { number: 85, title: 'other map', body: body(7), labels: ['trailhead:map-41'], url: 'https://github.com/o/r/issues/85' },
201
+ ].map((i) => ({ updatedAt: 'T1', edits: 3, ...i })) });
202
+ const r = run(t, add(5, ['q.js']));
203
+ ok('reuse: the lowest-numbered open merge ticket gets the entry', r.code === 0 && r.json.action === 'updated' && r.json.issue === 70 && t.edits.length === 1 && t.edits[0].n === 70 && t.created.length === 0);
204
+ ok('reuse: a second open one is named in a warning, none closed', r.json.warnings.length === 1 && /#70/.test(r.json.warnings[0]) && /#80/.test(r.json.warnings[0]) && !t.calls.some((x) => x.startsWith('issue close')));
205
+ const t2 = makeTracker({ issues: [{ number: 60, title: 'plain', body: 'no marker in this body, only prose that is long enough', labels: [MAPLABEL], url: 'u' }].map((i) => ({ updatedAt: 'T1', edits: 3, ...i })) });
206
+ const r2 = run(t2, add(5, ['q.js']));
207
+ ok('a body without the marker is ignored: a new ticket is created', r2.code === 0 && r2.json.action === 'created' && t2.edits.length === 0);
208
+ }
209
+
210
+ // --- read errors -----------------------------------------------------------------
211
+ {
212
+ const cases = [
213
+ ['gh failure', makeTracker({ listFail: true })],
214
+ ['bad JSON', makeTracker({ listRaw: 'not json' })],
215
+ ['graphql errors', makeTracker({ listRaw: JSON.stringify({ errors: [{ message: 'boom' }], data: null }) })],
216
+ ['no repository', makeTracker({ listRaw: JSON.stringify({ data: { repository: null } }) })],
217
+ ['no nodes', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { pageInfo: { hasNextPage: false, endCursor: null } } } } }) })],
218
+ ['no pageInfo', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { nodes: [] } } } }) })],
219
+ ['cursor missing', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: null } } } } }) })],
220
+ ];
221
+ for (const [name, t] of cases) {
222
+ const r = run(t, add(5, ['a.js']));
223
+ ok(`read error: ${name}: error, nothing written`, r.code === 1 && r.json.status === 'error' && writes(t).length === 0 && !t.calls.some((x) => x.startsWith('label create')));
224
+ }
225
+ const endless = makeTracker({ respond: (p) => ({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: String(p + 1) } } } } }) });
226
+ const cap = run(endless, add(5, ['a.js']));
227
+ ok('page cap exceeded is an error, never none found', cap.code === 1 && /page/.test(cap.json.reason) && endless.listCalls === 100 && writes(endless).length === 0);
228
+ }
229
+ {
230
+ // The merge ticket sits on page two: it is found, not duplicated.
231
+ const issues = [];
232
+ const e = [{ ticket: 9, title: 'T9', url: 'https://github.com/o/r/issues/1', branch: 'trailhead/t9', workspace: '/w', files: ['f'] }];
233
+ for (let i = 1; i <= 3; i++) issues.push({ number: i, title: 'x', body: 'plain body without a marker', labels: [MAPLABEL] });
234
+ issues.push({ number: 4, title: 'merge', body: `intro\n${encodeMarker(e)}\n`, labels: [MAPLABEL] });
235
+ const t = makeTracker({ pageSize: 2, issues: issues.map((i) => ({ updatedAt: 'T1', edits: 3, url: 'u', ...i })) });
236
+ const r = run(t, add(5, ['a.js']));
237
+ ok('pagination: a merge ticket on a later page is found', r.code === 0 && r.json.action === 'updated' && r.json.issue === 4 && t.created.length === 0 && t.listCalls === 2);
238
+ }
239
+
240
+ // --- concurrency ------------------------------------------------------------------
241
+ {
242
+ const seed = () => {
243
+ const t = makeTracker();
244
+ run(t, add(9, ['m.js']));
245
+ t.edits.length = 0;
246
+ return t;
247
+ };
248
+ const other = (n) => ({ ticket: n, title: `T${n}`, url: `https://github.com/o/r/issues/${n}`, branch: `trailhead/t${n}`, workspace: `/w/${n}`, files: ['o.js'] });
249
+ const t = seed();
250
+ const cur = decodeMarker(t.issues.find((i) => i.number === 100).body).entries;
251
+ let first = true;
252
+ t.onView = (tr, n) => { if (first && n === 2) { first = false; tr.touch(100, renderOther(cur, other(7))); } };
253
+ function renderOther(entries, extra) {
254
+ const { renderBody } = require(scriptPath);
255
+ return renderBody([...entries, extra], 'Parent: [The map](https://github.com/o/r/issues/40)');
256
+ }
257
+ const r = run(t, add(5, ['n.js']));
258
+ const ents = decodeMarker(t.edits[0].text).entries.map((e) => e.ticket).join();
259
+ ok('concurrent edit that added another entry: both survive', r.code === 0 && r.json.action === 'updated' && ents === '5,7,9');
260
+ }
261
+ {
262
+ const t = makeTracker();
263
+ run(t, add(9, ['m.js']));
264
+ t.edits.length = 0;
265
+ const b = () => t.issues.find((i) => i.number === 100).body;
266
+ t.onView = (tr, n) => { if (n === 2 || n === 3) tr.touch(100, b() + '\nnote ' + n); };
267
+ const r = run(t, add(5, ['n.js']));
268
+ ok('changed twice while preparing: error, nothing written', r.code === 1 && /changed twice/.test(r.json.reason) && t.edits.length === 0);
269
+ }
270
+ {
271
+ const t = makeTracker();
272
+ run(t, add(9, ['m.js']));
273
+ t.edits.length = 0;
274
+ t.onEdit = (tr) => { tr.issues.find((i) => i.number === 100).edits += 1; };
275
+ const r = run(t, add(5, ['n.js']));
276
+ ok('post-write conflict is an error naming it', r.code === 1 && /conflict/.test(r.json.reason));
277
+ const t2 = makeTracker();
278
+ run(t2, add(9, ['m.js']));
279
+ t2.garble = true;
280
+ const r2 = run(t2, add(5, ['n.js']));
281
+ ok('read-back mismatch is a conflict error', r2.code === 1 && /conflict/.test(r2.json.reason));
282
+ }
283
+
284
+ // --- secrets and guard --------------------------------------------------------------
285
+ {
286
+ const t = makeTracker();
287
+ const r = run(t, add(5, ['ghp_' + 'a'.repeat(30) + '.js']));
288
+ ok('secret hit on create: error, nothing written', r.code === 1 && /secret/.test(r.json.reason) && writes(t).length === 0);
289
+ const t2 = makeTracker();
290
+ const r2 = run(t2, add(5, ['a.js']), { guard: null });
291
+ ok('missing guard on create: fail closed, nothing written', r2.code === 1 && /secret-scan unavailable/.test(r2.json.reason) && writes(t2).length === 0);
292
+ const t3 = makeTracker();
293
+ run(t3, add(9, ['m.js']));
294
+ t3.edits.length = 0;
295
+ const r3 = run(t3, add(5, ['ghp_' + 'b'.repeat(30) + '.js']));
296
+ ok('secret hit on update: error, nothing written', r3.code === 1 && /secret/.test(r3.json.reason) && t3.edits.length === 0);
297
+ const r4 = run(t3, add(5, ['a.js']), { guard: null });
298
+ ok('missing guard on update: fail closed, nothing written', r4.code === 1 && t3.edits.length === 0);
299
+ }
300
+
301
+ // --- edge, twins, marker faults ------------------------------------------------------
302
+ {
303
+ const t = makeTracker({ edgeFail: true });
304
+ const r = run(t, add(5, ['a.js']));
305
+ ok('edge failure is a warning, status ok', r.code === 0 && r.json.status === 'ok' && r.json.action === 'created' && r.json.warnings.length === 1 && /sub-issue edge/.test(r.json.warnings[0]) && /edge denied/.test(r.json.warnings[0]));
306
+ }
307
+ {
308
+ const t = makeTracker({ secondListSeesTwo: true });
309
+ const r = run(t, add(5, ['a.js']));
310
+ ok('post-create find seeing two open: warning names both, nothing closed', r.code === 0 && r.json.warnings.some((w) => /#100/.test(w) && /#99/.test(w)) && !t.calls.some((x) => x.startsWith('issue close')));
311
+ }
312
+ {
313
+ const mk = (marker) => makeTracker({ issues: [{ number: 70, title: 'm', body: `Parent: [x](y)\n\n## Question\nq\n\n${marker}\n`, labels: [MAPLABEL], url: 'u', updatedAt: 'T1', edits: 3 }] });
314
+ for (const [name, marker] of [
315
+ ['undecodable payload', '<!-- trailhead:merge-ticket v1 !!!not-base64-json -->'],
316
+ ['unknown version', '<!-- trailhead:merge-ticket v2 W10= -->'],
317
+ ['payload not an entry list', `<!-- trailhead:merge-ticket v1 ${Buffer.from('{"a":1}').toString('base64')} -->`],
318
+ ['entry missing files', `<!-- trailhead:merge-ticket v1 ${Buffer.from('[{"ticket":1,"title":"t","url":"u","branch":"b","workspace":"w","files":[]}]').toString('base64')} -->`],
319
+ ]) {
320
+ const t = mk(marker);
321
+ const r = run(t, add(5, ['a.js']));
322
+ ok(`marker fault: ${name}: error, nothing written`, r.code === 1 && r.json.status === 'error' && /#70/.test(r.json.reason) && writes(t).length === 0 && t.created.length === 0);
323
+ }
324
+ }
325
+
326
+ // --- round trip and rendering ---------------------------------------------------------
327
+ {
328
+ const t = makeTracker({ titles: { 5: 'Fix `a` --> now\nsecond line <!-- [x]', 7: 'Ticket seven', 9: 'n', 40: 'The map' } });
329
+ const weird = ['dir/a,b.js', 'we`ird`name.js', 'new\nline.js', 'plain.js'];
330
+ const r = run(t, add(5, weird, { workspace: '/w/with `tick`' }));
331
+ const c = t.created[0];
332
+ const ent = decodeMarker(c.text).entries[0];
333
+ ok('round trip: comma paths, backticks, newlines and a --> title survive intact', r.code === 0 && ent.title === 'Fix `a` --> now\nsecond line <!-- [x]' && ent.files.join('|') === weird.join('|') && ent.workspace === '/w/with `tick`');
334
+ const visible = c.text.split('\n').filter((l) => !l.startsWith('<!-- trailhead:merge-ticket'));
335
+ ok('rendered text: no raw newline leaks, no raw <!--, backticks fenced', visible.every((l) => !l.includes('<!--')) && c.text.includes('``we`ird`name.js``') && c.text.includes('[#5 Fix \'a\' --> now second line &lt;!-- (x)]'));
336
+ ok('rendered text: exactly one marker line', c.text.split('\n').filter((l) => l.startsWith('<!-- trailhead:merge-ticket')).length === 1);
337
+ r.json && ok('round trip: a repeat of the same input is unchanged', run(t, add(5, weird, { workspace: '/w/with `tick`' })).json.action === 'unchanged');
338
+ }
339
+ {
340
+ const t = makeTracker();
341
+ run(t, add(9, ['m.js']));
342
+ run(t, add(5, ['n.js']));
343
+ ok('no em-dash in any rendered body', t.edits.every((e) => !e.text.includes(EM_DASH)) && t.created.every((c) => !c.text.includes(EM_DASH)));
344
+ ok('the decoded marker of a rendered body is stable under encode', encodeMarker(decodeMarker(t.edits[0].text).entries) === t.edits[0].text.split('\n').find((l) => l.startsWith('<!-- trailhead:merge-ticket')));
345
+ }
346
+
347
+ // --- end-to-end with a fake gh on PATH --------------------------------------------------
348
+ {
349
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-ticket-'));
350
+ const binDir = path.join(tmp, 'bin');
351
+ fs.mkdirSync(binDir);
352
+ const log = path.join(tmp, 'calls.log');
353
+ const empty = JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } } });
354
+ const script = `#!/bin/sh
355
+ echo "$@" >> '${log}'
356
+ case "$1 $2" in
357
+ "api graphql") cat <<'EOF_JSON'
358
+ ${empty}
359
+ EOF_JSON
360
+ ;;
361
+ "issue view") echo '{"title":"A ticket","url":"https://github.com/o/r/issues/5"}' ;;
362
+ "issue create") echo 'https://github.com/o/r/issues/55' ;;
363
+ "label create") ;;
364
+ *) case "$1" in api) echo '{"id":9955}' ;; esac ;;
365
+ esac
366
+ `;
367
+ fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
368
+ const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
369
+ const res = spawnSync('node', [scriptPath, ...add(5, ['a.js'], { scope: 'whiteboard' })], { encoding: 'utf8', env });
370
+ let j = null;
371
+ try { j = JSON.parse(res.stdout.trim()); } catch { /* reported below */ }
372
+ ok('e2e: creates through gh on PATH', res.status === 0 && j && j.action === 'created' && j.issue === 55);
373
+ ok('e2e: gh issue create was called', fs.readFileSync(log, 'utf8').includes('issue create'));
374
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
375
+ const bad = spawnSync('node', [scriptPath, ...add(5, ['a.js'])], { encoding: 'utf8', env });
376
+ ok('e2e: failing gh exits 1 with a JSON error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
377
+ const use = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
378
+ ok('e2e: usage error exits 2', use.status === 2);
379
+ fs.rmSync(tmp, { recursive: true, force: true });
380
+ }
381
+
382
+ // --- untrusted text in the rendered body (review I1) --------------------------
383
+ {
384
+ const { renderBody } = require(scriptPath);
385
+ const e = { ticket: 7, title: 'Ping @someone here', url: 'https://github.com/o/r/issues/7', branch: 'trailhead/t7', workspace: '/w/r-t7', files: ['a.js'] };
386
+ const body = renderBody([e], '');
387
+ ok('a title @mention is neutralised (no notification)', !/@someone/.test(body) && body.includes('@​someone'));
388
+ const enc = (list) => encodeMarker(list);
389
+ ok('a marker entry whose url is not https is rejected', !!decodeMarker(enc([{ ...e, url: 'javascript:alert(1)' }])).error);
390
+ ok('a marker entry whose url carries a ) or space is rejected', !!decodeMarker(enc([{ ...e, url: 'https://x/y) [z' }])).error);
391
+ ok('a well-formed https url still decodes', !decodeMarker(enc([e])).error);
392
+ }
393
+
394
+ console.log(`merge-ticket.test.js: ${passed} checks passed`);
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-needs-you.js. Run: node needs-you.test.js
3
+ // No framework: plain asserts, same style as uat-followup.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. main() (park, clear) against a fake tracker behind an injected `gh` and guard.
7
+ // 2. End-to-end: the script itself with a fake `gh` on PATH.
8
+ const assert = require('assert');
9
+ const { spawnSync } = require('child_process');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+
14
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-needs-you.js');
15
+ const { main } = require(scriptPath);
16
+
17
+ let passed = 0;
18
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
19
+
20
+ const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
21
+
22
+ // --- fake tracker ------------------------------------------------------------
23
+ // One issue: labels [names], assignees [logins], comments [bodies]. failOn names the
24
+ // gh step that fails: ensure | view | comment | unassign | label | remove.
25
+ function makeTracker(init = {}) {
26
+ const t = { labels: init.labels || [], assignees: init.assignees || [], comments: init.comments || [], failOn: init.failOn || null, badJson: !!init.badJson, calls: [], ensured: false };
27
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
28
+ const step = (name, fn) => (t.failOn === name ? res('', 1) : fn());
29
+ t.gh = (args) => {
30
+ t.calls.push(args.join(' '));
31
+ const [a, b] = args;
32
+ if (a === 'label' && b === 'create') return step('ensure', () => { t.ensured = true; return res(''); });
33
+ if (a === 'issue' && b === 'view') {
34
+ return step('view', () => (t.badJson ? res('not json') : res(JSON.stringify({
35
+ labels: t.labels.map((name) => ({ name })),
36
+ assignees: t.assignees.map((login) => ({ login })),
37
+ comments: t.comments.map((body) => ({ body })),
38
+ }))));
39
+ }
40
+ if (a === 'issue' && b === 'comment') return step('comment', () => { t.comments.push(args[args.indexOf('--body') + 1]); return res(''); });
41
+ if (a === 'issue' && b === 'edit') {
42
+ if (args.includes('--remove-assignee')) return step('unassign', () => { t.assignees = []; return res(''); });
43
+ if (args.includes('--add-label')) return step('label', () => { t.labels.push(args[args.indexOf('--add-label') + 1]); return res(''); });
44
+ if (args.includes('--remove-label')) return step('remove', () => { const l = args[args.indexOf('--remove-label') + 1]; t.labels = t.labels.filter((x) => x !== l); return res(''); });
45
+ }
46
+ return res('', 1);
47
+ };
48
+ return t;
49
+ }
50
+
51
+ function run(t, argv, extra = {}) {
52
+ let out = '';
53
+ let errOut = '';
54
+ const code = main(argv, { gh: t.gh, guard: fakeGuard, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
55
+ let json = null;
56
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
57
+ return { code, out, json, errOut };
58
+ }
59
+
60
+ const park = (reason = 'Decide between A and B') => ['park', '--issue', '7', '--reason', reason, '--repo', 'o/r'];
61
+ const clear = ['clear', '--issue', '7', '--repo', 'o/r'];
62
+ const idx = (t, re) => t.calls.findIndex((c) => re.test(c));
63
+
64
+ // --- park: order and output ----------------------------------------------------
65
+ {
66
+ const t = makeTracker({ assignees: ['me'], labels: ['trailhead:ticket'] });
67
+ const r = run(t, park());
68
+ ok('park ok, one JSON line', r.code === 0 && r.json.status === 'ok' && r.out.trim().split('\n').length === 1);
69
+ ok('park output fields', r.json.issue === 7 && r.json.commented === true && r.json.unassigned === true && r.json.labelled === true);
70
+ ok('comment body is NEEDS YOU, a blank line, then the reason', t.comments[0] === 'NEEDS YOU\n\nDecide between A and B');
71
+ const iE = idx(t, /^label create trailhead:needs-you .*--force/);
72
+ const iV = idx(t, /^issue view 7 --json labels,assignees,comments/);
73
+ const iC = idx(t, /^issue comment 7/);
74
+ const iU = idx(t, /--remove-assignee @me/);
75
+ const iL = idx(t, /--add-label trailhead:needs-you/);
76
+ ok('call order: ensure, view, comment, unassign, label', iE >= 0 && iE < iV && iV < iC && iC < iU && iU < iL);
77
+ ok('label ensured with the color and description', t.calls[iE].includes('--color D93F0B') && t.calls[iE].includes('--description'));
78
+ ok('-R passed on every call', t.calls.every((c) => c.includes('-R o/r')));
79
+ ok('final state: unassigned and labelled', t.assignees.length === 0 && t.labels.includes('trailhead:needs-you'));
80
+ }
81
+
82
+ // --- park: idempotence ---------------------------------------------------------
83
+ {
84
+ const t = makeTracker({ assignees: ['me'] });
85
+ run(t, park());
86
+ const before = t.comments.length;
87
+ const r = run(t, park(' Decide between A\nand B '));
88
+ ok('same reason (whitespace-normalised) posts no second comment', t.comments.length === before && r.json.commented === false);
89
+ ok('re-park on an already-labelled unassigned ticket is ok', r.code === 0 && r.json.unassigned === false && r.json.labelled === false && t.labels.filter((l) => l === 'trailhead:needs-you').length === 1);
90
+ const r2 = run(t, park('A different reason'));
91
+ ok('a different reason posts a new comment', r2.json.commented === true && t.comments.length === before + 1);
92
+ const r3 = run(t, park('Decide between A and B'));
93
+ ok('only the latest NEEDS YOU comment is compared', r3.json.commented === true);
94
+ const t2 = makeTracker({ comments: ['NEEDS YOUR attention\n\nx', 'unrelated'] });
95
+ ok('a non NEEDS YOU comment does not dedup', run(t2, park('x')).json.commented === true);
96
+ }
97
+
98
+ // --- park: guards and failures --------------------------------------------------
99
+ {
100
+ const t = makeTracker({ assignees: ['me'] });
101
+ const r = run(t, park('token ghp_abcdefghijklmnopqrstuvwxyz0123'));
102
+ ok('a secret in the reason writes nothing', r.code === 1 && r.json.status === 'error' && /secret/.test(r.json.reason) && t.calls.length === 0);
103
+ const t2 = makeTracker();
104
+ const r2 = run(t2, park(), { guard: null });
105
+ ok('a missing guard fails closed', r2.code === 1 && r2.json.status === 'error' && t2.calls.length === 0);
106
+ }
107
+ for (const [failOn, later] of [['ensure', ['view', 'comment', 'unassign', 'label']], ['view', ['comment', 'unassign', 'label']], ['comment', ['unassign', 'label']], ['unassign', ['label']], ['label', []]]) {
108
+ const t = makeTracker({ assignees: ['me'], failOn });
109
+ const r = run(t, park());
110
+ ok(`failing ${failOn} is status error, exit 1`, r.code === 1 && r.json.status === 'error' && r.json.reason.length > 0);
111
+ ok(`after ${failOn} fails, later steps do not run`, later.every((s) => !t.calls.some((c) => ({ view: /issue view/, comment: /issue comment/, unassign: /--remove-assignee/, label: /--add-label/ })[s].test(c))));
112
+ }
113
+ {
114
+ const t = makeTracker({ assignees: ['me'], failOn: 'unassign' });
115
+ run(t, park());
116
+ ok('a failed unassign leaves no label', !t.labels.includes('trailhead:needs-you'));
117
+ const tb = makeTracker({ badJson: true });
118
+ const r = run(tb, park());
119
+ ok('unparseable view JSON is an error, nothing written', r.code === 1 && r.json.status === 'error' && tb.comments.length === 0);
120
+ }
121
+
122
+ // --- clear ----------------------------------------------------------------------
123
+ {
124
+ const t = makeTracker({ labels: ['trailhead:ticket', 'trailhead:needs-you'] });
125
+ const r = run(t, clear);
126
+ ok('clear removes the label when present', r.code === 0 && r.json.status === 'ok' && r.json.issue === 7 && r.json.cleared === true && !t.labels.includes('trailhead:needs-you'));
127
+ const r2 = run(t, clear);
128
+ ok('clear is a no-op when absent', r2.code === 0 && r2.json.cleared === false && t.calls.filter((c) => /--remove-label/.test(c)).length === 1);
129
+ const tf = makeTracker({ labels: ['trailhead:needs-you'], failOn: 'remove' });
130
+ ok('clear errors on a failed removal', run(tf, clear).code === 1);
131
+ const tv = makeTracker({ failOn: 'view' });
132
+ const rv = run(tv, clear);
133
+ ok('clear errors on a failed read', rv.code === 1 && rv.json.status === 'error');
134
+ ok('clear errors on unparseable JSON', run(makeTracker({ badJson: true }), clear).code === 1);
135
+ }
136
+
137
+ // --- usage ----------------------------------------------------------------------
138
+ for (const argv of [[], ['bogus'], ['park'], ['park', '--issue', '7', '--repo', 'o/r'], ['park', '--issue', 'x', '--reason', 'r', '--repo', 'o/r'],
139
+ ['park', '--issue', '7', '--reason', ' ', '--repo', 'o/r'], ['park', '--issue', '7', '--reason', 'r'], ['clear', '--issue', '7'], ['clear', '--repo', 'o/r'],
140
+ ['clear', '--issue', '7', '--repo', 'nope'], ['clear', '--issue', '7', '--repo', 'o/r', '--reason', 'r'], ['clear', '--issue']]) {
141
+ const t = makeTracker();
142
+ const r = run(t, argv);
143
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && t.calls.length === 0 && r.errOut.length > 0);
144
+ }
145
+
146
+ // --- end-to-end with a fake gh on PATH --------------------------------------------
147
+ {
148
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-you-'));
149
+ const binDir = path.join(tmp, 'bin');
150
+ fs.mkdirSync(binDir);
151
+ const log = path.join(tmp, 'calls.log');
152
+ const script = `#!/bin/sh
153
+ echo "$*" >> "${log}"
154
+ case "$*" in
155
+ "issue view"*)
156
+ if grep -q -- '--add-label' "${log}"; then L='{"name":"trailhead:needs-you"}'; else L=''; fi
157
+ echo "{\\"labels\\":[$L],\\"assignees\\":[{\\"login\\":\\"me\\"}],\\"comments\\":[]}" ;;
158
+ *) exit 0 ;;
159
+ esac
160
+ `;
161
+ fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
162
+ const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
163
+ const res = spawnSync('node', [scriptPath, ...park('Needs a human call')], { encoding: 'utf8', env });
164
+ const j = JSON.parse(res.stdout.trim());
165
+ ok('e2e: park prints one ok JSON line', res.status === 0 && j.status === 'ok' && j.commented === true && j.unassigned === true);
166
+ const calls = fs.readFileSync(log, 'utf8');
167
+ ok('e2e: comment before unassign before label', calls.indexOf('issue comment') < calls.indexOf('--remove-assignee') && calls.indexOf('--remove-assignee') < calls.indexOf('--add-label'));
168
+ const cl = spawnSync('node', [scriptPath, ...clear], { encoding: 'utf8', env });
169
+ ok('e2e: clear removes the present label', cl.status === 0 && JSON.parse(cl.stdout.trim()).cleared === true && fs.readFileSync(log, 'utf8').includes('--remove-label trailhead:needs-you'));
170
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
171
+ const bad = spawnSync('node', [scriptPath, ...clear], { encoding: 'utf8', env });
172
+ ok('e2e: gh failing exits 1 with an error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
173
+ ok('e2e: usage error exits 2', spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
174
+ fs.rmSync(tmp, { recursive: true, force: true });
175
+ }
176
+
177
+ console.log(`needs-you.test.js: ${passed} checks passed`);