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.
@@ -1,12 +1,242 @@
1
1
  /**
2
2
  * PR Status Extension
3
3
  *
4
- * Shows the current branch's PR URL, CI check status, and unresolved
5
- * review comment count in the pi footer status bar. Polls every 30 seconds.
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 { getBranch, getRepoInfo, getPrForBranch, formatStatus, type PrInfo, type RepoInfo } from "../lib/github.ts";
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
- lastPr = undefined;
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
- lastPr = pr ?? undefined;
307
+ showStatus(pr, ui);
308
+ }
40
309
 
41
- if (lastPr) {
42
- ui.setStatus(STATUS_KEY, formatStatus(lastPr));
43
- } else {
44
- ui.setStatus(STATUS_KEY, undefined);
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(() => update(ctx.cwd, ctx.ui), POLL_INTERVAL);
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.1.1",
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
- }