@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,366 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead merge-ticket helper (self-contained, ships in templates/ so it reaches
4
+ // every install channel). When an autonomous run leaves a slot branch unmerged (a
5
+ // `conflict` from trailhead-auto-merge.js merge), this opens, or updates, the one merge
6
+ // ticket of that ticket's map (or of the whiteboard), right then. #287.
7
+ //
8
+ // CLI:
9
+ // node trailhead-merge-ticket.js add --scope <map-<n>|whiteboard> --ticket <n>
10
+ // --file <path> [--file <path> ...] --workspace <path> --repo <owner/name>
11
+ // The branch is derived as `trailhead/t<ticket>`. --file is repeatable and taken as
12
+ // is (one per conflicting path, commas allowed).
13
+ //
14
+ // Find: every OPEN issue labelled with the scope (`trailhead:map-<n>` or
15
+ // `trailhead:whiteboard`) is read with a paginated GraphQL query (`gh api graphql`,
16
+ // 100 per page, not `gh issue list`, whose search index lags), and those whose body
17
+ // has the marker line are the scope's merge tickets; the lowest-numbered one is
18
+ // reused. A read that fails, is unparseable, carries GraphQL errors, has an unexpected
19
+ // shape, or exceeds the page cap (100 pages) is an error, never "none found".
20
+ //
21
+ // The data lives in one marker line, `<!-- trailhead:merge-ticket v1 <base64 of the
22
+ // JSON entry list> -->`; the readable list is rendered from the entries only, never
23
+ // parsed back from prose. An unknown version or an undecodable payload on the found
24
+ // issue is an error with nothing written.
25
+ //
26
+ // Create (none open): ensures the `trailhead:needs-you` label, secret-scans the body
27
+ // (fail closed), opens the ticket (`trailhead:ticket`, `trailhead:task`,
28
+ // `trailhead:needs-you`, the scope label), and for a map wires the native sub-issue
29
+ // edge (a failure is a warning). Update (one open): the guarded body write of
30
+ // trailhead-issue-body.js (guardedWrite) re-reads the issue, upserts this ticket's
31
+ // entry and re-renders the whole body; a repeat add with the same files and workspace
32
+ // is `unchanged` and writes nothing.
33
+ //
34
+ // Prints exactly one JSON line on stdout:
35
+ // ok: {"status":"ok","action":"created|updated|unchanged","issue":n,"url":"...","scope":"...","entries":k,"warnings":[...]}
36
+ // error: {"status":"error","reason":"..."}
37
+ // Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const { spawnSync } = require('child_process');
43
+ const { guardedWrite, loadGuard, secretProblem } = require('./trailhead-issue-body.js');
44
+
45
+ const MAX_PAGES = 100; // 100 pages x 100 issues
46
+ const VERSION = 'v1';
47
+ const MARKER_PREFIX = '<!-- trailhead:merge-ticket ';
48
+ const MARKER_RE = /^<!-- trailhead:merge-ticket (\S+) (\S*) -->$/;
49
+ const NEEDS_YOU = 'trailhead:needs-you';
50
+ const NEEDS_YOU_COLOR = 'D93F0B';
51
+ const NEEDS_YOU_DESC = 'Needs you: an autonomous run parked it for the human';
52
+ const TITLE_PREFIX = 'Merge the branches left unmerged: ';
53
+
54
+ function defaultGh(args) {
55
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
56
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
57
+ }
58
+
59
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
60
+ const byNumber = (a, b) => a.number - b.number;
61
+
62
+ // --- args ---------------------------------------------------------------------
63
+
64
+ function parseArgs(argv) {
65
+ const cmd = argv[0];
66
+ if (cmd !== 'add') return { error: `usage: expected add, got: ${cmd === undefined ? '(nothing)' : cmd}` };
67
+ const o = { cmd, scope: null, ticket: null, files: [], workspace: null, repo: null };
68
+ for (let i = 1; i < argv.length; i++) {
69
+ const flag = argv[i];
70
+ if (!['--scope', '--ticket', '--file', '--workspace', '--repo'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
71
+ i++;
72
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
73
+ const v = argv[i];
74
+ if (flag === '--repo') {
75
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
76
+ o.repo = v;
77
+ } else if (flag === '--ticket') {
78
+ if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --ticket must be a positive integer, got: ${v}` };
79
+ o.ticket = Number(v);
80
+ } else if (flag === '--scope') {
81
+ if (v !== 'whiteboard' && !/^map-[1-9]\d*$/.test(v)) return { error: `usage: --scope must be map-<n> or whiteboard, got: ${v}` };
82
+ o.scope = v;
83
+ } else if (flag === '--file') {
84
+ if (v === '') return { error: 'usage: --file must not be empty' };
85
+ o.files.push(v);
86
+ } else {
87
+ if (v.trim() === '') return { error: 'usage: --workspace must not be empty' };
88
+ o.workspace = v;
89
+ }
90
+ }
91
+ if (!o.repo) return { error: 'usage: add requires --repo' };
92
+ if (!o.scope) return { error: 'usage: add requires --scope' };
93
+ if (!o.ticket) return { error: 'usage: add requires --ticket' };
94
+ if (!o.files.length) return { error: 'usage: add requires at least one --file' };
95
+ if (!o.workspace) return { error: 'usage: add requires --workspace' };
96
+ return o;
97
+ }
98
+
99
+ const scopeLabel = (scope) => (scope === 'whiteboard' ? 'trailhead:whiteboard' : `trailhead:${scope}`);
100
+ const branchOf = (ticket) => `trailhead/t${ticket}`;
101
+
102
+ // --- marker -------------------------------------------------------------------
103
+
104
+ // A link target that cannot break out of `](...)`: https, no whitespace, no parens or angle brackets.
105
+ const SAFE_URL = /^https:\/\/[^\s()<>]+$/;
106
+
107
+ const isEntry = (e) => e && typeof e === 'object'
108
+ && Number.isInteger(e.ticket) && e.ticket > 0
109
+ && ['title', 'url', 'branch', 'workspace'].every((k) => typeof e[k] === 'string')
110
+ && SAFE_URL.test(e.url)
111
+ && Array.isArray(e.files) && e.files.length > 0 && e.files.every((f) => typeof f === 'string' && f !== '');
112
+
113
+ function encodeMarker(entries) {
114
+ return `${MARKER_PREFIX}${VERSION} ${Buffer.from(JSON.stringify(entries), 'utf8').toString('base64')} -->`;
115
+ }
116
+
117
+ // { entries } or { error }. The body must carry exactly one marker line.
118
+ function decodeMarker(body) {
119
+ const lines = String(body || '').replace(/\r/g, '').split('\n').filter((l) => l.startsWith(MARKER_PREFIX));
120
+ if (lines.length === 0) return { error: 'the merge ticket has no marker line' };
121
+ if (lines.length > 1) return { error: 'the merge ticket has more than one marker line' };
122
+ const m = MARKER_RE.exec(lines[0].trimEnd());
123
+ if (!m) return { error: 'the merge ticket marker line is malformed' };
124
+ if (m[1] !== VERSION) return { error: `the merge ticket marker has an unknown version: ${m[1]}` };
125
+ let list;
126
+ try { list = JSON.parse(Buffer.from(m[2], 'base64').toString('utf8')); } catch { return { error: 'the merge ticket marker payload cannot be decoded' }; }
127
+ if (!Array.isArray(list) || !list.every(isEntry)) return { error: 'the merge ticket marker payload is not a valid entry list' };
128
+ return { entries: list };
129
+ }
130
+
131
+ const hasMarker = (body) => String(body || '').replace(/\r/g, '').split('\n').some((l) => l.startsWith(MARKER_PREFIX));
132
+
133
+ // --- rendering ----------------------------------------------------------------
134
+
135
+ const oneLine = (s) => String(s).replace(/[\r\n]+/g, ' ');
136
+
137
+ // Link text: no newline, no backtick, no brackets, no raw `<` (a `<!--` would hide the body),
138
+ // and a zero-width space after `@` so a title never notifies the handle it names.
139
+ const linkText = (s) => oneLine(s).replace(/`/g, "'").replace(/</g, '&lt;').replace(/\[/g, '(').replace(/\]/g, ')').replace(/@/g, '@\u200B');
140
+
141
+ // Inline code that survives backticks in the text: a fence longer than any run inside.
142
+ function code(s) {
143
+ const t = oneLine(s);
144
+ const longest = Math.max(0, ...(t.match(/`+/g) || []).map((r) => r.length));
145
+ const fence = '`'.repeat(longest + 1);
146
+ const pad = t.startsWith('`') || t.endsWith('`') ? ' ' : '';
147
+ return `${fence}${pad}${t}${pad}${fence}`;
148
+ }
149
+
150
+ const byTicket = (a, b) => a.ticket - b.ticket;
151
+
152
+ function renderEntry(e, all, index) {
153
+ const others = all.filter((o) => o.ticket !== e.ticket)
154
+ .map((o) => ({ o, shared: o.files.filter((f) => e.files.includes(f)) }))
155
+ .filter((x) => x.shared.length);
156
+ const lines = [
157
+ `${index}. ${code(e.branch)}: [#${e.ticket} ${linkText(e.title)}](${e.url})`,
158
+ ` - Workspace: ${code(e.workspace)}`,
159
+ ` - Conflicting files: ${e.files.map(code).join(', ')}`,
160
+ ];
161
+ if (others.length) lines.push(` - Also touched by: ${others.map((x) => `${code(x.o.branch)} (${x.shared.map(code).join(', ')})`).join('; ')}`);
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // The whole body from the entries alone. parentLine is kept verbatim ('' for none).
166
+ function renderBody(entriesIn, parentLine) {
167
+ const entries = [...entriesIn].sort(byTicket);
168
+ const parts = [];
169
+ if (parentLine) parts.push(parentLine, '');
170
+ parts.push(
171
+ '## Question',
172
+ 'Merge into the trunk the branches an autonomous run left unmerged.',
173
+ '',
174
+ '## Unmerged branches',
175
+ 'Suggested merge order: oldest ticket first (a suggestion, not the order the run followed). After each branch lands, re-check each later one against the trunk before merging it.',
176
+ '',
177
+ ...entries.map((e, i) => renderEntry(e, entries, i + 1)),
178
+ '',
179
+ '## Done when',
180
+ '- Each listed branch is merged into the trunk or dropped, and its ticket resolved.',
181
+ '- Close this ticket when the list is done.',
182
+ '',
183
+ encodeMarker(entries),
184
+ '',
185
+ );
186
+ return parts.join('\n');
187
+ }
188
+
189
+ // Pure: replace the entry of the same ticket in place or append it.
190
+ function upsert(entries, entry) {
191
+ const at = entries.findIndex((e) => e.ticket === entry.ticket);
192
+ return at < 0 ? [...entries, entry] : entries.map((e, i) => (i === at ? entry : e));
193
+ }
194
+
195
+ const sameList = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
196
+
197
+ // --- tracker reads ------------------------------------------------------------
198
+
199
+ const query = (label) => `query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:100,after:$after,labels:["${label}"],states:[OPEN],orderBy:{field:CREATED_AT,direction:ASC}){nodes{number title body url} pageInfo{hasNextPage endCursor}}}}`;
200
+
201
+ // { merge: [{number,title,body,url}] sorted ascending } or { error }.
202
+ function findOpen(run, repo, label) {
203
+ const [owner, name] = repo.split('/');
204
+ const rows = [];
205
+ let after = null;
206
+ for (let page = 1; ; page++) {
207
+ if (page > MAX_PAGES) return { error: `incomplete read: more than ${MAX_PAGES} pages of ${label} issues` };
208
+ const args = ['api', 'graphql', '-f', `query=${query(label)}`, '-f', `owner=${owner}`, '-f', `name=${name}`];
209
+ if (after !== null) args.push('-f', `after=${after}`);
210
+ const r = run(args);
211
+ if (r.status !== 0) return { error: `gh api graphql failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
212
+ let body;
213
+ try { body = JSON.parse(r.stdout); } catch { return { error: 'gh api graphql returned unparseable JSON' }; }
214
+ if (!body || typeof body !== 'object') return { error: 'gh api graphql returned unexpected JSON' };
215
+ if (Array.isArray(body.errors) && body.errors.length) return { error: `graphql errors: ${firstLine(body.errors[0] && body.errors[0].message) || 'unknown'}` };
216
+ const repoNode = body.data && body.data.repository;
217
+ if (!repoNode) return { error: 'repository not found in the graphql response' };
218
+ const issues = repoNode.issues;
219
+ if (!issues || !Array.isArray(issues.nodes)) return { error: 'graphql response has no issues.nodes' };
220
+ const info = issues.pageInfo;
221
+ if (!info || typeof info.hasNextPage !== 'boolean') return { error: 'graphql response has no pageInfo' };
222
+ rows.push(...issues.nodes.filter((n) => n && typeof n === 'object'));
223
+ if (!info.hasNextPage) break;
224
+ if (typeof info.endCursor !== 'string' || !info.endCursor) return { error: 'graphql pageInfo has hasNextPage without an endCursor' };
225
+ after = info.endCursor;
226
+ }
227
+ return { merge: rows.filter((i) => Number.isInteger(i.number) && hasMarker(i.body)).sort(byNumber) };
228
+ }
229
+
230
+ // { title, url } of an issue, or { error }.
231
+ function viewIssue(run, repo, n) {
232
+ const r = run(['issue', 'view', String(n), '--json', 'title,url', '-R', repo]);
233
+ if (r.status !== 0) return { error: `gh issue view #${n} failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
234
+ let j;
235
+ try { j = JSON.parse(r.stdout); } catch { return { error: `gh issue view #${n} returned unparseable JSON` }; }
236
+ if (!j || typeof j.title !== 'string' || typeof j.url !== 'string' || !j.title || !j.url) return { error: `gh issue view #${n} returned no title or url` };
237
+ return { title: j.title, url: j.url };
238
+ }
239
+
240
+ // --- create -------------------------------------------------------------------
241
+
242
+ function create(run, o, entry, ctx) {
243
+ const labels = ['trailhead:ticket', 'trailhead:task', NEEDS_YOU, scopeLabel(o.scope)];
244
+ let title;
245
+ let parentLine = '';
246
+ if (o.scope === 'whiteboard') {
247
+ title = `${TITLE_PREFIX}whiteboard`;
248
+ } else {
249
+ const n = Number(o.scope.slice(4));
250
+ const map = viewIssue(run, o.repo, n);
251
+ if (map.error) return { error: map.error };
252
+ title = `${TITLE_PREFIX}${oneLine(map.title)}`;
253
+ parentLine = `Parent: [${linkText(map.title)}](${map.url})`;
254
+ }
255
+ const body = renderBody([entry], parentLine);
256
+ const problem = secretProblem(`${title}\n${body}`, ctx.guard);
257
+ if (problem) return { error: problem };
258
+
259
+ const ensure = run(['label', 'create', NEEDS_YOU, '--color', NEEDS_YOU_COLOR, '--description', NEEDS_YOU_DESC, '--force', '-R', o.repo]);
260
+ if (ensure.status !== 0) return { error: `gh label create failed: ${firstLine(ensure.stderr) || 'exit ' + ensure.status}` };
261
+
262
+ let dir = null;
263
+ let made;
264
+ try {
265
+ dir = fs.mkdtempSync(path.join(ctx.tmpdir || os.tmpdir(), 'trailhead-merge-ticket-'));
266
+ const file = path.join(dir, 'body.md');
267
+ fs.writeFileSync(file, body);
268
+ made = run(['issue', 'create', '--title', title, '--body-file', file, ...labels.flatMap((l) => ['--label', l]), '-R', o.repo]);
269
+ } catch (e) {
270
+ return { error: `could not stage the body in a temp file, nothing written: ${firstLine(e && e.message)}` };
271
+ } finally {
272
+ if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort cleanup */ } }
273
+ }
274
+ if (made.status !== 0) return { error: `gh issue create failed: ${firstLine(made.stderr) || 'exit ' + made.status}` };
275
+ const m = /\/issues\/(\d+)\s*$/.exec(made.stdout.trim());
276
+ if (!m) return { error: 'gh issue create returned no issue url' };
277
+ const number = Number(m[1]);
278
+ const url = made.stdout.trim();
279
+ const warnings = [];
280
+
281
+ if (o.scope !== 'whiteboard') {
282
+ const parent = Number(o.scope.slice(4));
283
+ const w = wireSubIssue(run, o.repo, parent, number);
284
+ if (w) warnings.push(w);
285
+ }
286
+ return { number, url, warnings };
287
+ }
288
+
289
+ // null on success, else the warning text.
290
+ function wireSubIssue(run, repo, parent, child) {
291
+ const got = run(['api', `repos/${repo}/issues/${child}`]);
292
+ let id = null;
293
+ if (got.status === 0) { try { id = JSON.parse(got.stdout).id; } catch { /* handled below */ } }
294
+ if (!Number.isInteger(id)) return `sub-issue edge not wired: could not read the id of #${child}${got.status === 0 ? '' : ': ' + (firstLine(got.stderr) || 'exit ' + got.status)}`;
295
+ const r = run(['api', '--method', 'POST', `repos/${repo}/issues/${parent}/sub_issues`, '-F', `sub_issue_id=${id}`]);
296
+ return r.status === 0 ? null : `sub-issue edge not wired: ${firstLine(r.stderr) || 'exit ' + r.status}`;
297
+ }
298
+
299
+ // --- main ---------------------------------------------------------------------
300
+
301
+ function main(argv, { gh, guard, tmpdir, stdout, stderr } = {}) {
302
+ const out = stdout || process.stdout;
303
+ const err = stderr || process.stderr;
304
+ const o = parseArgs(argv || []);
305
+ if (o.error) { err.write(o.error + '\n'); return 2; }
306
+ const run = gh || defaultGh;
307
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
308
+ const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
309
+ const ctx = { guard: guard === undefined ? loadGuard() : guard, tmpdir };
310
+ const label = scopeLabel(o.scope);
311
+ const done = (action, issue, url, entries, warnings) => {
312
+ emit({ status: 'ok', action, issue, url, scope: o.scope, entries, warnings });
313
+ return 0;
314
+ };
315
+ const twinWarning = (list) => (list.length > 1
316
+ ? [`more than one open merge ticket for ${o.scope}: ${list.map((i) => '#' + i.number).join(', ')} (none closed; the human merges them)`]
317
+ : []);
318
+
319
+ const found = findOpen(run, o.repo, label);
320
+ if (found.error) return fail(found.error);
321
+ const wanted = { branch: branchOf(o.ticket), workspace: o.workspace, files: o.files };
322
+
323
+ if (found.merge.length === 0) {
324
+ const t = viewIssue(run, o.repo, o.ticket);
325
+ if (t.error) return fail(t.error);
326
+ const entry = { ticket: o.ticket, title: t.title, url: t.url, ...wanted };
327
+ const made = create(run, o, entry, ctx);
328
+ if (made.error) return fail(made.error);
329
+ const warnings = [...made.warnings];
330
+ const again = findOpen(run, o.repo, label);
331
+ if (again.error) warnings.push(`could not re-check for duplicate merge tickets: ${again.error}`);
332
+ else warnings.push(...twinWarning(again.merge));
333
+ return done('created', made.number, made.url, 1, warnings);
334
+ }
335
+
336
+ const target = found.merge[0];
337
+ const cur = decodeMarker(target.body);
338
+ if (cur.error) return fail(`#${target.number}: ${cur.error}, nothing written`);
339
+ const prior = cur.entries.find((e) => e.ticket === o.ticket);
340
+ if (prior && prior.workspace === o.workspace && sameList(prior.files, o.files)) {
341
+ return done('unchanged', target.number, target.url, cur.entries.length, twinWarning(found.merge));
342
+ }
343
+
344
+ let info = prior ? { title: prior.title, url: prior.url } : viewIssue(run, o.repo, o.ticket);
345
+ if (info.error) return fail(info.error);
346
+ let count = 0;
347
+ const compute = (fresh) => {
348
+ const dec = decodeMarker(fresh);
349
+ if (dec.error) return { error: `${dec.error}, nothing written` };
350
+ const old = dec.entries.find((e) => e.ticket === o.ticket);
351
+ if (old && !prior) info = { title: old.title, url: old.url };
352
+ const entries = upsert(dec.entries, { ticket: o.ticket, title: info.title, url: info.url, ...wanted });
353
+ count = entries.length;
354
+ const parent = String(fresh).replace(/\r/g, '').split('\n').find((l) => l.startsWith('Parent: ')) || '';
355
+ return renderBody(entries, parent);
356
+ };
357
+ const g = guardedWrite(target.number, compute, { gh: run, repo: o.repo, guard: ctx.guard, tmpdir });
358
+ if (!g.ok) return fail(`#${target.number}: ${g.reason}`);
359
+ return done('updated', target.number, target.url, count, twinWarning(found.merge));
360
+ }
361
+
362
+ module.exports = { main, parseArgs, encodeMarker, decodeMarker, renderBody, upsert, findOpen };
363
+
364
+ if (require.main === module) {
365
+ process.exit(main(process.argv.slice(2), {}));
366
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead needs-you helper (self-contained, ships in templates/ so it reaches every
4
+ // install channel). Marks a ticket an autonomous run parked for the human, and clears
5
+ // the mark when a human takes the ticket back. #282.
6
+ //
7
+ // CLI:
8
+ // node trailhead-needs-you.js park --issue <n> --reason <text> --repo <owner/name>
9
+ // node trailhead-needs-you.js clear --issue <n> --repo <owner/name>
10
+ //
11
+ // park, in this order (so any partial failure leaves the ticket visible: a label is
12
+ // never set on a ticket still claimed by the run):
13
+ // 1. secret-scan the reason with the shipped secret-guard (fail closed: nothing is
14
+ // written when the guard is missing or hits);
15
+ // 2. ensure the repo label `trailhead:needs-you` (`gh label create --force`);
16
+ // 3. read the ticket's labels, assignees and comments;
17
+ // 4. post a comment whose first line is exactly `NEEDS YOU`, a blank line, then the
18
+ // reason, unless the latest comment with that first line carries the same reason
19
+ // (whitespace-normalised). Limit: dedup only sees the comments `gh` returns, so on
20
+ // a very long thread the worst case is one duplicate comment, never a missing one;
21
+ // 5. unassign `@me` when the ticket is assigned;
22
+ // 6. add the label last.
23
+ // clear: when the ticket carries the label, remove it; otherwise a no-op.
24
+ //
25
+ // Prints exactly one JSON line on stdout:
26
+ // park: {"status":"ok","issue":n,"commented":bool,"unassigned":bool,"labelled":bool}
27
+ // clear: {"status":"ok","issue":n,"cleared":bool}
28
+ // error: {"status":"error","reason":"..."}
29
+ // Exit 0 on ok, 1 on an error (any failed or unparseable gh step), 2 on a usage error
30
+ // (stderr).
31
+
32
+ const { spawnSync } = require('child_process');
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const LABEL = 'trailhead:needs-you';
37
+ const LABEL_COLOR = 'D93F0B';
38
+ const LABEL_DESC = 'Needs you: an autonomous run parked it for the human';
39
+ const MARKER = 'NEEDS YOU';
40
+
41
+ function defaultGh(args) {
42
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
43
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
44
+ }
45
+
46
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
47
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
48
+
49
+ // --- secret guard (same approach as trailhead-issue-body.js) -----------------
50
+
51
+ function guardCandidates() {
52
+ return [
53
+ path.join(__dirname, '..', 'hooks', 'trailhead-secret-guard.js'),
54
+ path.join(__dirname, '..', '..', 'hooks', 'trailhead-secret-guard.js'),
55
+ ];
56
+ }
57
+
58
+ // The shipped secret-guard module ({ scan }), or null when none is found.
59
+ function loadGuard(candidates = guardCandidates()) {
60
+ for (const file of candidates) {
61
+ if (!fs.existsSync(file)) continue;
62
+ try {
63
+ const m = require(file);
64
+ if (m && typeof m.scan === 'function') return m;
65
+ } catch { /* try the next layout */ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // null when the text is clean, else the abort reason (fail closed).
71
+ function secretProblem(text, guard) {
72
+ if (!guard || typeof guard.scan !== 'function') return 'secret-scan unavailable, nothing written';
73
+ try {
74
+ const hit = guard.scan(text, {});
75
+ return hit ? `secret detected (${hit}) in the reason, nothing written` : null;
76
+ } catch {
77
+ return 'secret-scan unavailable, nothing written';
78
+ }
79
+ }
80
+
81
+ // --- args ---------------------------------------------------------------------
82
+
83
+ function parseArgs(argv) {
84
+ const cmd = argv[0];
85
+ if (cmd !== 'park' && cmd !== 'clear') return { error: `usage: expected park or clear, got: ${cmd === undefined ? '(nothing)' : cmd}` };
86
+ const allowed = cmd === 'park' ? ['--issue', '--reason', '--repo'] : ['--issue', '--repo'];
87
+ const o = { cmd, issue: null, reason: null, repo: null };
88
+ for (let i = 1; i < argv.length; i++) {
89
+ const flag = argv[i];
90
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
91
+ i++;
92
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
93
+ const v = argv[i];
94
+ if (flag === '--repo') {
95
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
96
+ o.repo = v;
97
+ } else if (flag === '--issue') {
98
+ if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --issue must be a positive integer, got: ${v}` };
99
+ o.issue = Number(v);
100
+ } else {
101
+ o.reason = v;
102
+ }
103
+ }
104
+ if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
105
+ if (!o.issue) return { error: `usage: ${cmd} requires --issue` };
106
+ if (cmd === 'park' && (o.reason === null || !o.reason.trim())) return { error: 'usage: park requires a non-empty --reason' };
107
+ return o;
108
+ }
109
+
110
+ // --- gh steps -----------------------------------------------------------------
111
+
112
+ // { labels:[names], assignees:[logins], comments:[bodies] } or { error }.
113
+ function readIssue(run, o) {
114
+ const r = run(['issue', 'view', String(o.issue), '--json', 'labels,assignees,comments', '-R', o.repo]);
115
+ if (r.status !== 0) return { error: `gh issue view failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
116
+ let j;
117
+ try { j = JSON.parse(r.stdout); } catch { return { error: 'gh issue view returned unparseable JSON' }; }
118
+ if (!j || typeof j !== 'object') return { error: 'gh issue view returned unexpected JSON' };
119
+ const list = (v) => (Array.isArray(v) ? v : []);
120
+ return {
121
+ labels: list(j.labels).map((l) => (typeof l === 'string' ? l : l && l.name)).filter(Boolean),
122
+ assignees: list(j.assignees).map((a) => (typeof a === 'string' ? a : a && a.login)).filter(Boolean),
123
+ comments: list(j.comments).map((c) => (typeof c === 'string' ? c : String((c && c.body) || ''))),
124
+ };
125
+ }
126
+
127
+ // True when the latest NEEDS YOU comment already carries this reason.
128
+ function alreadyCommented(comments, reason) {
129
+ const mine = comments.filter((b) => String(b).replace(/\r/g, '').split('\n')[0] === MARKER);
130
+ if (!mine.length) return false;
131
+ const latest = String(mine[mine.length - 1]).replace(/\r/g, '');
132
+ return norm(latest.slice(latest.indexOf('\n') + 1)) === norm(reason);
133
+ }
134
+
135
+ function main(argv, { gh, guard, stdout, stderr } = {}) {
136
+ const out = stdout || process.stdout;
137
+ const err = stderr || process.stderr;
138
+ const o = parseArgs(argv || []);
139
+ if (o.error) { err.write(o.error + '\n'); return 2; }
140
+ const run = gh || defaultGh;
141
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
142
+ const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
143
+ const R = ['-R', o.repo];
144
+ const n = String(o.issue);
145
+
146
+ if (o.cmd === 'clear') {
147
+ const cur = readIssue(run, o);
148
+ if (cur.error) return fail(cur.error);
149
+ if (!cur.labels.includes(LABEL)) { emit({ status: 'ok', issue: o.issue, cleared: false }); return 0; }
150
+ const r = run(['issue', 'edit', n, '--remove-label', LABEL, ...R]);
151
+ if (r.status !== 0) return fail(`gh issue edit (remove label) failed: ${firstLine(r.stderr) || 'exit ' + r.status}`);
152
+ emit({ status: 'ok', issue: o.issue, cleared: true });
153
+ return 0;
154
+ }
155
+
156
+ const problem = secretProblem(o.reason, guard === undefined ? loadGuard() : guard);
157
+ if (problem) return fail(problem);
158
+
159
+ const ensure = run(['label', 'create', LABEL, '--color', LABEL_COLOR, '--description', LABEL_DESC, '--force', ...R]);
160
+ if (ensure.status !== 0) return fail(`gh label create failed: ${firstLine(ensure.stderr) || 'exit ' + ensure.status}`);
161
+
162
+ const cur = readIssue(run, o);
163
+ if (cur.error) return fail(cur.error);
164
+
165
+ let commented = false;
166
+ if (!alreadyCommented(cur.comments, o.reason)) {
167
+ const c = run(['issue', 'comment', n, '--body', `${MARKER}\n\n${o.reason.trim()}`, ...R]);
168
+ if (c.status !== 0) return fail(`gh issue comment failed: ${firstLine(c.stderr) || 'exit ' + c.status}`);
169
+ commented = true;
170
+ }
171
+
172
+ let unassigned = false;
173
+ if (cur.assignees.length) {
174
+ const u = run(['issue', 'edit', n, '--remove-assignee', '@me', ...R]);
175
+ if (u.status !== 0) return fail(`gh issue edit (unassign) failed: ${firstLine(u.stderr) || 'exit ' + u.status}`);
176
+ unassigned = true;
177
+ }
178
+
179
+ let labelled = false;
180
+ if (!cur.labels.includes(LABEL)) {
181
+ const l = run(['issue', 'edit', n, '--add-label', LABEL, ...R]);
182
+ if (l.status !== 0) return fail(`gh issue edit (add label) failed: ${firstLine(l.stderr) || 'exit ' + l.status}`);
183
+ labelled = true;
184
+ }
185
+ emit({ status: 'ok', issue: o.issue, commented, unassigned, labelled });
186
+ return 0;
187
+ }
188
+
189
+ module.exports = { main, parseArgs, alreadyCommented, loadGuard, secretProblem };
190
+
191
+ if (require.main === module) {
192
+ process.exit(main(process.argv.slice(2), {}));
193
+ }