From 58ec52ff4ab555c092d1cf7b7321a83fc031549a Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:48:53 -0700 Subject: [PATCH 1/6] chore(deps): sync the lockfile with package.json - the lock still said 0.5.0 while package.json says 1.0.1 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 722cb84..c2162bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo-github-integration", - "version": "0.5.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo-github-integration", - "version": "0.5.0", + "version": "1.0.1", "dependencies": { "@getpaseo/plugin": "0.8.0-beta.1" }, From 38422cf8a58d652b7a072085758e5d60ba4eb9ee Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:48:54 -0700 Subject: [PATCH 2/6] fix(board): spend the search limit per relation - a shared budget went to whichever relation held the newest items, so a full review queue pushed the viewer's own work off the board --- server/board/buckets.test.ts | 23 +++++++++++++++++++---- server/board/buckets.ts | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/server/board/buckets.test.ts b/server/board/buckets.test.ts index 61ed2c0..c7c0798 100644 --- a/server/board/buckets.test.ts +++ b/server/board/buckets.test.ts @@ -68,7 +68,7 @@ describe("mergeBucketResults", () => { expect(merged?.relations).toEqual(["author", "owned"]); }); - it("applies the limit to the merged, sorted union rather than per bucket", () => { + it("applies the limit per relation and returns the union newest first", () => { const buckets: BucketResult[] = [ { relation: "author", @@ -86,9 +86,24 @@ describe("mergeBucketResults", () => { }, ]; const result = mergeBucketResults(buckets, toBoardItem, 2); - expect(result).toHaveLength(2); - // The two newest across both buckets, not the first two encountered. - expect(result.map((item) => item.id)).toEqual(["new-1", "new-2"]); + expect(result.map((item) => item.id)).toEqual(["new-1", "new-2", "old-2", "old-1"]); + }); + + it("keeps an older authored item when a newer relation fills the budget", () => { + // The review queue this models is what a shared budget spent entirely: + // every review request is newer than the one pull request the viewer wrote. + const buckets: BucketResult[] = [ + { + relation: "review-requested", + nodes: [ + { id: "review-1", fakeUpdatedAt: "2024-06-03T00:00:00Z" }, + { id: "review-2", fakeUpdatedAt: "2024-06-02T00:00:00Z" }, + ], + }, + { relation: "author", nodes: [{ id: "mine", fakeUpdatedAt: "2024-01-01T00:00:00Z" }] }, + ]; + const result = mergeBucketResults(buckets, toBoardItem, 2); + expect(result.map((item) => item.id)).toContain("mine"); }); it("drops a node that toBoardItem rejects", () => { diff --git a/server/board/buckets.ts b/server/board/buckets.ts index 6fce4d2..b88bc71 100644 --- a/server/board/buckets.ts +++ b/server/board/buckets.ts @@ -82,6 +82,12 @@ export async function runBuckets( * null for a node the caller wants dropped entirely (an archived discussion, * an empty node from the other inline fragment matching nothing), which is * why it runs before the relation is ever recorded. + * + * `limit` is a budget per relation, not one shared by the union. A shared + * budget is spent by whichever relation happens to have the most recently + * updated items: a full review queue is newer than almost anything else, so + * it took the whole list and left the viewer's own pull requests off a board + * that exists to show them. */ export function mergeBucketResults( buckets: readonly BucketResult[], @@ -110,5 +116,13 @@ export function mergeBucketResults( for (const item of byId.values()) { item.relations.sort((a, b) => RELATION_ORDER[a] - RELATION_ORDER[b]); } - return [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); + + const items = [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const kept = new Set(); + for (const relation of new Set(buckets.map((bucket) => bucket.relation))) { + for (const item of items.filter((item) => item.relations.includes(relation)).slice(0, limit)) { + kept.add(item.id); + } + } + return items.filter((item) => kept.has(item.id)); } From 98be84b474cb33d1b252ff67ca7a69197256e837 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:48:54 -0700 Subject: [PATCH 3/6] fix(board): batch the checks query - nodes(ids:) caps at 100 ids; ask in batches of that size so a longer board keeps its checks --- server/board/checks.ts | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/server/board/checks.ts b/server/board/checks.ts index 47e2de0..a9fc2b2 100644 --- a/server/board/checks.ts +++ b/server/board/checks.ts @@ -11,9 +11,8 @@ import { gh } from "../github/gh"; * would turn that into a blank Draft PRs *and* Open PRs column; asking for it * separately costs pills nobody could have seen anyway. * - * `nodes(ids:)` takes at most 100 ids, which the caller cannot exceed: it asks - * only for the open pull requests, and the merged list was already cut to - * `limit`, whose own ceiling is 100. + * `nodes(ids:)` takes at most 100 ids, so `attachChecks` asks in batches of + * that size rather than assuming the open pull requests fit in one. */ const CHECKS_QUERY = `query($ids: [ID!]!) { nodes(ids: $ids) { @@ -57,6 +56,9 @@ const CHECKS_QUERY = `query($ids: [ID!]!) { */ type CheckOutcome = "passed" | "failed" | "pending" | "ignored"; +/** GitHub's own ceiling on `nodes(ids:)`. */ +const CHECKS_BATCH = 100; + /** * Mirrors Paseo's `mapCheckRunStatus` so the board and the sidebar cannot * disagree about the same pull request, with one deliberate difference: @@ -211,16 +213,20 @@ async function fetchChecks(ids: readonly string[]): Promise { const ids = items.map((item) => item.id).filter((id) => id !== ""); if (ids.length === 0) return [...items]; - let summaries: Map; - try { - summaries = await fetchChecks(ids); - } catch (error) { - console.warn( - `[github-board] pull request checks unavailable: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return [...items]; + + const summaries = new Map(); + for (let start = 0; start < ids.length; start += CHECKS_BATCH) { + const batch = ids.slice(start, start + CHECKS_BATCH); + try { + for (const [id, summary] of await fetchChecks(batch)) summaries.set(id, summary); + } catch (error) { + // One batch failing costs its own pills, not every other batch's. + console.warn( + `[github-board] pull request checks unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } return items.map((item) => ({ ...item, checks: summaries.get(item.id) ?? null })); } From cca666c9c85b455e123cc0391bb384d721565511 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:03 -0700 Subject: [PATCH 4/6] feat: open the original chat from a pull request - match the pull request each active or retained workspace tracks to the first agent created there, the originating conversation - an action on the row and in the panel jumps straight to that chat --- client/board/board-body.tsx | 5 + client/board/board-row.styles.ts | 1 + client/board/github-board.tsx | 2 + client/board/item-row.tsx | 56 +++++++--- client/board/use-board-overlays.tsx | 18 +++- client/board/use-chat-links.test.ts | 58 ++++++++++ client/board/use-chat-links.ts | 152 +++++++++++++++++++++++++++ client/board/use-github-board.tsx | 4 + client/detail/detail-actions-row.tsx | 15 +++ client/detail/detail-panel.tsx | 7 ++ 10 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 client/board/use-chat-links.test.ts create mode 100644 client/board/use-chat-links.ts diff --git a/client/board/board-body.tsx b/client/board/board-body.tsx index 5742403..162dbfc 100644 --- a/client/board/board-body.tsx +++ b/client/board/board-body.tsx @@ -9,6 +9,7 @@ import { ProjectsView } from "../projects/projects-view"; import { EMPTY_PROMPTS, PromptSettingsView } from "../settings/prompt-settings-view"; import type { Styles } from "../theme/use-styles"; import { openExternalUrl } from "../web"; +import type { ChatLink } from "./use-chat-links"; /** * Everything below the header and the toolbar: the settings screen when it is @@ -37,6 +38,7 @@ export function BoardBody({ setBodyWidth, detailTarget, detailItem, + detailChatLink, detailProgress, closeDetails, savedFraction, @@ -63,6 +65,7 @@ export function BoardBody({ setBodyWidth: (next: number | null) => void; detailTarget: { item: BoardItem; type: ColumnId } | null; detailItem: BoardItem | null; + detailChatLink: ChatLink | null; detailProgress: Animated.Value; closeDetails: () => void; savedFraction: number | null; @@ -141,6 +144,7 @@ export function BoardBody({ key={detailItem.id} item={detailItem} type={detailTarget.type} + chatLink={detailChatLink} styles={styles} accentColor={surfaceProps.theme.colors.accent} foregroundColor={surfaceProps.theme.colors.foreground} @@ -149,6 +153,7 @@ export function BoardBody({ onWidthCommitted={commitWidth} progress={detailProgress} onClose={closeDetails} + onOpenChat={(chat) => surfaceProps.navigation?.openAgent({ agentId: chat.agentId })} onSend={openSendDialog} onMerged={dropItem} /> diff --git a/client/board/board-row.styles.ts b/client/board/board-row.styles.ts index 74dfcc7..9fcbf37 100644 --- a/client/board/board-row.styles.ts +++ b/client/board/board-row.styles.ts @@ -68,6 +68,7 @@ export function buildBoardRowStyles( gap: 6, marginTop: 6, }, + itemRowActions: { flexDirection: "row" as const, alignItems: "center" as const, gap: 4 }, /** Hidden until the row or the action itself is hovered; see `sendButtonHidden`. */ itemRowActionHidden: { opacity: 0 }, label: { diff --git a/client/board/github-board.tsx b/client/board/github-board.tsx index 3ca2152..f86ee11 100644 --- a/client/board/github-board.tsx +++ b/client/board/github-board.tsx @@ -51,6 +51,7 @@ export function GitHubBoard(props: PluginSurfaceProps) { setBodyWidth, detailTarget, detailItem, + detailChatLink, detailProgress, closeDetails, savedFraction, @@ -154,6 +155,7 @@ export function GitHubBoard(props: PluginSurfaceProps) { setBodyWidth={setBodyWidth} detailTarget={detailTarget} detailItem={detailItem} + detailChatLink={detailChatLink} detailProgress={detailProgress} closeDetails={closeDetails} savedFraction={savedFraction} diff --git a/client/board/item-row.tsx b/client/board/item-row.tsx index b365fa2..cecf6ec 100644 --- a/client/board/item-row.tsx +++ b/client/board/item-row.tsx @@ -8,6 +8,7 @@ import { linkedIssueLabel } from "../lib/formatting"; import { relativeTime } from "../lib/time"; import type { SortOrder } from "../lib/sort"; import type { Styles } from "../theme/use-styles"; +import type { ChatLink } from "./use-chat-links"; import { describeRow } from "./item-row-format"; import { ItemRowTrailing } from "./item-row-trailing"; @@ -59,7 +60,9 @@ export const ItemRow = memo(function ItemRow({ selected, accentColor, mutedColor, + chatLink, onOpen, + onOpenChat, onSend, onLabels, type, @@ -83,8 +86,11 @@ export const ItemRow = memo(function ItemRow({ accentColor: string; /** The state glyph's colour for a draft or a discussion, and the send icon's. */ mutedColor: string; + /** The first Paseo chat associated with this pull request's workspace. */ + chatLink: ChatLink | null; /** A press: opens the row in the detail panel, never the browser. */ onOpen: (item: BoardItem, type: ColumnId) => void; + onOpenChat: (chat: ChatLink) => void; onSend: (item: BoardItem, type: ColumnId) => void; /** Null where labels cannot be edited, which takes the gesture away entirely. */ onLabels: ((item: BoardItem, point: { x: number; y: number }) => void) | null; @@ -120,6 +126,10 @@ export const ItemRow = memo(function ItemRow({ onSend(item, type); }, [item, onSend, type]); + const openChat = useCallback(() => { + if (chatLink !== null) onOpenChat(chatLink); + }, [chatLink, onOpenChat]); + const openLabels = useCallback( (event: unknown) => { const point = pointerPoint(event); @@ -200,20 +210,38 @@ export const ItemRow = memo(function ItemRow({ {compact ? {trailing} : null} {compact ? null : {trailing}} - setActionHovered(true)} - onHoverOut={() => setActionHovered(false)} - style={({ pressed }) => [ - styles.iconButton, - revealed ? null : styles.itemRowActionHidden, - pressed ? styles.cardPressed : null, - ]} - > - - + + {chatLink === null ? null : ( + setActionHovered(true)} + onHoverOut={() => setActionHovered(false)} + style={({ pressed }) => [ + styles.iconButton, + revealed ? null : styles.itemRowActionHidden, + pressed ? styles.cardPressed : null, + ]} + > + + + )} + setActionHovered(true)} + onHoverOut={() => setActionHovered(false)} + style={({ pressed }) => [ + styles.iconButton, + revealed ? null : styles.itemRowActionHidden, + pressed ? styles.cardPressed : null, + ]} + > + + + ); }); diff --git a/client/board/use-board-overlays.tsx b/client/board/use-board-overlays.tsx index b7e16dc..dbebce1 100644 --- a/client/board/use-board-overlays.tsx +++ b/client/board/use-board-overlays.tsx @@ -10,6 +10,7 @@ import type { BoardRow, SortOrder } from "../lib/sort"; import { DETAIL_CLOSE_MS, DETAIL_OPEN_MS } from "../detail/constants"; import { ItemRow } from "./item-row"; import type { Styles } from "../theme/use-styles"; +import type { ChatLink } from "./use-chat-links"; import { LABEL_MENU_MAX_HEIGHT, LABEL_MENU_WIDTH, MENU_MARGIN } from "./label-menu"; import type { LabelMenuTarget } from "./label-menu"; @@ -23,6 +24,7 @@ export interface UseBoardOverlaysInputs { mutateBoardCache: (updater: (current: Board) => Board) => void; /** The active sort's row label and date accessor, for `ItemRow`'s meta line. */ activeOrder: SortOrder; + chatLinkForItem: (item: BoardItem) => ChatLink | null; } /** What `useBoardOverlays` exposes to the surface. */ @@ -31,6 +33,7 @@ export interface UseBoardOverlaysResult { setBodyWidth: (next: number | null) => void; detailTarget: { item: BoardItem; type: ColumnId } | null; detailItem: BoardItem | null; + detailChatLink: ChatLink | null; detailProgress: Animated.Value; closeDetails: () => void; openSendDialog: (item: BoardItem, type: ColumnId) => void; @@ -58,7 +61,7 @@ export interface UseBoardOverlaysResult { export function useBoardOverlays( props: PluginSurfaceProps, styles: Styles, - { board, promptValues, mutateBoardCache, activeOrder }: UseBoardOverlaysInputs, + { board, promptValues, mutateBoardCache, activeOrder, chatLinkForItem }: UseBoardOverlaysInputs, ): UseBoardOverlaysResult { const toast = useToast(); @@ -200,6 +203,14 @@ export function useBoardOverlays( }, [board, detailTarget]); const selectedId = detailItem?.id ?? null; + const detailChatLink = detailItem === null ? null : chatLinkForItem(detailItem); + + const openChat = useCallback( + (chat: ChatLink) => { + props.navigation?.openAgent({ agentId: chat.agentId }); + }, + [props.navigation], + ); /** * Opens the launch dialog on this card, with the card's template already @@ -256,7 +267,9 @@ export function useBoardOverlays( selected={row.item.id === selectedId} accentColor={props.theme.colors.accent} mutedColor={props.theme.colors.foregroundMuted} + chatLink={chatLinkForItem(row.item)} onOpen={openDetails} + onOpenChat={openChat} onSend={openSendDialog} onLabels={row.type === "discussions" ? null : openLabelMenu} type={row.type} @@ -271,7 +284,9 @@ export function useBoardOverlays( selectedId, props.theme.colors.accent, props.theme.colors.foregroundMuted, + chatLinkForItem, openDetails, + openChat, openSendDialog, openLabelMenu, activeOrder, @@ -283,6 +298,7 @@ export function useBoardOverlays( setBodyWidth, detailTarget, detailItem, + detailChatLink, detailProgress, closeDetails, openSendDialog, diff --git a/client/board/use-chat-links.test.ts b/client/board/use-chat-links.test.ts new file mode 100644 index 0000000..28456ba --- /dev/null +++ b/client/board/use-chat-links.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildChatLinks, chatLinkFor } from "./use-chat-links"; + +describe("buildChatLinks", () => { + it("links a pull request to the first chat in its workspace", () => { + const links = buildChatLinks( + [ + { + id: "workspace-1", + name: "Fix dashboard", + githubRuntime: { + pullRequest: { url: "https://ghe.example.com/an-org/example/pull/42" }, + }, + }, + ], + [ + { + agent: { + id: "follow-up", + title: "Review", + workspaceId: "workspace-1", + createdAt: "2026-09-16T12:00:00Z", + }, + }, + { + agent: { + id: "original", + title: "Implement", + workspaceId: "workspace-1", + createdAt: "2026-09-15T12:00:00Z", + }, + }, + ], + ); + + expect(chatLinkFor(links, "https://ghe.example.com/an-org/example/pull/42/")).toEqual({ + agentId: "original", + agentTitle: "Implement", + workspaceId: "workspace-1", + workspaceName: "Fix dashboard", + }); + }); + + it("does not link a workspace with no chat", () => { + const links = buildChatLinks( + [ + { + id: "workspace-1", + name: "No agent", + githubRuntime: { pullRequest: { url: "https://github.com/an-owner/example/pull/1" } }, + }, + ], + [], + ); + + expect(links.size).toBe(0); + }); +}); diff --git a/client/board/use-chat-links.ts b/client/board/use-chat-links.ts new file mode 100644 index 0000000..da767bf --- /dev/null +++ b/client/board/use-chat-links.ts @@ -0,0 +1,152 @@ +import { usePaseo } from "@getpaseo/plugin/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; + +const PAGE_LIMIT = 200; +const MAX_PAGES = 10; +const BACKSTOP_REFRESH_MS = 60_000; +const EVENT_DEBOUNCE_MS = 500; + +type PaseoApi = ReturnType; +type Workspace = Awaited>["entries"][number]; +type AgentEntry = Awaited>["entries"][number]; + +export interface ChatLink { + agentId: string; + agentTitle: string; + workspaceId: string; + workspaceName: string; +} + +export interface WorkspaceChatSource { + id: string; + name: string; + githubRuntime?: { pullRequest?: { url: string } | null | undefined } | null | undefined; +} + +export interface AgentChatSource { + agent: { + id: string; + title?: string | null | undefined; + workspaceId?: string | undefined; + createdAt: string; + }; +} + +function normalizePullRequestUrl(url: string): string { + return url.trim().replace(/\/+$/, "").toLowerCase(); +} + +async function loadWorkspaces(paseo: PaseoApi): Promise { + const entries: Workspace[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await paseo.workspaces.list({ + page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, + }); + entries.push(...result.entries); + cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined; + if (!cursor) break; + } + return entries; +} + +async function loadAgents(paseo: PaseoApi): Promise { + const entries: AgentEntry[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await paseo.agents.list({ + filter: { includeArchived: true }, + sort: [{ key: "created_at", direction: "asc" }], + page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, + }); + entries.push(...result.entries); + cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined; + if (!cursor) break; + } + return entries; +} + +/** + * Match each PR tracked by Paseo's workspace runtime to the first chat created + * in that workspace. The first agent is the originating conversation; later + * agents may be reviewers or follow-up repair work. + */ +export function buildChatLinks( + workspaces: readonly WorkspaceChatSource[], + agents: readonly AgentChatSource[], +): Map { + const firstAgentByWorkspace = new Map(); + for (const { agent } of agents) { + if (!agent.workspaceId) continue; + const current = firstAgentByWorkspace.get(agent.workspaceId); + if (current === undefined || agent.createdAt.localeCompare(current.createdAt) < 0) { + firstAgentByWorkspace.set(agent.workspaceId, agent); + } + } + + const links = new Map(); + for (const workspace of workspaces) { + const url = workspace.githubRuntime?.pullRequest?.url; + if (!url) continue; + const agent = firstAgentByWorkspace.get(workspace.id); + if (agent === undefined) continue; + + const key = normalizePullRequestUrl(url); + const existing = links.get(key); + const candidate: ChatLink = { + agentId: agent.id, + agentTitle: agent.title?.trim() || agent.id.slice(0, 7), + workspaceId: workspace.id, + workspaceName: workspace.name, + }; + if (existing === undefined) { + links.set(key, candidate); + continue; + } + + const existingAgent = agents.find(({ agent: entry }) => entry.id === existing.agentId)?.agent; + if (existingAgent !== undefined && agent.createdAt.localeCompare(existingAgent.createdAt) < 0) { + links.set(key, candidate); + } + } + return links; +} + +export function useChatLinks(hostId: string): Map { + const paseo = usePaseo(); + const queryClient = useQueryClient(); + const queryKey = useMemo(() => ["github-board-chat-links", hostId], [hostId]); + const { data } = useQuery({ + queryKey, + queryFn: async () => { + const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]); + return buildChatLinks(workspaces, agents); + }, + refetchInterval: BACKSTOP_REFRESH_MS, + }); + + useEffect(() => { + let timer: ReturnType | undefined; + const invalidate = () => { + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + void queryClient.invalidateQueries({ queryKey }); + }, EVENT_DEBOUNCE_MS); + }; + const unsubscribeAgents = paseo.agents.subscribe(invalidate); + const unsubscribeWorkspaces = paseo.workspaces.subscribe(invalidate); + return () => { + if (timer !== undefined) clearTimeout(timer); + unsubscribeAgents(); + unsubscribeWorkspaces(); + }; + }, [paseo, queryClient, queryKey]); + + return data ?? new Map(); +} + +export function chatLinkFor(links: ReadonlyMap, url: string): ChatLink | null { + return links.get(normalizePullRequestUrl(url)) ?? null; +} diff --git a/client/board/use-github-board.tsx b/client/board/use-github-board.tsx index 5d0b20e..68d3ca7 100644 --- a/client/board/use-github-board.tsx +++ b/client/board/use-github-board.tsx @@ -5,6 +5,7 @@ import { useBoardFilters } from "./use-board-filters"; import { useBoardOverlays } from "./use-board-overlays"; import { useBoardQuery } from "./use-board-query"; import { useBoardSettings } from "./use-board-settings"; +import { chatLinkFor, useChatLinks } from "./use-chat-links"; /** * Everything the board surface needs beyond its own JSX: the fetch and its @@ -22,11 +23,13 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) { const settings = useBoardSettings(); const query = useBoardQuery(props, settings.watchedOwners); const filters = useBoardFilters(query.board, settings.display); + const chatLinks = useChatLinks(props.host.id); const overlays = useBoardOverlays(props, styles, { board: query.board, promptValues: settings.promptValues, mutateBoardCache: query.mutateBoardCache, activeOrder: filters.activeOrder, + chatLinkForItem: (item) => chatLinkFor(chatLinks, item.url), }); return { @@ -69,6 +72,7 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) { setBodyWidth: overlays.setBodyWidth, detailTarget: overlays.detailTarget, detailItem: overlays.detailItem, + detailChatLink: overlays.detailChatLink, detailProgress: overlays.detailProgress, closeDetails: overlays.closeDetails, savedFraction: settings.savedFraction, diff --git a/client/detail/detail-actions-row.tsx b/client/detail/detail-actions-row.tsx index c60de61..e1de091 100644 --- a/client/detail/detail-actions-row.tsx +++ b/client/detail/detail-actions-row.tsx @@ -1,6 +1,7 @@ import { Pressable, Text, View } from "react-native"; import type { BoardItem, ColumnId, ItemDetails } from "../../shared/board"; +import type { ChatLink } from "../board/use-chat-links"; import type { Styles } from "../theme/use-styles"; import { openExternalUrl } from "../web"; import { ReviewActions } from "./review-actions"; @@ -15,20 +16,24 @@ import { ReviewActions } from "./review-actions"; export function DetailActionsRow({ item, type, + chatLink, details, styles, acting, onApprove, onMergeOpen, + onOpenChat, onSend, }: { item: BoardItem; type: ColumnId; + chatLink: ChatLink | null; details: ItemDetails | null; styles: Styles; acting: boolean; onApprove: () => void; onMergeOpen: () => void; + onOpenChat: (chat: ChatLink) => void; onSend: (item: BoardItem, type: ColumnId) => void; }) { return ( @@ -43,6 +48,16 @@ export function DetailActionsRow({ onMerge={onMergeOpen} /> ) : null} + {chatLink === null ? null : ( + [styles.button, pressed ? styles.sendButtonPressed : null]} + onPress={() => onOpenChat(chatLink)} + > + Open original chat + + )} void; onClose: () => void; + onOpenChat: (chat: ChatLink) => void; onSend: (item: BoardItem, type: ColumnId) => void; onMerged: (itemId: string) => void; }) { @@ -129,11 +134,13 @@ export function ItemDetailPanel({ actions.setMergeOpen(true)} + onOpenChat={onOpenChat} onSend={onSend} /> From 2f528d219bfeda9a5fd4d2fa35ced6622136cd62 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:48:55 -0700 Subject: [PATCH 5/6] feat(github): send gh calls to a configured host - resolve the hostname from GH_HOST or the plugin's settings file and set it on every gh subprocess --- server/github/gh.ts | 3 +++ server/github/graphql.ts | 2 ++ server/github/host.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 server/github/host.ts diff --git a/server/github/gh.ts b/server/github/gh.ts index 4edece0..6f11a8c 100644 --- a/server/github/gh.ts +++ b/server/github/gh.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { ghProcessEnv, githubHostname } from "./host"; import { assertBudget, prepareGraphqlArgs, recordRateLimit } from "./rate-limit"; const execFileAsync = promisify(execFile); @@ -49,8 +50,10 @@ export async function gh(args: readonly string[]): Promise { const graphql = args[0] === "api" && args[1] === "graphql"; if (graphql) assertBudget(); try { + const hostname = await githubHostname(); const { stdout } = await execFileAsync("gh", graphql ? prepareGraphqlArgs(args) : [...args], { maxBuffer: MAX_OUTPUT_BYTES, + env: ghProcessEnv(hostname), }); if (graphql) recordRateLimit(peekRateLimitData(stdout)); return stdout; diff --git a/server/github/graphql.ts b/server/github/graphql.ts index 282a1b4..a352b2e 100644 --- a/server/github/graphql.ts +++ b/server/github/graphql.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { gh, describeGhFailure, MAX_OUTPUT_BYTES } from "./gh"; +import { ghProcessEnv, githubHostname } from "./host"; import { assertBudget, prepareGraphqlArgs, recordRateLimit } from "./rate-limit"; const execFileAsync = promisify(execFile); @@ -55,6 +56,7 @@ export async function ghGraphqlRaw(args: readonly string[]): Promise { + const fromEnv = process.env.GH_HOST?.trim(); + if (fromEnv !== undefined && fromEnv !== "") return fromEnv; + + for (const path of hostnameSettingsPaths()) { + try { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (typeof parsed !== "object" || parsed === null) continue; + const hostname = (parsed as { hostname?: unknown }).hostname; + if (typeof hostname === "string" && hostname.trim() !== "") return hostname.trim(); + } catch { + // Missing or unreadable; try the next path. + } + } + return null; +} + +export function ghProcessEnv(hostname: string | null): NodeJS.ProcessEnv { + return hostname === null ? process.env : { ...process.env, GH_HOST: hostname }; +} From ad8edec824c0f9fe96093cf9524a1d9299cdb315 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:04 -0700 Subject: [PATCH 6/6] feat: aggregate all authenticated GitHub hosts - gh auth status names every authenticated host and its active account - item ids carry their host, so reads and actions reach the host that owns the item - the sweep unions every host's columns and keeps each host's failure in the column error --- client/board/board-body.tsx | 1 - client/board/item-row-format.ts | 7 +- client/board/item-row.tsx | 10 ++- client/board/label-menu.tsx | 5 +- client/board/use-board-overlays.tsx | 4 +- client/projects/project-board-view.tsx | 6 +- client/projects/projects-list-view.tsx | 16 ++-- client/projects/projects-view.tsx | 8 +- server/board/checks.ts | 8 +- server/board/handler.ts | 108 ++++++++++++++++++------- server/board/item.ts | 7 +- server/board/pull-requests.ts | 9 ++- server/github/host.test.ts | 22 +++++ server/github/host.ts | 57 +++++++++++++ server/github/item-id.test.ts | 16 ++++ server/github/item-id.ts | 28 +++++++ server/items/comments.ts | 58 ++++++------- server/items/details.ts | 17 ++-- server/items/labels.ts | 30 ++++--- server/items/review.ts | 7 +- server/projects/list.ts | 83 ++++++++++++------- server/projects/scope.ts | 6 +- server/projects/single.ts | 67 ++++++++------- shared/board.ts | 6 +- 24 files changed, 414 insertions(+), 172 deletions(-) create mode 100644 server/github/host.test.ts create mode 100644 server/github/item-id.test.ts create mode 100644 server/github/item-id.ts diff --git a/client/board/board-body.tsx b/client/board/board-body.tsx index 162dbfc..68b0731 100644 --- a/client/board/board-body.tsx +++ b/client/board/board-body.tsx @@ -97,7 +97,6 @@ export function BoardBody({ diff --git a/client/board/item-row-format.ts b/client/board/item-row-format.ts index fbaa594..9d40198 100644 --- a/client/board/item-row-format.ts +++ b/client/board/item-row-format.ts @@ -38,7 +38,7 @@ export interface RowDisplay { */ export function describeRow({ item, - viewerLogin, + viewerLogins, order, type, accentColor, @@ -48,7 +48,7 @@ export function describeRow({ isWeb, }: { item: BoardItem; - viewerLogin: string; + viewerLogins: readonly string[]; order: SortOrder; type: ColumnId; accentColor: string; @@ -73,7 +73,8 @@ export function describeRow({ : "CircleDot"; const iconColor = type === "draft-prs" || type === "discussions" ? mutedColor : accentColor; - const byline = item.author !== null && item.author !== viewerLogin ? item.author : null; + const byline = + item.author !== null && !viewerLogins.includes(item.author) ? item.author : null; const openedBy = byline === null ? "" : `, opened by ${byline}`; const linkedTo = closes === "" ? "" : `, closes ${closes}`; diff --git a/client/board/item-row.tsx b/client/board/item-row.tsx index cecf6ec..85b27aa 100644 --- a/client/board/item-row.tsx +++ b/client/board/item-row.tsx @@ -53,7 +53,7 @@ function pointerPoint(event: unknown): { x: number; y: number } | null { */ export const ItemRow = memo(function ItemRow({ item, - viewerLogin, + viewerLogins, styles, platform, compact, @@ -70,7 +70,7 @@ export const ItemRow = memo(function ItemRow({ }: { item: BoardItem; /** The login the board was queried for, so a row of someone else's reads as one. */ - viewerLogin: string; + viewerLogins: readonly string[]; styles: Styles; /** * Decides two things the row cannot ask about itself: whether hovering @@ -158,10 +158,11 @@ export const ItemRow = memo(function ItemRow({ const closes = item.linkedIssues .map((issue) => linkedIssueLabel(issue, item.repository)) .join(", "); + const hostname = new URL(item.url).hostname; const display = describeRow({ item, - viewerLogin, + viewerLogins, order, type, accentColor, @@ -204,7 +205,8 @@ export const ItemRow = memo(function ItemRow({ {type === "draft-prs" ? Draft : null} - {item.repository} #{item.number} · {display.stampLabel} {relativeTime(display.stampDate)} + {item.repository} #{item.number} · {hostname} · {display.stampLabel}{" "} + {relativeTime(display.stampDate)} {display.byline !== null ? ` by ${display.byline}` : ""} {compact ? {trailing} : null} diff --git a/client/board/label-menu.tsx b/client/board/label-menu.tsx index 2841ac9..4f77ade 100644 --- a/client/board/label-menu.tsx +++ b/client/board/label-menu.tsx @@ -56,6 +56,7 @@ export function LabelMenu({ onChanged: (itemId: string, labels: string[]) => void; }) { const { item } = target; + const host = new URL(item.url).hostname; const list = useRpc(listLabels); const apply = useRpc(toggleLabel); @@ -68,8 +69,8 @@ export function LabelMenu({ * did: the surface unmounts on every workspace switch, the cache does not. */ const labelsQuery = useQuery({ - queryKey: ["repository-labels", item.repository], - queryFn: () => list({ repository: item.repository }).then((result) => result.labels), + queryKey: ["repository-labels", host, item.repository], + queryFn: () => list({ repository: item.repository, host }).then((result) => result.labels), staleTime: STALE_AFTER_MS, }); const labels = labelsQuery.data ?? null; diff --git a/client/board/use-board-overlays.tsx b/client/board/use-board-overlays.tsx index dbebce1..cf1c510 100644 --- a/client/board/use-board-overlays.tsx +++ b/client/board/use-board-overlays.tsx @@ -260,7 +260,7 @@ export function useBoardOverlays( ({ item: row }: { item: BoardRow }) => ( ), [ - board?.login, + board?.viewerLogins, styles, props.layout.platform, props.layout.compact, diff --git a/client/projects/project-board-view.tsx b/client/projects/project-board-view.tsx index a31a7d7..f442da3 100644 --- a/client/projects/project-board-view.tsx +++ b/client/projects/project-board-view.tsx @@ -45,6 +45,7 @@ function itemGlyph(item: ProjectItem, colors: ThemeColors): { name: string; colo export function ProjectBoardView({ theme, styles, + host, owner, number, onOpenUrl, @@ -52,6 +53,7 @@ export function ProjectBoardView({ }: { theme: PluginSurfaceProps["theme"]; styles: ProjectsStyles; + host: string; owner: string; number: number; onOpenUrl: (url: string) => void; @@ -68,7 +70,7 @@ export function ProjectBoardView({ setDetail(null); setDetailError(null); setDetailLoading(true); - fetchProject({ owner, number, force: false }) + fetchProject({ host, owner, number, force: false }) .then((result) => { if (live) setDetail({ title: result.title, url: result.url, columns: result.columns }); }) @@ -81,7 +83,7 @@ export function ProjectBoardView({ return () => { live = false; }; - }, [fetchProject, owner, number]); + }, [fetchProject, host, owner, number]); /** * An empty column is not the interesting case, but a project with nothing diff --git a/client/projects/projects-list-view.tsx b/client/projects/projects-list-view.tsx index cfc0e68..8267e84 100644 --- a/client/projects/projects-list-view.tsx +++ b/client/projects/projects-list-view.tsx @@ -26,15 +26,13 @@ const SCOPE_COMMAND = "gh auth refresh -h github.com -s read:project"; export function ProjectsListView({ theme, styles, - login, owners, onOpenProject, }: { theme: PluginSurfaceProps["theme"]; styles: ProjectsStyles; - login: string; owners: readonly string[]; - onOpenProject: (owner: string, number: number) => void; + onOpenProject: (host: string, owner: string, number: number) => void; }) { const fetchProjects = useRpc(listProjects); @@ -56,9 +54,7 @@ export function ProjectsListView({ setListLoading(true); setListError(null); try { - const input = - login === "" ? { owners: [...owners], force } : { login, owners: [...owners], force }; - const result = await fetchProjects(input); + const result = await fetchProjects({ owners: [...owners], force }); setProjects(result.projects); setListError(result.error); setNeedsScope(result.needsScope); @@ -70,7 +66,7 @@ export function ProjectsListView({ setListLoading(false); } }, - [fetchProjects, login, ownersKey], + [fetchProjects, ownersKey], ); useEffect(() => { @@ -146,7 +142,7 @@ export function ProjectsListView({ key={project.id} style={styles.projectRow} accessibilityRole="button" - onPress={() => onOpenProject(project.owner, project.number)} + onPress={() => onOpenProject(project.host, project.owner, project.number)} > @@ -159,7 +155,9 @@ export function ProjectsListView({ ) : null} - {project.owner} + + {project.owner} · {project.host} + · {project.itemCount} {project.itemCount === 1 ? "item" : "items"} diff --git a/client/projects/projects-view.tsx b/client/projects/projects-view.tsx index 331ab57..976c73e 100644 --- a/client/projects/projects-view.tsx +++ b/client/projects/projects-view.tsx @@ -15,6 +15,7 @@ import { buildProjectsStyles } from "./projects.styles"; /** Which project's own board is open, or `null` for the list. */ interface ProjectTarget { + host: string; owner: string; number: number; } @@ -30,11 +31,10 @@ interface ProjectTarget { export function ProjectsView(props: { theme: PluginSurfaceProps["theme"]; layout: PluginSurfaceProps["layout"]; - login: string; owners: readonly string[]; onOpenUrl: (url: string) => void; }): JSX.Element { - const { theme, layout, login, owners, onOpenUrl } = props; + const { theme, layout, owners, onOpenUrl } = props; const styles = useMemo(() => buildProjectsStyles(theme, layout), [theme, layout]); const [target, setTarget] = useState(null); @@ -43,6 +43,7 @@ export function ProjectsView(props: { setTarget({ owner, number })} + onOpenProject={(host, owner, number) => setTarget({ host, owner, number })} /> ); } diff --git a/server/board/checks.ts b/server/board/checks.ts index a9fc2b2..1c3138b 100644 --- a/server/board/checks.ts +++ b/server/board/checks.ts @@ -1,5 +1,6 @@ import type { BoardItem, CheckSummary } from "../../shared/board"; import { gh } from "../github/gh"; +import { decodeItemId } from "../github/item-id"; /** * The checks on each pull request's head commit, by node id. @@ -218,7 +219,12 @@ export async function attachChecks(items: readonly BoardItem[]): Promise decodeItemId(id).nodeId); + const fetched = await fetchChecks(nodeIds); + batch.forEach((id, index) => { + const summary = fetched.get(nodeIds[index] ?? ""); + if (summary !== undefined) summaries.set(id, summary); + }); } catch (error) { // One batch failing costs its own pills, not every other batch's. console.warn( diff --git a/server/board/handler.ts b/server/board/handler.ts index c1e2f2a..19a23ff 100644 --- a/server/board/handler.ts +++ b/server/board/handler.ts @@ -1,8 +1,11 @@ import type { z } from "zod"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import type { BoardColumn, BoardItem, loadBoard } from "../../shared/board"; -import { resolveViewerLogin } from "../github/gh"; -import { readSettings } from "../settings/settings"; +import { + type GithubAccount, + listGithubAccounts, + withGithubHostname, +} from "../github/host"; import { loadProjectIndex, repositoryIdFor } from "../launch/project-index"; import type { PaseoApi } from "../launch/project-index"; import { fetchDiscussions } from "./discussions"; @@ -51,45 +54,87 @@ async function settle( } } +async function loadAccountColumns( + account: GithubAccount, + owners: readonly string[], + limit: number, +): Promise { + return withGithubHostname(account.hostname, async () => { + const pullRequests = fetchPullRequests(account.login, owners, limit).then( + (split) => ({ split, error: null as string | null }), + (error: unknown) => ({ + split: { draft: [] as BoardItem[], open: [] as BoardItem[] }, + error: error instanceof Error ? error.message : String(error), + }), + ); + const [issues, prs, discussions] = await Promise.all([ + settle("issues", "Issues", () => fetchIssues(account.login, owners, limit)), + pullRequests, + settle("discussions", "Discussions", () => + fetchDiscussions(account.login, owners, limit), + ), + ]); + return [ + issues, + { id: "draft-prs", title: "Draft PRs", items: prs.split.draft, error: prs.error }, + { id: "open-prs", title: "Open PRs", items: prs.split.open, error: prs.error }, + discussions, + ]; + }); +} + +function mergeColumns( + accounts: readonly GithubAccount[], + results: readonly BoardColumn[][], +): BoardColumn[] { + const ids: readonly BoardColumn["id"][] = ["issues", "draft-prs", "open-prs", "discussions"]; + const titles: Record = { + issues: "Issues", + "draft-prs": "Draft PRs", + "open-prs": "Open PRs", + discussions: "Discussions", + }; + return ids.map((id) => { + const byId = new Map(); + const errors: string[] = []; + results.forEach((columns, index) => { + const column = columns.find((candidate) => candidate.id === id); + if (column === undefined) return; + if (column.error !== null) errors.push(`${accounts[index]?.hostname ?? "GitHub"}: ${column.error}`); + for (const item of column.items) byId.set(item.id, item); + }); + return { + id, + title: titles[id], + items: [...byId.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + error: errors.length === 0 ? null : errors.join(" "), + }; + }); +} + export async function loadBoardHandler( { login, owners, limit, force }: z.output, { paseo }: PluginHandlerContext, ): Promise> { const requested = login?.trim(); - const settings = await readSettings(); - const resolved = - requested !== undefined && requested !== "" && requested !== "@me" - ? requested - : (settings.login ?? (await resolveViewerLogin())); - - const key = `${resolved}\u0000${limit}\u0000${[...owners].sort().join(",")}`; + const available = await listGithubAccounts(); + const accounts = + requested === undefined || requested === "" || requested === "@me" + ? available + : available.filter((account) => account.login.toLowerCase() === requested.toLowerCase()); + if (accounts.length === 0) { + throw new Error(`gh has no authenticated account with login "${requested}".`); + } + const key = `${accounts.map(({ hostname, login: accountLogin }) => `${hostname}:${accountLogin}`).join(",")}\u0000${limit}\u0000${[...owners].sort().join(",")}`; const { columns, fetchedAt } = await boardCache.get( key, BOARD_TTL_MS, async () => { - // Both pull request columns share one request, so they settle together. - const pullRequests = fetchPullRequests(resolved, owners, limit).then( - (split) => ({ split, error: null as string | null }), - (error: unknown) => ({ - split: { draft: [] as BoardItem[], open: [] as BoardItem[] }, - error: error instanceof Error ? error.message : String(error), - }), + const columns = mergeColumns( + accounts, + await Promise.all(accounts.map((account) => loadAccountColumns(account, owners, limit))), ); - - const [issues, prs, discussions] = await Promise.all([ - settle("issues", "Issues", () => fetchIssues(resolved, owners, limit)), - pullRequests, - settle("discussions", "Discussions", () => fetchDiscussions(resolved, owners, limit)), - ]); - - const columns: BoardColumn[] = [ - issues, - { id: "draft-prs", title: "Draft PRs", items: prs.split.draft, error: prs.error }, - { id: "open-prs", title: "Open PRs", items: prs.split.open, error: prs.error }, - discussions, - ]; - return { columns, fetchedAt: new Date().toISOString() }; }, { @@ -101,7 +146,8 @@ export async function loadBoardHandler( ); return { - login: resolved, + login: accounts[0]?.login ?? "", + viewerLogins: accounts.map((account) => account.login), ...(await describeRepositoryProjects(paseo, columns)), columns, fetchedAt, diff --git a/server/board/item.ts b/server/board/item.ts index 2b37b3a..74b395c 100644 --- a/server/board/item.ts +++ b/server/board/item.ts @@ -1,4 +1,5 @@ import type { BoardItem } from "../../shared/board"; +import { encodeItemId, hostnameFromUrl } from "../github/item-id"; import type { GhSearchNode } from "./types"; /** GitHub returns a label as `{ name }`; every list of them needs the same narrowing. */ @@ -23,11 +24,13 @@ export function toItem( const repository = typeof node.repository?.nameWithOwner === "string" ? node.repository.nameWithOwner : ""; const ownerSeparator = repository.indexOf("/"); + const url = typeof node.url === "string" ? node.url : ""; + const nodeId = typeof node.id === "string" ? node.id : url; return { - id: typeof node.id === "string" ? node.id : String(node.url), + id: encodeItemId(hostnameFromUrl(url), nodeId), number: typeof node.number === "number" ? node.number : 0, title: typeof node.title === "string" ? node.title : "", - url: typeof node.url === "string" ? node.url : "", + url, repository, updatedAt: typeof node.updatedAt === "string" ? node.updatedAt : "", createdAt: typeof node.createdAt === "string" ? node.createdAt : "", diff --git a/server/board/pull-requests.ts b/server/board/pull-requests.ts index ed838e2..5c6fdab 100644 --- a/server/board/pull-requests.ts +++ b/server/board/pull-requests.ts @@ -1,4 +1,5 @@ import type { BoardItem, LinkedIssue } from "../../shared/board"; +import { encodeItemId, hostnameFromUrl } from "../github/item-id"; import { attachChecks } from "./checks"; import { ownedBuckets, personalBuckets, runBuckets, mergeBucketResults } from "./buckets"; import { toItem } from "./item"; @@ -40,10 +41,11 @@ interface GhPullRequestNode extends GhSearchNode { function toLinkedIssues(node: GhPullRequestNode): LinkedIssue[] { const nodes = node.closingIssuesReferences?.nodes; if (!Array.isArray(nodes)) return []; + const hostname = hostnameFromUrl(typeof node.url === "string" ? node.url : ""); return nodes .filter((issue): issue is Record => typeof issue === "object" && issue !== null) .map((issue) => ({ - id: typeof issue.id === "string" ? issue.id : "", + id: typeof issue.id === "string" ? encodeItemId(hostname, issue.id) : "", number: typeof issue.number === "number" ? issue.number : 0, repository: typeof (issue.repository as { nameWithOwner?: unknown } | undefined)?.nameWithOwner === @@ -85,7 +87,10 @@ export async function fetchPullRequests( const merged = mergeBucketResults( results, (row) => { - const id = typeof row.id === "string" ? row.id : String(row.url); + const id = encodeItemId( + hostnameFromUrl(typeof row.url === "string" ? row.url : ""), + typeof row.id === "string" ? row.id : String(row.url), + ); if (row.isDraft === true) drafts.add(id); return { ...toItem(row, null, toLastCommitAt(row)), linkedIssues: toLinkedIssues(row) }; }, diff --git a/server/github/host.test.ts b/server/github/host.test.ts new file mode 100644 index 0000000..c38598c --- /dev/null +++ b/server/github/host.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parseGithubAccounts } from "./host"; + +describe("parseGithubAccounts", () => { + it("returns the active successful account for every authenticated host", () => { + expect( + parseGithubAccounts({ + hosts: { + "ghe.example.com": [ + { state: "success", active: true, login: "octocat-enterprise" }, + { state: "success", active: false, login: "other" }, + ], + "github.com": [{ state: "success", active: true, login: "octocat" }], + "broken.example": [{ state: "failed", active: true, login: "broken" }], + }, + }), + ).toEqual([ + { hostname: "ghe.example.com", login: "octocat-enterprise" }, + { hostname: "github.com", login: "octocat" }, + ]); + }); +}); diff --git a/server/github/host.ts b/server/github/host.ts index 45b77c5..525bfa2 100644 --- a/server/github/host.ts +++ b/server/github/host.ts @@ -1,6 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const hostnameContext = new AsyncLocalStorage(); + +export interface GithubAccount { + hostname: string; + login: string; +} function paseoHome(): string { return process.env.PASEO_HOME ?? join(homedir(), ".paseo"); @@ -16,6 +27,9 @@ function hostnameSettingsPaths(): string[] { /** Resolve the GitHub host from the daemon environment or saved plugin settings. */ export async function githubHostname(): Promise { + const contextual = hostnameContext.getStore(); + if (contextual !== undefined) return contextual; + const fromEnv = process.env.GH_HOST?.trim(); if (fromEnv !== undefined && fromEnv !== "") return fromEnv; @@ -32,6 +46,49 @@ export async function githubHostname(): Promise { return null; } +export function withGithubHostname(hostname: string, run: () => Promise): Promise { + return hostnameContext.run(hostname, run); +} + +export function parseGithubAccounts(value: unknown): GithubAccount[] { + const hosts = (value as { hosts?: unknown } | null)?.hosts; + if (typeof hosts !== "object" || hosts === null) return []; + + const accounts: GithubAccount[] = []; + for (const [hostname, rawEntries] of Object.entries(hosts)) { + if (!Array.isArray(rawEntries)) continue; + const entries = rawEntries.filter( + (entry): entry is { state?: unknown; active?: unknown; login?: unknown } => + typeof entry === "object" && entry !== null, + ); + const selected = + entries.find((entry) => entry.state === "success" && entry.active === true) ?? + entries.find((entry) => entry.state === "success"); + if (selected !== undefined && typeof selected.login === "string" && selected.login !== "") { + accounts.push({ hostname, login: selected.login }); + } + } + return accounts.sort((left, right) => left.hostname.localeCompare(right.hostname)); +} + +/** Every authenticated host known to gh, with the active account for each host. */ +export async function listGithubAccounts(): Promise { + const { GH_HOST: _ignored, ...env } = process.env; + const { stdout } = await execFileAsync("gh", ["auth", "status", "--json", "hosts"], { + env, + maxBuffer: 1024 * 1024, + }); + const accounts = parseGithubAccounts(JSON.parse(stdout)); + if (accounts.length === 0) { + throw new Error("GitHub CLI has no authenticated hosts. Run `gh auth login`."); + } + return accounts; +} + export function ghProcessEnv(hostname: string | null): NodeJS.ProcessEnv { return hostname === null ? process.env : { ...process.env, GH_HOST: hostname }; } + +export function projectScopeCommand(hostname: string | null): string { + return `gh auth refresh -h ${hostname ?? "github.com"} -s read:project`; +} diff --git a/server/github/item-id.test.ts b/server/github/item-id.test.ts new file mode 100644 index 0000000..417df85 --- /dev/null +++ b/server/github/item-id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { decodeItemId, encodeItemId } from "./item-id"; + +describe("item identity", () => { + it("round-trips a host and GraphQL node id", () => { + const encoded = encodeItemId("ghe.example.com", "PR_kwDOExample"); + expect(decodeItemId(encoded)).toEqual({ + hostname: "ghe.example.com", + nodeId: "PR_kwDOExample", + }); + }); + + it("rejects pre-multi-host ids", () => { + expect(() => decodeItemId("PR_kwDOExample")).toThrow("Refresh the dashboard"); + }); +}); diff --git a/server/github/item-id.ts b/server/github/item-id.ts new file mode 100644 index 0000000..d82988a --- /dev/null +++ b/server/github/item-id.ts @@ -0,0 +1,28 @@ +import { withGithubHostname } from "./host"; + +const SEPARATOR = "\u0000"; + +export function encodeItemId(hostname: string, nodeId: string): string { + return `${hostname}${SEPARATOR}${nodeId}`; +} + +export function decodeItemId(id: string): { hostname: string; nodeId: string } { + const separator = id.indexOf(SEPARATOR); + if (separator <= 0 || separator === id.length - 1) { + throw new Error("This item has no GitHub host. Refresh the dashboard and try again."); + } + return { hostname: id.slice(0, separator), nodeId: id.slice(separator + 1) }; +} + +export function withItemId(id: string, run: (nodeId: string) => Promise): Promise { + const { hostname, nodeId } = decodeItemId(id); + return withGithubHostname(hostname, () => run(nodeId)); +} + +export function hostnameFromUrl(url: string): string { + try { + return new URL(url).hostname; + } catch { + throw new Error(`GitHub returned an invalid item URL: ${url}`); + } +} diff --git a/server/items/comments.ts b/server/items/comments.ts index a13925b..b6d7532 100644 --- a/server/items/comments.ts +++ b/server/items/comments.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ItemComment, loadComments } from "../../shared/board"; import { gh } from "../github/gh"; +import { withItemId } from "../github/item-id"; import { Cache } from "../cache/cache"; /** @@ -67,35 +68,36 @@ function countOf(value: unknown): number { } async function fetchComments(id: string): Promise<{ comments: ItemComment[]; truncated: boolean }> { - const raw = await gh([ - "api", - "graphql", - "-f", - `query=${COMMENTS_QUERY}`, - "-f", - `id=${id}`, - "-F", - `first=${COMMENTS_PAGE}`, - "-F", - `replies=${REPLIES_PAGE}`, - ]); - const parsed: unknown = JSON.parse(raw); - const node = (parsed as { data?: { node?: unknown } }).data?.node; - if (typeof node !== "object" || node === null) { - throw new Error("GitHub no longer has this item, or the account cannot see it."); - } - const connection = (node as { comments?: unknown }).comments; - const comments: ItemComment[] = []; - let truncated = countOf(connection) > COMMENTS_PAGE; - for (const commentNode of commentNodesOf(connection)) { - comments.push(toComment(commentNode, 0)); - // Replies are a discussion's second level; issues and pull requests have none. - if (countOf(commentNode.replies) > REPLIES_PAGE) truncated = true; - for (const reply of commentNodesOf(commentNode.replies)) { - comments.push(toComment(reply, 1)); + return withItemId(id, async (nodeId) => { + const raw = await gh([ + "api", + "graphql", + "-f", + `query=${COMMENTS_QUERY}`, + "-f", + `id=${nodeId}`, + "-F", + `first=${COMMENTS_PAGE}`, + "-F", + `replies=${REPLIES_PAGE}`, + ]); + const parsed: unknown = JSON.parse(raw); + const node = (parsed as { data?: { node?: unknown } }).data?.node; + if (typeof node !== "object" || node === null) { + throw new Error("GitHub no longer has this item, or the account cannot see it."); } - } - return { comments: comments.filter((comment) => comment.id !== ""), truncated }; + const connection = (node as { comments?: unknown }).comments; + const comments: ItemComment[] = []; + let truncated = countOf(connection) > COMMENTS_PAGE; + for (const commentNode of commentNodesOf(connection)) { + comments.push(toComment(commentNode, 0)); + if (countOf(commentNode.replies) > REPLIES_PAGE) truncated = true; + for (const reply of commentNodesOf(commentNode.replies)) { + comments.push(toComment(reply, 1)); + } + } + return { comments: comments.filter((comment) => comment.id !== ""), truncated }; + }); } /** Cached like the body, and for as long; `force` is the same Refresh button. */ diff --git a/server/items/details.ts b/server/items/details.ts index 209c4d4..fcf62ab 100644 --- a/server/items/details.ts +++ b/server/items/details.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ItemDetails, MergeMethod, ReviewState, loadItem } from "../../shared/board"; import { gh } from "../github/gh"; +import { withItemId } from "../github/item-id"; import { Cache } from "../cache/cache"; /** @@ -140,13 +141,15 @@ function toItemDetails(node: GhItemNode): ItemDetails { } export async function fetchItemDetails(id: string): Promise { - const raw = await gh(["api", "graphql", "-f", `query=${ITEM_QUERY}`, "-f", `id=${id}`]); - const parsed: unknown = JSON.parse(raw); - const node = (parsed as { data?: { node?: unknown } }).data?.node; - if (typeof node !== "object" || node === null) { - throw new Error("GitHub no longer has this item, or the account cannot see it."); - } - return toItemDetails(node as GhItemNode); + return withItemId(id, async (nodeId) => { + const raw = await gh(["api", "graphql", "-f", `query=${ITEM_QUERY}`, "-f", `id=${nodeId}`]); + const parsed: unknown = JSON.parse(raw); + const node = (parsed as { data?: { node?: unknown } }).data?.node; + if (typeof node !== "object" || node === null) { + throw new Error("GitHub no longer has this item, or the account cannot see it."); + } + return toItemDetails(node as GhItemNode); + }); } /** diff --git a/server/items/labels.ts b/server/items/labels.ts index dc54d3e..2469be7 100644 --- a/server/items/labels.ts +++ b/server/items/labels.ts @@ -1,6 +1,8 @@ import type { z } from "zod"; import type { RepositoryLabel, listLabels, toggleLabel } from "../../shared/board"; import { gh } from "../github/gh"; +import { withGithubHostname } from "../github/host"; +import { withItemId } from "../github/item-id"; import { Cache } from "../cache/cache"; import { patchCachedLabels } from "../board/cache"; @@ -65,8 +67,12 @@ async function fetchRepositoryLabels(repository: string): Promise): Promise> { - const labels = await labelsCache.get(repository, LABELS_TTL_MS, () => fetchRepositoryLabels(repository)); + const key = `${host}\u0000${repository}`; + const labels = await labelsCache.get(key, LABELS_TTL_MS, () => + withGithubHostname(host, () => fetchRepositoryLabels(repository)), + ); return { labels }; } @@ -111,16 +117,18 @@ export async function toggleLabelHandler({ labelId, add, }: z.output): Promise> { - const raw = await gh([ - "api", - "graphql", - "-f", - `query=${add ? ADD_LABEL_MUTATION : REMOVE_LABEL_MUTATION}`, - "-f", - `item=${itemId}`, - "-f", - `label=${labelId}`, - ]); + const raw = await withItemId(itemId, (nodeId) => + gh([ + "api", + "graphql", + "-f", + `query=${add ? ADD_LABEL_MUTATION : REMOVE_LABEL_MUTATION}`, + "-f", + `item=${nodeId}`, + "-f", + `label=${labelId}`, + ]), + ); const parsed: unknown = JSON.parse(raw); const data = (parsed as { data?: Record }).data; const labels = labelNamesOf(add ? data?.addLabelsToLabelable : data?.removeLabelsFromLabelable); diff --git a/server/items/review.ts b/server/items/review.ts index 0a76d77..0ff1c77 100644 --- a/server/items/review.ts +++ b/server/items/review.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ItemDetails, MergeMethod, approvePullRequest, mergePullRequest } from "../../shared/board"; import { ghMutation } from "../github/graphql"; +import { withItemId } from "../github/item-id"; import { dropCachedItem } from "../board/cache"; import { detailsCache, fetchItemDetails } from "./details"; @@ -37,7 +38,7 @@ export async function approveHandler({ id, body, }: z.output): Promise> { - await ghMutation(APPROVE_MUTATION, { id, body }); + await withItemId(id, (nodeId) => ghMutation(APPROVE_MUTATION, { id: nodeId, body })); return refreshDetails(id); } @@ -45,7 +46,9 @@ export async function mergeHandler({ id, method, }: z.output): Promise> { - await ghMutation(MERGE_MUTATION, { id, method: MERGE_METHOD_NAMES[method] }); + await withItemId(id, (nodeId) => + ghMutation(MERGE_MUTATION, { id: nodeId, method: MERGE_METHOD_NAMES[method] }), + ); const details = await refreshDetails(id); await dropCachedItem(id); return details; diff --git a/server/projects/list.ts b/server/projects/list.ts index 6e72b46..dce06bd 100644 --- a/server/projects/list.ts +++ b/server/projects/list.ts @@ -1,10 +1,13 @@ import type { z } from "zod"; import type { ProjectSummary, listProjects } from "../../shared/board"; import { aliasName, ghGraphqlRaw, nodesOf } from "../github/graphql"; -import { resolveViewerLogin } from "../github/gh"; +import { + type GithubAccount, + listGithubAccounts, + withGithubHostname, +} from "../github/host"; import { Cache } from "../cache/cache"; -import { readSettings } from "../settings/settings"; -import { needsProjectScope, PROJECT_SCOPE_MESSAGE } from "./scope"; +import { needsProjectScope, projectScopeMessage } from "./scope"; /** One project summary node, as `PROJECT_SUMMARY_FIELDS` shapes it. */ interface GhProjectSummaryNode { @@ -21,10 +24,11 @@ interface GhProjectSummaryNode { const PROJECT_SUMMARY_FIELDS = `id number title url shortDescription closed updatedAt items { totalCount } owner { ... on User { login } ... on Organization { login } }`; -function toProjectSummary(node: GhProjectSummaryNode): ProjectSummary | null { +function toProjectSummary(host: string, node: GhProjectSummaryNode): ProjectSummary | null { if (typeof node.id !== "string" || typeof node.url !== "string") return null; return { - id: node.id, + id: `${host}\u0000${node.id}`, + host, number: typeof node.number === "number" ? node.number : 0, title: typeof node.title === "string" ? node.title : "", url: node.url, @@ -47,24 +51,11 @@ type ListProjectsResult = z.input; const projectsCache = new Cache("projects"); -export async function listProjectsHandler({ - login, - owners, - force, -}: z.output): Promise { - const requested = login?.trim(); - const settings = await readSettings(); - const resolved = - requested !== undefined && requested !== "" && requested !== "@me" - ? requested - : (settings.login ?? (await resolveViewerLogin())); - - const key = `${resolved}\u0000${[...owners].sort().join(",")}`; - - return projectsCache.get( - key, - PROJECTS_TTL_MS, - async () => { +async function fetchAccountProjects( + account: GithubAccount, + owners: readonly string[], +): Promise { + return withGithubHostname(account.hostname, async () => { const orderBy = "orderBy: { field: UPDATED_AT, direction: DESC }"; const ownerAliases = owners .map( @@ -80,14 +71,14 @@ export async function listProjectsHandler({ ${ownerAliases} }`; - const args = ["api", "graphql", "-f", `query=${query}`, "-f", `self=${resolved}`]; + const args = ["api", "graphql", "-f", `query=${query}`, "-f", `self=${account.login}`]; owners.forEach((owner, index) => args.push("-f", `${aliasName(index)}=${owner}`)); const { data, errors } = await ghGraphqlRaw(args); if (data === null) { return needsProjectScope(errors) - ? { projects: [], error: PROJECT_SCOPE_MESSAGE, needsScope: true } + ? { projects: [], error: projectScopeMessage(account.hostname), needsScope: true } : { projects: [], error: errors.map((error) => error.message).join(" ") || "GitHub returned no data.", @@ -105,16 +96,54 @@ export async function listProjectsHandler({ const ownerNode = data[alias] as GhOwnerProjectsNode | null; for (const raw of nodesOf(ownerNode?.projectsV2)) { if (typeof raw !== "object" || raw === null) continue; - const summary = toProjectSummary(raw as GhProjectSummaryNode); + const summary = toProjectSummary(account.hostname, raw as GhProjectSummaryNode); if (summary !== null) byId.set(summary.id, summary); } }; - collect("self", resolved); + collect("self", account.login); owners.forEach((owner, index) => collect(aliasName(index), owner)); const projects = [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { projects, error: null, needsScope: false }; + }); +} + +export async function listProjectsHandler({ + login, + owners, + force, +}: z.output): Promise { + const requested = login?.trim(); + const available = await listGithubAccounts(); + const accounts = + requested === undefined || requested === "" || requested === "@me" + ? available + : available.filter((account) => account.login.toLowerCase() === requested.toLowerCase()); + if (accounts.length === 0) { + throw new Error(`gh has no authenticated account with login "${requested}".`); + } + const key = `${accounts.map(({ hostname, login: accountLogin }) => `${hostname}:${accountLogin}`).join(",")}\u0000${[...owners].sort().join(",")}`; + + return projectsCache.get( + key, + PROJECTS_TTL_MS, + async () => { + const results = await Promise.all( + accounts.map((account) => fetchAccountProjects(account, owners)), + ); + const projects = results + .flatMap((result) => result.projects) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + const errors = results + .map((result) => result.error) + .filter((error): error is string => error !== null); + const empty = projects.length === 0; + return { + projects, + error: empty && errors.length > 0 ? errors.join(" ") : null, + needsScope: empty && results.some((result) => result.needsScope), + }; }, { force, diff --git a/server/projects/scope.ts b/server/projects/scope.ts index c9f463a..8d65101 100644 --- a/server/projects/scope.ts +++ b/server/projects/scope.ts @@ -1,3 +1,4 @@ +import { projectScopeCommand } from "../github/host"; import type { GraphqlError } from "../github/graphql"; /** @@ -7,8 +8,9 @@ import type { GraphqlError } from "../github/graphql"; * all, before any field runs — see `ghGraphqlRaw` — so this is checked ahead * of the per-owner partial-failure handling, not folded into it. */ -export const PROJECT_SCOPE_MESSAGE = - "GitHub Projects needs a scope this token does not have. Run `gh auth refresh -h github.com -s read:project`, then reload."; +export function projectScopeMessage(hostname: string): string { + return `GitHub Projects on ${hostname} needs a scope this token does not have. Run \`${projectScopeCommand(hostname)}\`, then reload.`; +} export function needsProjectScope(errors: readonly GraphqlError[]): boolean { return errors.some( diff --git a/server/projects/single.ts b/server/projects/single.ts index f464c11..36d25d1 100644 --- a/server/projects/single.ts +++ b/server/projects/single.ts @@ -1,9 +1,10 @@ import type { z } from "zod"; import type { ProjectItem, loadProject } from "../../shared/board"; import { ghGraphqlRaw, nodesOf } from "../github/graphql"; +import { withGithubHostname } from "../github/host"; import { Cache } from "../cache/cache"; import { labelNodeNames } from "../board/item"; -import { needsProjectScope, PROJECT_SCOPE_MESSAGE } from "./scope"; +import { needsProjectScope, projectScopeMessage } from "./scope"; /** The Status field's own single-select name, read by `fieldValueByName` and `field`. */ const STATUS_FIELD_NAME = "Status"; @@ -185,46 +186,50 @@ type LoadProjectResult = z.input; const projectCache = new Cache("project"); export async function loadProjectHandler({ + host, owner, number, force, }: z.output): Promise { - const key = `${owner}\u0000${number}`; + const key = `${host}\u0000${owner}\u0000${number}`; return projectCache.get( key, PROJECT_TTL_MS, - async () => { - const { data, errors } = await ghGraphqlRaw([ - "api", - "graphql", - "-f", - `query=${PROJECT_QUERY}`, - "-f", - `owner=${owner}`, - "-F", - `number=${number}`, - ]); - - if (data === null) { - if (needsProjectScope(errors)) throw new Error(PROJECT_SCOPE_MESSAGE); - throw new Error(errors.map((error) => error.message).join(" ") || "GitHub returned no data."); - } + () => + withGithubHostname(host, async () => { + const { data, errors } = await ghGraphqlRaw([ + "api", + "graphql", + "-f", + `query=${PROJECT_QUERY}`, + "-f", + `owner=${owner}`, + "-F", + `number=${number}`, + ]); + + if (data === null) { + if (needsProjectScope(errors)) throw new Error(projectScopeMessage(host)); + throw new Error( + errors.map((error) => error.message).join(" ") || "GitHub returned no data.", + ); + } - const asUser = data.asUser as { projectV2?: GhProjectV2Node | null } | null; - const asOrg = data.asOrg as { projectV2?: GhProjectV2Node | null } | null; - const project = asUser?.projectV2 ?? asOrg?.projectV2; + const asUser = data.asUser as { projectV2?: GhProjectV2Node | null } | null; + const asOrg = data.asOrg as { projectV2?: GhProjectV2Node | null } | null; + const project = asUser?.projectV2 ?? asOrg?.projectV2; - if (project === null || project === undefined) { - const message = errors.map((error) => error.message).join(" "); - throw new Error(message !== "" ? message : `Project ${owner}/${number} was not found.`); - } + if (project === null || project === undefined) { + const message = errors.map((error) => error.message).join(" "); + throw new Error(message !== "" ? message : `Project ${owner}/${number} was not found.`); + } - return { - title: typeof project.title === "string" ? project.title : "", - url: typeof project.url === "string" ? project.url : "", - columns: groupProjectItems(project), - }; - }, + return { + title: typeof project.title === "string" ? project.title : "", + url: typeof project.url === "string" ? project.url : "", + columns: groupProjectItems(project), + }; + }), { force }, ); } diff --git a/shared/board.ts b/shared/board.ts index 54a63e1..e3ae442 100644 --- a/shared/board.ts +++ b/shared/board.ts @@ -170,6 +170,8 @@ export const BoardColumnSchema = z.object({ export const BoardSchema = z.object({ /** The concrete login every query ran against, never the `@me` alias. */ login: z.string(), + /** Viewer logins for every authenticated gh host included in this response. */ + viewerLogins: z.array(z.string()), columns: z.array(BoardColumnSchema), /** * `owner/name` to project id, for the repositories on this board only. The @@ -244,6 +246,7 @@ export const saveLogin = defineRpc({ */ export const ProjectSummarySchema = z.object({ id: z.string(), + host: z.string(), number: z.number().int(), title: z.string(), url: z.string(), @@ -308,6 +311,7 @@ export type ProjectItem = z.output; export const loadProject = defineRpc({ name: "board.project", input: z.object({ + host: z.string().min(1), owner: z.string().min(1), number: z.number().int(), force: z.boolean().default(false), @@ -476,7 +480,7 @@ export type BoardTimelineItem = z.infer; */ export const listLabels = defineRpc({ name: "board.labels", - input: z.object({ repository: z.string().min(1) }), + input: z.object({ repository: z.string().min(1), host: z.string().min(1) }), output: z.object({ labels: z.array(RepositoryLabelSchema) }), });