@webpieces/pr-gate 0.4.722 → 0.4.724

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/pr-gate",
3
- "version": "0.4.722",
3
+ "version": "0.4.724",
4
4
  "description": "Gated PR system: 3-point squash-merge, merge validation gate, and red/yellow/green PR dashboard. Standalone scripts, no Nx dependency required.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -16,8 +16,8 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@inversifyjs/binding-decorators": "1.1.5",
19
- "@webpieces/ai-hook-rules": "0.4.722",
20
- "@webpieces/rules-config": "0.4.722",
19
+ "@webpieces/ai-hook-rules": "0.4.724",
20
+ "@webpieces/rules-config": "0.4.724",
21
21
  "inversify": "7.10.4",
22
22
  "reflect-metadata": "0.2.2"
23
23
  },
@@ -1,6 +1,7 @@
1
1
  import { RepoRootFinder, BranchArchiver } from '@webpieces/rules-config';
2
2
  import { AiBranchName } from '../workflow/git-readAiBranchName';
3
3
  import { BranchNaming } from '../workflow/branch-naming';
4
+ import { LandedTreeResolver } from '../workflow/landed-tree-resolver';
4
5
  import { LandedWorktreeReaper } from '../workflow/landed-worktree-reaper';
5
6
  import { MergeBodyTempFile } from '../workflow/merge-body-temp-file';
6
7
  import { MergeInfoIndex } from '../workflow/merge-info-index';
@@ -34,9 +35,26 @@ import { PrMerger } from '../workflow/pr-merger';
34
35
  * and reaping the landed worktree. That half belongs to the tree whose `<branch>` really is the
35
36
  * commit being squashed, and it must NOT be attempted from anywhere else: another clone's
36
37
  * `<branch>` is a different commit, so archiving it there would tag the wrong objects under the
37
- * right name. {@link LandPrCommand.bookkeeping} tests that by comparing this tree's `<branch>`
38
- * against the PR's own `headRefOid`, and when they disagree the merge still happens and the
39
- * bookkeeping is SKIPPED OUT LOUD.
38
+ * right name.
39
+ *
40
+ * ─── WHICH tree that is, is a FACT — it is not "the one I am standing in" ──────────────────────────
41
+ * That distinction used to be resolved from `process.cwd()`, and it was wrong twice over.
42
+ *
43
+ * It was wrong MECHANICALLY: `pnpm` hoists a bin's cwd to the workspace root, and a Claude Code agent
44
+ * worktree lives at `<primary>/.claude/worktrees/agent-<id>` — INSIDE the primary clone — so pnpm walked
45
+ * straight past it and `git branch --show-current` answered `main`. Landing a worktree PR, i.e. every
46
+ * `/full-cycle` run, reported "No open PR found for this branch" for a PR that was open, and the #512
47
+ * worktree reap was unreachable dead code. The invocation directory is now read from `INIT_CWD` (pnpm
48
+ * exports the directory the human actually typed in) and given to BOTH the repo-root resolution and the
49
+ * `git` call, so neither can be answered by the hoisted directory.
50
+ *
51
+ * And it was wrong in PRINCIPLE, which is the larger half: most of the time the `/full-cycle` subagent
52
+ * lands its own PR, but many times it does not — CI was still running when it finished, it errored, or a
53
+ * coordinator picks the work up an hour later, by which point that agent is gone and its worktree is a
54
+ * directory nobody is standing in. So `--pr <n>` names the PR, and {@link LandedTreeResolver} finds its
55
+ * tree by the pair `(headRefName, headRefOid)` — never by branch name alone. The reap target is the
56
+ * worktree whose HEAD is the exact commit GitHub squashed, whichever directory the operator is in, and
57
+ * when nothing local holds that commit the merge still happens and the bookkeeping is SKIPPED OUT LOUD.
40
58
  */
41
59
  export declare class LandPrCommand {
42
60
  private readonly repoRootFinder;
@@ -47,8 +65,36 @@ export declare class LandPrCommand {
47
65
  private readonly mergeInfoIndex;
48
66
  private readonly landedWorktree;
49
67
  private readonly bodyFile;
50
- constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, prMerger: PrMerger, archiver: BranchArchiver, mergeInfoIndex: MergeInfoIndex, landedWorktree: LandedWorktreeReaper, bodyFile: MergeBodyTempFile);
51
- run(): Promise<void>;
68
+ private readonly landedTree;
69
+ constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, prMerger: PrMerger, archiver: BranchArchiver, mergeInfoIndex: MergeInfoIndex, landedWorktree: LandedWorktreeReaper, bodyFile: MergeBodyTempFile, landedTree: LandedTreeResolver);
70
+ /**
71
+ * THE INVOCATION DIRECTORY, which is not `process.cwd()`.
72
+ *
73
+ * `pnpm` runs a workspace bin with its cwd hoisted to the workspace root and exports the directory
74
+ * the operator was actually in as `INIT_CWD`. Every agent worktree is NESTED inside the primary
75
+ * clone, so the hoist silently relocated this command into a different tree holding a different
76
+ * branch — see the class doc. `process.cwd()` remains the answer when nothing set `INIT_CWD` (a
77
+ * direct `node` invocation, or a spec), which is the case where the two are the same anyway.
78
+ */
79
+ private invocationCwd;
80
+ run(request: LandPrRequest): Promise<void>;
81
+ /**
82
+ * `--pr` without a usable number. It REFUSES rather than falling back to the branch, because the
83
+ * fallback would land a different PR from the one the operator was reaching for, and a squash into
84
+ * main is not a place to guess. `--pr` bare and `--pr HEAD` land here alike.
85
+ */
86
+ private badPrNumber;
87
+ /**
88
+ * The refusal when `gh` has no open PR for what we asked about — and it must say WHICH question was
89
+ * asked, because the two have different cures.
90
+ *
91
+ * The branch form used to be the only one, and it was the face of the cwd bug: run from an agent
92
+ * worktree, pnpm's hoist made the branch read `main`, and an operator with an open PR in front of
93
+ * them was told there was none. So the branch form now says what it looked up and where it read that
94
+ * branch FROM, which is the one fact that makes a wrong answer recognisable as a wrong answer, and it
95
+ * names the `--pr <n>` form that does not depend on the tree at all.
96
+ */
97
+ private noSuchPr;
52
98
  /**
53
99
  * The refusal when the PR exists but its description is empty.
54
100
  *
@@ -114,7 +160,12 @@ export declare class LandPrCommand {
114
160
  * archive it, and a tree that has committed further work since finish ran correctly declines.
115
161
  */
116
162
  private bookkeeping;
117
- /** What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was. */
163
+ /**
164
+ * What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was.
165
+ *
166
+ * Two causes, one message, because the cure is the same for both: the objects the PR squashed are not
167
+ * in this repo under that name, so nothing here may archive them or reap a tree for them.
168
+ */
118
169
  private notTheLandedTipNotice;
119
170
  /**
120
171
  * What happens next — which is NOT the same act when you landed from a worktree.
@@ -160,3 +211,21 @@ export declare class LandPrCommand {
160
211
  private parsePr;
161
212
  private str;
162
213
  }
214
+ /**
215
+ * Data-only (per CLAUDE.md, classes for data): what the operator asked `wp-land-pr` to land.
216
+ *
217
+ * `prNumber` is '' for the zero-arg form — "this branch's PR" — which stays the shorthand the worker
218
+ * uses. It is NOT an optional constructor parameter with a default at the call site: `wp-land-pr.ts`
219
+ * builds exactly one of these from the parsed argv, so there is one spelling of the decision.
220
+ */
221
+ export declare class LandPrRequest {
222
+ /**
223
+ * Was `--pr` on the command line at all? Kept SEPARATE from the value because `--pr` with no number
224
+ * is a mistake, not a request to guess: collapsing the two would silently land whatever PR the
225
+ * current directory's branch happens to have, which is the opposite of what the operator typed.
226
+ */
227
+ prFlagPresent: boolean;
228
+ /** The `--pr <n>` value verbatim, or '' to infer the PR from the invocation directory's branch. */
229
+ prNumber: string;
230
+ constructor(prFlagPresent: boolean, prNumber: string);
231
+ }
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LandPrCommand = void 0;
3
+ exports.LandPrRequest = exports.LandPrCommand = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
6
  const rules_config_1 = require("@webpieces/rules-config");
7
7
  const inversify_1 = require("inversify");
8
8
  const git_readAiBranchName_1 = require("../workflow/git-readAiBranchName");
9
9
  const branch_naming_1 = require("../workflow/branch-naming");
10
+ const landed_tree_resolver_1 = require("../workflow/landed-tree-resolver");
10
11
  const landed_worktree_reaper_1 = require("../workflow/landed-worktree-reaper");
11
12
  const merge_body_temp_file_1 = require("../workflow/merge-body-temp-file");
12
13
  const merge_info_index_1 = require("../workflow/merge-info-index");
@@ -41,9 +42,26 @@ const SEP = '━━━━━━━━━━━━━━━━━━━━━━
41
42
  * and reaping the landed worktree. That half belongs to the tree whose `<branch>` really is the
42
43
  * commit being squashed, and it must NOT be attempted from anywhere else: another clone's
43
44
  * `<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.
45
+ * right name.
46
+ *
47
+ * ─── WHICH tree that is, is a FACT — it is not "the one I am standing in" ──────────────────────────
48
+ * That distinction used to be resolved from `process.cwd()`, and it was wrong twice over.
49
+ *
50
+ * It was wrong MECHANICALLY: `pnpm` hoists a bin's cwd to the workspace root, and a Claude Code agent
51
+ * worktree lives at `<primary>/.claude/worktrees/agent-<id>` — INSIDE the primary clone — so pnpm walked
52
+ * straight past it and `git branch --show-current` answered `main`. Landing a worktree PR, i.e. every
53
+ * `/full-cycle` run, reported "No open PR found for this branch" for a PR that was open, and the #512
54
+ * worktree reap was unreachable dead code. The invocation directory is now read from `INIT_CWD` (pnpm
55
+ * exports the directory the human actually typed in) and given to BOTH the repo-root resolution and the
56
+ * `git` call, so neither can be answered by the hoisted directory.
57
+ *
58
+ * And it was wrong in PRINCIPLE, which is the larger half: most of the time the `/full-cycle` subagent
59
+ * lands its own PR, but many times it does not — CI was still running when it finished, it errored, or a
60
+ * coordinator picks the work up an hour later, by which point that agent is gone and its worktree is a
61
+ * directory nobody is standing in. So `--pr <n>` names the PR, and {@link LandedTreeResolver} finds its
62
+ * tree by the pair `(headRefName, headRefOid)` — never by branch name alone. The reap target is the
63
+ * worktree whose HEAD is the exact commit GitHub squashed, whichever directory the operator is in, and
64
+ * when nothing local holds that commit the merge still happens and the bookkeeping is SKIPPED OUT LOUD.
47
65
  */
48
66
  let LandPrCommand = class LandPrCommand {
49
67
  repoRootFinder;
@@ -54,8 +72,9 @@ let LandPrCommand = class LandPrCommand {
54
72
  mergeInfoIndex;
55
73
  landedWorktree;
56
74
  bodyFile;
75
+ landedTree;
57
76
  // eslint-disable-next-line @typescript-eslint/max-params
58
- constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex, landedWorktree, bodyFile) {
77
+ constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex, landedWorktree, bodyFile, landedTree) {
59
78
  this.repoRootFinder = repoRootFinder;
60
79
  this.aiBranchName = aiBranchName;
61
80
  this.branchNaming = branchNaming;
@@ -64,19 +83,38 @@ let LandPrCommand = class LandPrCommand {
64
83
  this.mergeInfoIndex = mergeInfoIndex;
65
84
  this.landedWorktree = landedWorktree;
66
85
  this.bodyFile = bodyFile;
86
+ this.landedTree = landedTree;
67
87
  }
68
- async run() {
69
- const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
70
- const base = this.branchNaming.baseBranchName((0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim());
71
- const ref = this.readPr(base);
72
- if (ref === null) {
73
- throw new rules_config_1.InformAiError('\n' + SEP + '❌ No open PR found for this branch\n' + SEP + '\n' +
74
- `No open PR has head branch "${base}". Nothing to land.\n` +
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);
79
- }
88
+ /**
89
+ * THE INVOCATION DIRECTORY, which is not `process.cwd()`.
90
+ *
91
+ * `pnpm` runs a workspace bin with its cwd hoisted to the workspace root and exports the directory
92
+ * the operator was actually in as `INIT_CWD`. Every agent worktree is NESTED inside the primary
93
+ * clone, so the hoist silently relocated this command into a different tree holding a different
94
+ * branch see the class doc. `process.cwd()` remains the answer when nothing set `INIT_CWD` (a
95
+ * direct `node` invocation, or a spec), which is the case where the two are the same anyway.
96
+ */
97
+ invocationCwd() {
98
+ const init = process.env['INIT_CWD'] ?? '';
99
+ return init !== '' ? init : process.cwd();
100
+ }
101
+ async run(request) {
102
+ if (request.prFlagPresent && !/^\d+$/.test(request.prNumber))
103
+ throw this.badPrNumber(request);
104
+ const cwd = this.invocationCwd();
105
+ const repoRoot = this.repoRootFinder.resolveRepoRoot(cwd);
106
+ // `--pr <n>` selects the PR outright; with no flag it is this branch's, read from the tree the
107
+ // operator is really standing in.
108
+ const selector = request.prNumber !== ''
109
+ ? request.prNumber
110
+ : this.branchNaming.baseBranchName((0, child_process_1.execSync)('git branch --show-current', { cwd, encoding: 'utf8' }).trim());
111
+ const ref = this.readPr(selector);
112
+ if (ref === null)
113
+ throw this.noSuchPr(request, selector);
114
+ // The PR's OWN head branch, never the selector: with `--pr <n>` the operator named a number and
115
+ // has said nothing about branches, and even in the zero-arg case GitHub is the authority on which
116
+ // branch that PR is merging.
117
+ const base = ref.headRefName !== '' ? ref.headRefName : selector;
80
118
  if (ref.body === '')
81
119
  throw this.emptyDescription(ref);
82
120
  const unfit = this.notFitForGitLog(ref.body);
@@ -96,7 +134,7 @@ let LandPrCommand = class LandPrCommand {
96
134
  // about `mergeMode: AUTO` two lines above this command printing `mergeMode is NONE`.
97
135
  const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, new pr_merger_1.MergeIntent(policy ?? '', true));
98
136
  const bookkeeping = outcome.merged
99
- ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention)
137
+ ? this.bookkeeping(repoRoot, base, ref, config.landPr.branchRetention, cwd)
100
138
  : '';
101
139
  process.stdout.write('\n' + SEP + (outcome.merged ? '✅ Landed\n' : 'ℹ️ Not landed yet\n') + SEP + '\n' +
102
140
  ` ${outcome.message}\n` +
@@ -106,6 +144,46 @@ let LandPrCommand = class LandPrCommand {
106
144
  : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\n`) +
107
145
  '\n');
108
146
  }
147
+ /**
148
+ * `--pr` without a usable number. It REFUSES rather than falling back to the branch, because the
149
+ * fallback would land a different PR from the one the operator was reaching for, and a squash into
150
+ * main is not a place to guess. `--pr` bare and `--pr HEAD` land here alike.
151
+ */
152
+ badPrNumber(request) {
153
+ const got = request.prNumber === '' ? 'nothing' : `"${request.prNumber}"`;
154
+ return new rules_config_1.InformAiError('\n' + SEP + '❌ --pr needs a PR NUMBER\n' + SEP + '\n' +
155
+ `\`--pr\` was given ${got}. It takes the digits of one pull request:\n` +
156
+ ' pnpm wp-land-pr --pr 1087\n\n' +
157
+ 'To land the PR of the branch you are standing on, pass no flag at all:\n' +
158
+ ' pnpm wp-land-pr\n' + SEP);
159
+ }
160
+ /**
161
+ * The refusal when `gh` has no open PR for what we asked about — and it must say WHICH question was
162
+ * asked, because the two have different cures.
163
+ *
164
+ * The branch form used to be the only one, and it was the face of the cwd bug: run from an agent
165
+ * worktree, pnpm's hoist made the branch read `main`, and an operator with an open PR in front of
166
+ * them was told there was none. So the branch form now says what it looked up and where it read that
167
+ * branch FROM, which is the one fact that makes a wrong answer recognisable as a wrong answer, and it
168
+ * names the `--pr <n>` form that does not depend on the tree at all.
169
+ */
170
+ noSuchPr(request, selector) {
171
+ if (request.prNumber !== '') {
172
+ return new rules_config_1.InformAiError('\n' + SEP + `❌ No open PR #${request.prNumber}\n` + SEP + '\n' +
173
+ `\`gh pr view ${request.prNumber}\` found no OPEN pull request with that number.\n` +
174
+ 'If it is already merged, run `pnpm wp-cleanup` from the primary clone to finish the\n' +
175
+ 'branch and worktree bookkeeping. Otherwise check the number with `gh pr list`.\n' + SEP);
176
+ }
177
+ return new rules_config_1.InformAiError('\n' + SEP + '❌ No open PR found for this branch\n' + SEP + '\n' +
178
+ `No open PR has head branch "${selector}", read from ${this.invocationCwd()}. Nothing to land.\n` +
179
+ 'If that is not the branch you expected, land it by number instead — it does not depend on\n' +
180
+ 'which directory you are standing in:\n' +
181
+ ' pnpm wp-land-pr --pr <n>\n' +
182
+ 'If the PR is already merged, run `pnpm wp-cleanup`.\n' +
183
+ 'If it was never posted, run the gated flow — it posts the PR AND writes the description\n' +
184
+ 'that becomes this commit body:\n' +
185
+ ' pnpm wp-start-upsert-pr && pnpm wp-review-upsert-pr && pnpm wp-finish-upsert-pr\n' + SEP);
186
+ }
109
187
  /**
110
188
  * The refusal when the PR exists but its description is empty.
111
189
  *
@@ -203,22 +281,30 @@ let LandPrCommand = class LandPrCommand {
203
281
  * it is more precise in both directions — a second clone sitting on the SAME commit can safely
204
282
  * archive it, and a tree that has committed further work since finish ran correctly declines.
205
283
  */
206
- bookkeeping(repoRoot, base, ref, retention) {
207
- const local = this.revParse(repoRoot, base);
208
- if (ref.headRefOid !== '' && local !== '' && local !== ref.headRefOid) {
209
- return this.notTheLandedTipNotice(base, local, ref);
210
- }
211
- return this.archiveAndPromote(repoRoot, base, ref, retention) + this.nextStep(repoRoot, base);
284
+ bookkeeping(repoRoot, base, ref, retention, cwd) {
285
+ const tree = this.landedTree.resolve(repoRoot, base, ref.headRefOid);
286
+ if (!tree.bookkeepingAllowed)
287
+ return this.notTheLandedTipNotice(base, tree, ref);
288
+ return this.archiveAndPromote(repoRoot, base, ref, retention)
289
+ + this.nextStep(repoRoot, tree, cwd);
212
290
  }
213
- /** What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was. */
214
- notTheLandedTipNotice(base, local, ref) {
215
- return '\n ⚠️ Archive + worktree cleanup SKIPPED — this tree\'s branch is not the commit that landed:\n' +
216
- ` ${base} here ${local}\n` +
291
+ /**
292
+ * What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was.
293
+ *
294
+ * Two causes, one message, because the cure is the same for both: the objects the PR squashed are not
295
+ * in this repo under that name, so nothing here may archive them or reap a tree for them.
296
+ */
297
+ notTheLandedTipNotice(base, tree, ref) {
298
+ const found = tree.kind === landed_tree_resolver_1.LANDED_TREE_ABSENT
299
+ ? ` ${base} is not in this repo at all\n`
300
+ : ` ${base} here → ${tree.localSha}\n`;
301
+ return '\n ⚠️ Archive + worktree cleanup SKIPPED — nothing here holds the commit that landed:\n' +
302
+ found +
217
303
  ` PR #${ref.number} squashed → ${ref.headRefOid}\n` +
218
304
  ' Archiving from here would tag the wrong objects under the right name, and the\n' +
219
305
  ` merge-info record and (if any) the worktree holding ${base} live with the other tip.\n` +
220
- ' Either this is a second clone of the repo — finish the bookkeeping in the tree that\n' +
221
- ' posted the PR with `pnpm wp-cleanup` — or this tree has commits made after\n' +
306
+ ' Either this is a second clone of the repo — finish the bookkeeping in the clone that\n' +
307
+ ' posted the PR with `pnpm wp-cleanup` — or that tree has commits made after\n' +
222
308
  ' `pnpm wp-finish-upsert-pr` ran, which the PR does not contain.\n';
223
309
  }
224
310
  /**
@@ -237,8 +323,8 @@ let LandPrCommand = class LandPrCommand {
237
323
  * hand-off is not safely achievable the #512 notice is printed unchanged, because an honest
238
324
  * limitation beats a command that deletes its own working directory mid-run.
239
325
  */
240
- nextStep(repoRoot, base) {
241
- const handoff = this.landedWorktree.plan(repoRoot, base);
326
+ nextStep(repoRoot, tree, cwd) {
327
+ const handoff = this.landedWorktree.plan(repoRoot, tree.worktree, cwd);
242
328
  if (handoff === null)
243
329
  return ' Next: `pnpm wp-cleanup` to delete the merged branch.\n';
244
330
  if (!handoff.canReap)
@@ -290,8 +376,8 @@ let LandPrCommand = class LandPrCommand {
290
376
  * class doc). `headRefOid` is read BEFORE the merge, because it is what the bookkeeping check
291
377
  * compares against and the merge is what makes the branch's fate uninteresting to GitHub.
292
378
  */
293
- readPr(baseBranch) {
294
- const result = (0, child_process_1.spawnSync)('gh', ['pr', 'view', baseBranch, '--json', 'number,title,url,body,headRefOid'], { encoding: 'utf8' });
379
+ readPr(selector) {
380
+ const result = (0, child_process_1.spawnSync)('gh', ['pr', 'view', selector, '--json', 'number,title,url,body,headRefOid,headRefName'], { encoding: 'utf8' });
295
381
  if (result.status !== 0)
296
382
  return null;
297
383
  return this.parsePr(result.stdout ?? '');
@@ -310,7 +396,7 @@ let LandPrCommand = class LandPrCommand {
310
396
  // GitHub stores descriptions with CRLF. A commit body must not carry them, and the bytes are
311
397
  // otherwise identical to what finish rendered, so the line endings are normalized back here.
312
398
  const body = this.str(raw['body']).replace(/\r\n/g, '\n').trim();
313
- return new PrIdentity(number, this.str(raw['title']), this.str(raw['url']), body === '' ? '' : body + '\n', this.str(raw['headRefOid']));
399
+ return new PrIdentity(number, this.str(raw['title']), this.str(raw['url']), body === '' ? '' : body + '\n', this.str(raw['headRefOid']), this.str(raw['headRefName']));
314
400
  }
315
401
  catch (err) {
316
402
  const error = (0, rules_config_1.toError)(err);
@@ -333,23 +419,53 @@ exports.LandPrCommand = LandPrCommand = tslib_1.__decorate([
333
419
  rules_config_1.BranchArchiver,
334
420
  merge_info_index_1.MergeInfoIndex,
335
421
  landed_worktree_reaper_1.LandedWorktreeReaper,
336
- merge_body_temp_file_1.MergeBodyTempFile])
422
+ merge_body_temp_file_1.MergeBodyTempFile,
423
+ landed_tree_resolver_1.LandedTreeResolver])
337
424
  ], LandPrCommand);
338
425
  // The open PR as GitHub holds it: its number, title, web URL, DESCRIPTION (= the gated commit body) and
339
- // the head commit being squashed.
426
+ // the PAIR that identifies its tree locally — the head branch's name and the commit being squashed.
340
427
  class PrIdentity {
341
428
  number;
342
429
  title;
343
430
  url;
344
431
  body;
345
432
  headRefOid;
433
+ /**
434
+ * The PR's head BRANCH. Required alongside `headRefOid` because neither half identifies a tree on its
435
+ * own: the name is what a worktree list can be searched by, the sha is what makes a hit the right
436
+ * one. It also frees `--pr <n>` from needing the operator to be standing anywhere in particular.
437
+ */
438
+ headRefName;
346
439
  // eslint-disable-next-line @typescript-eslint/max-params
347
- constructor(number, title, url, body, headRefOid) {
440
+ constructor(number, title, url, body, headRefOid, headRefName) {
348
441
  this.number = number;
349
442
  this.title = title;
350
443
  this.url = url;
351
444
  this.body = body;
352
445
  this.headRefOid = headRefOid;
446
+ this.headRefName = headRefName;
447
+ }
448
+ }
449
+ /**
450
+ * Data-only (per CLAUDE.md, classes for data): what the operator asked `wp-land-pr` to land.
451
+ *
452
+ * `prNumber` is '' for the zero-arg form — "this branch's PR" — which stays the shorthand the worker
453
+ * uses. It is NOT an optional constructor parameter with a default at the call site: `wp-land-pr.ts`
454
+ * builds exactly one of these from the parsed argv, so there is one spelling of the decision.
455
+ */
456
+ class LandPrRequest {
457
+ /**
458
+ * Was `--pr` on the command line at all? Kept SEPARATE from the value because `--pr` with no number
459
+ * is a mistake, not a request to guess: collapsing the two would silently land whatever PR the
460
+ * current directory's branch happens to have, which is the opposite of what the operator typed.
461
+ */
462
+ prFlagPresent;
463
+ /** The `--pr <n>` value verbatim, or '' to infer the PR from the invocation directory's branch. */
464
+ prNumber;
465
+ constructor(prFlagPresent, prNumber) {
466
+ this.prFlagPresent = prFlagPresent;
467
+ this.prNumber = prNumber;
353
468
  }
354
469
  }
470
+ exports.LandPrRequest = LandPrRequest;
355
471
  //# 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 {\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 * What it must NOT be read as is proof that the bytes did not come from `renderPrBody`. They can:\n * the compact body interpolates author text, and a `|` reaches that text from a TypeScript union, a\n * regex alternation or a quoted shell pipeline. `Dashboard.gitLogSafe` substitutes both markers at\n * the render exit so a freshly-rendered body no longer trips this — which is where that invariant\n * belongs, since the renderer is the only thing that can fix it without an extra CI cycle.\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.\n *\n * It names BOTH ways bytes get here, because naming only one sent readers down a path that did not\n * exist. It used to assert an old release as \"the usual cause\" and prescribe re-running finish — and\n * when the marker had come from AUTHOR TEXT instead (a `|` in a summary, from a TypeScript union or a\n * regex alternation), finish re-rendered the identical character from the unchanged `review.json` and\n * landing refused again: a loop costing a CI cycle per turn, escapable only by guessing that one\n * character in your prose was the problem. `Dashboard.gitLogSafe` now substitutes both markers at the\n * render exit, so a freshly-rendered body cannot reach here at all — which leaves a HAND-EDITED\n * description and a genuinely old PR as the two remaining causes, and this says so.\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 — it substitutes both markers as it renders — so these bytes are not the gated summary,\\n' +\n 'and landing them would put a PR Gate Dashboard (or hand-written markdown) into main\\'s\\n' +\n 'history permanently.\\n\\n' +\n 'There are two causes, and they need different fixes:\\n\\n' +\n ' 1. The description was EDITED BY HAND on GitHub after finish posted it. Re-running finish\\n' +\n ' overwrites that edit with the compact body, so landing works.\\n' +\n ' 2. The PR was posted by a webpieces release OLDER than the one that made the description\\n' +\n ' the commit body, so it is still the full dashboard. The dashboard now lives in the PR\\'s\\n' +\n ' 1st comment instead, and re-running finish moves it there.\\n\\n' +\n `Either way the cure is the same, and it does NOT require removing ${marker.trim()} from your review\\n` +\n 'text — a pipe or a heading in a summary is rendered safe, not rejected:\\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"]}
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 { LandedTree, LandedTreeResolver, LANDED_TREE_ABSENT } from '../workflow/landed-tree-resolver';\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.\n *\n * ─── WHICH tree that is, is a FACT — it is not \"the one I am standing in\" ──────────────────────────\n * That distinction used to be resolved from `process.cwd()`, and it was wrong twice over.\n *\n * It was wrong MECHANICALLY: `pnpm` hoists a bin's cwd to the workspace root, and a Claude Code agent\n * worktree lives at `<primary>/.claude/worktrees/agent-<id>` — INSIDE the primary clone — so pnpm walked\n * straight past it and `git branch --show-current` answered `main`. Landing a worktree PR, i.e. every\n * `/full-cycle` run, reported \"No open PR found for this branch\" for a PR that was open, and the #512\n * worktree reap was unreachable dead code. The invocation directory is now read from `INIT_CWD` (pnpm\n * exports the directory the human actually typed in) and given to BOTH the repo-root resolution and the\n * `git` call, so neither can be answered by the hoisted directory.\n *\n * And it was wrong in PRINCIPLE, which is the larger half: most of the time the `/full-cycle` subagent\n * lands its own PR, but many times it does not — CI was still running when it finished, it errored, or a\n * coordinator picks the work up an hour later, by which point that agent is gone and its worktree is a\n * directory nobody is standing in. So `--pr <n>` names the PR, and {@link LandedTreeResolver} finds its\n * tree by the pair `(headRefName, headRefOid)` — never by branch name alone. The reap target is the\n * worktree whose HEAD is the exact commit GitHub squashed, whichever directory the operator is in, and\n * when nothing local holds that commit the merge still happens and the 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 private readonly landedTree: LandedTreeResolver,\n ) {}\n\n /**\n * THE INVOCATION DIRECTORY, which is not `process.cwd()`.\n *\n * `pnpm` runs a workspace bin with its cwd hoisted to the workspace root and exports the directory\n * the operator was actually in as `INIT_CWD`. Every agent worktree is NESTED inside the primary\n * clone, so the hoist silently relocated this command into a different tree holding a different\n * branch — see the class doc. `process.cwd()` remains the answer when nothing set `INIT_CWD` (a\n * direct `node` invocation, or a spec), which is the case where the two are the same anyway.\n */\n private invocationCwd(): string {\n const init = process.env['INIT_CWD'] ?? '';\n return init !== '' ? init : process.cwd();\n }\n\n async run(request: LandPrRequest): Promise<void> {\n if (request.prFlagPresent && !/^\\d+$/.test(request.prNumber)) throw this.badPrNumber(request);\n const cwd = this.invocationCwd();\n const repoRoot = this.repoRootFinder.resolveRepoRoot(cwd);\n // `--pr <n>` selects the PR outright; with no flag it is this branch's, read from the tree the\n // operator is really standing in.\n const selector = request.prNumber !== ''\n ? request.prNumber\n : this.branchNaming.baseBranchName(\n execSync('git branch --show-current', { cwd, encoding: 'utf8' }).trim());\n\n const ref = this.readPr(selector);\n if (ref === null) throw this.noSuchPr(request, selector);\n // The PR's OWN head branch, never the selector: with `--pr <n>` the operator named a number and\n // has said nothing about branches, and even in the zero-arg case GitHub is the authority on which\n // branch that PR is merging.\n const base = ref.headRefName !== '' ? ref.headRefName : selector;\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, cwd)\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 * `--pr` without a usable number. It REFUSES rather than falling back to the branch, because the\n * fallback would land a different PR from the one the operator was reaching for, and a squash into\n * main is not a place to guess. `--pr` bare and `--pr HEAD` land here alike.\n */\n private badPrNumber(request: LandPrRequest): InformAiError {\n const got = request.prNumber === '' ? 'nothing' : `\"${request.prNumber}\"`;\n return new InformAiError(\n '\\n' + SEP + '❌ --pr needs a PR NUMBER\\n' + SEP + '\\n' +\n `\\`--pr\\` was given ${got}. It takes the digits of one pull request:\\n` +\n ' pnpm wp-land-pr --pr 1087\\n\\n' +\n 'To land the PR of the branch you are standing on, pass no flag at all:\\n' +\n ' pnpm wp-land-pr\\n' + SEP);\n }\n\n /**\n * The refusal when `gh` has no open PR for what we asked about — and it must say WHICH question was\n * asked, because the two have different cures.\n *\n * The branch form used to be the only one, and it was the face of the cwd bug: run from an agent\n * worktree, pnpm's hoist made the branch read `main`, and an operator with an open PR in front of\n * them was told there was none. So the branch form now says what it looked up and where it read that\n * branch FROM, which is the one fact that makes a wrong answer recognisable as a wrong answer, and it\n * names the `--pr <n>` form that does not depend on the tree at all.\n */\n private noSuchPr(request: LandPrRequest, selector: string): InformAiError {\n if (request.prNumber !== '') {\n return new InformAiError(\n '\\n' + SEP + `❌ No open PR #${request.prNumber}\\n` + SEP + '\\n' +\n `\\`gh pr view ${request.prNumber}\\` found no OPEN pull request with that number.\\n` +\n 'If it is already merged, run `pnpm wp-cleanup` from the primary clone to finish the\\n' +\n 'branch and worktree bookkeeping. Otherwise check the number with `gh pr list`.\\n' + SEP);\n }\n return new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${selector}\", read from ${this.invocationCwd()}. Nothing to land.\\n` +\n 'If that is not the branch you expected, land it by number instead — it does not depend on\\n' +\n 'which directory you are standing in:\\n' +\n ' pnpm wp-land-pr --pr <n>\\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 /**\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 * What it must NOT be read as is proof that the bytes did not come from `renderPrBody`. They can:\n * the compact body interpolates author text, and a `|` reaches that text from a TypeScript union, a\n * regex alternation or a quoted shell pipeline. `Dashboard.gitLogSafe` substitutes both markers at\n * the render exit so a freshly-rendered body no longer trips this — which is where that invariant\n * belongs, since the renderer is the only thing that can fix it without an extra CI cycle.\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.\n *\n * It names BOTH ways bytes get here, because naming only one sent readers down a path that did not\n * exist. It used to assert an old release as \"the usual cause\" and prescribe re-running finish — and\n * when the marker had come from AUTHOR TEXT instead (a `|` in a summary, from a TypeScript union or a\n * regex alternation), finish re-rendered the identical character from the unchanged `review.json` and\n * landing refused again: a loop costing a CI cycle per turn, escapable only by guessing that one\n * character in your prose was the problem. `Dashboard.gitLogSafe` now substitutes both markers at the\n * render exit, so a freshly-rendered body cannot reach here at all — which leaves a HAND-EDITED\n * description and a genuinely old PR as the two remaining causes, and this says so.\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 — it substitutes both markers as it renders — so these bytes are not the gated summary,\\n' +\n 'and landing them would put a PR Gate Dashboard (or hand-written markdown) into main\\'s\\n' +\n 'history permanently.\\n\\n' +\n 'There are two causes, and they need different fixes:\\n\\n' +\n ' 1. The description was EDITED BY HAND on GitHub after finish posted it. Re-running finish\\n' +\n ' overwrites that edit with the compact body, so landing works.\\n' +\n ' 2. The PR was posted by a webpieces release OLDER than the one that made the description\\n' +\n ' the commit body, so it is still the full dashboard. The dashboard now lives in the PR\\'s\\n' +\n ' 1st comment instead, and re-running finish moves it there.\\n\\n' +\n `Either way the cure is the same, and it does NOT require removing ${marker.trim()} from your review\\n` +\n 'text — a pipe or a heading in a summary is rendered safe, not rejected:\\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(\n repoRoot: string, base: string, ref: PrIdentity, retention: string, cwd: string,\n ): string {\n const tree = this.landedTree.resolve(repoRoot, base, ref.headRefOid);\n if (!tree.bookkeepingAllowed) return this.notTheLandedTipNotice(base, tree, ref);\n return this.archiveAndPromote(repoRoot, base, ref, retention)\n + this.nextStep(repoRoot, tree, cwd);\n }\n\n /**\n * What was skipped, why, and what the two SHAs are — so the reader can tell WHICH cause it was.\n *\n * Two causes, one message, because the cure is the same for both: the objects the PR squashed are not\n * in this repo under that name, so nothing here may archive them or reap a tree for them.\n */\n private notTheLandedTipNotice(base: string, tree: LandedTree, ref: PrIdentity): string {\n const found = tree.kind === LANDED_TREE_ABSENT\n ? ` ${base} is not in this repo at all\\n`\n : ` ${base} here → ${tree.localSha}\\n`;\n return '\\n ⚠️ Archive + worktree cleanup SKIPPED — nothing here holds the commit that landed:\\n' +\n found +\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 clone that\\n' +\n ' posted the PR with `pnpm wp-cleanup` — or that 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, tree: LandedTree, cwd: string): string {\n const handoff: WorktreeReapHandoff | null\n = this.landedWorktree.plan(repoRoot, tree.worktree, cwd);\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(selector: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', selector, '--json', 'number,title,url,body,headRefOid,headRefName'],\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']), this.str(raw['headRefName']),\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 PAIR that identifies its tree locally — the head branch's name and the commit being squashed.\nclass PrIdentity {\n number: string;\n title: string;\n url: string;\n body: string;\n headRefOid: string;\n /**\n * The PR's head BRANCH. Required alongside `headRefOid` because neither half identifies a tree on its\n * own: the name is what a worktree list can be searched by, the sha is what makes a hit the right\n * one. It also frees `--pr <n>` from needing the operator to be standing anywhere in particular.\n */\n headRefName: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n number: string, title: string, url: string, body: string, headRefOid: string, headRefName: string,\n ) {\n this.number = number;\n this.title = title;\n this.url = url;\n this.body = body;\n this.headRefOid = headRefOid;\n this.headRefName = headRefName;\n }\n}\n\n/**\n * Data-only (per CLAUDE.md, classes for data): what the operator asked `wp-land-pr` to land.\n *\n * `prNumber` is '' for the zero-arg form — \"this branch's PR\" — which stays the shorthand the worker\n * uses. It is NOT an optional constructor parameter with a default at the call site: `wp-land-pr.ts`\n * builds exactly one of these from the parsed argv, so there is one spelling of the decision.\n */\nexport class LandPrRequest {\n /**\n * Was `--pr` on the command line at all? Kept SEPARATE from the value because `--pr` with no number\n * is a mistake, not a request to guess: collapsing the two would silently land whatever PR the\n * current directory's branch happens to have, which is the opposite of what the operator typed.\n */\n prFlagPresent: boolean;\n /** The `--pr <n>` value verbatim, or '' to infer the PR from the invocation directory's branch. */\n prNumber: string;\n\n constructor(prFlagPresent: boolean, prNumber: string) {\n this.prFlagPresent = prFlagPresent;\n this.prNumber = prNumber;\n }\n}\n"]}