pi-pr-status 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/extensions/pr-status.ts +151 -12
- package/package.json +1 -1
package/extensions/pr-status.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
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";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
15
|
import { execSync } from "node:child_process";
|
|
10
16
|
|
|
11
17
|
// --- GitHub helpers (inlined for pi extension compatibility) ---
|
|
@@ -98,6 +104,67 @@ function parseChecks(statusCheckRollup: unknown[]): CheckStatus {
|
|
|
98
104
|
return checks;
|
|
99
105
|
}
|
|
100
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
|
+
|
|
101
168
|
function getPrForBranch(cwd: string, repo?: RepoInfo): PrInfo | undefined {
|
|
102
169
|
try {
|
|
103
170
|
const json = execSync("gh pr view --json number,title,url,state,statusCheckRollup", {
|
|
@@ -180,7 +247,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
247
|
let lastPr: PrInfo | undefined;
|
|
181
248
|
let cachedRepo: RepoInfo | undefined;
|
|
182
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
|
+
|
|
183
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
|
+
|
|
184
290
|
const branch = getBranch(cwd);
|
|
185
291
|
|
|
186
292
|
if (branch !== lastBranch) {
|
|
@@ -189,8 +295,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
189
295
|
}
|
|
190
296
|
|
|
191
297
|
if (!branch || branch === "HEAD") {
|
|
192
|
-
|
|
193
|
-
ui.setStatus(STATUS_KEY, undefined);
|
|
298
|
+
showStatus(undefined, ui);
|
|
194
299
|
return;
|
|
195
300
|
}
|
|
196
301
|
|
|
@@ -199,24 +304,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
199
304
|
}
|
|
200
305
|
|
|
201
306
|
const pr = getPrForBranch(cwd, cachedRepo);
|
|
202
|
-
|
|
307
|
+
showStatus(pr, ui);
|
|
308
|
+
}
|
|
203
309
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
209
327
|
}
|
|
210
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
|
+
|
|
211
345
|
pi.on("session_start", async (_event, ctx) => {
|
|
346
|
+
latestCtx = ctx;
|
|
212
347
|
update(ctx.cwd, ctx.ui);
|
|
213
|
-
timer = setInterval(() =>
|
|
348
|
+
timer = setInterval(() => {
|
|
349
|
+
if (latestCtx) update(latestCtx.cwd, latestCtx.ui);
|
|
350
|
+
}, POLL_INTERVAL);
|
|
214
351
|
});
|
|
215
352
|
|
|
216
353
|
pi.on("session_switch", async (_event, ctx) => {
|
|
217
354
|
lastBranch = undefined;
|
|
218
355
|
lastPr = undefined;
|
|
219
356
|
cachedRepo = undefined;
|
|
357
|
+
pinnedPr = null;
|
|
358
|
+
latestCtx = ctx;
|
|
220
359
|
update(ctx.cwd, ctx.ui);
|
|
221
360
|
});
|
|
222
361
|
|
package/package.json
CHANGED