@marcomigozzi/trailhead 0.16.1 → 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 +82 -25
- package/README.md +82 -25
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +155 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- 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 +28 -19
- package/bin/lib/codex-projection.test.js +77 -20
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- 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/report-status-docs.test.js +10 -2
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +15 -8
- package/bin/trailhead.test.js +71 -17
- package/bin/unblock-docs.test.js +56 -0
- 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-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- 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/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/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/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- 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/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/report-status.test.js +56 -1
- 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 +42 -17
- 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 +204 -49
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
- 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 +20 -2
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- 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 +123 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
- package/plugins/trailhead/skills/_shared/substrate.md +4 -3
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
- package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques.md +8 -6
- 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 +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
- 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 +419 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- 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 +161 -12
- 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-report-status.js +57 -9
- 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 +386 -50
- 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/my-tickets.test.ts +66 -0
- 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/repo-pin.test.ts +20 -5
- 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
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -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
|
|
@@ -116,20 +124,80 @@ function bodyLines(text) {
|
|
|
116
124
|
return t === '' ? [] : t.split('\n');
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
// A list entry: a top-level `-
|
|
127
|
+
// A list entry: a top-level bullet (`- `, `* `, `+ `) or ordered item (`1. `, `1) `)
|
|
128
|
+
// plus the indented lines that follow it (a continuation paragraph may follow blank
|
|
129
|
+
// lines; the entry's end excludes trailing blanks). Ordered entries carry their number and
|
|
130
|
+
// delimiter (null for a bullet); an indented `1.` is a continuation, not an entry.
|
|
120
131
|
function listEntries(lines, sec) {
|
|
121
132
|
const entries = [];
|
|
122
133
|
const end = contentEnd(lines, sec);
|
|
123
134
|
for (let i = sec.from; i < end; i++) {
|
|
124
|
-
|
|
135
|
+
const m = /^(?:[-*+]|(\d+)([.)]))[ \t]/.exec(lines[i]);
|
|
136
|
+
if (!m) continue;
|
|
125
137
|
let j = i + 1;
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
for (;;) {
|
|
139
|
+
// Blank lines belong to the entry only when an indented line follows them.
|
|
140
|
+
let k = j;
|
|
141
|
+
while (k < end && isBlank(lines[k])) k++;
|
|
142
|
+
if (k >= end || !/^[ \t]+\S/.test(lines[k])) break;
|
|
143
|
+
j = k + 1;
|
|
144
|
+
}
|
|
145
|
+
entries.push({
|
|
146
|
+
start: i,
|
|
147
|
+
end: j,
|
|
148
|
+
text: lines.slice(i, j).join('\n'),
|
|
149
|
+
num: m[1] === undefined ? null : Number(m[1]),
|
|
150
|
+
numText: m[1] === undefined ? '' : m[1],
|
|
151
|
+
delim: m[2] || null,
|
|
152
|
+
});
|
|
128
153
|
i = j - 1;
|
|
129
154
|
}
|
|
130
155
|
return entries;
|
|
131
156
|
}
|
|
132
157
|
|
|
158
|
+
// After removing entry `h`, the following ordered entries with the same delimiter are
|
|
159
|
+
// renumbered from h's own number. Entries separated only by blank lines (a loose list)
|
|
160
|
+
// stay in the run; a paragraph, a bullet or another delimiter ends it. A list that
|
|
161
|
+
// repeats one number (`1. 1. 1.`) is left alone, and zero padding is kept.
|
|
162
|
+
// Returns a new lines array.
|
|
163
|
+
function renumberAfter(lines, entries, h) {
|
|
164
|
+
if (h.num === null) return lines;
|
|
165
|
+
const run = [];
|
|
166
|
+
let prev = h;
|
|
167
|
+
for (const e of entries.filter((x) => x.start > h.start)) {
|
|
168
|
+
const gap = lines.slice(prev.end, e.start);
|
|
169
|
+
if (e.num === null || e.delim !== h.delim || !gap.every(isBlank)) break;
|
|
170
|
+
run.push(e);
|
|
171
|
+
prev = e;
|
|
172
|
+
}
|
|
173
|
+
if ([h, ...run].every((e) => e.num === h.num)) return lines;
|
|
174
|
+
const padded = (t) => /^0\d/.test(t);
|
|
175
|
+
const fixed = new Map(run.map((e, k) => {
|
|
176
|
+
const width = Math.max(...[h, e].filter((x) => padded(x.numText)).map((x) => x.numText.length), 0);
|
|
177
|
+
const num = String(h.num + k).padStart(width, '0');
|
|
178
|
+
return [e.start, `${num}${h.delim}${lines[e.start].replace(/^\d+[.)]/, '')}`];
|
|
179
|
+
}));
|
|
180
|
+
return lines.map((l, i) => (fixed.has(i) ? fixed.get(i) : l));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The line range [from, to) to drop for entry `h`: the entry plus, in a loose list, one
|
|
184
|
+
// adjoining run of blank lines so no double blank or stray leading blank is left.
|
|
185
|
+
function removalRange(lines, entries, h, sec) {
|
|
186
|
+
const k = entries.indexOf(h);
|
|
187
|
+
const prev = entries[k - 1];
|
|
188
|
+
const next = entries[k + 1];
|
|
189
|
+
const blankGap = (from, to) => to > from && lines.slice(from, to).every(isBlank);
|
|
190
|
+
const blankAfter = next && blankGap(h.end, next.start);
|
|
191
|
+
const blankBefore = prev && blankGap(prev.end, h.start);
|
|
192
|
+
const tightBefore = prev && prev.end === h.start;
|
|
193
|
+
if (blankAfter && !tightBefore) return [h.start, next.start];
|
|
194
|
+
if (blankBefore && !next) return [prev.end, h.end];
|
|
195
|
+
// Sole entry of a spaced section before a heading: drop one trailing blank too.
|
|
196
|
+
const soleSpaced = !prev && !next && h.start > sec.from && isBlank(lines[h.start - 1]) && h.end < sec.to && isBlank(lines[h.end]);
|
|
197
|
+
if (soleSpaced && sec.to < lines.length) return [h.start, h.end + 1];
|
|
198
|
+
return [h.start, h.end];
|
|
199
|
+
}
|
|
200
|
+
|
|
133
201
|
// The non-blank content lines of a section, or null when the section is missing.
|
|
134
202
|
function readSection(body, name) {
|
|
135
203
|
const { lines } = splitBody(String(body));
|
|
@@ -137,9 +205,83 @@ function readSection(body, name) {
|
|
|
137
205
|
return sec ? lines.slice(sec.from, sec.to).filter((l) => !isBlank(l)) : null;
|
|
138
206
|
}
|
|
139
207
|
|
|
140
|
-
//
|
|
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? }.
|
|
141
279
|
// Returns { body, changed, summary } or { error }.
|
|
142
280
|
function applyOp(body, op) {
|
|
281
|
+
return applyStamped(body, op);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyCore(body, op) {
|
|
143
285
|
const parts = splitBody(String(body));
|
|
144
286
|
const { lines } = parts;
|
|
145
287
|
const sec = findSection(lines, op.section);
|
|
@@ -179,13 +321,16 @@ function applyOp(body, op) {
|
|
|
179
321
|
}
|
|
180
322
|
|
|
181
323
|
if (op.kind === 'remove') {
|
|
182
|
-
const
|
|
324
|
+
const entries = listEntries(lines, sec);
|
|
325
|
+
const hits = entries.filter((e) => e.text.includes(op.match));
|
|
183
326
|
if (hits.length === 0) {
|
|
184
327
|
return { body: String(body), changed: false, summary: `no entry matching "${op.match}" in ${label}, nothing written` };
|
|
185
328
|
}
|
|
186
329
|
if (hits.length > 1) return { error: `ambiguous: ${hits.length} entries in ${label} contain "${op.match}"` };
|
|
187
330
|
const h = hits[0];
|
|
188
|
-
const
|
|
331
|
+
const renumbered = renumberAfter(lines, entries, h);
|
|
332
|
+
const [from, to] = removalRange(lines, entries, h, sec);
|
|
333
|
+
const next = [...renumbered.slice(0, from), ...renumbered.slice(to)];
|
|
189
334
|
return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `removed 1 entry from ${label}` };
|
|
190
335
|
}
|
|
191
336
|
|
|
@@ -228,7 +373,7 @@ function secretProblem(text, guard) {
|
|
|
228
373
|
// --- gh boundary -------------------------------------------------------------
|
|
229
374
|
|
|
230
375
|
function defaultGh(args) {
|
|
231
|
-
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 });
|
|
232
377
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
233
378
|
}
|
|
234
379
|
|
|
@@ -382,7 +527,7 @@ function parseArgs(argv) {
|
|
|
382
527
|
for (let i = 1; i < argv.length; i++) {
|
|
383
528
|
const flag = argv[i];
|
|
384
529
|
if (flag === '--dry-run') { o.dryRun = true; continue; }
|
|
385
|
-
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}` };
|
|
386
531
|
i++;
|
|
387
532
|
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
388
533
|
const v = argv[i];
|
|
@@ -396,6 +541,10 @@ function parseArgs(argv) {
|
|
|
396
541
|
}
|
|
397
542
|
if (o.issue === undefined) return { error: 'usage: --issue is required' };
|
|
398
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
|
+
}
|
|
399
548
|
const given = ['line', 'file', 'match'].filter((k) => o[k] !== undefined);
|
|
400
549
|
const want = { append: 'line', replace: 'file', remove: 'match' }[kind];
|
|
401
550
|
if (given.length !== 1 || given[0] !== want) return { error: `usage: ${kind} needs exactly --${want}` };
|
|
@@ -412,7 +561,7 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
|
|
|
412
561
|
if (o.kind === 'replace') {
|
|
413
562
|
try { text = fs.readFileSync(o.file, 'utf8'); } catch (e) { err.write(`usage: cannot read --file ${o.file}: ${e.message}\n`); return 2; }
|
|
414
563
|
}
|
|
415
|
-
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 };
|
|
416
565
|
const prefix = `issue #${o.issue}`;
|
|
417
566
|
let summary = '';
|
|
418
567
|
let unchanged = false;
|
|
@@ -452,7 +601,7 @@ function noopFirst(o, compute, wasUnchanged, opts) {
|
|
|
452
601
|
return !(r && r.error) && wasUnchanged();
|
|
453
602
|
}
|
|
454
603
|
|
|
455
|
-
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 };
|
|
456
605
|
|
|
457
606
|
if (require.main === module) {
|
|
458
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);
|