@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
|
@@ -17,8 +17,8 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
|
17
17
|
const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected, name); passed++; };
|
|
18
18
|
|
|
19
19
|
// Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
|
|
20
|
-
function runHook(command, { env = {}, args = [], hook = HOOK } = {}) {
|
|
21
|
-
const input = JSON.stringify({ tool_input: { command } });
|
|
20
|
+
function runHook(command, { env = {}, args = [], hook = HOOK, cwd } = {}) {
|
|
21
|
+
const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
|
|
22
22
|
const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
|
|
23
23
|
return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
24
24
|
}
|
|
@@ -30,9 +30,10 @@ const BAD_SECRET = 'gh issue comment 5 --body "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012
|
|
|
30
30
|
const BAD_INSTALL = 'npm install left-pad';
|
|
31
31
|
const BAD_SEARCH = 'cd x && grep y z';
|
|
32
32
|
const BAD_BODY = 'gh issue edit 5 --body ""';
|
|
33
|
+
const BAD_LABELS = 'gh issue create --title x --body y --label trailhead:bug';
|
|
33
34
|
|
|
34
35
|
// --- unit: constants ---
|
|
35
|
-
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body']);
|
|
36
|
+
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels']);
|
|
36
37
|
ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
|
|
37
38
|
|
|
38
39
|
// --- unit: parseModGuards ---
|
|
@@ -48,10 +49,10 @@ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit
|
|
|
48
49
|
eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
|
|
49
50
|
|
|
50
51
|
// --- unit: parseGuardsArg ---
|
|
51
|
-
eq('no --guards -> all
|
|
52
|
+
eq('no --guards -> all six', parseGuardsArg([]), GUARD_ORDER);
|
|
52
53
|
eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
|
|
53
|
-
eq('empty --guards -> all
|
|
54
|
-
eq('malformed --guards -> all
|
|
54
|
+
eq('empty --guards -> all six', parseGuardsArg(['--guards=']), GUARD_ORDER);
|
|
55
|
+
eq('malformed --guards -> all six', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
|
|
55
56
|
eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
|
|
56
57
|
|
|
57
58
|
// --- unit: runGuards with fake evaluators ---
|
|
@@ -65,7 +66,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
65
66
|
evaluators: {
|
|
66
67
|
commit: spy('commit', () => { throw new Error('boom'); }),
|
|
67
68
|
secret: spy('secret', allow), install: spy('install', blocker('I')),
|
|
68
|
-
search: spy('search', blocker('S')), body: spy('body', allow),
|
|
69
|
+
search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow),
|
|
69
70
|
},
|
|
70
71
|
});
|
|
71
72
|
eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
|
|
@@ -75,7 +76,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
75
76
|
{
|
|
76
77
|
const r = runGuards('x', {
|
|
77
78
|
now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
|
|
78
|
-
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow },
|
|
79
|
+
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow },
|
|
79
80
|
});
|
|
80
81
|
eq('a fresh stamp skips the listed guard', r.code, 0);
|
|
81
82
|
}
|
|
@@ -83,7 +84,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
83
84
|
const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
|
|
84
85
|
const r = runGuards('x', {
|
|
85
86
|
now: NOW, env: {},
|
|
86
|
-
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow },
|
|
87
|
+
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow },
|
|
87
88
|
});
|
|
88
89
|
eq('advisories exit 0', r.code, 0);
|
|
89
90
|
eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
|
|
@@ -96,9 +97,10 @@ const { evaluate: evSecret } = require('./trailhead-secret-guard.js');
|
|
|
96
97
|
const { evaluate: evInstall } = require('./trailhead-install-guard.js');
|
|
97
98
|
const { evaluate: evSearch } = require('./trailhead-search-guard.js');
|
|
98
99
|
const { evaluate: evBody } = require('./trailhead-body-guard.js');
|
|
100
|
+
const { evaluate: evLabels } = require('./trailhead-labels-guard.js');
|
|
99
101
|
for (const [name, cmd, ev] of [
|
|
100
102
|
['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
|
|
101
|
-
['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody],
|
|
103
|
+
['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody], ['labels', BAD_LABELS, evLabels],
|
|
102
104
|
]) {
|
|
103
105
|
const r = runHook(cmd);
|
|
104
106
|
const want = ev(cmd);
|
|
@@ -169,19 +171,19 @@ for (const [name, cmd, ev] of [
|
|
|
169
171
|
const hook = path.join(tmp, 'trailhead-bash-guards.js');
|
|
170
172
|
fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
|
|
171
173
|
|
|
172
|
-
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body'] });
|
|
174
|
+
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
173
175
|
eq('--guards without search never loads search-guard', codex.code, 0);
|
|
174
176
|
eq('--guards without search: no warning', codex.err, '');
|
|
175
|
-
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body'] });
|
|
177
|
+
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
176
178
|
eq('--guards subset still blocks with its guards', still.code, 2);
|
|
177
179
|
const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
|
|
178
180
|
eq('unselected guards do not run', only.code, 0);
|
|
179
181
|
|
|
180
182
|
for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
|
|
181
183
|
const r = runHook(BAD_INSTALL, { hook, args: [bad] });
|
|
182
|
-
eq(`${bad}: falls back to all
|
|
184
|
+
eq(`${bad}: falls back to all six (install blocks)`, r.code, 2);
|
|
183
185
|
const clean = runHook('git status', { hook, args: [bad] });
|
|
184
|
-
ok(`${bad}: all
|
|
186
|
+
ok(`${bad}: all six are selected (the missing search module is reported)`, /search/.test(clean.err));
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
|
|
@@ -212,12 +214,16 @@ for (const [name, cmd, ev] of [
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
// --- every existing guard suite, re-run through the merged hook ---
|
|
215
|
-
|
|
217
|
+
// Run from a scratch dir outside any trailhead repo: those suites open unlabelled issues, which
|
|
218
|
+
// the labels guard (rightly) blocks inside a trailhead repo.
|
|
219
|
+
const SUITE_CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-suites-'));
|
|
220
|
+
for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels']) {
|
|
216
221
|
const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
|
|
217
|
-
const r = spawnSync('node', [file], { env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
|
|
222
|
+
const r = spawnSync('node', [file], { cwd: SUITE_CWD, env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
|
|
218
223
|
if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
|
|
219
224
|
eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
|
|
220
225
|
}
|
|
226
|
+
fs.rmSync(SUITE_CWD, { recursive: true, force: true });
|
|
221
227
|
|
|
222
228
|
// --- registration: one Bash entry, one command (the merged hook) ---
|
|
223
229
|
{
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-labels-guard.js: PreToolUse(Bash) hook.
|
|
3
|
+
// A trailhead ticket is identified by its label set: `trailhead:ticket`, exactly one
|
|
4
|
+
// type label and exactly one destination (a map or the whiteboard). When an agent
|
|
5
|
+
// opens a ticket with `gh issue create` and forgets part of that set (or adds the
|
|
6
|
+
// missing labels in a later call that never comes), the ticket is invisible to the
|
|
7
|
+
// board and the engine. This guard checks the label set at the create call itself.
|
|
8
|
+
// - A create that carries at least one `trailhead:*` label must carry the whole
|
|
9
|
+
// ticket shape: `trailhead:ticket`, exactly one of trailhead:decision | research |
|
|
10
|
+
// prototype | build | bug | task, and exactly one of `trailhead:map-<n>` |
|
|
11
|
+
// `trailhead:whiteboard` (never both, never two maps). Non-ticket trailhead issues
|
|
12
|
+
// (`trailhead:map`, `trailhead:dashboard`, `trailhead:codebase`,
|
|
13
|
+
// `trailhead:conventions`, and `trailhead:fog` / `trailhead:unverified`) are exempt.
|
|
14
|
+
// State labels (blocked, seed, paused, ...) are allowed extras and never count
|
|
15
|
+
// toward the shape. This rule applies in any repo.
|
|
16
|
+
// - A create with NO `trailhead:*` label is blocked too, but only inside a trailhead
|
|
17
|
+
// repo (the hook cwd sits in a git work tree whose top level has a `.trailhead/`
|
|
18
|
+
// directory) and not when aimed at another repo (-R / --repo / GH_REPO=). A
|
|
19
|
+
// deliberate non-trailhead issue opts out by prefixing the command with
|
|
20
|
+
// `TRAILHEAD_PLAIN_ISSUE=1`, like the install guard's TRAILHEAD_VERIFIED_INSTALL=1.
|
|
21
|
+
// After a `cd` the working directory is unknown, so this ground never blocks there.
|
|
22
|
+
// - A label value that cannot be resolved statically ($VAR, `$(...)`, backticks) makes
|
|
23
|
+
// the whole create unparseable: it is never blocked on what cannot be read.
|
|
24
|
+
// The command is not required to START with `gh`: the shared statement walk (ghWrites in
|
|
25
|
+
// secret-guard) finds each `gh issue create` through `;` `&&` `||` `|`, subshells and
|
|
26
|
+
// leading NAME=v. Opaque writes (eval, sh -c, ...) are ignored (fail-open).
|
|
27
|
+
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
28
|
+
// Crash-safe: any error -> allow. Self-contained except for the sibling lib/ modules and
|
|
29
|
+
// the secret-guard statement walk. Pure functions are exported for tests and the merged
|
|
30
|
+
// trailhead-bash-guards.js; runs as a hook when executed directly.
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const { parseGhSubcommand, dequote } = require('./lib/gh-subcommand.js');
|
|
35
|
+
const { words, parseRedirects } = require('./lib/body-sources.js');
|
|
36
|
+
|
|
37
|
+
const PLAIN_SENTINEL = /(?:^|\s)TRAILHEAD_PLAIN_ISSUE=1(?:\s|$)/;
|
|
38
|
+
const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
|
|
39
|
+
const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified'];
|
|
40
|
+
const MAP_RE = /^trailhead:map-\d+$/;
|
|
41
|
+
const WHITEBOARD = 'trailhead:whiteboard';
|
|
42
|
+
const TYPE_LIST = TYPES.map((t) => `trailhead:${t}`).join(' | ');
|
|
43
|
+
|
|
44
|
+
// gh issue create flags that consume a value (long and short forms).
|
|
45
|
+
const VALUE_FLAGS = new Set([
|
|
46
|
+
'--assignee', '--body', '--body-file', '--label', '--milestone', '--project', '--repo', '--title', '--template', '--recover',
|
|
47
|
+
'-a', '-b', '-F', '-l', '-m', '-p', '-R', '-t', '-T',
|
|
48
|
+
]);
|
|
49
|
+
const ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
50
|
+
|
|
51
|
+
// Read the options of one `gh issue create` statement.
|
|
52
|
+
// { labels: [lowercased label names], unresolved: bool, repoFlag: bool, help: bool }
|
|
53
|
+
function parseCreate(text) {
|
|
54
|
+
const { args } = parseRedirects(words(text));
|
|
55
|
+
const toks = args.map(dequote);
|
|
56
|
+
const labels = [];
|
|
57
|
+
let unresolved = false;
|
|
58
|
+
let repoFlag = false;
|
|
59
|
+
let help = false;
|
|
60
|
+
let afterCreate = false;
|
|
61
|
+
let sawIssue = false;
|
|
62
|
+
const take = (v) => {
|
|
63
|
+
if (/[$`]/.test(v)) unresolved = true;
|
|
64
|
+
for (const part of v.split(',')) {
|
|
65
|
+
const l = part.trim().toLowerCase();
|
|
66
|
+
if (l) labels.push(l);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// leading NAME=v: GH_REPO aims the command at another repo
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < toks.length && ASSIGN_RE.test(toks[i])) {
|
|
72
|
+
if (/^GH_REPO=/.test(toks[i])) repoFlag = true;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
for (i += 1; i < toks.length; i++) { // past the gh word
|
|
76
|
+
if (args[i].startsWith('#')) break; // an unquoted shell comment: the rest is not an argument
|
|
77
|
+
const t = toks[i];
|
|
78
|
+
if (t === '-h' || t === '--help') { help = true; continue; }
|
|
79
|
+
if (!t.startsWith('-')) {
|
|
80
|
+
if (!sawIssue && t === 'issue') sawIssue = true;
|
|
81
|
+
else if (sawIssue && t === 'create') afterCreate = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
let flag = t;
|
|
85
|
+
let value;
|
|
86
|
+
if (t.startsWith('--')) {
|
|
87
|
+
const eq = t.indexOf('=');
|
|
88
|
+
if (eq !== -1) { flag = t.slice(0, eq); value = t.slice(eq + 1); }
|
|
89
|
+
else if (VALUE_FLAGS.has(t)) value = toks[++i];
|
|
90
|
+
} else if (VALUE_FLAGS.has(t)) {
|
|
91
|
+
value = toks[++i];
|
|
92
|
+
} else if (t.length > 2 && VALUE_FLAGS.has(t.slice(0, 2))) {
|
|
93
|
+
flag = t.slice(0, 2); // glued shorthand: -lbug, -l=bug
|
|
94
|
+
value = t.slice(2).replace(/^=/, '');
|
|
95
|
+
}
|
|
96
|
+
if (value === undefined) continue;
|
|
97
|
+
if (flag === '-R' || flag === '--repo') repoFlag = true;
|
|
98
|
+
else if (afterCreate && (flag === '--label' || flag === '-l')) take(value);
|
|
99
|
+
}
|
|
100
|
+
return { labels, unresolved, repoFlag, help };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Is `cwd` in a git work tree whose top level has a `.trailhead/` directory? Walks up to
|
|
104
|
+
// the first directory holding `.git` (dir or file). Any fs error -> false (fail open).
|
|
105
|
+
function inTrailheadRepo(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
let dir = path.resolve(cwd || process.cwd());
|
|
108
|
+
for (;;) {
|
|
109
|
+
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
110
|
+
return fs.statSync(path.join(dir, '.trailhead')).isDirectory();
|
|
111
|
+
}
|
|
112
|
+
const up = path.dirname(dir);
|
|
113
|
+
if (up === dir) return false;
|
|
114
|
+
dir = up;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The ticket-shape problems of a label set that has at least one trailhead:* label
|
|
122
|
+
// and is not a non-ticket issue. Empty list = well formed.
|
|
123
|
+
function shapeProblems(th) {
|
|
124
|
+
const problems = [];
|
|
125
|
+
if (!th.includes('trailhead:ticket')) problems.push('missing `trailhead:ticket`');
|
|
126
|
+
const types = th.filter((l) => TYPES.some((t) => l === `trailhead:${t}`));
|
|
127
|
+
if (types.length === 0) problems.push(`missing a type label (exactly one of ${TYPE_LIST})`);
|
|
128
|
+
else if (new Set(types).size > 1) problems.push(`conflicting type labels (${[...new Set(types)].join(', ')}): keep exactly one`);
|
|
129
|
+
const maps = [...new Set(th.filter((l) => MAP_RE.test(l)))];
|
|
130
|
+
const board = th.includes(WHITEBOARD);
|
|
131
|
+
if (maps.length === 0 && !board) problems.push('missing a destination (exactly one of `trailhead:map-<n>` | `trailhead:whiteboard`)');
|
|
132
|
+
else if (maps.length > 1) problems.push(`two map destinations (${maps.join(', ')}): keep exactly one`);
|
|
133
|
+
else if (maps.length === 1 && board) problems.push(`two destinations (${maps[0]} and ${WHITEBOARD}): keep exactly one`);
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const RETRY =
|
|
138
|
+
'Retry with the full label set in the SAME `gh issue create` call (`--label trailhead:ticket --label <type> --label <destination>`); ' +
|
|
139
|
+
'never add labels afterwards with `--add-label`.';
|
|
140
|
+
|
|
141
|
+
function block(code, reason) {
|
|
142
|
+
return { kind: 'block', payload: { decision: 'block', code, reason } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Verdict for one parsed create, or null to allow.
|
|
146
|
+
function checkCreate(parsed, { cmd, cwd, cwdKnown }) {
|
|
147
|
+
if (parsed.help) return null; // `--help` creates nothing
|
|
148
|
+
if (parsed.unresolved) return null; // a label we cannot read: never block on it
|
|
149
|
+
const th = parsed.labels.filter((l) => l.startsWith('trailhead:'));
|
|
150
|
+
if (th.length > 0) {
|
|
151
|
+
if (th.some((l) => EXEMPT.includes(l))) return null; // a non-ticket trailhead issue
|
|
152
|
+
const problems = shapeProblems(th);
|
|
153
|
+
if (problems.length === 0) return null;
|
|
154
|
+
return block(
|
|
155
|
+
'TICKET_LABELS_INCOMPLETE',
|
|
156
|
+
`trailhead: this gh issue create opens a trailhead ticket (labels: ${th.join(', ')}) with an incomplete label set: ${problems.join('; ')}. ${RETRY}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (parsed.repoFlag || !cwdKnown || PLAIN_SENTINEL.test(cmd)) return null;
|
|
160
|
+
if (!inTrailheadRepo(cwd)) return null;
|
|
161
|
+
return block(
|
|
162
|
+
'TRAILHEAD_ISSUE_WITHOUT_LABELS',
|
|
163
|
+
'trailhead: this gh issue create carries no `trailhead:*` label, so the issue would be invisible to the board and the engine. ' +
|
|
164
|
+
`${RETRY} ` +
|
|
165
|
+
'For a deliberate non-trailhead issue, re-run the exact command prefixed with `TRAILHEAD_PLAIN_ISSUE=1`.',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Every statement of the command that is a `gh issue create`: { text, cwdKnown }.
|
|
170
|
+
function creates(cmd) {
|
|
171
|
+
let ghWrites;
|
|
172
|
+
try {
|
|
173
|
+
({ ghWrites } = require('./trailhead-secret-guard.js'));
|
|
174
|
+
} catch {
|
|
175
|
+
return [{ text: cmd, cwdKnown: true }]; // partial install: check the lone statement
|
|
176
|
+
}
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const w of ghWrites(cmd, {})) {
|
|
179
|
+
if (w.kind !== 'issue') continue;
|
|
180
|
+
const p = parseGhSubcommand(w.text);
|
|
181
|
+
if (p && p.sub === 'issue' && p.verb === 'create') out.push({ text: w.text, cwdKnown: w.cwdKnown });
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pure verdict: null (allow), or { kind:'block', payload }. Never exits or writes; any
|
|
187
|
+
// internal error allows.
|
|
188
|
+
function evaluate(cmd, opts) {
|
|
189
|
+
try {
|
|
190
|
+
const cwd = opts && opts.cwd;
|
|
191
|
+
for (const c of creates(String(cmd))) {
|
|
192
|
+
const p = parseGhSubcommand(c.text);
|
|
193
|
+
if (!p || p.sub !== 'issue' || p.verb !== 'create') continue;
|
|
194
|
+
const verdict = checkCreate(parseCreate(c.text), { cmd: String(cmd), cwd, cwdKnown: c.cwdKnown });
|
|
195
|
+
if (verdict) return verdict;
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// a guard must never wedge the workflow
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function run(data) {
|
|
204
|
+
let cmd = '';
|
|
205
|
+
let cwd;
|
|
206
|
+
try {
|
|
207
|
+
const input = JSON.parse(data);
|
|
208
|
+
cmd = (input.tool_input || {}).command || '';
|
|
209
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
const v = evaluate(cmd, { cwd });
|
|
214
|
+
if (v && v.kind === 'block') {
|
|
215
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
216
|
+
process.exit(2);
|
|
217
|
+
}
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (require.main === module) {
|
|
222
|
+
let data = '';
|
|
223
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
|
|
224
|
+
process.stdin.on('data', (c) => (data += c));
|
|
225
|
+
process.stdin.on('end', () => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
run(data);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { parseCreate, shapeProblems, inTrailheadRepo, evaluate };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for trailhead-labels-guard.js. Run: node trailhead-labels-guard.test.js
|
|
3
|
+
// No framework: plain asserts + child_process for the end-to-end hook behaviour.
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
11
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-labels-guard.js');
|
|
12
|
+
const { evaluate, parseCreate, inTrailheadRepo } = require('./trailhead-labels-guard.js');
|
|
13
|
+
|
|
14
|
+
let passed = 0;
|
|
15
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
16
|
+
|
|
17
|
+
// Scratch dirs: a trailhead repo (.git + .trailhead/), a plain repo (.git only), a plain dir.
|
|
18
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'labels-guard-'));
|
|
19
|
+
const mk = (rel, ...sub) => {
|
|
20
|
+
const d = path.join(tmp, rel);
|
|
21
|
+
fs.mkdirSync(d, { recursive: true });
|
|
22
|
+
for (const s of sub) fs.mkdirSync(path.join(d, s), { recursive: true });
|
|
23
|
+
return d;
|
|
24
|
+
};
|
|
25
|
+
const TH = mk('th', '.git', '.trailhead', 'src/deep');
|
|
26
|
+
const THDEEP = path.join(TH, 'src', 'deep');
|
|
27
|
+
const PLAIN_REPO = mk('plain', '.git');
|
|
28
|
+
const NOREPO = mk('norepo');
|
|
29
|
+
// A worktree-style repo: `.git` is a file, not a dir.
|
|
30
|
+
const WT = mk('wt', '.trailhead');
|
|
31
|
+
fs.writeFileSync(path.join(WT, '.git'), 'gitdir: /elsewhere\n');
|
|
32
|
+
|
|
33
|
+
// Run the hook end-to-end: tool_input JSON (+ cwd) on stdin, capture {code, out}.
|
|
34
|
+
function runHook(command, cwd) {
|
|
35
|
+
const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
|
|
36
|
+
const r = spawnSync('node', [HOOK], { input, cwd: NOREPO });
|
|
37
|
+
return { code: r.status, out: r.stdout.toString() };
|
|
38
|
+
}
|
|
39
|
+
const blocked = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return !!v && v.kind === 'block'; };
|
|
40
|
+
const reasonOf = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return v ? v.payload.reason : ''; };
|
|
41
|
+
const codeOf = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return v ? v.payload.code : null; };
|
|
42
|
+
|
|
43
|
+
const create = (labels, extra = '') =>
|
|
44
|
+
`gh issue create --title x --body y ${labels.map((l) => `--label ${l}`).join(' ')} ${extra}`.trim();
|
|
45
|
+
const FULL_WB = ['trailhead:ticket', 'trailhead:bug', 'trailhead:whiteboard'];
|
|
46
|
+
const FULL_MAP = ['trailhead:ticket', 'trailhead:build', 'trailhead:map-12'];
|
|
47
|
+
|
|
48
|
+
// --- the repro commands (#308) ---
|
|
49
|
+
{
|
|
50
|
+
const r1 = 'gh issue create --label trailhead:bug --label trailhead:whiteboard --title x --body-file /dev/null';
|
|
51
|
+
const r2 = 'gh issue create --label "trailhead:bug,trailhead:whiteboard" --title x --body y';
|
|
52
|
+
const r3 = 'gh issue create --title x --body y';
|
|
53
|
+
for (const c of [r1, r2]) {
|
|
54
|
+
ok(`blocks partial set: ${c.slice(0, 50)}`, blocked(c, NOREPO));
|
|
55
|
+
ok('names the missing ticket label', reasonOf(c, NOREPO).includes('missing `trailhead:ticket`'));
|
|
56
|
+
ok('tells to retry in the same create call', /SAME `gh issue create`/.test(reasonOf(c, NOREPO)) && /--add-label/.test(reasonOf(c, NOREPO)));
|
|
57
|
+
const h = runHook(c, NOREPO);
|
|
58
|
+
ok('hook exits 2 with a block payload', h.code === 2 && JSON.parse(h.out).decision === 'block');
|
|
59
|
+
}
|
|
60
|
+
ok('no-label create is blocked in a trailhead repo', blocked(r3, TH));
|
|
61
|
+
ok('no-label block mentions the opt-out', reasonOf(r3, TH).includes('TRAILHEAD_PLAIN_ISSUE=1'));
|
|
62
|
+
const h = runHook(r3, TH);
|
|
63
|
+
ok('no-label hook exits 2 inside a trailhead repo', h.code === 2 && JSON.parse(h.out).code === 'TRAILHEAD_ISSUE_WITHOUT_LABELS');
|
|
64
|
+
ok('no-label hook exits 0 outside', runHook(r3, NOREPO).code === 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- a complete ticket is allowed ---
|
|
68
|
+
ok('whiteboard ticket passes', !blocked(create(FULL_WB), TH));
|
|
69
|
+
ok('map ticket passes', !blocked(create(FULL_MAP), TH));
|
|
70
|
+
ok('comma-separated full set passes', !blocked('gh issue create --title x --body y --label "trailhead:ticket,trailhead:task,trailhead:map-3"', TH));
|
|
71
|
+
ok('-l shorthand passes', !blocked('gh issue create -t x -b y -l trailhead:ticket -l trailhead:research -l trailhead:whiteboard', TH));
|
|
72
|
+
ok('--label=value passes', !blocked('gh issue create --title x --body y --label=trailhead:ticket --label=trailhead:decision --label=trailhead:whiteboard', TH));
|
|
73
|
+
ok('glued -lvalue passes', !blocked('gh issue create -t x -b y -ltrailhead:ticket -ltrailhead:task -ltrailhead:map-1', TH));
|
|
74
|
+
ok('quoted values pass', !blocked('gh issue create --title x --body y --label "trailhead:ticket" --label \'trailhead:prototype\' --label "trailhead:whiteboard"', TH));
|
|
75
|
+
ok('state labels are allowed extras', !blocked(create([...FULL_WB, 'trailhead:blocked', 'trailhead:needs-you', 'bug']), TH));
|
|
76
|
+
ok('non-trailhead labels beside a full set pass', !blocked(create([...FULL_MAP, 'enhancement']), NOREPO));
|
|
77
|
+
ok('every type passes', ['decision', 'research', 'prototype', 'build', 'bug', 'task']
|
|
78
|
+
.every((t) => !blocked(create(['trailhead:ticket', `trailhead:${t}`, 'trailhead:whiteboard']), TH)));
|
|
79
|
+
ok('seed shape passes', !blocked(create(['trailhead:ticket', 'trailhead:task', 'trailhead:map-4', 'trailhead:seed', 'trailhead:blocked']), TH));
|
|
80
|
+
ok('a seed without type is blocked', blocked(create(['trailhead:ticket', 'trailhead:map-4', 'trailhead:seed', 'trailhead:blocked']), TH));
|
|
81
|
+
|
|
82
|
+
// --- the shape rules, with the names in the reason ---
|
|
83
|
+
{
|
|
84
|
+
const noTicket = create(['trailhead:bug', 'trailhead:map-2']);
|
|
85
|
+
ok('missing ticket blocks', blocked(noTicket, NOREPO) && reasonOf(noTicket, NOREPO).includes('missing `trailhead:ticket`'));
|
|
86
|
+
const noType = create(['trailhead:ticket', 'trailhead:whiteboard']);
|
|
87
|
+
ok('missing type blocks', blocked(noType, NOREPO) && /missing a type label/.test(reasonOf(noType, NOREPO)));
|
|
88
|
+
ok('missing type names the choices', reasonOf(noType, NOREPO).includes('trailhead:decision'));
|
|
89
|
+
const noDest = create(['trailhead:ticket', 'trailhead:bug']);
|
|
90
|
+
ok('missing destination blocks', blocked(noDest, NOREPO) && /missing a destination/.test(reasonOf(noDest, NOREPO)));
|
|
91
|
+
const both = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-2', 'trailhead:whiteboard']);
|
|
92
|
+
ok('both destinations block', blocked(both, NOREPO) && /two destinations \(trailhead:map-2 and trailhead:whiteboard\)/.test(reasonOf(both, NOREPO)));
|
|
93
|
+
const twoMaps = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-2', 'trailhead:map-3']);
|
|
94
|
+
ok('two maps block', blocked(twoMaps, NOREPO) && /two map destinations \(trailhead:map-2, trailhead:map-3\)/.test(reasonOf(twoMaps, NOREPO)));
|
|
95
|
+
const twoTypes = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:task', 'trailhead:whiteboard']);
|
|
96
|
+
ok('two types block', blocked(twoTypes, NOREPO) && /conflicting type labels \(trailhead:bug, trailhead:task\)/.test(reasonOf(twoTypes, NOREPO)));
|
|
97
|
+
const multi = create(['trailhead:whiteboard']);
|
|
98
|
+
ok('several gaps are all named', ['trailhead:ticket', 'type label'].every((n) => reasonOf(multi, NOREPO).includes(n)));
|
|
99
|
+
ok('a state label alone does not satisfy the shape', blocked(create(['trailhead:blocked']), NOREPO));
|
|
100
|
+
ok('trailhead:map alone is not a destination for a ticket',
|
|
101
|
+
!blocked(create(['trailhead:map']), NOREPO)); // it IS the map issue, exempt
|
|
102
|
+
ok('a bare map-x (not digits) is no destination', blocked(create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-x']), NOREPO));
|
|
103
|
+
ok('labels are matched case-insensitively', !blocked(create(['Trailhead:Ticket', 'TRAILHEAD:BUG', 'trailhead:WhiteBoard']), NOREPO));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- non-ticket trailhead issues are exempt ---
|
|
107
|
+
for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified']) {
|
|
108
|
+
ok(`${l} create passes`, !blocked(create([l]), TH));
|
|
109
|
+
}
|
|
110
|
+
ok('map issue with extra labels passes', !blocked(create(['trailhead:map', 'trailhead:paused']), TH));
|
|
111
|
+
|
|
112
|
+
// --- no-label ground: only inside a trailhead repo ---
|
|
113
|
+
{
|
|
114
|
+
const bare = 'gh issue create --title x --body y';
|
|
115
|
+
ok('blocked at the trailhead repo root', blocked(bare, TH));
|
|
116
|
+
ok('blocked from a subdirectory (walks up to .git)', blocked(bare, THDEEP));
|
|
117
|
+
ok('blocked in a worktree whose .git is a file', blocked(bare, WT));
|
|
118
|
+
ok('allowed in a repo without .trailhead/', !blocked(bare, PLAIN_REPO));
|
|
119
|
+
ok('allowed outside any repo', !blocked(bare, NOREPO));
|
|
120
|
+
ok('allowed with an unlabelled non-trailhead label', !blocked(bare, NOREPO) && blocked(`${bare} --label bug`, TH));
|
|
121
|
+
ok('TRAILHEAD_PLAIN_ISSUE=1 opts out', !blocked(`TRAILHEAD_PLAIN_ISSUE=1 ${bare}`, TH));
|
|
122
|
+
ok('opt-out works on a compound command', !blocked(`cd x; TRAILHEAD_PLAIN_ISSUE=1 ${bare}`, TH));
|
|
123
|
+
ok('TRAILHEAD_PLAIN_ISSUE=0 does not opt out', blocked(`TRAILHEAD_PLAIN_ISSUE=0 ${bare}`, TH));
|
|
124
|
+
ok('opt-out never excuses a partial trailhead set', blocked(`TRAILHEAD_PLAIN_ISSUE=1 ${create(['trailhead:bug'])}`, TH));
|
|
125
|
+
ok('-R other/repo is not blocked on the no-label ground', !blocked(`${bare} -R other/repo`, TH));
|
|
126
|
+
ok('--repo=other/repo is not blocked on the no-label ground', !blocked(`${bare} --repo=other/repo`, TH));
|
|
127
|
+
ok('global -R before the subcommand is not blocked', !blocked('gh -R other/repo issue create --title x --body y', TH));
|
|
128
|
+
ok('GH_REPO=other/repo is not blocked on the no-label ground', !blocked(`GH_REPO=other/repo ${bare}`, TH));
|
|
129
|
+
ok('-R other/repo is still checked on the partial-label ground', blocked(`${create(['trailhead:bug'])} -R other/repo`, TH));
|
|
130
|
+
ok('--web with no labels is treated like any create', blocked('gh issue create --web', TH));
|
|
131
|
+
ok('an unreadable cwd fails open', !blocked(bare, path.join(tmp, 'does', 'not', 'exist')));
|
|
132
|
+
ok('inTrailheadRepo: .trailhead as a plain file is not a trailhead repo', (() => {
|
|
133
|
+
const d = mk('filedir', '.git');
|
|
134
|
+
fs.writeFileSync(path.join(d, '.trailhead'), 'x');
|
|
135
|
+
return inTrailheadRepo(d) === false;
|
|
136
|
+
})());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- unresolvable labels fail open ---
|
|
140
|
+
ok('$VAR label does not block', !blocked('gh issue create --title x --body y --label "$LABEL"', TH));
|
|
141
|
+
ok('$VAR beside a partial set does not block', !blocked('gh issue create --title x --body y --label trailhead:bug --label $L', TH));
|
|
142
|
+
ok('backtick label does not block', !blocked('gh issue create --title x --body y --label `cat l`', TH));
|
|
143
|
+
ok('$(...) label does not block', !blocked('gh issue create --title x --body y --label "$(echo trailhead:bug)"', TH));
|
|
144
|
+
ok('${VAR}-embedded label does not block', !blocked('gh issue create --title x --body y --label "trailhead:${KIND}"', TH));
|
|
145
|
+
|
|
146
|
+
// --- compound commands, wrappers, quoting ---
|
|
147
|
+
{
|
|
148
|
+
const bad = 'gh issue create --title x --body y --label trailhead:bug';
|
|
149
|
+
ok('after cd &&', blocked(`cd x && ${bad}`, NOREPO));
|
|
150
|
+
ok('after ;', blocked(`echo hi; ${bad}`, NOREPO));
|
|
151
|
+
ok('after &&', blocked(`true && ${bad}`, NOREPO));
|
|
152
|
+
ok('after ||', blocked(`false || ${bad}`, NOREPO));
|
|
153
|
+
ok('in a subshell', blocked(`(${bad})`, NOREPO));
|
|
154
|
+
ok('behind a leading assignment', blocked(`FOO=1 ${bad}`, NOREPO));
|
|
155
|
+
ok('behind a global flag', blocked('gh --hostname h issue create --title x --body y --label trailhead:bug', NOREPO));
|
|
156
|
+
ok('behind -R', blocked('gh -R o/r issue create --title x --body y --label trailhead:bug', NOREPO));
|
|
157
|
+
ok('quoted gh word', blocked('"gh" issue create --title x --body y --label trailhead:bug', NOREPO));
|
|
158
|
+
ok('the second of two creates is checked', blocked(`${create(FULL_WB)} && ${bad}`, NOREPO));
|
|
159
|
+
ok('two good creates pass', !blocked(`${create(FULL_WB)} && ${create(FULL_MAP)}`, TH));
|
|
160
|
+
ok('after a cd the cwd is unknown: no-label ground does not block', !blocked('cd /elsewhere && gh issue create --title x --body y', TH));
|
|
161
|
+
ok('a label-looking word in the title is not a label', blocked('gh issue create --title "--label trailhead:ticket" --body y --label trailhead:bug', NOREPO));
|
|
162
|
+
ok('a label-looking word in the body is not a label', !blocked('gh issue create --title x --body "-l trailhead:bug" --label trailhead:ticket --label trailhead:bug --label trailhead:whiteboard', NOREPO));
|
|
163
|
+
ok('a quoted gh text in an echo is not a create', !blocked('echo "gh issue create --label trailhead:bug"', TH));
|
|
164
|
+
ok('a heredoc body mentioning a create is not a create', !blocked('cat <<EOF\ngh issue create --label trailhead:bug\nEOF', TH));
|
|
165
|
+
ok('the create is blocked through a pipeline', blocked(`echo y | ${bad}`, NOREPO));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- everything that is not `gh issue create` is untouched ---
|
|
169
|
+
for (const c of [
|
|
170
|
+
'gh issue edit 5 --add-label trailhead:bug',
|
|
171
|
+
'gh issue list --label trailhead:bug',
|
|
172
|
+
'gh issue view 5 --json labels',
|
|
173
|
+
'gh issue comment 5 --body hi',
|
|
174
|
+
'gh pr create --title x --body y',
|
|
175
|
+
'gh pr create --title x --body y --label trailhead:bug',
|
|
176
|
+
'git status',
|
|
177
|
+
'gh label create trailhead:bug',
|
|
178
|
+
'gh api repos/o/r/issues -f title=x',
|
|
179
|
+
'',
|
|
180
|
+
]) ok(`untouched: ${c || '(empty)'}`, !blocked(c, TH));
|
|
181
|
+
|
|
182
|
+
// --- help and shell comments (review of #308) ---
|
|
183
|
+
ok('--help is not a create', !blocked('gh issue create --help', TH));
|
|
184
|
+
ok('-h is not a create', !blocked('gh issue create -h', TH));
|
|
185
|
+
ok('a trailing comment cannot supply the labels',
|
|
186
|
+
blocked('gh issue create -t x -b y # --label trailhead:ticket,trailhead:bug,trailhead:whiteboard', TH));
|
|
187
|
+
ok('a comment after a full set changes nothing', !blocked(`${create(FULL_WB)} # note`, TH));
|
|
188
|
+
ok('a quoted # is still a value', !blocked('gh issue create -t "#1 x" -b y --label "trailhead:ticket,trailhead:bug,trailhead:whiteboard"', TH));
|
|
189
|
+
|
|
190
|
+
// --- parseCreate ---
|
|
191
|
+
{
|
|
192
|
+
const p = parseCreate('gh issue create -l a,b --label=c -ld --title t');
|
|
193
|
+
ok('parseCreate reads every label form', JSON.stringify(p.labels) === JSON.stringify(['a', 'b', 'c', 'd']));
|
|
194
|
+
ok('parseCreate flags an unresolved value', parseCreate('gh issue create --label $X').unresolved === true);
|
|
195
|
+
ok('parseCreate sees -R', parseCreate('gh issue create -R o/r').repoFlag === true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- crash-safety ---
|
|
199
|
+
ok('non-string input does not throw', evaluate(undefined, {}) === null && evaluate(null) === null);
|
|
200
|
+
{
|
|
201
|
+
const bad = spawnSync('node', [HOOK], { input: 'not json {{' });
|
|
202
|
+
ok('unparseable hook input exits 0', bad.status === 0);
|
|
203
|
+
const nul = spawnSync('node', [HOOK], { input: 'null' });
|
|
204
|
+
ok('null hook input exits 0', nul.status === 0);
|
|
205
|
+
const clean = runHook('git status', TH);
|
|
206
|
+
ok('a clean command exits 0 with empty stdout', clean.code === 0 && clean.out === '');
|
|
207
|
+
const good = runHook(create(FULL_WB), TH);
|
|
208
|
+
ok('a full whiteboard create exits 0 end to end', good.code === 0 && good.out === '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
212
|
+
console.log(`labels-guard: ${passed} assertions passed`);
|