@henryqw/pi-pr 2.1.0 → 2.1.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
@@ -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
@@ -39,13 +41,23 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
39
41
  | --- | --- |
40
42
  | No current-branch pull request, including no upstream push target | Start pull-request creation. |
41
43
  | Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
42
- | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
43
44
  | CI failed | Run the CI fix workflow when the same local prerequisite holds. |
45
+ | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
44
46
  | No-action state | Report the state without taking action. |
45
47
  | Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge directly if confirmed. |
46
48
 
47
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.
48
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
+
49
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.
50
62
 
51
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.
@@ -56,8 +68,8 @@ A missing pull request uses creation. For an existing pull request, the first ma
56
68
 
57
69
  1. Merged, closed, or draft: no action.
58
70
  2. Base update required or merge conflict. Run only with a clean tree and equal local and PR heads.
59
- 3. Changes requested or unresolved review threads. Apply the same local prerequisite.
60
- 4. CI failure. Apply the same local prerequisite.
71
+ 3. CI failure. Apply the same local prerequisite.
72
+ 4. Changes requested or unresolved review threads. Apply the same local prerequisite.
61
73
  5. Waiting or local safety block: no action.
62
74
  6. Merge-ready: allow clean local HEAD equal to or behind the PR head. Confirm, then merge directly.
63
75
 
@@ -78,9 +78,9 @@
78
78
  <line x1="704" y1="360" x2="704" y2="392" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
79
79
  <text x="148" y="384" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">4</text>
80
80
  <text x="200" y="368" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
81
- <text x="200" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Changes requested or unresolved threads</text>
81
+ <text x="200" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">CI failed</text>
82
82
  <text x="728" y="368" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
83
- <text x="728" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Sweep if clean + equal</text>
83
+ <text x="728" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Fix CI if clean + equal</text>
84
84
  <text x="1136" y="388" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
85
85
 
86
86
  <!-- 5 -->
@@ -89,9 +89,9 @@
89
89
  <line x1="704" y1="424" x2="704" y2="456" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
90
90
  <text x="148" y="448" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">5</text>
91
91
  <text x="200" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
92
- <text x="200" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">CI failed</text>
92
+ <text x="200" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Changes requested or unresolved threads</text>
93
93
  <text x="728" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
94
- <text x="728" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Fix CI if clean + equal</text>
94
+ <text x="728" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Sweep if clean + equal</text>
95
95
  <text x="1136" y="452" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
96
96
 
97
97
  <!-- 6 -->
@@ -45,10 +45,10 @@ export function deriveNextStep(pullRequest: PullRequest | null): NextStep {
45
45
  if (conditions.baseUpdateRequired || conditions.conflict) {
46
46
  return localMutationSafe(local) ? "update-branch" : "none";
47
47
  }
48
+ if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
48
49
  if (conditions.changesRequested || conditions.unresolvedThreads > 0) {
49
50
  return localMutationSafe(local) ? "sweep" : "none";
50
51
  }
51
- if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
52
52
  if (
53
53
  conditions.ci === "running" ||
54
54
  conditions.review === "pending" ||
@@ -38,9 +38,9 @@ function footerStatus(input: PrDisplayInput, nextStep: NextStep): Pick<PrFooter,
38
38
  if (conditions.draft) return { text: "draft", color: "warning" };
39
39
  if (conditions.conflict) return { text: "merge conflict", color: "error" };
40
40
  if (conditions.baseUpdateRequired) return { text: "base update required", color: "warning" };
41
+ if (conditions.ci === "failure") return { text: "CI failed", color: "error" };
41
42
  if (conditions.unresolvedThreads > 0) return { text: `${conditions.unresolvedThreads} unresolved`, color: "warning" };
42
43
  if (conditions.changesRequested) return { text: "changes requested", color: "error" };
43
- if (conditions.ci === "failure") return { text: "CI failed", color: "error" };
44
44
  if (conditions.ci === "running") return { text: "CI running", color: "warning" };
45
45
  if (nextStep === "merge") return { text: "merge-ready", color: "success" };
46
46
  if (input.approved) return { text: "approved", color: "success" };
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,
@@ -16,6 +17,7 @@ import {
16
17
  } from "./pr-ui.ts";
17
18
 
18
19
  const POLL_INTERVAL_MS = 30_000;
20
+ const HERDR_TIMEOUT_MS = 10_000;
19
21
  const UI_KEY = "pi-pr";
20
22
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
21
23
  const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
@@ -27,10 +29,54 @@ type PullRequestExtensionDependencies = {
27
29
  createPrCommandHandler?: typeof createPrCommandHandler;
28
30
  };
29
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
+
30
49
  export default function pullRequestExtension(
31
50
  pi: ExtensionAPI,
32
51
  dependencies: PullRequestExtensionDependencies = {},
33
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
+
34
80
  const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
35
81
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
36
82
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
@@ -40,9 +86,10 @@ export default function pullRequestExtension(
40
86
  let active: AbortController | undefined;
41
87
  let queued = false;
42
88
  let refreshFailureReported = false;
89
+ let pendingWorkspaceRename = false;
43
90
  let displayedWidget: PrDisplay | undefined;
44
91
  let commandGeneration = 0;
45
- const activeInvocations = new Map<number, "routing" | "workflow">();
92
+ const activeInvocations = new Map<number, "routing" | "create-workflow" | "workflow">();
46
93
 
47
94
  const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
48
95
  if (display?.widget === undefined) {
@@ -80,6 +127,7 @@ export default function pullRequestExtension(
80
127
  context = undefined;
81
128
  queued = false;
82
129
  refreshFailureReported = false;
130
+ pendingWorkspaceRename = false;
83
131
  displayedWidget = undefined;
84
132
  commandGeneration = 0;
85
133
  activeInvocations.clear();
@@ -101,6 +149,15 @@ export default function pullRequestExtension(
101
149
  }
102
150
  };
103
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
+
104
161
  const refresh = async (): Promise<void> => {
105
162
  const ctx = context;
106
163
  if (!ctx) return;
@@ -128,6 +185,20 @@ export default function pullRequestExtension(
128
185
  if (controller.signal.aborted || sessionGeneration !== generation) return;
129
186
  render(ctx, pullRequest, localCommit);
130
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
+ }
131
202
  } finally {
132
203
  if (active !== controller) return;
133
204
  active = undefined;
@@ -162,13 +233,16 @@ export default function pullRequestExtension(
162
233
  pi.on("agent_settled", async (_event, ctx) => {
163
234
  if (!ctx.hasUI || !ctx.isIdle() || !context) return;
164
235
  let workflowSettled = false;
236
+ let createWorkflowSettled = false;
165
237
  for (const [invocation, phase] of activeInvocations) {
166
- if (phase !== "workflow") continue;
238
+ if (phase !== "workflow" && phase !== "create-workflow") continue;
167
239
  activeInvocations.delete(invocation);
168
240
  workflowSettled = true;
241
+ if (phase === "create-workflow") createWorkflowSettled = true;
169
242
  }
170
243
  if (!workflowSettled) return;
171
244
  cancelRefresh();
245
+ if (createWorkflowSettled) pendingWorkspaceRename = true;
172
246
  await refresh().catch(reportRefreshFailure);
173
247
  });
174
248
 
@@ -206,7 +280,7 @@ export default function pullRequestExtension(
206
280
  if (sessionGeneration !== generation) return;
207
281
  cancelRefresh();
208
282
  if (nextStep !== "none" && nextStep !== "merge") {
209
- activeInvocations.set(invocation, "workflow");
283
+ activeInvocations.set(invocation, nextStep === "create" ? "create-workflow" : "workflow");
210
284
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
211
285
  setWidget(ctx, undefined);
212
286
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.1.0",
3
+ "version": "2.1.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",
@@ -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
@@ -36,4 +36,23 @@ Create current branch GitHub pull request.
36
36
  Reuse one result only when its base matches. Refresh its title and body.
37
37
  Stop on a different base or multiple results. Otherwise create with explicit
38
38
  `--head <OWNER>:<ref>`, `--base <base>`, title, and body file.
39
- 6. Reply only with PR URL.
39
+ 6. After successful create or reuse, retain the already validated PR URL. Parse
40
+ its PR path segment with Node's standard `URL` and derive `<number>`. Require
41
+ the positive decimal form `^[1-9][0-9]*$`.
42
+
43
+ Only when `HERDR_ENV=1` and trimmed `HERDR_WORKSPACE_ID` is non-empty, use
44
+ that trimmed value as `<workspace-id>`. Run `herdr workspace get
45
+ "<workspace-id>"`. Parse its default JSON with Node's standard library
46
+ (`node:fs` and `JSON.parse`), not `jq`. Require
47
+ `result.workspace.workspace_id` to exactly equal `<workspace-id>` and
48
+ `result.workspace.label` to be a non-empty string.
49
+ Remove every trailing suffix with `/(?: · PR #[1-9][0-9]*)+$/` from the
50
+ label, then append exactly one ` · PR #<number>` suffix. Run `herdr workspace
51
+ rename "<workspace-id>" "<normalized-label>"` only when the normalized label
52
+ differs from the current label. Rename only the workspace, never a pane, tab,
53
+ Pi session, or Git branch.
54
+
55
+ Otherwise, including outside Herdr, skip labeling silently. If any
56
+ workspace-label step fails after PR success, do not undo the PR; reply with
57
+ its URL and `Herdr workspace rename failed: <error>`. Otherwise reply only
58
+ with the PR URL.