@henryqw/pi-pr 1.0.0 → 1.0.1

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
@@ -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
 
@@ -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, headOid, remoteHeadOid, repository, ref: push.ref };
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,
@@ -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) return { nextStep, widget: "Run /pr to create pull request" };
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 { loadCurrentPullRequest } from "./pr-github.ts";
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 = (ctx: ExtensionContext, pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>): void => {
37
- const display = projectPrDisplay(pullRequest);
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": "1.0.0",
3
+ "version": "1.0.1",
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,7 +22,7 @@
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
  },