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.
- package/CHANGELOG.md +35 -1
- package/CONTEXT.md +27 -3
- package/README.md +6 -3
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +8 -0
- package/dist/cwd.js +2 -2
- package/dist/footer.js +15 -3
- package/dist/herdr.js +78 -0
- package/dist/index.js +62 -1
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/rex.js +289 -0
- package/dist/session.js +72 -59
- package/dist/siblings.js +14 -0
- package/package.json +5 -3
- package/release-notes.md +29 -42
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +8 -0
- package/src/cwd.ts +5 -4
- package/src/footer.ts +23 -3
- package/src/herdr.ts +78 -5
- package/src/index.ts +63 -2
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/rex.ts +304 -0
- package/src/session.ts +76 -50
- package/src/siblings.ts +38 -0
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +23 -10
- package/test/footer-composition.test.ts +61 -0
- package/test/index.test.ts +157 -1
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
|
@@ -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
|
+
}
|