@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
|
|
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
|
|
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
|
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|