pi-pr-status 0.1.1 → 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/extensions/pr-status.ts +315 -13
- package/package.json +1 -2
- package/lib/github.ts +0 -169
package/extensions/pr-status.ts
CHANGED
|
@@ -1,12 +1,242 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PR Status Extension
|
|
3
3
|
*
|
|
4
|
-
* Shows the
|
|
5
|
-
*
|
|
4
|
+
* Shows PR status in the pi footer status bar. Polls every 30 seconds.
|
|
5
|
+
*
|
|
6
|
+
* Detects PRs from two sources:
|
|
7
|
+
* 1. The current git branch (via `gh pr view`)
|
|
8
|
+
* 2. GitHub PR URLs in user input (e.g. "lets continue this PR: https://github.com/owner/repo/pull/123")
|
|
9
|
+
*
|
|
10
|
+
* URL detection fires on the `input` event, so the status appears immediately
|
|
11
|
+
* — even before the agent starts processing or checks out a branch.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import {
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
// --- GitHub helpers (inlined for pi extension compatibility) ---
|
|
18
|
+
|
|
19
|
+
interface CheckStatus {
|
|
20
|
+
total: number;
|
|
21
|
+
pass: number;
|
|
22
|
+
fail: number;
|
|
23
|
+
pending: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PrInfo {
|
|
27
|
+
number: number;
|
|
28
|
+
title: string;
|
|
29
|
+
url: string;
|
|
30
|
+
state: string;
|
|
31
|
+
checks: CheckStatus;
|
|
32
|
+
unresolvedThreads: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RepoInfo {
|
|
36
|
+
owner: string;
|
|
37
|
+
name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getBranch(cwd: string): string | undefined {
|
|
41
|
+
try {
|
|
42
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf-8",
|
|
45
|
+
timeout: 3000,
|
|
46
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
+
}).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getRepoInfo(cwd: string): RepoInfo | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const json = execSync("gh repo view --json owner,name", {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
60
|
+
}).trim();
|
|
61
|
+
const repo = JSON.parse(json);
|
|
62
|
+
return repo.owner?.login && repo.name ? { owner: repo.owner.login, name: repo.name } : undefined;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseChecks(statusCheckRollup: unknown[]): CheckStatus {
|
|
69
|
+
const checks: CheckStatus = { total: 0, pass: 0, fail: 0, pending: 0 };
|
|
70
|
+
for (const check of statusCheckRollup) {
|
|
71
|
+
const c = check as Record<string, string>;
|
|
72
|
+
const conclusion = (c.conclusion || "").toUpperCase();
|
|
73
|
+
const status = (c.status || "").toUpperCase();
|
|
74
|
+
const name = c.name || "";
|
|
75
|
+
|
|
76
|
+
// Skip ghost checks with no meaningful data (e.g. Vercel deployment statuses)
|
|
77
|
+
if (!name && !conclusion && !status) continue;
|
|
78
|
+
|
|
79
|
+
checks.total++;
|
|
80
|
+
if (conclusion === "SUCCESS" || conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
|
|
81
|
+
checks.pass++;
|
|
82
|
+
} else if (
|
|
83
|
+
conclusion === "FAILURE" ||
|
|
84
|
+
conclusion === "TIMED_OUT" ||
|
|
85
|
+
conclusion === "CANCELLED" ||
|
|
86
|
+
conclusion === "ACTION_REQUIRED"
|
|
87
|
+
) {
|
|
88
|
+
checks.fail++;
|
|
89
|
+
} else if (
|
|
90
|
+
status === "IN_PROGRESS" ||
|
|
91
|
+
status === "QUEUED" ||
|
|
92
|
+
status === "PENDING" ||
|
|
93
|
+
status === "WAITING"
|
|
94
|
+
) {
|
|
95
|
+
checks.pending++;
|
|
96
|
+
} else if (status === "COMPLETED") {
|
|
97
|
+
// Completed but no recognized conclusion — treat as passed
|
|
98
|
+
checks.pass++;
|
|
99
|
+
} else {
|
|
100
|
+
// Unknown state — treat as pending
|
|
101
|
+
checks.pending++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return checks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const PR_URL_RE = /https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/;
|
|
108
|
+
|
|
109
|
+
function parsePrUrl(text: string): { url: string; repo: string; number: number } | null {
|
|
110
|
+
const match = text.match(PR_URL_RE);
|
|
111
|
+
if (!match) return null;
|
|
112
|
+
return { url: match[0], repo: match[1], number: parseInt(match[2], 10) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getPrByNumber(repo: string, prNumber: number): PrInfo | undefined {
|
|
116
|
+
try {
|
|
117
|
+
const json = execSync(
|
|
118
|
+
`gh pr view ${prNumber} --repo ${repo} --json number,title,url,state,statusCheckRollup`,
|
|
119
|
+
{
|
|
120
|
+
encoding: "utf-8",
|
|
121
|
+
timeout: 10_000,
|
|
122
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
123
|
+
},
|
|
124
|
+
).trim();
|
|
125
|
+
if (!json) return undefined;
|
|
126
|
+
const pr = JSON.parse(json);
|
|
127
|
+
if (!pr.number || !pr.url) return undefined;
|
|
128
|
+
|
|
129
|
+
const checks = Array.isArray(pr.statusCheckRollup)
|
|
130
|
+
? parseChecks(pr.statusCheckRollup)
|
|
131
|
+
: { total: 0, pass: 0, fail: 0, pending: 0 };
|
|
132
|
+
|
|
133
|
+
const [owner, name] = repo.split("/");
|
|
134
|
+
let unresolvedThreads = 0;
|
|
135
|
+
if (owner && name) {
|
|
136
|
+
try {
|
|
137
|
+
const gql = execSync(
|
|
138
|
+
`gh api graphql -f query='{ repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${pr.number}) { reviewThreads(first: 100) { nodes { isResolved } } } } }'`,
|
|
139
|
+
{
|
|
140
|
+
encoding: "utf-8",
|
|
141
|
+
timeout: 10_000,
|
|
142
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
143
|
+
},
|
|
144
|
+
).trim();
|
|
145
|
+
const data = JSON.parse(gql);
|
|
146
|
+
const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes;
|
|
147
|
+
if (Array.isArray(threads)) {
|
|
148
|
+
unresolvedThreads = threads.filter((t: { isResolved: boolean }) => !t.isResolved).length;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// GraphQL failed — show PR without thread count
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
number: pr.number,
|
|
157
|
+
title: pr.title,
|
|
158
|
+
url: pr.url,
|
|
159
|
+
state: pr.state,
|
|
160
|
+
checks,
|
|
161
|
+
unresolvedThreads,
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPrForBranch(cwd: string, repo?: RepoInfo): PrInfo | undefined {
|
|
169
|
+
try {
|
|
170
|
+
const json = execSync("gh pr view --json number,title,url,state,statusCheckRollup", {
|
|
171
|
+
cwd,
|
|
172
|
+
encoding: "utf-8",
|
|
173
|
+
timeout: 10_000,
|
|
174
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
175
|
+
}).trim();
|
|
176
|
+
if (!json) return undefined;
|
|
177
|
+
const pr = JSON.parse(json);
|
|
178
|
+
if (!pr.number || !pr.url) return undefined;
|
|
179
|
+
|
|
180
|
+
const checks = Array.isArray(pr.statusCheckRollup) ? parseChecks(pr.statusCheckRollup) : { total: 0, pass: 0, fail: 0, pending: 0 };
|
|
181
|
+
|
|
182
|
+
let unresolvedThreads = 0;
|
|
183
|
+
if (repo) {
|
|
184
|
+
try {
|
|
185
|
+
const gql = execSync(
|
|
186
|
+
`gh api graphql -f query='{ repository(owner: "${repo.owner}", name: "${repo.name}") { pullRequest(number: ${pr.number}) { reviewThreads(first: 100) { nodes { isResolved } } } } }'`,
|
|
187
|
+
{
|
|
188
|
+
cwd,
|
|
189
|
+
encoding: "utf-8",
|
|
190
|
+
timeout: 10_000,
|
|
191
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
192
|
+
},
|
|
193
|
+
).trim();
|
|
194
|
+
const data = JSON.parse(gql);
|
|
195
|
+
const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes;
|
|
196
|
+
if (Array.isArray(threads)) {
|
|
197
|
+
unresolvedThreads = threads.filter((t: { isResolved: boolean }) => !t.isResolved).length;
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// GraphQL failed — show PR without thread count
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
number: pr.number,
|
|
206
|
+
title: pr.title,
|
|
207
|
+
url: pr.url,
|
|
208
|
+
state: pr.state,
|
|
209
|
+
checks,
|
|
210
|
+
unresolvedThreads,
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatStatus(pr: PrInfo): string {
|
|
218
|
+
const stateIcon = pr.state === "MERGED" ? "🟣" : pr.state === "CLOSED" ? "🔴" : "🟢";
|
|
219
|
+
const parts: string[] = [`${stateIcon} PR #${pr.number}`];
|
|
220
|
+
|
|
221
|
+
if (pr.checks.total > 0) {
|
|
222
|
+
if (pr.checks.fail > 0) {
|
|
223
|
+
parts.push(`❌ ${pr.checks.fail}/${pr.checks.total} checks failed`);
|
|
224
|
+
} else if (pr.checks.pending > 0) {
|
|
225
|
+
parts.push(`⏳ ${pr.checks.pending}/${pr.checks.total} checks pending`);
|
|
226
|
+
} else {
|
|
227
|
+
parts.push(`✅ ${pr.checks.total} checks passed`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (pr.unresolvedThreads > 0) {
|
|
232
|
+
parts.push(`💬 ${pr.unresolvedThreads} unresolved`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
parts.push(pr.url);
|
|
236
|
+
return parts.join(" · ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Extension ---
|
|
10
240
|
|
|
11
241
|
const POLL_INTERVAL = 30_000;
|
|
12
242
|
const STATUS_KEY = "pr-status";
|
|
@@ -17,7 +247,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
17
247
|
let lastPr: PrInfo | undefined;
|
|
18
248
|
let cachedRepo: RepoInfo | undefined;
|
|
19
249
|
|
|
250
|
+
// Track a PR pinned by URL (takes priority over branch-based detection).
|
|
251
|
+
// Only set when the current branch has no active (open) PR of its own.
|
|
252
|
+
let pinnedPr: { repo: string; number: number } | null = null;
|
|
253
|
+
let latestCtx: ExtensionContext | null = null;
|
|
254
|
+
|
|
255
|
+
/** Returns true when the current branch has an open PR. */
|
|
256
|
+
function hasActiveBranchPr(): boolean {
|
|
257
|
+
return !!lastPr && lastPr.state === "OPEN";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function showStatus(pr: PrInfo | undefined, ui: { setStatus: (key: string, value: string | undefined) => void }) {
|
|
261
|
+
lastPr = pr ?? undefined;
|
|
262
|
+
ui.setStatus(STATUS_KEY, lastPr ? formatStatus(lastPr) : undefined);
|
|
263
|
+
}
|
|
264
|
+
|
|
20
265
|
function update(cwd: string, ui: { setStatus: (key: string, value: string | undefined) => void }) {
|
|
266
|
+
// If a PR is pinned by URL, use that instead of branch detection
|
|
267
|
+
if (pinnedPr) {
|
|
268
|
+
const pr = getPrByNumber(pinnedPr.repo, pinnedPr.number);
|
|
269
|
+
showStatus(pr, ui);
|
|
270
|
+
|
|
271
|
+
// If the branch now has its own open PR, drop the pin and let
|
|
272
|
+
// branch-based detection take over from the next cycle.
|
|
273
|
+
if (pr) {
|
|
274
|
+
const branch = getBranch(cwd);
|
|
275
|
+
if (branch && branch !== "HEAD" && branch !== lastBranch) {
|
|
276
|
+
lastBranch = branch;
|
|
277
|
+
}
|
|
278
|
+
if (branch && branch !== "HEAD") {
|
|
279
|
+
if (!cachedRepo) cachedRepo = getRepoInfo(cwd);
|
|
280
|
+
const branchPr = getPrForBranch(cwd, cachedRepo);
|
|
281
|
+
if (branchPr && branchPr.state === "OPEN") {
|
|
282
|
+
pinnedPr = null;
|
|
283
|
+
showStatus(branchPr, ui);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
21
290
|
const branch = getBranch(cwd);
|
|
22
291
|
|
|
23
292
|
if (branch !== lastBranch) {
|
|
@@ -26,8 +295,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
295
|
}
|
|
27
296
|
|
|
28
297
|
if (!branch || branch === "HEAD") {
|
|
29
|
-
|
|
30
|
-
ui.setStatus(STATUS_KEY, undefined);
|
|
298
|
+
showStatus(undefined, ui);
|
|
31
299
|
return;
|
|
32
300
|
}
|
|
33
301
|
|
|
@@ -36,24 +304,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
36
304
|
}
|
|
37
305
|
|
|
38
306
|
const pr = getPrForBranch(cwd, cachedRepo);
|
|
39
|
-
|
|
307
|
+
showStatus(pr, ui);
|
|
308
|
+
}
|
|
40
309
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
310
|
+
function tryPinFromUrl(text: string, ctx: ExtensionContext) {
|
|
311
|
+
const parsed = parsePrUrl(text);
|
|
312
|
+
if (!parsed) return;
|
|
313
|
+
|
|
314
|
+
// Don't re-pin the same PR
|
|
315
|
+
if (pinnedPr?.repo === parsed.repo && pinnedPr?.number === parsed.number) return;
|
|
316
|
+
|
|
317
|
+
// Only pin when the current branch has no active (open) PR.
|
|
318
|
+
// This avoids hijacking the status when casually referencing another PR.
|
|
319
|
+
if (hasActiveBranchPr()) return;
|
|
320
|
+
|
|
321
|
+
pinnedPr = { repo: parsed.repo, number: parsed.number };
|
|
322
|
+
latestCtx = ctx;
|
|
323
|
+
|
|
324
|
+
// Fetch and show immediately
|
|
325
|
+
const pr = getPrByNumber(parsed.repo, parsed.number);
|
|
326
|
+
showStatus(pr, ctx.ui);
|
|
46
327
|
}
|
|
47
328
|
|
|
329
|
+
// Detect PR URLs in user input — fires before the agent starts
|
|
330
|
+
pi.on("input", async (event, ctx) => {
|
|
331
|
+
if (event.source === "extension") return { action: "continue" as const };
|
|
332
|
+
|
|
333
|
+
latestCtx = ctx;
|
|
334
|
+
tryPinFromUrl(event.text, ctx);
|
|
335
|
+
|
|
336
|
+
return { action: "continue" as const };
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Also check in before_agent_start for skill/template-expanded text
|
|
340
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
341
|
+
latestCtx = ctx;
|
|
342
|
+
tryPinFromUrl(event.prompt, ctx);
|
|
343
|
+
});
|
|
344
|
+
|
|
48
345
|
pi.on("session_start", async (_event, ctx) => {
|
|
346
|
+
latestCtx = ctx;
|
|
49
347
|
update(ctx.cwd, ctx.ui);
|
|
50
|
-
timer = setInterval(() =>
|
|
348
|
+
timer = setInterval(() => {
|
|
349
|
+
if (latestCtx) update(latestCtx.cwd, latestCtx.ui);
|
|
350
|
+
}, POLL_INTERVAL);
|
|
51
351
|
});
|
|
52
352
|
|
|
53
353
|
pi.on("session_switch", async (_event, ctx) => {
|
|
54
354
|
lastBranch = undefined;
|
|
55
355
|
lastPr = undefined;
|
|
56
356
|
cachedRepo = undefined;
|
|
357
|
+
pinnedPr = null;
|
|
358
|
+
latestCtx = ctx;
|
|
57
359
|
update(ctx.cwd, ctx.ui);
|
|
58
360
|
});
|
|
59
361
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-status",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A Pi extension that shows the current PR link, CI check status, and unresolved review comments in the footer status bar",
|
|
5
5
|
"author": "Bruno Garcia",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
"extensions/pr-status.ts",
|
|
21
|
-
"lib/github.ts",
|
|
22
21
|
"README.md",
|
|
23
22
|
"LICENSE"
|
|
24
23
|
],
|
package/lib/github.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure logic for querying GitHub PR status via the `gh` CLI.
|
|
3
|
-
* Separated from the pi extension API for testability.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { execSync } from "node:child_process";
|
|
7
|
-
|
|
8
|
-
export interface CheckStatus {
|
|
9
|
-
total: number;
|
|
10
|
-
pass: number;
|
|
11
|
-
fail: number;
|
|
12
|
-
pending: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface PrInfo {
|
|
16
|
-
number: number;
|
|
17
|
-
title: string;
|
|
18
|
-
url: string;
|
|
19
|
-
state: string;
|
|
20
|
-
checks: CheckStatus;
|
|
21
|
-
unresolvedThreads: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface RepoInfo {
|
|
25
|
-
owner: string;
|
|
26
|
-
name: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function getBranch(cwd: string): string | undefined {
|
|
30
|
-
try {
|
|
31
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
32
|
-
cwd,
|
|
33
|
-
encoding: "utf-8",
|
|
34
|
-
timeout: 3000,
|
|
35
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
36
|
-
}).trim();
|
|
37
|
-
} catch {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function getRepoInfo(cwd: string): RepoInfo | undefined {
|
|
43
|
-
try {
|
|
44
|
-
const json = execSync("gh repo view --json owner,name", {
|
|
45
|
-
cwd,
|
|
46
|
-
encoding: "utf-8",
|
|
47
|
-
timeout: 5000,
|
|
48
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
-
}).trim();
|
|
50
|
-
const repo = JSON.parse(json);
|
|
51
|
-
return repo.owner?.login && repo.name ? { owner: repo.owner.login, name: repo.name } : undefined;
|
|
52
|
-
} catch {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function parseChecks(statusCheckRollup: unknown[]): CheckStatus {
|
|
58
|
-
const checks: CheckStatus = { total: 0, pass: 0, fail: 0, pending: 0 };
|
|
59
|
-
for (const check of statusCheckRollup) {
|
|
60
|
-
const c = check as Record<string, string>;
|
|
61
|
-
const conclusion = (c.conclusion || "").toUpperCase();
|
|
62
|
-
const status = (c.status || "").toUpperCase();
|
|
63
|
-
const name = c.name || "";
|
|
64
|
-
|
|
65
|
-
// Skip ghost checks with no meaningful data (e.g. Vercel deployment statuses)
|
|
66
|
-
if (!name && !conclusion && !status) continue;
|
|
67
|
-
|
|
68
|
-
checks.total++;
|
|
69
|
-
if (conclusion === "SUCCESS" || conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
|
|
70
|
-
checks.pass++;
|
|
71
|
-
} else if (
|
|
72
|
-
conclusion === "FAILURE" ||
|
|
73
|
-
conclusion === "TIMED_OUT" ||
|
|
74
|
-
conclusion === "CANCELLED" ||
|
|
75
|
-
conclusion === "ACTION_REQUIRED"
|
|
76
|
-
) {
|
|
77
|
-
checks.fail++;
|
|
78
|
-
} else if (
|
|
79
|
-
status === "IN_PROGRESS" ||
|
|
80
|
-
status === "QUEUED" ||
|
|
81
|
-
status === "PENDING" ||
|
|
82
|
-
status === "WAITING"
|
|
83
|
-
) {
|
|
84
|
-
checks.pending++;
|
|
85
|
-
} else if (status === "COMPLETED") {
|
|
86
|
-
// Completed but no recognized conclusion — treat as passed
|
|
87
|
-
checks.pass++;
|
|
88
|
-
} else {
|
|
89
|
-
// Unknown state — treat as pending
|
|
90
|
-
checks.pending++;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return checks;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function countUnresolvedThreads(threads: { isResolved: boolean }[]): number {
|
|
97
|
-
return threads.filter((t) => !t.isResolved).length;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function getPrForBranch(cwd: string, repo?: RepoInfo): PrInfo | undefined {
|
|
101
|
-
try {
|
|
102
|
-
const json = execSync("gh pr view --json number,title,url,state,statusCheckRollup", {
|
|
103
|
-
cwd,
|
|
104
|
-
encoding: "utf-8",
|
|
105
|
-
timeout: 10_000,
|
|
106
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
107
|
-
}).trim();
|
|
108
|
-
if (!json) return undefined;
|
|
109
|
-
const pr = JSON.parse(json);
|
|
110
|
-
if (!pr.number || !pr.url) return undefined;
|
|
111
|
-
|
|
112
|
-
const checks = Array.isArray(pr.statusCheckRollup) ? parseChecks(pr.statusCheckRollup) : { total: 0, pass: 0, fail: 0, pending: 0 };
|
|
113
|
-
|
|
114
|
-
let unresolvedThreads = 0;
|
|
115
|
-
if (repo) {
|
|
116
|
-
try {
|
|
117
|
-
const gql = execSync(
|
|
118
|
-
`gh api graphql -f query='{ repository(owner: "${repo.owner}", name: "${repo.name}") { pullRequest(number: ${pr.number}) { reviewThreads(first: 100) { nodes { isResolved } } } } }'`,
|
|
119
|
-
{
|
|
120
|
-
cwd,
|
|
121
|
-
encoding: "utf-8",
|
|
122
|
-
timeout: 10_000,
|
|
123
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
124
|
-
},
|
|
125
|
-
).trim();
|
|
126
|
-
const data = JSON.parse(gql);
|
|
127
|
-
const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes;
|
|
128
|
-
if (Array.isArray(threads)) {
|
|
129
|
-
unresolvedThreads = countUnresolvedThreads(threads);
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// GraphQL failed — show PR without thread count
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
number: pr.number,
|
|
138
|
-
title: pr.title,
|
|
139
|
-
url: pr.url,
|
|
140
|
-
state: pr.state,
|
|
141
|
-
checks,
|
|
142
|
-
unresolvedThreads,
|
|
143
|
-
};
|
|
144
|
-
} catch {
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function formatStatus(pr: PrInfo): string {
|
|
150
|
-
const stateIcon = pr.state === "MERGED" ? "🟣" : pr.state === "CLOSED" ? "🔴" : "🟢";
|
|
151
|
-
const parts: string[] = [`${stateIcon} PR #${pr.number}`];
|
|
152
|
-
|
|
153
|
-
if (pr.checks.total > 0) {
|
|
154
|
-
if (pr.checks.fail > 0) {
|
|
155
|
-
parts.push(`❌ ${pr.checks.fail}/${pr.checks.total} checks failed`);
|
|
156
|
-
} else if (pr.checks.pending > 0) {
|
|
157
|
-
parts.push(`⏳ ${pr.checks.pending}/${pr.checks.total} checks pending`);
|
|
158
|
-
} else {
|
|
159
|
-
parts.push(`✅ ${pr.checks.total} checks passed`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (pr.unresolvedThreads > 0) {
|
|
164
|
-
parts.push(`💬 ${pr.unresolvedThreads} unresolved`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
parts.push(pr.url);
|
|
168
|
-
return parts.join(" · ");
|
|
169
|
-
}
|