@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
|
-
|
|
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,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,
|
|
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.
|
package/extensions/pr-command.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|
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,
|
|
@@ -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:${
|
|
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
|
-
|
|
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 &&
|
|
109
|
+
if (!controller.signal.aborted && sessionGeneration === generation) reportRefreshFailure(error);
|
|
99
110
|
return;
|
|
100
111
|
}
|
|
101
|
-
if (controller.signal.aborted ||
|
|
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 (
|
|
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
|
-
}
|
|
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.
|
|
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:${
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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
|