@marcomigozzi/trailhead 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
// trailhead commit-msg hook sync (self-contained, ships in templates/ so it
|
|
4
4
|
// reaches every install channel: bin/trailhead.js copies templates/ wholesale
|
|
5
|
-
// for Claude/npm and Codex alike). Installs the sibling
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// for Claude/npm and Codex alike). Installs the sibling templates into the
|
|
6
|
+
// repo's git hooks dir: trailhead-commit-msg (a small sh wrapper, installed as
|
|
7
|
+
// commit-msg) and trailhead-commit-msg.cjs (the logic, installed beside it; a
|
|
8
|
+
// .cjs is always CommonJS, so a "type": "module" repo cannot break it, #257).
|
|
9
|
+
// Upgrades in place when an OLDER trailhead hook (the legacy single-file JS
|
|
10
|
+
// one, or an outdated wrapper/.cjs) is already there, recognised by its own
|
|
11
|
+
// header line, never by trusting the filename, and never touches a foreign
|
|
9
12
|
// (non-trailhead) hook. #185.
|
|
10
13
|
//
|
|
11
14
|
// CLI: node trailhead-commit-msg-sync.js [--repo <dir>] (default cwd)
|
|
@@ -21,13 +24,17 @@ const path = require('path');
|
|
|
21
24
|
const crypto = require('crypto');
|
|
22
25
|
const { execFileSync } = require('child_process');
|
|
23
26
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
// Nome del file di logica installato accanto al wrapper commit-msg.
|
|
28
|
+
const CJS_NAME = 'trailhead-commit-msg.cjs';
|
|
29
|
+
|
|
30
|
+
// La riga riconosciuta di ogni versione dell'hook trailhead: basta che una
|
|
31
|
+
// delle prime 5 righe (trim) inizi con una di queste stringhe. La prima è
|
|
32
|
+
// l'hook JS monofile legacy, la seconda il wrapper sh (layout #257).
|
|
33
|
+
const TRAILHEAD_MARKS = ['// trailhead commit-msg hook (git).', '# trailhead commit-msg hook (git).'];
|
|
27
34
|
|
|
28
35
|
function isTrailheadHook(text) {
|
|
29
36
|
const lines = String(text == null ? '' : text).split('\n').slice(0, 5);
|
|
30
|
-
return lines.some((line) => line.trim().startsWith(
|
|
37
|
+
return lines.some((line) => TRAILHEAD_MARKS.some((mark) => line.trim().startsWith(mark)));
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
// Uniforma i fine riga per il confronto di contenuto: CRLF -> LF, non tocca
|
|
@@ -65,10 +72,36 @@ function resolveHooksTarget({ repoDir, runGit = defaultRunGit }) {
|
|
|
65
72
|
return { hooksDir, hooksPathSet };
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
function
|
|
75
|
+
// Scrittura atomica: temp file unico nella stessa dir, poi rename sul
|
|
76
|
+
// destinazione (mai un truncate in place).
|
|
77
|
+
function writeAtomic(fsMod, hooksDir, finalPath, text) {
|
|
78
|
+
const tmpPath = path.join(hooksDir, `.commit-msg.trailhead-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
79
|
+
try {
|
|
80
|
+
fsMod.writeFileSync(tmpPath, text);
|
|
81
|
+
fsMod.chmodSync(tmpPath, 0o755);
|
|
82
|
+
fsMod.renameSync(tmpPath, finalPath);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// true se il file esiste, non è un symlink e ha esattamente il contenuto atteso.
|
|
90
|
+
function matchesTemplate(fsMod, filePath, text) {
|
|
91
|
+
try {
|
|
92
|
+
if (fsMod.lstatSync(filePath).isSymbolicLink()) return false;
|
|
93
|
+
return normalize(fsMod.readFileSync(filePath, 'utf8')) === normalize(text);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Applica la sincronizzazione vera e propria dentro hooksDir. templateText è il
|
|
100
|
+
// wrapper sh (installato come commit-msg), cjsText la logica (installata come
|
|
101
|
+
// trailhead-commit-msg.cjs). fs è iniettato per testabilità.
|
|
102
|
+
function syncCommitMsgHook({ hooksDir, templateText, cjsText, hooksPathSet, fs: fsMod = fs }) {
|
|
71
103
|
const hookPath = path.join(hooksDir, 'commit-msg');
|
|
104
|
+
const cjsPath = path.join(hooksDir, CJS_NAME);
|
|
72
105
|
|
|
73
106
|
if (!fsMod.existsSync(hookPath)) {
|
|
74
107
|
if (hooksPathSet) {
|
|
@@ -79,8 +112,9 @@ function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = f
|
|
|
79
112
|
};
|
|
80
113
|
}
|
|
81
114
|
fsMod.mkdirSync(hooksDir, { recursive: true });
|
|
82
|
-
|
|
83
|
-
fsMod
|
|
115
|
+
// Prima la logica, poi il wrapper: mai un wrapper senza il suo script.
|
|
116
|
+
writeAtomic(fsMod, hooksDir, cjsPath, cjsText);
|
|
117
|
+
writeAtomic(fsMod, hooksDir, hookPath, templateText);
|
|
84
118
|
return { status: 'installed', hook: hookPath, message: 'installed the trailhead commit-msg hook.' };
|
|
85
119
|
}
|
|
86
120
|
|
|
@@ -94,22 +128,18 @@ function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = f
|
|
|
94
128
|
return { status: 'foreign', hook: hookPath, message: 'a foreign commit-msg hook is present; left untouched.' };
|
|
95
129
|
}
|
|
96
130
|
|
|
97
|
-
|
|
131
|
+
const wrapperCurrent = normalize(existingText) === normalize(templateText);
|
|
132
|
+
const cjsCurrent = matchesTemplate(fsMod, cjsPath, cjsText);
|
|
133
|
+
|
|
134
|
+
if (wrapperCurrent && cjsCurrent) {
|
|
98
135
|
if ((stat.mode & 0o777) !== 0o755) fsMod.chmodSync(hookPath, 0o755);
|
|
99
136
|
return { status: 'current', hook: hookPath, message: 'the trailhead commit-msg hook is already current.' };
|
|
100
137
|
}
|
|
101
138
|
|
|
102
|
-
// Upgrade
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fsMod.writeFileSync(tmpPath, templateText);
|
|
107
|
-
fsMod.chmodSync(tmpPath, 0o755);
|
|
108
|
-
fsMod.renameSync(tmpPath, hookPath);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
|
|
111
|
-
throw err;
|
|
112
|
-
}
|
|
139
|
+
// Upgrade: logica per prima, wrapper per ultimo (mai un wrapper senza script).
|
|
140
|
+
if (!cjsCurrent) writeAtomic(fsMod, hooksDir, cjsPath, cjsText);
|
|
141
|
+
if (!wrapperCurrent) writeAtomic(fsMod, hooksDir, hookPath, templateText);
|
|
142
|
+
else if ((stat.mode & 0o777) !== 0o755) fsMod.chmodSync(hookPath, 0o755);
|
|
113
143
|
return { status: 'upgraded', hook: hookPath, message: 'upgraded the trailhead commit-msg hook to the current template.' };
|
|
114
144
|
}
|
|
115
145
|
|
|
@@ -153,7 +183,8 @@ function main(argv, { cwd, stdout } = {}) {
|
|
|
153
183
|
hooksDirForError = target.hooksDir;
|
|
154
184
|
const templatePath = path.join(__dirname, 'trailhead-commit-msg');
|
|
155
185
|
const templateText = fs.readFileSync(templatePath, 'utf8');
|
|
156
|
-
const
|
|
186
|
+
const cjsText = fs.readFileSync(path.join(__dirname, CJS_NAME), 'utf8');
|
|
187
|
+
const result = syncCommitMsgHook({ hooksDir: target.hooksDir, templateText, cjsText, hooksPathSet: target.hooksPathSet, fs });
|
|
157
188
|
writeResult(out, result);
|
|
158
189
|
return 0;
|
|
159
190
|
} catch (err) {
|
|
@@ -163,7 +194,7 @@ function main(argv, { cwd, stdout } = {}) {
|
|
|
163
194
|
}
|
|
164
195
|
}
|
|
165
196
|
|
|
166
|
-
module.exports = { resolveHooksTarget, isTrailheadHook, normalize, syncCommitMsgHook, parseArgs, main };
|
|
197
|
+
module.exports = { CJS_NAME, resolveHooksTarget, isTrailheadHook, normalize, syncCommitMsgHook, parseArgs, main };
|
|
167
198
|
|
|
168
199
|
if (require.main === module) {
|
|
169
200
|
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead commit-msg hook logic (git, CommonJS). Host-independent guardrail:
|
|
4
|
+
// installed beside the sh wrapper .git/hooks/commit-msg (which calls it) at repo
|
|
5
|
+
// first-use so trailhead's commit discipline is enforced on EVERY `git commit`,
|
|
6
|
+
// whatever agent host drives it (Codex has no hook bus; this git hook is how the
|
|
7
|
+
// commit-guard survives there). Decision #16.
|
|
8
|
+
//
|
|
9
|
+
// The .cjs extension is deliberate (#257): Node takes the module format of an
|
|
10
|
+
// extensionless file from the nearest package.json "type", and under .git/hooks/
|
|
11
|
+
// that is the user repo's, so a "type": "module" repo would load this as ESM and
|
|
12
|
+
// fail on require(). A .cjs file is always CommonJS.
|
|
13
|
+
//
|
|
14
|
+
// Self-contained by design: detached from the trailhead install, it inlines the
|
|
15
|
+
// same rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
|
|
16
|
+
// lib's test drives this hook end-to-end so the copies cannot drift.
|
|
17
|
+
//
|
|
18
|
+
// git passes the commit-message file path as argv[2] (the wrapper forwards it).
|
|
19
|
+
// On a violation: print the reason to stderr and exit VIOLATION (3); the wrapper
|
|
20
|
+
// maps exactly that code to exit 1 (git aborts the commit); any code other than
|
|
21
|
+
// 0 or 3 (a crash at load) is reported as the one-line "inactive" notice on
|
|
22
|
+
// stderr and exits 0. Otherwise exit 0. Crash-safe: any unexpected error exits
|
|
23
|
+
// 0 (never wedge a commit).
|
|
24
|
+
//
|
|
25
|
+
// Note: a merge/revert/fixup message already fails the conventional-subject
|
|
26
|
+
// rule below, so it needs no special case; `git commit --amend` keeps whatever
|
|
27
|
+
// trailer the original message carried; `git commit --no-verify` bypasses this
|
|
28
|
+
// hook entirely, as before.
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const VIOLATION = 3; // codice dedicato, mappato a 1 dal wrapper sh
|
|
34
|
+
const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
|
|
35
|
+
const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
|
|
36
|
+
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
37
|
+
const SCISSORS = '# ------------------------ >8';
|
|
38
|
+
const REFS_LINE = /^\s*refs\s*:(.*)$/i;
|
|
39
|
+
|
|
40
|
+
function messageRegion(raw) {
|
|
41
|
+
const text = String(raw == null ? '' : raw);
|
|
42
|
+
const i = text.indexOf(SCISSORS);
|
|
43
|
+
return i === -1 ? text : text.slice(0, i);
|
|
44
|
+
}
|
|
45
|
+
function subjectOf(raw) {
|
|
46
|
+
for (const line of messageRegion(raw).split('\n')) {
|
|
47
|
+
if (line.trim() === '' || line.startsWith('#')) continue;
|
|
48
|
+
return line.trim();
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Mirrors parseSessionTicket() in commit-message-check.js: parses the first
|
|
54
|
+
// line of a .trailhead/session-ticket marker (`#<n> <title>`).
|
|
55
|
+
function parseSessionTicket(text) {
|
|
56
|
+
const raw = String(text == null ? '' : text).replace(/^/, '');
|
|
57
|
+
const firstLine = raw.split('\n')[0].replace(/\r$/, '');
|
|
58
|
+
const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
|
|
59
|
+
return m ? Number(m[1]) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mirrors hasRefsFor() in commit-message-check.js.
|
|
63
|
+
function hasRefsFor(message, n) {
|
|
64
|
+
const target = new RegExp('#' + n + '(?![\\w])');
|
|
65
|
+
for (const line of messageRegion(message).split('\n')) {
|
|
66
|
+
if (line.trim().startsWith('#')) continue;
|
|
67
|
+
const m = line.match(REFS_LINE);
|
|
68
|
+
if (m && target.test(m[1])) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reads the session-ticket marker at the worktree top-level. git runs
|
|
74
|
+
// commit-msg from the worktree top-level (linked worktrees included), so
|
|
75
|
+
// process.cwd() is the right root here. Its own try/catch: a read error
|
|
76
|
+
// (marker absent, unreadable, etc.) must never skip the other rules below,
|
|
77
|
+
// it just leaves the Refs rule off.
|
|
78
|
+
function readTicket() {
|
|
79
|
+
try {
|
|
80
|
+
const markerPath = path.join(process.cwd(), '.trailhead', 'session-ticket');
|
|
81
|
+
return parseSessionTicket(fs.readFileSync(markerPath, 'utf8'));
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const file = process.argv[2];
|
|
89
|
+
const raw = file ? fs.readFileSync(file, 'utf8') : '';
|
|
90
|
+
const region = messageRegion(raw);
|
|
91
|
+
|
|
92
|
+
if (CO_AUTHORED.test(region)) {
|
|
93
|
+
process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
|
|
94
|
+
process.exit(VIOLATION);
|
|
95
|
+
}
|
|
96
|
+
const subject = subjectOf(raw);
|
|
97
|
+
if (subject && !CONVENTIONAL.test(subject)) {
|
|
98
|
+
process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
|
|
99
|
+
process.exit(VIOLATION);
|
|
100
|
+
}
|
|
101
|
+
if (subject.length > 72) {
|
|
102
|
+
process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
|
|
103
|
+
process.exit(VIOLATION);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ticket = readTicket();
|
|
107
|
+
if (Number.isInteger(ticket) && ticket > 0 && !hasRefsFor(raw, ticket)) {
|
|
108
|
+
process.stderr.write(`trailhead: this commit is on ticket #${ticket} (per .trailhead/session-ticket) but has no "Refs: #${ticket}" trailer. Add "Refs: #${ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${ticket}, remove the stale .trailhead/session-ticket marker first.\n`);
|
|
109
|
+
process.exit(VIOLATION);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
process.exit(0); // never wedge a commit on an unexpected error
|
|
113
|
+
}
|
|
114
|
+
process.exit(0);
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead dashboard regeneration (self-contained, ships in templates/ so it
|
|
4
|
+
// reaches every install channel). Gathers the tracker data the pinned
|
|
5
|
+
// `trailhead:dashboard` issue indexes, renders the whole body deterministically
|
|
6
|
+
// and writes it through trailhead-issue-body.js's guarded write (fail-safe read,
|
|
7
|
+
// updatedAt check, post-write conflict check, secret scan). #253.
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
|
|
11
|
+
// --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
|
|
12
|
+
// audit's only writer path); without it the marker is copied verbatim from the
|
|
13
|
+
// existing body. --dry-run gathers and reads through gh, computes and scans, but
|
|
14
|
+
// writes, creates, pins and labels nothing; it prints the old and new byte sizes.
|
|
15
|
+
//
|
|
16
|
+
// Singleton labels (dashboard, codebase, conventions): more than one open issue
|
|
17
|
+
// aborts before any write, naming every duplicate by title and number.
|
|
18
|
+
//
|
|
19
|
+
// Self-heal: ensures the `trailhead:dashboard` label; creates the issue directly
|
|
20
|
+
// with the final scanned body when it is missing (never an empty or placeholder
|
|
21
|
+
// body) and pins it; re-pins it when unpinned. Never closes or prunes a map: the
|
|
22
|
+
// flags are signals, the offers stay with the agent.
|
|
23
|
+
//
|
|
24
|
+
// Prints exactly one line on stdout:
|
|
25
|
+
// dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
|
|
26
|
+
// aborted: <reason>
|
|
27
|
+
// Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
|
|
28
|
+
// A pin check or pin failure AFTER the body was written is not an abort: the line
|
|
29
|
+
// says `written, pin check failed: <reason>` (or `written, pin failed ...`) and the
|
|
30
|
+
// exit is 0, since the body is in place; the agent surfaces the pin problem.
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const issueBody = require('./trailhead-issue-body.js');
|
|
36
|
+
|
|
37
|
+
const { guardedWrite, loadGuard, secretProblem, readSection, PLACEHOLDER_RE } = issueBody;
|
|
38
|
+
|
|
39
|
+
const LABEL = 'trailhead:dashboard';
|
|
40
|
+
const TITLE = '📊 trailhead dashboard';
|
|
41
|
+
const NEAR_CAP = 90;
|
|
42
|
+
const CAP = 100;
|
|
43
|
+
const RESOLVED_SHOWN = 10;
|
|
44
|
+
const AUDIT_LINE_RE = /^<!-- trailhead:last-audit [^ ]* -->[ \t\r]*$/;
|
|
45
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
46
|
+
// List-query limits; a list that comes back full is reported as truncated.
|
|
47
|
+
const LIMITS = { maps: 200, scoped: 1000, whiteboard: 200, resolved: 100, inbox: 1000 };
|
|
48
|
+
const NOT_RESOLVED_LABELS = ['trailhead:superseded', 'trailhead:out-of-scope'];
|
|
49
|
+
|
|
50
|
+
// --- pure render -------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
// A title safe to put inside a markdown link text, on one line. Comment
|
|
53
|
+
// delimiters are neutralised so a title can never forge the last-audit marker.
|
|
54
|
+
function linkText(title) {
|
|
55
|
+
return String(title).replace(/\s+/g, ' ').trim()
|
|
56
|
+
.replace(/<!--/g, '<!--').replace(/-->/g, '-->')
|
|
57
|
+
.replace(/([\[\]])/g, '\\$1');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The last-audit marker the dashboard itself wrote: a line of its own (the last
|
|
61
|
+
// such line), never text embedded in another line.
|
|
62
|
+
function extractAudit(body) {
|
|
63
|
+
const hits = String(body).split('\n').filter((l) => AUDIT_LINE_RE.test(l));
|
|
64
|
+
return hits.length ? hits[hits.length - 1].trim() : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const issueUrl = (repo, n) => `https://github.com/${repo}/issues/${n}`;
|
|
68
|
+
const link = (repo, n, text) => `[${text}](${issueUrl(repo, n)})`;
|
|
69
|
+
|
|
70
|
+
// The flags of one map, from its raw numbers. exhausted needs a zero open-scoped
|
|
71
|
+
// count AND a confirmed-empty fog; prunable needs the bounded closed-edge check.
|
|
72
|
+
function mapFlags(m) {
|
|
73
|
+
const flags = [];
|
|
74
|
+
if (m.openScoped === 0 && m.fog === false) flags.push('exhausted · closeable');
|
|
75
|
+
if (m.total >= NEAR_CAP) {
|
|
76
|
+
const base = `${m.total >= CAP ? 'at cap' : 'near cap'} · ${m.total}/${CAP}`;
|
|
77
|
+
flags.push(m.closedEdges === true ? `${base} · prunable` : base);
|
|
78
|
+
}
|
|
79
|
+
return flags;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Classifies the open whiteboard tickets with the panel's precedence: unverified
|
|
83
|
+
// (quarantined first), paused, blocked, in progress (assigned), frontier
|
|
84
|
+
// (everything else). A ticket lands in exactly one bucket. #263.
|
|
85
|
+
function classify(whiteboard) {
|
|
86
|
+
const out = { frontier: [], inProgress: [], paused: [], blocked: [], unverified: [] };
|
|
87
|
+
for (const t of whiteboard) {
|
|
88
|
+
if (t.labels.includes('trailhead:unverified')) out.unverified.push(t);
|
|
89
|
+
else if (t.labels.includes('trailhead:paused')) out.paused.push(t);
|
|
90
|
+
else if (t.labels.includes('trailhead:blocked')) out.blocked.push(t);
|
|
91
|
+
else if (t.assignees && t.assignees.length) out.inProgress.push(t);
|
|
92
|
+
else out.frontier.push(t);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// snapshot: { repo, maps[], whiteboard[], resolved[], inbox, anchors, lastAudit }.
|
|
98
|
+
function renderDashboard(s) {
|
|
99
|
+
const lines = [];
|
|
100
|
+
lines.push("The pinned index of this repo's trailhead surface. Regenerate any time with `/trailhead:dashboard`; it also self-heals on `/trailhead`, `/trailhead:map`, and `/trailhead:whiteboard`.");
|
|
101
|
+
lines.push('', '## 🗺 Maps');
|
|
102
|
+
if (s.maps.length === 0) lines.push('_No open maps._');
|
|
103
|
+
for (const m of s.maps) {
|
|
104
|
+
lines.push(`- ${link(s.repo, m.number, linkText(m.title))}: ${m.openScoped} open${mapFlags(m).map((f) => ` · ${f}`).join('')}`);
|
|
105
|
+
}
|
|
106
|
+
const wb = classify(s.whiteboard);
|
|
107
|
+
lines.push('', '## 🧷 Whiteboard (map-less tickets)');
|
|
108
|
+
if (s.whiteboard.length === 0) lines.push('_Nothing on the whiteboard._');
|
|
109
|
+
for (const [label, list] of [['Frontier', wb.frontier], ['In progress', wb.inProgress], ['Paused', wb.paused], ['Blocked', wb.blocked], ['Unverified', wb.unverified]]) {
|
|
110
|
+
if (list.length === 0) continue;
|
|
111
|
+
lines.push(`${label} (${list.length}):`);
|
|
112
|
+
for (const t of list) lines.push(`- ${link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)}`);
|
|
113
|
+
}
|
|
114
|
+
if (s.resolved.length) {
|
|
115
|
+
lines.push('', `_Recently resolved: ${s.resolved.map((t) => link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)).join(', ')}_`);
|
|
116
|
+
}
|
|
117
|
+
lines.push('', '## 📊 Counts');
|
|
118
|
+
lines.push(`- Untriaged inbox: ${s.inbox}${(s.truncated || []).includes('inbox') ? '+' : ''}`);
|
|
119
|
+
lines.push(`- Whiteboard frontier: ${wb.frontier.length}`);
|
|
120
|
+
lines.push(`- Open maps: ${s.maps.length}`);
|
|
121
|
+
const anchors = [['Codebase', s.anchors.codebase], ['Conventions', s.anchors.conventions]]
|
|
122
|
+
.filter(([, n]) => n).map(([name, n]) => link(s.repo, n, `${name} #${n}`));
|
|
123
|
+
if (anchors.length) lines.push('', `Anchors: ${anchors.join(' · ')}`);
|
|
124
|
+
if (s.lastAudit) lines.push('', s.lastAudit);
|
|
125
|
+
return lines.join('\n') + '\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- gather ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
class GatherError extends Error {}
|
|
131
|
+
|
|
132
|
+
function ghJson(gh, args) {
|
|
133
|
+
const r = gh(args);
|
|
134
|
+
if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 3).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
|
|
135
|
+
try { return JSON.parse(r.stdout); } catch { throw new GatherError(`gh ${args.slice(0, 3).join(' ')} returned unparseable JSON`); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ghText(gh, args) {
|
|
139
|
+
const r = gh(args);
|
|
140
|
+
if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 2).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
|
|
141
|
+
return r.stdout.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
|
145
|
+
|
|
146
|
+
function issueList(gh, repo, extra) {
|
|
147
|
+
return ghJson(gh, ['issue', 'list', ...extra, '-R', repo]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function gatherMap(gh, repo, m, truncated) {
|
|
151
|
+
const scoped = issueList(gh, repo, ['--label', `trailhead:map-${m.number}`, '--state', 'open', '--json', 'number', '--limit', String(LIMITS.scoped)]);
|
|
152
|
+
if (scoped.length >= LIMITS.scoped) truncated.push(`map-${m.number} scoped`);
|
|
153
|
+
const total = parseInt(ghText(gh, ['api', `repos/${repo}/issues/${m.number}`, '--jq', '.sub_issues_summary.total']), 10);
|
|
154
|
+
if (!Number.isInteger(total)) throw new GatherError(`map #${m.number}: unreadable sub-issue total`);
|
|
155
|
+
const out = { number: m.number, title: m.title, openScoped: scoped.length, total, fog: null, closedEdges: null };
|
|
156
|
+
if (out.openScoped === 0) {
|
|
157
|
+
// Candidate only: confirm the fog (`## Not yet specified`) is empty.
|
|
158
|
+
const view = ghJson(gh, ['issue', 'view', String(m.number), '--json', 'body', '-R', repo]);
|
|
159
|
+
const fog = readSection(String(view.body || ''), 'Not yet specified');
|
|
160
|
+
// A missing section is unconfirmed (fog stays null: no exhausted flag), only
|
|
161
|
+
// a present and empty one confirms there is no fog left.
|
|
162
|
+
out.fog = fog === null ? null : fog.some((l) => !PLACEHOLDER_RE.test(l));
|
|
163
|
+
}
|
|
164
|
+
if (total >= NEAR_CAP) {
|
|
165
|
+
const edges = ghJson(gh, ['api', `repos/${repo}/issues/${m.number}/sub_issues?per_page=100`]);
|
|
166
|
+
out.closedEdges = Array.isArray(edges) && edges.some((e) => e && e.state === 'closed');
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Singleton labels: exactly one open issue each. More than one is ambiguous and
|
|
172
|
+
// never resolved by picking one (#274).
|
|
173
|
+
const SINGLETON_LIMIT = 10;
|
|
174
|
+
const SINGLETON_LABELS = [LABEL, 'trailhead:codebase', 'trailhead:conventions'];
|
|
175
|
+
|
|
176
|
+
// Looks up every singleton label first and aborts (GatherError) naming all the
|
|
177
|
+
// duplicated ones at once, before anything is written. Returns the single
|
|
178
|
+
// number per label, or null when there is none.
|
|
179
|
+
function lookupSingletons(gh, repo) {
|
|
180
|
+
const found = SINGLETON_LABELS.map((label) => ({
|
|
181
|
+
label,
|
|
182
|
+
list: issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number,title', '--limit', String(SINGLETON_LIMIT)]),
|
|
183
|
+
}));
|
|
184
|
+
const dupes = found.filter((f) => f.list.length > 1);
|
|
185
|
+
if (dupes.length) {
|
|
186
|
+
// Control characters are stripped so the one-line stdout stays safe; a list
|
|
187
|
+
// that filled the fetch limit may be cut, so say more may exist.
|
|
188
|
+
const safe = (title) => linkText(title).replace(/[\x00-\x1f\x7f]/g, '');
|
|
189
|
+
const named = dupes.map((f) => `${f.label}: ${f.list.map((i) => `${safe(i.title)} (#${i.number})`).join(', ')}${f.list.length >= SINGLETON_LIMIT ? ', and possibly more' : ''}`).join('; ');
|
|
190
|
+
throw new GatherError(`ambiguous singleton issues, close the duplicates and keep one: ${named}`);
|
|
191
|
+
}
|
|
192
|
+
const num = (label) => { const f = found.find((x) => x.label === label); return f.list.length ? f.list[0].number : null; };
|
|
193
|
+
return { dashboard: num(LABEL), codebase: num('trailhead:codebase'), conventions: num('trailhead:conventions') };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function gatherSnapshot(gh, repo, singles) {
|
|
197
|
+
const one = singles || lookupSingletons(gh, repo);
|
|
198
|
+
const truncated = [];
|
|
199
|
+
const capped = (list, limit, name) => { if (list.length >= limit) truncated.push(name); return list; };
|
|
200
|
+
const maps = capped(issueList(gh, repo, ['--label', 'trailhead:map', '--state', 'open', '--json', 'number,title', '--limit', String(LIMITS.maps)]), LIMITS.maps, 'maps')
|
|
201
|
+
.sort((a, b) => a.number - b.number)
|
|
202
|
+
.map((m) => gatherMap(gh, repo, m, truncated));
|
|
203
|
+
const whiteboard = capped(issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'open', '--json', 'number,title,labels,assignees', '--limit', String(LIMITS.whiteboard)]), LIMITS.whiteboard, 'whiteboard')
|
|
204
|
+
.map((t) => ({ number: t.number, title: t.title, labels: names(t.labels), assignees: (t.assignees || []).map((a) => a.login) }));
|
|
205
|
+
// Most recently updated first, so the newest closures sit inside the fetched
|
|
206
|
+
// window even on a repo with a long closed history; then ordered by closedAt.
|
|
207
|
+
const closed = issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'closed', '--search', 'sort:updated-desc', '--json', 'number,title,closedAt,labels', '--limit', String(LIMITS.resolved)]);
|
|
208
|
+
const resolved = closed
|
|
209
|
+
.filter((t) => !names(t.labels).some((n) => NOT_RESOLVED_LABELS.includes(n)))
|
|
210
|
+
.sort((a, b) => String(b.closedAt).localeCompare(String(a.closedAt)))
|
|
211
|
+
.slice(0, RESOLVED_SHOWN)
|
|
212
|
+
.map((t) => ({ number: t.number, title: t.title }));
|
|
213
|
+
// A full window only matters when it could not fill the shown list.
|
|
214
|
+
if (closed.length >= LIMITS.resolved && resolved.length < RESOLVED_SHOWN) truncated.push('resolved');
|
|
215
|
+
const openIssues = capped(issueList(gh, repo, ['--state', 'open', '--json', 'number,labels', '--limit', String(LIMITS.inbox)]), LIMITS.inbox, 'inbox');
|
|
216
|
+
const inbox = openIssues.filter((i) => !names(i.labels).some((n) => n.startsWith('trailhead:'))).length;
|
|
217
|
+
const anchors = { codebase: one.codebase, conventions: one.conventions };
|
|
218
|
+
return { repo, maps, whiteboard, resolved, inbox, anchors, lastAudit: null, truncated };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- summary and self-heal ---------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function flagSummary(maps) {
|
|
224
|
+
const parts = [];
|
|
225
|
+
for (const m of maps) {
|
|
226
|
+
for (const f of mapFlags(m)) parts.push(`map-${m.number} ${f.replace(' · closeable', '').replace(/ · /g, ' ')}`);
|
|
227
|
+
}
|
|
228
|
+
return parts.length ? parts.join(', ') : 'none';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function summaryLine(number, snap) {
|
|
232
|
+
const cut = snap.truncated && snap.truncated.length ? `, truncated: ${snap.truncated.join(', ')}` : '';
|
|
233
|
+
return `dashboard${number === null ? '' : ' #' + number}: ${snap.maps.length} maps, whiteboard ${snap.whiteboard.length}, flags: ${flagSummary(snap.maps)}${cut}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isPinned(gh, repo, number) {
|
|
237
|
+
const [owner, name] = repo.split('/');
|
|
238
|
+
const q = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){pinnedIssues(first:3){nodes{issue{number}}}}}';
|
|
239
|
+
const data = ghJson(gh, ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`]);
|
|
240
|
+
const nodes = (((data.data || {}).repository || {}).pinnedIssues || {}).nodes || [];
|
|
241
|
+
return nodes.some((n) => n && n.issue && n.issue.number === number);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Pins the issue; returns null on success or a short failure note.
|
|
245
|
+
function pin(gh, repo, number) {
|
|
246
|
+
const node = gh(['api', `repos/${repo}/issues/${number}`, '--jq', '.node_id']);
|
|
247
|
+
if (node.status !== 0 || !node.stdout.trim()) return 'pin failed (node id)';
|
|
248
|
+
const r = gh(['api', 'graphql', '-f', 'query=mutation($id:ID!){ pinIssue(input:{issueId:$id}){ issue{ number } } }', '-F', `id=${node.stdout.trim()}`]);
|
|
249
|
+
return r.status === 0 ? null : 'pin failed (pinned slots full?)';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createDashboard(gh, repo, body, tmpdir) {
|
|
253
|
+
const dir = fs.mkdtempSync(path.join(tmpdir || os.tmpdir(), 'trailhead-dashboard-'));
|
|
254
|
+
const file = path.join(dir, 'body.md');
|
|
255
|
+
try {
|
|
256
|
+
fs.writeFileSync(file, body);
|
|
257
|
+
const r = gh(['issue', 'create', '--title', TITLE, '--label', LABEL, '--body-file', file, '-R', repo]);
|
|
258
|
+
if (r.status !== 0) return { error: `gh issue create failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}` };
|
|
259
|
+
const m = /\/issues\/(\d+)\s*$/.exec(r.stdout.trim());
|
|
260
|
+
return m ? { number: Number(m[1]) } : { error: 'gh issue create returned no issue url' };
|
|
261
|
+
} finally {
|
|
262
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- CLI ---------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
function parseArgs(argv) {
|
|
269
|
+
const o = { repo: null, setLastAudit: null, dryRun: false };
|
|
270
|
+
for (let i = 0; i < argv.length; i++) {
|
|
271
|
+
const flag = argv[i];
|
|
272
|
+
if (flag === '--dry-run') { o.dryRun = true; continue; }
|
|
273
|
+
if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
|
|
274
|
+
i++;
|
|
275
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
276
|
+
const v = argv[i];
|
|
277
|
+
if (flag === '--repo') {
|
|
278
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
279
|
+
o.repo = v;
|
|
280
|
+
} else {
|
|
281
|
+
if (!ISO_RE.test(v)) return { error: `usage: --set-last-audit must be an ISO-8601 UTC time like 2026-10-14T09:30:00Z, got: ${v}` };
|
|
282
|
+
o.setLastAudit = v;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return o;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function defaultRepo(gh) {
|
|
289
|
+
const r = gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
|
|
290
|
+
const slug = r.status === 0 ? r.stdout.trim() : '';
|
|
291
|
+
return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {}) {
|
|
295
|
+
const out = stdout || process.stdout;
|
|
296
|
+
const err = stderr || process.stderr;
|
|
297
|
+
const o = parseArgs(argv || []);
|
|
298
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
299
|
+
const run = gh || issueBody.defaultGh;
|
|
300
|
+
const g = guard === undefined ? loadGuard() : guard;
|
|
301
|
+
const abort = (reason) => { out.write(`aborted: ${reason}\n`); return 1; };
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const repo = o.repo || defaultRepo(run);
|
|
305
|
+
if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
|
|
306
|
+
const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
|
|
307
|
+
if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
|
|
308
|
+
const snap = gatherSnapshot(run, repo, singles);
|
|
309
|
+
const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
|
|
310
|
+
const number = singles.dashboard;
|
|
311
|
+
|
|
312
|
+
if (number === null) {
|
|
313
|
+
const body = renderDashboard({ ...snap, lastAudit: marker });
|
|
314
|
+
const problem = secretProblem(body, g);
|
|
315
|
+
if (problem) return abort(problem);
|
|
316
|
+
if (o.dryRun) {
|
|
317
|
+
out.write(`${summaryLine(null, snap)} (dry-run: would create and pin, body ${Buffer.byteLength(body)} bytes), nothing written\n`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
const created = createDashboard(run, repo, body, tmpdir);
|
|
321
|
+
if (created.error) return abort(created.error);
|
|
322
|
+
const note = pin(run, repo, created.number);
|
|
323
|
+
out.write(`${summaryLine(created.number, snap)} (created${note ? ', ' + note : ' and pinned'}, body ${Buffer.byteLength(body)} bytes)\n`);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const w = guardedWrite(number, (current) => {
|
|
328
|
+
const carried = marker || extractAudit(current);
|
|
329
|
+
return renderDashboard({ ...snap, lastAudit: carried });
|
|
330
|
+
}, { gh: run, repo, guard: g, dryRun: o.dryRun, tmpdir, firstEditDelta2 });
|
|
331
|
+
if (!w.ok) return abort(w.reason);
|
|
332
|
+
const sizes = `body ${w.oldSize} -> ${w.newSize} bytes`;
|
|
333
|
+
if (w.dryRun) {
|
|
334
|
+
out.write(`${summaryLine(number, snap)} (dry-run, ${sizes}), nothing written\n`);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
// The body is already written: a pin check or pin failure is reported next to
|
|
338
|
+
// that fact (exit 0), never as an abort that looks like nothing was written.
|
|
339
|
+
let note = w.historyFull ? issueBody.HISTORY_FULL_NOTE.replace(/^ \((.*)\)$/, ', $1') : '';
|
|
340
|
+
try {
|
|
341
|
+
if (!isPinned(run, repo, number)) {
|
|
342
|
+
const failed = pin(run, repo, number);
|
|
343
|
+
note += failed ? `, written, ${failed}` : ', re-pinned';
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
note += `, written, pin check failed: ${e && e.message ? e.message : String(e)}`;
|
|
347
|
+
}
|
|
348
|
+
out.write(`${summaryLine(number, snap)} (${sizes}${note})\n`);
|
|
349
|
+
return 0;
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (e instanceof GatherError) return abort(e.message);
|
|
352
|
+
return abort(`unexpected error: ${e && e.message ? e.message : String(e)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = { renderDashboard, mapFlags, classify, gatherSnapshot, parseArgs, main };
|
|
357
|
+
|
|
358
|
+
if (require.main === module) {
|
|
359
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
360
|
+
}
|