@marcomigozzi/trailhead 0.15.0 → 0.16.1

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 (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  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/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -13,6 +13,9 @@
13
13
  // existing body. --dry-run gathers and reads through gh, computes and scans, but
14
14
  // writes, creates, pins and labels nothing; it prints the old and new byte sizes.
15
15
  //
16
+ // Singleton labels (dashboard, codebase, conventions): more than one open issue
17
+ // aborts before any write, naming every duplicate by title and number.
18
+ //
16
19
  // Self-heal: ensures the `trailhead:dashboard` label; creates the issue directly
17
20
  // with the final scanned body when it is missing (never an empty or placeholder
18
21
  // body) and pins it; re-pins it when unpinned. Never closes or prunes a map: the
@@ -165,12 +168,33 @@ function gatherMap(gh, repo, m, truncated) {
165
168
  return out;
166
169
  }
167
170
 
168
- function firstNumber(gh, repo, label) {
169
- const list = issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number', '--limit', '1']);
170
- return list.length ? list[0].number : null;
171
+ // Singleton labels: exactly one open issue each. More than one is ambiguous and
172
+ // never resolved by picking one (#274).
173
+ const SINGLETON_LIMIT = 10;
174
+ const SINGLETON_LABELS = [LABEL, 'trailhead:codebase', 'trailhead:conventions'];
175
+
176
+ // Looks up every singleton label first and aborts (GatherError) naming all the
177
+ // duplicated ones at once, before anything is written. Returns the single
178
+ // number per label, or null when there is none.
179
+ function lookupSingletons(gh, repo) {
180
+ const found = SINGLETON_LABELS.map((label) => ({
181
+ label,
182
+ list: issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number,title', '--limit', String(SINGLETON_LIMIT)]),
183
+ }));
184
+ const dupes = found.filter((f) => f.list.length > 1);
185
+ if (dupes.length) {
186
+ // Control characters are stripped so the one-line stdout stays safe; a list
187
+ // that filled the fetch limit may be cut, so say more may exist.
188
+ const safe = (title) => linkText(title).replace(/[\x00-\x1f\x7f]/g, '');
189
+ const named = dupes.map((f) => `${f.label}: ${f.list.map((i) => `${safe(i.title)} (#${i.number})`).join(', ')}${f.list.length >= SINGLETON_LIMIT ? ', and possibly more' : ''}`).join('; ');
190
+ throw new GatherError(`ambiguous singleton issues, close the duplicates and keep one: ${named}`);
191
+ }
192
+ const num = (label) => { const f = found.find((x) => x.label === label); return f.list.length ? f.list[0].number : null; };
193
+ return { dashboard: num(LABEL), codebase: num('trailhead:codebase'), conventions: num('trailhead:conventions') };
171
194
  }
172
195
 
173
- function gatherSnapshot(gh, repo) {
196
+ function gatherSnapshot(gh, repo, singles) {
197
+ const one = singles || lookupSingletons(gh, repo);
174
198
  const truncated = [];
175
199
  const capped = (list, limit, name) => { if (list.length >= limit) truncated.push(name); return list; };
176
200
  const maps = capped(issueList(gh, repo, ['--label', 'trailhead:map', '--state', 'open', '--json', 'number,title', '--limit', String(LIMITS.maps)]), LIMITS.maps, 'maps')
@@ -190,7 +214,7 @@ function gatherSnapshot(gh, repo) {
190
214
  if (closed.length >= LIMITS.resolved && resolved.length < RESOLVED_SHOWN) truncated.push('resolved');
191
215
  const openIssues = capped(issueList(gh, repo, ['--state', 'open', '--json', 'number,labels', '--limit', String(LIMITS.inbox)]), LIMITS.inbox, 'inbox');
192
216
  const inbox = openIssues.filter((i) => !names(i.labels).some((n) => n.startsWith('trailhead:'))).length;
193
- const anchors = { codebase: firstNumber(gh, repo, 'trailhead:codebase'), conventions: firstNumber(gh, repo, 'trailhead:conventions') };
217
+ const anchors = { codebase: one.codebase, conventions: one.conventions };
194
218
  return { repo, maps, whiteboard, resolved, inbox, anchors, lastAudit: null, truncated };
195
219
  }
196
220
 
@@ -209,11 +233,6 @@ function summaryLine(number, snap) {
209
233
  return `dashboard${number === null ? '' : ' #' + number}: ${snap.maps.length} maps, whiteboard ${snap.whiteboard.length}, flags: ${flagSummary(snap.maps)}${cut}`;
210
234
  }
211
235
 
212
- function findDashboard(gh, repo) {
213
- const list = issueList(gh, repo, ['--label', LABEL, '--state', 'open', '--json', 'number', '--limit', '1']);
214
- return list.length ? list[0].number : null;
215
- }
216
-
217
236
  function isPinned(gh, repo, number) {
218
237
  const [owner, name] = repo.split('/');
219
238
  const q = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){pinnedIssues(first:3){nodes{issue{number}}}}}';
@@ -284,10 +303,11 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
284
303
  try {
285
304
  const repo = o.repo || defaultRepo(run);
286
305
  if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
306
+ const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
287
307
  if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
288
- const snap = gatherSnapshot(run, repo);
308
+ const snap = gatherSnapshot(run, repo, singles);
289
309
  const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
290
- const number = findDashboard(run, repo);
310
+ const number = singles.dashboard;
291
311
 
292
312
  if (number === null) {
293
313
  const body = renderDashboard({ ...snap, lastAudit: marker });
@@ -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
+ }