@marcomigozzi/trailhead 0.17.0 → 0.18.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 +28 -18
- package/README.md +28 -18
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/engine-route-docs.test.js +7 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +8 -8
- package/bin/lib/codex-projection.test.js +19 -3
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +9 -4
- package/bin/trailhead.test.js +18 -6
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-grill.md +2 -2
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +30 -14
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
- package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
- package/plugins/trailhead/skills/_shared/substrate.md +3 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +2 -1
- package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
- package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead inbox needs-info classifier (self-contained, ships in templates/ so it
|
|
4
|
+
// reaches every install channel). Read-only: never writes to the tracker. #354.
|
|
5
|
+
//
|
|
6
|
+
// An inbox issue parked with `trailhead:needs-info` waits on its reporter. This
|
|
7
|
+
// helper sorts the parked issues so the inbox protocol can act on them:
|
|
8
|
+
// answered the reporter commented strictly after the latest label event
|
|
9
|
+
// timedOut no answer and at least N whole days since the label
|
|
10
|
+
// waiting no answer yet, inside the window
|
|
11
|
+
// unknown a read failed, no label event, or no usable reporter identity
|
|
12
|
+
//
|
|
13
|
+
// CLI:
|
|
14
|
+
// node trailhead-inbox-needs-info.js --repo <owner/name> --config-root <project root>
|
|
15
|
+
// N comes from `inbox.needs_info_days` (project config over the global one, default 14).
|
|
16
|
+
//
|
|
17
|
+
// Prints exactly one JSON line on stdout:
|
|
18
|
+
// {status, days, answered, timedOut, waiting, unknown, warnings}
|
|
19
|
+
// Exit 0 on ok, 1 on status error (the list read failed), 2 on a usage error (stderr).
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { spawnSync } = require('child_process');
|
|
25
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
26
|
+
|
|
27
|
+
const LABEL = 'trailhead:needs-info';
|
|
28
|
+
const KEY = 'inbox.needs_info_days';
|
|
29
|
+
const DEFAULT_DAYS = 14;
|
|
30
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
function defaultGh(args) {
|
|
33
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
34
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const o = { repo: null, configRoot: null };
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const flag = argv[i];
|
|
43
|
+
if (flag !== '--repo' && flag !== '--config-root') return { error: `usage: unknown argument: ${flag}` };
|
|
44
|
+
i++;
|
|
45
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
46
|
+
const v = argv[i];
|
|
47
|
+
if (flag === '--repo') {
|
|
48
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
49
|
+
o.repo = v;
|
|
50
|
+
} else {
|
|
51
|
+
o.configRoot = v;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!o.repo) return { error: 'usage: missing --repo <owner/name>' };
|
|
55
|
+
if (!o.configRoot) return { error: 'usage: missing --config-root <project root>' };
|
|
56
|
+
return o;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Lines of `gh api --paginate ... --jq '... | tojson'`: one JSON document per line.
|
|
60
|
+
// Any non-zero exit discards the whole read: a later page can fail after earlier ones printed.
|
|
61
|
+
function jsonLines(r) {
|
|
62
|
+
if (r.status !== 0) return null;
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const line of r.stdout.split('\n')) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try { out.push(JSON.parse(line)); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
72
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
73
|
+
|
|
74
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
75
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
76
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
77
|
+
|
|
78
|
+
function globalConfigPath(env) {
|
|
79
|
+
const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
80
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// { found, value } for the key in one parsed file: the flat key wins over the nested one.
|
|
84
|
+
function pick(cfg) {
|
|
85
|
+
if (has(cfg, KEY)) return { found: true, value: cfg[KEY] };
|
|
86
|
+
if (isObject(cfg.inbox) && has(cfg.inbox, 'needs_info_days')) return { found: true, value: cfg.inbox.needs_info_days };
|
|
87
|
+
return { found: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Days from the nearest valid layer. A bad layer warns and falls to the next one: a timeout only
|
|
91
|
+
// ever produces an offer the maintainer confirms.
|
|
92
|
+
function resolveDays(root, env, fs_, warnings) {
|
|
93
|
+
for (const file of [path.join(root, '.trailhead', 'config.json'), globalConfigPath(env)]) {
|
|
94
|
+
let cfg;
|
|
95
|
+
try {
|
|
96
|
+
cfg = JSON.parse(fs_.readFileSync(file, 'utf8'));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e && e.code === 'ENOENT') continue;
|
|
99
|
+
warnings.push(e instanceof SyntaxError ? `${file} is not valid JSON; ignored` : `cannot read ${file}; ignored`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!isObject(cfg)) { warnings.push(`${file} is not a JSON object; ignored`); continue; }
|
|
103
|
+
const hit = pick(cfg);
|
|
104
|
+
if (!hit.found) continue;
|
|
105
|
+
if (Number.isInteger(hit.value) && hit.value > 0) return hit.value;
|
|
106
|
+
warnings.push(`${KEY} in ${file} must be a positive integer; ignored`);
|
|
107
|
+
}
|
|
108
|
+
return DEFAULT_DAYS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const usable = (login) => typeof login === 'string' && login !== '' && login.toLowerCase() !== 'ghost';
|
|
112
|
+
|
|
113
|
+
function main(argv, { gh, now, stdout, stderr, env, fs: fs_ } = {}) {
|
|
114
|
+
const out = stdout || process.stdout;
|
|
115
|
+
const err = stderr || process.stderr;
|
|
116
|
+
const o = parseArgs(argv || []);
|
|
117
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
118
|
+
const run = gh || defaultGh;
|
|
119
|
+
const nowMs = (now ? now() : new Date()).getTime();
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const days = resolveDays(o.configRoot, env || process.env, fs_ || fs, warnings);
|
|
122
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
123
|
+
|
|
124
|
+
const list = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues?labels=${LABEL}&state=open&per_page=100`, '--jq', '.[] | select(.pull_request == null) | {number, title, author: .user.login} | tojson']));
|
|
125
|
+
if (!list) {
|
|
126
|
+
emit({ status: 'error', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings, reason: 'could not read the parked issues' });
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const res = { status: 'ok', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings };
|
|
131
|
+
for (const i of list) {
|
|
132
|
+
const { number, title } = i;
|
|
133
|
+
const unknown = (reason) => res.unknown.push({ number, title, reason });
|
|
134
|
+
const events = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/events`, '--jq', `.[] | select(.event == "labeled" and .label.name == "${LABEL}") | {at: .created_at} | tojson`]));
|
|
135
|
+
if (!events) { unknown('could not read the label events'); continue; }
|
|
136
|
+
const times = events.map((e) => Date.parse(e.at)).filter((t) => !Number.isNaN(t));
|
|
137
|
+
if (!times.length) { unknown('no label event found'); continue; }
|
|
138
|
+
if (!usable(i.author)) { unknown('the reporter has no usable identity'); continue; }
|
|
139
|
+
const labeledMs = Math.max(...times);
|
|
140
|
+
const labeledAt = new Date(labeledMs).toISOString().replace('.000Z', 'Z');
|
|
141
|
+
const comments = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/comments?since=${labeledAt}&per_page=100`, '--jq', '.[] | {author: .user.login, at: .created_at} | tojson']));
|
|
142
|
+
if (!comments) { unknown('could not read the comments'); continue; }
|
|
143
|
+
// `since` filters on update time, so the creation time is checked again.
|
|
144
|
+
const reporter = i.author.toLowerCase();
|
|
145
|
+
const answers = comments
|
|
146
|
+
.filter((c) => usable(c.author) && c.author.toLowerCase() === reporter && Date.parse(c.at) > labeledMs)
|
|
147
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
148
|
+
const ageDays = Math.max(0, Math.floor((nowMs - labeledMs) / DAY_MS));
|
|
149
|
+
if (answers.length) res.answered.push({ number, title, author: i.author, labeledAt, answeredAt: answers[0].at });
|
|
150
|
+
else if (ageDays >= days) res.timedOut.push({ number, title, author: i.author, labeledAt, ageDays });
|
|
151
|
+
else res.waiting.push({ number, title, labeledAt, ageDays });
|
|
152
|
+
}
|
|
153
|
+
emit(res);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { globalConfigPath, main, parseArgs, resolveDays, DEFAULT_DAYS };
|
|
158
|
+
|
|
159
|
+
if (require.main === module) {
|
|
160
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
161
|
+
}
|
|
@@ -32,6 +32,7 @@ const fs = require('fs');
|
|
|
32
32
|
const path = require('path');
|
|
33
33
|
const crypto = require('crypto');
|
|
34
34
|
const { execFileSync } = require('child_process');
|
|
35
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
35
36
|
|
|
36
37
|
const STATE_DIR = '.trailhead';
|
|
37
38
|
const STATE_FILE = 'isolation-answer.json';
|
|
@@ -124,7 +125,7 @@ function parseArgs(argv) {
|
|
|
124
125
|
function resolveProjectRoot(dir) {
|
|
125
126
|
try {
|
|
126
127
|
const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
127
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv() }).trim();
|
|
128
129
|
if (common && path.basename(common) === '.git') return path.dirname(common);
|
|
129
130
|
return dir;
|
|
130
131
|
} catch {
|
|
@@ -33,6 +33,7 @@ const fs = require('fs');
|
|
|
33
33
|
const os = require('os');
|
|
34
34
|
const path = require('path');
|
|
35
35
|
const { execFileSync } = require('child_process');
|
|
36
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
36
37
|
|
|
37
38
|
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
38
39
|
const SUFFIXES = ['.example', '.sample'];
|
|
@@ -98,8 +99,12 @@ function readCopyList(file, fs_) {
|
|
|
98
99
|
return { state: 'set', list: value, warnings };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
103
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
104
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
105
|
+
|
|
101
106
|
function globalConfigPath(env) {
|
|
102
|
-
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
107
|
+
const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
103
108
|
return path.join(base, 'trailhead', 'config.json');
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -131,7 +136,7 @@ const exists = (p, fs_) => {
|
|
|
131
136
|
function suggest(from, fs_) {
|
|
132
137
|
let tracked;
|
|
133
138
|
try {
|
|
134
|
-
tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
|
|
139
|
+
tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv(), maxBuffer: 64 * 1024 * 1024 })
|
|
135
140
|
.toString().split('\0').filter(Boolean);
|
|
136
141
|
} catch (err) {
|
|
137
142
|
return [];
|
|
@@ -143,7 +148,7 @@ function suggest(from, fs_) {
|
|
|
143
148
|
const sibling = file.slice(0, -suffix.length);
|
|
144
149
|
if (out.includes(sibling) || checkRelPath(sibling) !== null || !exists(path.join(from, sibling), fs_)) continue;
|
|
145
150
|
try {
|
|
146
|
-
execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore' });
|
|
151
|
+
execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore', env: gitEnv() });
|
|
147
152
|
out.push(sibling);
|
|
148
153
|
} catch (err) {
|
|
149
154
|
// not ignored (exit 1) or git error: not a suggestion
|
|
@@ -231,7 +236,7 @@ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
|
231
236
|
}
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
module.exports = { checkRelPath, readCopyList, parseArgs, main };
|
|
239
|
+
module.exports = { globalConfigPath, checkRelPath, readCopyList, parseArgs, main };
|
|
235
240
|
|
|
236
241
|
if (require.main === module) {
|
|
237
242
|
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// CLI:
|
|
10
10
|
// node trailhead-issue-body.js <append|replace|remove> --issue <n> [--repo <owner/name>]
|
|
11
|
-
// --section "<name>" (--line "<text>" | --file <path> | --match "<text>")
|
|
11
|
+
// --section "<name>" (--line "<text>" | --file <path> | --match "<text>")
|
|
12
|
+
// [--stamp <sha>] [--dry-run]
|
|
12
13
|
// append adds --line as the last line of the section (a `_none yet_`-style
|
|
13
14
|
// placeholder that is the section's only content is dropped)
|
|
14
15
|
// replace swaps the section content for the text of --file
|
|
@@ -17,6 +18,12 @@
|
|
|
17
18
|
// <name> matches a `## ` heading with its leading emoji/punctuation stripped
|
|
18
19
|
// (`Decisions so far` matches `## ✅ Decisions so far`); `:preamble` is the text
|
|
19
20
|
// before the first `## ` (the conventions header).
|
|
21
|
+
// --stamp <sha> (7 to 40 lowercase hex) sets the section's `_as of <sha>_` line, the
|
|
22
|
+
// commit a codebase facet was written or last patched against: the first content
|
|
23
|
+
// line of the section, then a blank line. Applied only when given (map and
|
|
24
|
+
// conventions patches omit it) and written in the same single guarded write as the
|
|
25
|
+
// patch. Without it an existing stamp is kept; a no-op `remove` writes nothing and
|
|
26
|
+
// does not stamp. Not valid on `:preamble`. #360.
|
|
20
27
|
// --dry-run reads and computes (secret scan included) but writes nothing and
|
|
21
28
|
// prints the old and new byte sizes.
|
|
22
29
|
//
|
|
@@ -40,6 +47,7 @@ const fs = require('fs');
|
|
|
40
47
|
const os = require('os');
|
|
41
48
|
const path = require('path');
|
|
42
49
|
const { spawnSync } = require('child_process');
|
|
50
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
43
51
|
|
|
44
52
|
const PREAMBLE = ':preamble';
|
|
45
53
|
const DEGENERATE_MAX = 20; // a body with fewer non-space characters is degenerate
|
|
@@ -197,9 +205,83 @@ function readSection(body, name) {
|
|
|
197
205
|
return sec ? lines.slice(sec.from, sec.to).filter((l) => !isBlank(l)) : null;
|
|
198
206
|
}
|
|
199
207
|
|
|
200
|
-
//
|
|
208
|
+
// --- facet stamp -------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
const STAMP_RE = /^_as of ([0-9a-f]{7,40})_$/;
|
|
211
|
+
const STAMP_VALUE_RE = /^[0-9a-f]{7,40}$/;
|
|
212
|
+
const stampLine = (sha) => `_as of ${sha}_`;
|
|
213
|
+
|
|
214
|
+
// The first non-blank line of lines[from..to) when it is a stamp: { at, sha }, else null.
|
|
215
|
+
function stampIn(lines, from, to) {
|
|
216
|
+
for (let i = from; i < to; i++) {
|
|
217
|
+
if (isBlank(lines[i])) continue;
|
|
218
|
+
const m = STAMP_RE.exec(lines[i].trim());
|
|
219
|
+
return m ? { at: i, sha: m[1] } : null;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// `## ` sections of a body as { name, sha, text }: sha is the stamp or null, text
|
|
225
|
+
// the section content. Fence-aware (headingIndexes), so a fenced `## ` is no facet.
|
|
226
|
+
function facetsOf(body) {
|
|
227
|
+
const { lines } = splitBody(String(body));
|
|
228
|
+
const heads = headingIndexes(lines);
|
|
229
|
+
return heads.map((h, k) => {
|
|
230
|
+
const to = k + 1 < heads.length ? heads[k + 1] : lines.length;
|
|
231
|
+
const stamp = stampIn(lines, h + 1, to);
|
|
232
|
+
return { name: headingName(lines[h]), sha: stamp ? stamp.sha : null, text: lines.slice(h + 1, to).join('\n') };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Runs the op with the stamp set aside, then writes the supplied stamp (else the
|
|
237
|
+
// old one, else the file's own for a replace) back as the section's first content.
|
|
238
|
+
function applyStamped(body, op) {
|
|
239
|
+
if (op.stamp !== undefined && !STAMP_VALUE_RE.test(op.stamp)) return { error: 'stamp must be 7 to 40 lowercase hex characters' };
|
|
240
|
+
if (op.section === PREAMBLE) return op.stamp === undefined ? applyCore(body, op) : { error: 'a stamp does not apply to the preamble' };
|
|
241
|
+
const parts = splitBody(String(body));
|
|
242
|
+
const sec = findSection(parts.lines, op.section);
|
|
243
|
+
if (!sec) return { error: `section not found: ${op.section}` };
|
|
244
|
+
const old = stampIn(parts.lines, sec.from, sec.to);
|
|
245
|
+
let text = op.text;
|
|
246
|
+
let own = null;
|
|
247
|
+
if (op.kind === 'replace') {
|
|
248
|
+
const tl = bodyLines(text);
|
|
249
|
+
const t = stampIn(tl, 0, tl.length);
|
|
250
|
+
if (t) {
|
|
251
|
+
own = t.sha;
|
|
252
|
+
tl.splice(t.at, t.at + 1 < tl.length && isBlank(tl[t.at + 1]) ? 2 : 1);
|
|
253
|
+
text = tl.join('\n');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const sha = op.stamp !== undefined ? op.stamp : old ? old.sha : own;
|
|
257
|
+
if (!old && sha === null) return applyCore(body, { ...op, text });
|
|
258
|
+
// Strip the old stamp line and the one blank line after it.
|
|
259
|
+
let stripped = parts;
|
|
260
|
+
if (old) {
|
|
261
|
+
const drop = old.at + 1 < sec.to && isBlank(parts.lines[old.at + 1]) ? 2 : 1;
|
|
262
|
+
stripped = { ...parts, lines: [...parts.lines.slice(0, old.at), ...parts.lines.slice(old.at + drop)] };
|
|
263
|
+
}
|
|
264
|
+
const r = applyCore(joinBody(stripped), { ...op, text });
|
|
265
|
+
if (r.error) return r;
|
|
266
|
+
if (!r.changed) return { ...r, body: String(body) };
|
|
267
|
+
const out = splitBody(r.body);
|
|
268
|
+
const ns = findSection(out.lines, op.section);
|
|
269
|
+
let at = ns.from;
|
|
270
|
+
while (at < ns.to && isBlank(out.lines[at])) at++;
|
|
271
|
+
if (at === ns.to) at = ns.from;
|
|
272
|
+
const add = [stampLine(sha)];
|
|
273
|
+
if (at >= out.lines.length || !isBlank(out.lines[at])) add.push('');
|
|
274
|
+
const lines = [...out.lines.slice(0, at), ...add, ...out.lines.slice(at)];
|
|
275
|
+
return { ...r, body: joinBody({ ...out, lines }) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// op: { kind: 'append'|'replace'|'remove', section, line|text|match, stamp? }.
|
|
201
279
|
// Returns { body, changed, summary } or { error }.
|
|
202
280
|
function applyOp(body, op) {
|
|
281
|
+
return applyStamped(body, op);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyCore(body, op) {
|
|
203
285
|
const parts = splitBody(String(body));
|
|
204
286
|
const { lines } = parts;
|
|
205
287
|
const sec = findSection(lines, op.section);
|
|
@@ -291,7 +373,7 @@ function secretProblem(text, guard) {
|
|
|
291
373
|
// --- gh boundary -------------------------------------------------------------
|
|
292
374
|
|
|
293
375
|
function defaultGh(args) {
|
|
294
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
376
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
295
377
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
296
378
|
}
|
|
297
379
|
|
|
@@ -445,7 +527,7 @@ function parseArgs(argv) {
|
|
|
445
527
|
for (let i = 1; i < argv.length; i++) {
|
|
446
528
|
const flag = argv[i];
|
|
447
529
|
if (flag === '--dry-run') { o.dryRun = true; continue; }
|
|
448
|
-
if (!['--issue', '--repo', '--section', '--line', '--file', '--match'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
530
|
+
if (!['--issue', '--repo', '--section', '--line', '--file', '--match', '--stamp'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
449
531
|
i++;
|
|
450
532
|
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
451
533
|
const v = argv[i];
|
|
@@ -459,6 +541,10 @@ function parseArgs(argv) {
|
|
|
459
541
|
}
|
|
460
542
|
if (o.issue === undefined) return { error: 'usage: --issue is required' };
|
|
461
543
|
if (!o.section) return { error: 'usage: --section is required' };
|
|
544
|
+
if (o.stamp !== undefined) {
|
|
545
|
+
if (!STAMP_VALUE_RE.test(o.stamp)) return { error: `usage: --stamp must be 7 to 40 lowercase hex characters, got: ${o.stamp}` };
|
|
546
|
+
if (o.section === PREAMBLE) return { error: 'usage: --stamp does not apply to :preamble' };
|
|
547
|
+
}
|
|
462
548
|
const given = ['line', 'file', 'match'].filter((k) => o[k] !== undefined);
|
|
463
549
|
const want = { append: 'line', replace: 'file', remove: 'match' }[kind];
|
|
464
550
|
if (given.length !== 1 || given[0] !== want) return { error: `usage: ${kind} needs exactly --${want}` };
|
|
@@ -475,7 +561,7 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
|
|
|
475
561
|
if (o.kind === 'replace') {
|
|
476
562
|
try { text = fs.readFileSync(o.file, 'utf8'); } catch (e) { err.write(`usage: cannot read --file ${o.file}: ${e.message}\n`); return 2; }
|
|
477
563
|
}
|
|
478
|
-
const op = { kind: o.kind, section: o.section, line: o.line, text, match: o.match };
|
|
564
|
+
const op = { kind: o.kind, section: o.section, line: o.line, text, match: o.match, stamp: o.stamp };
|
|
479
565
|
const prefix = `issue #${o.issue}`;
|
|
480
566
|
let summary = '';
|
|
481
567
|
let unchanged = false;
|
|
@@ -515,7 +601,7 @@ function noopFirst(o, compute, wasUnchanged, opts) {
|
|
|
515
601
|
return !(r && r.error) && wasUnchanged();
|
|
516
602
|
}
|
|
517
603
|
|
|
518
|
-
module.exports = { applyOp, readSection, defaultGh, PLACEHOLDER_RE, guardedWrite, loadGuard, main, parseArgs, readIssue, editCount, secretProblem, PREAMBLE, HISTORY_FULL_NOTE };
|
|
604
|
+
module.exports = { applyOp, facetsOf, readSection, defaultGh, PLACEHOLDER_RE, guardedWrite, loadGuard, main, parseArgs, readIssue, editCount, secretProblem, PREAMBLE, HISTORY_FULL_NOTE };
|
|
519
605
|
|
|
520
606
|
if (require.main === module) {
|
|
521
607
|
process.exit(main(process.argv.slice(2), {}));
|
|
@@ -40,6 +40,7 @@ const fs = require('fs');
|
|
|
40
40
|
const os = require('os');
|
|
41
41
|
const path = require('path');
|
|
42
42
|
const { spawnSync } = require('child_process');
|
|
43
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
43
44
|
const { guardedWrite, loadGuard, secretProblem } = require('./trailhead-issue-body.js');
|
|
44
45
|
|
|
45
46
|
const MAX_PAGES = 100; // 100 pages x 100 issues
|
|
@@ -52,7 +53,7 @@ const NEEDS_YOU_DESC = 'Needs you: an autonomous run parked it for the human';
|
|
|
52
53
|
const TITLE_PREFIX = 'Merge the branches left unmerged: ';
|
|
53
54
|
|
|
54
55
|
function defaultGh(args) {
|
|
55
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
56
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
56
57
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// CLI:
|
|
8
8
|
// node trailhead-needs-you.js park --issue <n> --reason <text> --repo <owner/name>
|
|
9
9
|
// node trailhead-needs-you.js clear --issue <n> --repo <owner/name>
|
|
10
|
+
// node trailhead-needs-you.js sweep --repo <owner/name>
|
|
10
11
|
//
|
|
11
12
|
// park, in this order (so any partial failure leaves the ticket visible: a label is
|
|
12
13
|
// never set on a ticket still claimed by the run):
|
|
@@ -21,15 +22,19 @@
|
|
|
21
22
|
// 5. unassign `@me` when the ticket is assigned;
|
|
22
23
|
// 6. add the label last.
|
|
23
24
|
// clear: when the ticket carries the label, remove it; otherwise a no-op.
|
|
25
|
+
// sweep: remove the label from every CLOSED issue that still carries it, however it was
|
|
26
|
+
// closed (a Resolve, a hand close, the web UI): a closed ticket never waits on the human.
|
|
24
27
|
//
|
|
25
28
|
// Prints exactly one JSON line on stdout:
|
|
26
29
|
// park: {"status":"ok","issue":n,"commented":bool,"unassigned":bool,"labelled":bool}
|
|
27
30
|
// clear: {"status":"ok","issue":n,"cleared":bool}
|
|
31
|
+
// sweep: {"status":"ok","cleared":[n,...]} (an error also carries the cleared list)
|
|
28
32
|
// error: {"status":"error","reason":"..."}
|
|
29
33
|
// Exit 0 on ok, 1 on an error (any failed or unparseable gh step), 2 on a usage error
|
|
30
34
|
// (stderr).
|
|
31
35
|
|
|
32
36
|
const { spawnSync } = require('child_process');
|
|
37
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
33
38
|
const fs = require('fs');
|
|
34
39
|
const path = require('path');
|
|
35
40
|
|
|
@@ -37,9 +42,10 @@ const LABEL = 'trailhead:needs-you';
|
|
|
37
42
|
const LABEL_COLOR = 'D93F0B';
|
|
38
43
|
const LABEL_DESC = 'Needs you: an autonomous run parked it for the human';
|
|
39
44
|
const MARKER = 'NEEDS YOU';
|
|
45
|
+
const SWEEP_LIMIT = 1000;
|
|
40
46
|
|
|
41
47
|
function defaultGh(args) {
|
|
42
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
48
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
43
49
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -82,8 +88,8 @@ function secretProblem(text, guard) {
|
|
|
82
88
|
|
|
83
89
|
function parseArgs(argv) {
|
|
84
90
|
const cmd = argv[0];
|
|
85
|
-
if (
|
|
86
|
-
const allowed =
|
|
91
|
+
if (!['park', 'clear', 'sweep'].includes(cmd)) return { error: `usage: expected park, clear or sweep, got: ${cmd === undefined ? '(nothing)' : cmd}` };
|
|
92
|
+
const allowed = { park: ['--issue', '--reason', '--repo'], clear: ['--issue', '--repo'], sweep: ['--repo'] }[cmd];
|
|
87
93
|
const o = { cmd, issue: null, reason: null, repo: null };
|
|
88
94
|
for (let i = 1; i < argv.length; i++) {
|
|
89
95
|
const flag = argv[i];
|
|
@@ -102,7 +108,7 @@ function parseArgs(argv) {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
|
|
105
|
-
if (!o.issue) return { error: `usage: ${cmd} requires --issue` };
|
|
111
|
+
if (cmd !== 'sweep' && !o.issue) return { error: `usage: ${cmd} requires --issue` };
|
|
106
112
|
if (cmd === 'park' && (o.reason === null || !o.reason.trim())) return { error: 'usage: park requires a non-empty --reason' };
|
|
107
113
|
return o;
|
|
108
114
|
}
|
|
@@ -124,6 +130,32 @@ function readIssue(run, o) {
|
|
|
124
130
|
};
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
// Numeri delle issue chiuse che portano ancora la label, ordinati; oppure { error }.
|
|
134
|
+
function listClosedLabelled(run, repo) {
|
|
135
|
+
const r = run(['issue', 'list', '--state', 'closed', '--label', LABEL, '--json', 'number', '--limit', String(SWEEP_LIMIT), '-R', repo]);
|
|
136
|
+
if (r.status !== 0) return { error: `gh issue list failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
|
|
137
|
+
let j;
|
|
138
|
+
try { j = JSON.parse(r.stdout); } catch { return { error: 'gh issue list returned unparseable JSON' }; }
|
|
139
|
+
if (!Array.isArray(j)) return { error: 'gh issue list returned unexpected JSON' };
|
|
140
|
+
return { numbers: j.map((i) => i && i.number).filter((n) => Number.isInteger(n)).sort((a, b) => a - b) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Toglie la label da ogni issue chiusa; continua dopo un errore e lo riporta alla fine.
|
|
144
|
+
function sweep(run, repo, emit) {
|
|
145
|
+
const list = listClosedLabelled(run, repo);
|
|
146
|
+
if (list.error) { emit({ status: 'error', reason: list.error, cleared: [] }); return 1; }
|
|
147
|
+
const results = list.numbers.map((n) => ({ n, r: run(['issue', 'edit', String(n), '--remove-label', LABEL, '-R', repo]) }));
|
|
148
|
+
const cleared = results.filter((x) => x.r.status === 0).map((x) => x.n);
|
|
149
|
+
const failed = results.filter((x) => x.r.status !== 0);
|
|
150
|
+
if (failed.length) {
|
|
151
|
+
const reason = failed.map((x) => `#${x.n}: ${firstLine(x.r.stderr) || 'exit ' + x.r.status}`).join('; ');
|
|
152
|
+
emit({ status: 'error', reason: `gh issue edit (remove label) failed for ${reason}`, cleared });
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
emit({ status: 'ok', cleared });
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
// True when the latest NEEDS YOU comment already carries this reason.
|
|
128
160
|
function alreadyCommented(comments, reason) {
|
|
129
161
|
const mine = comments.filter((b) => String(b).replace(/\r/g, '').split('\n')[0] === MARKER);
|
|
@@ -143,6 +175,8 @@ function main(argv, { gh, guard, stdout, stderr } = {}) {
|
|
|
143
175
|
const R = ['-R', o.repo];
|
|
144
176
|
const n = String(o.issue);
|
|
145
177
|
|
|
178
|
+
if (o.cmd === 'sweep') return sweep(run, o.repo, emit);
|
|
179
|
+
|
|
146
180
|
if (o.cmd === 'clear') {
|
|
147
181
|
const cur = readIssue(run, o);
|
|
148
182
|
if (cur.error) return fail(cur.error);
|