@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.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead UAT follow-up finder (self-contained, ships in templates/ so it reaches
|
|
4
|
+
// every install channel). Tells an autonomous run whether a ticket's UAT follow-up
|
|
5
|
+
// already exists, so a resume reuses it instead of opening a second one. #281.
|
|
6
|
+
//
|
|
7
|
+
// CLI:
|
|
8
|
+
// node trailhead-uat-followup.js find --parent <n> --repo <owner/name>
|
|
9
|
+
//
|
|
10
|
+
// Reads every `trailhead:uat` issue (open and closed) with a direct GraphQL query
|
|
11
|
+
// (`gh api graphql`, `repository.issues`), paginated here 100 at a time. It does not
|
|
12
|
+
// use `gh issue list --label`: gh turns a label filter into a search query, and the
|
|
13
|
+
// search index lags, so a follow-up opened seconds earlier would be missed. It keeps
|
|
14
|
+
// the issues whose body has a line starting `UAT of:` that points at the parent: a
|
|
15
|
+
// markdown link target https://github.com/<owner>/<name>/issues/<n> (same repo, exact
|
|
16
|
+
// number) or a bare `#<n>`. A mid-line mention is not a pointer. Only an OPEN
|
|
17
|
+
// follow-up is reusable: `reuse` is the lowest-numbered open one, null when none is
|
|
18
|
+
// open; closed ones are reported but never count. A read that fails, is unparseable,
|
|
19
|
+
// carries GraphQL errors, has an unexpected shape, or exceeds the page cap (100 pages of 100, so 10,000 issues) fails
|
|
20
|
+
// closed (the caller must not open a follow-up blind).
|
|
21
|
+
//
|
|
22
|
+
// Prints exactly one JSON line on stdout:
|
|
23
|
+
// ok: {"status":"ok","parent":n,"open":[{"number","title"}],"closed":[{"number","title"}],"reuse":<n>|null}
|
|
24
|
+
// error: {"status":"error","reason":"..."}
|
|
25
|
+
// Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
|
|
26
|
+
|
|
27
|
+
const { spawnSync } = require('child_process');
|
|
28
|
+
|
|
29
|
+
const MAX_PAGES = 100; // 100 pages x 100 issues
|
|
30
|
+
const QUERY = 'query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:100,after:$after,labels:["trailhead:uat"],states:[OPEN,CLOSED],orderBy:{field:CREATED_AT,direction:ASC}){nodes{number title state body} pageInfo{hasNextPage endCursor}}}}';
|
|
31
|
+
const URL_RE = /https:\/\/github\.com\/([^/\s)]+)\/([^/\s)]+)\/issues\/(\d+)(?![\w])/gi;
|
|
32
|
+
|
|
33
|
+
function defaultGh(args) {
|
|
34
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
35
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
39
|
+
const byNumber = (a, b) => a.number - b.number;
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const cmd = argv[0];
|
|
43
|
+
if (cmd !== 'find') return { error: `usage: expected find, got: ${cmd === undefined ? '(nothing)' : cmd}` };
|
|
44
|
+
const o = { cmd, parent: null, repo: null };
|
|
45
|
+
for (let i = 1; i < argv.length; i++) {
|
|
46
|
+
const flag = argv[i];
|
|
47
|
+
if (flag !== '--parent' && flag !== '--repo') return { error: `usage: unknown argument: ${flag}` };
|
|
48
|
+
i++;
|
|
49
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
50
|
+
const v = argv[i];
|
|
51
|
+
if (flag === '--repo') {
|
|
52
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
53
|
+
o.repo = v;
|
|
54
|
+
} else {
|
|
55
|
+
if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --parent must be a positive integer, got: ${v}` };
|
|
56
|
+
o.parent = Number(v);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!o.repo) return { error: 'usage: find requires --repo' };
|
|
60
|
+
if (!o.parent) return { error: 'usage: find requires --parent' };
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pure: does a `UAT of:` line of the body point at issue `parent` of `repo`?
|
|
65
|
+
function pointsAt(body, parent, repo) {
|
|
66
|
+
const want = repo.toLowerCase();
|
|
67
|
+
const lines = String(body || '').replace(/\r/g, '').split('\n');
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const m = /^UAT of:(.*)$/.exec(line);
|
|
70
|
+
if (!m) continue;
|
|
71
|
+
const rest = m[1];
|
|
72
|
+
const urls = [...rest.matchAll(URL_RE)];
|
|
73
|
+
if (urls.length) {
|
|
74
|
+
if (urls.some((u) => `${u[1]}/${u[2]}`.toLowerCase() === want && Number(u[3]) === parent)) return true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const bare = [...rest.matchAll(/(?:^|[^\w/])#(\d+)\b/g)];
|
|
78
|
+
if (bare.some((b) => Number(b[1]) === parent)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function main(argv, { gh, stdout, stderr } = {}) {
|
|
84
|
+
const out = stdout || process.stdout;
|
|
85
|
+
const err = stderr || process.stderr;
|
|
86
|
+
const o = parseArgs(argv || []);
|
|
87
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
88
|
+
const run = gh || defaultGh;
|
|
89
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
90
|
+
const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
|
|
91
|
+
|
|
92
|
+
const [owner, name] = o.repo.split('/');
|
|
93
|
+
const rows = [];
|
|
94
|
+
let after = null;
|
|
95
|
+
for (let page = 1; ; page++) {
|
|
96
|
+
if (page > MAX_PAGES) return fail(`incomplete read: more than ${MAX_PAGES} pages of trailhead:uat issues`);
|
|
97
|
+
const args = ['api', 'graphql', '-f', `query=${QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`];
|
|
98
|
+
if (after !== null) args.push('-f', `after=${after}`);
|
|
99
|
+
const r = run(args);
|
|
100
|
+
if (r.status !== 0) return fail(`gh api graphql failed: ${firstLine(r.stderr) || 'exit ' + r.status}`);
|
|
101
|
+
let body;
|
|
102
|
+
try { body = JSON.parse(r.stdout); } catch { return fail('gh api graphql returned unparseable JSON'); }
|
|
103
|
+
if (!body || typeof body !== 'object') return fail('gh api graphql returned unexpected JSON');
|
|
104
|
+
if (Array.isArray(body.errors) && body.errors.length) return fail(`graphql errors: ${firstLine(body.errors[0] && body.errors[0].message) || 'unknown'}`);
|
|
105
|
+
const repo = body.data && body.data.repository;
|
|
106
|
+
if (!repo) return fail('repository not found in the graphql response');
|
|
107
|
+
const issues = repo.issues;
|
|
108
|
+
if (!issues || !Array.isArray(issues.nodes)) return fail('graphql response has no issues.nodes');
|
|
109
|
+
const info = issues.pageInfo;
|
|
110
|
+
if (!info || typeof info.hasNextPage !== 'boolean') return fail('graphql response has no pageInfo');
|
|
111
|
+
rows.push(...issues.nodes.filter((n) => n && typeof n === 'object'));
|
|
112
|
+
if (!info.hasNextPage) break;
|
|
113
|
+
if (typeof info.endCursor !== 'string' || !info.endCursor) return fail('graphql pageInfo has hasNextPage without an endCursor');
|
|
114
|
+
after = info.endCursor;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const mine = rows.filter((i) => pointsAt(i.body, o.parent, o.repo)).map((i) => ({ number: i.number, title: i.title, state: String(i.state || '').toUpperCase() }));
|
|
118
|
+
const pick = (open) => mine.filter((i) => (i.state === 'OPEN') === open).sort(byNumber).map(({ number, title }) => ({ number, title }));
|
|
119
|
+
const open = pick(true);
|
|
120
|
+
emit({ status: 'ok', parent: o.parent, open, closed: pick(false), reuse: open.length ? open[0].number : null });
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { main, parseArgs, pointsAt };
|
|
125
|
+
|
|
126
|
+
if (require.main === module) {
|
|
127
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
128
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead UAT setup (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
|
|
5
|
+
// and Codex). Reads the project's `uat.setup` / `uat.accounts` config, reports
|
|
6
|
+
// what a UAT needs, and runs the setup steps in order. #252.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-uat-setup.js plan --repo <working-root> [--config-root <project-root>]
|
|
10
|
+
// node trailhead-uat-setup.js run --repo <working-root> [--config-root <project-root>]
|
|
11
|
+
// [--from <i>] [--only <i>] [--ready-timeout <s>] [--step-timeout <s>]
|
|
12
|
+
// --config-root is the original checkout the effective config was loaded from
|
|
13
|
+
// (defaults to --repo); a worktree/clone workspace never re-resolves config.
|
|
14
|
+
//
|
|
15
|
+
// Config: `uat.setup` (ordered array of {name, run, check?, note?}) and `uat.accounts`
|
|
16
|
+
// ({role: {user, secret}}, secret a bare $ENV reference, never a literal), nested
|
|
17
|
+
// ({"uat":{"setup":[...]}}) or flat ({"uat.setup":[...]}); nested wins when a file
|
|
18
|
+
// carries both. The project file (read from --config-root) replaces the global one
|
|
19
|
+
// (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) key by key as a whole value.
|
|
20
|
+
//
|
|
21
|
+
// plan: validates, lists the steps (index, name, note, check present) and the $ENV
|
|
22
|
+
// names each step / account references with whether each is set. Runs nothing, prints
|
|
23
|
+
// no value.
|
|
24
|
+
// run: per step, every referenced $NAME / ${NAME} must be set and non-empty (else
|
|
25
|
+
// missing-env, nothing runs); a passing `check` -> skipped; else `run` via `sh -c` at
|
|
26
|
+
// --repo (non-zero -> failed); with a `check`, poll it after `run` until it passes or
|
|
27
|
+
// --ready-timeout (default 60 s) elapses (-> not-ready). Each run/check invocation is
|
|
28
|
+
// killed after --step-timeout (default 600 s): a run -> timeout; a check counts as
|
|
29
|
+
// failing; while polling, each check is capped at the remaining ready time. Stops at the
|
|
30
|
+
// first failing step. A `run`'s output goes to a temp file, never a pipe, so a backgrounded
|
|
31
|
+
// process (a dev server) cannot block the helper; it should redirect its own output, else
|
|
32
|
+
// it keeps writing to that (deleted) file. A `check`'s output is ignored.
|
|
33
|
+
// `$NAME` / `${NAME}` inside single quotes, in a `#` comment or written `\$NAME` is not a
|
|
34
|
+
// reference; with an unbalanced quote every `$NAME` counts. Timeouts are capped at 86400 s.
|
|
35
|
+
// Prints one JSON line:
|
|
36
|
+
// {"status","steps":[{index,name,result,code?,output?,missing?}],"failedStep"?,
|
|
37
|
+
// "accounts"?,"warnings":[],"message"?}
|
|
38
|
+
// status: unconfigured | none | invalid | ready | failed
|
|
39
|
+
// result: skipped | ran | failed | missing-env | not-ready | timeout
|
|
40
|
+
// Only the failing step carries `output`: the last 2000 chars, with the value of every
|
|
41
|
+
// referenced variable redacted to its $NAME (best effort, not a guarantee).
|
|
42
|
+
// Fail closed: only ready and none let the caller go on; unconfigured goes to the probe.
|
|
43
|
+
// Exit 0 whenever a JSON line is printed; exit 2 only on a usage error.
|
|
44
|
+
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
const os = require('os');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const { spawnSync } = require('child_process');
|
|
49
|
+
|
|
50
|
+
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
51
|
+
const OUTPUT_TAIL = 2000;
|
|
52
|
+
const READ_BYTES = 65536;
|
|
53
|
+
const POLL_MS = 500;
|
|
54
|
+
const DEFAULT_READY_S = 60;
|
|
55
|
+
const DEFAULT_STEP_S = 600;
|
|
56
|
+
const MAX_TIMEOUT_S = 86400;
|
|
57
|
+
const STEP_KEYS = ['name', 'run', 'check', 'note'];
|
|
58
|
+
const ACCOUNT_KEYS = ['user', 'secret'];
|
|
59
|
+
const VALUED = ['repo', 'config-root', 'from', 'only', 'ready-timeout', 'step-timeout'];
|
|
60
|
+
const COMMANDS = ['plan', 'run'];
|
|
61
|
+
const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
62
|
+
const BARE_REF = /^(?:\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\})$/;
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const command = argv[0];
|
|
66
|
+
if (!COMMANDS.includes(command)) return { error: `usage: first argument must be one of ${COMMANDS.join(', ')}` };
|
|
67
|
+
const out = { command };
|
|
68
|
+
for (let i = 1; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i];
|
|
70
|
+
const name = a.startsWith('--') ? a.slice(2) : null;
|
|
71
|
+
if (!name || !VALUED.includes(name)) return { error: `usage: unknown argument ${a}` };
|
|
72
|
+
const v = argv[++i];
|
|
73
|
+
if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
|
|
74
|
+
out[name] = v;
|
|
75
|
+
}
|
|
76
|
+
if (!out.repo) return { error: 'usage: missing --repo <working-root>' };
|
|
77
|
+
for (const k of ['from', 'only']) {
|
|
78
|
+
if (out[k] !== undefined && !/^\d+$/.test(out[k])) return { error: `usage: --${k} needs a non-negative integer` };
|
|
79
|
+
}
|
|
80
|
+
for (const k of ['ready-timeout', 'step-timeout']) {
|
|
81
|
+
if (out[k] !== undefined && !(Number.isFinite(Number(out[k])) && Number(out[k]) > 0 && Number(out[k]) <= MAX_TIMEOUT_S)) return { error: `usage: --${k} needs a positive number of seconds, at most ${MAX_TIMEOUT_S}` };
|
|
82
|
+
}
|
|
83
|
+
if (out.from !== undefined && out.only !== undefined) return { error: 'usage: --from and --only are exclusive' };
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
88
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
89
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
|
|
90
|
+
|
|
91
|
+
// Reads and parses one config file. { state: 'unset' } | { state: 'ok', cfg } | { state: 'invalid', message }
|
|
92
|
+
function readFile(file, fs_) {
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err && err.code === 'ENOENT') return { state: 'unset' };
|
|
98
|
+
return { state: 'invalid', message: `cannot read ${file}: ${err && err.message ? err.message : err}` };
|
|
99
|
+
}
|
|
100
|
+
let cfg;
|
|
101
|
+
try {
|
|
102
|
+
cfg = JSON.parse(text);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { state: 'invalid', message: `${file} is not valid JSON` };
|
|
105
|
+
}
|
|
106
|
+
if (!isObject(cfg)) return { state: 'invalid', message: `${file} is not a JSON object` };
|
|
107
|
+
if (has(cfg, 'uat') && !isObject(cfg.uat)) return { state: 'invalid', message: `uat in ${file} must be an object` };
|
|
108
|
+
return { state: 'ok', cfg };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Picks `uat.<key>` out of a parsed config: { found, value, warning? }.
|
|
112
|
+
function pick(cfg, key, file) {
|
|
113
|
+
const nested = isObject(cfg.uat) && has(cfg.uat, key);
|
|
114
|
+
const flat = has(cfg, `uat.${key}`);
|
|
115
|
+
if (!nested && !flat) return { found: false };
|
|
116
|
+
const out = { found: true, value: nested ? cfg.uat[key] : cfg[`uat.${key}`] };
|
|
117
|
+
if (nested && flat) out.warning = `${file} sets uat.${key} in both nested and flat form; the nested form wins`;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function globalConfigPath(env) {
|
|
122
|
+
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
123
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateSetup(value, where) {
|
|
127
|
+
if (!Array.isArray(value)) return `uat.setup in ${where} must be an array of steps`;
|
|
128
|
+
for (let i = 0; i < value.length; i++) {
|
|
129
|
+
const s = value[i];
|
|
130
|
+
if (!isObject(s)) return `uat.setup[${i}] in ${where} must be an object`;
|
|
131
|
+
const extra = Object.keys(s).find((k) => !STEP_KEYS.includes(k));
|
|
132
|
+
if (extra) return `uat.setup[${i}] in ${where} has an unknown key: ${extra}`;
|
|
133
|
+
if (!nonEmpty(s.name)) return `uat.setup[${i}] in ${where} needs a non-empty string name`;
|
|
134
|
+
if (!nonEmpty(s.run)) return `uat.setup[${i}] in ${where} needs a non-empty string run`;
|
|
135
|
+
for (const k of ['check', 'note']) {
|
|
136
|
+
if (has(s, k) && !nonEmpty(s[k])) return `uat.setup[${i}].${k} in ${where} must be a non-empty string`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateAccounts(value, where) {
|
|
143
|
+
if (!isObject(value)) return `uat.accounts in ${where} must be an object of role -> {user, secret}`;
|
|
144
|
+
for (const [role, a] of Object.entries(value)) {
|
|
145
|
+
if (!isObject(a)) return `uat.accounts.${role} in ${where} must be an object`;
|
|
146
|
+
const extra = Object.keys(a).find((k) => !ACCOUNT_KEYS.includes(k));
|
|
147
|
+
if (extra) return `uat.accounts.${role} in ${where} has an unknown key: ${extra}`;
|
|
148
|
+
if (!nonEmpty(a.user)) return `uat.accounts.${role} in ${where} needs a non-empty string user`;
|
|
149
|
+
if (typeof a.secret !== 'string' || !BARE_REF.test(a.secret)) {
|
|
150
|
+
return `uat.accounts.${role}.secret in ${where} must be a bare $ENV reference ($NAME or \${NAME}), never a literal`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const VALIDATORS = { setup: validateSetup, accounts: validateAccounts };
|
|
157
|
+
|
|
158
|
+
// Effective { setup, accounts } (undefined when unset) or { error }. The project file
|
|
159
|
+
// replaces the global one key by key as a whole value.
|
|
160
|
+
function readConfig(configRoot, env, fs_, warnings) {
|
|
161
|
+
const files = [path.join(configRoot, CONFIG_REL), globalConfigPath(env)];
|
|
162
|
+
const parsed = [];
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
const r = readFile(file, fs_);
|
|
165
|
+
if (r.state === 'invalid') return { error: r.message };
|
|
166
|
+
parsed.push({ file, cfg: r.state === 'ok' ? r.cfg : null });
|
|
167
|
+
}
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const key of ['setup', 'accounts']) {
|
|
170
|
+
for (const { file, cfg } of parsed) {
|
|
171
|
+
if (!cfg) continue;
|
|
172
|
+
const p = pick(cfg, key, file);
|
|
173
|
+
if (!p.found) continue;
|
|
174
|
+
const problem = VALIDATORS[key](p.value, file);
|
|
175
|
+
if (problem) return { error: problem };
|
|
176
|
+
if (p.warning) warnings.push(p.warning);
|
|
177
|
+
out[key] = p.value;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Text with the spans that are never env references blanked out: single-quoted spans
|
|
185
|
+
// and backslash-escaped characters (`\$`) and comments (a `#` at a word start outside quotes,
|
|
186
|
+
// to end of line). A single quote inside double quotes is literal. Fail closed: a quote span
|
|
187
|
+
// still open at the end drops every exemption, so every `$NAME` counts.
|
|
188
|
+
function liveText(t) {
|
|
189
|
+
let out = '';
|
|
190
|
+
let single = false;
|
|
191
|
+
let double = false;
|
|
192
|
+
for (let i = 0; i < t.length; i++) {
|
|
193
|
+
const c = t[i];
|
|
194
|
+
if (single) {
|
|
195
|
+
if (c === "'") single = false;
|
|
196
|
+
out += ' ';
|
|
197
|
+
} else if (c === '\\') {
|
|
198
|
+
i++;
|
|
199
|
+
out += ' ';
|
|
200
|
+
} else if (c === '#' && !double && (i === 0 || /[\s;&|()]/.test(t[i - 1]))) {
|
|
201
|
+
while (i < t.length && t[i] !== '\n') { out += ' '; i++; }
|
|
202
|
+
if (i < t.length) out += '\n';
|
|
203
|
+
} else if (c === "'" && !double) {
|
|
204
|
+
single = true;
|
|
205
|
+
out += ' ';
|
|
206
|
+
} else {
|
|
207
|
+
if (c === '"') double = !double;
|
|
208
|
+
out += c;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return single || double ? t : out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function refsOf(...texts) {
|
|
215
|
+
const names = [];
|
|
216
|
+
for (const t of texts) {
|
|
217
|
+
if (typeof t !== 'string') continue;
|
|
218
|
+
for (const m of liveText(t).matchAll(REF)) {
|
|
219
|
+
const n = m[1] || m[2];
|
|
220
|
+
if (!names.includes(n)) names.push(n);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return names;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const bareName = (s) => { const m = BARE_REF.exec(s); return m ? m[1] || m[2] : null; };
|
|
227
|
+
|
|
228
|
+
function describe(setup, accounts, env) {
|
|
229
|
+
const state = (name) => ({ name, set: typeof env[name] === 'string' && env[name] !== '' });
|
|
230
|
+
const steps = setup.map((s, index) => ({
|
|
231
|
+
index,
|
|
232
|
+
name: s.name,
|
|
233
|
+
...(s.note ? { note: s.note } : {}),
|
|
234
|
+
check: Boolean(s.check),
|
|
235
|
+
env: refsOf(s.run, s.check).map(state),
|
|
236
|
+
}));
|
|
237
|
+
const accts = Object.entries(accounts).map(([role, a]) => {
|
|
238
|
+
const userEnv = bareName(a.user);
|
|
239
|
+
return { role, user: userEnv ? null : a.user, userEnv: userEnv ? state(userEnv) : null, secretEnv: state(bareName(a.secret)) };
|
|
240
|
+
});
|
|
241
|
+
return { steps, accounts: accts };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
245
|
+
|
|
246
|
+
// Redacts the value of every referenced variable to its $NAME, longest value first.
|
|
247
|
+
function redactor(names, env) {
|
|
248
|
+
const pairs = names
|
|
249
|
+
.filter((n) => typeof env[n] === 'string' && env[n] !== '')
|
|
250
|
+
.map((n) => [n, env[n]])
|
|
251
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
252
|
+
return (text) => pairs.reduce((t, [n, v]) => t.split(v).join(`$${n}`), text);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Runs `sh -c command` at repo; the output of a `run` goes to a temp file (a check's is ignored). Returns
|
|
256
|
+
// { code, timedOut, tail } where tail is the bounded last chunk of the output.
|
|
257
|
+
function sh(command, repo, env, timeoutMs, wantOutput) {
|
|
258
|
+
const timeout = Math.max(1, Math.ceil(timeoutMs));
|
|
259
|
+
if (!wantOutput) {
|
|
260
|
+
const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
|
|
261
|
+
const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
|
|
262
|
+
return { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
|
|
263
|
+
}
|
|
264
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-uat-'));
|
|
265
|
+
const file = path.join(dir, 'out');
|
|
266
|
+
const fd = fs.openSync(file, 'w');
|
|
267
|
+
try {
|
|
268
|
+
const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
|
|
269
|
+
const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
|
|
270
|
+
const res = { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
|
|
271
|
+
if (wantOutput) {
|
|
272
|
+
const size = fs.fstatSync(fd).size;
|
|
273
|
+
const len = Math.min(size, READ_BYTES);
|
|
274
|
+
const buf = Buffer.alloc(len);
|
|
275
|
+
const rfd = fs.openSync(file, 'r');
|
|
276
|
+
try { fs.readSync(rfd, buf, 0, len, size - len); } finally { fs.closeSync(rfd); }
|
|
277
|
+
res.tail = buf.toString('utf8');
|
|
278
|
+
}
|
|
279
|
+
return res;
|
|
280
|
+
} finally {
|
|
281
|
+
fs.closeSync(fd);
|
|
282
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const passes = (r) => !r.timedOut && !r.spawnError && r.code === 0;
|
|
287
|
+
|
|
288
|
+
function finalTail(text, redact, extra) {
|
|
289
|
+
const t = redact(text) + (extra || '');
|
|
290
|
+
return t.length > OUTPUT_TAIL ? '...' + t.slice(t.length - OUTPUT_TAIL) : t;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function runStep(step, index, ctx) {
|
|
294
|
+
const { repo, env, stepMs, readyMs, redact } = ctx;
|
|
295
|
+
const base = { index, name: step.name };
|
|
296
|
+
const missing = refsOf(step.run, step.check).filter((n) => typeof env[n] !== 'string' || env[n] === '');
|
|
297
|
+
if (missing.length > 0) return { ...base, result: 'missing-env', missing };
|
|
298
|
+
if (step.check && passes(sh(step.check, repo, env, stepMs, false))) return { ...base, result: 'skipped' };
|
|
299
|
+
const r = sh(step.run, repo, env, stepMs, true);
|
|
300
|
+
if (r.timedOut) return { ...base, result: 'timeout', output: finalTail(r.tail, redact, `\n(timed out after ${stepMs / 1000}s)`) };
|
|
301
|
+
if (r.spawnError || r.code !== 0) {
|
|
302
|
+
return { ...base, result: 'failed', code: r.code, output: finalTail(r.tail, redact, r.spawnError ? `\n${r.spawnError}` : '') };
|
|
303
|
+
}
|
|
304
|
+
if (!step.check) return { ...base, result: 'ran' };
|
|
305
|
+
const deadline = Date.now() + readyMs;
|
|
306
|
+
for (;;) {
|
|
307
|
+
const remaining = deadline - Date.now();
|
|
308
|
+
if (passes(sh(step.check, repo, env, Math.max(1, Math.min(stepMs, remaining)), false))) return { ...base, result: 'ran' };
|
|
309
|
+
const left = deadline - Date.now();
|
|
310
|
+
if (left <= 0) break;
|
|
311
|
+
sleep(Math.min(POLL_MS, left));
|
|
312
|
+
}
|
|
313
|
+
return { ...base, result: 'not-ready', output: finalTail(r.tail, redact, `\n(check "${step.check}" did not pass within ${readyMs / 1000}s)`) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function execute(parsed, cwd, env, fs_) {
|
|
317
|
+
const repo = path.resolve(cwd, parsed.repo);
|
|
318
|
+
const configRoot = path.resolve(cwd, parsed['config-root'] || parsed.repo);
|
|
319
|
+
const warnings = [];
|
|
320
|
+
const result = { status: 'invalid', steps: [], warnings };
|
|
321
|
+
const cfg = readConfig(configRoot, env, fs_, warnings);
|
|
322
|
+
if (cfg.error) return { ...result, message: cfg.error };
|
|
323
|
+
|
|
324
|
+
const accounts = cfg.accounts || {};
|
|
325
|
+
const merged = { ...process.env, ...env };
|
|
326
|
+
const view = describe(cfg.setup || [], accounts, merged);
|
|
327
|
+
const accountsOut = view.accounts.length > 0 ? { accounts: view.accounts } : {};
|
|
328
|
+
if (cfg.setup === undefined) return { ...result, status: 'unconfigured', ...accountsOut };
|
|
329
|
+
if (cfg.setup.length === 0) return { ...result, status: 'none', ...accountsOut };
|
|
330
|
+
|
|
331
|
+
if (parsed.command === 'plan') return { ...result, status: 'ready', steps: view.steps, ...accountsOut };
|
|
332
|
+
|
|
333
|
+
const total = cfg.setup.length;
|
|
334
|
+
const pickIndex = parsed.only !== undefined ? Number(parsed.only) : parsed.from !== undefined ? Number(parsed.from) : 0;
|
|
335
|
+
if (pickIndex >= total) return { ...result, message: `step index ${pickIndex} is out of range (${total} steps)` };
|
|
336
|
+
const indexes = parsed.only !== undefined ? [pickIndex] : cfg.setup.map((_, i) => i).filter((i) => i >= pickIndex);
|
|
337
|
+
|
|
338
|
+
const names = [...new Set([...view.steps.flatMap((s) => s.env.map((e) => e.name)), ...view.accounts.flatMap((a) => [a.userEnv, a.secretEnv].filter(Boolean).map((e) => e.name))])];
|
|
339
|
+
const ctx = {
|
|
340
|
+
repo,
|
|
341
|
+
env: merged,
|
|
342
|
+
stepMs: Number(parsed['step-timeout'] || DEFAULT_STEP_S) * 1000,
|
|
343
|
+
readyMs: Number(parsed['ready-timeout'] || DEFAULT_READY_S) * 1000,
|
|
344
|
+
redact: redactor(names, merged),
|
|
345
|
+
};
|
|
346
|
+
for (const i of indexes) {
|
|
347
|
+
let r;
|
|
348
|
+
try {
|
|
349
|
+
r = runStep(cfg.setup[i], i, ctx);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return { ...result, message: 'error: ' + (err && err.message ? err.message : String(err)) };
|
|
352
|
+
}
|
|
353
|
+
result.steps.push(r);
|
|
354
|
+
if (!['skipped', 'ran'].includes(r.result)) return { ...result, status: 'failed', failedStep: i };
|
|
355
|
+
}
|
|
356
|
+
return { ...result, status: 'ready' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
360
|
+
const cwd_ = cwd || process.cwd();
|
|
361
|
+
const out = stdout || process.stdout;
|
|
362
|
+
const env_ = env || process.env;
|
|
363
|
+
const fs_ = fsMod || fs;
|
|
364
|
+
|
|
365
|
+
const parsed = parseArgs(argv || []);
|
|
366
|
+
if (parsed.error) {
|
|
367
|
+
process.stderr.write(parsed.error + '\n');
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
let result;
|
|
371
|
+
try {
|
|
372
|
+
result = execute(parsed, cwd_, env_, fs_);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
result = { status: 'invalid', steps: [], warnings: [], message: 'error: ' + (err && err.message ? err.message : String(err)) };
|
|
375
|
+
}
|
|
376
|
+
out.write(JSON.stringify(result) + '\n');
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { parseArgs, readConfig, main };
|
|
381
|
+
|
|
382
|
+
if (require.main === module) {
|
|
383
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|
|
384
|
+
process.exit(code);
|
|
385
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead unblock helper (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). After an autonomous run closes a ticket, removes `trailhead:blocked`
|
|
5
|
+
// from every open dependent whose blockers are now all closed, so the next frontier
|
|
6
|
+
// query returns it. #286.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-unblock.js --issue <n> --repo <owner/name>
|
|
10
|
+
//
|
|
11
|
+
// Reads the closed ticket's dependents (`dependencies/blocking`), and for each OPEN
|
|
12
|
+
// dependent that carries `trailhead:blocked` reads its `blocked_by`; when every blocker
|
|
13
|
+
// is closed it removes the label. Reads are paginated: `gh api --paginate --jq` prints
|
|
14
|
+
// one JSON object per line across every page, so a long list is read whole. A dependent
|
|
15
|
+
// without the label, or a closed one, is skipped, so a retry is idempotent. The first
|
|
16
|
+
// failed or unparseable gh step stops the helper: nothing further is edited.
|
|
17
|
+
//
|
|
18
|
+
// Prints exactly one JSON line on stdout:
|
|
19
|
+
// ok: {"status":"ok","unblocked":[n...],"stillBlocked":[{"issue":n,"openBlockers":[...]}]}
|
|
20
|
+
// error: {"status":"error","reason":"..."}
|
|
21
|
+
// Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
|
|
22
|
+
|
|
23
|
+
const { spawnSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const LABEL = 'trailhead:blocked';
|
|
26
|
+
|
|
27
|
+
function defaultGh(args) {
|
|
28
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
29
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const o = { issue: null, repo: null };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const flag = argv[i];
|
|
38
|
+
if (flag !== '--issue' && flag !== '--repo') return { error: `usage: unknown argument: ${flag}` };
|
|
39
|
+
i++;
|
|
40
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
41
|
+
const v = argv[i];
|
|
42
|
+
if (flag === '--repo') {
|
|
43
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
44
|
+
o.repo = v;
|
|
45
|
+
} else {
|
|
46
|
+
if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --issue must be a positive integer, got: ${v}` };
|
|
47
|
+
o.issue = Number(v);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!o.repo) return { error: 'usage: requires --repo' };
|
|
51
|
+
if (!o.issue) return { error: 'usage: requires --issue' };
|
|
52
|
+
return o;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One JSON object per line -> { items } or { error }.
|
|
56
|
+
function parseLines(text, what) {
|
|
57
|
+
const items = [];
|
|
58
|
+
for (const line of String(text).split('\n')) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
let j;
|
|
61
|
+
try { j = JSON.parse(line); } catch { return { error: `${what} returned unparseable JSON` }; }
|
|
62
|
+
if (!j || typeof j !== 'object') return { error: `${what} returned unexpected JSON` };
|
|
63
|
+
items.push(j);
|
|
64
|
+
}
|
|
65
|
+
return { items };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Paginated read of a dependency list for issue n. { items } or { error }.
|
|
69
|
+
function readList(run, o, n, kind, jq) {
|
|
70
|
+
const what = `gh api dependencies/${kind} of #${n}`;
|
|
71
|
+
const r = run(['api', '--paginate', `repos/${o.repo}/issues/${n}/dependencies/${kind}`, '--jq', jq]);
|
|
72
|
+
if (r.status !== 0) return { error: `${what} failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
|
|
73
|
+
return parseLines(r.stdout, what);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isOpen = (x) => String(x.state || '').toLowerCase() === 'open';
|
|
77
|
+
|
|
78
|
+
function main(argv, { gh, stdout, stderr } = {}) {
|
|
79
|
+
const out = stdout || process.stdout;
|
|
80
|
+
const err = stderr || process.stderr;
|
|
81
|
+
const o = parseArgs(argv || []);
|
|
82
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
83
|
+
const run = gh || defaultGh;
|
|
84
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
85
|
+
const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
|
|
86
|
+
|
|
87
|
+
const deps = readList(run, o, o.issue, 'blocking', '.[] | {number, state, labels: [.labels[].name]}');
|
|
88
|
+
if (deps.error) return fail(deps.error);
|
|
89
|
+
|
|
90
|
+
const unblocked = [];
|
|
91
|
+
const stillBlocked = [];
|
|
92
|
+
for (const d of deps.items) {
|
|
93
|
+
const labels = Array.isArray(d.labels) ? d.labels : [];
|
|
94
|
+
if (!isOpen(d) || !labels.includes(LABEL)) continue;
|
|
95
|
+
const blockers = readList(run, o, d.number, 'blocked_by', '.[] | {number, state}');
|
|
96
|
+
if (blockers.error) return fail(blockers.error);
|
|
97
|
+
const open = blockers.items.filter(isOpen).map((b) => b.number);
|
|
98
|
+
if (open.length) { stillBlocked.push({ issue: d.number, openBlockers: open }); continue; }
|
|
99
|
+
const r = run(['issue', 'edit', String(d.number), '--remove-label', LABEL, '-R', o.repo]);
|
|
100
|
+
if (r.status !== 0) return fail(`gh issue edit (remove label) of #${d.number} failed: ${firstLine(r.stderr) || 'exit ' + r.status}`);
|
|
101
|
+
unblocked.push(d.number);
|
|
102
|
+
}
|
|
103
|
+
emit({ status: 'ok', unblocked, stillBlocked });
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { main, parseArgs, parseLines };
|
|
108
|
+
|
|
109
|
+
if (require.main === module) {
|
|
110
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
111
|
+
}
|