@henryqw/pi-pr 0.2.0 → 0.3.0
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 +2 -2
- package/extensions/pr.ts +87 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,9 +17,9 @@ Requires authenticated GitHub CLI access (`gh auth login`) in a GitHub repositor
|
|
|
17
17
|
| Footer | UI | Show the current branch pull request. |
|
|
18
18
|
| `/pr` | command | Open current branch PR, or start PR workflow when absent. |
|
|
19
19
|
|
|
20
|
-
Each entry is one linked `PR #number` plus one plain-language state: `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `merge conflict`, `merged`, or `closed`.
|
|
20
|
+
Each entry is one linked `PR #number` plus one plain-language state: `<count> unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `merge conflict`, `merged`, or `closed`. Known unresolved review threads take priority, followed by merge conflict, changes requested, CI failure, then CI progress. Colors support text; they do not carry meaning alone.
|
|
21
21
|
|
|
22
|
-
The status loads at session start, polls every 30 seconds, and refreshes after an agent successfully runs `gh pr create` or
|
|
22
|
+
The status loads at session start, polls every 30 seconds, and refreshes after an agent successfully runs `gh pr create`, `git push`, or `/pr`. Unresolved review threads are checked every 30 seconds for 20 minutes after an open PR is first found. Each new push or remote PR update, including new comments, restarts that window. Footer and warning notification show the unresolved count when first found or increased. Last known footer count remains after review checks stop. No pull request leaves the footer blank.
|
|
23
23
|
|
|
24
24
|
`/pr` finds an open PR for current branch. When absent, it starts bundled `/skill:pi-pr-create` workflow. Agent resolves base, inspects and commits scoped changes, runs relevant validation, pushes branch, and creates or updates PR with live title and body. This workflow handles dirty worktrees; it never silently commits unrelated changes.
|
|
25
25
|
|
package/extensions/pr.ts
CHANGED
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
import { hyperlink } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
8
|
const POLL_INTERVAL_MS = 30_000;
|
|
9
|
-
const
|
|
9
|
+
const REVIEW_POLL_WINDOW_MS = 20 * 60_000;
|
|
10
|
+
const PR_FIELDS = "id,number,url,headRefOid,updatedAt,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
|
|
11
|
+
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
10
12
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
13
|
+
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
11
14
|
const CREATE_PR_SKILL_COMMAND = "skill:pi-pr-create";
|
|
12
15
|
const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
|
|
13
16
|
const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
|
|
@@ -15,8 +18,11 @@ const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
|
|
|
15
18
|
type Lifecycle = "D" | "O" | "M" | "C";
|
|
16
19
|
type CiStatus = "success" | "running" | "failure" | "none";
|
|
17
20
|
type PullRequest = {
|
|
21
|
+
id: string;
|
|
18
22
|
number: number;
|
|
19
23
|
url: string;
|
|
24
|
+
headRefOid: string;
|
|
25
|
+
updatedAt: string;
|
|
20
26
|
lifecycle: Lifecycle;
|
|
21
27
|
mergeable: string;
|
|
22
28
|
reviewDecision: string | null;
|
|
@@ -40,15 +46,21 @@ function pullRequestUrl(value: unknown): string | undefined {
|
|
|
40
46
|
export function parsePullRequest(value: unknown): PullRequest | undefined {
|
|
41
47
|
if (!isRecord(value)) return undefined;
|
|
42
48
|
|
|
49
|
+
const id = value.id;
|
|
43
50
|
const number = value.number;
|
|
44
51
|
const url = pullRequestUrl(value.url);
|
|
52
|
+
const headRefOid = value.headRefOid;
|
|
53
|
+
const updatedAt = value.updatedAt;
|
|
45
54
|
const state = value.state;
|
|
46
55
|
const isDraft = value.isDraft;
|
|
47
56
|
const mergeable = value.mergeable;
|
|
48
57
|
const reviewDecision = value.reviewDecision;
|
|
49
58
|
const statusCheckRollup = value.statusCheckRollup;
|
|
50
59
|
if (
|
|
51
|
-
typeof
|
|
60
|
+
typeof id !== "string" || !id ||
|
|
61
|
+
typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url ||
|
|
62
|
+
typeof headRefOid !== "string" || !headRefOid ||
|
|
63
|
+
typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt)) || typeof state !== "string" ||
|
|
52
64
|
typeof isDraft !== "boolean" || typeof mergeable !== "string" ||
|
|
53
65
|
(reviewDecision !== null && typeof reviewDecision !== "string") ||
|
|
54
66
|
(statusCheckRollup !== null && !Array.isArray(statusCheckRollup))
|
|
@@ -57,7 +69,17 @@ export function parsePullRequest(value: unknown): PullRequest | undefined {
|
|
|
57
69
|
const lifecycle = state === "MERGED" ? "M" : state === "CLOSED" ? "C" : state === "OPEN" ? isDraft ? "D" : "O" : undefined;
|
|
58
70
|
if (!lifecycle) return undefined;
|
|
59
71
|
|
|
60
|
-
return { number, url, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
|
|
72
|
+
return { id, number, url, headRefOid, updatedAt, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseUnresolvedReviewCount(value: string): number {
|
|
76
|
+
if (!value.trim()) throw new Error("Read review comments failed: invalid GitHub CLI output");
|
|
77
|
+
const pages = value.trim().split(/\s+/).map(Number);
|
|
78
|
+
const count = pages.reduce((total, page) => total + page, 0);
|
|
79
|
+
if (pages.some((page) => !Number.isSafeInteger(page) || page < 0) || !Number.isSafeInteger(count)) {
|
|
80
|
+
throw new Error("Read review comments failed: invalid GitHub CLI output");
|
|
81
|
+
}
|
|
82
|
+
return count;
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
function parseRepositoryName(json: string): string {
|
|
@@ -125,9 +147,11 @@ function statusFor(pullRequest: PullRequest, ci: CiStatus): Status {
|
|
|
125
147
|
return { text: "open", color: "accent" };
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"]): string {
|
|
150
|
+
export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"], unresolved = 0): string {
|
|
129
151
|
const link = hyperlink(theme.fg("text", `PR #${pullRequest.number}`), pullRequest.url);
|
|
130
|
-
const status =
|
|
152
|
+
const status = unresolved > 0
|
|
153
|
+
? { text: `${unresolved} unresolved`, color: "warning" as const }
|
|
154
|
+
: statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
|
|
131
155
|
return `${link} · ${theme.fg(status.color, status.text)}`;
|
|
132
156
|
}
|
|
133
157
|
|
|
@@ -136,10 +160,14 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
|
136
160
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
137
161
|
let active: AbortController | undefined;
|
|
138
162
|
let queued = false;
|
|
163
|
+
let reviewState: { id: string; unresolved: number } | undefined;
|
|
164
|
+
let reviewWindow: { id: string; headRefOid: string; updatedAt: string; until: number } | undefined;
|
|
139
165
|
|
|
140
166
|
const stop = () => {
|
|
141
167
|
context = undefined;
|
|
142
168
|
queued = false;
|
|
169
|
+
reviewState = undefined;
|
|
170
|
+
reviewWindow = undefined;
|
|
143
171
|
if (timer !== undefined) clearInterval(timer);
|
|
144
172
|
timer = undefined;
|
|
145
173
|
active?.abort();
|
|
@@ -169,7 +197,59 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
const pullRequest = parsePullRequest(JSON.parse(result.stdout));
|
|
172
|
-
|
|
200
|
+
if (!pullRequest) {
|
|
201
|
+
reviewState = undefined;
|
|
202
|
+
reviewWindow = undefined;
|
|
203
|
+
ctx.ui.setStatus("pi-pr", undefined);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (pullRequest.lifecycle === "M" || pullRequest.lifecycle === "C") {
|
|
207
|
+
reviewState = undefined;
|
|
208
|
+
reviewWindow = undefined;
|
|
209
|
+
ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const changedPullRequest = reviewWindow?.id !== pullRequest.id;
|
|
213
|
+
if (
|
|
214
|
+
changedPullRequest ||
|
|
215
|
+
reviewWindow?.headRefOid !== pullRequest.headRefOid ||
|
|
216
|
+
reviewWindow?.updatedAt !== pullRequest.updatedAt
|
|
217
|
+
) {
|
|
218
|
+
if (changedPullRequest) reviewState = undefined;
|
|
219
|
+
reviewWindow = {
|
|
220
|
+
id: pullRequest.id,
|
|
221
|
+
headRefOid: pullRequest.headRefOid,
|
|
222
|
+
updatedAt: pullRequest.updatedAt,
|
|
223
|
+
until: Date.now() + REVIEW_POLL_WINDOW_MS,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
ctx.ui.setStatus("pi-pr", formatPullRequest(
|
|
227
|
+
pullRequest,
|
|
228
|
+
ctx.ui.theme,
|
|
229
|
+
reviewState?.id === pullRequest.id ? reviewState.unresolved : 0,
|
|
230
|
+
));
|
|
231
|
+
if (Date.now() >= reviewWindow.until) return;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const reviews = await pi.exec(
|
|
235
|
+
"gh",
|
|
236
|
+
[
|
|
237
|
+
"api", "graphql", "--hostname", new URL(pullRequest.url).hostname, "--paginate",
|
|
238
|
+
"-f", `query=${REVIEW_THREADS_QUERY}`, "-F", `id=${pullRequest.id}`,
|
|
239
|
+
"--jq", "[.data.node.reviewThreads.nodes[] | select(.isResolved == false)] | length",
|
|
240
|
+
],
|
|
241
|
+
{ cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
|
|
242
|
+
);
|
|
243
|
+
if (controller.signal.aborted || context !== ctx || reviews.code !== 0) return;
|
|
244
|
+
const unresolved = parseUnresolvedReviewCount(reviews.stdout);
|
|
245
|
+
if (unresolved > 0 && (reviewState?.id !== pullRequest.id || unresolved > reviewState.unresolved)) {
|
|
246
|
+
ctx.ui.notify(`PR #${pullRequest.number} has ${unresolved} unresolved review thread${unresolved === 1 ? "" : "s"}`, "warning");
|
|
247
|
+
}
|
|
248
|
+
reviewState = { id: pullRequest.id, unresolved };
|
|
249
|
+
ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme, unresolved));
|
|
250
|
+
} catch {
|
|
251
|
+
// Keep known PR status when review lookup fails.
|
|
252
|
+
}
|
|
173
253
|
} catch {
|
|
174
254
|
if (!controller.signal.aborted && context === ctx) ctx.ui.setStatus("pi-pr", undefined);
|
|
175
255
|
} finally {
|
|
@@ -195,7 +275,7 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
|
195
275
|
pi.on("tool_result", async (event, ctx) => {
|
|
196
276
|
if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
|
|
197
277
|
const command = event.input.command;
|
|
198
|
-
if (typeof command === "string" && GH_PR_CREATE.test(command)) await refresh(true);
|
|
278
|
+
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) await refresh(true);
|
|
199
279
|
});
|
|
200
280
|
|
|
201
281
|
pi.registerCommand("pr", {
|