diff --git a/TODOS.md b/TODOS.md index 595e117e4..01803b85e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -4,7 +4,6 @@ ### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review) -- **Partial `gstack-post pr-body` posts drop the intro.** Since GST-412, any body file with a `## ` section replaces the text above the current body's first heading, so a lane that posts only `## Screenshots` deletes a human intro or a `Review-loop exception:` line written there (stderr names it; GitHub's edit history keeps it). Decide whether the preamble should be managed only when the file brings its own lead text or a receipt, or have /ship put the exception line under a named section. **Priority:** P3. - **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3. - **The root `.bun/` blind spot.** `gstack-wtree` leaves every untracked file under a root `.bun/` out of the fingerprint, so an edit there never makes evidence STALE. Narrow the exclusion to Bun's cache subfolder if anything starts loading code from it. **Priority:** P3. diff --git a/changelog.d/gsta195-prbody-intro.md b/changelog.d/gsta195-prbody-intro.md new file mode 100644 index 000000000..e7ba32451 --- /dev/null +++ b/changelog.d/gsta195-prbody-intro.md @@ -0,0 +1,7 @@ +**A partial `gstack-post pr-body` post keeps the PR's intro.** Since GST-412, any body file with a `## ` section replaced the text above the PR's first heading, so a lane that posted only `## Screenshots` deleted a human intro or a `Review-loop exception:` line under the receipt (GStack Minder: "a partial pr-body post must keep the human intro"). + +### Fixed +- **Only a full post owns the text above the first heading.** A file with a `## ` section and a receipt block or its own lead text, as /ship composes it, still replaces stale lead text and stale notes under the receipt. A file with `## ` sections only keeps them. + +### Tests +- `test/gstack-post.test.ts`: a partial post keeps the intro and the note under the receipt; the full-post cases now carry a receipt, as /ship's body does. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b3875c7cc..1b940f5a6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2518,12 +2518,15 @@ same-titled section (title compared case-insensitively), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite stay in place, and new sections are appended after them. The text above the first -`## ` heading belongs to the file whenever the file has a `## ` section: the -current body's text there (the opening lines, and any lines between the receipt -and the first heading, or the whole body when it has no `## ` heading) is -replaced by the file's own text above its first heading, or dropped when the -file has none. A file with no `## ` section (a receipt-only or text-only post) -leaves that text alone. One stderr line names what was replaced, kept, added +`## ` heading belongs to the file when the file is a full post: a `## ` +section plus a receipt block or text of its own above its first heading, as +/ship composes it. Then the current body's text there (the opening lines, and +any lines between the receipt and the first heading, or the whole body when it +has no `## ` heading) is replaced by the file's own text above its first +heading, or dropped when the file has none. A partial post (`## ` sections +only, such as a lane posting `## Screenshots`) and a file with no `## ` +section (a receipt-only or text-only post) leave that text alone, so a human +intro or a `Review-loop exception:` line under the receipt survives. One stderr line names what was replaced, kept, added and dropped, a dropped piece with its first line, or with that line withheld when the redaction scan flags it (`gstack-post: pr-body merged by section: replaced What changed; kept ship receipt, Why, Approvals; added diff --git a/lib/gstack-post.ts b/lib/gstack-post.ts index dca9b81f5..beae0132d 100644 --- a/lib/gstack-post.ts +++ b/lib/gstack-post.ts @@ -10,7 +10,8 @@ * the same runner): a section in the file replaces the same-titled one, the * ship-receipt block is a section of its own, sections the file does not * rewrite are kept, new ones are appended, and a file with a `## ` section - * replaces the text above the first heading; `--replace` overwrites instead. + * plus a receipt or its own lead text replaces the text above the first + * heading; `--replace` overwrites instead. * The scan runs on the merged bytes, which are the bytes sent. * * Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused; @@ -212,25 +213,27 @@ function splitSections(body: string): Section[] { * A file section whose title the current body holds more than once (an old * description pasted under the new one) matches no single section, so the * merge refuses instead of replacing one copy and keeping the stale other. - * A file with at least one `## ` section manages the preamble (GST-412): every + * A full post (a file with at least one `## ` section and a receipt block or + * text of its own above its first heading) manages the preamble (GST-412): every * non-blank text section before the current body's first heading (the leading * text, text after a receipt block) is dropped, the file's own text before its * first heading takes its place (leading text first, text after the file's * receipt right after the receipt), a dropped piece the file brings no text for * is listed in `dropped` with its first line (withheld when the redaction scan * flags it, since the merged bytes no longer hold it), and the receipt keeps the rules - * above. A file without a `## ` section merges as described above. + * above. A partial post (`## ` sections only) and a file without a `## ` + * section merge as described above, so the current intro is kept. */ export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } { const now = splitSections(current); const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === "")); const ambiguous = next.filter(n => now.filter(s => s.key === n.key).length > 1).map(n => `"${n.label}"`); if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`); - const managed = next.some(n => n.key.startsWith("h2:")); const preamble = (list: Section[]) => { const end = list.findIndex(s => s.key.startsWith("h2:")); return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.text.trim() !== ""); }; + const managed = next.some(n => n.key.startsWith("h2:")) && (next.some(n => n.key === "receipt") || preamble(next).length > 0); const stale = managed ? preamble(now) : []; const ours = managed ? preamble(next) : []; const role = (s: Section) => (s.key === "text:lead" ? "lead" : "trailing"); diff --git a/test/gstack-post.test.ts b/test/gstack-post.test.ts index 67bd093db..96e0acdc0 100644 --- a/test/gstack-post.test.ts +++ b/test/gstack-post.test.ts @@ -457,10 +457,10 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(sent(stale).input).toBe(RECEIPT("bbbb") + "\n## What changed\n\n- new\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n"); expect(stale.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, What changed; kept Why, Approvals; added none; dropped leading text (\"Draft intro from gh pr create, long enough to need truncati…\")\n"); - const between = withBody(RECEIPT("aaaa") + "Old note under the receipt\n\n## What changed\n\n- old\n", { "/b": "## What changed\n\n- new\n" }); + const between = withBody(RECEIPT("aaaa") + "Old note under the receipt\n\n## What changed\n\n- old\n", { "/b": RECEIPT("bbbb") + "## What changed\n\n- new\n" }); expect(run(["pr-body", "5", "--body-file", "/b"], between)).toBe(EXIT.posted); - expect(sent(between).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- new\n"); - expect(between.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt; added none; dropped trailing text (\"Old note under the receipt\")\n"); + expect(sent(between).input).toBe(RECEIPT("bbbb") + "\n## What changed\n\n- new\n"); + expect(between.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, What changed; kept none; added none; dropped trailing text (\"Old note under the receipt\")\n"); const own = withBody("Stale intro\n\n" + RECEIPT("aaaa") + "Stale note\n\n## Why\n\nold\n", { "/b": "Fresh intro\n\n" + RECEIPT("bbbb") + "Fresh note\n\n## Why\n\nnew\n" }); expect(run(["pr-body", "5", "--body-file", "/b"], own)).toBe(EXIT.posted); @@ -469,6 +469,17 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(own.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, ship receipt, trailing text, Why; kept none; added none\n"); }); + // Value: protects=a partial post (`## ` sections only, no receipt and no text of its own above its first heading) keeps the human intro and + // the note under the receipt, the same as a full post that brings no replacement for them; fails_when=a lane posting only `## Screenshots` + // deletes the intro or a Review-loop exception line; why_new=GST-412 made any file with a section own the preamble (GstackMinder, + // 2026-10-31: "a partial pr-body post must keep the human intro"); seam=fake run; no_claim=real gh + test("a partial post keeps the current intro and the note under the receipt", () => { + const partial = withBody("Human intro\n\n" + RECEIPT("aaaa") + "Review-loop exception: accepted by owner\n\n## What changed\n\n- old\n", { "/b": "## Screenshots\n\n![shot](u)\n" }); + expect(run(["pr-body", "5", "--body-file", "/b"], partial)).toBe(EXIT.posted); + expect(sent(partial).input).toBe("Human intro\n\n" + RECEIPT("aaaa") + "Review-loop exception: accepted by owner\n\n## What changed\n\n- old\n\n## Screenshots\n\n![shot](u)\n"); + expect(partial.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text, ship receipt, trailing text, What changed; added Screenshots\n"); + }); + // Value: protects=the dropped-piece excerpt on stderr never repeats a secret the merge removed from the body (the scan only reads the merged bytes); // fails_when=a stale intro holding a credential is echoed to stderr while it leaves the PR; why_new=the excerpt is new in GST-412; seam=fake run; no_claim=real gh test("a dropped piece whose first line the redaction scan flags is named without its text", () => { @@ -515,10 +526,10 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(replaced.stderr()).toBe(""); expect(replaced.calls.some(c => c.cmd === "gh" && c.args[1] === "view")).toBe(false); - const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" }); + const plain = withBody("Just a paragraph.\nAnother line.", { "/b": RECEIPT("bbbb") + "\n## Tests\n\n- one\n" }); expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted); - expect(sent(plain).input).toBe("## Tests\n\n- one\n"); - expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept none; added Tests; dropped leading text (\"Just a paragraph.\")\n"); + expect(sent(plain).input).toBe(RECEIPT("bbbb") + "\n## Tests\n\n- one\n"); + expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept none; added ship receipt, Tests; dropped leading text (\"Just a paragraph.\")\n"); const plainText = withBody("Just a paragraph.\nAnother line.", { "/b": RECEIPT("bbbb") }); expect(run(["pr-body", "5", "--body-file", "/b"], plainText)).toBe(EXIT.posted); expect(sent(plainText).input).toBe(RECEIPT("bbbb") + "\nJust a paragraph.\nAnother line.");