@henryqw/pi-pr 2.0.1 → 2.0.2

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,11 @@ 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. 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.
73
+ The footer and widget load at session start. They refresh after local commits, PR creation, pushes, and the creation workflow. They also poll every 30 seconds. Polling updates presentation only and may be stale.
74
+
75
+ The create hint stays hidden until the local branch has a commit beyond its creation point. It clears as soon as `/pr` starts creation. It returns if the workflow finishes without a pull request.
76
+
77
+ 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
78
 
75
79
  ## Safety limits
76
80
 
@@ -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 = RegisteredCommand["handler"];
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
  }
@@ -877,7 +877,6 @@ export async function loadCurrentPullRequest(
877
877
  ): Promise<CurrentPullRequest | null> {
878
878
  const pushTarget = await readPushTarget(pi, context);
879
879
  if (pushTarget === null) return null;
880
- const [headOwner] = pushTarget.repository.nameWithOwner.split("/");
881
880
  const search = await execute(pi, context, "Find pull requests", "gh", [
882
881
  "api",
883
882
  "search/issues",
@@ -888,7 +887,7 @@ export async function loadCurrentPullRequest(
888
887
  "-X",
889
888
  "GET",
890
889
  "-f",
891
- `q=is:pr head:${headOwner}:${pushTarget.ref}`,
890
+ `q=is:pr head:${pushTarget.ref}${pushTarget.remoteHeadOid === null ? "" : ` ${pushTarget.remoteHeadOid}`}`,
892
891
  "-f",
893
892
  `per_page=${PR_LIST_LIMIT}`,
894
893
  ]);
package/extensions/pr.ts CHANGED
@@ -34,10 +34,15 @@ 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 displayedNextStep: ReturnType<typeof projectPrDisplay>["nextStep"] | undefined;
43
+ let displayedWidget: string | undefined;
44
+ let commandGeneration = 0;
45
+ const pendingCreations = new Set<number>();
41
46
 
42
47
  const render = (
43
48
  ctx: ExtensionContext,
@@ -49,15 +54,23 @@ export default function pullRequestExtension(
49
54
  if (pullRequest !== null && footer === undefined) {
50
55
  throw new Error("Current pull request display is missing a footer");
51
56
  }
52
- const widget = formatPrWidget(display);
57
+ if (pullRequest !== null) pendingCreations.clear();
58
+ displayedWidget = formatPrWidget(display);
59
+ const widget = pendingCreations.size > 0 && display.nextStep === "create" ? undefined : displayedWidget;
60
+ displayedNextStep = display.nextStep;
53
61
  ctx.ui.setStatus(UI_KEY, footer);
54
62
  ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
55
63
  };
56
64
 
57
65
  const stop = (): void => {
66
+ sessionGeneration += 1;
58
67
  context = undefined;
59
68
  queued = false;
60
69
  refreshFailureReported = false;
70
+ displayedNextStep = undefined;
71
+ displayedWidget = undefined;
72
+ commandGeneration = 0;
73
+ pendingCreations.clear();
61
74
  if (timer !== undefined) clearInterval(timer);
62
75
  timer = undefined;
63
76
  active?.abort();
@@ -79,6 +92,7 @@ export default function pullRequestExtension(
79
92
  const refresh = async (): Promise<void> => {
80
93
  const ctx = context;
81
94
  if (!ctx) return;
95
+ const generation = sessionGeneration;
82
96
  if (active) {
83
97
  queued = true;
84
98
  return;
@@ -92,13 +106,14 @@ export default function pullRequestExtension(
92
106
  let localCommit = false;
93
107
  try {
94
108
  pullRequest = await load(pi, loadContext);
109
+ if (controller.signal.aborted || sessionGeneration !== generation) return;
95
110
  if (pullRequest === null) localCommit = await detectLocalCommit(pi, loadContext);
96
111
  } catch (error) {
97
112
  // Keep the last known display when lookup is unavailable.
98
- if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
113
+ if (!controller.signal.aborted && sessionGeneration === generation) reportRefreshFailure(error);
99
114
  return;
100
115
  }
101
- if (controller.signal.aborted || context !== ctx) return;
116
+ if (controller.signal.aborted || sessionGeneration !== generation) return;
102
117
  render(ctx, pullRequest, localCommit);
103
118
  refreshFailureReported = false;
104
119
  } finally {
@@ -115,16 +130,30 @@ export default function pullRequestExtension(
115
130
  void refresh().catch(reportRefreshFailure);
116
131
  };
117
132
 
133
+ const cancelRefresh = (): void => {
134
+ active?.abort();
135
+ active = undefined;
136
+ queued = false;
137
+ };
138
+
118
139
  pi.on("session_start", async (_event, ctx) => {
119
140
  stop();
141
+ const generation = sessionGeneration;
120
142
  if (!ctx.hasUI) return;
121
143
  context = ctx;
122
144
  await refresh();
123
- if (context === ctx) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
145
+ if (sessionGeneration === generation) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
124
146
  });
125
147
 
126
148
  pi.on("session_shutdown", stop);
127
149
 
150
+ pi.on("agent_settled", async (_event, ctx) => {
151
+ if (!ctx.hasUI || !ctx.isIdle() || !context || !pendingCreations.size) return;
152
+ cancelRefresh();
153
+ pendingCreations.clear();
154
+ await refresh().catch(reportRefreshFailure);
155
+ });
156
+
128
157
  pi.on("tool_result", async (event, ctx) => {
129
158
  if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
130
159
  const command = event.input.command;
@@ -137,10 +166,38 @@ export default function pullRequestExtension(
137
166
  pi.registerCommand("pr", {
138
167
  description: "Run the current branch pull request next step",
139
168
  handler: async (args, ctx) => {
140
- if (!ctx.hasUI) return;
169
+ if (!ctx.hasUI || !context) return;
170
+ const generation = sessionGeneration;
171
+ const invocation = ++commandGeneration;
172
+ const displayedCreation = displayedNextStep === "create";
173
+ if (displayedCreation) {
174
+ pendingCreations.add(invocation);
175
+ ctx.ui.setWidget(UI_KEY, undefined);
176
+ }
177
+ let nextStep: Awaited<ReturnType<typeof commandHandler>>;
141
178
  try {
142
- await commandHandler(args, ctx);
143
- } finally {
179
+ nextStep = await commandHandler(args, ctx);
180
+ } catch (error) {
181
+ if (sessionGeneration === generation) {
182
+ cancelRefresh();
183
+ pendingCreations.delete(invocation);
184
+ if (!pendingCreations.size) {
185
+ ctx.ui.setWidget(UI_KEY, displayedNextStep === "create" && displayedWidget !== undefined
186
+ ? [displayedWidget]
187
+ : undefined);
188
+ }
189
+ refreshInBackground();
190
+ }
191
+ throw error;
192
+ }
193
+ if (sessionGeneration !== generation) return;
194
+ cancelRefresh();
195
+ if (nextStep === "create") {
196
+ pendingCreations.add(invocation);
197
+ ctx.ui.setStatus(UI_KEY, undefined);
198
+ ctx.ui.setWidget(UI_KEY, undefined);
199
+ } else {
200
+ pendingCreations.delete(invocation);
144
201
  refreshInBackground();
145
202
  }
146
203
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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:${owner}:${push.ref}`,
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:Owner:feature/pr -f per_page=100" ]; then
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:Owner:feature/pr -f per_page=100`);
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:Owner:feature/pr -f per_page=100" ]; then
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:Owner:feature/pr -f per_page=100`);
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