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.
@@ -1,11 +1,17 @@
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";
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
- lastPr = undefined;
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
- lastPr = pr ?? undefined;
307
+ showStatus(pr, ui);
308
+ }
203
309
 
204
- if (lastPr) {
205
- ui.setStatus(STATUS_KEY, formatStatus(lastPr));
206
- } else {
207
- ui.setStatus(STATUS_KEY, undefined);
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(() => update(ctx.cwd, ctx.ui), POLL_INTERVAL);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-status",
3
- "version": "0.2.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",