@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.
- package/README.it.md +52 -14
- package/README.md +53 -15
- 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/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- 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 +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -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/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -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 +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- 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 +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- 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 +21 -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-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- 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/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- 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/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- 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-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- 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 +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- 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 +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- 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 +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead plan review rounds (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm and Codex).
|
|
5
|
+
// Backs the autonomous run's "External review rounds" start-up ask and the per-round
|
|
6
|
+
// convergence decision of Cross-AI plan review. #284.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-plan-review-rounds.js ask --repo <project-root> [--host claude|codex]
|
|
10
|
+
// node trailhead-plan-review-rounds.js round --choice <config|1|2|3|until> --configured <n> --history <file>
|
|
11
|
+
// Prints one JSON line on stdout. Exit 0 ok / 1 error / 2 usage (message on stderr).
|
|
12
|
+
//
|
|
13
|
+
// Config: `plan_review` (off | on | comma list of CLIs, default off) and `plan_review.rounds`
|
|
14
|
+
// (positive integer, default 2), flat keys. The project file (<project-root>/.trailhead/config.json)
|
|
15
|
+
// and the global one (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) are read key by key,
|
|
16
|
+
// nearest wins. A non-positive or non-integer rounds value falls back to 2 with a warning.
|
|
17
|
+
//
|
|
18
|
+
// ask: resolves the candidate CLIs (`on` = the known roster, a list = only those), keeps the ones
|
|
19
|
+
// whose binary is on PATH (kimi only when `kimi --help` mentions --output-format) and drops the
|
|
20
|
+
// host's own CLI (claude by default, codex with --host codex). A name in an explicit list that is
|
|
21
|
+
// not in the roster (a custom reviewer) counts only when a binary of that name is on PATH; else it
|
|
22
|
+
// is listed under `unknown` with a warning and does not count.
|
|
23
|
+
// {"status":"skip","reason":"off"|"no-reviewer","configured":n,"unknown":[],"warnings":[]}
|
|
24
|
+
// {"status":"ask","configured":n,"reviewers":[],"unknown":[],"warnings":[],"options":[],"fallback":[]}
|
|
25
|
+
// {"status":"error","reason":<text>} (unreadable or invalid config; exit 1)
|
|
26
|
+
// `options` is the AskUserQuestion menu, at most four entries (the tool's cap); each entry is
|
|
27
|
+
// {"value":"config"|"1"|"2"|"3"|"until","label":<text>,"recommended"?:true}. With n in 1..3 it is
|
|
28
|
+
// config (Recommended), the other two counts, until; otherwise config, 1, 2, until (3 stays
|
|
29
|
+
// reachable through the typed Other answer). `fallback` is the numbered list for a host without a
|
|
30
|
+
// structured question tool: config, 1, 2, 3, until (same entry shape, all five).
|
|
31
|
+
//
|
|
32
|
+
// round: --history is a JSON array, one entry per review round done so far, each an array of the
|
|
33
|
+
// blocking concerns still open after that round; a concern is {"key","text"}: `key` a short stable
|
|
34
|
+
// id reused across rounds (compared trimmed, lower-cased, whitespace collapsed), `text` the full
|
|
35
|
+
// human-readable concern. A malformed history is a usage error (exit 2).
|
|
36
|
+
// {"status":"ok","decision":<d>,"rounds":<rounds done>,"surviving"?:[{key,text}]}
|
|
37
|
+
// decision, first match wins: converged (latest round has no blocking concern) | no-progress
|
|
38
|
+
// (choice until, round 2 or later: the latest round resolved none of the previous round's
|
|
39
|
+
// concerns) | ceiling (choice until: 5 rounds done) | exhausted (fixed choice: its rounds are
|
|
40
|
+
// spent) | continue. Every stop but converged carries `surviving`, the latest round's concerns.
|
|
41
|
+
// `config` resolves to --configured.
|
|
42
|
+
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const os = require('os');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
const { spawnSync } = require('child_process');
|
|
47
|
+
|
|
48
|
+
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
49
|
+
const ROSTER = ['claude', 'gemini', 'codex', 'qwen', 'kimi', 'agy', 'opencode', 'cursor-agent', 'coderabbit'];
|
|
50
|
+
const DEFAULT_ROUNDS = 2;
|
|
51
|
+
const CEILING = 5;
|
|
52
|
+
const HOSTS = ['claude', 'codex'];
|
|
53
|
+
const CHOICES = ['config', '1', '2', '3', 'until'];
|
|
54
|
+
const VALUED = { ask: ['repo', 'host'], round: ['choice', 'configured', 'history'] };
|
|
55
|
+
|
|
56
|
+
class Usage extends Error {}
|
|
57
|
+
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const command = argv[0];
|
|
60
|
+
if (!Object.keys(VALUED).includes(command)) return { error: `usage: first argument must be one of ${Object.keys(VALUED).join(', ')}` };
|
|
61
|
+
const out = { command };
|
|
62
|
+
for (let i = 1; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
const name = a.startsWith('--') ? a.slice(2) : null;
|
|
65
|
+
if (!name || !VALUED[command].includes(name)) return { error: `usage: unknown argument ${a}` };
|
|
66
|
+
const v = argv[++i];
|
|
67
|
+
if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
|
|
68
|
+
out[name] = v;
|
|
69
|
+
}
|
|
70
|
+
if (command === 'ask') {
|
|
71
|
+
if (!out.repo) return { error: 'usage: missing --repo <project-root>' };
|
|
72
|
+
if (out.host !== undefined && !HOSTS.includes(out.host)) return { error: `usage: --host must be one of ${HOSTS.join(', ')}` };
|
|
73
|
+
} else {
|
|
74
|
+
if (!out.choice || !CHOICES.includes(out.choice)) return { error: `usage: --choice must be one of ${CHOICES.join(', ')}` };
|
|
75
|
+
if (!out.configured || !/^[1-9]\d*$/.test(out.configured)) return { error: 'usage: --configured needs a positive integer' };
|
|
76
|
+
if (!out.history) return { error: 'usage: missing --history <file>' };
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
82
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
83
|
+
|
|
84
|
+
// Reads one config file. { cfg } (null when absent) or { error }.
|
|
85
|
+
function readFile(file, fs_) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err && err.code === 'ENOENT') return { cfg: null };
|
|
91
|
+
return { error: `cannot read ${file}: ${err && err.message ? err.message : err}` };
|
|
92
|
+
}
|
|
93
|
+
let cfg;
|
|
94
|
+
try {
|
|
95
|
+
cfg = JSON.parse(text);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { error: `${file} is not valid JSON` };
|
|
98
|
+
}
|
|
99
|
+
if (!isObject(cfg)) return { error: `${file} is not a JSON object` };
|
|
100
|
+
return { cfg };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function globalConfigPath(env) {
|
|
104
|
+
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
105
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Effective { planReview, rounds } (undefined when unset) or { error }. Nearest file wins per key.
|
|
109
|
+
function readConfig(root, env, fs_) {
|
|
110
|
+
const files = [path.join(root, CONFIG_REL), globalConfigPath(env)];
|
|
111
|
+
const parsed = [];
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const r = readFile(file, fs_);
|
|
114
|
+
if (r.error) return { error: r.error };
|
|
115
|
+
if (r.cfg) parsed.push({ file, cfg: r.cfg });
|
|
116
|
+
}
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [key, prop] of [['plan_review', 'planReview'], ['plan_review.rounds', 'rounds']]) {
|
|
119
|
+
const hit = parsed.find(({ cfg }) => has(cfg, key));
|
|
120
|
+
if (hit) out[prop] = { value: hit.cfg[key], file: hit.file };
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveRounds(entry, warnings) {
|
|
126
|
+
if (!entry) return DEFAULT_ROUNDS;
|
|
127
|
+
const v = entry.value;
|
|
128
|
+
if (Number.isInteger(v) && v > 0) return v;
|
|
129
|
+
warnings.push(`plan_review.rounds in ${entry.file} must be a positive integer; using ${DEFAULT_ROUNDS}`);
|
|
130
|
+
return DEFAULT_ROUNDS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Pure PATH scan: an executable regular file named `name` in some PATH directory.
|
|
134
|
+
function pathLookup(env, fs_) {
|
|
135
|
+
const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
136
|
+
return (name) => dirs.some((dir) => {
|
|
137
|
+
const file = path.join(dir, name);
|
|
138
|
+
try {
|
|
139
|
+
return fs_.statSync(file).isFile() && (fs_.accessSync(file, fs_.constants.X_OK), true);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `kimi --help` output, or null when it cannot run.
|
|
147
|
+
function runKimiHelp() {
|
|
148
|
+
const r = spawnSync('kimi', ['--help'], { encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
149
|
+
if (r.error || r.status !== 0) return null;
|
|
150
|
+
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const entry = (value, label, recommended) => ({ value, label, ...(recommended ? { recommended: true } : {}) });
|
|
154
|
+
const countLabel = (n) => `${n} round${n === 1 ? '' : 's'}`;
|
|
155
|
+
|
|
156
|
+
function buildMenus(n) {
|
|
157
|
+
const config = entry('config', `As in config (${n})`, true);
|
|
158
|
+
const until = entry('until', 'Until convergence (stop on a round with no progress, ceiling 5)');
|
|
159
|
+
const count = (k) => entry(String(k), countLabel(k));
|
|
160
|
+
const options = n >= 1 && n <= 3
|
|
161
|
+
? [config, ...[1, 2, 3].filter((k) => k !== n).map(count), until]
|
|
162
|
+
: [config, count(1), count(2), until];
|
|
163
|
+
const fallback = [config, count(1), count(2), count(3), until];
|
|
164
|
+
return { options, fallback };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runAsk(parsed, cwd, env, fs_, deps) {
|
|
168
|
+
const cfg = readConfig(path.resolve(cwd, parsed.repo), env, fs_);
|
|
169
|
+
if (cfg.error) return { code: 1, result: { status: 'error', reason: cfg.error } };
|
|
170
|
+
const warnings = [];
|
|
171
|
+
const configured = resolveRounds(cfg.rounds, warnings);
|
|
172
|
+
const base = { configured, unknown: [], warnings };
|
|
173
|
+
|
|
174
|
+
const raw = cfg.planReview ? cfg.planReview.value : 'off';
|
|
175
|
+
if (typeof raw !== 'string') return { code: 1, result: { status: 'error', reason: `plan_review in ${cfg.planReview.file} must be a string (off, on or a comma list)` } };
|
|
176
|
+
const mode = raw.trim().toLowerCase();
|
|
177
|
+
if (mode === 'off') return { code: 0, result: { status: 'skip', reason: 'off', ...base } };
|
|
178
|
+
|
|
179
|
+
const explicit = mode !== 'on';
|
|
180
|
+
const names = explicit ? [...new Set(raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean))] : ROSTER;
|
|
181
|
+
if (names.length === 0) return { code: 1, result: { status: 'error', reason: `plan_review in ${cfg.planReview.file} names no CLI` } };
|
|
182
|
+
|
|
183
|
+
const which = deps.which || pathLookup(env, fs_);
|
|
184
|
+
const kimiHelp = deps.kimiHelp || runKimiHelp;
|
|
185
|
+
const host = parsed.host || 'claude';
|
|
186
|
+
const reviewers = [];
|
|
187
|
+
const unknown = [];
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
if (name === host) continue;
|
|
190
|
+
const installed = which(name);
|
|
191
|
+
if (!ROSTER.includes(name)) {
|
|
192
|
+
if (installed) reviewers.push(name);
|
|
193
|
+
else {
|
|
194
|
+
unknown.push(name);
|
|
195
|
+
warnings.push(`plan_review names "${name}", which is not a known CLI and has no binary on PATH; it does not count (typo?)`);
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!installed) continue;
|
|
200
|
+
if (name === 'kimi') {
|
|
201
|
+
const help = kimiHelp();
|
|
202
|
+
if (typeof help !== 'string' || !help.includes('--output-format')) continue;
|
|
203
|
+
}
|
|
204
|
+
reviewers.push(name);
|
|
205
|
+
}
|
|
206
|
+
if (reviewers.length === 0) return { code: 0, result: { status: 'skip', reason: 'no-reviewer', ...base, unknown } };
|
|
207
|
+
return { code: 0, result: { status: 'ask', configured, reviewers, unknown, warnings, ...buildMenus(configured) } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const norm = (key) => key.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
211
|
+
const filled = (v) => typeof v === 'string' && v.trim() !== '';
|
|
212
|
+
|
|
213
|
+
// Validates the history; throws Usage when malformed.
|
|
214
|
+
function validateHistory(h) {
|
|
215
|
+
if (!Array.isArray(h) || h.length === 0) throw new Usage('usage: history must be a non-empty JSON array, one entry per round');
|
|
216
|
+
h.forEach((round, i) => {
|
|
217
|
+
if (!Array.isArray(round)) throw new Usage(`usage: history[${i}] must be an array of concerns`);
|
|
218
|
+
round.forEach((c, j) => {
|
|
219
|
+
if (!isObject(c) || !filled(c.key) || !filled(c.text)) throw new Usage(`usage: history[${i}][${j}] must be {"key","text"} with non-empty strings`);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decide(history, choice, configured) {
|
|
225
|
+
const rounds = history.length;
|
|
226
|
+
const latest = history[rounds - 1];
|
|
227
|
+
if (latest.length === 0) return { status: 'ok', decision: 'converged', rounds };
|
|
228
|
+
const stop = (decision) => ({ status: 'ok', decision, rounds, surviving: latest.map((c) => ({ key: c.key, text: c.text })) });
|
|
229
|
+
if (choice === 'until') {
|
|
230
|
+
const prev = rounds >= 2 ? history[rounds - 2] : [];
|
|
231
|
+
const latestKeys = new Set(latest.map((c) => norm(c.key)));
|
|
232
|
+
if (prev.length > 0 && prev.every((c) => latestKeys.has(norm(c.key)))) return stop('no-progress');
|
|
233
|
+
if (rounds >= CEILING) return stop('ceiling');
|
|
234
|
+
return { status: 'ok', decision: 'continue', rounds };
|
|
235
|
+
}
|
|
236
|
+
const limit = choice === 'config' ? configured : Number(choice);
|
|
237
|
+
if (rounds >= limit) return stop('exhausted');
|
|
238
|
+
return { status: 'ok', decision: 'continue', rounds };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runRound(parsed, cwd, fs_) {
|
|
242
|
+
const file = path.resolve(cwd, parsed.history);
|
|
243
|
+
let text;
|
|
244
|
+
try {
|
|
245
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { code: 1, result: { status: 'error', reason: `cannot read ${file}: ${err && err.message ? err.message : err}` } };
|
|
248
|
+
}
|
|
249
|
+
let history;
|
|
250
|
+
try {
|
|
251
|
+
history = JSON.parse(text);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
throw new Usage(`usage: ${file} is not valid JSON`);
|
|
254
|
+
}
|
|
255
|
+
validateHistory(history);
|
|
256
|
+
return { code: 0, result: decide(history, parsed.choice, Number(parsed.configured)) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp } = {}) {
|
|
260
|
+
const cwd_ = cwd || process.cwd();
|
|
261
|
+
const out = stdout || process.stdout;
|
|
262
|
+
const err = stderr || process.stderr;
|
|
263
|
+
const env_ = env || process.env;
|
|
264
|
+
const fs_ = fsMod || fs;
|
|
265
|
+
|
|
266
|
+
const parsed = parseArgs(argv || []);
|
|
267
|
+
if (parsed.error) {
|
|
268
|
+
err.write(parsed.error + '\n');
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
let run;
|
|
272
|
+
try {
|
|
273
|
+
run = parsed.command === 'ask' ? runAsk(parsed, cwd_, env_, fs_, { which, kimiHelp }) : runRound(parsed, cwd_, fs_);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e instanceof Usage) {
|
|
276
|
+
err.write(e.message + '\n');
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
run = { code: 1, result: { status: 'error', reason: 'error: ' + (e && e.message ? e.message : String(e)) } };
|
|
280
|
+
}
|
|
281
|
+
out.write(JSON.stringify(run.result) + '\n');
|
|
282
|
+
return run.code;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { parseArgs, readConfig, decide, main };
|
|
286
|
+
|
|
287
|
+
if (require.main === module) {
|
|
288
|
+
process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, env: process.env }));
|
|
289
|
+
}
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
// family, matched case-insensitively; an empty value or `none` means none. A required
|
|
37
37
|
// field missing, a label given twice or a broken invariant is the `fields` problem.
|
|
38
38
|
// Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
|
|
39
|
+
// The verdict must also agree with the report's content, and only ever moves to the
|
|
40
|
+
// stricter one (fail closed, action normalise): a code-review `clean` whose counts line has
|
|
41
|
+
// critical N > 0 stands only if the ledger closes (`fixed`/`deferred`/`accepted`, first
|
|
42
|
+
// disposition word after the `C<n>` id) at least N distinct Critical ids and none is `open`,
|
|
43
|
+
// else it becomes `issues_found`; a verify tag below the highest `class: <c>` claim line
|
|
44
|
+
// (unmet/partial > inconclusive > met) is raised to it. Warnings and Info never gate.
|
|
39
45
|
|
|
40
46
|
const fs = require('fs');
|
|
41
47
|
|
|
@@ -48,6 +54,10 @@ const REVIEW_TOKENS = Object.freeze({
|
|
|
48
54
|
'code-review': frozen(['clean', 'issues_found']),
|
|
49
55
|
});
|
|
50
56
|
|
|
57
|
+
// The class a verify claim line carries (`class: <c>`); the contract is pinned against
|
|
58
|
+
// scripts/agent-blocks.md and verify.md by report-status.test.js.
|
|
59
|
+
const VERIFY_CLASSES = frozen(['met', 'unmet', 'partial', 'inconclusive']);
|
|
60
|
+
|
|
51
61
|
// The return contract of each field family. Hard-coded here (the checker ships
|
|
52
62
|
// self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
|
|
53
63
|
// states. An invariant says: for these statuses the field (or `body`, the report without
|
|
@@ -144,6 +154,58 @@ function countsOf(line) {
|
|
|
144
154
|
return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
// A ledger line: `C<n>`, an optional separator (colon, hyphen, en dash, em dash, `)`), then
|
|
158
|
+
// the disposition word right away (a bare `open` must end the line or be followed by
|
|
159
|
+
// punctuation: `C1 open redirect` is prose).
|
|
160
|
+
const LEDGER_RE = /^C(\d+)\s*[:\-\u2013\u2014)]?\s*(fixed|deferred|accepted|reclassified|open(?=\s*(?:$|[:.,;(\u2192])))(?![-\w])/i;
|
|
161
|
+
const CRITICAL_ID_RE = /^C(\d+)\b/i;
|
|
162
|
+
const CLOSED = frozen(['fixed', 'deferred', 'accepted']);
|
|
163
|
+
// Verify verdicts by strictness; unmet and partial claims both mean gaps_found.
|
|
164
|
+
const VERIFY_RANK = Object.freeze({ met: 0, inconclusive: 1, gaps_found: 2 });
|
|
165
|
+
const CLASS_VERDICT = Object.freeze({ met: 'met', unmet: 'gaps_found', partial: 'gaps_found', inconclusive: 'inconclusive' });
|
|
166
|
+
|
|
167
|
+
// Critical ids at the start of any candidate line, as { seen, ledger }: `seen` is every id
|
|
168
|
+
// (parsed as a ledger line or not), `ledger` the parsed lines [{ id, disposition }].
|
|
169
|
+
function criticalLines(lines, cands) {
|
|
170
|
+
const stripped = cands.map((i) => strip(lines[i]));
|
|
171
|
+
const seen = stripped.flatMap((s) => { const m = CRITICAL_ID_RE.exec(s); return m ? [`C${m[1]}`] : []; });
|
|
172
|
+
const ledger = stripped.flatMap((s) => {
|
|
173
|
+
const m = LEDGER_RE.exec(s);
|
|
174
|
+
return m ? [{ id: `C${m[1]}`, disposition: m[2].toLowerCase() }] : [];
|
|
175
|
+
});
|
|
176
|
+
return { seen: new Set(seen), ledger };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Does a `clean` verdict with `critical` Criticals stand against its ledger? Every id seen
|
|
180
|
+
// must be closed (or reclassified away), none parsed as open, and the closed count reach N.
|
|
181
|
+
function cleanStands(critical, { seen, ledger }) {
|
|
182
|
+
if (critical === 0) return true;
|
|
183
|
+
if (ledger.some((e) => e.disposition === 'open')) return false;
|
|
184
|
+
const closed = new Set(ledger.filter((e) => CLOSED.includes(e.disposition)).map((e) => e.id));
|
|
185
|
+
const moved = new Set(ledger.filter((e) => e.disposition === 'reclassified').map((e) => e.id));
|
|
186
|
+
if ([...seen].some((id) => !closed.has(id) && !moved.has(id))) return false;
|
|
187
|
+
return closed.size >= critical;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The verify verdict raised to the highest class among the claim lines; never lowered.
|
|
191
|
+
function raisedVerify(verdict, lines, cands, statusIdx) {
|
|
192
|
+
const classes = cands.filter((i) => !statusIdx.has(i)).flatMap((i) => {
|
|
193
|
+
const m = new RegExp(`(?:^|[\\s,;(])class\\s*:\\s*(${VERIFY_CLASSES.join('|')})\\b(?![-\\w])`, 'i').exec(strip(lines[i]));
|
|
194
|
+
return m ? [CLASS_VERDICT[m[1].toLowerCase()]] : [];
|
|
195
|
+
});
|
|
196
|
+
return classes.reduce((v, c) => (VERIFY_RANK[c] > VERIFY_RANK[v] ? c : v), verdict);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The verdict after the content check: the declared one, or the stricter one it contradicts.
|
|
200
|
+
function checkedVerdict(technique, verdict, lines, cands, statusIdx, counts) {
|
|
201
|
+
if (technique === 'verify') return raisedVerify(verdict, lines, cands, statusIdx);
|
|
202
|
+
if (technique === 'code-review' && verdict === 'clean') {
|
|
203
|
+
const critical = Number(/^critical: (\d+)/.exec(counts[0].text)[1]);
|
|
204
|
+
return cleanStands(critical, criticalLines(lines, cands)) ? verdict : 'issues_found';
|
|
205
|
+
}
|
|
206
|
+
return verdict;
|
|
207
|
+
}
|
|
208
|
+
|
|
147
209
|
const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
|
|
148
210
|
|
|
149
211
|
const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
|
|
@@ -198,7 +260,7 @@ function checkReport(text, technique) {
|
|
|
198
260
|
const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
|
|
199
261
|
if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
|
|
200
262
|
if (valid.size === 0) return reask(original, 'unknown-token');
|
|
201
|
-
const
|
|
263
|
+
const declared = [...valid][0];
|
|
202
264
|
|
|
203
265
|
let counts = [];
|
|
204
266
|
if (technique === 'code-review') {
|
|
@@ -206,6 +268,8 @@ function checkReport(text, technique) {
|
|
|
206
268
|
if (counts.length !== 1) return reask(original, 'counts');
|
|
207
269
|
}
|
|
208
270
|
|
|
271
|
+
const verdict = checkedVerdict(technique, declared, lines, cands, new Set(statusLines.map((c) => c.i)), counts);
|
|
272
|
+
|
|
209
273
|
// Blank lines that led the original are not content: drop them with the status lines.
|
|
210
274
|
const lead = lines.findIndex((l) => l.trim() !== '');
|
|
211
275
|
const drop = new Set([...statusLines, ...counts].map((c) => c.i));
|
|
@@ -224,7 +288,7 @@ function checkReport(text, technique) {
|
|
|
224
288
|
if (family === 'plan' && verdict === 'ready') body = text;
|
|
225
289
|
}
|
|
226
290
|
|
|
227
|
-
const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
|
|
291
|
+
const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}` && verdict === declared
|
|
228
292
|
&& (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
|
|
229
293
|
if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
|
|
230
294
|
|
|
@@ -271,4 +335,4 @@ function main(argv, io = {}) {
|
|
|
271
335
|
// exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
|
|
272
336
|
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
273
337
|
|
|
274
|
-
module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
|
|
338
|
+
module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };
|
|
@@ -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,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
|
+
}
|