@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead report status check (self-contained, ships in templates/ so it reaches
4
+ // every install channel). Checks the leading `status:` line of an engine-agent
5
+ // report (Code review, Goal-backward verification #250; executor, fix, plan, debug,
6
+ // research, codebase-map #249) before the engine branches on it, and returns the
7
+ // report's fields.
8
+ //
9
+ // CLI:
10
+ // node trailhead-report-status.js --technique <technique> [--file <path>]
11
+ // <technique> is verify | code-review | executor | fix | plan | debug | research |
12
+ // codebase-map. Reads the report from --file, or from stdin when there is none.
13
+ // Prints exactly one JSON line {"action","verdict","problem","report","fields","body"}:
14
+ // action accept | normalise | reask
15
+ // verdict the canonical token, or null on reask
16
+ // problem null | missing | unknown-token | conflicting | counts | fields (null iff not reask)
17
+ // report the original text on accept and reask; the normalised text on normalise
18
+ // fields label -> trimmed value for the report's fields (executor, fix, plan, debug,
19
+ // research, codebase-map); {} for verify and code-review; null on reask
20
+ // body plan with `ready` only: the normalised report without its status line, the
21
+ // PLAN to post verbatim; null otherwise
22
+ // Exit 0 for all three actions; exit 2 on a usage error (missing or unknown
23
+ // technique, unknown flag, missing or unreadable file) with {"error": "..."} on
24
+ // stderr and nothing on stdout.
25
+ //
26
+ // The rules, in short: a candidate status line is outside code fences and
27
+ // blockquotes and not indented as code; after stripping a heading, list bullet and
28
+ // bold/underscore/backtick decoration it reads `status: <token>` where the token,
29
+ // lower-cased with spaces and hyphens as underscores, is exactly one of the
30
+ // technique's closed set. One valid token in the exact layout (status on line 1;
31
+ // for code-review also the counts line on line 2) is accepted; the same token in
32
+ // any other layout is normalised; anything else is a reask, never a guess.
33
+ // Fields: a field starts at a top-level line (same rules as a status line) reading
34
+ // `<label>:` after the same stripping; its value is the rest of that line plus every
35
+ // following line up to the next field label or the end. Labels are a closed list per
36
+ // family, matched case-insensitively; an empty value or `none` means none. A required
37
+ // field missing, a label given twice or a broken invariant is the `fields` problem.
38
+ // Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
39
+
40
+ const fs = require('fs');
41
+
42
+ const frozen = (a) => Object.freeze([...a]);
43
+
44
+ // Hard-coded here; report-status.test.js asserts these equal the tokens parsed
45
+ // from skills/_shared/techniques/{verify,code-review}.md, which stay the source.
46
+ const REVIEW_TOKENS = Object.freeze({
47
+ verify: frozen(['met', 'gaps_found', 'inconclusive']),
48
+ 'code-review': frozen(['clean', 'issues_found']),
49
+ });
50
+
51
+ // The return contract of each field family. Hard-coded here (the checker ships
52
+ // self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
53
+ // states. An invariant says: for these statuses the field (or `body`, the report without
54
+ // its status line) is `none` / `non-empty`.
55
+ const CONTRACTS = Object.freeze({
56
+ implementing: Object.freeze({
57
+ tokens: frozen(['done', 'partial', 'blocked']),
58
+ required: frozen(['commits', 'tests', 'deviations', 'blockers']),
59
+ optional: frozen(['fidelity', 'surfaced scope']),
60
+ invariants: Object.freeze([
61
+ Object.freeze({ when: frozen(['done']), field: 'blockers', rule: 'none' }),
62
+ Object.freeze({ when: frozen(['partial', 'blocked']), field: 'blockers', rule: 'non-empty' }),
63
+ ]),
64
+ }),
65
+ investigating: Object.freeze({
66
+ tokens: frozen(['found', 'inconclusive', 'needs_input']),
67
+ required: frozen(['answer', 'evidence', 'open questions']),
68
+ optional: frozen([]),
69
+ invariants: Object.freeze([
70
+ Object.freeze({ when: frozen(['needs_input']), field: 'open questions', rule: 'non-empty' }),
71
+ ]),
72
+ }),
73
+ plan: Object.freeze({
74
+ tokens: frozen(['ready', 'needs_input']),
75
+ required: frozen([]),
76
+ optional: frozen(['questions']),
77
+ invariants: Object.freeze([
78
+ Object.freeze({ when: frozen(['ready']), field: 'body', rule: 'non-empty' }),
79
+ Object.freeze({ when: frozen(['needs_input']), field: 'questions', rule: 'non-empty' }),
80
+ ]),
81
+ }),
82
+ });
83
+
84
+ // Technique -> field family.
85
+ const FAMILY_OF = Object.freeze({
86
+ executor: 'implementing',
87
+ fix: 'implementing',
88
+ debug: 'investigating',
89
+ research: 'investigating',
90
+ 'codebase-map': 'investigating',
91
+ plan: 'plan',
92
+ });
93
+
94
+ // Technique -> its closed set of status tokens.
95
+ const TOKENS = Object.freeze({
96
+ ...REVIEW_TOKENS,
97
+ ...Object.fromEntries(Object.entries(FAMILY_OF).map(([t, f]) => [t, CONTRACTS[f].tokens])),
98
+ });
99
+
100
+ const COUNTS_RE = /^critical\s*:\s*(\d+)\s*·\s*warning\s*:\s*(\d+)\s*·\s*info\s*:\s*(\d+)$/i;
101
+ const BOM = '';
102
+
103
+ // Heading marks, a list bullet and bold/underscore/backtick decoration removed.
104
+ function strip(line) {
105
+ return line
106
+ .trim()
107
+ .replace(/^#{1,6}\s+/, '')
108
+ .replace(/^[-*+]\s+/, '')
109
+ .replace(/\*\*|__|`/g, '')
110
+ .trim();
111
+ }
112
+
113
+ // Index set of the lines that may carry a status or counts line: outside fenced
114
+ // code blocks, outside blockquotes, not indented as code (4+ spaces or a tab).
115
+ function candidateLines(lines) {
116
+ const out = [];
117
+ let fence = null; // { char, len } while inside a fenced block
118
+ lines.forEach((line, i) => {
119
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
120
+ if (fence) {
121
+ if (m && m[1][0] === fence.char && m[1].length >= fence.len && m[2].trim() === '') fence = null;
122
+ return;
123
+ }
124
+ if (m) {
125
+ fence = { char: m[1][0], len: m[1].length };
126
+ return;
127
+ }
128
+ if (/^(?: {4,}|\t)/.test(line)) return;
129
+ if (/^ {0,3}>/.test(line)) return;
130
+ out.push(i);
131
+ });
132
+ return out;
133
+ }
134
+
135
+ function statusOf(line) {
136
+ const s = strip(line);
137
+ const m = /^status\s*:(.*)$/i.exec(s);
138
+ if (!m) return null;
139
+ return m[1].trim().toLowerCase().replace(/[\s-]+/g, '_');
140
+ }
141
+
142
+ function countsOf(line) {
143
+ const m = COUNTS_RE.exec(strip(line));
144
+ return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
145
+ }
146
+
147
+ const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
148
+
149
+ const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
150
+
151
+ // The fields of a report: label -> trimmed value, over the top-level lines that are not
152
+ // status lines. Returns { fields, duplicate }.
153
+ function parseFields(lines, cands, skip, contract) {
154
+ const allowed = new Set([...contract.required, ...contract.optional]);
155
+ const hits = [];
156
+ for (const i of cands) {
157
+ if (skip.has(i)) continue;
158
+ const m = /^([A-Za-z][A-Za-z ]*?)\s*:(.*)$/.exec(strip(lines[i]));
159
+ const label = m && m[1].replace(/\s+/g, ' ').toLowerCase();
160
+ if (label && allowed.has(label)) hits.push({ i, label, first: m[2].trim() });
161
+ }
162
+ const fields = {};
163
+ let duplicate = false;
164
+ hits.forEach((h, k) => {
165
+ const end = k + 1 < hits.length ? hits[k + 1].i : lines.length;
166
+ const tail = [];
167
+ for (let j = h.i + 1; j < end; j++) if (!skip.has(j)) tail.push(lines[j]);
168
+ if (Object.prototype.hasOwnProperty.call(fields, h.label)) duplicate = true;
169
+ else fields[h.label] = [h.first, ...tail].join('\n').trim();
170
+ });
171
+ return { fields, duplicate };
172
+ }
173
+
174
+ // The fields problem for a verdict, or null when the contract holds.
175
+ function fieldsProblem(contract, verdict, fields, duplicate, body) {
176
+ if (duplicate) return true;
177
+ if (contract.required.some((f) => !Object.prototype.hasOwnProperty.call(fields, f))) return true;
178
+ return contract.invariants.some((inv) => {
179
+ if (!inv.when.includes(verdict)) return false;
180
+ const value = inv.field === 'body' ? body : fields[inv.field];
181
+ return inv.rule === 'none' ? !isNone(value) : isNone(value);
182
+ });
183
+ }
184
+
185
+ function checkReport(text, technique) {
186
+ const tokens = Object.prototype.hasOwnProperty.call(TOKENS, technique) ? TOKENS[technique] : null;
187
+ if (!tokens) throw new Error(`unknown technique: ${technique}`);
188
+ const original = typeof text === 'string' ? text : '';
189
+ const hasBom = original.startsWith(BOM);
190
+ const lines = (hasBom ? original.slice(1) : original).replace(/\r\n?/g, '\n').split('\n');
191
+ const cleanLayout = !hasBom && !original.includes('\r');
192
+
193
+ const cands = candidateLines(lines);
194
+ const statusLines = cands.map((i) => ({ i, token: statusOf(lines[i]) })).filter((c) => c.token !== null);
195
+ if (statusLines.length === 0) return reask(original, 'missing');
196
+
197
+ const valid = new Set(statusLines.filter((c) => tokens.includes(c.token)).map((c) => c.token));
198
+ const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
199
+ if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
200
+ if (valid.size === 0) return reask(original, 'unknown-token');
201
+ const verdict = [...valid][0];
202
+
203
+ let counts = [];
204
+ if (technique === 'code-review') {
205
+ counts = cands.map((i) => ({ i, text: countsOf(lines[i]) })).filter((c) => c.text !== null);
206
+ if (counts.length !== 1) return reask(original, 'counts');
207
+ }
208
+
209
+ // Blank lines that led the original are not content: drop them with the status lines.
210
+ const lead = lines.findIndex((l) => l.trim() !== '');
211
+ const drop = new Set([...statusLines, ...counts].map((c) => c.i));
212
+ const rest = lines.filter((_, i) => !drop.has(i) && i >= lead);
213
+
214
+ let fields = {};
215
+ let body = null;
216
+ const family = Object.prototype.hasOwnProperty.call(FAMILY_OF, technique) ? FAMILY_OF[technique] : null;
217
+ if (family) {
218
+ const contract = CONTRACTS[family];
219
+ const statusIdx = new Set(statusLines.map((c) => c.i));
220
+ const parsed = parseFields(lines, cands, statusIdx, contract);
221
+ const text = rest.join('\n').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '');
222
+ if (fieldsProblem(contract, verdict, parsed.fields, parsed.duplicate, text)) return reask(original, 'fields');
223
+ fields = parsed.fields;
224
+ if (family === 'plan' && verdict === 'ready') body = text;
225
+ }
226
+
227
+ const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
228
+ && (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
229
+ if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
230
+
231
+ const head = [`status: ${verdict}`, ...counts.map((c) => c.text)];
232
+ return { action: 'normalise', verdict, problem: null, report: [...head, ...rest].join('\n'), fields, body };
233
+ }
234
+
235
+ function parseArgs(argv) {
236
+ const opts = { technique: null, file: null };
237
+ for (let i = 0; i < argv.length; i++) {
238
+ const a = argv[i];
239
+ if (a === '--technique' || a === '--file') {
240
+ const v = argv[i + 1];
241
+ if (v === undefined || v === '' || v.startsWith('--')) throw new Error(`${a} needs a value`);
242
+ opts[a === '--technique' ? 'technique' : 'file'] = v;
243
+ i++;
244
+ } else {
245
+ throw new Error(`unknown argument: ${a}`);
246
+ }
247
+ }
248
+ if (!opts.technique) throw new Error(`--technique <${Object.keys(TOKENS).join('|')}> is required`);
249
+ if (!Object.prototype.hasOwnProperty.call(TOKENS, opts.technique)) throw new Error(`unknown technique: ${opts.technique}`);
250
+ return opts;
251
+ }
252
+
253
+ function main(argv, io = {}) {
254
+ const stdout = io.stdout || process.stdout;
255
+ const stderr = io.stderr || process.stderr;
256
+ const readFile = io.readFile || ((p) => fs.readFileSync(p, 'utf8'));
257
+ const readStdin = io.readStdin || (() => fs.readFileSync(0, 'utf8'));
258
+ let result;
259
+ try {
260
+ const opts = parseArgs(argv);
261
+ const text = opts.file !== null ? readFile(opts.file) : readStdin();
262
+ result = checkReport(text, opts.technique);
263
+ } catch (e) {
264
+ stderr.write(`${JSON.stringify({ error: e.message })}\n`);
265
+ return 2;
266
+ }
267
+ stdout.write(`${JSON.stringify(result)}\n`);
268
+ return 0;
269
+ }
270
+
271
+ // exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
272
+ if (require.main === module) process.exitCode = main(process.argv.slice(2));
273
+
274
+ module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead UAT backfill (self-contained, ships in templates/ so it reaches every
4
+ // install channel). One-time, idempotent migration: tags every open ticket whose
5
+ // body has a `UAT of:` line with the `trailhead:uat` label, so a pending UAT
6
+ // follow-up opened before the label existed is still findable. #261.
7
+ //
8
+ // CLI:
9
+ // node trailhead-uat-backfill.js [--repo <owner/name>] [--marker <path>]
10
+ // --marker makes the run once per clone: a present marker exits `skipped` with no
11
+ // `gh` call; the helper writes it only on a clean run (label confirmed, list read
12
+ // ok, no failed edit).
13
+ //
14
+ // Prints exactly one JSON line on stdout:
15
+ // {"status":"ok"|"skipped"|"error","tagged":[n,...],"failed":[n,...],"reason":"..."}
16
+ // Exit 0 on ok or skipped, 1 on an error, 2 on a usage error (stderr).
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { spawnSync } = require('child_process');
21
+
22
+ const LABEL = 'trailhead:uat';
23
+ const COLOR = 'BFD4F2';
24
+ const DESCRIPTION = 'Pending UAT: an acceptance test still to run';
25
+ const LIMIT = 1000;
26
+ // A line that starts with `UAT of:` (a mid-line mention is not a pointer).
27
+ const UAT_LINE_RE = /^UAT of:/m;
28
+
29
+ function defaultGh(args) {
30
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
31
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
32
+ }
33
+
34
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
35
+
36
+ function parseArgs(argv) {
37
+ const o = { repo: null, marker: null };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const flag = argv[i];
40
+ if (flag !== '--repo' && flag !== '--marker') return { error: `usage: unknown argument: ${flag}` };
41
+ i++;
42
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
43
+ const v = argv[i];
44
+ if (flag === '--repo') {
45
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
46
+ o.repo = v;
47
+ } else {
48
+ o.marker = v;
49
+ }
50
+ }
51
+ return o;
52
+ }
53
+
54
+ const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
55
+
56
+ // Ensures the label exists: idempotent create, then a confirming read. A create
57
+ // failure is only "already exists" when the label is actually there.
58
+ function ensureLabel(gh, rArgs) {
59
+ gh(['label', 'create', LABEL, '--color', COLOR, '--description', DESCRIPTION, ...rArgs]);
60
+ const r = gh(['label', 'list', '--search', LABEL, '--json', 'name', ...rArgs]);
61
+ if (r.status !== 0) return `cannot confirm the ${LABEL} label: ${firstLine(r.stderr) || 'exit ' + r.status}`;
62
+ let list;
63
+ try { list = JSON.parse(r.stdout); } catch { return `cannot confirm the ${LABEL} label: unparseable label list`; }
64
+ return Array.isArray(list) && names(list).includes(LABEL) ? null : `the ${LABEL} label does not exist and could not be created`;
65
+ }
66
+
67
+ function main(argv, { gh, stdout, stderr } = {}) {
68
+ const out = stdout || process.stdout;
69
+ const err = stderr || process.stderr;
70
+ const o = parseArgs(argv || []);
71
+ if (o.error) { err.write(o.error + '\n'); return 2; }
72
+ const run = gh || defaultGh;
73
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
74
+ const fail = (reason) => { emit({ status: 'error', tagged: [], failed: [], reason }); return 1; };
75
+
76
+ if (o.marker && fs.existsSync(o.marker)) { emit({ status: 'skipped', tagged: [], failed: [] }); return 0; }
77
+
78
+ const rArgs = o.repo ? ['-R', o.repo] : [];
79
+ const problem = ensureLabel(run, rArgs);
80
+ if (problem) return fail(problem);
81
+
82
+ const list = run(['issue', 'list', '--state', 'open', '--search', `"UAT of:" in:body -label:${LABEL}`, '--limit', String(LIMIT), '--json', 'number,body,labels', ...rArgs]);
83
+ if (list.status !== 0) return fail(`gh issue list failed: ${firstLine(list.stderr) || 'exit ' + list.status}`);
84
+ let issues;
85
+ try { issues = JSON.parse(list.stdout); } catch { return fail('gh issue list returned unparseable JSON'); }
86
+ if (!Array.isArray(issues)) return fail('gh issue list returned unexpected JSON');
87
+
88
+ const tagged = [];
89
+ const failed = [];
90
+ for (const i of issues) {
91
+ if (names(i.labels).includes(LABEL)) continue;
92
+ if (!UAT_LINE_RE.test(String(i.body || '').replace(/\r/g, ''))) continue;
93
+ const r = run(['issue', 'edit', String(i.number), '--add-label', LABEL, ...rArgs]);
94
+ (r.status === 0 ? tagged : failed).push(i.number);
95
+ }
96
+
97
+ let reason;
98
+ if (o.marker && failed.length === 0) {
99
+ try {
100
+ fs.mkdirSync(path.dirname(o.marker), { recursive: true });
101
+ fs.writeFileSync(o.marker, new Date().toISOString() + '\n');
102
+ } catch (e) {
103
+ reason = `marker not written: ${e && e.message ? e.message : String(e)}`;
104
+ }
105
+ }
106
+ emit(reason ? { status: 'ok', tagged, failed, reason } : { status: 'ok', tagged, failed });
107
+ return 0;
108
+ }
109
+
110
+ module.exports = { main, parseArgs, UAT_LINE_RE };
111
+
112
+ if (require.main === module) {
113
+ process.exit(main(process.argv.slice(2), {}));
114
+ }