@henryqw/pi-pr 2.0.4 → 2.1.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
@@ -14,6 +14,8 @@ The comment sweep resolves its bundled helper and references from the installed
14
14
 
15
15
  ## Works with
16
16
 
17
+ **Requires.** [`@henryqw/pi-herdr`](https://pi.henry.wang/extensions/pi-herdr) is the shared Herdr CLI client. It installs with this package.
18
+
17
19
  **Improves.** [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) shows current-branch pull-request status in the footer.
18
20
 
19
21
  ## Use
@@ -23,7 +25,9 @@ Run `/pr` without arguments in a GitHub checkout. It reads the current branch pu
23
25
  | Surface | Type | Purpose |
24
26
  | --- | --- | --- |
25
27
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
26
- | Widget hint | ui | Show at most one hint for the next `/pr` step. |
28
+ | Widget | ui | Show one actionable icon-prefixed `Run /pr to …` hint. |
29
+
30
+ The footer already shows the pull request and status. Actionable widgets omit duplicate identity and status. Each uses one semantic status icon, a space, and a plain `Run /pr to …` route. `✗` marks errors, `!` warnings, `✓` success, and `●` accent or neutral routes. In TUI, only the icon uses a theme color. RPC and non-TUI output use the same plain text without ANSI.
27
31
 
28
32
  ## Flow
29
33
 
@@ -44,6 +48,16 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
44
48
 
45
49
  `pi-pr-create` honors an existing configured push target. Without one, it pushes a captured OID to the local branch ref on `origin` and sets upstream.
46
50
 
51
+ After a `/pr` create workflow settles, the extension waits for a refresh that finds an open current PR. It then ends the Herdr workspace label with one ` · PR #<number>` suffix.
52
+
53
+ Failed or empty discovery leaves one rename pending for a later refresh. Closed or merged historical matches do not trigger it.
54
+
55
+ It removes every stale trailing PR suffix before adding the current one. This requires `HERDR_ENV=1` and a non-empty, trimmed `HERDR_WORKSPACE_ID`.
56
+
57
+ It renames only the workspace. Outside Herdr, it does nothing.
58
+
59
+ If Herdr lookup, JSON validation, or rename fails, the PR and normal UI refresh remain available. Each Herdr command has a 10-second timeout. The extension warns with `Herdr workspace rename failed: <error>`.
60
+
47
61
  Current-branch discovery matches the exact push repository and ref. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
48
62
 
49
63
  A no-action state includes a draft, merged or closed pull request, running CI, pending review, or blocked merge policy. It also includes a mutating workflow whose tree is dirty or whose local HEAD differs from the PR head.
@@ -65,7 +79,7 @@ Ordinary conversation comments do not trigger a route or block a merge. Changes
65
79
 
66
80
  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
81
 
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.
82
+ The create widget stays hidden until the local branch has a commit beyond its creation point. Any displayed widget 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 widget when the handler finishes.
69
83
 
70
84
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
71
85
 
@@ -1,4 +1,4 @@
1
- import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
1
+ import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  deriveNextStep,
4
4
  type NextStep,
@@ -93,6 +93,10 @@ export function formatPrFooter(display: PrDisplay, theme: PrTheme): string | und
93
93
  return `${link} · ${theme.fg(display.footer.color, display.footer.text)}`;
94
94
  }
95
95
 
96
- export function formatPrWidget(display: PrDisplay): string | undefined {
97
- return display.widget;
96
+ export function formatPrWidget(display: PrDisplay, theme?: PrTheme, width?: number): string[] | undefined {
97
+ if (display.widget === undefined) return undefined;
98
+ const color = display.footer?.color ?? "accent";
99
+ const icon = color === "error" ? "✗" : color === "warning" ? "!" : color === "success" ? "✓" : "●";
100
+ const line = `${theme ? theme.fg(color, icon) : icon} ${display.widget}`;
101
+ return [width === undefined ? line : truncateToWidth(line, Math.max(1, width))];
98
102
  }
package/extensions/pr.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { createHerdrClient } from "@henryqw/pi-herdr";
6
7
  import { createPrCommandHandler } from "./pr-command.ts";
7
8
  import {
8
9
  hasLocalCommit,
@@ -12,9 +13,11 @@ import {
12
13
  formatPrFooter,
13
14
  formatPrWidget,
14
15
  projectPrDisplay,
16
+ type PrDisplay,
15
17
  } from "./pr-ui.ts";
16
18
 
17
19
  const POLL_INTERVAL_MS = 30_000;
20
+ const HERDR_TIMEOUT_MS = 10_000;
18
21
  const UI_KEY = "pi-pr";
19
22
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
20
23
  const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
@@ -26,10 +29,54 @@ type PullRequestExtensionDependencies = {
26
29
  createPrCommandHandler?: typeof createPrCommandHandler;
27
30
  };
28
31
 
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function parseWorkspaceLabel(response: Record<string, unknown>, workspaceId: string): string {
37
+ const result = response.result;
38
+ const workspace = isRecord(result) && isRecord(result.workspace) ? result.workspace : undefined;
39
+ if (!workspace || workspace.workspace_id !== workspaceId) {
40
+ throw new Error("workspace get returned a different workspace_id");
41
+ }
42
+ const label = workspace.label;
43
+ if (typeof label !== "string" || !label.trim()) {
44
+ throw new Error("workspace get returned an empty label");
45
+ }
46
+ return label;
47
+ }
48
+
29
49
  export default function pullRequestExtension(
30
50
  pi: ExtensionAPI,
31
51
  dependencies: PullRequestExtensionDependencies = {},
32
52
  ): void {
53
+ const herdr = createHerdrClient(pi.exec.bind(pi));
54
+ const renameHerdrWorkspace = async (
55
+ cwd: string,
56
+ signal: AbortSignal,
57
+ workspaceId: string,
58
+ pullRequestNumber: number,
59
+ ): Promise<void> => {
60
+ signal.throwIfAborted();
61
+ const current = await herdr.json(["workspace", "get", workspaceId], {
62
+ cwd,
63
+ signal,
64
+ timeout: HERDR_TIMEOUT_MS,
65
+ });
66
+ signal.throwIfAborted();
67
+ const label = parseWorkspaceLabel(current, workspaceId);
68
+ const normalized = `${label.replace(/(?: · PR #[1-9][0-9]*)+$/, "")} · PR #${pullRequestNumber}`;
69
+ if (normalized === label) return;
70
+
71
+ signal.throwIfAborted();
72
+ await herdr.run(["workspace", "rename", workspaceId, normalized], {
73
+ cwd,
74
+ signal,
75
+ timeout: HERDR_TIMEOUT_MS,
76
+ });
77
+ signal.throwIfAborted();
78
+ };
79
+
33
80
  const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
34
81
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
35
82
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
@@ -39,9 +86,25 @@ export default function pullRequestExtension(
39
86
  let active: AbortController | undefined;
40
87
  let queued = false;
41
88
  let refreshFailureReported = false;
42
- let displayedWidget: string | undefined;
89
+ let pendingWorkspaceRename = false;
90
+ let displayedWidget: PrDisplay | undefined;
43
91
  let commandGeneration = 0;
44
- const activeInvocations = new Map<number, "routing" | "workflow">();
92
+ const activeInvocations = new Map<number, "routing" | "create-workflow" | "workflow">();
93
+
94
+ const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
95
+ if (display?.widget === undefined) {
96
+ ctx.ui.setWidget(UI_KEY, undefined);
97
+ return;
98
+ }
99
+ if (ctx.mode === "tui") {
100
+ ctx.ui.setWidget(UI_KEY, (_tui, theme) => ({
101
+ invalidate() {},
102
+ render: (width) => formatPrWidget(display, theme, width)!,
103
+ }));
104
+ return;
105
+ }
106
+ ctx.ui.setWidget(UI_KEY, formatPrWidget(display));
107
+ };
45
108
 
46
109
  const render = (
47
110
  ctx: ExtensionContext,
@@ -53,10 +116,10 @@ export default function pullRequestExtension(
53
116
  if (pullRequest !== null && footer === undefined) {
54
117
  throw new Error("Current pull request display is missing a footer");
55
118
  }
56
- displayedWidget = formatPrWidget(display);
119
+ displayedWidget = display.widget === undefined ? undefined : display;
57
120
  const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
58
121
  ctx.ui.setStatus(UI_KEY, footer);
59
- ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
122
+ setWidget(ctx, widget);
60
123
  };
61
124
 
62
125
  const stop = (): void => {
@@ -64,6 +127,7 @@ export default function pullRequestExtension(
64
127
  context = undefined;
65
128
  queued = false;
66
129
  refreshFailureReported = false;
130
+ pendingWorkspaceRename = false;
67
131
  displayedWidget = undefined;
68
132
  commandGeneration = 0;
69
133
  activeInvocations.clear();
@@ -85,6 +149,15 @@ export default function pullRequestExtension(
85
149
  }
86
150
  };
87
151
 
152
+ const reportHerdrRenameFailure = (ctx: ExtensionContext, error: unknown): void => {
153
+ try {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ ctx.ui.notify(`Herdr workspace rename failed: ${message.slice(0, 500)}`, "warning");
156
+ } catch (reportError) {
157
+ console.error("Herdr workspace rename failed and could not be reported", error, reportError);
158
+ }
159
+ };
160
+
88
161
  const refresh = async (): Promise<void> => {
89
162
  const ctx = context;
90
163
  if (!ctx) return;
@@ -112,6 +185,20 @@ export default function pullRequestExtension(
112
185
  if (controller.signal.aborted || sessionGeneration !== generation) return;
113
186
  render(ctx, pullRequest, localCommit);
114
187
  refreshFailureReported = false;
188
+
189
+ if (pendingWorkspaceRename && pullRequest?.lifecycle === "open") {
190
+ pendingWorkspaceRename = false;
191
+ const workspaceId = process.env.HERDR_WORKSPACE_ID?.trim();
192
+ if (process.env.HERDR_ENV === "1" && workspaceId) {
193
+ try {
194
+ await renameHerdrWorkspace(ctx.cwd, controller.signal, workspaceId, pullRequest.number);
195
+ } catch (error) {
196
+ if (!controller.signal.aborted && sessionGeneration === generation) {
197
+ reportHerdrRenameFailure(ctx, error);
198
+ }
199
+ }
200
+ }
201
+ }
115
202
  } finally {
116
203
  if (active !== controller) return;
117
204
  active = undefined;
@@ -146,13 +233,16 @@ export default function pullRequestExtension(
146
233
  pi.on("agent_settled", async (_event, ctx) => {
147
234
  if (!ctx.hasUI || !ctx.isIdle() || !context) return;
148
235
  let workflowSettled = false;
236
+ let createWorkflowSettled = false;
149
237
  for (const [invocation, phase] of activeInvocations) {
150
- if (phase !== "workflow") continue;
238
+ if (phase !== "workflow" && phase !== "create-workflow") continue;
151
239
  activeInvocations.delete(invocation);
152
240
  workflowSettled = true;
241
+ if (phase === "create-workflow") createWorkflowSettled = true;
153
242
  }
154
243
  if (!workflowSettled) return;
155
244
  cancelRefresh();
245
+ if (createWorkflowSettled) pendingWorkspaceRename = true;
156
246
  await refresh().catch(reportRefreshFailure);
157
247
  });
158
248
 
@@ -172,7 +262,7 @@ export default function pullRequestExtension(
172
262
  const generation = sessionGeneration;
173
263
  const invocation = ++commandGeneration;
174
264
  activeInvocations.set(invocation, "routing");
175
- if (displayedWidget !== undefined) ctx.ui.setWidget(UI_KEY, undefined);
265
+ if (displayedWidget !== undefined) setWidget(ctx, undefined);
176
266
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
177
267
  try {
178
268
  nextStep = await commandHandler(args, ctx);
@@ -181,7 +271,7 @@ export default function pullRequestExtension(
181
271
  cancelRefresh();
182
272
  activeInvocations.delete(invocation);
183
273
  if (!activeInvocations.size) {
184
- ctx.ui.setWidget(UI_KEY, displayedWidget === undefined ? undefined : [displayedWidget]);
274
+ setWidget(ctx, displayedWidget);
185
275
  }
186
276
  refreshInBackground();
187
277
  }
@@ -190,9 +280,9 @@ export default function pullRequestExtension(
190
280
  if (sessionGeneration !== generation) return;
191
281
  cancelRefresh();
192
282
  if (nextStep !== "none" && nextStep !== "merge") {
193
- activeInvocations.set(invocation, "workflow");
283
+ activeInvocations.set(invocation, nextStep === "create" ? "create-workflow" : "workflow");
194
284
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
195
- ctx.ui.setWidget(UI_KEY, undefined);
285
+ setWidget(ctx, undefined);
196
286
  } else {
197
287
  activeInvocations.delete(invocation);
198
288
  refreshInBackground();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.0.4",
3
+ "version": "2.1.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",
@@ -30,6 +30,9 @@
30
30
  "@earendil-works/pi-coding-agent": "^0.85.1",
31
31
  "@earendil-works/pi-tui": "^0.85.1"
32
32
  },
33
+ "dependencies": {
34
+ "@henryqw/pi-herdr": "^0.4.5"
35
+ },
33
36
  "repository": {
34
37
  "type": "git",
35
38
  "url": "git+https://github.com/HenryQW/pi-harness.git",
@@ -1024,6 +1024,7 @@ if [ "$1" = pr ] && [ "$2" = view ]; then
1024
1024
  exit 1
1025
1025
  fi
1026
1026
  if [ "$1" = api ] && [ "$2" = graphql ] && [ "$3" = --hostname ] && [ "$4" = ${enterpriseHost} ]; then
1027
+ cat >/dev/null
1027
1028
  echo 'host GraphQL failed' >&2
1028
1029
  exit 1
1029
1030
  fi