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 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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) }), }); From d5d58d91b0f35117576fde24ab7c7f29bb4b726b Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:23 -0700 Subject: [PATCH 07/12] feat: group the board by host, paint it at once - one section per host when more than one host contributed, in the order the sort puts their first card - an expired login on one host no longer blanks the board for the hosts that are fine - an expired board is served immediately, marked stale, and refreshed behind the surface --- client/board/board-body.tsx | 20 ++++++++--- client/board/board-row.styles.ts | 27 +++++++++++++++ client/board/constants.ts | 8 +++++ client/board/github-board.tsx | 4 +-- client/board/host-sections.test.ts | 54 +++++++++++++++++++++++++++++ client/board/host-sections.ts | 43 +++++++++++++++++++++++ client/board/item-row.tsx | 2 +- client/board/label-menu.tsx | 2 +- client/board/use-board-filters.tsx | 5 +++ client/board/use-board-query.tsx | 18 ++++++++-- client/board/use-github-board.tsx | 8 +++-- client/lib/search.test.ts | 1 + client/lib/sort.test.ts | 1 + client/lib/templates.test.ts | 1 + server/board/buckets.test.ts | 1 + server/board/handler.ts | 10 +++++- server/board/item.ts | 4 ++- server/cache/cache.test.ts | 30 ++++++++++++++++ server/cache/cache.ts | 47 +++++++++++++++++++++++-- server/github/host.ts | 55 ++++++++++++++++++++++++++---- shared/board.ts | 13 +++++++ 21 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 client/board/host-sections.test.ts create mode 100644 client/board/host-sections.ts diff --git a/client/board/board-body.tsx b/client/board/board-body.tsx index 68b0731..9ee5a85 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 { ListRow } from "./host-sections"; import type { ChatLink } from "./use-chat-links"; /** @@ -30,7 +31,7 @@ export function BoardBody({ board, mode, watchedOwners, - displayRows, + listRows, renderRow, modeRows, refresh, @@ -57,7 +58,7 @@ export function BoardBody({ board: Board | null; mode: BoardMode; watchedOwners: readonly string[]; - displayRows: readonly BoardRow[]; + listRows: readonly ListRow[]; renderRow: (info: { item: BoardRow }) => React.JSX.Element; modeRows: { rows: BoardRow[]; error: string | null }; refresh: (login?: string, force?: boolean) => Promise; @@ -103,9 +104,18 @@ export function BoardBody({ ) : ( row.item.id} - renderItem={renderRow} + data={listRows} + keyExtractor={(row) => row.key} + renderItem={({ item: row }) => + row.kind === "host" ? ( + + {row.host} + {row.count} + + ) : ( + renderRow({ item: row.row }) + ) + } ListEmptyComponent={ modeRows.error !== null ? ( {modeRows.error} diff --git a/client/board/board-row.styles.ts b/client/board/board-row.styles.ts index 9fcbf37..85c5456 100644 --- a/client/board/board-row.styles.ts +++ b/client/board/board-row.styles.ts @@ -15,6 +15,33 @@ export function buildBoardRowStyles( return { rowList: { flex: 1 }, rowListContent: { paddingBottom: layout.compact ? 32 : 8 }, + /** + * The heading above each host's run of cards, shown only when the board + * aggregates more than one GitHub host. Deliberately quieter than a card: + * it separates the list, it is not an entry in it. + */ + hostHeader: { + flexDirection: "row" as const, + alignItems: "center" as const, + justifyContent: "space-between" as const, + gap: 8, + paddingHorizontal: layout.compact ? 12 : 16, + paddingTop: layout.compact ? 14 : 12, + paddingBottom: 6, + backgroundColor: colors.surface1, + borderBottomWidth: 1, + borderBottomColor: separator, + }, + hostHeaderName: { + color: colors.foregroundMuted, + fontSize: layout.compact ? 12 : 11, + fontWeight: "700" as const, + letterSpacing: 0.4, + }, + hostHeaderCount: { + color: colors.foregroundMuted, + fontSize: layout.compact ? 12 : 11, + }, /** * One line of the GitHub-style list: an icon, the title and its meta, and * — on the wide layout only — the trailing checks/comments/labels. A diff --git a/client/board/constants.ts b/client/board/constants.ts index 9f01bee..5f29313 100644 --- a/client/board/constants.ts +++ b/client/board/constants.ts @@ -4,3 +4,11 @@ * catalogue the label menu keeps alongside it. */ export const STALE_AFTER_MS = 5 * 60_000; + +/** + * How often the board asks again while the daemon is refreshing the answer it + * just served from its cache. Short enough that the fresh board lands without + * the user reaching for Refresh, and each ask is cheap: the daemon answers + * from memory until its own sweep finishes. + */ +export const STALE_POLL_MS = 3_000; diff --git a/client/board/github-board.tsx b/client/board/github-board.tsx index f86ee11..264190b 100644 --- a/client/board/github-board.tsx +++ b/client/board/github-board.tsx @@ -44,7 +44,7 @@ export function GitHubBoard(props: PluginSurfaceProps) { applyPrompts, applyLogin, watchedOwners, - displayRows, + listRows, renderRow, modeRows, bodyWidth, @@ -147,7 +147,7 @@ export function GitHubBoard(props: PluginSurfaceProps) { board={board} mode={mode} watchedOwners={watchedOwners} - displayRows={displayRows} + listRows={listRows} renderRow={renderRow} modeRows={modeRows} refresh={refresh} diff --git a/client/board/host-sections.test.ts b/client/board/host-sections.test.ts new file mode 100644 index 0000000..980b11b --- /dev/null +++ b/client/board/host-sections.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { BoardItem } from "../../shared/board"; +import type { BoardRow } from "../lib/sort"; +import { withHostHeaders } from "./host-sections"; + +function makeRow(id: string, host: string): BoardRow { + return { + item: { + id, + number: 1, + title: id, + url: `https://${host}/owner/repo/pull/1`, + repository: "owner/repo", + host, + updatedAt: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + lastCommitAt: null, + commentsCount: 0, + labels: [], + author: null, + detail: null, + owner: "owner", + relations: ["author"], + linkedIssues: [], + checks: null, + } satisfies BoardItem, + type: "open-prs", + }; +} + +describe("withHostHeaders", () => { + it("adds no heading when every row is from one host", () => { + const rows = [makeRow("a", "github.com"), makeRow("b", "github.com")]; + expect(withHostHeaders(rows)).toEqual([ + { kind: "row", key: "a", row: rows[0] }, + { kind: "row", key: "b", row: rows[1] }, + ]); + }); + + it("groups each host under one heading, in the order the hosts first appear", () => { + const rows = [ + makeRow("a", "ghe.example.com"), + makeRow("b", "github.com"), + makeRow("c", "ghe.example.com"), + ]; + expect(withHostHeaders(rows)).toEqual([ + { kind: "host", key: "host:ghe.example.com", host: "ghe.example.com", count: 2 }, + { kind: "row", key: "a", row: rows[0] }, + { kind: "row", key: "c", row: rows[2] }, + { kind: "host", key: "host:github.com", host: "github.com", count: 1 }, + { kind: "row", key: "b", row: rows[1] }, + ]); + }); +}); diff --git a/client/board/host-sections.ts b/client/board/host-sections.ts new file mode 100644 index 0000000..c23b664 --- /dev/null +++ b/client/board/host-sections.ts @@ -0,0 +1,43 @@ +import type { BoardRow } from "../lib/sort"; + +/** + * What the board's list actually renders: a card, or the host heading that + * opens a run of cards from one GitHub host. Headings exist only when the + * board is aggregating more than one host — with a single account there is + * nothing to divide, and a lone "github.com" banner is just noise. + */ +export type ListRow = + | { kind: "host"; key: string; host: string; count: number } + | { kind: "row"; key: string; row: BoardRow }; + +/** + * Groups already-sorted rows by host without disturbing the order within a + * group. Hosts appear in the order their first row does, so whichever host + * owns the top card by the active sort keeps the top of the list rather than + * losing it to an alphabetical rule the sort dropdown never mentions. + */ +export function withHostHeaders(rows: readonly BoardRow[]): ListRow[] { + const order: string[] = []; + const byHost = new Map(); + for (const row of rows) { + const existing = byHost.get(row.item.host); + if (existing === undefined) { + byHost.set(row.item.host, [row]); + order.push(row.item.host); + } else { + existing.push(row); + } + } + + if (order.length < 2) { + return rows.map((row) => ({ kind: "row", key: row.item.id, row })); + } + + const list: ListRow[] = []; + for (const host of order) { + const group = byHost.get(host) ?? []; + list.push({ kind: "host", key: `host:${host}`, host, count: group.length }); + for (const row of group) list.push({ kind: "row", key: row.item.id, row }); + } + return list; +} diff --git a/client/board/item-row.tsx b/client/board/item-row.tsx index 85b27aa..324cbc1 100644 --- a/client/board/item-row.tsx +++ b/client/board/item-row.tsx @@ -158,7 +158,7 @@ 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 hostname = item.host; const display = describeRow({ item, diff --git a/client/board/label-menu.tsx b/client/board/label-menu.tsx index 4f77ade..93e2106 100644 --- a/client/board/label-menu.tsx +++ b/client/board/label-menu.tsx @@ -56,7 +56,7 @@ export function LabelMenu({ onChanged: (itemId: string, labels: string[]) => void; }) { const { item } = target; - const host = new URL(item.url).hostname; + const host = item.host; const list = useRpc(listLabels); const apply = useRpc(toggleLabel); diff --git a/client/board/use-board-filters.tsx b/client/board/use-board-filters.tsx index dd11406..4c15fd3 100644 --- a/client/board/use-board-filters.tsx +++ b/client/board/use-board-filters.tsx @@ -12,6 +12,7 @@ import { type BoardRow, type SortOrder, } from "../lib/sort"; +import { withHostHeaders, type ListRow } from "./host-sections"; import type { UseBoardSettingsResult } from "./use-board-settings"; /** Which of the three dropdowns is open; never more than one at once, so one backdrop closes any of them. */ @@ -43,6 +44,8 @@ export interface UseBoardFiltersResult { searchQuery: string; setSearchQuery: (next: string) => void; displayRows: BoardRow[]; + /** `displayRows` with a heading before each host's run of cards; see `withHostHeaders`. */ + listRows: ListRow[]; modeRows: { rows: BoardRow[]; error: string | null }; openFilter: OpenBoardFilter; setOpenFilter: Dispatch>; @@ -301,6 +304,7 @@ export function useBoardFilters( // rather than the shared one in place. return [...searchedRows].sort((a, b) => compareBySortDate(activeOrder, a, b)); }, [searchedRows, activeOrder]); + const listRows = useMemo(() => withHostHeaders(displayRows), [displayRows]); const selectColumnMode = useCallback((id: BoardMode) => setMode(id), []); @@ -397,6 +401,7 @@ export function useBoardFilters( searchQuery, setSearchQuery, displayRows, + listRows, modeRows, openFilter, setOpenFilter, diff --git a/client/board/use-board-query.tsx b/client/board/use-board-query.tsx index afe3856..65086dc 100644 --- a/client/board/use-board-query.tsx +++ b/client/board/use-board-query.tsx @@ -4,7 +4,7 @@ import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-quer import type { Board } from "../../shared/board"; import { loadBoard, saveLogin } from "../../shared/board"; -import { STALE_AFTER_MS } from "./constants"; +import { STALE_AFTER_MS, STALE_POLL_MS } from "./constants"; /** * The board's query key: the login it was fetched for (`null` meaning "let @@ -42,6 +42,7 @@ export interface UseBoardQueryResult { export function useBoardQuery( props: PluginSurfaceProps, watchedOwners: readonly string[], + ownersReady: boolean, ): UseBoardQueryResult { const load = useRpc(loadBoard); const persistLogin = useRpc(saveLogin); @@ -76,6 +77,19 @@ export function useBoardQuery( // keeps the previous key's board on screen while the new one loads, // which is what the hand-written cache did by never changing identity. placeholderData: keepPreviousData, + /** + * The watched owners are part of the key, so fetching before the settings + * read lands would sweep GitHub for the empty owner list and then sweep + * again for the real one — several seconds of `gh` calls spent on a board + * nobody sees. + */ + enabled: ownersReady, + /** + * A stale board is one the daemon is refreshing right now, so the fresh + * one is a short wait away rather than a sweep away; asking again picks it + * up. Polling stops the moment a board comes back fresh. + */ + refetchInterval: (query) => (query.state.data?.stale === true ? STALE_POLL_MS : false), }); const board = boardQuery.data ?? null; /** @@ -98,7 +112,7 @@ export function useBoardQuery( * new login's board outside that query's own key (see `refresh`). */ const [switchingLogin, setSwitchingLogin] = useState(false); - const busy = boardQuery.isFetching || switchingLogin; + const busy = boardQuery.isFetching || switchingLogin || !ownersReady; const [loginDraft, setLoginDraft] = useState(() => board?.login ?? ""); useEffect(() => { if (board !== null) setLoginDraft(board.login); diff --git a/client/board/use-github-board.tsx b/client/board/use-github-board.tsx index 68d3ca7..138c8e8 100644 --- a/client/board/use-github-board.tsx +++ b/client/board/use-github-board.tsx @@ -21,7 +21,11 @@ import { chatLinkFor, useChatLinks } from "./use-chat-links"; */ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) { const settings = useBoardSettings(); - const query = useBoardQuery(props, settings.watchedOwners); + const query = useBoardQuery( + props, + settings.watchedOwners, + settings.display.status !== "loading", + ); const filters = useBoardFilters(query.board, settings.display); const chatLinks = useChatLinks(props.host.id); const overlays = useBoardOverlays(props, styles, { @@ -65,7 +69,7 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) { applyPrompts: settings.applyPrompts, applyLogin: query.applyLogin, watchedOwners: settings.watchedOwners, - displayRows: filters.displayRows, + listRows: filters.listRows, renderRow: overlays.renderRow, modeRows: filters.modeRows, bodyWidth: overlays.bodyWidth, diff --git a/client/lib/search.test.ts b/client/lib/search.test.ts index a4f6990..6577025 100644 --- a/client/lib/search.test.ts +++ b/client/lib/search.test.ts @@ -15,6 +15,7 @@ function makeItem(overrides: Partial): BoardItem { title: "An item", url: "https://github.com/owner/repo/issues/1", repository: "owner/repo", + host: "github.com", updatedAt: "2024-01-01T00:00:00Z", createdAt: "2024-01-01T00:00:00Z", lastCommitAt: null, diff --git a/client/lib/sort.test.ts b/client/lib/sort.test.ts index 83b565a..8f2f00e 100644 --- a/client/lib/sort.test.ts +++ b/client/lib/sort.test.ts @@ -9,6 +9,7 @@ function makeItem(overrides: Partial): BoardItem { title: "An item", url: "https://github.com/owner/repo/pull/1", repository: "owner/repo", + host: "github.com", updatedAt: "2024-01-01T00:00:00Z", createdAt: "2024-01-01T00:00:00Z", lastCommitAt: null, diff --git a/client/lib/templates.test.ts b/client/lib/templates.test.ts index c8b5093..f754134 100644 --- a/client/lib/templates.test.ts +++ b/client/lib/templates.test.ts @@ -9,6 +9,7 @@ function makeItem(overrides: Partial): BoardItem { title: "Fix the thing", url: "https://github.com/owner/repo/issues/42", repository: "owner/repo", + host: "github.com", updatedAt: "2024-01-01T00:00:00Z", createdAt: "2024-01-01T00:00:00Z", lastCommitAt: null, diff --git a/server/board/buckets.test.ts b/server/board/buckets.test.ts index c7c0798..acbd177 100644 --- a/server/board/buckets.test.ts +++ b/server/board/buckets.test.ts @@ -15,6 +15,7 @@ function makeItem(id: string, updatedAt: string): BoardItem { title: `item ${id}`, url: `https://github.com/owner/repo/issues/${id}`, repository: "owner/repo", + host: "github.com", updatedAt, createdAt: updatedAt, lastCommitAt: null, diff --git a/server/board/handler.ts b/server/board/handler.ts index 19a23ff..e094b8c 100644 --- a/server/board/handler.ts +++ b/server/board/handler.ts @@ -127,7 +127,10 @@ export async function loadBoardHandler( } const key = `${accounts.map(({ hostname, login: accountLogin }) => `${hostname}:${accountLogin}`).join(",")}\u0000${limit}\u0000${[...owners].sort().join(",")}`; - const { columns, fetchedAt } = await boardCache.get( + const { + value: { columns, fetchedAt }, + stale, + } = await boardCache.read( key, BOARD_TTL_MS, async () => { @@ -139,6 +142,10 @@ export async function loadBoardHandler( }, { force, + // An expired board is shown immediately and refreshed behind the + // surface: a sweep costs several seconds of `gh` round trips, and the + // board it would replace is minutes old, not wrong. + revalidate: true, // A column that failed is not worth remembering: caching it would keep the // error on screen for the whole window even though a retry might succeed. shouldCache: (cached) => cached.columns.every((column) => column.error === null), @@ -151,5 +158,6 @@ export async function loadBoardHandler( ...(await describeRepositoryProjects(paseo, columns)), columns, fetchedAt, + stale, }; } diff --git a/server/board/item.ts b/server/board/item.ts index 74b395c..c3035c4 100644 --- a/server/board/item.ts +++ b/server/board/item.ts @@ -26,12 +26,14 @@ export function toItem( const ownerSeparator = repository.indexOf("/"); const url = typeof node.url === "string" ? node.url : ""; const nodeId = typeof node.id === "string" ? node.id : url; + const host = hostnameFromUrl(url); return { - id: encodeItemId(hostnameFromUrl(url), nodeId), + id: encodeItemId(host, nodeId), number: typeof node.number === "number" ? node.number : 0, title: typeof node.title === "string" ? node.title : "", url, repository, + host, updatedAt: typeof node.updatedAt === "string" ? node.updatedAt : "", createdAt: typeof node.createdAt === "string" ? node.createdAt : "", // Null on anything that is not a pull request, or a pull request GitHub diff --git a/server/cache/cache.test.ts b/server/cache/cache.test.ts index e2cf499..8a2eeda 100644 --- a/server/cache/cache.test.ts +++ b/server/cache/cache.test.ts @@ -79,6 +79,36 @@ describe("Cache.get", () => { }); }); +describe("Cache.read with revalidate", () => { + it("answers from the expired entry and marks it stale while reloading behind the caller", async () => { + const cache = new Cache("revalidate"); + const load = vi.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2); + expect(await cache.read("k", 10_000, load)).toEqual({ value: 1, stale: false }); + + vi.setSystemTime(1_000_000 + 10_001); + expect(await cache.read("k", 10_000, load, { revalidate: true })).toEqual({ + value: 1, + stale: true, + }); + + // The background sweep started; once it lands the same call answers fresh. + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + expect(await cache.read("k", 10_000, load, { revalidate: true })).toEqual({ + value: 2, + stale: false, + }); + }); + + it("still waits for the first sweep when nothing is cached yet", async () => { + const cache = new Cache("revalidate-cold"); + const load = vi.fn().mockResolvedValue(5); + expect(await cache.read("k", 10_000, load, { revalidate: true })).toEqual({ + value: 5, + stale: false, + }); + }); +}); + describe("Cache file permissions", () => { it("creates the cache file at 0600 and its directory at 0700", async () => { const cache = new Cache("perm-fresh"); diff --git a/server/cache/cache.ts b/server/cache/cache.ts index a3a1a4d..889cdee 100644 --- a/server/cache/cache.ts +++ b/server/cache/cache.ts @@ -8,6 +8,19 @@ interface CacheEntry { storedAt: number; } +/** Per-call knobs for `get`/`read`; see the doc comments on each. */ +interface CacheOptions { + force?: boolean; + shouldCache?: (value: T) => boolean; + /** + * Answer from an expired entry and refresh it in the background instead of + * making the caller wait for the sweep. Only worth it where a stale answer + * is better than a spinner and the caller can repaint when the fresh one + * lands — the board surface does both. + */ + revalidate?: boolean; +} + /** * The one cache this plugin uses everywhere an answer is worth remembering: a * TTL supplied by the caller rather than baked into the cache, single-flight @@ -111,13 +124,43 @@ export class Cache { key: string, ttl: number, load: () => Promise, - options?: { force?: boolean; shouldCache?: (value: T) => boolean }, + options?: CacheOptions, ): Promise { + const { value } = await this.read(key, ttl, load, options); + return value; + } + + /** + * `get` plus whether the value handed back is an expired one being refreshed + * behind the caller — only ever true with `revalidate`. A caller that can + * repaint later (the board surface) uses this to say so and come back for + * the fresh answer; one that cannot simply calls `get`. + */ + async read( + key: string, + ttl: number, + load: () => Promise, + options?: CacheOptions, + ): Promise<{ value: T; stale: boolean }> { await this.hydrate(); if (options?.force !== true) { const cached = this.memory.get(key); - if (cached !== undefined && Date.now() - cached.storedAt < ttl) return cached.value; + if (cached !== undefined) { + if (Date.now() - cached.storedAt < ttl) return { value: cached.value, stale: false }; + if (options?.revalidate === true) { + // Deliberately not awaited: the point is to answer from the expired + // entry now. A failure is the next request's problem — nothing was + // cached, so it simply sweeps again. + void this.load(key, load, options).catch(() => undefined); + return { value: cached.value, stale: true }; + } + } } + return { value: await this.load(key, load, options), stale: false }; + } + + /** The single-flight sweep behind `read`: one `load` per key, however many callers are waiting. */ + private load(key: string, load: () => Promise, options?: CacheOptions): Promise { const running = this.inFlight.get(key); if (running !== undefined) return running; diff --git a/server/github/host.ts b/server/github/host.ts index 525bfa2..24e409c 100644 --- a/server/github/host.ts +++ b/server/github/host.ts @@ -71,20 +71,61 @@ export function parseGithubAccounts(value: unknown): GithubAccount[] { 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 { +/** + * `gh auth status` validates each token against its host, so it costs a + * subprocess and a round trip per call — the better part of two seconds, paid + * before the board can even look in its own cache. Who is logged in changes + * only when someone runs `gh auth login`, so the answer is remembered for a + * few minutes and shared by every request in that window. + */ +const ACCOUNTS_TTL_MS = 5 * 60_000; +let cachedAccounts: { accounts: GithubAccount[]; storedAt: number } | null = null; +let accountsInFlight: Promise | null = null; + +async function readGithubAccounts(): 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)); + // `gh auth status` exits non-zero when any one host is in a failed state — + // an expired login on one host must not blank the board for the hosts that + // are fine — so the stdout is parsed first and the failure honoured only + // when the stdout says nothing usable. + let stdout = ""; + try { + ({ stdout } = await execFileAsync("gh", ["auth", "status", "--json", "hosts"], { + env, + maxBuffer: 1024 * 1024, + })); + } catch (error) { + const output = error as { stdout?: unknown }; + if (typeof output.stdout === "string") stdout = output.stdout; + } + const accounts = parseGithubAccounts(stdout.trim() === "" ? [] : JSON.parse(stdout)); if (accounts.length === 0) { throw new Error("GitHub CLI has no authenticated hosts. Run `gh auth login`."); } return accounts; } +/** Every authenticated host known to gh, with the active account for each host. */ +export async function listGithubAccounts(force = false): Promise { + if (!force && cachedAccounts !== null && Date.now() - cachedAccounts.storedAt < ACCOUNTS_TTL_MS) { + return cachedAccounts.accounts; + } + // A failed read is not remembered, so a `gh auth login` in another terminal + // takes effect on the next request rather than at the end of the window. + if (accountsInFlight === null) { + accountsInFlight = (async () => { + try { + const accounts = await readGithubAccounts(); + cachedAccounts = { accounts, storedAt: Date.now() }; + return accounts; + } finally { + accountsInFlight = null; + } + })(); + } + return accountsInFlight; +} + export function ghProcessEnv(hostname: string | null): NodeJS.ProcessEnv { return hostname === null ? process.env : { ...process.env, GH_HOST: hostname }; } diff --git a/shared/board.ts b/shared/board.ts index e3ae442..20b5f4e 100644 --- a/shared/board.ts +++ b/shared/board.ts @@ -64,6 +64,12 @@ export const BoardItemSchema = z.object({ url: z.string(), /** `owner/name`, the only repository form the board displays. */ repository: z.string(), + /** + * The gh host this item came from, e.g. `github.com` or `ghe.example.com`. + * Carried rather than parsed off `url` by each consumer, because the board + * groups by it and every row would otherwise rebuild the same URL. + */ + host: z.string(), updatedAt: z.string(), /** When it was opened, for the "Recently created" ordering. */ createdAt: z.string(), @@ -181,6 +187,13 @@ export const BoardSchema = z.object({ */ repositoryProjects: z.record(z.string(), z.string()), fetchedAt: z.string(), + /** + * True when these columns are a remembered answer the daemon is already + * refreshing behind this response. The board paints them right away and + * asks again shortly after, rather than holding an empty surface for the + * length of a GitHub sweep. + */ + stale: z.boolean(), }); export type ProjectRef = z.output; From c33ed867e6df742b0133a5ef66eec84ffb629a7a Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:24 -0700 Subject: [PATCH 08/12] fix: stop showing a board minutes behind GitHub - the board ttl drops to one minute, a sweep with a failed column is not cached, and the client refetches a stale answer --- client/board/use-board-query.tsx | 10 ++++++++++ server/board/cache.ts | 12 ++++++++---- server/cache/cache.test.ts | 16 ++++++++++++++++ server/cache/cache.ts | 7 +++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/client/board/use-board-query.tsx b/client/board/use-board-query.tsx index 65086dc..9e0ab19 100644 --- a/client/board/use-board-query.tsx +++ b/client/board/use-board-query.tsx @@ -73,6 +73,16 @@ export function useBoardQuery( : load({ login: queryLogin, owners: [...watchedOwners], force }); }, staleTime: STALE_AFTER_MS, + /** + * Opening the surface, and coming back to the app, always ask the daemon — + * `staleTime` above only dedupes re-renders within one view. A board is + * wrong the moment a pull request is merged anywhere else, and the + * question is cheap: the daemon answers from its own cache immediately and + * decides for itself whether that cache is old enough to sweep GitHub + * again, so this costs a round trip to the daemon, not to GitHub. + */ + refetchOnMount: "always", + refetchOnWindowFocus: "always", // A key change (the owner sweep widens or narrows, or the login switches) // keeps the previous key's board on screen while the new one loads, // which is what the hand-written cache did by never changing identity. diff --git a/server/board/cache.ts b/server/board/cache.ts index 487a5fb..0bcff8a 100644 --- a/server/board/cache.ts +++ b/server/board/cache.ts @@ -2,11 +2,15 @@ import type { BoardColumn } from "../../shared/board"; import { Cache } from "../cache/cache"; /** - * A board costs three `gh` subprocesses and a round trip to GitHub, so one is - * reused for a short window — the surface remounts on every workspace switch - * and should not pay that each time. The Refresh button sends `force`. + * How old a cached board may be before the next request sweeps GitHub again. + * Nobody waits for that sweep — an expired board is served immediately and + * refreshed behind the surface — so this is only about how far behind GitHub + * the board is allowed to run, not about how fast it opens. A minute keeps a + * pull request merged elsewhere from lingering while still collapsing the + * burst of requests a workspace switch or a window focus produces. The + * Refresh button sends `force` and skips it entirely. */ -export const BOARD_TTL_MS = 5 * 60_000; +export const BOARD_TTL_MS = 60_000; export interface CachedBoard { columns: BoardColumn[]; diff --git a/server/cache/cache.test.ts b/server/cache/cache.test.ts index 8a2eeda..9c813aa 100644 --- a/server/cache/cache.test.ts +++ b/server/cache/cache.test.ts @@ -99,6 +99,22 @@ describe("Cache.read with revalidate", () => { }); }); + it("drops the expired entry when the refreshed answer may not be cached", async () => { + const cache = new Cache("revalidate-uncacheable"); + const load = vi.fn().mockResolvedValueOnce(1).mockResolvedValue(2); + await cache.read("k", 10_000, load); + + // The sweep runs but its answer is declined, so the entry behind it goes + // too: serving it again would pin the caller to an answer that only ages. + vi.setSystemTime(1_000_000 + 10_001); + const decline = { revalidate: true, shouldCache: () => false }; + expect(await cache.read("k", 10_000, load, decline)).toEqual({ value: 1, stale: true }); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + + // Never the remembered 1 again: the caller waits for a real answer. + expect(await cache.read("k", 10_000, load, decline)).toEqual({ value: 2, stale: false }); + }); + it("still waits for the first sweep when nothing is cached yet", async () => { const cache = new Cache("revalidate-cold"); const load = vi.fn().mockResolvedValue(5); diff --git a/server/cache/cache.ts b/server/cache/cache.ts index 889cdee..1c73af0 100644 --- a/server/cache/cache.ts +++ b/server/cache/cache.ts @@ -170,6 +170,13 @@ export class Cache { if (options?.shouldCache?.(value) !== false) { this.memory.set(key, { value, storedAt: Date.now() }); await this.persist(); + } else if (this.memory.delete(key)) { + // An answer that may not be remembered must not leave an older one + // behind either: with `revalidate` that entry would be served as + // stale on every request from here on, refreshed by a sweep whose + // result is always discarded. Dropping it makes the next request + // wait for a real answer instead of showing an ageing one forever. + await this.persist(); } return value; } finally { From 7ee6ebf37e35ad20b41e2ad9857aa1562d0e232a Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:32 -0700 Subject: [PATCH 09/12] feat(images): proxy attachments on known hosts - the allowlist names no host itself: github.com plus the hosts gh auth status reports, all under /user-attachments/ only - an enterprise attachment is fetched with that host's own token via gh auth token --hostname - the githubusercontent CDNs carry the github.com token, never an enterprise one, and the signed redirect leg carries no header at all - the host list rides the board answer, so the client knows which urls the daemon will fetch --- client/board/use-github-board.tsx | 9 +++ client/detail/remote-image.tsx | 18 ++++- server/board/handler.ts | 1 + server/images/images.test.ts | 16 +++++ server/images/images.ts | 106 +++++++++++++++++++----------- shared/board.ts | 7 ++ shared/image-host.test.ts | 18 ++++- shared/image-host.ts | 19 ++++-- 8 files changed, 148 insertions(+), 46 deletions(-) create mode 100644 server/images/images.test.ts diff --git a/client/board/use-github-board.tsx b/client/board/use-github-board.tsx index 138c8e8..e8e6b0d 100644 --- a/client/board/use-github-board.tsx +++ b/client/board/use-github-board.tsx @@ -1,5 +1,7 @@ import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { useEffect } from "react"; +import { rememberImageHosts } from "../detail/remote-image"; import type { Styles } from "../theme/use-styles"; import { useBoardFilters } from "./use-board-filters"; import { useBoardOverlays } from "./use-board-overlays"; @@ -28,6 +30,13 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) { ); const filters = useBoardFilters(query.board, settings.display); const chatLinks = useChatLinks(props.host.id); + // The image-host list rides the board: the daemon names the hosts it will + // fetch attachments for, and the detail panel's images ask for exactly + // those. Recorded rather than threaded, so a fetched image keeps its host + // list across the remounts the surface takes on every workspace switch. + useEffect(() => { + if (query.board !== null) rememberImageHosts(query.board.imageHosts); + }, [query.board]); const overlays = useBoardOverlays(props, styles, { board: query.board, promptValues: settings.promptValues, diff --git a/client/detail/remote-image.tsx b/client/detail/remote-image.tsx index 5acdbb9..d726cc0 100644 --- a/client/detail/remote-image.tsx +++ b/client/detail/remote-image.tsx @@ -7,6 +7,22 @@ import { isGitHubImageHost } from "../../shared/image-host"; import type { Styles } from "../theme/use-styles"; import { openExternalUrl } from "../web"; +/** + * Every GitHub host the daemon is authenticated on, as the last board answer + * reported. The daemon decides which hosts it fetches images for; the client + * only needs the same list to know which URLs to ask it about. Kept at + * module scope because a fetched image outlives a remount, and a + * not-yet-fetched one should not flip to "unfetchable" on a workspace switch + * either. + */ +const imageHosts: string[] = []; + +/** The board records the hosts its answer was swept from, once per load. */ +export function rememberImageHosts(hosts: readonly string[]): void { + imageHosts.length = 0; + imageHosts.push(...hosts); +} + /** * One image on its own line of Markdown. A GitHub-hosted one goes through * `board.image` — a private repository's attachments answer 404 to the app, @@ -34,7 +50,7 @@ export function RemoteImage({ accentColor: string; }) { const fetchImage = useRpc(loadImage); - const isGitHubHosted = isGitHubImageHost(url); + const isGitHubHosted = isGitHubImageHost(url, imageHosts); /** * An image at a URL never changes underneath it, so once fetched and * measured it is cached for good (`staleTime: Infinity`) rather than on the diff --git a/server/board/handler.ts b/server/board/handler.ts index e094b8c..f99a0b9 100644 --- a/server/board/handler.ts +++ b/server/board/handler.ts @@ -155,6 +155,7 @@ export async function loadBoardHandler( return { login: accounts[0]?.login ?? "", viewerLogins: accounts.map((account) => account.login), + imageHosts: accounts.map((account) => account.hostname), ...(await describeRepositoryProjects(paseo, columns)), columns, fetchedAt, diff --git a/server/images/images.test.ts b/server/images/images.test.ts new file mode 100644 index 0000000..c4a6b76 --- /dev/null +++ b/server/images/images.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { tokenHostname } from "./images"; + +describe("tokenHostname", () => { + it("sends the github.com token to the githubusercontent CDNs", () => { + // gh is never logged in to a CDN hostname, so asking it for one would + // fail and take the image with it. + expect(tokenHostname("private-user-images.githubusercontent.com")).toBe("github.com"); + expect(tokenHostname("raw.githubusercontent.com")).toBe("github.com"); + }); + + it("keeps every other host's token on that host", () => { + expect(tokenHostname("github.com")).toBe("github.com"); + expect(tokenHostname("ghe.example.com")).toBe("ghe.example.com"); + }); +}); diff --git a/server/images/images.ts b/server/images/images.ts index ef220ea..1fd8528 100644 --- a/server/images/images.ts +++ b/server/images/images.ts @@ -2,72 +2,98 @@ import type { z } from "zod"; import type { loadImage } from "../../shared/board"; import { isGitHubImageHost } from "../../shared/image-host"; import { gh } from "../github/gh"; +import { listGithubAccounts } from "../github/host"; /** * An image out of a comment, fetched here because the app cannot: a * `github.com/user-attachments/assets/…` URL on a private repository answers - * 404 to anyone without the token, and with it answers a 302 to a signed S3 + * 404 to anyone without the token and, with it, answers a 302 to a signed * URL good for five minutes. The redirect is followed by hand * (`fetchFollowingRedirects` below) rather than left to `fetch`'s own - * `redirect: "follow"`: the Authorization header must never reach that S3 - * leg, and asserting that here is safer than trusting undici to keep + * `redirect: "follow"`: the Authorization header must never reach that + * signed leg — a presigned URL answers 400 to a request that also carries + * one — and asserting that here is safer than trusting undici to keep * dropping it across origins forever. * - * Only GitHub hosts, and only the `/user-attachments/` path this proxy - * exists to serve, checked again here rather than trusted from the client, - * because this is the daemon fetching a URL that a comment's author chose. + * Only GitHub image hosts — `github.com`, and any host `gh` is authenticated + * on, both narrowed to the `/user-attachments/` path this proxy exists to + * serve — checked again here rather than trusted from the client, because + * this is the daemon fetching a URL that a comment's author chose. An + * Enterprise attachment is fetched with that host's own token, which + * `gh auth token --hostname` answers. + * + * One cross-host case is accepted: a comment on one authenticated host can + * make the daemon send an authenticated request to `/user-attachments/` on + * another authenticated host. The bytes come back only to this user's + * client, so the worst a comment author achieves is loading an image the + * reader could have loaded anyway. */ const IMAGE_MAX_BYTES = 4 * 1024 * 1024; const IMAGE_CACHE_ENTRIES = 24; const MAX_IMAGE_REDIRECTS = 5; /** - * `gh auth token`, remembered for five minutes so opening several images in - * a row does not shell out to `gh` for each one. Kept in a plain - * module-local variable rather than the disk-backed `Cache` every other - * feature in this plugin uses: unlike every other cached answer, this one - * *is* the credential, and persisting it would write the account's GitHub - * token to a file on every daemon restart — a strict downgrade from `gh`'s - * own 0600 `hosts.yml`. A five-minute memo needs no file to unlink either, - * because nothing here ever reaches disk. + * `gh auth token`, remembered for five minutes per host so opening several + * images in a row does not shell out to `gh` for each one. Kept in plain + * module-local maps rather than the disk-backed `Cache` every other feature + * in this plugin uses: unlike every other cached answer, this one *is* the + * credential, and persisting it would write the account's GitHub token to a + * file on every daemon restart — a strict downgrade from `gh`'s own 0600 + * `hosts.yml`. A five-minute memo needs no file to unlink either, because + * nothing here ever reaches disk. */ const TOKEN_TTL_MS = 5 * 60_000; -let cachedToken: { value: string; storedAt: number } | null = null; -let tokenInFlight: Promise | null = null; +const cachedTokens = new Map(); +const tokensInFlight = new Map>(); -async function ghToken(): Promise { - if (cachedToken !== null && Date.now() - cachedToken.storedAt < TOKEN_TTL_MS) { - return cachedToken.value; +async function ghToken(hostname: string): Promise { + const hit = cachedTokens.get(hostname); + if (hit !== undefined && Date.now() - hit.storedAt < TOKEN_TTL_MS) { + return hit.value; } - if (tokenInFlight !== null) return tokenInFlight; - tokenInFlight = (async () => { + const running = tokensInFlight.get(hostname); + if (running !== undefined) return running; + const promise = (async () => { try { - const token = (await gh(["auth", "token"])).trim(); - if (token === "") throw new Error("GitHub CLI has no token for this account."); - cachedToken = { value: token, storedAt: Date.now() }; + const token = (await gh(["auth", "token", "--hostname", hostname])).trim(); + if (token === "") throw new Error(`GitHub CLI has no token for ${hostname}.`); + cachedTokens.set(hostname, { value: token, storedAt: Date.now() }); return token; } finally { - tokenInFlight = null; + tokensInFlight.delete(hostname); } })(); - return tokenInFlight; + tokensInFlight.set(hostname, promise); + return promise; +} + +/** + * The host whose token a redirect hop carries: the githubusercontent CDNs + * answer to the github.com token — they are github.com's own image hosting, + * not hosts gh itself is logged in to — and every other allowed host answers + * to its own. Any host that is neither is refused before a hop is fetched, + * so it never reaches this mapping; asking `gh auth token` for a CDN + * hostname would fail, which is what this function exists to prevent. + */ +export function tokenHostname(hostname: string): string { + return hostname.endsWith(".githubusercontent.com") ? "github.com" : hostname; } /** * Follows a redirect chain one hop at a time instead of handing `redirect: - * "follow"` to `fetch`: the Authorization header is attached only while the - * next hop is still an allowed GitHub image host, so the signed S3 URL a - * `user-attachments` redirect resolves to is fetched without it — asserted - * here rather than relied on as `fetch`'s cross-origin behaviour. + * "follow"` to `fetch`: the Authorization header a hop carries is always the + * token of the host that hop belongs to, so one host's credential is never + * sent to another, and the signed URL a `user-attachments` redirect resolves + * to is fetched without any header at all. */ -async function fetchFollowingRedirects(url: string): Promise { +async function fetchFollowingRedirects(url: string, hosts: readonly string[]): Promise { let target = url; for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) { - const carryToken = isGitHubImageHost(target); - const response = await fetch(target, { - headers: carryToken ? { Authorization: `token ${await ghToken()}` } : {}, - redirect: "manual", - }); + const carryToken = isGitHubImageHost(target, hosts); + const headers = carryToken + ? { Authorization: `token ${await ghToken(tokenHostname(new URL(target).hostname))}` } + : {}; + const response = await fetch(target, { headers, redirect: "manual" }); if (response.status < 300 || response.status >= 400) return response; const location = response.headers.get("location"); if (location === null) return response; @@ -77,10 +103,12 @@ async function fetchFollowingRedirects(url: string): Promise { } async function fetchImage(url: string): Promise { - if (!isGitHubImageHost(url)) { - throw new Error("Only images hosted on GitHub are fetched through the daemon."); + const accounts = await listGithubAccounts(); + const hosts = accounts.map((account) => account.hostname); + if (!isGitHubImageHost(url, hosts)) { + throw new Error("Only images from GitHub or an authenticated host are fetched through the daemon."); } - const response = await fetchFollowingRedirects(url); + const response = await fetchFollowingRedirects(url, hosts); if (!response.ok) { throw new Error(`GitHub answered ${response.status} for this image.`); } diff --git a/shared/board.ts b/shared/board.ts index 20b5f4e..85b6ddf 100644 --- a/shared/board.ts +++ b/shared/board.ts @@ -187,6 +187,13 @@ export const BoardSchema = z.object({ */ repositoryProjects: z.record(z.string(), z.string()), fetchedAt: z.string(), + /** + * Every GitHub host `gh` is authenticated on — `github.com` and any + * GitHub Enterprise hosts — so the client can tell an attachment the + * daemon will fetch from an image it refuses. The daemon derives the + * list; nothing in the plugin names a host itself. + */ + imageHosts: z.array(z.string()), /** * True when these columns are a remembered answer the daemon is already * refreshing behind this response. The board paints them right away and diff --git a/shared/image-host.test.ts b/shared/image-host.test.ts index babc2ad..452e1c8 100644 --- a/shared/image-host.test.ts +++ b/shared/image-host.test.ts @@ -10,6 +10,20 @@ describe("isGitHubImageHost", () => { expect(isGitHubImageHost("https://raw.githubusercontent.com/owner/repo/main/img.png")).toBe(true); }); + it("accepts an authenticated GitHub Enterprise host under user-attachments", () => { + expect( + isGitHubImageHost("https://ghe.example.com/user-attachments/assets/abc-123", [ + "ghe.example.com", + ]), + ).toBe(true); + }); + + it("rejects an enterprise host the account is not authenticated on", () => { + expect( + isGitHubImageHost("https://ghe.example.com/user-attachments/assets/abc-123", []), + ).toBe(false); + }); + it("rejects a github.com path outside user-attachments", () => { expect(isGitHubImageHost("https://github.com/owner/repo/settings")).toBe(false); }); @@ -23,6 +37,8 @@ describe("isGitHubImageHost", () => { }); it("rejects a URL carrying credentials", () => { - expect(isGitHubImageHost("https://user:pass@github.com/user-attachments/assets/abc")).toBe(false); + expect(isGitHubImageHost("https://user:pass@github.com/user-attachments/assets/abc")).toBe( + false, + ); }); }); diff --git a/shared/image-host.ts b/shared/image-host.ts index 316afd1..1f525a8 100644 --- a/shared/image-host.ts +++ b/shared/image-host.ts @@ -14,11 +14,20 @@ * exists to serve: the host check alone would let a comment's author pick * any `github.com` path and have the daemon fetch it carrying the account's * token, which is a forced authenticated request nobody asked the daemon to - * make. `*.githubusercontent.com` needs no such narrowing — it never receives - * the token (see `server/images/images.ts`'s redirect handling) and is where + * make. `*.githubusercontent.com` needs no such narrowing — it is where * every other GitHub-hosted image, including raw content, actually lives. + * The token one of its hops may carry is the github.com one, decided in + * `server/images/images.ts`: the CDNs are github.com's own hosting, not + * hosts `gh` is logged in to, and an Enterprise host's token never leaves + * that host. + * + * A GitHub Enterprise host serves the same `/user-attachments/` paths, so it + * is accepted under the same prefix — but only a host the account is actually + * authenticated on, because that is the set the daemon holds a token for. The + * caller supplies it; nothing here names any host itself, which is what keeps + * the daemon's token inside the hosts the user chose to log in to. */ -export function isGitHubImageHost(url: string): boolean { +export function isGitHubImageHost(url: string, authenticatedHosts: readonly string[] = []): boolean { // Parsed, never matched. A regex over the raw string decides on different // text than `fetch` does: WHATWG ends the host at a backslash too, so // `https://evil.example\.githubusercontent.com/a.png` reads as the @@ -37,6 +46,6 @@ export function isGitHubImageHost(url: string): boolean { if (host.username !== "" || host.password !== "") return false; const name = host.hostname.toLowerCase(); if (name.endsWith(".githubusercontent.com")) return true; - return name === "github.com" && host.pathname.startsWith("/user-attachments/"); + const isKnownHost = name === "github.com" || authenticatedHosts.includes(name); + return isKnownHost && host.pathname.startsWith("/user-attachments/"); } - From 4cdfb05962c846e9560b32a0b3bdf6e194d68f1b Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:50:10 -0700 Subject: [PATCH 10/12] docs: describe the multi-host board and its fixes - changelog entries under unreleased for the ported features and fixes - readme gains the host, chat and freshness paragraphs, and the image limitation names the hosts the daemon will fetch for --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 26 +++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73eca1d..22611bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ pin and a line to read before you move. ## [Unreleased] +### Added + +- **Every authenticated GitHub host is now swept together.** The hosts `gh auth status` reports are + all queried at once, each with its own account, so one board can show github.com and GitHub + Enterprise work side by side. Reads and actions stay routed to the host that owns the item, and + with more than one host on the board the list is divided into one section per host rather than + interleaving them. An Enterprise repository's screenshots come through the daemon too, fetched + with that host's own token; hosts neither GitHub nor your logins know are still never fetched. +- **Open the original chat from a pull request.** A pull request that a Paseo workspace tracks is + linked to the first agent created in that workspace — the originating conversation — and a new + action on the row and in the panel jumps straight to that chat. +- **The board is painted before GitHub answers.** A board the daemon already has is shown at once + and refreshed behind it, and the header says how old the board on screen is. + +### Fixed + +- **The search limit is now spent per relation, not as one shared budget.** A full review queue + used to take the whole list and push the viewer's own work off the board. Pull request checks + are also asked for in batches of 100 ids, an expired login on one host no longer blanks the + board for the hosts that are fine, and the board no longer runs minutes behind GitHub: the + daemon re-sweeps once its board is more than a minute old, and a sweep in which a column failed + is not remembered. + ## [1.0.1] — 2026-09-12 ### Changed diff --git a/README.md b/README.md index a627008..e6fe6f1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,21 @@ GitHub inside [Paseo](https://paseo.sh): the issues and pull requests you are at waiting on your review, your Projects boards, and the review actions that finish a pull request — without leaving the app, and with one click to hand any of them to a coding agent. +The board also links a pull request back to its original Paseo chat. It matches the PR tracked +by each active or retained workspace to the first agent created there, then adds an **Open original +chat** action to the PR row and detail panel. + +Every authenticated host reported by `gh auth status --json hosts` is queried together. One board +can therefore show github.com and GitHub Enterprise work at the same time, using each host's active +account and keeping reads and actions routed to the host that owns the item. With more than one host +on the board, the list is divided into a section per host rather than interleaving them. + +Opening the surface does not wait on GitHub: a board the daemon already has is painted immediately +and refreshed behind it, and the refreshed one replaces it as soon as the sweep lands. Every open, +and every return to the app, asks the daemon; the daemon sweeps GitHub again when its own board is +more than a minute old, so a pull request merged elsewhere leaves the list within seconds of the +board being opened. The header says how old the board on screen is. + ![The GitHub surface: the Pull requests / Issues / Discussions / Projects switcher, the relation chips with their counts, the owner and repository pickers, the search box, and the list of pull requests.](images/board.png) @@ -34,6 +49,9 @@ method the repository allows, edit labels, open on GitHub — or **send to chat* Paseo agent with a prompt template of your choosing, in a workspace of your choosing, with the issue or pull request pinned to the top of the conversation. +For pull requests associated with a Paseo workspace, **Open original chat** jumps directly to the +first agent in that workspace. + ![One pull request open in the detail panel: title, author, branches, checks, the Approve, Merge, Send to chat and Open on GitHub actions, and the rendered body.](images/detail-panel.png) @@ -86,9 +104,11 @@ inputs.paseo-github.url = "github:alysnnix/paseo-github-integration"; - **GitHub search backs the sweep**, so its rules apply: results are capped per query, and a watched owner with thousands of open items shows the most recently updated slice rather than all of them. -- **One GitHub account at a time**, the one `gh` is authenticated as. There is no account switcher. -- **Images hosted outside GitHub are not loaded**, deliberately: they would report your IP address - to whoever wrote the comment. They render as a link you can open yourself. +- **One account per host**, the one `gh` is authenticated as there. Every authenticated host is + swept together, but there is no account switcher within a host. +- **Images from hosts neither GitHub nor your `gh` logins know are not loaded**, deliberately: + they would report your IP address to whoever wrote the comment. They render as a link you can + open yourself. ## Trust From 5b6e330ed085e933d6a89a842486367ee9515bd3 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:33:55 -0700 Subject: [PATCH 11/12] fix(github): keep the host override to GH_HOST - the settings-file hostname key was read but never written or documented, and the next settings save silently erased it - GH_HOST in the daemon environment is the documented override, and the sweep itself sets each host's context --- server/github/host.ts | 33 ++++++--------------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/server/github/host.ts b/server/github/host.ts index 24e409c..cbea3c1 100644 --- a/server/github/host.ts +++ b/server/github/host.ts @@ -1,8 +1,5 @@ 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); @@ -13,36 +10,18 @@ export interface GithubAccount { login: string; } -function paseoHome(): string { - return process.env.PASEO_HOME ?? join(homedir(), ".paseo"); -} - -function hostnameSettingsPaths(): string[] { - const home = paseoHome(); - return [ - join(home, "plugins", "github-integration", "settings.json"), - join(home, "plugins", "github-board", "settings.json"), - ]; -} - -/** Resolve the GitHub host from the daemon environment or saved plugin settings. */ +/** + * Resolve the GitHub host for a gh call outside any item's context: the + * daemon environment alone. A per-item host is set by `withGithubHostname`, + * and the accounts sweep sets one per host itself, so this is only the + * fallback for calls that belong to no particular host. + */ 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; - - 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; } From 1e02ce174a385c4caf3fdf01053729f4191a2eb8 Mon Sep 17 00:00:00 2001 From: tensorcopy <10591164+tensorcopy@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:33:55 -0700 Subject: [PATCH 12/12] docs(images): say which hop carries which token - a refused hop carries no header; a githubusercontent hop carries the github.com token, those CDNs being github.com's own image hosting --- server/images/images.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/images/images.ts b/server/images/images.ts index 1fd8528..e6b8199 100644 --- a/server/images/images.ts +++ b/server/images/images.ts @@ -83,8 +83,10 @@ export function tokenHostname(hostname: string): string { * Follows a redirect chain one hop at a time instead of handing `redirect: * "follow"` to `fetch`: the Authorization header a hop carries is always the * token of the host that hop belongs to, so one host's credential is never - * sent to another, and the signed URL a `user-attachments` redirect resolves - * to is fetched without any header at all. + * sent to another. A hop the allowlist refuses — the signed S3 URL a + * `user-attachments` redirect resolves to, say — is fetched with no header at + * all; a githubusercontent hop carries the github.com token, those CDNs being + * github.com's own image hosting. */ async function fetchFollowingRedirects(url: string, hosts: readonly string[]): Promise { let target = url;