@marcomigozzi/trailhead 0.16.0 → 0.17.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 +67 -18
- package/README.md +67 -18
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/lib/codex-projection.js +30 -14
- package/bin/lib/codex-projection.test.js +81 -17
- package/bin/lib/host-descriptor.js +15 -2
- package/bin/lib/host-descriptor.test.js +24 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/report-status-docs.test.js +11 -3
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/trailhead.js +7 -5
- package/bin/trailhead.test.js +53 -11
- package/bin/unblock-docs.test.js +56 -0
- 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-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +24 -10
- package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
- package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques.md +7 -5
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- 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 +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
- package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
- package/plugins/trailhead/templates/trailhead-capture.js +418 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
- package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
- package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -7,7 +7,7 @@ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
|
|
|
7
7
|
|
|
8
8
|
trailhead's codebase-map reader subagent. Map ONE non-overlapping facet of the repo for the distilled `trailhead:codebase` issue; do not read another reader's territory.
|
|
9
9
|
|
|
10
|
-
The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/codebase-map.md`); follow that, not any embedded copy. Read-only by policy: read-only by construction. Model-agnostic by design: the engine passes the
|
|
10
|
+
The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/codebase-map.md`); follow that, not any embedded copy. Read-only by policy: read-only by construction. Model-agnostic by design: the engine passes the `config.models.codebase-map` model as the spawn-time override, so config stays authoritative.
|
|
11
11
|
|
|
12
12
|
<!-- trailhead:agent-block common -->
|
|
13
13
|
**Search-command hygiene.** Prefer the Grep and Read tools; use absolute paths and `git -C <abs>`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trailhead-grill
|
|
3
|
+
color: yellow
|
|
4
|
+
description: "trailhead grill subagent: one read-only Grilling round, returned as a question or a holistic review."
|
|
5
|
+
tools: Read, Grep, Glob, WebFetch, WebSearch, TodoWrite
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
trailhead's grill subagent. Run one round of Grilling for the orchestrator: read the code facts the question needs, then return either the next numbered question with your recommended answer, or the holistic review of the settled choices. You are read-only: you never talk to the user and never answer on their behalf; the orchestrator relays your question and re-dispatches you with the answer.
|
|
9
|
+
|
|
10
|
+
The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/grilling.md`); follow its round bullets and the `Return contract` below, not any embedded copy. The orchestrator-facing section of that file is not yours. The orchestrator passes the ticket body, the relevant decisions, the tickets the subject names and the Q&A so far; a fact you cannot reach goes under `facts` as `not looked up:`. Model-agnostic by design: the engine passes the `config.models.grill` model as the spawn-time override, so config stays authoritative.
|
|
11
|
+
|
|
12
|
+
<!-- trailhead:agent-block common -->
|
|
13
|
+
**Search-command hygiene.** Prefer the Grep and Read tools; use absolute paths and `git -C <abs>`.
|
|
14
|
+
Never `cd` (or `pushd`/`env -C`) into a directory first; the full rule is in `_shared/techniques.md`.
|
|
15
|
+
|
|
16
|
+
**Boundaries.** Never `git push`. Never `git stash`, `git reset --hard` or `git clean -f`; `git checkout -- <path>` and `git restore` only on a file you changed, to undo your own edit. Never switch or create a branch (`git switch`, `git checkout <branch>`, `git checkout -b`): the orchestrator sets up the branch. Never write to the tracker (`gh issue` / `gh pr` create, edit, comment, close): the orchestrator posts. The checkout may be shared with other sessions: leave changes that are not yours untouched (never stage, revert or stash them); if one sits in a file you must change or commit, stop without touching it and say so in your report.
|
|
17
|
+
<!-- /trailhead:agent-block common -->
|
|
18
|
+
|
|
19
|
+
<!-- trailhead:agent-block grilling -->
|
|
20
|
+
**Return contract.** Return the complete report as your final message. Do not post it anywhere (no issue comment, no PR comment): the orchestrator posts what it records. Never commit. Read-only: never Edit/Write.
|
|
21
|
+
- The first line of the report is `status: <token>`, verbatim, with nothing before it. It is the only line starting with `status:`: status text anywhere in the body is quoted in a code fence or a blockquote, or reworded.
|
|
22
|
+
- statuses: `question` | `review`.
|
|
23
|
+
- required fields: `settled`, `frontier`, `amendments`, `conflicts`.
|
|
24
|
+
- optional fields: `question`, `recommended`, `areas`, `facts`.
|
|
25
|
+
- invariant: `question` => `question` non-empty.
|
|
26
|
+
- invariant: `question` => `recommended` non-empty.
|
|
27
|
+
- invariant: `question` => `amendments` none.
|
|
28
|
+
- invariant: `question` => `conflicts` none.
|
|
29
|
+
- invariant: `review` => `frontier` none.
|
|
30
|
+
- invariant: `review` => `question` none.
|
|
31
|
+
- A field is a top-level line `<label>:` (outside code fences and blockquotes); its value is the rest of that line plus every following line up to the next field label. An empty value or `none` means none.
|
|
32
|
+
- Fields are the end of the report: nothing after the last field.
|
|
33
|
+
- You never talk to the user and never answer on their behalf: the orchestrator relays the question and collects the answer. The recommended answer is only your pick.
|
|
34
|
+
- `settled:` every settled choice so far, one per line by question number, with its rationale; `none` before the first answer.
|
|
35
|
+
- `frontier:` the open questions whose prerequisites are settled; `none` once it is empty.
|
|
36
|
+
- `question:` the one numbered question to ask now. Illustrate each option: what it optimizes and what it sacrifices. Offer defer in prose when the question is blocked on a prerequisite. Play back the user's understanding when it closes a branch.
|
|
37
|
+
- `recommended:` your pick, stress-tested, ending with a `Weak point:` line (not a field label, so a review may carry one per fix). Never the delegate option.
|
|
38
|
+
- `areas:` for a multi-part space, every functional area, each marked visited or open.
|
|
39
|
+
- `facts:` what you looked up, with `file:line` or a URL. A fact you could not reach is written as `not looked up: <what>`; the orchestrator fetches it for the next round.
|
|
40
|
+
- `amendments:` / `conflicts:` the holistic review. Each item names its question numbers; each conflict carries its fix, or one line saying where it goes. Both are `none` when nothing is found, and always `none` on a `question` round.
|
|
41
|
+
<!-- /trailhead:agent-block grilling -->
|
|
@@ -19,7 +19,7 @@ Never `cd` (or `pushd`/`env -C`) into a directory first; the full rule is in `_s
|
|
|
19
19
|
<!-- trailhead:agent-block reviewing -->
|
|
20
20
|
**Return contract.** Return the complete report as your final message. Do not post it anywhere (no issue comment, no PR comment): the orchestrator posts the `VERIFY` comment after it checks the status line.
|
|
21
21
|
- The first line of the report is the technique's `status:` line, verbatim: `status: <token>`, with nothing before it and no other `status:` line.
|
|
22
|
-
- trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive
|
|
23
|
-
- trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n
|
|
22
|
+
- trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive`; each claim line carries `class: <met|unmet|partial|inconclusive>`, and `met` lists no `unmet`, `partial` or `inconclusive` claim.
|
|
23
|
+
- trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n>`; `clean` with a non-zero `critical` count requires a ledger line closing every Critical (`fixed`, `deferred` or `accepted`), none `open`.
|
|
24
24
|
- Read-only: never Edit/Write, never commit.
|
|
25
25
|
<!-- /trailhead:agent-block reviewing -->
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-capture.js (#332). Run: node capture.test.js
|
|
3
|
+
// No framework: plain asserts, same style as needs-you.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. Seams: parseArgs, buildIssue, parseAllowlist, checkTrust, resolveDestination,
|
|
7
|
+
// findDuplicate.
|
|
8
|
+
// 2. main() against a fake tracker behind an injected `gh` and `sleep`.
|
|
9
|
+
// 3. End-to-end: the script itself with a fake `gh` on PATH.
|
|
10
|
+
const assert = require('assert');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-capture.js');
|
|
17
|
+
const guardYml = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-label-guard.yml');
|
|
18
|
+
const cap = require(scriptPath);
|
|
19
|
+
const { main, parseArgs, buildIssue, parseAllowlist, checkTrust, resolveDestination, findDuplicate } = cap;
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
|
|
25
|
+
const TYPE_LABELS = ['decision', 'research', 'prototype', 'build', 'bug', 'task'].map((t) => `trailhead:${t}`);
|
|
26
|
+
const URL_BASE = 'https://github.com/o/r/issues';
|
|
27
|
+
|
|
28
|
+
// --- fake tracker ------------------------------------------------------------
|
|
29
|
+
// issues: number -> { state, title, labels:[names] } for pre-existing issues (maps, origins).
|
|
30
|
+
// Options: login (null = unreadable identity), perms {login: permission}, allowlist (undefined =
|
|
31
|
+
// unreadable variable), labeller (actor of the labeled events), eventsFail, dropLabels (labels the
|
|
32
|
+
// server loses), eventsLag (count of leading event reads that return an empty list), eventsPartial
|
|
33
|
+
// (count of leading reads that show only the first label), lateLabeller (actor of the events
|
|
34
|
+
// after the partial reads, default labeller), edgeFail (count of failing edge POSTs), dashFail,
|
|
35
|
+
// searchCap, open [{number,body}].
|
|
36
|
+
function makeTracker(init = {}) {
|
|
37
|
+
const t = {
|
|
38
|
+
login: 'maint', perms: { maint: 'admin' }, allowlist: undefined, labeller: 'maint', eventsFail: false, eventsLag: 0, eventsPartial: 0, lateLabeller: null, eventReads: 0,
|
|
39
|
+
dropLabels: [], edgeFail: 0, dashFail: false, searchCap: false, open: [], permFail: false,
|
|
40
|
+
issues: { 5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] }, 6: { state: 'CLOSED', title: 'Map six', labels: ['trailhead:map'] } },
|
|
41
|
+
...init, calls: [], created: [], edges: [], inputs: [], next: 100,
|
|
42
|
+
};
|
|
43
|
+
const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
|
|
44
|
+
const j = (o) => res(JSON.stringify(o));
|
|
45
|
+
t.gh = (args, input) => {
|
|
46
|
+
const line = args.join(' ');
|
|
47
|
+
t.calls.push(line);
|
|
48
|
+
const [a, b] = args;
|
|
49
|
+
if (a === 'label' && b === 'create') return res('');
|
|
50
|
+
if (line.startsWith('api user')) return t.login === null ? res('', 1) : res(t.login + '\n');
|
|
51
|
+
let m = /^api repos\/o\/r\/collaborators\/([^/]+)\/permission/.exec(line);
|
|
52
|
+
if (m) return t.permFail ? res('', 1) : j({ permission: t.perms[m[1]] || 'none', role_name: t.perms[m[1]] || 'none' });
|
|
53
|
+
if (/^api repos\/o\/r\/actions\/variables\/TRAILHEAD_LABEL_ALLOWLIST/.test(line)) return t.allowlist === undefined ? res('', 1) : res(t.allowlist + '\n');
|
|
54
|
+
if (a === 'issue' && b === 'view') {
|
|
55
|
+
const n = Number(args[2]);
|
|
56
|
+
const pre = t.issues[n];
|
|
57
|
+
const made = t.created.find((c) => c.number === n);
|
|
58
|
+
if (made) return j({ labels: made.labels.filter((l) => !t.dropLabels.includes(l)).map((name) => ({ name })) });
|
|
59
|
+
if (!pre) return res('', 1);
|
|
60
|
+
return j({ state: pre.state, title: pre.title, url: `${URL_BASE}/${n}`, labels: pre.labels.map((name) => ({ name })) });
|
|
61
|
+
}
|
|
62
|
+
if (a === 'issue' && b === 'list' && args.includes('--search')) {
|
|
63
|
+
const items = t.searchCap ? Array.from({ length: 1000 }, (_, i) => ({ number: 9000 + i, url: `${URL_BASE}/${9000 + i}`, body: 'x' })) : t.open.map((o) => ({ number: o.number, url: `${URL_BASE}/${o.number}`, body: o.body }));
|
|
64
|
+
return j(items);
|
|
65
|
+
}
|
|
66
|
+
if (a === 'issue' && b === 'list') return t.dashFail ? res('', 1) : res('[]');
|
|
67
|
+
if (/^api --paginate repos\/o\/r\/issues\?state=open/.test(line)) {
|
|
68
|
+
return res(t.open.map((o) => JSON.stringify({ number: o.number, html_url: `${URL_BASE}/${o.number}`, body: o.body, pr: !!o.pr })).join('\n') + '\n');
|
|
69
|
+
}
|
|
70
|
+
m = /^api --paginate repos\/o\/r\/issues\/(\d+)\/events/.exec(line);
|
|
71
|
+
if (m) {
|
|
72
|
+
const read = t.eventReads++;
|
|
73
|
+
if (t.eventsFail) return res('', 1);
|
|
74
|
+
const made = t.created.find((c) => c.number === Number(m[1]));
|
|
75
|
+
if (read < t.eventsLag) return res('');
|
|
76
|
+
if (read < t.eventsLag + t.eventsPartial) return res(JSON.stringify({ label: made.labels[0], actor: t.labeller }) + '\n');
|
|
77
|
+
const late = t.lateLabeller || t.labeller;
|
|
78
|
+
return res(made.labels.map((l, i) => JSON.stringify({ label: l, actor: i === 0 ? t.labeller : late })).join('\n') + '\n');
|
|
79
|
+
}
|
|
80
|
+
if (a === 'issue' && b === 'create') {
|
|
81
|
+
const n = t.next++;
|
|
82
|
+
const labels = args[args.indexOf('--label') + 1].split(',');
|
|
83
|
+
t.created.push({ number: n, title: args[args.indexOf('--title') + 1], labels, body: input });
|
|
84
|
+
t.inputs.push(input);
|
|
85
|
+
return res(`${URL_BASE}/${n}\n`);
|
|
86
|
+
}
|
|
87
|
+
m = /^api repos\/o\/r\/issues\/(\d+) --jq \.id/.exec(line);
|
|
88
|
+
if (m) return res(String(9000 + Number(m[1])) + '\n');
|
|
89
|
+
m = /^api --method POST repos\/o\/r\/issues\/(\d+)\/sub_issues/.exec(line);
|
|
90
|
+
if (m) {
|
|
91
|
+
if (t.edgeFail > 0) { t.edgeFail--; return res('', 1); }
|
|
92
|
+
t.edges.push({ map: Number(m[1]), sub: line.split('sub_issue_id=')[1] });
|
|
93
|
+
return res('{}');
|
|
94
|
+
}
|
|
95
|
+
return res('', 1);
|
|
96
|
+
};
|
|
97
|
+
return t;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function run(t, argv, extra = {}) {
|
|
101
|
+
let out = '';
|
|
102
|
+
let errOut = '';
|
|
103
|
+
const code = main(argv, { gh: t.gh, guard: fakeGuard, env: {}, sleep: () => {}, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
|
|
104
|
+
let json = null;
|
|
105
|
+
try { json = JSON.parse(out.trim()); } catch { /* not json */ }
|
|
106
|
+
return { code, out, json, errOut };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const base = (type, extra = []) => ['--type', type, '--title', 'Add login rate limit', '--body', 'Details here', '--repo', 'o/r', ...extra];
|
|
110
|
+
const creates = (t) => t.calls.filter((c) => c.startsWith('issue create'));
|
|
111
|
+
|
|
112
|
+
// The documented frontier filter, applied to the fake's created issues.
|
|
113
|
+
const onFrontier = (issue, destLabel) => issue.labels.includes('trailhead:ticket') && issue.labels.includes(destLabel)
|
|
114
|
+
&& !['trailhead:blocked', 'trailhead:unverified', 'trailhead:paused'].some((l) => issue.labels.includes(l));
|
|
115
|
+
|
|
116
|
+
// --- parseArgs ---------------------------------------------------------------------
|
|
117
|
+
{
|
|
118
|
+
const o = parseArgs(base('bug', ['--map', '5', '--unverified', '--dedupe-key', 'a.b:c-1', '--no-dashboard']));
|
|
119
|
+
ok('parseArgs: full bug', !o.error && o.type === 'bug' && o.map === 5 && o.unverified && o.dedupeKey === 'a.b:c-1' && o.noDashboard && o.repo === 'o/r' && o.body === 'Details here');
|
|
120
|
+
ok('parseArgs: --unverified rejected for idea', /--unverified is only for ticket types/.test(parseArgs(base('idea', ['--unverified'])).error || ''));
|
|
121
|
+
ok('parseArgs: seed with trigger', parseArgs(base('seed', ['--trigger', 'when X'])).trigger === 'when X');
|
|
122
|
+
ok('parseArgs: --of with bug', parseArgs(base('bug', ['--of', '12'])).of === 12);
|
|
123
|
+
ok('parseArgs: --body-file -', parseArgs(['--type', 'todo', '--title', 't', '--body-file', '-', '--repo', 'o/r']).bodyFile === '-');
|
|
124
|
+
}
|
|
125
|
+
for (const [name, argv] of [
|
|
126
|
+
['no args', []],
|
|
127
|
+
['unknown type', base('epic')],
|
|
128
|
+
['missing title', ['--type', 'bug', '--body', 'b', '--repo', 'o/r']],
|
|
129
|
+
['empty title', ['--type', 'bug', '--title', ' ', '--body', 'b', '--repo', 'o/r']],
|
|
130
|
+
['missing body', ['--type', 'bug', '--title', 't', '--repo', 'o/r']],
|
|
131
|
+
['body and body-file', base('bug', ['--body-file', 'x'])],
|
|
132
|
+
['missing repo', ['--type', 'bug', '--title', 't', '--body', 'b']],
|
|
133
|
+
['bad repo', ['--type', 'bug', '--title', 't', '--body', 'b', '--repo', 'nope']],
|
|
134
|
+
['seed without trigger', base('seed')],
|
|
135
|
+
['trigger on a todo', base('todo', ['--trigger', 'x'])],
|
|
136
|
+
['--of on a todo', base('todo', ['--of', '3'])],
|
|
137
|
+
['--of with --map', base('bug', ['--of', '3', '--map', '5'])],
|
|
138
|
+
['bad map', base('bug', ['--map', 'x'])],
|
|
139
|
+
['bad dedupe key (space)', base('bug', ['--dedupe-key', 'a b'])],
|
|
140
|
+
['bad dedupe key (too long)', base('bug', ['--dedupe-key', 'a'.repeat(101)])],
|
|
141
|
+
['unknown flag', base('bug', ['--bogus', '1'])],
|
|
142
|
+
['flag without value', ['--type']],
|
|
143
|
+
]) {
|
|
144
|
+
ok(`parseArgs rejects: ${name}`, !!parseArgs(argv).error);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- buildIssue (pure) --------------------------------------------------------------
|
|
148
|
+
{
|
|
149
|
+
const map = { number: 5, title: 'Map five', url: `${URL_BASE}/5` };
|
|
150
|
+
const bug = buildIssue({ type: 'bug', title: 'T', body: 'B', map });
|
|
151
|
+
ok('buildIssue bug on a map', bug.labels.join() === 'trailhead:ticket,trailhead:bug,trailhead:map-5' && bug.destination === 'map-5');
|
|
152
|
+
ok('buildIssue map ticket: Parent is the first body line', bug.body.split('\n')[0] === `Parent: [Map five](${URL_BASE}/5)`);
|
|
153
|
+
const todo = buildIssue({ type: 'todo', title: 'T', body: 'B', map: null });
|
|
154
|
+
ok('buildIssue todo on the whiteboard', todo.labels.join() === 'trailhead:ticket,trailhead:build,trailhead:whiteboard' && todo.destination === 'whiteboard' && !/Parent:/.test(todo.body));
|
|
155
|
+
const seed = buildIssue({ type: 'seed', title: 'T', body: 'B', map: null, trigger: 'when X ships' });
|
|
156
|
+
ok('buildIssue seed labels', ['trailhead:ticket', 'trailhead:build', 'trailhead:seed', 'trailhead:blocked', 'trailhead:whiteboard'].every((l) => seed.labels.includes(l)));
|
|
157
|
+
ok('buildIssue seed body: Trigger line and Blocked by section', /^Trigger: when X ships$/m.test(seed.body) && /## Blocked by\n[^]*when X ships/.test(seed.body));
|
|
158
|
+
for (const [type, extra] of [['bug', {}], ['todo', {}], ['seed', { trigger: 'x' }]]) {
|
|
159
|
+
const b = buildIssue({ type, title: 'T', body: 'B', map: null, unverified: true, ...extra });
|
|
160
|
+
ok(`buildIssue ${type}: exactly one type label (state labels never count)`, b.labels.filter((l) => TYPE_LABELS.includes(l)).length === 1);
|
|
161
|
+
ok(`buildIssue ${type}: --unverified adds the label`, b.labels.includes('trailhead:unverified'));
|
|
162
|
+
}
|
|
163
|
+
const idea = buildIssue({ type: 'idea', title: 'T', body: 'B', map: null });
|
|
164
|
+
ok('buildIssue idea: only trailhead:fog, no Parent', idea.labels.join() === 'trailhead:fog' && !/Parent:/.test(idea.body));
|
|
165
|
+
const ideaMap = buildIssue({ type: 'idea', title: 'T', body: 'B', map });
|
|
166
|
+
ok('buildIssue idea with a map: Parent line, still only fog', ideaMap.labels.join() === 'trailhead:fog' && ideaMap.body.startsWith(`Parent: [Map five](${URL_BASE}/5)`));
|
|
167
|
+
const reg = buildIssue({ type: 'bug', title: 'T', body: 'B', map: null, of: { title: 'Old work', url: `${URL_BASE}/9` } });
|
|
168
|
+
ok('buildIssue --of: Regression of line', reg.body.includes(`Regression of: [Old work](${URL_BASE}/9)`));
|
|
169
|
+
const dd = buildIssue({ type: 'todo', title: 'T', body: 'B', map: null, dedupeKey: 'k1' });
|
|
170
|
+
ok('buildIssue dedupe marker', dd.body.includes('<!-- trailhead-dedupe: k1 -->'));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- allowlist parity with the guard Action ------------------------------------------
|
|
174
|
+
{
|
|
175
|
+
const yml = fs.readFileSync(guardYml, 'utf8');
|
|
176
|
+
const m = /const extra = (\(process\.env\.ALLOWLIST[^;]*?\.filter\(Boolean\));/.exec(yml);
|
|
177
|
+
ok('parity: the guard parsing expression is found', !!m);
|
|
178
|
+
const actionParse = (s) => new Function('process', `return ${m[1]};`)({ env: { ALLOWLIST: s } });
|
|
179
|
+
for (const s of ['', 'a,b', ' A , b ,, C', ',,', 'Mixed,CASE , spaced ', undefined]) {
|
|
180
|
+
assert.deepStrictEqual(parseAllowlist(s), actionParse(s));
|
|
181
|
+
passed++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- checkTrust -----------------------------------------------------------------------
|
|
186
|
+
{
|
|
187
|
+
const t = makeTracker({ login: 'maint', perms: { maint: 'write' } });
|
|
188
|
+
ok('checkTrust: write -> ok', checkTrust(t.gh, 'o/r').trust === 'ok');
|
|
189
|
+
const tm = makeTracker({ login: 'm', perms: { m: 'maintain' } });
|
|
190
|
+
ok('checkTrust: maintain -> ok', checkTrust(tm.gh, 'o/r').trust === 'ok');
|
|
191
|
+
const ta = makeTracker({ login: 'bot', perms: {}, allowlist: 'Other, BOT' });
|
|
192
|
+
ok('checkTrust: repo allowlist (case-insensitive) -> allowlisted', checkTrust(ta.gh, 'o/r').trust === 'allowlisted');
|
|
193
|
+
const tn = makeTracker({ login: 'rando', perms: { rando: 'read' }, allowlist: 'someone' });
|
|
194
|
+
ok('checkTrust: neither -> insufficient', checkTrust(tn.gh, 'o/r').trust === 'insufficient');
|
|
195
|
+
const tu = makeTracker({ login: 'rando', perms: {}, allowlist: undefined });
|
|
196
|
+
ok('checkTrust: unreadable variable counts as not allowlisted', checkTrust(tu.gh, 'o/r').trust === 'insufficient');
|
|
197
|
+
const tp = makeTracker({ login: 'maint', permFail: true });
|
|
198
|
+
ok('checkTrust: failed permission read counts as none', checkTrust(tp.gh, 'o/r').trust === 'insufficient');
|
|
199
|
+
const ti = makeTracker({ login: null });
|
|
200
|
+
ok('checkTrust: unreadable identity -> pending', checkTrust(ti.gh, 'o/r').trust === 'pending');
|
|
201
|
+
const te = makeTracker({ login: 'rando', perms: {}, allowlist: undefined });
|
|
202
|
+
const r = checkTrust(te.gh, 'o/r', { TRAILHEAD_LABEL_ALLOWLIST: 'rando' });
|
|
203
|
+
ok('checkTrust: the local environment is never consulted', r.trust === 'insufficient');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- resolveDestination -----------------------------------------------------------------
|
|
207
|
+
{
|
|
208
|
+
const t = makeTracker({ issues: {
|
|
209
|
+
5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] },
|
|
210
|
+
6: { state: 'CLOSED', title: 'Map six', labels: ['trailhead:map'] },
|
|
211
|
+
7: { state: 'OPEN', title: 'Not a map', labels: ['trailhead:ticket'] },
|
|
212
|
+
10: { state: 'CLOSED', title: 'Origin on 5', labels: ['trailhead:ticket', 'trailhead:map-5'] },
|
|
213
|
+
11: { state: 'CLOSED', title: 'Origin loose', labels: ['trailhead:ticket', 'trailhead:whiteboard'] },
|
|
214
|
+
12: { state: 'CLOSED', title: 'Origin on closed', labels: ['trailhead:ticket', 'trailhead:map-6'] },
|
|
215
|
+
13: { state: 'CLOSED', title: 'Origin bare', labels: ['trailhead:ticket'] },
|
|
216
|
+
14: { state: 'CLOSED', title: 'Origin both', labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:map-5'] },
|
|
217
|
+
15: { state: 'CLOSED', title: 'Origin two maps', labels: ['trailhead:ticket', 'trailhead:map-5', 'trailhead:map-6'] },
|
|
218
|
+
} });
|
|
219
|
+
const rd = (o) => resolveDestination(t.gh, 'o/r', o);
|
|
220
|
+
ok('resolveDestination: open map', rd({ map: 5 }).map.number === 5 && rd({ map: 5 }).map.title === 'Map five');
|
|
221
|
+
ok('resolveDestination: closed map is an error', !!rd({ map: 6 }).error);
|
|
222
|
+
ok('resolveDestination: non-map issue is an error', !!rd({ map: 7 }).error);
|
|
223
|
+
ok('resolveDestination: missing map is an error', !!rd({ map: 99 }).error);
|
|
224
|
+
ok('resolveDestination: none -> whiteboard', rd({}).map === null && !rd({}).error);
|
|
225
|
+
const of10 = rd({ of: 10 });
|
|
226
|
+
ok('resolveDestination --of: inherits the open map and returns the origin', of10.map.number === 5 && of10.of.title === 'Origin on 5');
|
|
227
|
+
const of11 = rd({ of: 11 });
|
|
228
|
+
ok('resolveDestination --of: inherits the whiteboard', of11.map === null && !of11.error && of11.of.title === 'Origin loose');
|
|
229
|
+
for (const n of [12, 13, 14, 15, 99]) {
|
|
230
|
+
ok(`resolveDestination --of ${n}: unresolved`, /origin destination unresolved/.test(rd({ of: n }).error || ''));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- findDuplicate ----------------------------------------------------------------------
|
|
235
|
+
{
|
|
236
|
+
const t = makeTracker({ open: [{ number: 3, body: 'x\n<!-- trailhead-dedupe: k1 -->' }, { number: 4, body: 'mentions trailhead-dedupe: k1 only in prose' }] });
|
|
237
|
+
const hit = findDuplicate(t.gh, 'o/r', 'k1');
|
|
238
|
+
ok('findDuplicate: exact marker hit', hit.issue === 3 && hit.url.endsWith('/3'));
|
|
239
|
+
ok('findDuplicate: prose mention is not a hit', findDuplicate(t.gh, 'o/r', 'k2') === null);
|
|
240
|
+
ok('findDuplicate: search uses the quoted phrase and the cap', t.calls[0].includes('"trailhead-dedupe: k1" in:body') && t.calls[0].includes('--limit 1000'));
|
|
241
|
+
const tc = makeTracker({ searchCap: true, open: [{ number: 40, body: 'a <!-- trailhead-dedupe: k9 --> b' }, { number: 41, body: 'pr', pr: true }] });
|
|
242
|
+
const capHit = findDuplicate(tc.gh, 'o/r', 'k9');
|
|
243
|
+
ok('findDuplicate: a capped search falls back to the full paginated scan', capHit && capHit.issue === 40 && tc.calls.some((c) => c.startsWith('api --paginate repos/o/r/issues?state=open')));
|
|
244
|
+
const tf = makeTracker({ searchCap: true, open: [{ number: 41, body: '<!-- trailhead-dedupe: k9 -->', pr: true }] });
|
|
245
|
+
ok('findDuplicate: pull requests are skipped in the scan', findDuplicate(tf.gh, 'o/r', 'k9') === null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- main: type x destination ---------------------------------------------------------
|
|
249
|
+
for (const type of ['bug', 'todo', 'seed']) {
|
|
250
|
+
const extra = type === 'seed' ? ['--trigger', 'when X ships'] : [];
|
|
251
|
+
const tm = makeTracker();
|
|
252
|
+
const rm = run(tm, base(type, ['--map', '5', ...extra]));
|
|
253
|
+
const made = tm.created[0];
|
|
254
|
+
ok(`${type} on a map: created, exit 0, one JSON line`, rm.code === 0 && rm.json.status === 'created' && rm.out.trim().split('\n').length === 1);
|
|
255
|
+
ok(`${type} on a map: output fields`, rm.json.issue === 100 && rm.json.url.endsWith('/100') && rm.json.type === type && rm.json.destination === 'map-5' && rm.json.trust === 'ok' && rm.json.edge === 'ok' && rm.json.dashboard === 'not-applicable' && Array.isArray(rm.json.warnings));
|
|
256
|
+
ok(`${type} on a map: labels ride in the single create call`, creates(tm).length === 1 && made.labels.includes('trailhead:map-5') && made.labels.includes('trailhead:ticket'));
|
|
257
|
+
ok(`${type} on a map: Parent first line, body on stdin`, made.body.split('\n')[0] === `Parent: [Map five](${URL_BASE}/5)` && tm.calls[tm.calls.findIndex((c) => c.startsWith('issue create'))].includes('--body-file -'));
|
|
258
|
+
ok(`${type} on a map: native edge`, tm.edges.length === 1 && tm.edges[0].map === 5 && tm.edges[0].sub === '9100');
|
|
259
|
+
ok(`${type} on a map: no dashboard call`, !tm.calls.some((c) => c.includes('trailhead:dashboard')));
|
|
260
|
+
const onF = onFrontier(made, 'trailhead:map-5');
|
|
261
|
+
ok(`${type} on a map: frontier membership`, type === 'seed' ? (made.labels.includes('trailhead:map-5') && !onF) : onF);
|
|
262
|
+
|
|
263
|
+
const tw = makeTracker();
|
|
264
|
+
const dashCalls = [];
|
|
265
|
+
const rw = run(tw, base(type, extra), { dashboard: { main: (argv) => { dashCalls.push(argv); return 0; } } });
|
|
266
|
+
const madeW = tw.created[0];
|
|
267
|
+
ok(`${type} on the whiteboard: created, whiteboard label, no Parent, no edge`, rw.json.status === 'created' && rw.json.destination === 'whiteboard' && madeW.labels.includes('trailhead:whiteboard') && !/Parent:/.test(madeW.body) && tw.edges.length === 0 && !('edge' in rw.json));
|
|
268
|
+
ok(`${type} on the whiteboard: label ensured before create`, tw.calls.findIndex((c) => c.startsWith('label create trailhead:whiteboard')) >= 0 && tw.calls.findIndex((c) => c.startsWith('label create trailhead:whiteboard')) < tw.calls.findIndex((c) => c.startsWith('issue create')));
|
|
269
|
+
ok(`${type} on the whiteboard: dashboard refreshed update-only`, rw.json.dashboard === 'refreshed' && dashCalls.length === 1 && dashCalls[0].join(' ') === '--repo o/r --update-only');
|
|
270
|
+
const onW = onFrontier(madeW, 'trailhead:whiteboard');
|
|
271
|
+
ok(`${type} on the whiteboard: frontier membership`, type === 'seed' ? (madeW.labels.includes('trailhead:whiteboard') && !onW) : onW);
|
|
272
|
+
}
|
|
273
|
+
{
|
|
274
|
+
const t = makeTracker();
|
|
275
|
+
const r = run(t, base('todo', ['--unverified']));
|
|
276
|
+
ok('--unverified is held off the frontier', t.created[0].labels.includes('trailhead:unverified') && !onFrontier(t.created[0], 'trailhead:whiteboard') && r.json.status === 'created');
|
|
277
|
+
}
|
|
278
|
+
{
|
|
279
|
+
// The real dashboard helper in-process through the fake tracker: no dashboard issue -> skipped, exit 0.
|
|
280
|
+
const t = makeTracker();
|
|
281
|
+
const r = run(t, base('bug'));
|
|
282
|
+
ok('whiteboard capture runs the real dashboard helper update-only', r.json.dashboard === 'refreshed' && t.calls.some((c) => c.includes('--label trailhead:dashboard')) && !t.calls.some((c) => c.startsWith('issue create') && c.includes('trailhead:dashboard')));
|
|
283
|
+
const tn = makeTracker();
|
|
284
|
+
ok('--no-dashboard skips the refresh', run(tn, base('bug', ['--no-dashboard'])).json.dashboard === 'skipped' && !tn.calls.some((c) => c.includes('--label trailhead:dashboard')));
|
|
285
|
+
const tf = makeTracker({ dashFail: true });
|
|
286
|
+
const rf = run(tf, base('bug'));
|
|
287
|
+
ok('dashboard failure keeps the status, flags it, warns', rf.code === 0 && rf.json.status === 'created' && rf.json.dashboard === 'failed' && rf.json.warnings.length === 1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- main: idea as fog -----------------------------------------------------------------
|
|
291
|
+
{
|
|
292
|
+
const t = makeTracker();
|
|
293
|
+
const r = run(t, base('idea'));
|
|
294
|
+
ok('idea: created as trailhead:fog only', r.code === 0 && r.json.status === 'created' && t.created[0].labels.join() === 'trailhead:fog' && r.json.labels.join() === 'trailhead:fog');
|
|
295
|
+
ok('idea: no edge, no dashboard, no map label', t.edges.length === 0 && r.json.dashboard === 'not-applicable' && !t.calls.some((c) => c.includes('trailhead:dashboard')) && !('edge' in r.json));
|
|
296
|
+
const tm = makeTracker();
|
|
297
|
+
const rm = run(tm, base('idea', ['--map', '5']));
|
|
298
|
+
ok('idea with a map: Parent line, still fog only, no edge', rm.json.status === 'created' && tm.created[0].body.startsWith('Parent: [Map five]') && tm.created[0].labels.join() === 'trailhead:fog' && tm.edges.length === 0 && rm.json.destination === 'map-5');
|
|
299
|
+
const tc = makeTracker();
|
|
300
|
+
ok('idea with a closed map is rejected, nothing created', run(tc, base('idea', ['--map', '6'])).code === 1 && creates(tc).length === 0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- main: trust --------------------------------------------------------------------------
|
|
304
|
+
{
|
|
305
|
+
const t = makeTracker({ login: 'rando', perms: { rando: 'read' }, allowlist: 'x' });
|
|
306
|
+
const r = run(t, base('bug', ['--map', '5']));
|
|
307
|
+
ok('insufficient trust: exit 1, trust insufficient, no create call', r.code === 1 && r.json.status === 'error' && r.json.trust === 'insufficient' && creates(t).length === 0);
|
|
308
|
+
const ta = makeTracker({ login: 'bot', perms: {}, allowlist: 'bot' });
|
|
309
|
+
ok('allowlisted identity: created with trust allowlisted', run(ta, base('todo')).json.trust === 'allowlisted');
|
|
310
|
+
}
|
|
311
|
+
{
|
|
312
|
+
// Unreadable identity: decided from the labeled events after create.
|
|
313
|
+
const cases = [
|
|
314
|
+
['github-actions[bot]', {}, 'actions-token', 0],
|
|
315
|
+
['appbot', { allowlist: 'AppBot' }, 'allowlisted', 0],
|
|
316
|
+
['writer', { perms: { writer: 'write' } }, 'ok', 0],
|
|
317
|
+
['stranger', { perms: { stranger: 'read' }, allowlist: 'x' }, null, 1],
|
|
318
|
+
];
|
|
319
|
+
for (const [labeller, extra, trust, code] of cases) {
|
|
320
|
+
const t = makeTracker({ login: null, labeller, ...extra });
|
|
321
|
+
const r = run(t, base('bug', ['--map', '5']));
|
|
322
|
+
if (code === 0) {
|
|
323
|
+
ok(`pending trust, labelled by ${labeller}: created as ${trust}`, r.code === 0 && r.json.status === 'created' && r.json.trust === trust && t.edges.length === 1);
|
|
324
|
+
} else {
|
|
325
|
+
ok(`pending trust, labelled by ${labeller}: error, no edge`, r.code === 1 && r.json.status === 'error' && r.json.trust === 'insufficient' && r.json.issue === 100 && /will be stripped/.test(r.json.reason) && t.edges.length === 0);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const te = makeTracker({ login: null, eventsFail: true });
|
|
329
|
+
const re = run(te, base('bug', ['--map', '5']));
|
|
330
|
+
ok('pending trust, events unreadable: error, no edge', re.code === 1 && re.json.trust === 'insufficient' && re.json.issue === 100 && /could not be read/.test(re.json.reason) && !/stripped/.test(re.json.reason) && te.edges.length === 0);
|
|
331
|
+
ok('events unreadable: retried on the backoff schedule', te.eventReads === 4);
|
|
332
|
+
const naps = [];
|
|
333
|
+
const tl = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsLag: 2 });
|
|
334
|
+
const rl = run(tl, base('bug', ['--map', '5']), { sleep: (ms) => naps.push(ms) });
|
|
335
|
+
ok('pending trust, events empty at first: created as actions-token after naps', rl.code === 0 && rl.json.status === 'created' && rl.json.trust === 'actions-token' && tl.edges.length === 1 && naps.slice(0, 2).join() === '1000,2000');
|
|
336
|
+
const tp = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsPartial: 2 });
|
|
337
|
+
const rp = run(tp, base('bug', ['--map', '5']));
|
|
338
|
+
ok('pending trust, partial events: created only once every label is visible', rp.code === 0 && rp.json.trust === 'actions-token' && tp.eventReads === 3);
|
|
339
|
+
const tn = makeTracker({ login: null, labeller: 'github-actions[bot]', eventsLag: 99 });
|
|
340
|
+
const rn = run(tn, base('bug', ['--map', '5']));
|
|
341
|
+
ok('pending trust, labels never visible: distinct reason naming labels, issue kept, no edge', rn.code === 1 && rn.json.trust === 'insufficient' && rn.json.issue === 100 && /no visible labeled event/.test(rn.json.reason) && /trailhead:bug/.test(rn.json.reason) && !/stripped/.test(rn.json.reason) && tn.edges.length === 0 && tn.eventReads === 4);
|
|
342
|
+
const tb = makeTracker({ login: null, labeller: 'stranger', perms: { stranger: 'read' }, allowlist: 'x' });
|
|
343
|
+
const rb = run(tb, base('bug', ['--map', '5']));
|
|
344
|
+
ok('bad actor fails at once, without retries', rb.code === 1 && rb.json.trust === 'insufficient' && /stranger will be stripped/.test(rb.json.reason) && tb.edges.length === 0 && tb.eventReads === 1);
|
|
345
|
+
const tlb = makeTracker({ login: null, labeller: 'github-actions[bot]', lateLabeller: 'stranger', perms: { stranger: 'read' }, allowlist: 'x', eventsPartial: 1 });
|
|
346
|
+
const rlb = run(tlb, base('bug', ['--map', '5']));
|
|
347
|
+
ok('bad actor on a later read fails, though an earlier read was trusted', rlb.code === 1 && rlb.json.trust === 'insufficient' && /stranger will be stripped/.test(rlb.json.reason) && tlb.edges.length === 0 && tlb.eventReads === 2);
|
|
348
|
+
const empty = cap.verifyActors(() => ({ status: 0, stdout: '', stderr: '' }), 'o/r', 100, [], () => {});
|
|
349
|
+
ok('no expected trailhead:* label: fails closed, never trusts an empty events list', !empty.trust && /no trailhead:\* label to verify/.test(empty.error));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- main: read-back, dedupe, edge, --of, secrets ------------------------------------------
|
|
353
|
+
{
|
|
354
|
+
const t = makeTracker({ dropLabels: ['trailhead:map-5'] });
|
|
355
|
+
const r = run(t, base('bug', ['--map', '5']));
|
|
356
|
+
ok('labels missing at read-back: exit 1, issue named, no edge', r.code === 1 && r.json.status === 'error' && /labels missing/.test(r.json.reason) && r.json.issue === 100 && t.edges.length === 0);
|
|
357
|
+
}
|
|
358
|
+
{
|
|
359
|
+
const t = makeTracker({ open: [{ number: 3, body: '<!-- trailhead-dedupe: nightly-1 -->' }] });
|
|
360
|
+
const r = run(t, base('bug', ['--dedupe-key', 'nightly-1']));
|
|
361
|
+
ok('dedupe hit: existing, exit 0, no create call', r.code === 0 && r.json.status === 'existing' && r.json.issue === 3 && r.json.dedupeKey === 'nightly-1' && creates(t).length === 0);
|
|
362
|
+
const r2 = run(t, base('bug', ['--dedupe-key', 'nightly-2']));
|
|
363
|
+
ok('dedupe miss: created, marker in the body', r2.json.status === 'created' && t.created[0].body.includes('<!-- trailhead-dedupe: nightly-2 -->'));
|
|
364
|
+
}
|
|
365
|
+
{
|
|
366
|
+
const t1 = makeTracker({ edgeFail: 1 });
|
|
367
|
+
const r1 = run(t1, base('bug', ['--map', '5']));
|
|
368
|
+
ok('edge fails once then succeeds: created', r1.code === 0 && r1.json.status === 'created' && r1.json.edge === 'ok' && t1.edges.length === 1);
|
|
369
|
+
const t2 = makeTracker({ edgeFail: 2 });
|
|
370
|
+
const r2 = run(t2, base('bug', ['--map', '5']));
|
|
371
|
+
ok('edge fails twice: partial, exit 3, names prune', r2.code === 3 && r2.json.status === 'partial' && r2.json.edge === 'failed' && r2.json.issue === 100 && r2.json.warnings.some((w) => /trailhead prune command/.test(w)) && t2.edges.length === 0);
|
|
372
|
+
}
|
|
373
|
+
{
|
|
374
|
+
const t = makeTracker({ issues: { 5: { state: 'OPEN', title: 'Map five', labels: ['trailhead:map'] }, 10: { state: 'CLOSED', title: 'Origin on 5', labels: ['trailhead:map-5'] }, 12: { state: 'CLOSED', title: 'Origin closed map', labels: ['trailhead:map-6'] }, 6: { state: 'CLOSED', title: 'm6', labels: ['trailhead:map'] } } });
|
|
375
|
+
const r = run(t, base('bug', ['--of', '10']));
|
|
376
|
+
ok('--of inherits the map: label, Parent, Regression of, edge', r.json.destination === 'map-5' && t.created[0].labels.includes('trailhead:map-5') && t.created[0].body.includes('Regression of: [Origin on 5]') && t.edges.length === 1);
|
|
377
|
+
const tu = makeTracker({ issues: { 12: { state: 'CLOSED', title: 'x', labels: ['trailhead:map-6'] }, 6: { state: 'CLOSED', title: 'm6', labels: ['trailhead:map'] } } });
|
|
378
|
+
const ru = run(tu, base('bug', ['--of', '12']));
|
|
379
|
+
ok('--of unresolved: exit 1, nothing created', ru.code === 1 && /origin destination unresolved/.test(ru.json.reason) && creates(tu).length === 0);
|
|
380
|
+
}
|
|
381
|
+
{
|
|
382
|
+
const t = makeTracker();
|
|
383
|
+
const r = run(t, ['--type', 'todo', '--title', 'T', '--body', 'token ghp_abcdefghijklmnopqrstuvwxyz0123', '--repo', 'o/r']);
|
|
384
|
+
ok('secret in the body: exit 1, no create', r.code === 1 && /secret/.test(r.json.reason) && creates(t).length === 0);
|
|
385
|
+
const ts = makeTracker();
|
|
386
|
+
ok('secret in the title: exit 1, no create', run(ts, ['--type', 'todo', '--title', 'ghp_abcdefghijklmnopqrstuvwxyz0123', '--body', 'b', '--repo', 'o/r']).code === 1 && creates(ts).length === 0);
|
|
387
|
+
const tmx = makeTracker({ issues: { 5: { state: 'OPEN', title: 'Map ghp_abcdefghijklmnopqrstuvwxyz0123', labels: ['trailhead:map'] } } });
|
|
388
|
+
ok('secret in a map title (Parent line): exit 1, no create', run(tmx, base('bug', ['--map', '5'])).code === 1 && creates(tmx).length === 0);
|
|
389
|
+
const tg = makeTracker();
|
|
390
|
+
const rg = run(tg, base('bug'), { guard: null });
|
|
391
|
+
ok('guard missing fails closed', rg.code === 1 && rg.json.status === 'error' && creates(tg).length === 0);
|
|
392
|
+
const tb = makeTracker();
|
|
393
|
+
ok('closed map: exit 1, no create', run(tb, base('bug', ['--map', '6'])).code === 1 && creates(tb).length === 0);
|
|
394
|
+
const tbf = makeTracker({ login: 'x', perms: { x: 'admin' } });
|
|
395
|
+
const rf = run(tbf, ['--type', 'todo', '--title', 'T', '--body-file', '-', '--repo', 'o/r'], { stdin: 'body from stdin' });
|
|
396
|
+
ok('--body-file - reads stdin', rf.json.status === 'created' && tbf.created[0].body.includes('body from stdin'));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- usage ----------------------------------------------------------------------------------
|
|
400
|
+
for (const argv of [[], ['--type', 'epic'], base('seed'), base('todo', ['--of', '3'])]) {
|
|
401
|
+
const t = makeTracker();
|
|
402
|
+
const r = run(t, argv);
|
|
403
|
+
ok(`usage error exit 2 with a usage JSON line: ${argv.join(' ')}`, r.code === 2 && r.json.status === 'usage' && r.json.reason.length > 0 && r.errOut.length > 0 && t.calls.length === 0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// --- end-to-end with a fake gh on PATH ---------------------------------------------------------
|
|
407
|
+
{
|
|
408
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'capture-'));
|
|
409
|
+
const binDir = path.join(tmp, 'bin');
|
|
410
|
+
fs.mkdirSync(binDir);
|
|
411
|
+
const log = path.join(tmp, 'calls.log');
|
|
412
|
+
const script = `#!/bin/sh
|
|
413
|
+
echo "$*" >> "${log}"
|
|
414
|
+
case "$*" in
|
|
415
|
+
"api user"*) echo maint ;;
|
|
416
|
+
"api repos/o/r/collaborators/maint/permission"*) echo '{"permission":"admin","role_name":"admin"}' ;;
|
|
417
|
+
"issue view 5 "*) echo '{"state":"OPEN","title":"Map five","url":"https://github.com/o/r/issues/5","labels":[{"name":"trailhead:map"}]}' ;;
|
|
418
|
+
"issue view 100 "*) echo '{"labels":[{"name":"trailhead:ticket"},{"name":"trailhead:bug"},{"name":"trailhead:map-5"}]}' ;;
|
|
419
|
+
"issue create"*) cat > "${path.join(tmp, 'body.txt')}"; echo https://github.com/o/r/issues/100 ;;
|
|
420
|
+
"api repos/o/r/issues/100 --jq .id") echo 9100 ;;
|
|
421
|
+
*) exit 0 ;;
|
|
422
|
+
esac
|
|
423
|
+
`;
|
|
424
|
+
fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
|
|
425
|
+
const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
|
|
426
|
+
const res = spawnSync('node', [scriptPath, ...base('bug', ['--map', '5'])], { encoding: 'utf8', env });
|
|
427
|
+
const j = JSON.parse(res.stdout.trim());
|
|
428
|
+
ok('e2e: prints one created JSON line, exit 0', res.status === 0 && j.status === 'created' && j.issue === 100 && j.edge === 'ok');
|
|
429
|
+
const calls = fs.readFileSync(log, 'utf8');
|
|
430
|
+
ok('e2e: create before read-back before edge', calls.indexOf('issue create') < calls.indexOf('issue view 100') && calls.indexOf('issue view 100') < calls.indexOf('sub_issues'));
|
|
431
|
+
ok('e2e: body reached gh on stdin with the Parent line', fs.readFileSync(path.join(tmp, 'body.txt'), 'utf8').startsWith('Parent: [Map five]'));
|
|
432
|
+
fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
|
433
|
+
const bad = spawnSync('node', [scriptPath, ...base('bug', ['--map', '5'])], { encoding: 'utf8', env });
|
|
434
|
+
ok('e2e: gh failing exits 1 with an error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
|
|
435
|
+
ok('e2e: usage error exits 2', spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
|
|
436
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
console.log(`capture.test.js: ${passed} checks passed`);
|