@henryqw/pi-pr 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,6 @@
1
1
  # `@henryqw/pi-pr`
2
2
 
3
- Show the current branch pull request in the Pi footer and use `/pr` to run its next safe step.
4
-
5
- ## Why
6
-
7
- - **Created for**: Check pull-request progress without repeated `gh` commands.
8
- - **Advantage**: See the current pull request and its next step in one place.
3
+ See the current branch pull request in the Pi footer. Use `/pr` to run its next safe step. It shows CI, review, merge, and lifecycle status without repeated `gh` commands.
9
4
 
10
5
  ## Install
11
6
 
@@ -17,24 +12,22 @@ Requires an authenticated GitHub CLI session (`gh auth login`) and a checkout on
17
12
 
18
13
  The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
19
14
 
20
- ## With
15
+ ## Works with
21
16
 
22
- [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) improves this package by showing its pull-request status in the footer.
17
+ **Improves.** [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) shows current-branch pull-request status in the footer.
23
18
 
24
19
  ## Use
25
20
 
26
- Run `/pr` in a GitHub checkout. Pi opens the current branch pull request when one exists.
27
-
28
- If none exists, Pi starts the bundled creation workflow instead.
21
+ Run `/pr` without arguments in a GitHub checkout. It reads the current branch pull request and local state, then runs one route.
29
22
 
30
23
  | Surface | Type | Purpose |
31
24
  | --- | --- | --- |
32
25
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
33
26
  | Widget hint | ui | Show at most one hint for the next `/pr` step. |
34
27
 
35
- Footer statuses include `N unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `base update required`, `merge conflict`, `merge-ready`, `merged`, and `closed`. Colors support the text; they do not carry meaning alone.
28
+ ## Flow
36
29
 
37
- Use `/pr` without arguments. It reads the current branch pull request and local state, then runs one route.
30
+ Each footer entry is one linked `PR #number` plus one plain-language status: `N unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `base update required`, `merge conflict`, `merge-ready`, `merged`, or `closed`. Colors support the text; they do not carry meaning alone.
38
31
 
39
32
  ![Flowchart showing /pr reading fresh GitHub and local state, choosing the first matching condition, and stopping after one route](./docs/pr-routing.svg)
40
33
 
@@ -43,7 +36,7 @@ Use `/pr` without arguments. It reads the current branch pull request and local
43
36
  | Current condition | `/pr` route |
44
37
  | --- | --- |
45
38
  | No current-branch pull request, including no upstream push target | Start pull-request creation. |
46
- | Base update required or merge conflict | Update from the exact base when the tree is clean and local HEAD equals the PR head. |
39
+ | Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
47
40
  | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
48
41
  | CI failed | Run the CI fix workflow when the same local prerequisite holds. |
49
42
  | No-action state | Report the state without taking action. |
@@ -70,7 +63,11 @@ Ordinary conversation comments do not trigger a route or block a merge. Changes
70
63
 
71
64
  ## Refresh
72
65
 
73
- The footer and widget load at session start. They refresh after local commits, PR creation, and pushes, and poll every 30 seconds. Polling updates presentation only and may be stale. The create hint stays hidden until the local branch has a commit beyond its creation point. Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
66
+ The footer and widget load at session start. They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. They also poll every 30 seconds. Polling updates presentation only and may be stale.
67
+
68
+ The create hint stays hidden until the local branch has a commit beyond its creation point. Any displayed hint clears as soon as `/pr` starts. A dispatched workflow keeps it hidden until the agent settles. A direct merge, no-action route, or failed command refreshes the hint when the handler finishes.
69
+
70
+ Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
74
71
 
75
72
  ## Safety limits
76
73
 
@@ -85,6 +82,7 @@ The footer and widget load at session start. They refresh after local commits, P
85
82
  - Applicable ruleset restrictions intersect repository-wide merge methods. An empty intersection stops the workflow.
86
83
  - Before merge, `/pr` fetches the exact head OID from the validated push URL without shared fetch state.
87
84
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
85
+ - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
88
86
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
89
87
  - An already-published local HEAD needs no second push.
90
88
  - Direct merge requires final confirmation and a fresh readiness check.
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
- RegisteredCommand,
5
4
  } from "@earendil-works/pi-coding-agent";
6
5
  import {
7
6
  executeGitHubMerge,
@@ -26,7 +25,7 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
26
25
  };
27
26
 
28
27
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
29
- export type PrCommandHandler = RegisteredCommand["handler"];
28
+ export type PrCommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<NextStep>;
30
29
 
31
30
  function dispatchWorkflow(pi: PrCommandPi, ctx: ExtensionCommandContext, commandName: string): void {
32
31
  const command = pi.getCommands().find((candidate) =>
@@ -141,14 +140,15 @@ export function createPrCommandHandler(pi: PrCommandPi): PrCommandHandler {
141
140
  const notification = noActionNotification(current);
142
141
  ctx.ui.notify(notification.message, notification.type);
143
142
  }
144
- return;
143
+ return nextStep;
145
144
  }
146
145
  if (nextStep === "merge") {
147
146
  if (!current) throw new Error("/pr merge failed: pull request is unavailable");
148
147
  await mergePullRequest(pi, ctx, current);
149
- return;
148
+ return nextStep;
150
149
  }
151
150
 
152
151
  dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep]);
152
+ return nextStep;
153
153
  };
154
154
  }
@@ -18,6 +18,7 @@ const PR_LIST_LIMIT = 100;
18
18
  const PR_SEARCH_CAP = 1_000;
19
19
  const PR_FIELDS = "id,number,url,state,isDraft,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup";
20
20
  const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
21
+ const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
21
22
  const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
22
23
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
23
24
  const FAILED_CHECK_STATES = new Set([
@@ -609,6 +610,25 @@ function parseUnresolvedReviewThreads(output: string): number {
609
610
  return total;
610
611
  }
611
612
 
613
+ function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
614
+ const value = parseJson(output, "Read base ref");
615
+ if (!isRecord(value)) fail("Read base ref", "invalid GitHub CLI output");
616
+ if (value.errors !== undefined) {
617
+ if (!Array.isArray(value.errors)) fail("Read base ref", "invalid GitHub CLI output");
618
+ if (value.errors.length) fail("Read base ref", "GitHub GraphQL returned errors");
619
+ }
620
+ const repository = isRecord(value.data) ? value.data.repository : undefined;
621
+ if (!isRecord(repository) || !isRecord(repository.ref) || !isRecord(repository.ref.target)) {
622
+ fail("Read base ref", "invalid GitHub CLI output");
623
+ }
624
+ if (
625
+ normalizeRepository(repositoryName(repository.nameWithOwner, "Read base ref", "repository")) !==
626
+ normalizeRepository(candidate.base.repository) ||
627
+ text(repository.ref.name, "Read base ref", "ref") !== candidate.base.ref
628
+ ) fail("Read base ref", "response does not match pull request base");
629
+ return oid(repository.ref.target.oid, "Read base ref", "target OID");
630
+ }
631
+
612
632
  function parseLegacyBaseBranchPolicy(output: string, candidate: ListedPullRequest): boolean {
613
633
  const value = parseJson(output, "Read base branch policy");
614
634
  if (!isRecord(value)) fail("Read base branch policy", "invalid GitHub CLI output");
@@ -810,6 +830,29 @@ async function readUnresolvedReviewThreads(
810
830
  return parseUnresolvedReviewThreads(result.stdout);
811
831
  }
812
832
 
833
+ async function readBaseRefOid(
834
+ pi: Pick<ExtensionAPI, "exec">,
835
+ context: PullRequestLoadContext,
836
+ candidate: ListedPullRequest,
837
+ ): Promise<string> {
838
+ const [owner, name] = candidate.base.repository.split("/");
839
+ const result = await execute(pi, context, "Read base ref", "gh", [
840
+ "api",
841
+ "graphql",
842
+ "--hostname",
843
+ candidate.url.hostname,
844
+ "-f",
845
+ `query=${BASE_REF_QUERY}`,
846
+ "-F",
847
+ `owner=${owner}`,
848
+ "-F",
849
+ `name=${name}`,
850
+ "-F",
851
+ `qualifiedName=refs/heads/${candidate.base.ref}`,
852
+ ]);
853
+ return parseBaseRefOid(result.stdout, candidate);
854
+ }
855
+
813
856
  async function readLegacyBaseBranchPolicy(
814
857
  pi: Pick<ExtensionAPI, "exec">,
815
858
  context: PullRequestLoadContext,
@@ -877,7 +920,6 @@ export async function loadCurrentPullRequest(
877
920
  ): Promise<CurrentPullRequest | null> {
878
921
  const pushTarget = await readPushTarget(pi, context);
879
922
  if (pushTarget === null) return null;
880
- const [headOwner] = pushTarget.repository.nameWithOwner.split("/");
881
923
  const search = await execute(pi, context, "Find pull requests", "gh", [
882
924
  "api",
883
925
  "search/issues",
@@ -888,7 +930,7 @@ export async function loadCurrentPullRequest(
888
930
  "-X",
889
931
  "GET",
890
932
  "-f",
891
- `q=is:pr head:${headOwner}:${pushTarget.ref}`,
933
+ `q=is:pr head:${pushTarget.ref}${pushTarget.remoteHeadOid === null ? "" : ` ${pushTarget.remoteHeadOid}`}`,
892
934
  "-f",
893
935
  `per_page=${PR_LIST_LIMIT}`,
894
936
  ]);
@@ -914,6 +956,9 @@ export async function loadCurrentPullRequest(
914
956
  const unresolvedThreads = candidate.lifecycle === "open"
915
957
  ? await readUnresolvedReviewThreads(pi, context, candidate)
916
958
  : 0;
959
+ const liveBaseOid = candidate.lifecycle === "open"
960
+ ? await readBaseRefOid(pi, context, candidate)
961
+ : null;
917
962
  const rulesetPolicy = candidate.lifecycle === "open"
918
963
  ? await readRulesetBaseBranchPolicy(pi, context, candidate)
919
964
  : null;
@@ -945,7 +990,7 @@ export async function loadCurrentPullRequest(
945
990
  lifecycle: candidate.lifecycle,
946
991
  conditions: pullRequestConditions,
947
992
  local,
948
- base: candidate.base,
993
+ base: liveBaseOid ? { ...candidate.base, oid: liveBaseOid } : candidate.base,
949
994
  head: candidate.head,
950
995
  headFetchSource: pushTarget.fetchSource,
951
996
  merge,
package/extensions/pr.ts CHANGED
@@ -34,10 +34,14 @@ export default function pullRequestExtension(
34
34
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
35
35
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
36
36
  let context: ExtensionContext | undefined;
37
+ let sessionGeneration = 0;
37
38
  let timer: ReturnType<typeof setInterval> | undefined;
38
39
  let active: AbortController | undefined;
39
40
  let queued = false;
40
41
  let refreshFailureReported = false;
42
+ let displayedWidget: string | undefined;
43
+ let commandGeneration = 0;
44
+ const activeInvocations = new Map<number, "routing" | "workflow">();
41
45
 
42
46
  const render = (
43
47
  ctx: ExtensionContext,
@@ -49,15 +53,20 @@ export default function pullRequestExtension(
49
53
  if (pullRequest !== null && footer === undefined) {
50
54
  throw new Error("Current pull request display is missing a footer");
51
55
  }
52
- const widget = formatPrWidget(display);
56
+ displayedWidget = formatPrWidget(display);
57
+ const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
53
58
  ctx.ui.setStatus(UI_KEY, footer);
54
59
  ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
55
60
  };
56
61
 
57
62
  const stop = (): void => {
63
+ sessionGeneration += 1;
58
64
  context = undefined;
59
65
  queued = false;
60
66
  refreshFailureReported = false;
67
+ displayedWidget = undefined;
68
+ commandGeneration = 0;
69
+ activeInvocations.clear();
61
70
  if (timer !== undefined) clearInterval(timer);
62
71
  timer = undefined;
63
72
  active?.abort();
@@ -79,6 +88,7 @@ export default function pullRequestExtension(
79
88
  const refresh = async (): Promise<void> => {
80
89
  const ctx = context;
81
90
  if (!ctx) return;
91
+ const generation = sessionGeneration;
82
92
  if (active) {
83
93
  queued = true;
84
94
  return;
@@ -92,13 +102,14 @@ export default function pullRequestExtension(
92
102
  let localCommit = false;
93
103
  try {
94
104
  pullRequest = await load(pi, loadContext);
105
+ if (controller.signal.aborted || sessionGeneration !== generation) return;
95
106
  if (pullRequest === null) localCommit = await detectLocalCommit(pi, loadContext);
96
107
  } catch (error) {
97
108
  // Keep the last known display when lookup is unavailable.
98
- if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
109
+ if (!controller.signal.aborted && sessionGeneration === generation) reportRefreshFailure(error);
99
110
  return;
100
111
  }
101
- if (controller.signal.aborted || context !== ctx) return;
112
+ if (controller.signal.aborted || sessionGeneration !== generation) return;
102
113
  render(ctx, pullRequest, localCommit);
103
114
  refreshFailureReported = false;
104
115
  } finally {
@@ -115,16 +126,36 @@ export default function pullRequestExtension(
115
126
  void refresh().catch(reportRefreshFailure);
116
127
  };
117
128
 
129
+ const cancelRefresh = (): void => {
130
+ active?.abort();
131
+ active = undefined;
132
+ queued = false;
133
+ };
134
+
118
135
  pi.on("session_start", async (_event, ctx) => {
119
136
  stop();
137
+ const generation = sessionGeneration;
120
138
  if (!ctx.hasUI) return;
121
139
  context = ctx;
122
140
  await refresh();
123
- if (context === ctx) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
141
+ if (sessionGeneration === generation) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
124
142
  });
125
143
 
126
144
  pi.on("session_shutdown", stop);
127
145
 
146
+ pi.on("agent_settled", async (_event, ctx) => {
147
+ if (!ctx.hasUI || !ctx.isIdle() || !context) return;
148
+ let workflowSettled = false;
149
+ for (const [invocation, phase] of activeInvocations) {
150
+ if (phase !== "workflow") continue;
151
+ activeInvocations.delete(invocation);
152
+ workflowSettled = true;
153
+ }
154
+ if (!workflowSettled) return;
155
+ cancelRefresh();
156
+ await refresh().catch(reportRefreshFailure);
157
+ });
158
+
128
159
  pi.on("tool_result", async (event, ctx) => {
129
160
  if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
130
161
  const command = event.input.command;
@@ -137,10 +168,33 @@ export default function pullRequestExtension(
137
168
  pi.registerCommand("pr", {
138
169
  description: "Run the current branch pull request next step",
139
170
  handler: async (args, ctx) => {
140
- if (!ctx.hasUI) return;
171
+ if (!ctx.hasUI || !context) return;
172
+ const generation = sessionGeneration;
173
+ const invocation = ++commandGeneration;
174
+ activeInvocations.set(invocation, "routing");
175
+ if (displayedWidget !== undefined) ctx.ui.setWidget(UI_KEY, undefined);
176
+ let nextStep: Awaited<ReturnType<typeof commandHandler>>;
141
177
  try {
142
- await commandHandler(args, ctx);
143
- } finally {
178
+ nextStep = await commandHandler(args, ctx);
179
+ } catch (error) {
180
+ if (sessionGeneration === generation) {
181
+ cancelRefresh();
182
+ activeInvocations.delete(invocation);
183
+ if (!activeInvocations.size) {
184
+ ctx.ui.setWidget(UI_KEY, displayedWidget === undefined ? undefined : [displayedWidget]);
185
+ }
186
+ refreshInBackground();
187
+ }
188
+ throw error;
189
+ }
190
+ if (sessionGeneration !== generation) return;
191
+ cancelRefresh();
192
+ if (nextStep !== "none" && nextStep !== "merge") {
193
+ activeInvocations.set(invocation, "workflow");
194
+ if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
195
+ ctx.ui.setWidget(UI_KEY, undefined);
196
+ } else {
197
+ activeInvocations.delete(invocation);
144
198
  refreshInBackground();
145
199
  }
146
200
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Run /pr to automatically choose the current pull request's next safe lifecycle step: create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -383,10 +383,9 @@ function candidatePrUrls(pages, hostname) {
383
383
 
384
384
  function discoverOpenPr(localHead) {
385
385
  const push = configuredPushTarget();
386
- const [owner] = push.repository.split("/", 2);
387
386
  const response = readJsonValueCommand("gh", [
388
387
  "api", "search/issues", "--hostname", push.hostname, "--paginate", "--slurp", "-X", "GET",
389
- "-f", `q=is:pr is:open head:${owner}:${push.ref}`,
388
+ "-f", `q=is:pr is:open head:${push.ref} ${localHead}`,
390
389
  "-f", `per_page=${PR_LIST_LIMIT}`,
391
390
  ]);
392
391
  const candidates = candidatePrUrls(response, push.hostname).map((url) => {
@@ -1151,7 +1150,7 @@ if [ "$*" = "repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url" ];
1151
1150
  echo '{"nameWithOwner":"Owner/Repo","url":"https://${enterpriseHost}/Owner/Repo"}'
1152
1151
  exit 0
1153
1152
  fi
1154
- if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100" ]; then
1153
+ if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:feature/pr ${discoveryMetadata.headRefOid} -f per_page=100" ]; then
1155
1154
  echo '[{"total_count":1,"incomplete_results":false,"items":[{"html_url":"https://${enterpriseHost}/Upstream/Project/pull/1"}]}]'
1156
1155
  exit 0
1157
1156
  fi
@@ -1198,7 +1197,7 @@ exit 1
1198
1197
  const discoveryCommands = readFileSync(discoveryCalls, "utf8").trim().split("\n");
1199
1198
  assert.equal(discoveryCommands[0], `repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url`);
1200
1199
  assert(!discoveryCommands.some((command) => command.includes("account") || command.includes("secret")));
1201
- assert.equal(discoveryCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100`);
1200
+ assert.equal(discoveryCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:feature/pr ${discoveryMetadata.headRefOid} -f per_page=100`);
1202
1201
  assert.match(discoveryCommands[2], new RegExp(`^pr view https://${enterpriseHost}/Upstream/Project/pull/1 `));
1203
1202
  assert(!discoveryCommands.some((command) => command.startsWith("pr view --json")));
1204
1203
 
@@ -1215,7 +1214,7 @@ if [ "$*" = "repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url" ];
1215
1214
  echo '{"nameWithOwner":"Owner/Repo","url":"https://${enterpriseHost}/Owner/Repo"}'
1216
1215
  exit 0
1217
1216
  fi
1218
- if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100" ]; then
1217
+ if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:feature/pr ${discoveryMetadata.headRefOid} -f per_page=100" ]; then
1219
1218
  echo '[{"total_count":1,"incomplete_results":false,"items":[{"html_url":"https://${enterpriseHost}/Owner/Repo/pull/1"}]}]'
1220
1219
  exit 0
1221
1220
  fi
@@ -1277,7 +1276,7 @@ exit 1
1277
1276
  assert.equal(readFileSync(resolved, "utf8"), "first\n");
1278
1277
  const resolutionCommands = readFileSync(resolutionCalls, "utf8").trim().split("\n");
1279
1278
  assert.equal(resolutionCommands[0], `repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url`);
1280
- assert.equal(resolutionCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100`);
1279
+ assert.equal(resolutionCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:feature/pr ${discoveryMetadata.headRefOid} -f per_page=100`);
1281
1280
  assert.match(resolutionCommands[2], new RegExp(`^pr view https://${enterpriseHost}/Owner/Repo/pull/1 `));
1282
1281
  assert(!resolutionCommands.some((command) => command.startsWith("pr view --json")));
1283
1282
 
@@ -13,8 +13,9 @@ Before changing anything:
13
13
 
14
14
  1. Require an attached branch and a clean tree. Save `git symbolic-ref --quiet --short HEAD` as `LOCAL_BRANCH`; it is checkout identity only. Inspect `git status --porcelain=v1 --untracked-files=all` and stop for any staged, unstaged, untracked, unresolved, or in-progress operation. Never commit, clean, stash, or hide a dirty tree.
15
15
  2. Read `LOCAL_BRANCH`'s validated `%(push:short)` with `git for-each-ref` and enumerate configured remote names. Match an exact `<remote>/` prefix, choosing the unique longest match so remote names containing `/` work. Save that remote and the remaining ref as `PUSH_REMOTE` and `PUSH_REF`, then validate `PUSH_REF` with `git check-ref-format --branch`. Require one push URL and validate its GitHub host and owner/repository. Do not use `%(push:remoteref)` or fall back to `LOCAL_BRANCH`.
16
- 3. Set `PR_FIELDS=number,url,state,baseRefName,baseRefOid,headRepository,headRefName,headRefOid,mergeStateStatus,mergeable`. On the push target's host, search open pull requests by the exact push owner and `PUSH_REF`. Inspect every candidate by its URL with exactly `PR_FIELDS`; stop for incomplete, capped, duplicate, or ambiguous results. Never use branch-default `gh pr view` or retry with `LOCAL_BRANCH`.
17
- 4. Require exactly one open PR. Require its HTTPS URL to be exactly `HOST/OWNER/REPOSITORY/pull/NUMBER`, with no credentials, port, query, or fragment, and require its number to match. The URL gives the base host and repository. Require `headRepository.nameWithOwner` and `headRefName` to match the recorded push repository and `PUSH_REF`. Require local `HEAD` to equal `headRefOid`. Record the PR and its base/head repositories, refs, and full OIDs. Set `EXPECTED_HEAD_SHA` and `BASE_SHA` from the initial head and base OIDs; never replace them. A fork head and upstream base are normal.
16
+ 3. Set `PR_FIELDS=number,url,state,baseRefName,headRepository,headRefName,headRefOid,mergeStateStatus,mergeable`. On the push target's host, search open pull requests by the exact push owner and `PUSH_REF`. Inspect every candidate by its URL with exactly `PR_FIELDS`; stop for incomplete, capped, duplicate, or ambiguous results. Never use branch-default `gh pr view` or retry with `LOCAL_BRANCH`.
17
+ 4. Require exactly one open PR. Require its HTTPS URL to be exactly `HOST/OWNER/REPOSITORY/pull/NUMBER`, with no credentials, port, query, or fragment, and require its number to match. The URL gives the base host and repository. Require `headRepository.nameWithOwner` and `headRefName` to match the recorded push repository and `PUSH_REF`. Require local `HEAD` to equal `headRefOid`. Record the PR and its base/head repositories and refs. Set `EXPECTED_HEAD_SHA` from the initial head OID; never replace it. A fork head and upstream base are normal.
18
+ 5. Validate `refs/heads/<BASE_REF>` with `git check-ref-format`, then split the validated base repository into its exact owner and name. Query `repository.ref(qualifiedName: "refs/heads/<BASE_REF>") { name target { oid } }` with `gh api graphql --hostname "$PR_HOST"`. Require no GraphQL errors, the exact recorded repository and ref, and one full hexadecimal target OID. Record that target as `BASE_SHA`; never replace it. Do not read `baseRefOid`, REST `pull.base.sha`, a local remote-tracking ref, or `git ls-remote` as a substitute.
18
19
 
19
20
  ## Fetch, pin, and merge
20
21
 
@@ -48,7 +49,7 @@ printf 'Fetched base %s %s at %s\n' "$BASE_REPOSITORY" "$BASE_REF" "$BASE_SHA"
48
49
 
49
50
  If protocol lookup, API lookup, URL validation, fetch, or object verification fails, stop. The HTTPS helper is command-local and receives credentials only through Git's credential protocol. Never print a token, run `gh auth setup-git`, or change persistent Git config. The recorded `BASE_SHA` is authoritative for this run. Do not fall back to another URL, ref, protocol, or credential source.
50
51
 
51
- Before merging, require the attached branch to remain `LOCAL_BRANCH` and the tree to remain clean. Re-resolve its configured push target exactly as above and require the saved remote, `PUSH_REF`, sole push URL, host, and repository. Re-read the PR by its recorded URL with exactly `PR_FIELDS`; require its number, URL, open state, base repository/ref/OID, head repository, `PUSH_REF`, and head OID to remain unchanged. Immediately before merging, require local `HEAD` to equal `EXPECTED_HEAD_SHA`:
52
+ Before merging, require the attached branch to remain `LOCAL_BRANCH` and the tree to remain clean. Re-resolve its configured push target exactly as above and require the saved remote, `PUSH_REF`, sole push URL, host, and repository. Re-read the PR by its recorded URL with exactly `PR_FIELDS`; require its number, URL, open state, base repository/ref, head repository, `PUSH_REF`, and head OID to remain unchanged. Re-run the exact live base-ref GraphQL query and require its repository, ref, and target OID to match the recorded values and `BASE_SHA`. Immediately before merging, require local `HEAD` to equal `EXPECTED_HEAD_SHA`:
52
53
 
53
54
  ```bash
54
55
  LOCAL_HEAD="$(git rev-parse --verify HEAD)"
@@ -106,7 +107,7 @@ After the merge completes:
106
107
  ```
107
108
 
108
109
  Stop on either failure. Record the validated full local `HEAD` as `MERGED_HEAD`. Do not substitute a newer ref or another SHA.
109
- 3. Immediately before the single push, repeat the complete branch, configured push target, sole push URL, and recorded-URL PR guard used before merging. Require all saved identities and PR fields to remain exact, the tree to be clean, and local `HEAD` to equal `MERGED_HEAD`. Stop on any change.
110
+ 3. Immediately before the single push, repeat the complete branch, configured push target, sole push URL, recorded-URL PR guard, and live base-ref GraphQL query used before merging. Require all saved identities and PR fields to remain exact, the live base target to remain `BASE_SHA`, the tree to be clean, and local `HEAD` to equal `MERGED_HEAD`. Stop on any change.
110
111
  4. Push once to the saved configured push ref, without force or retry:
111
112
 
112
113
  ```bash