diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 55775a1..51f2d16 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -76,21 +76,21 @@ describe("fetchOpenPRs (real gh CLI)", () => { describe("fetchRepoPRs (real gh CLI)", () => { test("returns PRs for a known repo with branch info", async () => { - // OctavianTocan/to-do-app has many open PRs - const prs = await fetchRepoPRs("OctavianTocan/to-do-app") + // cli/cli has many open PRs + const prs = await fetchRepoPRs("cli/cli") expect(Array.isArray(prs)).toBe(true) expect(prs.length).toBeGreaterThan(0) const pr = prs[0] - expect(pr.repo).toBe("OctavianTocan/to-do-app") + expect(pr.repo).toBe("cli/cli") expect(pr.headRefName).toBeTruthy() expect(pr.baseRefName).toBeTruthy() expect(pr.number).toBeGreaterThan(0) - expect(pr.url).toContain("github.com/OctavianTocan/to-do-app/pull/") + expect(pr.url).toContain("github.com/cli/cli/pull/") }) test("body is truncated to max 80 chars single line", async () => { - const prs = await fetchRepoPRs("OctavianTocan/to-do-app") + const prs = await fetchRepoPRs("cli/cli") for (const pr of prs) { expect(pr.body.length).toBeLessThanOrEqual(80) expect(pr.body).not.toContain("\n") @@ -98,7 +98,7 @@ describe("fetchRepoPRs (real gh CLI)", () => { }) test("all PRs have the correct repo field set", async () => { - const repo = "OctavianTocan/to-do-app" + const repo = "cli/cli" const prs = await fetchRepoPRs(repo) for (const pr of prs) { expect(pr.repo).toBe(repo) @@ -193,7 +193,7 @@ describe("formatStackedTitle", () => { describe("end-to-end: fetch -> detect -> format", () => { test("full pipeline works for a real repo", async () => { - const prs = await fetchRepoPRs("OctavianTocan/to-do-app") + const prs = await fetchRepoPRs("cli/cli") expect(prs.length).toBeGreaterThan(0) const stacks = detectStacks(prs) diff --git a/src/commands/ls.tsx b/src/commands/ls.tsx index 6320524..19a3498 100644 --- a/src/commands/ls.tsx +++ b/src/commands/ls.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect, useMemo, useCallback } from "react" +import { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from "react" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react" import { PRTable } from "../components/pr-table" import { Spinner } from "../components/spinner" import { SkeletonList } from "../components/skeleton" import { StatusView } from "../components/status-view" -import { fetchOpenPRs, getCurrentRepo, fetchPRDetails, submitPRReview, postPRComment, replyToReviewComment, resolveReviewThread } from "../lib/github" +import { fetchOpenPRs, getCurrentRepo, batchFetchPRDetails, submitPRReview, postPRComment, replyToReviewComment, resolveReviewThread } from "../lib/github" import { shortRepoName } from "../lib/format" import { PreviewPanel } from "../components/preview-panel" import { usePanel } from "../hooks/usePanel" @@ -12,6 +12,7 @@ import { detectPRState, compareByUrgency } from "../lib/pr-lifecycle" import { findNextUnresolvedCommentIndex, getCodeCommentThreadStats, markThreadResolved } from "../lib/review-threads" import type { PullRequest, Density, PRDetails, PanelTab, PRPanelData, PRLifecycleInfo } from "../lib/types" import { groupByRepo, groupByStack, groupByRepoAndStack, type GroupMode, type GroupedData } from "../lib/grouping" +import { getCachedPRs, cachePRs } from "../lib/db" interface LsCommandProps { author?: string @@ -68,22 +69,37 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr }, [initialRepoFilter]) useEffect(() => { + const cached = getCachedPRs() + if (cached.length > 0) { + const sorted = [...cached].sort((a, b) => { + const repoCompare = a.repo.localeCompare(b.repo) + if (repoCompare !== 0) return repoCompare + return a.number - b.number + }) + setAllPRs(sorted) + setLoading(false) + } + + let mounted = true async function load() { try { let results = await fetchOpenPRs(author, setLoadingStatus) + if (!mounted) return results.sort((a, b) => { const repoCompare = a.repo.localeCompare(b.repo) if (repoCompare !== 0) return repoCompare return a.number - b.number }) setAllPRs(results) + cachePRs(results) } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch PRs") + if (!cached.length) setError(e instanceof Error ? e.message : "Failed to fetch PRs") } finally { - setLoading(false) + if (mounted) setLoading(false) } } load() + return () => { mounted = false } }, [author]) // Get unique repos and authors for cycling @@ -99,6 +115,8 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr const [authorFilter, setAuthorFilter] = useState(null) + const deferredSearchQuery = useDeferredValue(searchQuery) + const filteredPRs = useMemo(() => { let prs = allPRs // Repo filter @@ -113,8 +131,8 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr if (statusFilter === "open") prs = prs.filter((pr) => !pr.isDraft) if (statusFilter === "draft") prs = prs.filter((pr) => pr.isDraft) // Search filter - if (searchQuery) { - const q = searchQuery.toLowerCase() + if (deferredSearchQuery) { + const q = deferredSearchQuery.toLowerCase() prs = prs.filter((pr) => pr.title.toLowerCase().includes(q) || shortRepoName(pr.repo).toLowerCase().includes(q) || @@ -122,17 +140,32 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr String(pr.number).includes(q) ) } - // Sort - prs = [...prs].sort((a, b) => { + return prs + }, [allPRs, repoFilter, authorFilter, statusFilter, deferredSearchQuery]) + + const urgencyMap = useMemo(() => { + const map = new Map() + for (const pr of filteredPRs) { + const details = detailsMap.get(pr.url) ?? null + const state = detectPRState(pr, details) + map.set(pr.url, state.urgency) + } + return map + }, [filteredPRs, detailsMap]) + + const sortedPRs = useMemo(() => { + const copy = [...filteredPRs] + const timestamps = sortMode === "age" + ? new Map(copy.map(pr => [pr.url, new Date(pr.createdAt).getTime()])) + : null + + return copy.sort((a, b) => { switch (sortMode) { case "attention": { - // Sort by lifecycle urgency score (computed below in lifecycleMap) - const aState = detailsMap.has(a.url) ? detectPRState(a, detailsMap.get(a.url)!) : detectPRState(a, null) - const bState = detailsMap.has(b.url) ? detectPRState(b, detailsMap.get(b.url)!) : detectPRState(b, null) - return compareByUrgency( - { urgency: aState.urgency, createdAt: a.createdAt }, - { urgency: bState.urgency, createdAt: b.createdAt }, - ) + const aUrg = urgencyMap.get(a.url) ?? 0 + const bUrg = urgencyMap.get(b.url) ?? 0 + if (aUrg !== bUrg) return bUrg - aUrg + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() } case "repo": { const rc = a.repo.localeCompare(b.repo) @@ -140,7 +173,7 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr } case "number": return a.number - b.number case "title": return a.title.localeCompare(b.title) - case "age": return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + case "age": return timestamps!.get(b.url)! - timestamps!.get(a.url)! case "status": { const sc = Number(a.isDraft) - Number(b.isDraft) return sc !== 0 ? sc : a.repo.localeCompare(b.repo) @@ -148,66 +181,66 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr default: return 0 } }) - return prs - }, [allPRs, repoFilter, authorFilter, statusFilter, searchQuery, sortMode, detailsMap]) + }, [filteredPRs, sortMode, urgencyMap]) // Grouped data computation const groupedData = useMemo(() => { if (groupMode === "none") return null if (groupMode === "repo") { - return groupByRepo(filteredPRs) + return groupByRepo(sortedPRs) } else if (groupMode === "stack") { - return groupByStack(filteredPRs) + return groupByStack(sortedPRs) } else if (groupMode === "repo-stack") { - return groupByRepoAndStack(filteredPRs) + return groupByRepoAndStack(sortedPRs) } return null - }, [filteredPRs, groupMode]) + }, [sortedPRs, groupMode]) useEffect(() => { - if (selectedIndex >= filteredPRs.length) { - setSelectedIndex(Math.max(0, filteredPRs.length - 1)) + if (selectedIndex >= sortedPRs.length) { + setSelectedIndex(Math.max(0, sortedPRs.length - 1)) } - }, [filteredPRs.length, selectedIndex]) + }, [sortedPRs.length, selectedIndex]) // Always fetch details - needed for lifecycle state detection and attention sort + const prevDetailUrlsRef = useRef("") + useEffect(() => { if (filteredPRs.length === 0) return const cache = cacheRef.current const toFetch = filteredPRs.filter(pr => !cache.hasDetails(pr.url)) - - if (toFetch.length === 0) { - // All cached, just update the map + + const urlFingerprint = filteredPRs.map(pr => pr.url).sort().join('\\n') + + const buildMap = () => { + if (urlFingerprint === prevDetailUrlsRef.current && toFetch.length === 0) return const map = new Map() for (const pr of filteredPRs) { const d = cache.getDetails(pr.url) if (d) map.set(pr.url, d) } + prevDetailUrlsRef.current = urlFingerprint setDetailsMap(map) + } + + if (toFetch.length === 0) { + buildMap() return } - // Fetch missing details - Promise.all( - toFetch.map(async (pr) => { - try { - const details = await fetchPRDetails(pr.repo, pr.number) - cache.setDetails(pr.url, details) - } catch { /* skip on error */ } - }) - ).then(() => { - const map = new Map() - for (const pr of filteredPRs) { - const d = cache.getDetails(pr.url) - if (d) map.set(pr.url, d) + batchFetchPRDetails(toFetch).then((fetchedMap) => { + for (const [url, details] of fetchedMap.entries()) { + cache.setDetails(url, details) } - setDetailsMap(map) + buildMap() + }).catch(() => { + buildMap() }) }, [filteredPRs]) - const selectedPR = filteredPRs[selectedIndex] ?? null + const selectedPR = sortedPRs[selectedIndex] ?? null // Panel state management (shared hook handles data fetching + caching + prefetching) const panel = usePanel(selectedPR, filteredPRs, selectedIndex) @@ -237,7 +270,7 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr if (selectedIndex < listHeight) return 0 return selectedIndex - listHeight + 1 }, [selectedIndex, listHeight]) - const visiblePRs = filteredPRs.slice(scrollOffset, scrollOffset + listHeight) + const visiblePRs = sortedPRs.slice(scrollOffset, scrollOffset + listHeight) const visibleSelectedIndex = selectedIndex - scrollOffset const showFlash = useCallback((msg: string) => { @@ -245,9 +278,12 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr setTimeout(() => setFlash(null), 2000) }, []) + const scrollOffsetRef = useRef(scrollOffset) + scrollOffsetRef.current = scrollOffset + const handleSelect = useCallback((index: number) => { - setSelectedIndex(scrollOffset + index) - }, [scrollOffset]) + setSelectedIndex(scrollOffsetRef.current + index) + }, []) useKeyboard((key) => { if (searchMode) { @@ -453,7 +489,7 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr if (repoFilter && !initialRepoFilter) { setRepoFilter(null); return } renderer.destroy() } else if (key.name === "j" || key.name === "down") { - setSelectedIndex((i) => Math.min(filteredPRs.length - 1, i + 1)) + setSelectedIndex((i) => Math.min(sortedPRs.length - 1, i + 1)) } else if (key.name === "k" || key.name === "up") { setSelectedIndex((i) => Math.max(0, i - 1)) } else if (key.name === "enter" || key.name === "return") { @@ -561,8 +597,10 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr } }) - const openCount = allPRs.filter((pr) => !pr.isDraft).length - const draftCount = allPRs.filter((pr) => pr.isDraft).length + const { openCount, draftCount } = useMemo(() => ({ + openCount: allPRs.filter((pr) => !pr.isDraft).length, + draftCount: allPRs.filter((pr) => pr.isDraft).length, + }), [allPRs]) if (error) { return ( @@ -595,7 +633,7 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr - {filteredPRs.length} PRs sort: {SORT_LABELS[sortMode]} view: {density} group: {groupMode === "none" ? "None" : groupMode === "repo-stack" ? "Repo→Stack" : groupMode} + {sortedPRs.length} PRs sort: {SORT_LABELS[sortMode]} view: {density} group: {groupMode === "none" ? "None" : groupMode === "repo-stack" ? "Repo→Stack" : groupMode} @@ -692,10 +730,10 @@ export function LsCommand({ author, repoFilter: initialRepoFilter }: LsCommandPr groupedData={groupedData} groupMode={groupMode} /> - {filteredPRs.length > listHeight && ( + {sortedPRs.length > listHeight && ( - {scrollOffset + 1}-{Math.min(scrollOffset + listHeight, filteredPRs.length)} of {filteredPRs.length} + {scrollOffset + 1}-{Math.min(scrollOffset + listHeight, sortedPRs.length)} of {sortedPRs.length} )} diff --git a/src/commands/split.tsx b/src/commands/split.tsx new file mode 100644 index 0000000..cfe617b --- /dev/null +++ b/src/commands/split.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect, useCallback } from "react" +import { useKeyboard, useRenderer } from "@opentui/react" +import { Spinner } from "../components/spinner" +import { readSplitState, formatSplitTopology } from "../lib/split-state" +import type { SplitState, SplitEntry } from "../lib/split-state" + +interface SplitCommandProps { + repo?: string +} + +async function getRepoRoot(): Promise { + const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + stdout: "pipe", + stderr: "pipe", + }) + const stdout = await new Response(proc.stdout).text() + const code = await proc.exited + return code === 0 ? stdout.trim() : null +} + +function StatusBadge({ status }: { status: string }) { + const colorMap: Record = { + pending: "#6b7089", + created: "#7aa2f7", + ready: "#e0af68", + reviewing: "#bb9af7", + approved: "#9ece6a", + merged: "#73daca", + } + const color = colorMap[status] ?? "#6b7089" + return [{status}] +} + +function SplitRow({ split, isSelected, index, onSelect }: { + split: SplitEntry + isSelected: boolean + index: number + onSelect?: (i: number) => void +}) { + const bgColor = isSelected ? "#292e42" : "transparent" + const cursor = isSelected ? "\u25B8" : " " + + return ( + onSelect?.(index)} + > + + {cursor} + + + {split.number}. + + + {split.prNumber ? ( + #{split.prNumber} + ) : ( + -- + )} + + + {split.name} + + + {split.lines}L + + + + + + ) +} + +export function SplitCommand({ repo }: SplitCommandProps) { + const renderer = useRenderer() + const [state, setState] = useState("loading") + const [selectedIndex, setSelectedIndex] = useState(0) + const [flash, setFlash] = useState(null) + + const splits = state && state !== "loading" ? state.splits : [] + + const showFlash = useCallback((msg: string) => { + setFlash(msg) + setTimeout(() => setFlash(null), 2000) + }, []) + + useKeyboard((key) => { + if (key.name === "q" || key.name === "escape") { + renderer.destroy() + } else if (key.name === "j" || key.name === "down") { + setSelectedIndex((i) => Math.min(splits.length - 1, i + 1)) + } else if (key.name === "k" || key.name === "up") { + setSelectedIndex((i) => Math.max(0, i - 1)) + } else if ((key.name === "enter" || key.name === "return") && splits[selectedIndex]?.prUrl) { + Bun.spawn(["open", splits[selectedIndex].prUrl!], { stdout: "ignore", stderr: "ignore" }) + showFlash("Opening PR...") + } else if (key.name === "c" && splits[selectedIndex]?.prUrl) { + renderer.copyToClipboardOSC52(splits[selectedIndex].prUrl!) + showFlash("Copied URL!") + } + }) + + useEffect(() => { + async function load() { + const root = await getRepoRoot() + if (!root) { + setState(null) + return + } + const splitState = await readSplitState(root) + setState(splitState) + } + load() + }, []) + + if (state === "loading") { + return ( + + + + ) + } + + if (state === null) { + return ( + + No active split. Use /split-branch to start a split. + + ) + } + + const topologyLines = formatSplitTopology(state) + const approvedCount = state.splits.filter((s) => s.status === "approved" || s.status === "merged").length + const mergedCount = state.splits.filter((s) => s.status === "merged").length + + return ( + + {/* Header */} + + + + raft split + - {state.originalBranch} {"->"} {state.targetBranch} + + + + + {state.strategy} | {approvedCount}/{state.splits.length} approved | {mergedCount} merged + + + + + {/* Phase */} + + + Phase: + {state.status} + | Topology: + {state.topology} + + + + {/* Topology tree */} + + {topologyLines.map((line, i) => ( + + {line} + + ))} + + + {/* Split list */} + + {state.splits.map((split, i) => ( + + ))} + + + {/* Detail panel */} + + {splits[selectedIndex] ? ( + <> + + + {splits[selectedIndex].name} + | + {splits[selectedIndex].files.length} files + | + {splits[selectedIndex].lines} lines + {splits[selectedIndex].dependsOn.length > 0 && ( + <> + | depends on: + {splits[selectedIndex].dependsOn.join(", ")} + + )} + + + + + Branch: {splits[selectedIndex].branch || "(not created)"} + + + + ) : ( + + No split selected + + )} + + {flash ? ( + {flash} + ) : ( + Enter: open PR c: copy URL j/k: navigate q: quit + )} + + + + ) +} diff --git a/src/components/pr-table.tsx b/src/components/pr-table.tsx index 0e2f5b9..09cd2f3 100644 --- a/src/components/pr-table.tsx +++ b/src/components/pr-table.tsx @@ -46,7 +46,7 @@ function renderReviewStatus(reviewStatusStr: string) { return parts } -function PRRow({ pr, isSelected, index, density, details, onSelect }: PRRowProps) { +const PRRow = React.memo(function PRRow({ pr, isSelected, index, density, details, onSelect }: PRRowProps) { const dotColor = pr.isDraft ? "#6b7089" : "#9ece6a" const dot = pr.isDraft ? "\u25CB" : "\u25CF" const cursor = isSelected ? "\u25B8" : " " @@ -222,7 +222,7 @@ function PRRow({ pr, isSelected, index, density, details, onSelect }: PRRowProps } return null -} +}) export function PRTable({ prs, selectedIndex, density, detailsMap, onSelect, groupedData, groupMode }: PRTableProps) { if (prs.length === 0) { diff --git a/src/components/preview-panel.tsx b/src/components/preview-panel.tsx index 0dfc816..f5e8d03 100644 --- a/src/components/preview-panel.tsx +++ b/src/components/preview-panel.tsx @@ -15,6 +15,7 @@ import React from "react" import type { PullRequest, PRPanelData, PanelTab } from "../lib/types" import { shortRepoName } from "../lib/format" import { Spinner } from "./spinner" +import { PanelSkeleton } from "./skeleton" import { PanelBody } from "./panel-body" import { PanelComments } from "./panel-comments" import { PanelCode } from "./panel-code" @@ -122,9 +123,12 @@ export function PreviewPanel({ pr, panelData, loading, tab, width, height, activ {/* Content area with native scrollbox */} - {loading ? ( - - + {loading && !panelData ? ( + + + + + ) : panelData ? ( ) } + +export function PanelSkeleton({ tab, width, height }: { tab: string; width: number; height: number }) { + if (tab === "body") { + return ( + + + + + + + + + ) + } + + if (tab === "comments") { + return ( + + + {[1, 2, 3].map(i => ( + + + + + + ))} + + ) + } + + if (tab === "code") { + return ( + + + {[1, 2].map(i => ( + + + + + + ))} + + ) + } + + if (tab === "files") { + return ( + + + {[1, 2].map(i => ( + + {"\u250C" + "\u2500".repeat(Math.min(width - 2, 40))} + + + + {"\u2514" + "\u2500".repeat(Math.min(width - 2, 40))} + + ))} + + ) + } + + return null +} diff --git a/src/hooks/usePanel.ts b/src/hooks/usePanel.ts index ea4e96e..3484f31 100644 --- a/src/hooks/usePanel.ts +++ b/src/hooks/usePanel.ts @@ -24,8 +24,8 @@ export interface PanelState { cacheRef: MutableRefObject setPanelOpen: (open: boolean) => void setPanelTab: (tab: PanelTab | ((prev: PanelTab) => PanelTab)) => void - setSplitRatio: (ratio: number | ((prev: number) => number)) => void - setPanelFullscreen: (fs: boolean | ((prev: boolean) => boolean)) => void + setSplitRatio: React.Dispatch> + setPanelFullscreen: React.Dispatch> setPanelData: (data: PRPanelData | null) => void } @@ -48,8 +48,8 @@ export function usePanel( ): PanelState { const [panelOpen, setPanelOpen] = useState(false) const [panelTab, setPanelTab] = useState("body") - const [splitRatio, setSplitRatio] = useState(LAYOUT.defaultSplitRatio) - const [panelFullscreen, setPanelFullscreen] = useState(false) + const [splitRatio, setSplitRatio] = useState(LAYOUT.defaultSplitRatio) + const [panelFullscreen, setPanelFullscreen] = useState(false) const [panelData, setPanelData] = useState(null) const [panelLoading, setPanelLoading] = useState(false) const cacheRef = useRef(new PRCache()) diff --git a/src/index.tsx b/src/index.tsx index 4f6c1c2..da7a533 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,9 +9,10 @@ import { LogCommand } from "./commands/log" import { MergeCommand } from "./commands/merge" import { SyncCommand } from "./commands/sync" import { NavCommand, CreateCommand, RestackCommand } from "./commands/nav" +import { SplitCommand } from "./commands/split" type Command = "ls" | "stack" | "stack-sync" | "log" | "merge" | "sync" - | "create" | "up" | "down" | "restack" | "home" | "help" + | "create" | "up" | "down" | "restack" | "split" | "home" | "help" interface Config { command: Command @@ -65,6 +66,7 @@ function parseArgs(argv: string[]): Config { if (command === "up") return { command: "up" } if (command === "down") return { command: "down" } if (command === "restack") return { command: "restack" } + if (command === "split") return { command: "split", repoFilter } return { command: "home" } } @@ -86,6 +88,7 @@ Usage: raft up Move up in the current stack raft down Move down in the current stack raft restack Rebase stack onto parents + raft split View split-branch state raft --help Show this help message`) } @@ -208,6 +211,9 @@ switch (config.command) { case "restack": root.render() break + case "split": + root.render() + break default: root.render() break diff --git a/src/lib/__tests__/cache.test.ts b/src/lib/__tests__/cache.test.ts index ddf0319..010afa4 100644 --- a/src/lib/__tests__/cache.test.ts +++ b/src/lib/__tests__/cache.test.ts @@ -35,8 +35,9 @@ describe("PRCache", () => { test("has() returns correct boolean", () => { const cache = new PRCache() - expect(cache.hasDetails("x")).toBe(false) - cache.setDetails("x", { additions: 0, deletions: 0, commentCount: 0, reviews: [], headRefName: "" }) - expect(cache.hasDetails("x")).toBe(true) + const uniqueKey = "test-unique-key-" + Date.now(); + expect(cache.hasDetails(uniqueKey)).toBe(false) + cache.setDetails(uniqueKey, { additions: 0, deletions: 0, commentCount: 0, reviews: [], headRefName: "" }) + expect(cache.hasDetails(uniqueKey)).toBe(true) }) }) diff --git a/src/lib/__tests__/github.test.ts b/src/lib/__tests__/github.test.ts index 784c0d6..874555c 100644 --- a/src/lib/__tests__/github.test.ts +++ b/src/lib/__tests__/github.test.ts @@ -3,18 +3,18 @@ import { parseSearchResults, stripStackPrefix } from "../github" describe("parseSearchResults", () => { test("parses gh search prs JSON output into PullRequest array", () => { - const raw = JSON.stringify([ + const raw = [ { number: 42, title: "Add feature", - url: "https://github.com/acme/api/pull/42", + html_url: "https://github.com/acme/api/pull/42", body: "First line of body\nSecond line", state: "open", - isDraft: false, - repository: { nameWithOwner: "acme/api" }, - createdAt: "2026-03-15T00:00:00Z", + draft: false, + repository_url: "https://api.github.com/repos/acme/api", + created_at: "2026-03-15T00:00:00Z", }, - ]) + ] const result = parseSearchResults(raw) expect(result).toHaveLength(1) expect(result[0].repo).toBe("acme/api") @@ -27,52 +27,52 @@ describe("parseSearchResults", () => { test("truncates body to first line, max 80 chars", () => { const longLine = "A".repeat(100) - const raw = JSON.stringify([ + const raw = [ { number: 1, title: "T", - url: "u", + html_url: "u", body: longLine + "\nSecond", state: "open", - isDraft: false, - repository: { nameWithOwner: "a/b" }, - createdAt: "2026-01-01T00:00:00Z", + draft: false, + repository_url: "https://api.github.com/repos/a/b", + created_at: "2026-01-01T00:00:00Z", }, - ]) + ] const result = parseSearchResults(raw) expect(result[0].body.length).toBeLessThanOrEqual(80) }) test("handles empty body", () => { - const raw = JSON.stringify([ + const raw = [ { number: 1, title: "T", - url: "u", + html_url: "u", body: "", state: "open", - isDraft: false, - repository: { nameWithOwner: "a/b" }, - createdAt: "2026-01-01T00:00:00Z", + draft: false, + repository_url: "https://api.github.com/repos/a/b", + created_at: "2026-01-01T00:00:00Z", }, - ]) + ] const result = parseSearchResults(raw) expect(result[0].body).toBe("") }) test("handles null body", () => { - const raw = JSON.stringify([ + const raw = [ { number: 1, title: "T", - url: "u", + html_url: "u", body: null, state: "open", - isDraft: false, - repository: { nameWithOwner: "a/b" }, - createdAt: "2026-01-01T00:00:00Z", + draft: false, + repository_url: "https://api.github.com/repos/a/b", + created_at: "2026-01-01T00:00:00Z", }, - ]) + ] const result = parseSearchResults(raw) expect(result[0].body).toBe("") }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b923959 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,21 @@ +import { safeSpawn, buildCleanEnv } from "./process" + +let cachedToken: string | null = null; + +export async function getGithubToken(): Promise { + if (cachedToken) { + return cachedToken; + } + + // Use the gh CLI to get the OAuth token, avoiding any .env leaks via buildCleanEnv + const { stdout, exitCode, stderr } = await safeSpawn(["gh", "auth", "token"], { + env: buildCleanEnv(), + }); + + if (exitCode !== 0) { + throw new Error(`Failed to get GitHub token from gh CLI. Ensure you are logged in via 'gh auth login'. Error: ${stderr}`); + } + + cachedToken = stdout.trim(); + return cachedToken; +} diff --git a/src/lib/cache.ts b/src/lib/cache.ts index cd07391..903b73a 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -1,30 +1,46 @@ import type { PRDetails, PRPanelData } from "./types" +import { getCachedPRDetails, cachePRDetails, getCachedPRPanelData, cachePRPanelData } from "./db" export class PRCache { + // In-memory backing for immediate synchronous reads during renders private details = new Map() private panelData = new Map() getDetails(url: string): PRDetails | undefined { - return this.details.get(url) + if (this.details.has(url)) return this.details.get(url); + const fromDb = getCachedPRDetails(url); + if (fromDb) { + this.details.set(url, fromDb); + return fromDb; + } + return undefined; } setDetails(url: string, data: PRDetails): void { - this.details.set(url, data) + this.details.set(url, data); + cachePRDetails(url, data); } hasDetails(url: string): boolean { - return this.details.has(url) + return this.details.has(url) || getCachedPRDetails(url) !== null; } getPanelData(url: string): PRPanelData | undefined { - return this.panelData.get(url) + if (this.panelData.has(url)) return this.panelData.get(url); + const fromDb = getCachedPRPanelData(url); + if (fromDb) { + this.panelData.set(url, fromDb); + return fromDb; + } + return undefined; } setPanelData(url: string, data: PRPanelData): void { - this.panelData.set(url, data) + this.panelData.set(url, data); + cachePRPanelData(url, data); } hasPanelData(url: string): boolean { - return this.panelData.has(url) + return this.panelData.has(url) || getCachedPRPanelData(url) !== null; } } diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..389ec06 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,87 @@ +import { Database } from "bun:sqlite"; +import type { PullRequest, PRDetails, PRPanelData } from "./types"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +// Initialize database in ~/.config/raft/raft.sqlite +const configDir = join(homedir(), ".config", "raft"); +mkdirSync(configDir, { recursive: true }); + +const dbPath = join(configDir, "raft.sqlite"); +export const db = new Database(dbPath); + +db.run(` + CREATE TABLE IF NOT EXISTS pull_requests ( + url TEXT PRIMARY KEY, + data JSON NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS pr_details ( + url TEXT PRIMARY KEY, + data JSON NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS pr_panel_data ( + url TEXT PRIMARY KEY, + data JSON NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +export function getCachedPRs(): PullRequest[] { + const query = db.query(`SELECT data FROM pull_requests`); + return query.all().map((row: any) => JSON.parse(row.data)); +} + +export function cachePRs(prs: PullRequest[]) { + const insert = db.prepare(` + INSERT OR REPLACE INTO pull_requests (url, data, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `); + + db.transaction(() => { + // We clear old PRs to ensure we don't keep ones that were closed/merged + // This could be optimized later to sync rather than drop + db.run("DELETE FROM pull_requests"); + for (const pr of prs) { + insert.run(pr.url, JSON.stringify(pr)); + } + })(); +} + +export function getCachedPRDetails(url: string): PRDetails | null { + const query = db.query(`SELECT data FROM pr_details WHERE url = ?`); + const row = query.get(url) as any; + if (!row) return null; + return JSON.parse(row.data); +} + +export function cachePRDetails(url: string, details: PRDetails) { + const insert = db.prepare(` + INSERT OR REPLACE INTO pr_details (url, data, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `); + insert.run(url, JSON.stringify(details)); +} + +export function getCachedPRPanelData(url: string): PRPanelData | null { + const query = db.query(`SELECT data FROM pr_panel_data WHERE url = ?`); + const row = query.get(url) as any; + if (!row) return null; + return JSON.parse(row.data); +} + +export function cachePRPanelData(url: string, panelData: PRPanelData) { + const insert = db.prepare(` + INSERT OR REPLACE INTO pr_panel_data (url, data, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `); + insert.run(url, JSON.stringify(panelData)); +} diff --git a/src/lib/github.ts b/src/lib/github.ts index f4a749a..ead7ebd 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,312 +1,149 @@ import type { PullRequest, PRDetails, Review, PRPanelData, Comment, CodeComment, FileDiff } from "./types" import { STACK_COMMENT_MARKER } from "./types" -import { safeSpawn, buildCleanEnv } from "./process" +import { safeSpawn } from "./process" import { hydrateCodeComments } from "./review-threads" +import { getGithubToken } from "./auth" + +export async function fetchGh(endpoint: string, options: RequestInit = {}): Promise { + const token = await getGithubToken(); + const url = endpoint.startsWith("http") ? endpoint : `https://api.github.com/${endpoint.replace(/^\//, "")}`; + + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("Accept")) { + headers.set("Accept", "application/vnd.github.v3+json"); + } + + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API error ${response.status}: ${text}`); + } + + if (response.status === 204) { + return {}; + } + + return response.json(); +} + +export async function fetchGhGraphql(query: string, variables: any = {}): Promise { + const token = await getGithubToken(); + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }) + }); + + if (!response.ok) { + throw new Error(`GraphQL error ${response.status}: ${await response.text()}`); + } -interface RawSearchResult { - number: number - title: string - url: string - body: string - state: string - isDraft: boolean - repository: { nameWithOwner: string } - createdAt: string - author?: { login: string } + const json = await response.json(); + if (json.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`); + } + + return json.data; } -/** - * Parse GitHub search JSON results into PullRequest objects. - * - * Converts raw GitHub CLI JSON output from `gh search prs` into a normalized - * PullRequest array. Truncates body to first line and 80 characters. - * - * @param jsonStr - Raw JSON string from GitHub CLI search output - * @returns Array of normalized PullRequest objects - */ -export function parseSearchResults(jsonStr: string): PullRequest[] { - const raw: RawSearchResult[] = JSON.parse(jsonStr) - return raw.map((pr) => { - const firstLine = (pr.body ?? "").split("\n")[0] ?? "" +export function parseSearchResults(items: any[]): PullRequest[] { + return items.map((pr) => { + const firstLine = (pr.body ?? "").split("\n")[0] ?? ""; + const repoUrlParts = pr.repository_url.split("/"); + const repo = `${repoUrlParts[repoUrlParts.length - 2]}/${repoUrlParts[repoUrlParts.length - 1]}`; return { number: pr.number, title: pr.title, - url: pr.url, + url: pr.html_url, body: firstLine.slice(0, 80), state: pr.state, - isDraft: pr.isDraft, - repo: pr.repository.nameWithOwner, + isDraft: pr.draft || false, + repo, headRefName: "", baseRefName: "", - createdAt: pr.createdAt, - author: pr.author?.login, + createdAt: pr.created_at, + author: pr.user?.login, } - }) + }); } -/** - * Remove stack numbering prefix from a PR title. - * - * Strips the `[n/m]` prefix that marks PR position in a stacked series. - * Example: `[2/4] Add auth` becomes `Add auth`. - * - * @param title - PR title potentially prefixed with stack notation - * @returns Title without the prefix - */ export function stripStackPrefix(title: string): string { return title.replace(/^\[\d+\/\d+\]\s*/, "") } -async function runGh(args: string[]): Promise { - // Use safeSpawn to prevent fd leaks that caused segfaults after ~72s - const { stdout, stderr, exitCode } = await safeSpawn(["gh", ...args], { - env: buildCleanEnv(), - }) - if (exitCode !== 0) { - throw new Error(`gh ${args.join(" ")} failed: ${stderr}`) - } - return stdout -} - -type GhRunner = typeof runGh - -/** Get all authenticated gh account usernames. */ export async function getGhAccounts(): Promise { - try { - const output = await runGh(["auth", "status"]) - // Parse account names from "Logged in to github.com account " - const accounts: string[] = [] - for (const line of output.split("\n")) { - const match = line.match(/account\s+(\S+)/) - if (match) accounts.push(match[1]) - } - return accounts - } catch (e) { - // gh auth status exits non-zero sometimes; parse stderr too - const msg = e instanceof Error ? e.message : "" - const accounts: string[] = [] - for (const line of msg.split("\n")) { - const match = line.match(/account\s+(\S+)/) - if (match) accounts.push(match[1]) - } - return accounts.length > 0 ? accounts : ["@me"] - } + // Not heavily used with fetch paradigm, just return dummy since token implies identity + return ["@me"] } -/** Get the currently active gh account. */ -async function getActiveAccount(): Promise { - try { - const output = await runGh(["auth", "status"]) - const lines = output.split("\n") - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes("Active account: true")) { - // Account name is on a preceding line - for (let j = i - 1; j >= 0; j--) { - const match = lines[j].match(/account\s+(\S+)/) - if (match) return match[1] - } - } - } - } catch { /* ignore */ } - return null -} - -async function switchAccount(username: string): Promise { - await runGh(["auth", "switch", "--user", username]) -} - -/** Fetch open PRs across all gh accounts, deduped by URL. - * Switches accounts and uses @me for each, since the GitHub username - * may not match the PR author (e.g., org-linked accounts). */ export async function fetchAllAccountPRs( onProgress?: (status: string) => void, ): Promise { - onProgress?.("Discovering accounts...") - const accounts = await getGhAccounts() - const originalAccount = await getActiveAccount() - const allPRs: PullRequest[] = [] - const seen = new Set() - - for (let i = 0; i < accounts.length; i++) { - const account = accounts[i] - onProgress?.(`Fetching PRs for ${account} (${i + 1}/${accounts.length})...`) - if (accounts.length > 1) { - try { await switchAccount(account) } catch { continue } - } - try { - const json = await runGh([ - "search", "prs", - "--author=@me", - "--state=open", - "--limit=100", - "--json", "number,title,url,body,state,repository,isDraft,createdAt", - ]) - if (json) { - const parsed = parseSearchResults(json) - for (const pr of parsed) { - if (!seen.has(pr.url)) { - seen.add(pr.url) - allPRs.push(pr) - } - } - onProgress?.(`Found ${allPRs.length} PRs so far...`) - } - } catch { /* skip account if query fails */ } - } - - onProgress?.(`Loaded ${allPRs.length} PRs across ${accounts.length} accounts`) - - // Restore original account - if (accounts.length > 1 && originalAccount) { - try { await switchAccount(originalAccount) } catch { /* ignore */ } - } - - return allPRs + onProgress?.("Fetching PRs..."); + const json = await fetchGh("search/issues?q=is:pr+is:open+author:@me&per_page=100"); + return parseSearchResults(json.items); } -/** - * Fetch open pull requests for a specific author or all accessible repositories. - * - * - If `author` is undefined: fetches PRs across all authenticated accounts via `@me`. - * - If `author` is empty string: fetches all open PRs accessible to the current account. - * - If `author` is provided: fetches PRs by that specific author. - * - * @param author - GitHub username or empty string for all repos, undefined for @me across accounts - * @param onProgress - Optional callback for progress status messages - * @returns Array of open pull requests - */ export async function fetchOpenPRs( author?: string, onProgress?: (status: string) => void, ): Promise { if (author === "") { - // Empty string means fetch all PRs across all repos the user has access to - onProgress?.("Fetching all open PRs...") - const json = await runGh([ - "search", "prs", - "--state=open", - "--limit=1000", - "--json", "number,title,url,body,state,repository,isDraft,createdAt,author", - ]) - if (!json) return [] - return parseSearchResults(json) + onProgress?.("Fetching all open PRs..."); + const json = await fetchGh("search/issues?q=is:pr+is:open&per_page=100"); + return parseSearchResults(json.items); } if (author) { - onProgress?.(`Fetching PRs for ${author}...`) - const json = await runGh([ - "search", "prs", - `--author=${author}`, - "--state=open", - "--limit=100", - "--json", "number,title,url,body,state,repository,isDraft,createdAt,author", - ]) - if (!json) return [] - return parseSearchResults(json) + onProgress?.(`Fetching PRs for ${author}...`); + const json = await fetchGh(`search/issues?q=is:pr+is:open+author:${author}&per_page=100`); + return parseSearchResults(json.items); } - return fetchAllAccountPRs(onProgress) + return fetchAllAccountPRs(onProgress); } -/** Try fetching repo PRs, attempting each account if needed. */ export async function fetchRepoPRs(repo: string): Promise { - const accounts = await getGhAccounts() - const originalAccount = await getActiveAccount() - - for (const account of accounts) { - if (accounts.length > 1) { - try { await switchAccount(account) } catch { continue } - } - try { - const json = await runGh([ - "pr", "list", - "--repo", repo, - "--state=open", - "--limit=100", - "--json", "number,title,url,body,state,isDraft,headRefName,baseRefName,createdAt", - ]) - // Restore original account before returning - if (accounts.length > 1 && originalAccount) { - try { await switchAccount(originalAccount) } catch { /* ignore */ } - } - if (!json) return [] - const raw = JSON.parse(json) as Array<{ - number: number - title: string - url: string - body: string - state: string - isDraft: boolean - headRefName: string - baseRefName: string - createdAt: string - }> - return raw.map((pr) => ({ - number: pr.number, - title: pr.title, - url: `https://github.com/${repo}/pull/${pr.number}`, - body: (pr.body ?? "").split("\n")[0]?.slice(0, 80) ?? "", - state: pr.state, - isDraft: pr.isDraft, - repo, - headRefName: pr.headRefName, - baseRefName: pr.baseRefName, - createdAt: pr.createdAt, - })) - } catch { - // This account can't access the repo, try next - continue - } - } - - // Restore original account - if (accounts.length > 1 && originalAccount) { - try { await switchAccount(originalAccount) } catch { /* ignore */ } + try { + const prs = await fetchGh(`repos/${repo}/pulls?state=open&per_page=100`); + return prs.map((pr: any) => ({ + number: pr.number, + title: pr.title, + url: pr.html_url, + body: (pr.body ?? "").split("\n")[0]?.slice(0, 80) ?? "", + state: pr.state, + isDraft: pr.draft || false, + repo, + headRefName: pr.head.ref, + baseRefName: pr.base.ref, + createdAt: pr.created_at, + })); + } catch { + return []; } - - return [] } -/** - * Update a PR's title via the GitHub API. - * - * @param repo - Full repository name in `owner/repo` format - * @param prNumber - The pull request number - * @param title - New title for the PR - */ export async function updatePRTitle(repo: string, prNumber: number, title: string): Promise { - await runGh(["pr", "edit", String(prNumber), "--repo", repo, "--title", title]) + await fetchGh(`repos/${repo}/pulls/${prNumber}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }) + }); } -/** - * Find the ID of a stacked PR's metadata comment. - * - * Searches for an issue comment containing the STACK_COMMENT_MARKER. - * Used to locate and update stack information (rebase status, dependencies). - * - * @param repo - Full repository name in `owner/repo` format - * @param prNumber - The pull request number - * @returns Comment ID if found, null otherwise - */ export async function findStackComment(repo: string, prNumber: number): Promise { - const json = await runGh([ - "api", - `repos/${repo}/issues/${prNumber}/comments`, - "--jq", `.[] | select(.body | contains("${STACK_COMMENT_MARKER}")) | .id`, - ]) - if (!json) return null - const id = parseInt(json.split("\n")[0], 10) - return isNaN(id) ? null : id + const comments = await fetchGh(`repos/${repo}/issues/${prNumber}/comments`); + for (const c of comments) { + if (c.body && c.body.includes(STACK_COMMENT_MARKER)) { + return c.id; + } + } + return null; } -/** - * Create or update a stacked PR's metadata comment. - * - * If a comment with STACK_COMMENT_MARKER exists, updates it. Otherwise creates a new one. - * Used to track rebase status and dependencies in stacked PR workflows. - * - * @param repo - Full repository name in `owner/repo` format - * @param prNumber - The pull request number - * @param body - Body content (without marker; marker is added automatically) - */ export async function upsertStackComment( repo: string, prNumber: number, @@ -314,242 +151,212 @@ export async function upsertStackComment( ): Promise { const existingId = await findStackComment(repo, prNumber) const fullBody = `${STACK_COMMENT_MARKER}\n${body}` + if (existingId) { - await runGh([ - "api", - `repos/${repo}/issues/comments/${existingId}`, - "--method", "PATCH", - "--field", `body=${fullBody}`, - ]) + await fetchGh(`repos/${repo}/issues/comments/${existingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: fullBody }) + }); } else { - await runGh([ - "api", - `repos/${repo}/issues/${prNumber}/comments`, - "--method", "POST", - "--field", `body=${fullBody}`, - ]) + await fetchGh(`repos/${repo}/issues/${prNumber}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: fullBody }) + }); } } -/** - * Get the current repository name from the working directory. - * - * Uses `gh repo view` to detect the repository that contains the current git checkout. - * Returns null if not in a git repository or gh cannot determine the repo. - * - * @returns Repository name in `owner/repo` format, or null if not in a repository - */ export async function getCurrentRepo(): Promise { try { - const result = await runGh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]) - return result || null + const { stdout } = await safeSpawn(["git", "config", "--get", "remote.origin.url"]); + const url = stdout.trim(); + const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/); + if (match) return match[1]; + return null; } catch { - return null + return null; } } -/** Helper: try fetching with account switching for repos that may need different auth */ -async function tryMultiAccountFetch( - fetchFn: () => Promise -): Promise { - const accounts = await getGhAccounts() - const originalAccount = await getActiveAccount() - - // Try with current account first - try { - return await fetchFn() - } catch (firstError) { - // If there's only one account, rethrow - if (accounts.length <= 1) { - throw firstError - } - - // Try other accounts - for (const account of accounts) { - if (account === originalAccount) continue - try { - await switchAccount(account) - const result = await fetchFn() - // Restore original account before returning - if (originalAccount) { - try { await switchAccount(originalAccount) } catch {} +export async function batchFetchPRDetails(prs: {repo: string, number: number, url: string}[]): Promise> { + const resultMap = new Map(); + if (prs.length === 0) return resultMap; + + // Chunk to avoid massive queries. Safe batch size: 20 + const CHUNK_SIZE = 20; + for (let i = 0; i < prs.length; i += CHUNK_SIZE) { + const chunk = prs.slice(i, i + CHUNK_SIZE); + let query = "query {\\n"; + for (let j = 0; j < chunk.length; j++) { + const pr = chunk[j]; + const [owner, name] = pr.repo.split("/"); + query += ` + pr_${j}: repository(owner: "${owner}", name: "${name}") { + pullRequest(number: ${pr.number}) { + additions + deletions + comments { totalCount } + headRefName + headRefOid + mergeable + reviews(first: 100) { + nodes { + author { login } + state + } + } + reviewThreads(first: 100) { + nodes { + isResolved + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } } - return result - } catch { - continue - } + `; } + query += "}"; - // Restore original account - if (originalAccount) { - try { await switchAccount(originalAccount) } catch {} + try { + const data = await fetchGhGraphql(query); + for (let j = 0; j < chunk.length; j++) { + const pr = chunk[j]; + const prData = data[`pr_${j}`]?.pullRequest; + if (!prData) continue; + + const reviews: Review[] = (prData.reviews?.nodes || []).map((r: any) => ({ + user: r.author?.login || "unknown", + state: r.state + })); + + const unresolvedThreadCount = (prData.reviewThreads?.nodes || []) + .filter((t: any) => !t.isResolved).length; + + const checkState = prData.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state; + let ciStatus: "ready" | "pending" | "failing" | "unknown" = "unknown"; + if (checkState === "SUCCESS") ciStatus = "ready"; + else if (checkState === "PENDING") ciStatus = "pending"; + else if (checkState === "FAILURE" || checkState === "ERROR") ciStatus = "failing"; + + const hasConflicts = prData.mergeable === "CONFLICTING"; + + resultMap.set(pr.url, { + additions: prData.additions, + deletions: prData.deletions, + commentCount: prData.comments?.totalCount || 0, + reviews, + headRefName: prData.headRefName, + unresolvedThreadCount, + ciStatus, + hasConflicts, + }); + } + } catch (e) { + console.error("GraphQL batch failed:", e); } - throw firstError } + + return resultMap; } -/** Fetch detailed PR metadata: additions, deletions, comments count, reviews. */ export async function fetchPRDetails(repo: string, prNumber: number): Promise { - return tryMultiAccountFetch(async () => { - const [prJson, reviewsJson] = await Promise.all([ - runGh([ - "api", `repos/${repo}/pulls/${prNumber}`, - "--jq", "{additions, deletions, comments, headRefName: .head.ref, headSha: .head.sha, mergeable, mergeable_state: .mergeable_state}", - ]), - runGh([ - "api", `repos/${repo}/pulls/${prNumber}/reviews`, - "--jq", "[.[] | {user: .user.login, state: .state}]", - ]), - ]) - - const pr = JSON.parse(prJson) as { - additions: number - deletions: number - comments: number - headRefName: string - headSha: string - mergeable: boolean | null - mergeable_state: string | null - } - const reviews: Review[] = JSON.parse(reviewsJson) - const [ciStatus, reviewThreads] = await Promise.all([ - fetchCIStatusWithRunner(repo, pr.headSha, runGh).catch(() => "unknown" as const), - fetchReviewThreadsWithRunner(repo, prNumber, runGh).catch(() => null), - ]) - const unresolvedThreadCount = reviewThreads !== null - ? reviewThreads.filter((thread) => !thread.isResolved).length - : -1 - const hasConflicts = pr.mergeable === false || pr.mergeable_state === "dirty" - - return { - additions: pr.additions, - deletions: pr.deletions, - commentCount: pr.comments, - reviews, - headRefName: pr.headRefName, - unresolvedThreadCount, - ciStatus, - hasConflicts, - } - }) + const map = await batchFetchPRDetails([{repo, number: prNumber, url: `https://github.com/${repo}/pull/${prNumber}`}]); + const details = map.get(`https://github.com/${repo}/pull/${prNumber}`); + if (!details) throw new Error("Failed to fetch PR details"); + return details; } -/** Fetch full PR data for the preview panel: body, conversation comments, code comments. */ export async function fetchPRPanelData(repo: string, prNumber: number): Promise { - return tryMultiAccountFetch(async () => { - const [bodyJson, issueCommentsJson, codeCommentsJson, filesJson, reviewThreads] = await Promise.all([ - runGh([ - "api", `repos/${repo}/pulls/${prNumber}`, - "--jq", ".body", - ]), - runGh([ - "api", `repos/${repo}/issues/${prNumber}/comments`, - "--jq", "[.[] | {author: .user.login, body: .body, createdAt: .created_at, authorAssociation: .author_association}]", - ]), - runGh([ - "api", `repos/${repo}/pulls/${prNumber}/comments`, - "--jq", "[.[] | {id: .id, author: .user.login, body: .body, path: .path, line: (.line // .original_line // 0), diffHunk: .diff_hunk, createdAt: .created_at}]", - ]), - runGh([ - "api", `repos/${repo}/pulls/${prNumber}/files`, - "--jq", "[.[] | {filename: .filename, status: .status, additions: .additions, deletions: .deletions, changes: .changes, patch: .patch, previousFilename: .previous_filename}]", - ]), - fetchReviewThreadsWithRunner(repo, prNumber, runGh).catch(() => []), - ]) - - const body = bodyJson || "" - const comments: Comment[] = JSON.parse(issueCommentsJson || "[]") - const codeComments = hydrateCodeComments( - JSON.parse(codeCommentsJson || "[]") as CodeComment[], - reviewThreads, - ) - const files: FileDiff[] = JSON.parse(filesJson || "[]") - - return { body, comments, codeComments, files } - }) + const [prData, issueComments, codeComments, files, reviewThreads] = await Promise.all([ + fetchGh(`repos/${repo}/pulls/${prNumber}`), + fetchGh(`repos/${repo}/issues/${prNumber}/comments`), + fetchGh(`repos/${repo}/pulls/${prNumber}/comments`), + fetchGh(`repos/${repo}/pulls/${prNumber}/files?per_page=100`), + fetchReviewThreads(repo, prNumber), + ]); + + const body = prData.body || ""; + const comments: Comment[] = (issueComments || []).map((c: any) => ({ + author: c.user?.login || "unknown", + body: c.body, + createdAt: c.created_at, + authorAssociation: c.author_association + })); + + const rawCodeComments = (codeComments || []).map((c: any) => ({ + id: c.id, + author: c.user?.login || "unknown", + body: c.body, + path: c.path, + line: c.line || c.original_line || 0, + diffHunk: c.diff_hunk, + createdAt: c.created_at + })); + + const codeCommentsHydrated = hydrateCodeComments(rawCodeComments, reviewThreads); + + const formattedFiles: FileDiff[] = (files || []).map((f: any) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch, + previousFilename: f.previous_filename + })); + + return { body, comments, codeComments: codeCommentsHydrated, files: formattedFiles }; } -/** Valid review event types for the GitHub PR review API. */ export type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT" -/** - * Submits a PR review (approve, request changes, or comment) via the GitHub API. - * - * Uses the pull request review endpoint to create a review with the specified - * event type and optional body text. Handles multi-account auth automatically. - * - * @param repo - Full repository name in `owner/repo` format. - * @param prNumber - The pull request number. - * @param event - The review action: APPROVE, REQUEST_CHANGES, or COMMENT. - * @param body - Optional review comment body (required for REQUEST_CHANGES). - */ export async function submitPRReview( repo: string, prNumber: number, event: ReviewEvent, body?: string, ): Promise { - return tryMultiAccountFetch(async () => { - const args = [ - "api", "--method", "POST", - `repos/${repo}/pulls/${prNumber}/reviews`, - "--field", `event=${event}`, - ] - if (body) { - args.push("--field", `body=${body}`) - } - await runGh(args) - }) + await fetchGh(`repos/${repo}/pulls/${prNumber}/reviews`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ? { event, body } : { event }) + }); } -/** - * Replies to an existing review comment on a pull request. - * - * Uses the pull request review comment reply endpoint. The `commentId` is - * the ID of the review comment being replied to (from the Code tab). - * - * @param repo - Full repository name in `owner/repo` format. - * @param prNumber - The pull request number. - * @param commentId - The ID of the review comment to reply to. - * @param body - The reply text. - */ export async function replyToReviewComment( repo: string, prNumber: number, commentId: number, body: string, ): Promise { - return tryMultiAccountFetch(async () => { - await runGh([ - "api", "--method", "POST", - `repos/${repo}/pulls/${prNumber}/comments/${commentId}/replies`, - "--field", `body=${body}`, - ]) - }) + await fetchGh(`repos/${repo}/pulls/${prNumber}/comments/${commentId}/replies`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }) + }); } -/** - * Posts a general comment on a pull request (issue-level, not inline). - * - * @param repo - Full repository name in `owner/repo` format. - * @param prNumber - The pull request number. - * @param body - The comment text. - */ export async function postPRComment( repo: string, prNumber: number, body: string, ): Promise { - return tryMultiAccountFetch(async () => { - await runGh([ - "api", "--method", "POST", - `repos/${repo}/issues/${prNumber}/comments`, - "--field", `body=${body}`, - ]) - }) + await fetchGh(`repos/${repo}/issues/${prNumber}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }) + }); } -/** A review thread with resolution status and associated comments. */ export interface ReviewThread { id: string isResolved: boolean @@ -563,30 +370,8 @@ export interface ReviewThread { }> } -/** - * Fetch review threads with resolution status via GraphQL. - * - * Uses the pullRequest.reviewThreads connection to get thread-level - * resolution state, which isn't available from the REST API. - * - * @param repo - Full repository name in owner/repo format. - * @param prNumber - The pull request number. - * @returns Array of review threads with their resolution status. - */ export async function fetchReviewThreads(repo: string, prNumber: number): Promise { - return tryMultiAccountFetch(() => fetchReviewThreadsWithRunner(repo, prNumber, runGh)) -} - -async function fetchReviewThreadsWithRunner( - repo: string, - prNumber: number, - ghRunner: GhRunner, -): Promise { const [owner, name] = repo.split("/") - /** - * NOTE: Threads with >100 items or >50 comments per thread are truncated. - * Full pagination is deferred. - */ const query = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -610,15 +395,8 @@ async function fetchReviewThreadsWithRunner( } }` - const result = await ghRunner([ - "api", "graphql", - "-f", `query=${query}`, - "-F", `owner=${owner}`, - "-F", `name=${name}`, - "-F", `number=${prNumber}`, - ]) - const data = JSON.parse(result) - const threads = data.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [] + const data = await fetchGhGraphql(query, { owner, name, number: prNumber }); + const threads = data.repository?.pullRequest?.reviewThreads?.nodes ?? []; return threads.map((t: any) => ({ id: t.id, isResolved: t.isResolved, @@ -630,69 +408,36 @@ async function fetchReviewThreadsWithRunner( line: c.line, createdAt: c.createdAt, })), - })) + })); } -/** - * Resolve a review thread via GraphQL mutation. - * - * @param threadId - The GraphQL node ID of the review thread to resolve. - */ export async function resolveReviewThread(threadId: string): Promise { const query = `mutation { resolveReviewThread(input: { threadId: "${threadId}" }) { thread { id isResolved } } }` - - return tryMultiAccountFetch(async () => { - await runGh(["api", "graphql", "-f", `query=${query}`]) - }) + await fetchGhGraphql(query); } -/** - * Fetch CI check status for a PR's head commit. - * - * @param repo - Full repository name in owner/repo format. - * @param ref - Git ref (branch name or SHA) to check. - * @returns "ready" if all pass, "pending" if running, "failing" if any failed, "unknown" if status cannot be determined. - */ export async function fetchCIStatus(repo: string, ref: string): Promise<"ready" | "pending" | "failing" | "unknown"> { try { - return await tryMultiAccountFetch(() => fetchCIStatusWithRunner(repo, ref, runGh)) + const checks: any = await fetchGh(`repos/${repo}/commits/${ref}/check-runs`); + const checkRuns = checks.check_runs || []; + if (checkRuns.length === 0) return "ready"; + if (checkRuns.some((c: any) => ["failure", "timed_out", "cancelled", "action_required"].includes(c.conclusion ?? ""))) return "failing"; + if (checkRuns.some((c: any) => c.status !== "completed")) return "pending"; + return "ready"; } catch { - return "unknown" // Can't determine status + return "unknown"; } } -async function fetchCIStatusWithRunner( - repo: string, - ref: string, - ghRunner: GhRunner, -): Promise<"ready" | "pending" | "failing" | "unknown"> { - const result = await ghRunner(["api", `repos/${repo}/commits/${ref}/check-runs`, "--jq", ".check_runs"]) - const checks = JSON.parse(result) as Array<{ status: string; conclusion: string | null }> - if (checks.length === 0) return "ready" - if (checks.some(c => ["failure", "timed_out", "cancelled", "action_required"].includes(c.conclusion ?? ""))) return "failing" - if (checks.some(c => c.status !== "completed")) return "pending" - return "ready" -} - -/** - * Check if a PR has merge conflicts. - * - * @param repo - Full repository name in owner/repo format. - * @param prNumber - The pull request number. - * @returns true if the PR has merge conflicts. - */ export async function fetchHasConflicts(repo: string, prNumber: number): Promise { try { - const result = await tryMultiAccountFetch(async () => { - return runGh(["pr", "view", String(prNumber), "--repo", repo, "--json", "mergeable,mergeStateStatus"]) - }) - const data = JSON.parse(result) as { mergeable: string; mergeStateStatus: string } - return data.mergeable === "CONFLICTING" || data.mergeStateStatus === "DIRTY" + const pr: any = await fetchGh(`repos/${repo}/pulls/${prNumber}`); + return pr.mergeable === false || pr.mergeable_state === "dirty"; } catch { - return false + return false; } } diff --git a/src/lib/split-state.ts b/src/lib/split-state.ts new file mode 100644 index 0000000..3e9d23d --- /dev/null +++ b/src/lib/split-state.ts @@ -0,0 +1,86 @@ +export type SplitPhase = "analyzing" | "approved" | "branching" | "publishing" | "ready" | "merging" | "done" + +export type SplitEntryStatus = "pending" | "created" | "ready" | "reviewing" | "approved" | "merged" + +export interface SplitEntry { + number: number + name: string + branch: string + files: string[] + lines: number + dependsOn: number[] + prNumber: number | null + prUrl: string | null + status: SplitEntryStatus +} + +export interface SplitState { + version: number + originalBranch: string + targetBranch: string + strategy: string + createdAt: string + status: SplitPhase + topology: "linear" | "dag" + splits: SplitEntry[] +} + +export async function readSplitState(repoRoot: string): Promise { + const path = `${repoRoot}/.raft-split.json` + try { + const file = Bun.file(path) + const text = await file.text() + return JSON.parse(text) as SplitState + } catch { + return null + } +} + +export function writeSplitState(repoRoot: string, state: SplitState): void { + const path = `${repoRoot}/.raft-split.json` + Bun.write(path, JSON.stringify(state, null, 2) + "\n") +} + +/** Build a tree-line display of the split topology. */ +export function formatSplitTopology(state: SplitState): string[] { + const lines: string[] = [] + const { splits, targetBranch } = state + + // Build adjacency: parent -> children + // A split's parent is the split it depends on. Roots depend on nothing (target main). + const roots: SplitEntry[] = [] + const childrenOf = new Map() + + for (const split of splits) { + if (split.dependsOn.length === 0) { + roots.push(split) + } else { + for (const dep of split.dependsOn) { + const existing = childrenOf.get(dep) ?? [] + existing.push(split) + childrenOf.set(dep, existing) + } + } + } + + lines.push(targetBranch) + + function renderNode(split: SplitEntry, prefix: string, isLast: boolean) { + const connector = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500" + const prTag = split.prNumber ? `#${split.prNumber} ` : "" + const statusTag = `[${split.status}]` + lines.push(`${prefix}${connector} ${split.number}. ${prTag}${split.name} ${statusTag} (${split.lines} lines)`) + + const children = childrenOf.get(split.number) ?? [] + const childPrefix = prefix + (isLast ? " " : "\u2502 ") + for (let i = 0; i < children.length; i++) { + renderNode(children[i], childPrefix, i === children.length - 1) + } + } + + for (let i = 0; i < roots.length; i++) { + renderNode(roots[i], "", i === roots.length - 1) + } + + return lines +}