@henryqw/pi-pr 2.0.2 → 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,9 +63,9 @@ 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, pushes, and the creation workflow. They also poll every 30 seconds. Polling updates presentation only and may be stale.
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.
74
67
 
75
- The create hint stays hidden until the local branch has a commit beyond its creation point. It clears as soon as `/pr` starts creation. It returns if the workflow finishes without a pull request.
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.
76
69
 
77
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.
78
71
 
@@ -89,6 +82,7 @@ Presentation uses route priority, so draft appears before running CI. `/pr` read
89
82
  - Applicable ruleset restrictions intersect repository-wide merge methods. An empty intersection stops the workflow.
90
83
  - Before merge, `/pr` fetches the exact head OID from the validated push URL without shared fetch state.
91
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.
92
86
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
93
87
  - An already-published local HEAD needs no second push.
94
88
  - Direct merge requires final confirmation and a fresh readiness check.
@@ -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,
@@ -913,6 +956,9 @@ export async function loadCurrentPullRequest(
913
956
  const unresolvedThreads = candidate.lifecycle === "open"
914
957
  ? await readUnresolvedReviewThreads(pi, context, candidate)
915
958
  : 0;
959
+ const liveBaseOid = candidate.lifecycle === "open"
960
+ ? await readBaseRefOid(pi, context, candidate)
961
+ : null;
916
962
  const rulesetPolicy = candidate.lifecycle === "open"
917
963
  ? await readRulesetBaseBranchPolicy(pi, context, candidate)
918
964
  : null;
@@ -944,7 +990,7 @@ export async function loadCurrentPullRequest(
944
990
  lifecycle: candidate.lifecycle,
945
991
  conditions: pullRequestConditions,
946
992
  local,
947
- base: candidate.base,
993
+ base: liveBaseOid ? { ...candidate.base, oid: liveBaseOid } : candidate.base,
948
994
  head: candidate.head,
949
995
  headFetchSource: pushTarget.fetchSource,
950
996
  merge,
package/extensions/pr.ts CHANGED
@@ -39,10 +39,9 @@ export default function pullRequestExtension(
39
39
  let active: AbortController | undefined;
40
40
  let queued = false;
41
41
  let refreshFailureReported = false;
42
- let displayedNextStep: ReturnType<typeof projectPrDisplay>["nextStep"] | undefined;
43
42
  let displayedWidget: string | undefined;
44
43
  let commandGeneration = 0;
45
- const pendingCreations = new Set<number>();
44
+ const activeInvocations = new Map<number, "routing" | "workflow">();
46
45
 
47
46
  const render = (
48
47
  ctx: ExtensionContext,
@@ -54,10 +53,8 @@ export default function pullRequestExtension(
54
53
  if (pullRequest !== null && footer === undefined) {
55
54
  throw new Error("Current pull request display is missing a footer");
56
55
  }
57
- if (pullRequest !== null) pendingCreations.clear();
58
56
  displayedWidget = formatPrWidget(display);
59
- const widget = pendingCreations.size > 0 && display.nextStep === "create" ? undefined : displayedWidget;
60
- displayedNextStep = display.nextStep;
57
+ const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
61
58
  ctx.ui.setStatus(UI_KEY, footer);
62
59
  ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
63
60
  };
@@ -67,10 +64,9 @@ export default function pullRequestExtension(
67
64
  context = undefined;
68
65
  queued = false;
69
66
  refreshFailureReported = false;
70
- displayedNextStep = undefined;
71
67
  displayedWidget = undefined;
72
68
  commandGeneration = 0;
73
- pendingCreations.clear();
69
+ activeInvocations.clear();
74
70
  if (timer !== undefined) clearInterval(timer);
75
71
  timer = undefined;
76
72
  active?.abort();
@@ -148,9 +144,15 @@ export default function pullRequestExtension(
148
144
  pi.on("session_shutdown", stop);
149
145
 
150
146
  pi.on("agent_settled", async (_event, ctx) => {
151
- if (!ctx.hasUI || !ctx.isIdle() || !context || !pendingCreations.size) return;
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;
152
155
  cancelRefresh();
153
- pendingCreations.clear();
154
156
  await refresh().catch(reportRefreshFailure);
155
157
  });
156
158
 
@@ -169,22 +171,17 @@ export default function pullRequestExtension(
169
171
  if (!ctx.hasUI || !context) return;
170
172
  const generation = sessionGeneration;
171
173
  const invocation = ++commandGeneration;
172
- const displayedCreation = displayedNextStep === "create";
173
- if (displayedCreation) {
174
- pendingCreations.add(invocation);
175
- ctx.ui.setWidget(UI_KEY, undefined);
176
- }
174
+ activeInvocations.set(invocation, "routing");
175
+ if (displayedWidget !== undefined) ctx.ui.setWidget(UI_KEY, undefined);
177
176
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
178
177
  try {
179
178
  nextStep = await commandHandler(args, ctx);
180
179
  } catch (error) {
181
180
  if (sessionGeneration === generation) {
182
181
  cancelRefresh();
183
- pendingCreations.delete(invocation);
184
- if (!pendingCreations.size) {
185
- ctx.ui.setWidget(UI_KEY, displayedNextStep === "create" && displayedWidget !== undefined
186
- ? [displayedWidget]
187
- : undefined);
182
+ activeInvocations.delete(invocation);
183
+ if (!activeInvocations.size) {
184
+ ctx.ui.setWidget(UI_KEY, displayedWidget === undefined ? undefined : [displayedWidget]);
188
185
  }
189
186
  refreshInBackground();
190
187
  }
@@ -192,12 +189,12 @@ export default function pullRequestExtension(
192
189
  }
193
190
  if (sessionGeneration !== generation) return;
194
191
  cancelRefresh();
195
- if (nextStep === "create") {
196
- pendingCreations.add(invocation);
197
- ctx.ui.setStatus(UI_KEY, undefined);
192
+ if (nextStep !== "none" && nextStep !== "merge") {
193
+ activeInvocations.set(invocation, "workflow");
194
+ if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
198
195
  ctx.ui.setWidget(UI_KEY, undefined);
199
196
  } else {
200
- pendingCreations.delete(invocation);
197
+ activeInvocations.delete(invocation);
201
198
  refreshInBackground();
202
199
  }
203
200
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.0.2",
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",
@@ -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