@webpieces/pr-gate 0.4.599 → 0.4.601

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.
@@ -1,57 +1,49 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LandPrCommand = exports.LandPrOptions = void 0;
3
+ exports.LandPrCommand = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
- const fs = tslib_1.__importStar(require("fs"));
7
- const os = tslib_1.__importStar(require("os"));
8
- const path = tslib_1.__importStar(require("path"));
9
6
  const rules_config_1 = require("@webpieces/rules-config");
10
7
  const inversify_1 = require("inversify");
11
8
  const git_readAiBranchName_1 = require("../workflow/git-readAiBranchName");
12
9
  const branch_naming_1 = require("../workflow/branch-naming");
13
10
  const landed_worktree_reaper_1 = require("../workflow/landed-worktree-reaper");
11
+ const merge_body_temp_file_1 = require("../workflow/merge-body-temp-file");
14
12
  const merge_info_index_1 = require("../workflow/merge-info-index");
15
13
  const pr_merger_1 = require("../workflow/pr-merger");
16
14
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
17
- /** The `--fallback-title-only` opt-in. Data-only, per CLAUDE.md. */
18
- class LandPrOptions {
19
- /**
20
- * Land with a commit body built from the PR TITLE and LINK alone, because the gated body rendered by
21
- * `wp-finish-upsert-pr` is not on this machine. A HUMAN decision — see
22
- * {@link LandPrCommand.fallbackBody} for why the PR description is deliberately not an option.
23
- */
24
- fallbackTitleOnly = false;
25
- }
26
- exports.LandPrOptions = LandPrOptions;
27
15
  /**
28
16
  * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.
29
17
  *
30
- * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited
31
- * to the repo's squash_merge_commit_title/message settings, and none of their values yields the
32
- * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a
33
- * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were
34
- * still running when finish ran), this is the command that keeps main's history consistent.
18
+ * It exists because a merge clicked in the GitHub UI cannot set the squash SUBJECT to `<title> (#N)`
19
+ * and cannot archive the pre-squash tip. So when a merge has to happen outside `wp-finish-upsert-pr`
20
+ * (a mergeMode=NONE repo, or a PR whose checks were still running when finish ran), this is the command
21
+ * that keeps main's history consistent and finishes the branch's bookkeeping.
35
22
  *
36
- * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`
37
- * already did both and filed `merge-commit-body.md` under the PR's identity. Landing is a separate,
38
- * later act, and rebuilding here would mean a second authoritative gate whose result nobody reads. THE
39
- * BYTES THAT LAND ARE THE BYTES FINISH PRODUCED — that invariant survives everything below.
23
+ * ─── Where the bytes come from: THE PR ITSELF ──────────────────────────────────────────────────────
24
+ * `wp-finish-upsert-pr` renders ONE string and publishes it as the PR DESCRIPTION, and the full
25
+ * dashboard lives in the PR's comments (see `pr-body-is-merge-body.spec.ts`, which pins that the
26
+ * description and the merge body are one renderer's output). So the description IS the gated squash
27
+ * body, held by GitHub, and landing reads it back with `gh pr view --json body`.
40
28
  *
41
- * ─── Two scopes, and only one of them is the tree's ────────────────────────────────────────────────
42
- * This command does two different things, and they belong to different scopes:
29
+ * THE BYTES THAT LAND ARE THE BYTES FINISH PRODUCED — that invariant is unchanged; what changed is that
30
+ * GitHub, not this machine, is the thing holding them. That removes the whole machine-global receipt
31
+ * store: a cache of a fact the remote already owns, which could only ever be missing, stale, or on the
32
+ * wrong computer. Landing from a second clone, a fresh clone, or another machine entirely now just
33
+ * works. See `decisions/0005-the-pr-description-is-the-merge-body.md`.
34
+ *
35
+ * It still deliberately does NOT re-render the body: re-deriving it at land time would be a second
36
+ * authoritative gate whose result nobody reads, and it could silently disagree with what was reviewed.
43
37
  *
44
- * 1. THE MERGE, which belongs to the PR. The receipt is read from PrBodyStore —
45
- * `~/.webpieces/prs/<host>/<owner>/<repo>/<n>/` — so landing works from the primary clone, from any
46
- * linked worktree, and from a SECOND CLONE of the same repo, as long as the PR was posted from this
47
- * machine. It used to be read out of this worktree's own `pr-review/<branch>/`, and the day the
48
- * gated flow ran in the primary clone while landing happened in a worktree, it printed "Nothing to
49
- * land" at a perfectly good PR.
38
+ * ─── Two scopes, and only one of them is the tree's ────────────────────────────────────────────────
39
+ * 1. THE MERGE, which belongs to the PR — and is therefore reachable from anywhere.
50
40
  * 2. THE BOOKKEEPING — archiving the pre-squash tip as `archive/<date>/<branch>`, promoting merge-info,
51
- * and reaping the landed worktree. That half genuinely belongs to the tree that holds the branch,
52
- * and it must NOT be attempted from anywhere else: another clone's `<branch>` is a different commit,
53
- * so archiving it there would tag the wrong objects under the right name. When we are not that tree,
54
- * the merge still happens and the bookkeeping is SKIPPED OUT LOUD, naming the tree that owes it.
41
+ * and reaping the landed worktree. That half belongs to the tree whose `<branch>` really is the
42
+ * commit being squashed, and it must NOT be attempted from anywhere else: another clone's
43
+ * `<branch>` is a different commit, so archiving it there would tag the wrong objects under the
44
+ * right name. {@link LandPrCommand.bookkeeping} tests that by comparing this tree's `<branch>`
45
+ * against the PR's own `headRefOid`, and when they disagree the merge still happens and the
46
+ * bookkeeping is SKIPPED OUT LOUD.
55
47
  */
56
48
  let LandPrCommand = class LandPrCommand {
57
49
  repoRootFinder;
@@ -61,9 +53,9 @@ let LandPrCommand = class LandPrCommand {
61
53
  archiver;
62
54
  mergeInfoIndex;
63
55
  landedWorktree;
64
- prBodies;
56
+ bodyFile;
65
57
  // eslint-disable-next-line @typescript-eslint/max-params
66
- constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex, landedWorktree, prBodies) {
58
+ constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex, landedWorktree, bodyFile) {
67
59
  this.repoRootFinder = repoRootFinder;
68
60
  this.aiBranchName = aiBranchName;
69
61
  this.branchNaming = branchNaming;
@@ -71,27 +63,27 @@ let LandPrCommand = class LandPrCommand {
71
63
  this.archiver = archiver;
72
64
  this.mergeInfoIndex = mergeInfoIndex;
73
65
  this.landedWorktree = landedWorktree;
74
- this.prBodies = prBodies;
66
+ this.bodyFile = bodyFile;
75
67
  }
76
- async run(opts = new LandPrOptions()) {
68
+ async run() {
77
69
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
78
70
  const base = this.branchNaming.baseBranchName((0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim());
79
- // The PR is resolved FIRST now, because its NUMBER is the key the body is filed under. That
80
- // ordering is the whole change: the body is no longer a fact of this tree to be looked up by
81
- // branch name, it is a fact of PR #N.
82
- const ref = this.prNumberAndTitle(base);
71
+ const ref = this.readPr(base);
83
72
  if (ref === null) {
84
73
  throw new rules_config_1.InformAiError('\n' + SEP + '❌ No open PR found for this branch\n' + SEP + '\n' +
85
74
  `No open PR has head branch "${base}". Nothing to land.\n` +
86
- 'If the PR is already merged, run `pnpm wp-cleanup`.\n' + SEP);
75
+ 'If the PR is already merged, run `pnpm wp-cleanup`.\n' +
76
+ 'If it was never posted, run the gated flow — it posts the PR AND writes the description\n' +
77
+ 'that becomes this commit body:\n' +
78
+ ' pnpm wp-start-upsert-pr && pnpm wp-review-upsert-pr && pnpm wp-finish-upsert-pr\n' + SEP);
87
79
  }
88
- const stored = this.prBodies.read(repoRoot, ref.number);
89
- if (stored === null && !opts.fallbackTitleOnly)
90
- throw this.notOnThisMachine(repoRoot, ref);
80
+ if (ref.body === '')
81
+ throw this.emptyDescription(ref);
82
+ const unfit = this.notFitForGitLog(ref.body);
83
+ if (unfit !== '')
84
+ throw this.descriptionUnfitForGitLog(ref, unfit);
91
85
  process.stdout.write('\n' + SEP + `🚀 Landing PR #${ref.number}\n` + SEP + '\n');
92
- const mergeBodyFile = stored !== null ? stored.bodyFile : this.writeFallbackBody(ref);
93
- if (stored === null)
94
- process.stdout.write(this.fallbackNotice(ref));
86
+ const mergeBodyFile = this.bodyFile.write(ref.body);
95
87
  const config = (0, rules_config_1.loadAndValidate)(repoRoot).prGate;
96
88
  const policy = config.mergeMode;
97
89
  // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever
@@ -104,7 +96,7 @@ let LandPrCommand = class LandPrCommand {
104
96
  // about `mergeMode: AUTO` two lines above this command printing `mergeMode is NONE`.
105
97
  const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, new pr_merger_1.MergeIntent(policy ?? '', true));
106
98
  const bookkeeping = outcome.merged
107
- ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention, stored)
99
+ ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention)
108
100
  : '';
109
101
  process.stdout.write('\n' + SEP + (outcome.merged ? '✅ Landed\n' : 'ℹ️ Not landed yet\n') + SEP + '\n' +
110
102
  ` ${outcome.message}\n` +
@@ -115,118 +107,100 @@ let LandPrCommand = class LandPrCommand {
115
107
  '\n');
116
108
  }
117
109
  /**
118
- * The refusal when this machine holds no gated body for the PR.
110
+ * The refusal when the PR exists but its description is empty.
119
111
  *
120
- * It says MACHINE, not "branch" and not "worktree", because that is now the true scope: the receipt
121
- * is filed under the PR's global identity, so the only way to be missing it is to be on a different
122
- * computer from the one that posted the PR (or to be looking before finish ever ran).
112
+ * This is the ONE remaining "no gated body" case, and it is now a property of the PR rather than of
113
+ * this machine — which is why the cure is a single command with no human judgement call attached.
114
+ * There is deliberately no `--fallback-title-only` escape any more: that flag existed because the
115
+ * bytes could be on a DIFFERENT COMPUTER from the one landing, which cannot happen when GitHub holds
116
+ * them. Re-running finish is always available, always correct, and lands the reviewed bytes.
123
117
  */
124
- notOnThisMachine(repoRoot, ref) {
125
- const home = this.prBodies.home(repoRoot);
126
- const degraded = home.degraded
127
- ? `\n⚠️ This machine's webpieces state is DEGRADED to ${home.root}\n` +
128
- ` (${home.reason}), so the body is only visible from this clone. Set\n` +
129
- ` ${rules_config_1.WEBPIECES_STATE_HOME_ENV} to a writable directory to make it machine-global.\n`
130
- : '';
131
- return new rules_config_1.InformAiError('\n' + SEP + `❌ PR #${ref.number} was not found on this machine\n` + SEP + '\n' +
132
- `Expected the gated squash-commit body at:\n ${path.join(this.prBodies.dirFor(repoRoot, ref.number), rules_config_1.MERGE_BODY_FILE)}\n\n` +
133
- 'That file is written by `pnpm wp-finish-upsert-pr` ON THE MACHINE THAT POSTED THE PR, and it\n' +
134
- 'is never regenerated here: it is the gate\'s receipt, and re-deriving it at land time would\n' +
135
- 'be a second authoritative gate whose result nobody reads.\n' +
136
- degraded +
137
- this.legacySignpost(repoRoot) +
138
- '\nSo either:\n\n' +
139
- ' A. finish has not run on this branch — run the gated flow, which also posts the PR:\n' +
140
- ' pnpm wp-start-upsert-pr # update from main (3-point merge). No push, no build gate.\n' +
141
- ' pnpm wp-review-upsert-pr # validate the merge, build gate, extract the diff, brief reviewers\n' +
142
- ' # write review.json at the path wp-review-upsert-pr prints\n' +
143
- ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\n' +
144
- ' Then re-run `pnpm wp-land-pr`. This is the right answer almost every time.\n\n' +
145
- ' B. the PR was posted from a DIFFERENT machine. Land it from there, or re-run\n' +
146
- ' `pnpm wp-finish-upsert-pr` here so this machine renders its own receipt.\n\n' +
147
- ' C. A HUMAN may choose to land it WITHOUT the gated body:\n' +
148
- ' pnpm wp-land-pr --fallback-title-only\n' +
149
- ' That writes a commit body of the PR TITLE + LINK and a line saying the gated body was\n' +
150
- ' unavailable. It is a degraded commit message and it says so in main\'s history forever.\n' +
151
- ' DO NOT run it on your own initiative — ask the human, and let them decide.\n' + SEP);
118
+ emptyDescription(ref) {
119
+ return new rules_config_1.InformAiError('\n' + SEP + `❌ PR #${ref.number} has an EMPTY description\n` + SEP + '\n' +
120
+ 'The PR description IS the squash-commit body — `pnpm wp-finish-upsert-pr` renders one string\n' +
121
+ 'and publishes it as the description, and landing reads it straight back with\n' +
122
+ '`gh pr view --json body`. An empty description means the gated flow never published one\n' +
123
+ '(or a human cleared it), so there is nothing reviewed to land.\n\n' +
124
+ 'Re-run the gated flow — it re-renders the description and re-posts it to this same PR:\n' +
125
+ ' pnpm wp-start-upsert-pr # update from main (3-point merge). No push, no build gate.\n' +
126
+ ' pnpm wp-review-upsert-pr # validate the merge, build gate, extract the diff, brief reviewers\n' +
127
+ ' # write review.json at the path wp-review-upsert-pr prints\n' +
128
+ ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\n' +
129
+ 'Then re-run `pnpm wp-land-pr`.\n' + SEP);
152
130
  }
153
131
  /**
154
- * The ONE-TIME signpost for a body written by the PREVIOUS release into this tree's `pr-review/`.
132
+ * '' when the description is a compact gated body; otherwise the marker that proves it is not.
155
133
  *
156
- * LOUD, and never read — the RETIRED_CONFIG_KEYS pattern applied to an artifact. A silent fallback
157
- * to the old path is exactly the shim CLAUDE.md forbids: it would re-create two homes for the
158
- * receipt, and the stale one wins precisely when finish and land ran in different trees, which is
159
- * the bug this whole change removes. Re-running finish re-files it under the PR's identity, which
160
- * costs one command and leaves one home.
161
- */
162
- legacySignpost(repoRoot) {
163
- const legacy = path.join((0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName()), rules_config_1.MERGE_BODY_FILE);
164
- if (!fs.existsSync(legacy))
165
- return '';
166
- return '\n⚠️ A body written by an OLDER webpieces release is sitting at:\n' +
167
- ` ${legacy}\n` +
168
- ' It is deliberately NOT read. That path is per-worktree, so it is only correct while the\n' +
169
- ' branch never changes trees — the assumption that broke. Re-run `pnpm wp-finish-upsert-pr`\n' +
170
- ' to re-file it under this PR\'s identity, then land. (The stale file self-clears in 30 days.)\n';
171
- }
172
- /**
173
- * The explicitly-degraded commit body: PR TITLE + LINK + a line saying what is missing. NEVER the PR
174
- * DESCRIPTION.
134
+ * This reads the SAME invariant `pr-body-is-merge-body.spec.ts` asserts from the other end: the
135
+ * compact body "contains nothing a plain-text git log cannot carry" — no markdown heading, no table
136
+ * pipe. It is therefore not a heuristic about what a dashboard looks like; it is the renderer's own
137
+ * pinned property, checked against bytes that arrived from outside this process.
175
138
  *
176
- * That exclusion is the point of this method, not an oversight. In a real consuming repo the PR
177
- * description IS the full PR Gate Dashboard, and GitHub's default
178
- * `squash_merge_commit_message=PR_BODY` dumping that dashboard into the commit is precisely the ugly
179
- * git log this whole mechanism exists to prevent. A fallback that reached for the description would
180
- * produce a WORSE commit than doing nothing, while looking more complete.
139
+ * WHY IT IS NEEDED, and why it is not a compatibility fallback: a PR posted by a release OLDER than
140
+ * the surface swap still has the FULL DASHBOARD as its description. Measured on this repo,
141
+ * 2026-08-07 — PR #613 (posted after the swap) has a description byte-identical to its squash body,
142
+ * while PR #614 (still open, posted minutes before) begins `## 🚦 PR Gate Dashboard`. Landing that
143
+ * one would dump a risk table into main, which is precisely the defect
144
+ * `decisions/0004` § 4.1 warned about. So this REFUSES and names the one command that fixes it. It
145
+ * never falls back, never rewrites, and never reaches for a second source of bytes.
181
146
  *
182
- * It also announces itself in main's history, so an incomplete commit is self-identifying: anyone
183
- * reading `git log` later can tell at a glance that this one did not carry a gate receipt.
147
+ * It keeps earning its place after the transition: nothing stops a human editing a PR description
148
+ * in GitHub's textarea, and this is the only point between that edit and main's history.
184
149
  */
185
- fallbackBody(ref) {
186
- return `${ref.url === '' ? `PR #${ref.number}` : ref.url}\n\n` +
187
- '⚠️ FALLBACK COMMIT BODY — the gated risk/flags body rendered by `pnpm wp-finish-upsert-pr`\n' +
188
- 'was not available on this machine, and a human chose to land anyway with\n' +
189
- '`pnpm wp-land-pr --fallback-title-only`. This commit carries the PR title and link only.\n' +
190
- 'The PR description is deliberately NOT included here.\n';
191
- }
192
- writeFallbackBody(ref) {
193
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-land-fallback-')), rules_config_1.MERGE_BODY_FILE);
194
- fs.writeFileSync(file, this.fallbackBody(ref));
195
- return file;
150
+ notFitForGitLog(body) {
151
+ if (body.includes('##'))
152
+ return '## (a markdown heading)';
153
+ if (body.includes('|'))
154
+ return '| (a markdown table)';
155
+ return '';
196
156
  }
197
- fallbackNotice(ref) {
198
- return ` ⚠️ --fallback-title-only: no gated body for PR #${ref.number} on this machine, so this\n` +
199
- ' commit gets the PR title + link and a line saying the gated body was unavailable.\n\n';
157
+ /**
158
+ * The refusal when the description is not the compact gated body — almost always an OLD PR posted
159
+ * before the surface swap, whose description is still the full dashboard.
160
+ */
161
+ descriptionUnfitForGitLog(ref, marker) {
162
+ return new rules_config_1.InformAiError('\n' + SEP + `❌ PR #${ref.number}'s description is not a git-log commit body\n` + SEP + '\n' +
163
+ `It contains ${marker}, which the compact body rendered by \`pnpm wp-finish-upsert-pr\` never\n` +
164
+ 'does — so these bytes are not the gated summary, and landing them would put a PR Gate\n' +
165
+ 'Dashboard (or hand-written markdown) into main\'s history permanently.\n\n' +
166
+ 'The usual cause is a PR posted by a webpieces release OLDER than the one that made the\n' +
167
+ 'description the commit body. The dashboard now lives in the PR\'s 1st comment instead.\n\n' +
168
+ 'Re-run finish — it re-renders the description in the compact form and re-posts it to this\n' +
169
+ 'same PR, then landing works:\n' +
170
+ ' pnpm wp-start-upsert-pr && pnpm wp-review-upsert-pr && pnpm wp-finish-upsert-pr\n' + SEP);
200
171
  }
201
172
  /**
202
- * The landed branch's post-merge bookkeeping — but ONLY when this tree is the one that owes it.
173
+ * The landed branch's post-merge bookkeeping — but ONLY when this tree's `<branch>` IS the commit
174
+ * that was squashed.
203
175
  *
204
- * `origin.json` (written beside the body by finish) names the tree that posted the PR. When that is
205
- * not the tree we are standing in, every step below would act on the wrong objects: `<branch>` in a
206
- * second clone is a different commit, so `archive/<date>/<branch>` would tag the wrong tip under the
207
- * right name; `merge-info/staged/<feature>` lives in the posting tree's state, not ours; and the
208
- * worktree to reap is not this one. So the merge stands and the bookkeeping is declined ALOUD.
176
+ * The fact being tested is "does this working tree hold the objects the PR merged", and the PR
177
+ * answers it authoritatively: `headRefOid` is the tip GitHub squashed. A second clone's `<branch>`
178
+ * is a different commit, so `archive/<date>/<branch>` there would tag the wrong tip under the right
179
+ * name; `merge-info/staged/<feature>` lives in the posting tree's state, not ours; and the worktree
180
+ * to reap is not this one. So the merge stands and the bookkeeping is declined ALOUD.
209
181
  *
210
- * No origin at all (the `--fallback-title-only` path) means we have nothing that says otherwise, and
211
- * we ARE standing in the tree holding the branch — `base` came from this tree's HEAD. That is the
212
- * pre-existing behaviour and it stays.
182
+ * This used to be read out of an `origin.json` sidecar recording which tree posted the PR. Comparing
183
+ * SHAs is strictly better: it is a fact rather than a recorded claim, it needs no stored state, and
184
+ * it is more precise in both directions — a second clone sitting on the SAME commit can safely
185
+ * archive it, and a tree that has committed further work since finish ran correctly declines.
213
186
  */
214
- bookkeeping(repoRoot, base, ref, retention, stored) {
215
- const owner = stored?.origin?.treeRoot ?? '';
216
- if (owner !== '' && path.resolve(owner) !== path.resolve(repoRoot)) {
217
- return this.foreignTreeNotice(owner, base);
187
+ bookkeeping(repoRoot, base, ref, retention) {
188
+ const local = this.revParse(repoRoot, base);
189
+ if (ref.headRefOid !== '' && local !== '' && local !== ref.headRefOid) {
190
+ return this.notTheLandedTipNotice(base, local, ref);
218
191
  }
219
192
  return this.archiveAndPromote(repoRoot, base, ref, retention) + this.nextStep(repoRoot, base);
220
193
  }
221
- /** What was skipped, why, and the exact command that finishes it — in the tree that owes it. */
222
- foreignTreeNotice(ownerTree, base) {
223
- return '\n ⚠️ Archive + worktree cleanup SKIPPED — this PR was posted from a different tree:\n' +
224
- ` ${ownerTree}\n` +
225
- ' The pre-squash tip, the merge-info record and (if any) the worktree holding\n' +
226
- ` ${base} all live THERE, and "${base}" here is a different commit, so archiving it\n` +
227
- ' from this tree would tag the wrong objects under the right name.\n' +
228
- ' Finish the bookkeeping there:\n' +
229
- ` ${(0, rules_config_1.atRoot)(ownerTree, 'pnpm wp-cleanup')}\n`;
194
+ /** What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was. */
195
+ notTheLandedTipNotice(base, local, ref) {
196
+ return '\n ⚠️ Archive + worktree cleanup SKIPPED — this tree\'s branch is not the commit that landed:\n' +
197
+ ` ${base} here → ${local}\n` +
198
+ ` PR #${ref.number} squashed → ${ref.headRefOid}\n` +
199
+ ' Archiving from here would tag the wrong objects under the right name, and the\n' +
200
+ ` merge-info record and (if any) the worktree holding ${base} live with the other tip.\n` +
201
+ ' Either this is a second clone of the repo — finish the bookkeeping in the tree that\n' +
202
+ ' posted the PR with `pnpm wp-cleanup` — or this tree has commits made after\n' +
203
+ ' `pnpm wp-finish-upsert-pr` ran, which the PR does not contain.\n';
230
204
  }
231
205
  /**
232
206
  * What happens next — which is NOT the same act when you landed from a worktree.
@@ -288,17 +262,46 @@ let LandPrCommand = class LandPrCommand {
288
262
  const result = (0, child_process_1.spawnSync)('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });
289
263
  return result.status === 0 ? (result.stdout ?? '').trim() : '';
290
264
  }
291
- // The open PR's number, title and URL for this head branch, or null when there is none. The TITLE
292
- // comes from the PR itself, not review.json, so the squash subject matches what a reviewer approved
293
- // even if review.json was edited afterwards. The URL is only used by the fallback body.
294
- prNumberAndTitle(baseBranch) {
295
- const result = (0, child_process_1.spawnSync)('gh', ['pr', 'view', baseBranch, '--json', 'number,title,url', '--jq', '"\\(.number)\\t\\(.title)\\t\\(.url)"'], { encoding: 'utf8' });
265
+ /**
266
+ * Everything landing needs about the PR, in ONE `gh` read: number, title, URL, DESCRIPTION and the
267
+ * head sha GitHub is squashing. `null` when there is no open PR for this head branch.
268
+ *
269
+ * The TITLE comes from the PR itself, not review.json, so the squash subject matches what a reviewer
270
+ * approved even if review.json was edited afterwards. The BODY is the gated commit body (see the
271
+ * class doc). `headRefOid` is read BEFORE the merge, because it is what the bookkeeping check
272
+ * compares against and the merge is what makes the branch's fate uninteresting to GitHub.
273
+ */
274
+ readPr(baseBranch) {
275
+ const result = (0, child_process_1.spawnSync)('gh', ['pr', 'view', baseBranch, '--json', 'number,title,url,body,headRefOid'], { encoding: 'utf8' });
296
276
  if (result.status !== 0)
297
277
  return null;
298
- const parts = (result.stdout ?? '').trim().split('\t');
299
- if ((parts[0] ?? '') === '')
278
+ return this.parsePr(result.stdout ?? '');
279
+ }
280
+ // `gh --json` output → PrIdentity. Malformed/absent JSON reads as "no PR", which the caller turns
281
+ // into the same refusal a missing PR gets: both mean nothing here can be landed.
282
+ parsePr(stdout) {
283
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: unparseable gh output means "no PR", never a crash
284
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
285
+ try {
286
+ // webpieces-disable no-any-unknown -- gh's JSON is opaque until narrowed field-by-field below
287
+ const raw = JSON.parse(stdout);
288
+ const number = typeof raw['number'] === 'number' ? String(raw['number']) : this.str(raw['number']);
289
+ if (number === '')
290
+ return null;
291
+ // GitHub stores descriptions with CRLF. A commit body must not carry them, and the bytes are
292
+ // otherwise identical to what finish rendered, so the line endings are normalized back here.
293
+ const body = this.str(raw['body']).replace(/\r\n/g, '\n').trim();
294
+ return new PrIdentity(number, this.str(raw['title']), this.str(raw['url']), body === '' ? '' : body + '\n', this.str(raw['headRefOid']));
295
+ }
296
+ catch (err) {
297
+ const error = (0, rules_config_1.toError)(err);
298
+ void error;
300
299
  return null;
301
- return new PrIdentity(parts[0] ?? '', parts[1] ?? '', parts[2] ?? '');
300
+ }
301
+ }
302
+ // webpieces-disable no-any-unknown -- one opaque JSON field, narrowed to string
303
+ str(value) {
304
+ return typeof value === 'string' ? value : '';
302
305
  }
303
306
  };
304
307
  exports.LandPrCommand = LandPrCommand;
@@ -311,17 +314,23 @@ exports.LandPrCommand = LandPrCommand = tslib_1.__decorate([
311
314
  rules_config_1.BranchArchiver,
312
315
  merge_info_index_1.MergeInfoIndex,
313
316
  landed_worktree_reaper_1.LandedWorktreeReaper,
314
- rules_config_1.PrBodyStore])
317
+ merge_body_temp_file_1.MergeBodyTempFile])
315
318
  ], LandPrCommand);
316
- // The open PR's number, title and web URL, as read back from GitHub.
319
+ // The open PR as GitHub holds it: its number, title, web URL, DESCRIPTION (= the gated commit body) and
320
+ // the head commit being squashed.
317
321
  class PrIdentity {
318
322
  number;
319
323
  title;
320
324
  url;
321
- constructor(number, title, url) {
325
+ body;
326
+ headRefOid;
327
+ // eslint-disable-next-line @typescript-eslint/max-params
328
+ constructor(number, title, url, body, headRefOid) {
322
329
  this.number = number;
323
330
  this.title = title;
324
331
  this.url = url;
332
+ this.body = body;
333
+ this.headRefOid = headRefOid;
325
334
  }
326
335
  }
327
336
  //# sourceMappingURL=land-pr-command.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n prDirFor, InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate,\n BranchArchiver, BRANCH_RETENTION_ARCHIVE_TAG, BRANCH_RETENTION_KEEP,\n MERGE_BODY_FILE, PrBodyLocation, PrBodyStore, WEBPIECES_STATE_HOME_ENV, atRoot,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { LandedWorktreeReaper, WorktreeReapHandoff } from '../workflow/landed-worktree-reaper';\nimport { ArchiveRecord, MergeInfoIndex } from '../workflow/merge-info-index';\nimport { MergeIntent, PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/** The `--fallback-title-only` opt-in. Data-only, per CLAUDE.md. */\nexport class LandPrOptions {\n /**\n * Land with a commit body built from the PR TITLE and LINK alone, because the gated body rendered by\n * `wp-finish-upsert-pr` is not on this machine. A HUMAN decision — see\n * {@link LandPrCommand.fallbackBody} for why the PR description is deliberately not an option.\n */\n fallbackTitleOnly = false;\n}\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited\n * to the repo's squash_merge_commit_title/message settings, and none of their values yields the\n * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a\n * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were\n * still running when finish ran), this is the command that keeps main's history consistent.\n *\n * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`\n * already did both and filed `merge-commit-body.md` under the PR's identity. Landing is a separate,\n * later act, and rebuilding here would mean a second authoritative gate whose result nobody reads. THE\n * BYTES THAT LAND ARE THE BYTES FINISH PRODUCED — that invariant survives everything below.\n *\n * ─── Two scopes, and only one of them is the tree's ────────────────────────────────────────────────\n * This command does two different things, and they belong to different scopes:\n *\n * 1. THE MERGE, which belongs to the PR. The receipt is read from PrBodyStore —\n * `~/.webpieces/prs/<host>/<owner>/<repo>/<n>/` — so landing works from the primary clone, from any\n * linked worktree, and from a SECOND CLONE of the same repo, as long as the PR was posted from this\n * machine. It used to be read out of this worktree's own `pr-review/<branch>/`, and the day the\n * gated flow ran in the primary clone while landing happened in a worktree, it printed \"Nothing to\n * land\" at a perfectly good PR.\n * 2. THE BOOKKEEPING — archiving the pre-squash tip as `archive/<date>/<branch>`, promoting merge-info,\n * and reaping the landed worktree. That half genuinely belongs to the tree that holds the branch,\n * and it must NOT be attempted from anywhere else: another clone's `<branch>` is a different commit,\n * so archiving it there would tag the wrong objects under the right name. When we are not that tree,\n * the merge still happens and the bookkeeping is SKIPPED OUT LOUD, naming the tree that owes it.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n private readonly archiver: BranchArchiver,\n private readonly mergeInfoIndex: MergeInfoIndex,\n private readonly landedWorktree: LandedWorktreeReaper,\n private readonly prBodies: PrBodyStore,\n ) {}\n\n async run(opts: LandPrOptions = new LandPrOptions()): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n\n // The PR is resolved FIRST now, because its NUMBER is the key the body is filed under. That\n // ordering is the whole change: the body is no longer a fact of this tree to be looked up by\n // branch name, it is a fact of PR #N.\n const ref = this.prNumberAndTitle(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' + SEP,\n );\n }\n\n const stored = this.prBodies.read(repoRoot, ref.number);\n if (stored === null && !opts.fallbackTitleOnly) throw this.notOnThisMachine(repoRoot, ref);\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n const mergeBodyFile = stored !== null ? stored.bodyFile : this.writeFallbackBody(ref);\n if (stored === null) process.stdout.write(this.fallbackNotice(ref));\n\n const config = loadAndValidate(repoRoot).prGate;\n const policy = config.mergeMode;\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n //\n // `commanded: true` — running THIS command IS the intent to merge, so it is not gated on\n // pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand). The real `policy`\n // travels alongside it rather than being replaced by a literal AUTO, so PrMerger's diagnostics\n // never assert a config value nobody set — that is exactly what used to print a CONFIG MISMATCH\n // about `mergeMode: AUTO` two lines above this command printing `mergeMode is NONE`.\n const outcome = this.prMerger.merge(\n base, `${ref.title} (#${ref.number})`, mergeBodyFile, new MergeIntent(policy ?? '', true),\n );\n\n const bookkeeping = outcome.merged\n ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention, stored)\n : '';\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n bookkeeping +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n /**\n * The refusal when this machine holds no gated body for the PR.\n *\n * It says MACHINE, not \"branch\" and not \"worktree\", because that is now the true scope: the receipt\n * is filed under the PR's global identity, so the only way to be missing it is to be on a different\n * computer from the one that posted the PR (or to be looking before finish ever ran).\n */\n private notOnThisMachine(repoRoot: string, ref: PrIdentity): InformAiError {\n const home = this.prBodies.home(repoRoot);\n const degraded = home.degraded\n ? `\\n⚠️ This machine's webpieces state is DEGRADED to ${home.root}\\n` +\n ` (${home.reason}), so the body is only visible from this clone. Set\\n` +\n ` ${WEBPIECES_STATE_HOME_ENV} to a writable directory to make it machine-global.\\n`\n : '';\n return new InformAiError(\n '\\n' + SEP + `❌ PR #${ref.number} was not found on this machine\\n` + SEP + '\\n' +\n `Expected the gated squash-commit body at:\\n ${path.join(this.prBodies.dirFor(repoRoot, ref.number), MERGE_BODY_FILE)}\\n\\n` +\n 'That file is written by `pnpm wp-finish-upsert-pr` ON THE MACHINE THAT POSTED THE PR, and it\\n' +\n 'is never regenerated here: it is the gate\\'s receipt, and re-deriving it at land time would\\n' +\n 'be a second authoritative gate whose result nobody reads.\\n' +\n degraded +\n this.legacySignpost(repoRoot) +\n '\\nSo either:\\n\\n' +\n ' A. finish has not run on this branch — run the gated flow, which also posts the PR:\\n' +\n ' pnpm wp-start-upsert-pr # update from main (3-point merge). No push, no build gate.\\n' +\n ' pnpm wp-review-upsert-pr # validate the merge, build gate, extract the diff, brief reviewers\\n' +\n ' # write review.json at the path wp-review-upsert-pr prints\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n' +\n ' Then re-run `pnpm wp-land-pr`. This is the right answer almost every time.\\n\\n' +\n ' B. the PR was posted from a DIFFERENT machine. Land it from there, or re-run\\n' +\n ' `pnpm wp-finish-upsert-pr` here so this machine renders its own receipt.\\n\\n' +\n ' C. A HUMAN may choose to land it WITHOUT the gated body:\\n' +\n ' pnpm wp-land-pr --fallback-title-only\\n' +\n ' That writes a commit body of the PR TITLE + LINK and a line saying the gated body was\\n' +\n ' unavailable. It is a degraded commit message and it says so in main\\'s history forever.\\n' +\n ' DO NOT run it on your own initiative — ask the human, and let them decide.\\n' + SEP,\n );\n }\n\n /**\n * The ONE-TIME signpost for a body written by the PREVIOUS release into this tree's `pr-review/`.\n *\n * LOUD, and never read — the RETIRED_CONFIG_KEYS pattern applied to an artifact. A silent fallback\n * to the old path is exactly the shim CLAUDE.md forbids: it would re-create two homes for the\n * receipt, and the stale one wins precisely when finish and land ran in different trees, which is\n * the bug this whole change removes. Re-running finish re-files it under the PR's identity, which\n * costs one command and leaves one home.\n */\n private legacySignpost(repoRoot: string): string {\n const legacy = path.join(prDirFor(repoRoot, this.aiBranchName.getFeatureName()), MERGE_BODY_FILE);\n if (!fs.existsSync(legacy)) return '';\n return '\\n⚠️ A body written by an OLDER webpieces release is sitting at:\\n' +\n ` ${legacy}\\n` +\n ' It is deliberately NOT read. That path is per-worktree, so it is only correct while the\\n' +\n ' branch never changes trees — the assumption that broke. Re-run `pnpm wp-finish-upsert-pr`\\n' +\n ' to re-file it under this PR\\'s identity, then land. (The stale file self-clears in 30 days.)\\n';\n }\n\n /**\n * The explicitly-degraded commit body: PR TITLE + LINK + a line saying what is missing. NEVER the PR\n * DESCRIPTION.\n *\n * That exclusion is the point of this method, not an oversight. In a real consuming repo the PR\n * description IS the full PR Gate Dashboard, and GitHub's default\n * `squash_merge_commit_message=PR_BODY` dumping that dashboard into the commit is precisely the ugly\n * git log this whole mechanism exists to prevent. A fallback that reached for the description would\n * produce a WORSE commit than doing nothing, while looking more complete.\n *\n * It also announces itself in main's history, so an incomplete commit is self-identifying: anyone\n * reading `git log` later can tell at a glance that this one did not carry a gate receipt.\n */\n private fallbackBody(ref: PrIdentity): string {\n return `${ref.url === '' ? `PR #${ref.number}` : ref.url}\\n\\n` +\n '⚠️ FALLBACK COMMIT BODY — the gated risk/flags body rendered by `pnpm wp-finish-upsert-pr`\\n' +\n 'was not available on this machine, and a human chose to land anyway with\\n' +\n '`pnpm wp-land-pr --fallback-title-only`. This commit carries the PR title and link only.\\n' +\n 'The PR description is deliberately NOT included here.\\n';\n }\n\n private writeFallbackBody(ref: PrIdentity): string {\n const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-land-fallback-')), MERGE_BODY_FILE);\n fs.writeFileSync(file, this.fallbackBody(ref));\n return file;\n }\n\n private fallbackNotice(ref: PrIdentity): string {\n return ` ⚠️ --fallback-title-only: no gated body for PR #${ref.number} on this machine, so this\\n` +\n ' commit gets the PR title + link and a line saying the gated body was unavailable.\\n\\n';\n }\n\n /**\n * The landed branch's post-merge bookkeeping — but ONLY when this tree is the one that owes it.\n *\n * `origin.json` (written beside the body by finish) names the tree that posted the PR. When that is\n * not the tree we are standing in, every step below would act on the wrong objects: `<branch>` in a\n * second clone is a different commit, so `archive/<date>/<branch>` would tag the wrong tip under the\n * right name; `merge-info/staged/<feature>` lives in the posting tree's state, not ours; and the\n * worktree to reap is not this one. So the merge stands and the bookkeeping is declined ALOUD.\n *\n * No origin at all (the `--fallback-title-only` path) means we have nothing that says otherwise, and\n * we ARE standing in the tree holding the branch — `base` came from this tree's HEAD. That is the\n * pre-existing behaviour and it stays.\n */\n private bookkeeping(repoRoot: string, base: string, ref: PrIdentity, retention: string, stored: PrBodyLocation | null): string {\n const owner = stored?.origin?.treeRoot ?? '';\n if (owner !== '' && path.resolve(owner) !== path.resolve(repoRoot)) {\n return this.foreignTreeNotice(owner, base);\n }\n return this.archiveAndPromote(repoRoot, base, ref, retention) + this.nextStep(repoRoot, base);\n }\n\n /** What was skipped, why, and the exact command that finishes it — in the tree that owes it. */\n private foreignTreeNotice(ownerTree: string, base: string): string {\n return '\\n ⚠️ Archive + worktree cleanup SKIPPED — this PR was posted from a different tree:\\n' +\n ` ${ownerTree}\\n` +\n ' The pre-squash tip, the merge-info record and (if any) the worktree holding\\n' +\n ` ${base} all live THERE, and \"${base}\" here is a different commit, so archiving it\\n` +\n ' from this tree would tag the wrong objects under the right name.\\n' +\n ' Finish the bookkeeping there:\\n' +\n ` ${atRoot(ownerTree, 'pnpm wp-cleanup')}\\n`;\n }\n\n /**\n * What happens next — which is NOT the same act when you landed from a worktree.\n *\n * Landing from a linked worktree always used to leave a corpse: the merged branch is checked out\n * here, so `git branch -D` refuses, `wp-cleanup`'s branch pass spares it as in-use, and nothing\n * removed the worktree — so the pair sat there until branch-creation-guard hit its cap. #512 made\n * worktrees reapable and printed `cd <primary> && pnpm wp-cleanup`; that instruction was correct\n * and it was the most-skipped step in the flow, because the PR is already landed and the work\n * feels done.\n *\n * So the reap is no longer an instruction. This command still refuses to remove the directory it\n * is standing in — that rail is untouched — and instead HANDS THE REAP to a child process rooted\n * in the primary clone, which is a tree nobody is deleting. See LandedWorktreeReaper. When that\n * hand-off is not safely achievable the #512 notice is printed unchanged, because an honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n private nextStep(repoRoot: string, base: string): string {\n const handoff: WorktreeReapHandoff | null = this.landedWorktree.plan(repoRoot, base);\n if (handoff === null) return ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n';\n if (!handoff.canReap) return this.landedWorktree.manualNotice(handoff);\n return this.landedWorktree.handOff(handoff);\n }\n\n /**\n * The landed branch's post-merge bookkeeping, in one place:\n * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history\n * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while\n * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that\n * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.\n * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild\n * `index.json`, so `staged/` holds only branches that are still in flight.\n *\n * Never throws: the PR is already merged by the time we get here, and failing the command after a\n * successful merge would report a landed PR as a failure. Problems are reported in the recap.\n */\n private archiveAndPromote(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n if (retention === BRANCH_RETENTION_KEEP) return ' Branch retention is \"keep\" — nothing archived.\\n';\n\n let line = '';\n let tag = '';\n if (retention === BRANCH_RETENTION_ARCHIVE_TAG) {\n const archive = this.archiver.archive(repoRoot, base);\n tag = archive.tag;\n line = archive.ok\n ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\\n`\n : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\\n`;\n }\n\n const feature = this.aiBranchName.getFeatureName();\n const record = new ArchiveRecord(\n tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'),\n Number(ref.number), new Date().toISOString(),\n );\n if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {\n line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\\n`;\n }\n return line;\n }\n\n // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.\n private revParse(repoRoot: string, ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // The open PR's number, title and URL for this head branch, or null when there is none. The TITLE\n // comes from the PR itself, not review.json, so the squash subject matches what a reviewer approved\n // even if review.json was edited afterwards. The URL is only used by the fallback body.\n private prNumberAndTitle(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title,url', '--jq', '\"\\\\(.number)\\\\t\\\\(.title)\\\\t\\\\(.url)\"'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n const parts = (result.stdout ?? '').trim().split('\\t');\n if ((parts[0] ?? '') === '') return null;\n return new PrIdentity(parts[0] ?? '', parts[1] ?? '', parts[2] ?? '');\n }\n}\n\n// The open PR's number, title and web URL, as read back from GitHub.\nclass PrIdentity {\n number: string;\n title: string;\n url: string;\n\n constructor(number: string, title: string, url: string) {\n this.number = number;\n this.title = title;\n this.url = url;\n }\n}\n"]}
1
+ {"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport {\n InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate,\n BranchArchiver, BRANCH_RETENTION_ARCHIVE_TAG, BRANCH_RETENTION_KEEP, toError,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { LandedWorktreeReaper, WorktreeReapHandoff } from '../workflow/landed-worktree-reaper';\nimport { MergeBodyTempFile } from '../workflow/merge-body-temp-file';\nimport { ArchiveRecord, MergeInfoIndex } from '../workflow/merge-info-index';\nimport { MergeIntent, PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot set the squash SUBJECT to `<title> (#N)`\n * and cannot archive the pre-squash tip. So when a merge has to happen outside `wp-finish-upsert-pr`\n * (a mergeMode=NONE repo, or a PR whose checks were still running when finish ran), this is the command\n * that keeps main's history consistent and finishes the branch's bookkeeping.\n *\n * ─── Where the bytes come from: THE PR ITSELF ──────────────────────────────────────────────────────\n * `wp-finish-upsert-pr` renders ONE string and publishes it as the PR DESCRIPTION, and the full\n * dashboard lives in the PR's comments (see `pr-body-is-merge-body.spec.ts`, which pins that the\n * description and the merge body are one renderer's output). So the description IS the gated squash\n * body, held by GitHub, and landing reads it back with `gh pr view --json body`.\n *\n * THE BYTES THAT LAND ARE THE BYTES FINISH PRODUCED — that invariant is unchanged; what changed is that\n * GitHub, not this machine, is the thing holding them. That removes the whole machine-global receipt\n * store: a cache of a fact the remote already owns, which could only ever be missing, stale, or on the\n * wrong computer. Landing from a second clone, a fresh clone, or another machine entirely now just\n * works. See `decisions/0005-the-pr-description-is-the-merge-body.md`.\n *\n * It still deliberately does NOT re-render the body: re-deriving it at land time would be a second\n * authoritative gate whose result nobody reads, and it could silently disagree with what was reviewed.\n *\n * ─── Two scopes, and only one of them is the tree's ────────────────────────────────────────────────\n * 1. THE MERGE, which belongs to the PR — and is therefore reachable from anywhere.\n * 2. THE BOOKKEEPING — archiving the pre-squash tip as `archive/<date>/<branch>`, promoting merge-info,\n * and reaping the landed worktree. That half belongs to the tree whose `<branch>` really is the\n * commit being squashed, and it must NOT be attempted from anywhere else: another clone's\n * `<branch>` is a different commit, so archiving it there would tag the wrong objects under the\n * right name. {@link LandPrCommand.bookkeeping} tests that by comparing this tree's `<branch>`\n * against the PR's own `headRefOid`, and when they disagree the merge still happens and the\n * bookkeeping is SKIPPED OUT LOUD.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n private readonly archiver: BranchArchiver,\n private readonly mergeInfoIndex: MergeInfoIndex,\n private readonly landedWorktree: LandedWorktreeReaper,\n private readonly bodyFile: MergeBodyTempFile,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n\n const ref = this.readPr(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' +\n 'If it was never posted, run the gated flow — it posts the PR AND writes the description\\n' +\n 'that becomes this commit body:\\n' +\n ' pnpm wp-start-upsert-pr && pnpm wp-review-upsert-pr && pnpm wp-finish-upsert-pr\\n' + SEP,\n );\n }\n if (ref.body === '') throw this.emptyDescription(ref);\n const unfit = this.notFitForGitLog(ref.body);\n if (unfit !== '') throw this.descriptionUnfitForGitLog(ref, unfit);\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n const mergeBodyFile = this.bodyFile.write(ref.body);\n\n const config = loadAndValidate(repoRoot).prGate;\n const policy = config.mergeMode;\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n //\n // `commanded: true` — running THIS command IS the intent to merge, so it is not gated on\n // pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand). The real `policy`\n // travels alongside it rather than being replaced by a literal AUTO, so PrMerger's diagnostics\n // never assert a config value nobody set — that is exactly what used to print a CONFIG MISMATCH\n // about `mergeMode: AUTO` two lines above this command printing `mergeMode is NONE`.\n const outcome = this.prMerger.merge(\n base, `${ref.title} (#${ref.number})`, mergeBodyFile, new MergeIntent(policy ?? '', true),\n );\n\n const bookkeeping = outcome.merged\n ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention)\n : '';\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n bookkeeping +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n /**\n * The refusal when the PR exists but its description is empty.\n *\n * This is the ONE remaining \"no gated body\" case, and it is now a property of the PR rather than of\n * this machine — which is why the cure is a single command with no human judgement call attached.\n * There is deliberately no `--fallback-title-only` escape any more: that flag existed because the\n * bytes could be on a DIFFERENT COMPUTER from the one landing, which cannot happen when GitHub holds\n * them. Re-running finish is always available, always correct, and lands the reviewed bytes.\n */\n private emptyDescription(ref: PrIdentity): InformAiError {\n return new InformAiError(\n '\\n' + SEP + `❌ PR #${ref.number} has an EMPTY description\\n` + SEP + '\\n' +\n 'The PR description IS the squash-commit body — `pnpm wp-finish-upsert-pr` renders one string\\n' +\n 'and publishes it as the description, and landing reads it straight back with\\n' +\n '`gh pr view --json body`. An empty description means the gated flow never published one\\n' +\n '(or a human cleared it), so there is nothing reviewed to land.\\n\\n' +\n 'Re-run the gated flow — it re-renders the description and re-posts it to this same PR:\\n' +\n ' pnpm wp-start-upsert-pr # update from main (3-point merge). No push, no build gate.\\n' +\n ' pnpm wp-review-upsert-pr # validate the merge, build gate, extract the diff, brief reviewers\\n' +\n ' # write review.json at the path wp-review-upsert-pr prints\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n' +\n 'Then re-run `pnpm wp-land-pr`.\\n' + SEP,\n );\n }\n\n /**\n * '' when the description is a compact gated body; otherwise the marker that proves it is not.\n *\n * This reads the SAME invariant `pr-body-is-merge-body.spec.ts` asserts from the other end: the\n * compact body \"contains nothing a plain-text git log cannot carry\" — no markdown heading, no table\n * pipe. It is therefore not a heuristic about what a dashboard looks like; it is the renderer's own\n * pinned property, checked against bytes that arrived from outside this process.\n *\n * WHY IT IS NEEDED, and why it is not a compatibility fallback: a PR posted by a release OLDER than\n * the surface swap still has the FULL DASHBOARD as its description. Measured on this repo,\n * 2026-08-07 — PR #613 (posted after the swap) has a description byte-identical to its squash body,\n * while PR #614 (still open, posted minutes before) begins `## 🚦 PR Gate Dashboard`. Landing that\n * one would dump a risk table into main, which is precisely the defect\n * `decisions/0004` § 4.1 warned about. So this REFUSES and names the one command that fixes it. It\n * never falls back, never rewrites, and never reaches for a second source of bytes.\n *\n * It keeps earning its place after the transition: nothing stops a human editing a PR description\n * in GitHub's textarea, and this is the only point between that edit and main's history.\n */\n private notFitForGitLog(body: string): string {\n if (body.includes('##')) return '## (a markdown heading)';\n if (body.includes('|')) return '| (a markdown table)';\n return '';\n }\n\n /**\n * The refusal when the description is not the compact gated body — almost always an OLD PR posted\n * before the surface swap, whose description is still the full dashboard.\n */\n private descriptionUnfitForGitLog(ref: PrIdentity, marker: string): InformAiError {\n return new InformAiError(\n '\\n' + SEP + `❌ PR #${ref.number}'s description is not a git-log commit body\\n` + SEP + '\\n' +\n `It contains ${marker}, which the compact body rendered by \\`pnpm wp-finish-upsert-pr\\` never\\n` +\n 'does — so these bytes are not the gated summary, and landing them would put a PR Gate\\n' +\n 'Dashboard (or hand-written markdown) into main\\'s history permanently.\\n\\n' +\n 'The usual cause is a PR posted by a webpieces release OLDER than the one that made the\\n' +\n 'description the commit body. The dashboard now lives in the PR\\'s 1st comment instead.\\n\\n' +\n 'Re-run finish — it re-renders the description in the compact form and re-posts it to this\\n' +\n 'same PR, then landing works:\\n' +\n ' pnpm wp-start-upsert-pr && pnpm wp-review-upsert-pr && pnpm wp-finish-upsert-pr\\n' + SEP,\n );\n }\n\n /**\n * The landed branch's post-merge bookkeeping — but ONLY when this tree's `<branch>` IS the commit\n * that was squashed.\n *\n * The fact being tested is \"does this working tree hold the objects the PR merged\", and the PR\n * answers it authoritatively: `headRefOid` is the tip GitHub squashed. A second clone's `<branch>`\n * is a different commit, so `archive/<date>/<branch>` there would tag the wrong tip under the right\n * name; `merge-info/staged/<feature>` lives in the posting tree's state, not ours; and the worktree\n * to reap is not this one. So the merge stands and the bookkeeping is declined ALOUD.\n *\n * This used to be read out of an `origin.json` sidecar recording which tree posted the PR. Comparing\n * SHAs is strictly better: it is a fact rather than a recorded claim, it needs no stored state, and\n * it is more precise in both directions — a second clone sitting on the SAME commit can safely\n * archive it, and a tree that has committed further work since finish ran correctly declines.\n */\n private bookkeeping(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n const local = this.revParse(repoRoot, base);\n if (ref.headRefOid !== '' && local !== '' && local !== ref.headRefOid) {\n return this.notTheLandedTipNotice(base, local, ref);\n }\n return this.archiveAndPromote(repoRoot, base, ref, retention) + this.nextStep(repoRoot, base);\n }\n\n /** What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was. */\n private notTheLandedTipNotice(base: string, local: string, ref: PrIdentity): string {\n return '\\n ⚠️ Archive + worktree cleanup SKIPPED — this tree\\'s branch is not the commit that landed:\\n' +\n ` ${base} here → ${local}\\n` +\n ` PR #${ref.number} squashed → ${ref.headRefOid}\\n` +\n ' Archiving from here would tag the wrong objects under the right name, and the\\n' +\n ` merge-info record and (if any) the worktree holding ${base} live with the other tip.\\n` +\n ' Either this is a second clone of the repo — finish the bookkeeping in the tree that\\n' +\n ' posted the PR with `pnpm wp-cleanup` — or this tree has commits made after\\n' +\n ' `pnpm wp-finish-upsert-pr` ran, which the PR does not contain.\\n';\n }\n\n /**\n * What happens next — which is NOT the same act when you landed from a worktree.\n *\n * Landing from a linked worktree always used to leave a corpse: the merged branch is checked out\n * here, so `git branch -D` refuses, `wp-cleanup`'s branch pass spares it as in-use, and nothing\n * removed the worktree — so the pair sat there until branch-creation-guard hit its cap. #512 made\n * worktrees reapable and printed `cd <primary> && pnpm wp-cleanup`; that instruction was correct\n * and it was the most-skipped step in the flow, because the PR is already landed and the work\n * feels done.\n *\n * So the reap is no longer an instruction. This command still refuses to remove the directory it\n * is standing in — that rail is untouched — and instead HANDS THE REAP to a child process rooted\n * in the primary clone, which is a tree nobody is deleting. See LandedWorktreeReaper. When that\n * hand-off is not safely achievable the #512 notice is printed unchanged, because an honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n private nextStep(repoRoot: string, base: string): string {\n const handoff: WorktreeReapHandoff | null = this.landedWorktree.plan(repoRoot, base);\n if (handoff === null) return ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n';\n if (!handoff.canReap) return this.landedWorktree.manualNotice(handoff);\n return this.landedWorktree.handOff(handoff);\n }\n\n /**\n * The landed branch's post-merge bookkeeping, in one place:\n * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history\n * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while\n * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that\n * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.\n * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild\n * `index.json`, so `staged/` holds only branches that are still in flight.\n *\n * Never throws: the PR is already merged by the time we get here, and failing the command after a\n * successful merge would report a landed PR as a failure. Problems are reported in the recap.\n */\n private archiveAndPromote(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n if (retention === BRANCH_RETENTION_KEEP) return ' Branch retention is \"keep\" — nothing archived.\\n';\n\n let line = '';\n let tag = '';\n if (retention === BRANCH_RETENTION_ARCHIVE_TAG) {\n const archive = this.archiver.archive(repoRoot, base);\n tag = archive.tag;\n line = archive.ok\n ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\\n`\n : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\\n`;\n }\n\n const feature = this.aiBranchName.getFeatureName();\n const record = new ArchiveRecord(\n tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'),\n Number(ref.number), new Date().toISOString(),\n );\n if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {\n line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\\n`;\n }\n return line;\n }\n\n // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.\n private revParse(repoRoot: string, ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n /**\n * Everything landing needs about the PR, in ONE `gh` read: number, title, URL, DESCRIPTION and the\n * head sha GitHub is squashing. `null` when there is no open PR for this head branch.\n *\n * The TITLE comes from the PR itself, not review.json, so the squash subject matches what a reviewer\n * approved even if review.json was edited afterwards. The BODY is the gated commit body (see the\n * class doc). `headRefOid` is read BEFORE the merge, because it is what the bookkeeping check\n * compares against and the merge is what makes the branch's fate uninteresting to GitHub.\n */\n private readPr(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title,url,body,headRefOid'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n return this.parsePr(result.stdout ?? '');\n }\n\n // `gh --json` output → PrIdentity. Malformed/absent JSON reads as \"no PR\", which the caller turns\n // into the same refusal a missing PR gets: both mean nothing here can be landed.\n private parsePr(stdout: string): PrIdentity | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: unparseable gh output means \"no PR\", never a crash\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-any-unknown -- gh's JSON is opaque until narrowed field-by-field below\n const raw = JSON.parse(stdout) as Record<string, unknown>;\n const number = typeof raw['number'] === 'number' ? String(raw['number']) : this.str(raw['number']);\n if (number === '') return null;\n // GitHub stores descriptions with CRLF. A commit body must not carry them, and the bytes are\n // otherwise identical to what finish rendered, so the line endings are normalized back here.\n const body = this.str(raw['body']).replace(/\\r\\n/g, '\\n').trim();\n return new PrIdentity(\n number, this.str(raw['title']), this.str(raw['url']), body === '' ? '' : body + '\\n',\n this.str(raw['headRefOid']),\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n // webpieces-disable no-any-unknown -- one opaque JSON field, narrowed to string\n private str(value: unknown): string {\n return typeof value === 'string' ? value : '';\n }\n}\n\n// The open PR as GitHub holds it: its number, title, web URL, DESCRIPTION (= the gated commit body) and\n// the head commit being squashed.\nclass PrIdentity {\n number: string;\n title: string;\n url: string;\n body: string;\n headRefOid: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(number: string, title: string, url: string, body: string, headRefOid: string) {\n this.number = number;\n this.title = title;\n this.url = url;\n this.body = body;\n this.headRefOid = headRefOid;\n }\n}\n"]}