@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 +10 -16
- package/extensions/pr-github.ts +47 -1
- package/extensions/pr.ts +20 -23
- package/package.json +1 -1
- package/skills/pi-pr-update-branch/SKILL.md +5 -4
package/README.md
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
# `@henryqw/pi-pr`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
15
|
+
## Works with
|
|
21
16
|
|
|
22
|
-
[`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-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.
|
|
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
|
-
|
|
28
|
+
## Flow
|
|
36
29
|
|
|
37
|
-
|
|
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
|

|
|
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
|
|
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
|
|
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.
|
|
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.
|
package/extensions/pr-github.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
if (
|
|
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
|
-
|
|
184
|
-
if (!
|
|
185
|
-
ctx.ui.setWidget(UI_KEY,
|
|
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
|
|
196
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|