pi-minimal 0.5.0 → 0.6.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.
@@ -0,0 +1,81 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { describePr, PR_LEGEND, type PrSource, type PullRequest } from "./prs.js"
3
+
4
+ /**
5
+ * `/prs [mine|review|all]` lists the current repo's open PRs that involve me and offers actions on
6
+ * the chosen one. The `list_open_prs` tool gives the model the same list.
7
+ */
8
+
9
+ export type PrFilter = "all" | "mine" | "review"
10
+
11
+ export function parsePrFilter(args: string): PrFilter {
12
+ const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ""
13
+ if (["mine", "author", "authored", "my"].includes(word)) return "mine"
14
+ if (["review", "reviews", "review-requested", "requested"].includes(word)) return "review"
15
+ return "all"
16
+ }
17
+
18
+ export function filterPrs(prs: PullRequest[], filter: PrFilter): PullRequest[] {
19
+ if (filter === "all") return prs
20
+ return prs.filter((pr) => pr.kind === filter)
21
+ }
22
+
23
+ const ACTIONS = ["Open in browser", "Paste URL into editor", "Checkout branch", "Cancel"] as const
24
+
25
+ export async function runPrsCommand(args: string, ctx: ExtensionContext, pi: ExtensionAPI, source: PrSource | undefined): Promise<void> {
26
+ if (!source) {
27
+ ctx.ui.notify("PRs are only tracked in interactive sessions.", "info")
28
+ return
29
+ }
30
+ await source.refresh(true)
31
+ const snap = source.get()
32
+ if (!snap.repo && snap.prs.length === 0) {
33
+ ctx.ui.notify(snap.error ? `No GitHub repo here: ${snap.error}` : "No GitHub repo here.", "warning")
34
+ return
35
+ }
36
+ const filter = parsePrFilter(args)
37
+ const prs = filterPrs(snap.prs, filter)
38
+ const title = `${snap.repo?.nameWithOwner ?? "this session"}: ${prs.length} open PR${prs.length === 1 ? "" : "s"}${filter === "all" ? "" : ` (${filter})`}${snap.error ? ` · stale: ${snap.error}` : ""}`
39
+ if (prs.length === 0) {
40
+ ctx.ui.notify(title, "info")
41
+ return
42
+ }
43
+ if (ctx.mode !== "tui") {
44
+ ctx.ui.notify(`${title}\n${prs.map((pr) => `${describePr(pr, snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined)}\n ${pr.url}`).join("\n")}`, "info")
45
+ return
46
+ }
47
+ const cwdRepo = snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined
48
+ const labels = prs.map((pr) => describePr(pr, cwdRepo))
49
+ const chosen = await ctx.ui.select(`${title}\n${PR_LEGEND}`, labels)
50
+ if (chosen === undefined) return
51
+ const pr = prs[labels.indexOf(chosen)]
52
+ if (!pr) return
53
+ const action = await ctx.ui.select(`#${pr.number} ${pr.title}`, [...ACTIONS])
54
+ if (action === "Open in browser") {
55
+ const open = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"
56
+ const run = await pi.exec(open, [pr.url])
57
+ if (run.code !== 0) ctx.ui.notify(`Could not open browser: ${run.stderr.trim()}`, "error")
58
+ } else if (action === "Paste URL into editor") {
59
+ ctx.ui.pasteToEditor(pr.url)
60
+ } else if (action === "Checkout branch") {
61
+ const run = await pi.exec("gh", ["pr", "checkout", String(pr.number), "-R", pr.repo], { cwd: ctx.cwd, timeout: 60_000 })
62
+ ctx.ui.notify(run.code === 0 ? `Checked out #${pr.number}` : `Checkout failed: ${run.stderr.trim()}`, run.code === 0 ? "info" : "error")
63
+ }
64
+ }
65
+
66
+ export function prsToolResult(source: PrSource | undefined, filter: PrFilter) {
67
+ const snap = source?.get()
68
+ const prs = filterPrs(snap?.prs ?? [], filter).map((pr) => ({
69
+ number: pr.number,
70
+ title: pr.title,
71
+ url: pr.url,
72
+ repo: pr.repo,
73
+ author: pr.author,
74
+ isDraft: pr.isDraft,
75
+ kind: pr.kind,
76
+ reviewDecision: pr.reviewDecision,
77
+ ciFailing: pr.ciFailing,
78
+ updatedAt: pr.updatedAt,
79
+ }))
80
+ return { repo: snap?.repo?.nameWithOwner, host: snap?.repo?.host, fetchedAt: snap?.fetchedAt, error: snap?.error, prs }
81
+ }
package/src/prs.ts ADDED
@@ -0,0 +1,348 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { hyperlink } from "@earendil-works/pi-tui"
3
+ import { createListeners } from "./listeners.js"
4
+
5
+ /**
6
+ * Open pull requests for the repository the session is in, via the `gh` CLI: the ones I authored
7
+ * and the ones waiting on my review. Shown on the status line as clickable `#1675` chips (OSC 8
8
+ * links), coloured by kind, and listed by `/prs`.
9
+ *
10
+ * `gh` resolves the host from the repo's remote, so GitHub.com and GitHub Enterprise both work;
11
+ * the viewer login is looked up per host because it differs between them.
12
+ */
13
+
14
+ export type PrKind = "mine" | "review"
15
+
16
+ export type PullRequest = {
17
+ number: number
18
+ title: string
19
+ url: string
20
+ /** `host/owner/repo`, the form `gh -R` accepts for any host. */
21
+ repo: string
22
+ author: string
23
+ isDraft: boolean
24
+ /** Why it is on my list. A PR I authored that also requests my review counts as `mine`. */
25
+ kind: PrKind
26
+ /** GitHub's review decision: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED or empty. */
27
+ reviewDecision: string
28
+ /** Any status context or check run failed. */
29
+ ciFailing: boolean
30
+ updatedAt: string
31
+ }
32
+
33
+ export type PrRepo = {
34
+ /** `owner/repo` */
35
+ nameWithOwner: string
36
+ /** e.g. `github.com` */
37
+ host: string
38
+ url: string
39
+ }
40
+
41
+ export type PrSnapshot = {
42
+ repo?: PrRepo
43
+ /** The cwd repo's PRs first, then PRs created in this session elsewhere (deduped by URL). */
44
+ prs: PullRequest[]
45
+ /** Last `gh` error, when the list could not be refreshed. */
46
+ error?: string
47
+ fetchedAt?: number
48
+ }
49
+
50
+ export const PR_LIST_FIELDS = "number,title,url,isDraft,author,reviewRequests,reviewDecision,statusCheckRollup,updatedAt"
51
+
52
+ /** A GitHub PR URL on any host: `https://host/owner/repo/pull/123`. */
53
+ export const PR_URL_RE = /https?:\/\/([a-z0-9.-]+)\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\b/gi
54
+
55
+ /** Finds PR URLs in text, e.g. the output of `gh pr create`. */
56
+ export function findPrUrls(text: string): string[] {
57
+ const urls = new Set<string>()
58
+ for (const match of text.matchAll(PR_URL_RE)) urls.add(`https://${match[1]}/${match[2]}/${match[3]}/pull/${match[4]}`)
59
+ return [...urls]
60
+ }
61
+
62
+ /** Repo (`host/owner/repo`) a PR URL belongs to. */
63
+ export function prUrlRepo(url: string): string | undefined {
64
+ const match = new RegExp(PR_URL_RE.source, "i").exec(url)
65
+ return match ? `${match[1]}/${match[2]}/${match[3]}` : undefined
66
+ }
67
+
68
+ /** Commands whose output announces a PR this session created. */
69
+ export function isPrCreateCommand(command: string): boolean {
70
+ return /\bgh\s+pr\s+create\b|\bgt\s+submit\b/.test(command)
71
+ }
72
+
73
+ /** Parses `gh repo view --json nameWithOwner,url`. */
74
+ export function parseRepoView(stdout: string): PrRepo {
75
+ const data = JSON.parse(stdout) as { nameWithOwner?: unknown; url?: unknown }
76
+ if (typeof data.nameWithOwner !== "string" || typeof data.url !== "string") throw new Error("invalid gh repo view")
77
+ return { nameWithOwner: data.nameWithOwner, host: new URL(data.url).host, url: data.url }
78
+ }
79
+
80
+ type RawCheck = { state?: string | null; conclusion?: string | null; status?: string | null }
81
+ type RawPr = {
82
+ number?: unknown
83
+ title?: unknown
84
+ url?: unknown
85
+ isDraft?: unknown
86
+ author?: { login?: unknown } | null
87
+ reviewRequests?: Array<{ __typename?: string; login?: unknown; slug?: unknown; name?: unknown }> | null
88
+ reviewDecision?: unknown
89
+ statusCheckRollup?: RawCheck[] | null
90
+ updatedAt?: unknown
91
+ }
92
+
93
+ const FAILED_STATES = new Set(["FAILURE", "ERROR"])
94
+ const FAILED_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"])
95
+
96
+ export function ciFailing(checks: RawCheck[] | null | undefined): boolean {
97
+ return (checks ?? []).some(
98
+ (check) => FAILED_STATES.has(check.state ?? "") || FAILED_CONCLUSIONS.has(check.conclusion ?? ""),
99
+ )
100
+ }
101
+
102
+ function toPullRequest(raw: RawPr, repo: string, kind: PrKind): PullRequest | undefined {
103
+ if (typeof raw.number !== "number" || typeof raw.url !== "string") return undefined
104
+ return {
105
+ number: raw.number,
106
+ title: typeof raw.title === "string" ? raw.title : "",
107
+ url: raw.url,
108
+ repo,
109
+ author: typeof raw.author?.login === "string" ? raw.author.login : "",
110
+ isDraft: raw.isDraft === true,
111
+ kind,
112
+ reviewDecision: typeof raw.reviewDecision === "string" ? raw.reviewDecision : "",
113
+ ciFailing: ciFailing(raw.statusCheckRollup),
114
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
115
+ }
116
+ }
117
+
118
+ /** Parses `gh pr view URL --json …` for a PR this session created (always `mine`). */
119
+ export function parsePrView(stdout: string, url: string): PullRequest | undefined {
120
+ const raw = JSON.parse(stdout) as RawPr & { state?: unknown }
121
+ if (raw.state !== undefined && raw.state !== "OPEN") return undefined
122
+ const repo = prUrlRepo(url)
123
+ return repo ? toPullRequest(raw, repo, "mine") : undefined
124
+ }
125
+
126
+ /**
127
+ * Parses `gh pr list --json …` for `involves:@me` into my PRs: authored by `login`, or requesting
128
+ * my review (directly; team requests are included when `teams` lists my team slugs). Others I'm
129
+ * merely mentioned on are dropped.
130
+ */
131
+ export function parsePrList(stdout: string, repo: PrRepo, login: string, teams: readonly string[] = []): PullRequest[] {
132
+ const data = JSON.parse(stdout) as RawPr[]
133
+ if (!Array.isArray(data)) throw new Error("invalid gh pr list")
134
+ const teamSet = new Set(teams.map((team) => team.toLowerCase()))
135
+ const prs: PullRequest[] = []
136
+ for (const raw of data) {
137
+ const author = typeof raw.author?.login === "string" ? raw.author.login : ""
138
+ const requested = (raw.reviewRequests ?? []).some((request) => {
139
+ if (typeof request.login === "string") return request.login.toLowerCase() === login.toLowerCase()
140
+ const slug = typeof request.slug === "string" ? request.slug : typeof request.name === "string" ? request.name : ""
141
+ return teamSet.has(slug.toLowerCase())
142
+ })
143
+ const mine = author.toLowerCase() === login.toLowerCase()
144
+ if (!mine && !requested) continue
145
+ const pr = toPullRequest(raw, `${repo.host}/${repo.nameWithOwner}`, mine ? "mine" : "review")
146
+ if (pr) prs.push(pr)
147
+ }
148
+ // Review requests first (they need me), then mine; newest first within each.
149
+ prs.sort((a, b) => (a.kind === b.kind ? (a.updatedAt < b.updatedAt ? 1 : -1) : a.kind === "review" ? -1 : 1))
150
+ return prs
151
+ }
152
+
153
+ export type PrColor = "accent" | "dim" | "warning" | "error"
154
+
155
+ /** Chip colour: trouble (failing CI, changes requested) wins, then review requests, then drafts. */
156
+ export function prColor(pr: PullRequest): PrColor {
157
+ if (pr.ciFailing || pr.reviewDecision === "CHANGES_REQUESTED") return "error"
158
+ if (pr.kind === "review") return "warning"
159
+ if (pr.isDraft) return "dim"
160
+ return "accent"
161
+ }
162
+
163
+ export const PR_LEGEND = "#n mine · dim draft · amber needs my review · red failing CI / changes requested"
164
+
165
+ type Theme = { fg(color: string, text: string): string }
166
+
167
+ /** `#1675` inside the cwd repo; `repo#1675` for a PR elsewhere (created in this session) or when cwd is no repo. */
168
+ export function prChipLabel(pr: PullRequest, cwdRepo?: string): string {
169
+ if (cwdRepo && pr.repo === cwdRepo) return `#${pr.number}`
170
+ return `${pr.repo.split("/").pop()}#${pr.number}`
171
+ }
172
+
173
+ /** Footer segment: `#1675 #1670 +3` with each number an OSC 8 link; undefined when empty. */
174
+ export function prSegment(prs: PullRequest[], theme: Theme, max = 4, cwdRepo?: string): string | undefined {
175
+ if (prs.length === 0) return undefined
176
+ const shown = prs.slice(0, max)
177
+ const chips = shown.map((pr) => theme.fg(prColor(pr), hyperlink(prChipLabel(pr, cwdRepo), pr.url)))
178
+ const rest = prs.length - shown.length
179
+ if (rest > 0) chips.push(theme.fg("dim", `+${rest}`))
180
+ return chips.join(" ")
181
+ }
182
+
183
+ export function describePr(pr: PullRequest, cwdRepo?: string): string {
184
+ const flags = [
185
+ pr.kind === "review" ? "review requested" : pr.isDraft ? "draft" : "mine",
186
+ pr.ciFailing ? "CI failing" : undefined,
187
+ pr.reviewDecision === "CHANGES_REQUESTED" ? "changes requested" : pr.reviewDecision === "APPROVED" ? "approved" : undefined,
188
+ ].filter(Boolean)
189
+ return `${prChipLabel(pr, cwdRepo)} ${pr.title} · ${flags.join(" · ")}${pr.kind === "review" ? ` · @${pr.author}` : ""}`
190
+ }
191
+
192
+ export type PrSource = {
193
+ get(): PrSnapshot
194
+ onChange(listener: () => void): () => void
195
+ /** Re-fetch now (throttled unless `force`). */
196
+ refresh(force?: boolean): Promise<void>
197
+ /** Remember a PR this session created (any repo); it is shown until it is no longer open. */
198
+ addSessionPr(url: string): void
199
+ /** URLs remembered via addSessionPr, for persistence. */
200
+ sessionPrs(): string[]
201
+ dispose(): void
202
+ }
203
+
204
+ export type PrSourceDeps = {
205
+ pi: ExtensionAPI
206
+ cwd: () => string
207
+ /** PR URLs restored from a previous run of this session. */
208
+ sessionPrs?: readonly string[]
209
+ now?: () => number
210
+ /** Poll period; 3 min by default. */
211
+ pollMs?: number
212
+ /** Minimum gap between refreshes triggered by events. */
213
+ minIntervalMs?: number
214
+ }
215
+
216
+ const GH_TIMEOUT_MS = 15_000
217
+ const DEFAULT_POLL_MS = 3 * 60_000
218
+ const DEFAULT_MIN_INTERVAL_MS = 60_000
219
+
220
+ export function createPrSource(deps: PrSourceDeps): PrSource {
221
+ const now = deps.now ?? Date.now
222
+ const pollMs = deps.pollMs ?? DEFAULT_POLL_MS
223
+ const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
224
+ const listeners = createListeners()
225
+ let alive = true
226
+ let state: PrSnapshot = { prs: [] }
227
+ let inFlight: Promise<void> | undefined
228
+ let timer: ReturnType<typeof setTimeout> | undefined
229
+ let lastRun = 0
230
+ const logins = new Map<string, string>()
231
+ const session = new Set<string>(deps.sessionPrs ?? [])
232
+
233
+ // Only what is displayed counts as a change; a refresh that confirms the same list is silent.
234
+ const visible = (snap: PrSnapshot) => JSON.stringify([snap.repo, snap.prs, snap.error])
235
+ const set = (next: PrSnapshot) => {
236
+ const changed = visible(next) !== visible(state)
237
+ state = next
238
+ if (changed) listeners.notify()
239
+ }
240
+
241
+ const gh = (args: string[]) => deps.pi.exec("gh", args, { cwd: deps.cwd(), timeout: GH_TIMEOUT_MS })
242
+
243
+ const viewerLogin = async (host: string): Promise<string | undefined> => {
244
+ const cached = logins.get(host)
245
+ if (cached) return cached
246
+ const run = await gh(["api", "--hostname", host, "user", "--jq", ".login"])
247
+ if (run.code !== 0) return undefined
248
+ const login = run.stdout.trim()
249
+ if (login) logins.set(host, login)
250
+ return login || undefined
251
+ }
252
+
253
+ /** PRs this session created, wherever they live; a merged or closed one is forgotten. */
254
+ const fetchSessionPrs = async (exclude: Set<string>): Promise<PullRequest[]> => {
255
+ const extra = await Promise.all(
256
+ [...session].filter((url) => !exclude.has(url)).map(async (url) => {
257
+ const view = await gh(["pr", "view", url, "--json", `${PR_LIST_FIELDS},state`])
258
+ if (view.code !== 0) return undefined
259
+ try {
260
+ const pr = parsePrView(view.stdout, url)
261
+ if (!pr) session.delete(url)
262
+ return pr
263
+ } catch {
264
+ return undefined
265
+ }
266
+ }),
267
+ )
268
+ return extra.filter((pr): pr is PullRequest => pr !== undefined)
269
+ }
270
+
271
+ const fetchOnce = async (): Promise<void> => {
272
+ let repo = state.repo
273
+ let repoError: string | undefined
274
+ try {
275
+ if (!repo) {
276
+ const view = await gh(["repo", "view", "--json", "nameWithOwner,url"])
277
+ if (!alive) return
278
+ if (view.code === 0) repo = parseRepoView(view.stdout)
279
+ else repoError = view.stderr.trim() || "gh repo view failed"
280
+ }
281
+ let prs: PullRequest[] = []
282
+ let error = repoError
283
+ if (repo) {
284
+ const login = await viewerLogin(repo.host)
285
+ if (!alive) return
286
+ if (!login) {
287
+ error = `gh is not authenticated for ${repo.host}`
288
+ } else {
289
+ const list = await gh([
290
+ "pr", "list", "-R", `${repo.host}/${repo.nameWithOwner}`, "--state", "open", "--limit", "100",
291
+ "--search", "involves:@me", "--json", PR_LIST_FIELDS,
292
+ ])
293
+ if (!alive) return
294
+ if (list.code !== 0) error = list.stderr.trim() || "gh pr list failed"
295
+ else prs = parsePrList(list.stdout, repo, login)
296
+ }
297
+ }
298
+ // Outside a GitHub repo, or when the list failed, the session's own PRs still show.
299
+ prs.push(...(await fetchSessionPrs(new Set(prs.map((pr) => pr.url)))))
300
+ if (!alive) return
301
+ set({ repo, prs: error && prs.length === 0 && state.prs.length > 0 ? state.prs : prs, error, fetchedAt: now() })
302
+ } catch (error) {
303
+ if (!alive) return
304
+ set({ repo, prs: state.prs, error: error instanceof Error ? error.message : String(error), fetchedAt: state.fetchedAt })
305
+ }
306
+ }
307
+
308
+ const schedule = () => {
309
+ if (!alive) return
310
+ if (timer) clearTimeout(timer)
311
+ timer = setTimeout(() => { void refresh(true) }, pollMs)
312
+ timer.unref?.()
313
+ }
314
+
315
+ const refresh = (force = false): Promise<void> => {
316
+ if (!alive) return Promise.resolve()
317
+ if (inFlight) return inFlight
318
+ if (!force && now() - lastRun < minIntervalMs) return Promise.resolve()
319
+ lastRun = now()
320
+ inFlight = fetchOnce().finally(() => {
321
+ inFlight = undefined
322
+ schedule()
323
+ })
324
+ return inFlight
325
+ }
326
+
327
+ void refresh(true)
328
+
329
+ return {
330
+ get: () => state,
331
+ onChange: listeners.add,
332
+ refresh,
333
+ addSessionPr(url) {
334
+ const found = findPrUrls(url)[0]
335
+ if (!found || session.has(found)) return
336
+ session.add(found)
337
+ void refresh(true)
338
+ },
339
+ sessionPrs: () => [...session],
340
+ dispose() {
341
+ if (!alive) return
342
+ alive = false
343
+ if (timer) clearTimeout(timer)
344
+ timer = undefined
345
+ listeners.clear()
346
+ },
347
+ }
348
+ }