@henryqw/pi-pr 4.0.4 → 4.0.6
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 +6 -6
- package/extensions/pr-github.ts +46 -20
- package/extensions/pr.ts +24 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
74
74
|
| No-action state | Report the state without taking action. |
|
|
75
75
|
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and squash-merge if confirmed. |
|
|
76
76
|
|
|
77
|
-
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation.
|
|
77
|
+
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation. If the current branch is the selected base, pi-pr stays silent because GitHub cannot create a pull request from a ref to itself.
|
|
78
78
|
|
|
79
79
|
The base always comes from validated `origin`. The head may use that repository or a fork with the same GitHub source. Base and head must use the same GitHub host. Other fork relationships stop before mutation.
|
|
80
80
|
|
|
@@ -129,17 +129,17 @@ The sweep runs existing non-destructive checks on the clean committed `HEAD` bef
|
|
|
129
129
|
|
|
130
130
|
### Refresh
|
|
131
131
|
|
|
132
|
-
The footer and widget load at session start. A directory outside a Git worktree stays silent
|
|
132
|
+
The footer and widget load once at session start. A directory outside a Git worktree stays silent. The UI shows `PR · status unavailable` for other discovery failures and reports only a generic error.
|
|
133
133
|
|
|
134
|
-
They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles.
|
|
134
|
+
They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. There is no periodic presentation refresh, so external changes may leave the footer and widget stale indefinitely. `/pr` reads fresh state before routing or acting and remains authoritative.
|
|
135
135
|
|
|
136
|
-
The create widget stays hidden until the local branch has a commit beyond its creation point. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh.
|
|
136
|
+
The create widget stays hidden until the local branch has a commit beyond its creation point. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh, except when fresh lookup hits the GitHub API quota: it shows the sanitized message `GitHub API rate limit exhausted; retry after GitHub resets it` and does not immediately retry.
|
|
137
137
|
|
|
138
138
|
Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
|
|
139
139
|
|
|
140
140
|
### Session identity
|
|
141
141
|
|
|
142
|
-
The extension records one configured PR identity in the Pi session. It stores only the PR URL, number, host, head identity, and configured target identity. It does not store lifecycle, CI, review, readiness, or base state.
|
|
142
|
+
The extension records one configured PR identity in the Pi session. It stores only the PR URL, number, host, head identity, and configured target identity. It does not store lifecycle, CI, review, readiness, or base state. Event-driven refreshes do not add duplicate entries, and no repository cache file is created.
|
|
143
143
|
|
|
144
144
|
Normal discovery always runs first. If the configured remote ref was deleted, the footer and `/pr` may reload the exact observed PR URL. The current host, repository, branch, remote, ref, and local HEAD must still match the observation. Repository names use case-insensitive GitHub matching.
|
|
145
145
|
|
|
@@ -149,7 +149,7 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
|
|
|
149
149
|
|
|
150
150
|
- `/pr` accepts creation syntax only as a leading `--base BRANCH`, followed by optional creation guidance. It does not open a browser.
|
|
151
151
|
- It does not run `/done` or `/sweep`.
|
|
152
|
-
-
|
|
152
|
+
- Presentation refreshes do not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
|
|
153
153
|
- It does not enable auto-merge or add a merge queue.
|
|
154
154
|
- It does not rebase the local branch, overwrite concurrent remote updates, delete branches, or clean up worktrees. Creation uses exact leases plus ancestry checks; an empty lease is only an atomic absence check.
|
|
155
155
|
- Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
|
package/extensions/pr-github.ts
CHANGED
|
@@ -66,6 +66,13 @@ export class PullRequestLoadError extends Error {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export class GitHubRateLimitError extends PullRequestLoadError {
|
|
70
|
+
constructor() {
|
|
71
|
+
super("GitHub API rate limit exhausted; retry after GitHub resets it");
|
|
72
|
+
this.name = "GitHubRateLimitError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
export type PullRequestRef = {
|
|
70
77
|
repository: string;
|
|
71
78
|
ref: string;
|
|
@@ -107,6 +114,10 @@ export type PullRequestCreationPreflight = {
|
|
|
107
114
|
ahead: number;
|
|
108
115
|
};
|
|
109
116
|
|
|
117
|
+
type CreationPreflightResult =
|
|
118
|
+
| { kind: "same-ref" }
|
|
119
|
+
| { kind: "distinct-ref"; preflight: PullRequestCreationPreflight };
|
|
120
|
+
|
|
110
121
|
type CreationIdentity = {
|
|
111
122
|
target: PullRequestTarget;
|
|
112
123
|
head: string;
|
|
@@ -365,7 +376,11 @@ async function invoke(
|
|
|
365
376
|
return parseCommandOutput(result, action);
|
|
366
377
|
}
|
|
367
378
|
|
|
368
|
-
function commandFailure(action: string, result: CommandOutput): never {
|
|
379
|
+
function commandFailure(action: string, result: CommandOutput, command?: string): never {
|
|
380
|
+
if (
|
|
381
|
+
command === "gh" && !result.killed && result.code !== 0 &&
|
|
382
|
+
result.stderr.includes("GraphQL: API rate limit exceeded")
|
|
383
|
+
) throw new GitHubRateLimitError();
|
|
369
384
|
fail(action, result.killed ? "command was cancelled" : `exit code ${result.code}`);
|
|
370
385
|
}
|
|
371
386
|
|
|
@@ -377,7 +392,7 @@ async function execute(
|
|
|
377
392
|
args: string[],
|
|
378
393
|
): Promise<CommandOutput> {
|
|
379
394
|
const result = await invoke(pi, context, action, command, args);
|
|
380
|
-
if (result.killed || result.code !== 0) commandFailure(action, result);
|
|
395
|
+
if (result.killed || result.code !== 0) commandFailure(action, result, command);
|
|
381
396
|
return result;
|
|
382
397
|
}
|
|
383
398
|
|
|
@@ -1104,19 +1119,18 @@ function parseCreationRepositoryLineage(output: string, expected: PushRepository
|
|
|
1104
1119
|
return normalizeRepository(sourceName);
|
|
1105
1120
|
}
|
|
1106
1121
|
|
|
1107
|
-
async function
|
|
1122
|
+
async function inspectCreationRepositoryRelation(
|
|
1108
1123
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1109
1124
|
context: PullRequestLoadContext,
|
|
1110
1125
|
target: PullRequestTarget,
|
|
1111
1126
|
origin: { repository: PushRepository },
|
|
1112
1127
|
baseRef: string,
|
|
1113
|
-
): Promise<
|
|
1128
|
+
): Promise<"same-ref" | "distinct-ref"> {
|
|
1114
1129
|
if (target.host !== origin.repository.host) {
|
|
1115
1130
|
fail("Read creation repository", "base and head hosts do not match");
|
|
1116
1131
|
}
|
|
1117
1132
|
if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
|
|
1118
|
-
|
|
1119
|
-
return;
|
|
1133
|
+
return target.ref === baseRef ? "same-ref" : "distinct-ref";
|
|
1120
1134
|
}
|
|
1121
1135
|
const read = async (repository: PushRepository): Promise<string> => {
|
|
1122
1136
|
const [owner, name] = repository.nameWithOwner.split("/");
|
|
@@ -1133,6 +1147,7 @@ async function assertCreationRepositoryRelation(
|
|
|
1133
1147
|
host: target.host,
|
|
1134
1148
|
});
|
|
1135
1149
|
if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
|
|
1150
|
+
return "distinct-ref";
|
|
1136
1151
|
}
|
|
1137
1152
|
|
|
1138
1153
|
function parseCreationAhead(output: string): number {
|
|
@@ -1149,7 +1164,7 @@ async function preflightCreation(
|
|
|
1149
1164
|
target: PullRequestTarget,
|
|
1150
1165
|
explicitBaseRef: string | undefined,
|
|
1151
1166
|
identity: CreationIdentity,
|
|
1152
|
-
): Promise<
|
|
1167
|
+
): Promise<CreationPreflightResult> {
|
|
1153
1168
|
const validatedTarget = validatedCreationTarget(target);
|
|
1154
1169
|
if (!sameCreationTarget(identity.target, validatedTarget)) {
|
|
1155
1170
|
fail("Read creation target", "target changed");
|
|
@@ -1160,7 +1175,8 @@ async function preflightCreation(
|
|
|
1160
1175
|
? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
|
|
1161
1176
|
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1162
1177
|
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1163
|
-
await
|
|
1178
|
+
const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1179
|
+
if (relation === "same-ref") return { kind: relation };
|
|
1164
1180
|
const trackingRef = `refs/remotes/origin/${baseRef}`;
|
|
1165
1181
|
await execute(pi, context, "Fetch creation base", "git", [
|
|
1166
1182
|
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
|
|
@@ -1180,16 +1196,19 @@ async function preflightCreation(
|
|
|
1180
1196
|
"rev-list", "--count", `${mergeBase}..${identity.head}`,
|
|
1181
1197
|
])).stdout);
|
|
1182
1198
|
return {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1199
|
+
kind: relation,
|
|
1200
|
+
preflight: {
|
|
1201
|
+
head: identity.head,
|
|
1202
|
+
base: {
|
|
1203
|
+
host: origin.repository.host,
|
|
1204
|
+
repository: origin.repository.nameWithOwner,
|
|
1205
|
+
fetchSource: origin.fetchSource,
|
|
1206
|
+
ref: baseRef,
|
|
1207
|
+
oid: baseOid,
|
|
1208
|
+
mergeBase,
|
|
1209
|
+
},
|
|
1210
|
+
ahead,
|
|
1191
1211
|
},
|
|
1192
|
-
ahead,
|
|
1193
1212
|
};
|
|
1194
1213
|
}
|
|
1195
1214
|
|
|
@@ -1200,7 +1219,9 @@ export async function preflightPullRequestCreation(
|
|
|
1200
1219
|
explicitBaseRef?: string,
|
|
1201
1220
|
): Promise<PullRequestCreationPreflight> {
|
|
1202
1221
|
const identity = await captureCreationIdentity(pi, context, target);
|
|
1203
|
-
|
|
1222
|
+
const result = await preflightCreation(pi, context, target, explicitBaseRef, identity);
|
|
1223
|
+
if (result.kind === "same-ref") fail("Read creation repository", "head and base refs match");
|
|
1224
|
+
return result.preflight;
|
|
1204
1225
|
}
|
|
1205
1226
|
|
|
1206
1227
|
async function creationDiscovery(
|
|
@@ -1211,8 +1232,12 @@ async function creationDiscovery(
|
|
|
1211
1232
|
identity?: CreationIdentity,
|
|
1212
1233
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1213
1234
|
const captured = identity ?? await captureCreationIdentity(pi, context, target);
|
|
1214
|
-
const
|
|
1215
|
-
return {
|
|
1235
|
+
const result = await preflightCreation(pi, context, target, explicitBaseRef, captured);
|
|
1236
|
+
return {
|
|
1237
|
+
kind: "none",
|
|
1238
|
+
creationTarget: target,
|
|
1239
|
+
branch: { ahead: result.kind === "same-ref" ? 0 : result.preflight.ahead },
|
|
1240
|
+
};
|
|
1216
1241
|
}
|
|
1217
1242
|
|
|
1218
1243
|
async function readRemoteAuthority(
|
|
@@ -1251,6 +1276,7 @@ async function readRemoteAuthority(
|
|
|
1251
1276
|
) fail("Read fetch repository", "fetch and push repositories do not match");
|
|
1252
1277
|
return { fetchSource: pushUrl.fetchSource, repository: pushRepository };
|
|
1253
1278
|
} catch (error) {
|
|
1279
|
+
if (error instanceof GitHubRateLimitError) throw error;
|
|
1254
1280
|
if (!strict && error instanceof PullRequestLoadError) return null;
|
|
1255
1281
|
throw error;
|
|
1256
1282
|
}
|
package/extensions/pr.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./pr-command.ts";
|
|
20
20
|
import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
|
|
21
21
|
import {
|
|
22
|
+
GitHubRateLimitError,
|
|
22
23
|
loadCurrentPullRequest,
|
|
23
24
|
parsePullRequestObservation,
|
|
24
25
|
pullRequestObservation,
|
|
@@ -37,7 +38,6 @@ import {
|
|
|
37
38
|
} from "./pr-ui.ts";
|
|
38
39
|
import { PullRequestBranchUpdater, type UpdateBranchOptions } from "./pr-update-branch.ts";
|
|
39
40
|
|
|
40
|
-
const POLL_INTERVAL_MS = 30_000;
|
|
41
41
|
const ROUTING_SPINNER_INTERVAL_MS = 80;
|
|
42
42
|
const ROUTING_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
43
|
const ROUTING_WIDGET_TEXT = "Checking pull request…";
|
|
@@ -282,10 +282,9 @@ export default function pullRequestExtension(
|
|
|
282
282
|
return discovery;
|
|
283
283
|
};
|
|
284
284
|
let sessionGeneration = 0;
|
|
285
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
286
285
|
let active: AbortController | undefined;
|
|
287
286
|
let queued = false;
|
|
288
|
-
let
|
|
287
|
+
let reportedRefreshFailure: "generic" | "quota" | undefined;
|
|
289
288
|
let displayEstablished = false;
|
|
290
289
|
let lastDiscovery: "configured" | "inferred" | "absent" | "blocked" | "inactive" | undefined;
|
|
291
290
|
let lastBlockedIssueKey: string | undefined;
|
|
@@ -561,8 +560,6 @@ export default function pullRequestExtension(
|
|
|
561
560
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
562
561
|
): void => {
|
|
563
562
|
if (discovery.kind === "inactive") {
|
|
564
|
-
if (timer !== undefined) clearInterval(timer);
|
|
565
|
-
timer = undefined;
|
|
566
563
|
displayEstablished = true;
|
|
567
564
|
lastDiscovery = "inactive";
|
|
568
565
|
displayedWidget = undefined;
|
|
@@ -600,7 +597,7 @@ export default function pullRequestExtension(
|
|
|
600
597
|
context = undefined;
|
|
601
598
|
observation = undefined;
|
|
602
599
|
queued = false;
|
|
603
|
-
|
|
600
|
+
reportedRefreshFailure = undefined;
|
|
604
601
|
displayEstablished = false;
|
|
605
602
|
lastDiscovery = undefined;
|
|
606
603
|
lastBlockedIssueKey = undefined;
|
|
@@ -612,18 +609,22 @@ export default function pullRequestExtension(
|
|
|
612
609
|
activeInvocations.clear();
|
|
613
610
|
stopRoutingSpinner();
|
|
614
611
|
widgetKind = "presentation";
|
|
615
|
-
if (timer !== undefined) clearInterval(timer);
|
|
616
|
-
timer = undefined;
|
|
617
612
|
active?.abort();
|
|
618
613
|
active = undefined;
|
|
619
614
|
};
|
|
620
615
|
|
|
621
|
-
const reportRefreshFailure = (): void => {
|
|
616
|
+
const reportRefreshFailure = (error: unknown): void => {
|
|
622
617
|
const ctx = context;
|
|
623
|
-
|
|
624
|
-
|
|
618
|
+
const category = error instanceof GitHubRateLimitError ? "quota" : "generic";
|
|
619
|
+
if (!ctx || reportedRefreshFailure === category || reportedRefreshFailure === "quota") return;
|
|
620
|
+
reportedRefreshFailure = category;
|
|
625
621
|
try {
|
|
626
|
-
ctx.ui.notify(
|
|
622
|
+
ctx.ui.notify(
|
|
623
|
+
error instanceof GitHubRateLimitError
|
|
624
|
+
? error.message
|
|
625
|
+
: "PR status refresh failed: status unavailable",
|
|
626
|
+
"error",
|
|
627
|
+
);
|
|
627
628
|
} catch {
|
|
628
629
|
console.error("PR status refresh failed and could not be reported");
|
|
629
630
|
}
|
|
@@ -655,7 +656,7 @@ export default function pullRequestExtension(
|
|
|
655
656
|
try {
|
|
656
657
|
discovery = await load(pi, loadContext);
|
|
657
658
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
658
|
-
} catch {
|
|
659
|
+
} catch (error) {
|
|
659
660
|
// Keep an established footer. A refresh failure must not leave a stale action hint.
|
|
660
661
|
if (!controller.signal.aborted && sessionGeneration === generation) {
|
|
661
662
|
displayedWidget = undefined;
|
|
@@ -665,13 +666,13 @@ export default function pullRequestExtension(
|
|
|
665
666
|
ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
|
|
666
667
|
displayEstablished = true;
|
|
667
668
|
}
|
|
668
|
-
reportRefreshFailure();
|
|
669
|
+
reportRefreshFailure(error);
|
|
669
670
|
}
|
|
670
671
|
return;
|
|
671
672
|
}
|
|
672
673
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
673
674
|
render(ctx, discovery);
|
|
674
|
-
|
|
675
|
+
reportedRefreshFailure = undefined;
|
|
675
676
|
|
|
676
677
|
const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
|
|
677
678
|
if (pendingWorkspaceRename && pullRequest?.target.provenance === "configured") {
|
|
@@ -716,14 +717,10 @@ export default function pullRequestExtension(
|
|
|
716
717
|
|
|
717
718
|
pi.on("session_start", async (_event, ctx) => {
|
|
718
719
|
stop();
|
|
719
|
-
const generation = sessionGeneration;
|
|
720
720
|
observation = latestObservation(ctx);
|
|
721
721
|
if (!ctx.hasUI) return;
|
|
722
722
|
context = ctx;
|
|
723
723
|
await refresh();
|
|
724
|
-
if (sessionGeneration === generation && lastDiscovery !== "inactive") {
|
|
725
|
-
timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
|
|
726
|
-
}
|
|
727
724
|
});
|
|
728
725
|
|
|
729
726
|
pi.on("session_shutdown", stop);
|
|
@@ -805,8 +802,14 @@ export default function pullRequestExtension(
|
|
|
805
802
|
if (sessionGeneration === generation) {
|
|
806
803
|
cancelRefresh();
|
|
807
804
|
activeInvocations.delete(invocation);
|
|
808
|
-
|
|
809
|
-
|
|
805
|
+
if (error instanceof GitHubRateLimitError) {
|
|
806
|
+
displayedWidget = undefined;
|
|
807
|
+
reconcileWidget(ctx);
|
|
808
|
+
reportRefreshFailure(error);
|
|
809
|
+
} else {
|
|
810
|
+
reconcileWidget(ctx);
|
|
811
|
+
refreshInBackground();
|
|
812
|
+
}
|
|
810
813
|
}
|
|
811
814
|
throw error;
|
|
812
815
|
}
|
package/package.json
CHANGED