@henryqw/pi-pr 1.0.0 → 2.0.0
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 +1 -1
- package/extensions/pr-github.ts +36 -77
- package/extensions/pr-ui.ts +7 -2
- package/extensions/pr.ts +17 -5
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Ordinary conversation comments do not trigger a route or block a merge. Changes
|
|
|
70
70
|
|
|
71
71
|
## Refresh
|
|
72
72
|
|
|
73
|
-
The footer and widget load at session start and poll every 30 seconds. Polling updates presentation only and may be stale. Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
|
|
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.
|
|
74
74
|
|
|
75
75
|
## Safety limits
|
|
76
76
|
|
package/extensions/pr-github.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { lstat } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
1
|
import type {
|
|
4
2
|
ExtensionAPI,
|
|
5
3
|
ExtensionContext,
|
|
6
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { inspectLocalMergeSafety } from "./pr-merge.ts";
|
|
7
6
|
import type {
|
|
8
7
|
CiStatus,
|
|
9
8
|
LocalMergeSafety,
|
|
@@ -110,7 +109,6 @@ type PushUrl = {
|
|
|
110
109
|
|
|
111
110
|
type PushTarget = {
|
|
112
111
|
fetchSource: string;
|
|
113
|
-
headOid: string;
|
|
114
112
|
remoteHeadOid: string | null;
|
|
115
113
|
repository: PushRepository;
|
|
116
114
|
ref: string;
|
|
@@ -707,6 +705,29 @@ function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[]
|
|
|
707
705
|
return { allowedMergeMethods, viewerDefaultMergeMethod };
|
|
708
706
|
}
|
|
709
707
|
|
|
708
|
+
export async function hasLocalCommit(
|
|
709
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
710
|
+
context: PullRequestLoadContext,
|
|
711
|
+
): Promise<boolean> {
|
|
712
|
+
const branch = singleLine(
|
|
713
|
+
(await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
|
|
714
|
+
"Read current branch",
|
|
715
|
+
"branch",
|
|
716
|
+
);
|
|
717
|
+
const output = (await execute(pi, context, "Read branch history", "git", [
|
|
718
|
+
"reflog",
|
|
719
|
+
"show",
|
|
720
|
+
"--format=%H",
|
|
721
|
+
`refs/heads/${branch}`,
|
|
722
|
+
])).stdout.replace(/\r\n/g, "\n");
|
|
723
|
+
const entries = output.split("\n");
|
|
724
|
+
if (entries.at(-1) === "") entries.pop();
|
|
725
|
+
if (!entries.length) fail("Read branch history", "missing branch creation entry");
|
|
726
|
+
const commits = entries.map((entry) => oid(entry, "Read branch history", "commit"));
|
|
727
|
+
// ponytail: reflog expiry can hide old branch history; resolve the PR base if this becomes observable.
|
|
728
|
+
return commits[0] !== commits.at(-1);
|
|
729
|
+
}
|
|
730
|
+
|
|
710
731
|
async function readPushTarget(
|
|
711
732
|
pi: Pick<ExtensionAPI, "exec">,
|
|
712
733
|
context: PullRequestLoadContext,
|
|
@@ -716,11 +737,6 @@ async function readPushTarget(
|
|
|
716
737
|
"Read current branch",
|
|
717
738
|
"branch",
|
|
718
739
|
);
|
|
719
|
-
const headOid = oid(
|
|
720
|
-
singleLine((await execute(pi, context, "Read current HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout, "Read current HEAD", "HEAD"),
|
|
721
|
-
"Read current HEAD",
|
|
722
|
-
"HEAD",
|
|
723
|
-
);
|
|
724
740
|
const pushReference = optionalPushReference(
|
|
725
741
|
(await execute(pi, context, "Read push target", "git", [
|
|
726
742
|
"for-each-ref",
|
|
@@ -771,7 +787,7 @@ async function readPushTarget(
|
|
|
771
787
|
if (remoteHead.code !== 0) commandFailure("Read remote push ref", remoteHead);
|
|
772
788
|
remoteHeadOid = parseRemotePushRef(remoteHead.stdout, push.ref);
|
|
773
789
|
}
|
|
774
|
-
return { fetchSource: pushUrl.fetchSource,
|
|
790
|
+
return { fetchSource: pushUrl.fetchSource, remoteHeadOid, repository, ref: push.ref };
|
|
775
791
|
}
|
|
776
792
|
|
|
777
793
|
async function readUnresolvedReviewThreads(
|
|
@@ -838,73 +854,6 @@ async function readRulesetBaseBranchPolicy(
|
|
|
838
854
|
return parseRulesetBaseBranchPolicy(result.stdout);
|
|
839
855
|
}
|
|
840
856
|
|
|
841
|
-
async function readLocalMergeSafety(
|
|
842
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
843
|
-
context: PullRequestLoadContext,
|
|
844
|
-
pushTarget: PushTarget,
|
|
845
|
-
pullRequestHead: string,
|
|
846
|
-
): Promise<LocalMergeSafety> {
|
|
847
|
-
const status = await execute(pi, context, "Read worktree status", "git", [
|
|
848
|
-
"status",
|
|
849
|
-
"--porcelain=v1",
|
|
850
|
-
"--untracked-files=all",
|
|
851
|
-
]);
|
|
852
|
-
const operationStates = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
853
|
-
const statePaths = lines(
|
|
854
|
-
(await execute(pi, context, "Read Git operation state", "git", [
|
|
855
|
-
"rev-parse",
|
|
856
|
-
...operationStates.flatMap((state) => ["--git-path", state]),
|
|
857
|
-
])).stdout,
|
|
858
|
-
"Read Git operation state",
|
|
859
|
-
"state path",
|
|
860
|
-
);
|
|
861
|
-
if (statePaths.length !== operationStates.length) fail("Read Git operation state", "invalid state paths");
|
|
862
|
-
let operationInProgress = false;
|
|
863
|
-
for (const [index, path] of statePaths.entries()) {
|
|
864
|
-
try {
|
|
865
|
-
await lstat(resolve(context.cwd, path));
|
|
866
|
-
operationInProgress = true;
|
|
867
|
-
} catch (error) {
|
|
868
|
-
if (isRecord(error) && error.code === "ENOENT") continue;
|
|
869
|
-
const code = isRecord(error) && typeof error.code === "string" ? error.code : "filesystem error";
|
|
870
|
-
fail("Read Git operation state", `cannot inspect ${operationStates[index]}: ${code}`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
const worktree = status.stdout === "" && !operationInProgress ? "clean" : "dirty";
|
|
874
|
-
|
|
875
|
-
await execute(pi, context, "Fetch pull request head", "git", [
|
|
876
|
-
"fetch",
|
|
877
|
-
"--no-write-fetch-head",
|
|
878
|
-
"--no-tags",
|
|
879
|
-
"--no-recurse-submodules",
|
|
880
|
-
pushTarget.fetchSource,
|
|
881
|
-
pullRequestHead,
|
|
882
|
-
]);
|
|
883
|
-
await execute(pi, context, "Verify pull request head", "git", ["cat-file", "-e", `${pullRequestHead}^{commit}`]);
|
|
884
|
-
if (pushTarget.headOid === pullRequestHead) return { worktree, head: "equal" };
|
|
885
|
-
|
|
886
|
-
const localIsAncestor = await invoke(pi, context, "Compare pull request head", "git", [
|
|
887
|
-
"merge-base",
|
|
888
|
-
"--is-ancestor",
|
|
889
|
-
pushTarget.headOid,
|
|
890
|
-
pullRequestHead,
|
|
891
|
-
]);
|
|
892
|
-
if (localIsAncestor.killed) commandFailure("Compare pull request head", localIsAncestor);
|
|
893
|
-
if (localIsAncestor.code === 0) return { worktree, head: "behind" };
|
|
894
|
-
if (localIsAncestor.code !== 1) commandFailure("Compare pull request head", localIsAncestor);
|
|
895
|
-
|
|
896
|
-
const pullRequestIsAncestor = await invoke(pi, context, "Compare pull request head", "git", [
|
|
897
|
-
"merge-base",
|
|
898
|
-
"--is-ancestor",
|
|
899
|
-
pullRequestHead,
|
|
900
|
-
pushTarget.headOid,
|
|
901
|
-
]);
|
|
902
|
-
if (pullRequestIsAncestor.killed) commandFailure("Compare pull request head", pullRequestIsAncestor);
|
|
903
|
-
if (pullRequestIsAncestor.code === 0) return { worktree, head: "ahead" };
|
|
904
|
-
if (pullRequestIsAncestor.code === 1) return { worktree, head: "diverged" };
|
|
905
|
-
return commandFailure("Compare pull request head", pullRequestIsAncestor);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
857
|
async function readMergeMethods(
|
|
909
858
|
pi: Pick<ExtensionAPI, "exec">,
|
|
910
859
|
context: PullRequestLoadContext,
|
|
@@ -973,10 +922,20 @@ export async function loadCurrentPullRequest(
|
|
|
973
922
|
: false;
|
|
974
923
|
const requiresStrictStatusChecks = legacyStrict || (rulesetPolicy?.requiresStrictStatusChecks ?? false);
|
|
975
924
|
const pullRequestConditions = conditions(candidate, unresolvedThreads, requiresStrictStatusChecks);
|
|
976
|
-
const local = inspectedLocal ?? await readLocalMergeSafety(pi, context, pushTarget, candidate.head.oid);
|
|
977
925
|
const merge = candidate.lifecycle === "open"
|
|
978
926
|
? await readMergeMethods(pi, context, candidate, rulesetPolicy?.allowedMergeMethods ?? null)
|
|
979
927
|
: null;
|
|
928
|
+
const inspected = inspectedLocal ?? await inspectLocalMergeSafety({
|
|
929
|
+
exec: (command, args, options) => pi.exec(command, args, {
|
|
930
|
+
...options,
|
|
931
|
+
signal: context.signal,
|
|
932
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
933
|
+
}),
|
|
934
|
+
cwd: context.cwd,
|
|
935
|
+
expectedHead: candidate.head.oid,
|
|
936
|
+
headFetchSource: pushTarget.fetchSource,
|
|
937
|
+
});
|
|
938
|
+
const local: LocalMergeSafety = { worktree: inspected.worktree, head: inspected.head };
|
|
980
939
|
return {
|
|
981
940
|
id: candidate.id,
|
|
982
941
|
number: candidate.number,
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -65,9 +65,14 @@ function widgetText(input: PrDisplayInput, nextStep: NextStep): string | undefin
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export function projectPrDisplay(input: PrDisplayInput | null): PrDisplay {
|
|
68
|
+
export function projectPrDisplay(input: PrDisplayInput | null, hasLocalCommit = false): PrDisplay {
|
|
69
69
|
const nextStep = deriveNextStep(input);
|
|
70
|
-
if (input === null)
|
|
70
|
+
if (input === null) {
|
|
71
|
+
return {
|
|
72
|
+
nextStep,
|
|
73
|
+
widget: hasLocalCommit ? "Run /pr to create pull request" : undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
71
76
|
|
|
72
77
|
const status = footerStatus(input, nextStep);
|
|
73
78
|
return {
|
package/extensions/pr.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { createPrCommandHandler } from "./pr-command.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
hasLocalCommit,
|
|
9
|
+
loadCurrentPullRequest,
|
|
10
|
+
} from "./pr-github.ts";
|
|
8
11
|
import {
|
|
9
12
|
formatPrFooter,
|
|
10
13
|
formatPrWidget,
|
|
@@ -14,10 +17,12 @@ import {
|
|
|
14
17
|
const POLL_INTERVAL_MS = 30_000;
|
|
15
18
|
const UI_KEY = "pi-pr";
|
|
16
19
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
20
|
+
const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
17
21
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
18
22
|
|
|
19
23
|
type PullRequestExtensionDependencies = {
|
|
20
24
|
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
25
|
+
hasLocalCommit?: typeof hasLocalCommit;
|
|
21
26
|
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
22
27
|
};
|
|
23
28
|
|
|
@@ -26,6 +31,7 @@ export default function pullRequestExtension(
|
|
|
26
31
|
dependencies: PullRequestExtensionDependencies = {},
|
|
27
32
|
): void {
|
|
28
33
|
const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
34
|
+
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
29
35
|
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
30
36
|
let context: ExtensionContext | undefined;
|
|
31
37
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -33,8 +39,12 @@ export default function pullRequestExtension(
|
|
|
33
39
|
let queued = false;
|
|
34
40
|
let refreshFailureReported = false;
|
|
35
41
|
|
|
36
|
-
const render = (
|
|
37
|
-
|
|
42
|
+
const render = (
|
|
43
|
+
ctx: ExtensionContext,
|
|
44
|
+
pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
45
|
+
localCommit: boolean,
|
|
46
|
+
): void => {
|
|
47
|
+
const display = projectPrDisplay(pullRequest, localCommit);
|
|
38
48
|
const footer = pullRequest === null ? undefined : formatPrFooter(display, ctx.ui.theme);
|
|
39
49
|
if (pullRequest !== null && footer === undefined) {
|
|
40
50
|
throw new Error("Current pull request display is missing a footer");
|
|
@@ -79,15 +89,17 @@ export default function pullRequestExtension(
|
|
|
79
89
|
active = controller;
|
|
80
90
|
try {
|
|
81
91
|
let pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
92
|
+
let localCommit = false;
|
|
82
93
|
try {
|
|
83
94
|
pullRequest = await load(pi, loadContext);
|
|
95
|
+
if (pullRequest === null) localCommit = await detectLocalCommit(pi, loadContext);
|
|
84
96
|
} catch (error) {
|
|
85
97
|
// Keep the last known display when lookup is unavailable.
|
|
86
98
|
if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
|
|
87
99
|
return;
|
|
88
100
|
}
|
|
89
101
|
if (controller.signal.aborted || context !== ctx) return;
|
|
90
|
-
render(ctx, pullRequest);
|
|
102
|
+
render(ctx, pullRequest, localCommit);
|
|
91
103
|
refreshFailureReported = false;
|
|
92
104
|
} finally {
|
|
93
105
|
if (active !== controller) return;
|
|
@@ -116,7 +128,7 @@ export default function pullRequestExtension(
|
|
|
116
128
|
pi.on("tool_result", async (event, ctx) => {
|
|
117
129
|
if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
|
|
118
130
|
const command = event.input.command;
|
|
119
|
-
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) {
|
|
131
|
+
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_COMMIT.test(command) || GIT_PUSH.test(command))) {
|
|
120
132
|
await refresh().catch(reportRefreshFailure);
|
|
121
133
|
}
|
|
122
134
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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",
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test test/*.test.ts",
|
|
25
|
+
"test": "node --test test/*.test.ts && node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs self-test",
|
|
26
26
|
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
27
27
|
"pack:check": "npm pack --dry-run"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
31
|
-
"@earendil-works/pi-tui": "^0.
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^0.85.0",
|
|
31
|
+
"@earendil-works/pi-tui": "^0.85.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|