diff --git a/desktop/playwright.b3-pr-capture.config.ts b/desktop/playwright.b3-pr-capture.config.ts new file mode 100644 index 0000000000..8dcc374d69 --- /dev/null +++ b/desktop/playwright.b3-pr-capture.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + outputDir: "test-results-b3-pr-demo", + timeout: 120_000, + retries: 0, + workers: 1, + reporter: [["list"]], + use: { + baseURL: "http://127.0.0.1:4173", + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 }, + video: { mode: "on", size: { width: 1440, height: 900 } }, + screenshot: "on", + trace: "off", + }, + projects: [ + { + name: "relay-b3-pr-capture", + testMatch: "**/workstream-board-b3-pr.capture.spec.ts", + }, + { + name: "canvas-bridge-regression", + testMatch: "**/get-canvas-bridge.spec.ts", + }, + ], + webServer: { + command: "python3 -m http.server 4173 -d dist", + cwd: ".", + reuseExistingServer: false, + url: "http://127.0.0.1:4173", + }, +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs new file mode 100644 index 0000000000..ec8c6e2f6f --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +import { + createWorkstreamPollingRegistry, + fetchBuzzPullRequestStatus, + fetchGithubPullRequestStatus, + parseWorkstreamPullRequestReference, + parseWorkstreamPullRequestReferences, +} from "./workstreamPullRequestStatus.ts"; + +const OWNER = "a".repeat(64); +const EVENT = "b".repeat(64); +const buzz = `buzz://pr?id=${EVENT}&owner=${OWNER}&d=buzz`; + +function response(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +test("parses canonical Buzz, GitHub, malformed, and unsupported references", () => { + const refs = parseWorkstreamPullRequestReferences([ + buzz, + { url: "https://github.com/Block/Buzz/pull/42", label: "labeled PR" }, + "https://github.com/Block/Buzz/pull/42", + "https://github.com/block/buzz/issues/42", + "https://example.com/pr/1", + "not a url", + null, + ]); + assert.equal(refs[0].kind, "buzz"); + assert.equal(refs[0].identity, `buzz:${OWNER}:buzz:${EVENT}`); + assert.equal(refs[1].kind, "github"); + assert.equal(refs[1].href, "https://github.com/block/buzz/pull/42"); + assert.equal(refs[2].kind, "github"); + assert.equal(refs[3].kind, "unsupported"); + assert.equal(refs[3].reason, "malformed"); + assert.equal(refs[4].reason, "unsupported-provider"); + assert.equal(refs[5].reason, "malformed"); + assert.equal(refs[6].reason, "invalid-field"); +}); + +test("GitHub status reports lifecycle and latest review state", async () => { + const ref = parseWorkstreamPullRequestReference( + "https://github.com/block/buzz/pull/42", + ); + assert.equal(ref.kind, "github"); + const result = await fetchGithubPullRequestStatus(ref, { + fetcher: async (url) => + String(url).endsWith("/reviews?per_page=100") + ? response([ + { + user: { login: "reviewer" }, + state: "APPROVED", + submitted_at: "2026-08-18T00:00:00Z", + }, + ]) + : response({ + number: 42, + title: "Board PR", + state: "closed", + draft: false, + merged_at: "2026-08-18T01:00:00Z", + requested_reviewers: [], + }), + }); + assert.deepEqual(result, { + status: "live", + provider: "github", + href: "https://github.com/block/buzz/pull/42", + repository: "block/buzz", + number: 42, + title: "Board PR", + lifecycle: "merged", + reviewState: "approved", + }); +}); + +test("Buzz status consumes root and existing NIP-34 status events", async () => { + const ref = parseWorkstreamPullRequestReference(buzz); + assert.equal(ref.kind, "buzz"); + const root = { + id: EVENT, + pubkey: OWNER, + kind: 1618, + created_at: 10, + content: "Wire board", + tags: [ + ["a", `30617:${OWNER}:buzz`], + ["d", "pr-1"], + ["subject", "Wire board"], + ["p", "c".repeat(64)], + ], + }; + const status = { + id: "c".repeat(64), + pubkey: OWNER, + kind: 1631, + created_at: 20, + content: "", + tags: [ + ["e", EVENT], + ["a", `30617:${OWNER}:buzz`], + ], + }; + const result = await fetchBuzzPullRequestStatus(ref, async (filter) => + filter.kinds[0] === 1618 ? [root] : [status], + ); + assert.equal(result.status, "live"); + assert.equal(result.lifecycle, "merged"); + assert.equal(result.title, "Wire board"); + assert.equal(result.reviewState, "review-requested"); +}); + +test("polling deduplicates canonical identities and tears down", async () => { + let requests = 0; + let aborted = false; + let resolveRequest; + const pending = new Promise((resolve) => { + resolveRequest = resolve; + }); + const reference = parseWorkstreamPullRequestReference( + "https://github.com/block/buzz/pull/42", + ); + assert.equal(reference.kind, "github"); + const registry = createWorkstreamPollingRegistry({ + fetcher: async (_url, init) => { + requests += 1; + init.signal.addEventListener("abort", () => { + aborted = true; + }); + await pending; + return response({ + number: 42, + title: "Board", + state: "open", + draft: false, + }); + }, + intervalMs: 1000, + }); + const listener = () => {}; + const unsubscribeOne = registry.subscribe(reference, listener); + const unsubscribeTwo = registry.subscribe(reference, () => {}); + assert.equal(registry.entryCount(), 1); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(requests, 2); + unsubscribeOne(); + assert.equal(registry.entryCount(), 1); + unsubscribeTwo(); + assert.equal(registry.entryCount(), 0); + assert.equal(aborted, true); + resolveRequest(); +}); + +test("workstream status hook exposes loading before a supported PR resolves", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useWorkstreamPullRequestStatuses } = await import( + "./workstreamPullRequestStatus.ts" + ); + const reference = parseWorkstreamPullRequestReference( + "https://github.com/block/buzz/pull/42", + ); + const registry = createWorkstreamPollingRegistry({ + fetcher: () => new Promise(() => {}), + }); + const references = [reference]; + const { result, unmount } = renderHook(() => + useWorkstreamPullRequestStatuses(references, registry), + ); + assert.deepEqual(result.current.get(reference.identity), { + status: "loading", + }); + unmount(); + cleanup(); +}); + +test("workstream status hook preserves a synchronous live callback", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useWorkstreamPullRequestStatuses } = await import( + "./workstreamPullRequestStatus.ts" + ); + const reference = parseWorkstreamPullRequestReference( + "https://github.com/block/buzz/pull/42", + ); + const registry = createWorkstreamPollingRegistry({ + fetcher: async (url) => + String(url).endsWith("/reviews?per_page=100") + ? response([]) + : response({ number: 42, title: "Board", state: "open", draft: false }), + maxPolls: 1, + }); + const prime = registry.subscribe(reference, () => {}); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const references = [reference]; + const { result, unmount } = renderHook(() => + useWorkstreamPullRequestStatuses(references, registry), + ); + await act(async () => { + await Promise.resolve(); + }); + assert.deepEqual(result.current.get(reference.identity), { + status: "live", + provider: "github", + href: "https://github.com/block/buzz/pull/42", + repository: "block/buzz", + number: 42, + title: "Board", + lifecycle: "open", + reviewState: "no-reviews", + }); + unmount(); + prime(); + cleanup(); +}); + +test("workstream status hook removes stale references and preserves unsupported state", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useWorkstreamPullRequestStatuses } = await import( + "./workstreamPullRequestStatus.ts" + ); + const supported = parseWorkstreamPullRequestReference( + "https://github.com/block/buzz/pull/42", + ); + const unsupported = parseWorkstreamPullRequestReference( + "https://example.com/pr/1", + ); + const registry = createWorkstreamPollingRegistry({ + fetcher: () => new Promise(() => {}), + }); + const { result, rerender, unmount } = renderHook( + ({ refs }) => useWorkstreamPullRequestStatuses(refs, registry), + { initialProps: { refs: [supported, unsupported] } }, + ); + assert.deepEqual( + result.current.get(unsupported.identity)?.status, + "unsupported", + ); + await act(async () => { + rerender({ refs: [unsupported] }); + }); + assert.equal(result.current.has(supported.identity), false); + assert.deepEqual( + result.current.get(unsupported.identity)?.status, + "unsupported", + ); + unmount(); + cleanup(); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts new file mode 100644 index 0000000000..ed5e66b7a3 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts @@ -0,0 +1,568 @@ +import * as React from "react"; + +import { eventToProjectPullRequest } from "@/features/projects/projectPullRequests.mjs"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, +} from "@/shared/constants/kinds"; +import { buildPullRequestLink, parseEntityLink } from "@/shared/lib/entityLink"; + +export type WorkstreamPullRequestReference = + | { + kind: "buzz"; + rawUrl: string; + href: string; + identity: string; + repoAddress: string; + eventId: string; + repository: string; + } + | { + kind: "github"; + rawUrl: string; + href: string; + identity: string; + owner: string; + repository: string; + number: number; + } + | { + kind: "unsupported"; + rawUrl: string; + href: string; + identity: string; + reason: "malformed" | "unsupported-provider" | "invalid-field"; + }; + +export type WorkstreamReviewState = + | "approved" + | "changes-requested" + | "review-requested" + | "no-reviews"; + +export type WorkstreamPullRequestStatus = { + status: "live"; + provider: "buzz" | "github"; + href: string; + repository: string; + number: number | null; + title: string; + lifecycle: "draft" | "open" | "merged" | "closed"; + reviewState: WorkstreamReviewState; +}; + +export type WorkstreamPullRequestUnknown = { + status: "unknown"; + href: string; + reason: "not-found"; +}; + +export type WorkstreamPullRequestDisplayState = + | { status: "loading" } + | { status: "error"; href: string } + | { + status: "unsupported"; + href: string; + reason: "malformed" | "unsupported-provider" | "invalid-field"; + } + | WorkstreamPullRequestStatus + | WorkstreamPullRequestUnknown; + +const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]); +const GITHUB_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/i; +const MAX_GITHUB_POLLS = 10; +const DEFAULT_GITHUB_POLL_INTERVAL_MS = 30_000; +const DEFAULT_GITHUB_POLL_LIFETIME_MS = 5 * 60_000; +const NIP34_STATUS_KINDS = [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]; + +function normalizeRawUrl(value: unknown): string { + if (typeof value === "string") return value.trim(); + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const url = (value as { url?: unknown }).url; + if (typeof url === "string") return url.trim(); + } + return "[invalid reference]"; +} + +function unsupportedReference( + rawUrl: string, + reason: Extract< + WorkstreamPullRequestReference, + { kind: "unsupported" } + >["reason"], +): WorkstreamPullRequestReference { + return { + kind: "unsupported", + rawUrl, + href: rawUrl, + identity: `unsupported:${reason}:${rawUrl}`, + reason, + }; +} + +/** Parse a single v1 canvas PR reference without throwing. */ +export function parseWorkstreamPullRequestReference( + value: unknown, +): WorkstreamPullRequestReference { + const rawUrl = normalizeRawUrl(value); + if (!rawUrl || rawUrl === "[invalid reference]") { + return unsupportedReference(rawUrl, "invalid-field"); + } + + if (rawUrl.startsWith("buzz://")) { + const parsed = parseEntityLink(rawUrl); + if (!parsed.ok || parsed.value.type !== "pr") { + return unsupportedReference(rawUrl, "malformed"); + } + + const { id, owner, dtag } = parsed.value; + return { + kind: "buzz", + rawUrl, + href: buildPullRequestLink({ dtag, id, owner }), + identity: `buzz:${owner.toLowerCase()}:${dtag}:${id.toLowerCase()}`, + repoAddress: `30617:${owner.toLowerCase()}:${dtag}`, + eventId: id.toLowerCase(), + repository: dtag, + }; + } + + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return unsupportedReference(rawUrl, "malformed"); + } + + if (!GITHUB_HOSTS.has(parsed.hostname.toLowerCase())) { + return unsupportedReference(rawUrl, "unsupported-provider"); + } + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.protocol !== "https:" + ) { + return unsupportedReference(rawUrl, "malformed"); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + if ( + segments.length !== 4 || + segments[2].toLowerCase() !== "pull" || + !/^\d+$/.test(segments[3]) + ) { + return unsupportedReference(rawUrl, "malformed"); + } + + const number = Number(segments[3]); + if ( + !Number.isSafeInteger(number) || + number < 1 || + !GITHUB_URL_RE.test(rawUrl) + ) { + return unsupportedReference(rawUrl, "malformed"); + } + + const owner = segments[0].toLowerCase(); + const repository = segments[1].toLowerCase(); + const href = `https://github.com/${owner}/${repository}/pull/${number}`; + return { + kind: "github", + rawUrl, + href, + identity: `github:${owner}/${repository}#${number}`, + owner, + repository, + number, + }; +} + +export function parseWorkstreamPullRequestReferences( + values: readonly unknown[], +): WorkstreamPullRequestReference[] { + return values.map(parseWorkstreamPullRequestReference); +} + +type GithubResponse = { + state?: unknown; + draft?: unknown; + merged_at?: unknown; + number?: unknown; + title?: unknown; + requested_reviewers?: unknown; +}; + +type GithubReview = { + user?: { login?: unknown } | null; + state?: unknown; + submitted_at?: unknown; + updated_at?: unknown; +}; + +export type GithubFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +type EventFetcher = ( + filter: Parameters[0], +) => Promise; + +function isGithubReview(value: unknown): value is GithubReview { + return typeof value === "object" && value !== null; +} + +function reviewState( + reviews: unknown[], + requestedReviewers: unknown, +): WorkstreamReviewState { + const latestByReviewer = new Map(); + for (const review of reviews.filter(isGithubReview)) { + const login = + typeof review.user?.login === "string" ? review.user.login : null; + if (!login) continue; + const current = latestByReviewer.get(login.toLowerCase()); + const currentAt = String( + current?.submitted_at ?? current?.updated_at ?? "", + ); + const nextAt = String(review.submitted_at ?? review.updated_at ?? ""); + if (!current || nextAt >= currentAt) { + latestByReviewer.set(login.toLowerCase(), review); + } + } + + const latestStates = [...latestByReviewer.values()].map((review) => + typeof review.state === "string" ? review.state.toUpperCase() : "", + ); + if (latestStates.includes("CHANGES_REQUESTED")) return "changes-requested"; + if (latestStates.includes("APPROVED")) return "approved"; + if (Array.isArray(requestedReviewers) && requestedReviewers.length > 0) { + return "review-requested"; + } + return "no-reviews"; +} + +function lifecycleFromGithubResponse( + response: GithubResponse, +): WorkstreamPullRequestStatus["lifecycle"] { + if (typeof response.merged_at === "string" && response.merged_at) { + return "merged"; + } + if (response.draft === true) return "draft"; + return response.state === "closed" ? "closed" : "open"; +} + +async function fetchJson( + fetcher: GithubFetch, + url: string, + signal?: AbortSignal, +): Promise { + const response = await fetcher(url, { + headers: { Accept: "application/vnd.github+json" }, + signal, + }); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`GitHub metadata request failed (${response.status}).`); + } + return response.json(); +} + +/** Fetch bounded, unauthenticated GitHub PR metadata for one canonical PR. */ +export async function fetchGithubPullRequestStatus( + reference: Extract, + options: { fetcher?: GithubFetch; signal?: AbortSignal } = {}, +): Promise { + const fetcher = options.fetcher ?? fetch; + const base = `https://api.github.com/repos/${reference.owner}/${reference.repository}/pulls/${reference.number}`; + const [rawPullRequest, rawReviews] = await Promise.all([ + fetchJson(fetcher, base, options.signal), + fetchJson(fetcher, `${base}/reviews?per_page=100`, options.signal), + ]); + + if (rawPullRequest === null) { + return { status: "unknown", href: reference.href, reason: "not-found" }; + } + if ( + typeof rawPullRequest !== "object" || + rawPullRequest === null || + !Array.isArray(rawReviews) + ) { + throw new Error("GitHub metadata response was unavailable."); + } + + const response = rawPullRequest as GithubResponse; + if ( + typeof response.title !== "string" || + response.number !== reference.number + ) { + throw new Error("GitHub metadata response was invalid."); + } + + return { + status: "live", + provider: "github", + href: reference.href, + repository: `${reference.owner}/${reference.repository}`, + number: reference.number, + title: response.title, + lifecycle: lifecycleFromGithubResponse(response), + reviewState: reviewState(rawReviews, response.requested_reviewers), + }; +} + +/** Consume the existing NIP-34 root/status event streams for one Buzz PR. */ +export async function fetchBuzzPullRequestStatus( + reference: Extract, + fetchEvents: EventFetcher = (filter) => relayClient.fetchEvents(filter), +): Promise { + const roots = await fetchEvents({ + kinds: [KIND_GIT_PULL_REQUEST], + ids: [reference.eventId], + "#a": [reference.repoAddress], + limit: 1, + }); + const root = roots.find( + (event) => event.id.toLowerCase() === reference.eventId, + ); + if (!root) { + return { status: "unknown", href: reference.href, reason: "not-found" }; + } + + const [updates, comments, statuses] = await Promise.all([ + fetchEvents({ kinds: [1634], "#a": [reference.repoAddress], limit: 500 }), + fetchEvents({ kinds: [1], "#a": [reference.repoAddress], limit: 500 }), + fetchEvents({ + kinds: NIP34_STATUS_KINDS, + "#a": [reference.repoAddress], + limit: 500, + }), + ]); + const pullRequest = eventToProjectPullRequest( + root, + updates, + comments, + statuses, + ); + return { + status: "live", + provider: "buzz", + href: reference.href, + repository: reference.repository, + number: null, + title: pullRequest.title, + lifecycle: + pullRequest.status.toLowerCase() as WorkstreamPullRequestStatus["lifecycle"], + reviewState: + pullRequest.changeRequests.length > 0 + ? "changes-requested" + : pullRequest.approvals.length > 0 + ? "approved" + : pullRequest.reviewers.length > 0 + ? "review-requested" + : "no-reviews", + }; +} + +type PollerListener = (state: WorkstreamPullRequestDisplayState) => void; +type PollerReference = Exclude< + WorkstreamPullRequestReference, + { kind: "unsupported" } +>; +type PollerEntry = { + reference: PollerReference; + listeners: Set; + state: WorkstreamPullRequestDisplayState; + controller: AbortController | null; + timer: ReturnType | null; + startedAt: number; + pollCount: number; +}; + +export type WorkstreamPollingRegistryOptions = { + fetcher?: GithubFetch; + fetchEvents?: EventFetcher; + intervalMs?: number; + maxPolls?: number; + maxLifetimeMs?: number; + now?: () => number; + schedule?: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + cancel?: (timer: ReturnType) => void; +}; + +/** One bounded, route-scoped metadata stream per canonical PR identity. */ +export function createWorkstreamPollingRegistry( + options: WorkstreamPollingRegistryOptions = {}, +) { + const githubFetcher = options.fetcher ?? fetch; + const fetchEvents = + options.fetchEvents ?? ((filter) => relayClient.fetchEvents(filter)); + const intervalMs = options.intervalMs ?? DEFAULT_GITHUB_POLL_INTERVAL_MS; + const maxPolls = options.maxPolls ?? MAX_GITHUB_POLLS; + const maxLifetimeMs = + options.maxLifetimeMs ?? DEFAULT_GITHUB_POLL_LIFETIME_MS; + const now = options.now ?? (() => Date.now()); + const schedule = + options.schedule ?? ((callback, delay) => setTimeout(callback, delay)); + const cancel = options.cancel ?? ((timer) => clearTimeout(timer)); + const entries = new Map(); + + const stop = (entry: PollerEntry) => { + if (entry.timer !== null) cancel(entry.timer); + entry.timer = null; + entry.controller?.abort(); + entry.controller = null; + entries.delete(entry.reference.identity); + }; + + const notify = (entry: PollerEntry) => { + for (const listener of entry.listeners) listener(entry.state); + }; + + const fetchStatus = (entry: PollerEntry, signal: AbortSignal) => + entry.reference.kind === "github" + ? fetchGithubPullRequestStatus(entry.reference, { + fetcher: githubFetcher, + signal, + }) + : fetchBuzzPullRequestStatus(entry.reference, fetchEvents); + + const poll = async (entry: PollerEntry) => { + if (!entries.has(entry.reference.identity) || entry.listeners.size === 0) + return; + const controller = new AbortController(); + entry.controller = controller; + try { + entry.state = await fetchStatus(entry, controller.signal); + notify(entry); + } catch { + if (controller.signal.aborted && entry.listeners.size === 0) return; + entry.state = { status: "error", href: entry.reference.href }; + notify(entry); + } finally { + entry.controller = null; + entry.pollCount += 1; + const bounded = + entry.pollCount >= maxPolls || now() - entry.startedAt >= maxLifetimeMs; + if (entry.listeners.size > 0 && !bounded) { + entry.timer = schedule(() => { + entry.timer = null; + void poll(entry); + }, intervalMs); + } + } + }; + + const subscribe = (reference: PollerReference, listener: PollerListener) => { + let entry = entries.get(reference.identity); + if (!entry) { + entry = { + reference, + listeners: new Set(), + state: { status: "loading" }, + controller: null, + timer: null, + startedAt: now(), + pollCount: 0, + }; + entries.set(reference.identity, entry); + } + entry.listeners.add(listener); + if ( + entry.pollCount === 0 && + entry.controller === null && + entry.timer === null + ) { + void poll(entry); + } + listener(entry.state); + return () => { + if (!entry?.listeners.delete(listener)) return; + if (entry.listeners.size === 0) stop(entry); + }; + }; + + return { + subscribe, + entryCount: () => entries.size, + reset: () => { + for (const entry of [...entries.values()]) stop(entry); + }, + }; +} + +const defaultWorkstreamPollingRegistry = createWorkstreamPollingRegistry(); + +type WorkstreamPollingRegistry = Pick< + ReturnType, + "subscribe" +>; + +export function useWorkstreamPullRequestStatuses( + references: readonly WorkstreamPullRequestReference[], + pollingRegistry: WorkstreamPollingRegistry = defaultWorkstreamPollingRegistry, +): ReadonlyMap { + const [states, setStates] = React.useState< + ReadonlyMap + >(() => new Map()); + const referencesByIdentity = React.useMemo(() => { + const next = new Map(); + for (const reference of references) next.set(reference.identity, reference); + return next; + }, [references]); + + React.useEffect(() => { + const initial = new Map(); + for (const reference of referencesByIdentity.values()) { + initial.set( + reference.identity, + reference.kind === "unsupported" + ? { + status: "unsupported", + href: reference.href, + reason: reference.reason, + } + : { status: "loading" }, + ); + } + setStates(initial); + + const unsubscribers: Array<() => void> = []; + for (const reference of referencesByIdentity.values()) { + if (reference.kind === "unsupported") continue; + const unsubscribe = pollingRegistry.subscribe(reference, (state) => { + setStates((current) => { + const updated = new Map(current); + updated.set(reference.identity, state); + return updated; + }); + }); + unsubscribers.push(unsubscribe); + } + return () => { + for (const unsubscribe of unsubscribers) unsubscribe(); + }; + }, [pollingRegistry, referencesByIdentity]); + + return states; +} + +export const workstreamPollingLimits = { + intervalMs: DEFAULT_GITHUB_POLL_INTERVAL_MS, + maxLifetimeMs: DEFAULT_GITHUB_POLL_LIFETIME_MS, + maxPolls: MAX_GITHUB_POLLS, +}; diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx index 17af849991..3f297cab26 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -1,12 +1,15 @@ +import * as React from "react"; import { Hash } from "lucide-react"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { useCanvasQuery } from "@/features/channels/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { parseWorkstreamPullRequestReferences } from "@/features/workstream-board/lib/workstreamPullRequestStatus"; import { buildWorkstreamCardViewModel } from "@/features/workstream-board/lib/workstreamCardViewModel"; +import { WorkstreamPullRequests } from "@/features/workstream-board/ui/WorkstreamPullRequests"; +import { WorkstreamWorkingIndicator } from "@/features/workstream-board/ui/WorkstreamWorkingIndicator"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { WorkstreamWorkingIndicator } from "@/features/workstream-board/ui/WorkstreamWorkingIndicator"; type WorkstreamCardProps = { activeWorking?: ActiveChannelTurnSummary; @@ -27,6 +30,13 @@ export function WorkstreamCard({ isLoading: canvasQuery.isLoading, isError: canvasQuery.isError, }); + const references = React.useMemo( + () => + viewModel.status === "ready" + ? parseWorkstreamPullRequestReferences(viewModel.card.pullRequests) + : [], + [viewModel], + ); return (
) : null} + {references.length > 0 ? ( + + ) : null}
) : viewModel.status === "loading" ? ( diff --git a/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx new file mode 100644 index 0000000000..a1ee317686 --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx @@ -0,0 +1,108 @@ +import { ExternalLink, GitPullRequest } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { parseEntityLink } from "@/shared/lib/entityLink"; +import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks"; +import { + type WorkstreamPullRequestDisplayState, + type WorkstreamPullRequestReference, + useWorkstreamPullRequestStatuses, +} from "@/features/workstream-board/lib/workstreamPullRequestStatus"; + +function lifecycleLabel( + state: Extract, +) { + return state.lifecycle[0].toUpperCase() + state.lifecycle.slice(1); +} + +function reviewLabel( + state: Extract, +) { + switch (state.reviewState) { + case "approved": + return "Approved"; + case "changes-requested": + return "Changes requested"; + case "review-requested": + return "Review requested"; + default: + return "No reviews"; + } +} + +function referenceLabel(reference: WorkstreamPullRequestReference) { + if (reference.kind === "github") { + return `${reference.owner}/${reference.repository} #${reference.number}`; + } + if (reference.kind === "buzz") { + return `${reference.repository} · ${reference.eventId.slice(0, 8)}`; + } + return reference.rawUrl; +} + +export function WorkstreamPullRequests({ + references, +}: { + references: readonly WorkstreamPullRequestReference[]; +}) { + const states = useWorkstreamPullRequestStatuses(references); + const openEntityLink = useOpenEntityLink(); + + return ( +
+

+ Pull requests +

+ {references.map((reference) => { + const state = states.get(reference.identity); + const label = referenceLabel(reference); + const parsedBuzzLink = + reference.kind === "buzz" ? parseEntityLink(reference.href) : null; + const open = () => { + if (parsedBuzzLink?.ok) { + // Keep Buzz PRs in the existing canonical entity navigation path. + openEntityLink(parsedBuzzLink.value); + return; + } + void openUrl(reference.href); + }; + + return ( + + ); + })} +
+ ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1f7b147749..d96eb46ab9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13345,6 +13345,29 @@ export function maybeInstallE2eTauriMocks() { if (canvasReadError) { throw new Error(canvasReadError); } + if (isRelayMode(activeConfig)) { + const channelId = (payload as { channelId?: string }).channelId; + if (!channelId) { + throw new Error("get_canvas requires channelId"); + } + const events = await relayQuery(activeConfig, [ + { kinds: [40100], "#h": [channelId], limit: 1 }, + ]); + const event = events[0]; + return event + ? { + content: event.content, + event_id: event.id, + updated_at: event.created_at, + author: event.pubkey, + } + : { + content: "", + event_id: null, + updated_at: null, + author: null, + }; + } // Return the no-canvas success shape — content null means no canvas set. return { content: null, updated_at: null, author: null }; } diff --git a/desktop/tests/e2e/get-canvas-bridge.spec.ts b/desktop/tests/e2e/get-canvas-bridge.spec.ts new file mode 100644 index 0000000000..b50ed52471 --- /dev/null +++ b/desktop/tests/e2e/get-canvas-bridge.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, installRelayBridge } from "../helpers/bridge"; + +async function invokeCanvas( + page: import("@playwright/test").Page, + channelId: string, +) { + return page.evaluate(async (id) => { + const tauriWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + const invoke = + tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? + tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + return invoke("get_canvas", { channelId: id }); + }, channelId); +} + +test("relay mode forwards get_canvas to the authenticated kind-40100 query", async ({ + page, +}) => { + await installRelayBridge(page, "tyler"); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + const result = (await invokeCanvas( + page, + "eb4d4cee-2b29-4d53-addf-e6c4b93b4c0d", + )) as { content?: string | null; event_id?: string | null }; + expect(result.event_id).toBeTruthy(); + expect(result.content).toContain("B3 linked PR"); +}); + +test("mock mode keeps the deterministic null canvas response", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + await expect(await invokeCanvas(page, "mock-channel")).toEqual({ + content: null, + updated_at: null, + author: null, + }); +}); + +test("mock mode keeps configured canvas errors isolated", async ({ page }) => { + await installMockBridge(page, { canvasReadError: "canvas unavailable" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + await expect(invokeCanvas(page, "mock-channel")).rejects.toThrow( + "canvas unavailable", + ); +}); diff --git a/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts b/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts new file mode 100644 index 0000000000..91d7526ad8 --- /dev/null +++ b/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +import { installRelayBridge } from "../helpers/bridge"; + +test("continuous relay-backed Workstream Board bullet 3 PR-status demo", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + await installRelayBridge(page, "tyler", { seedPreviewFeatures: true }); + await page.routeWebSocket(/localhost:3000/, (socket) => { + const server = socket.connectToServer(); + let delayedPrSubscription: string | null = null; + let delayedFrames: string[] = []; + let releaseScheduled = false; + const release = () => { + const frames = delayedFrames; + delayedFrames = []; + delayedPrSubscription = null; + releaseScheduled = false; + for (const frame of frames) socket.send(frame); + }; + server.onMessage((message) => { + if (typeof message === "string") { + try { + const payload = JSON.parse(message) as unknown[]; + const type = payload[0]; + const subId = typeof payload[1] === "string" ? payload[1] : null; + const event = payload[2] as { kind?: unknown } | undefined; + if (type === "EVENT" && event?.kind === 1618 && subId) { + delayedPrSubscription = subId; + delayedFrames.push(message); + if (!releaseScheduled) { + releaseScheduled = true; + setTimeout(release, 2_500); + } + return; + } + if (type === "EOSE" && subId === delayedPrSubscription) { + delayedFrames.push(message); + return; + } + } catch { + // Pass non-JSON relay frames through unchanged. + } + } + socket.send(message); + }); + socket.onMessage((message) => server.send(message)); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + await expect(page.getByTestId("open-workstream-board-view")).toBeVisible(); + await page.getByTestId("open-workstream-board-view").click(); + + const board = page.getByTestId("workstream-board-view"); + await expect(board).toBeVisible(); + const channelId = "eb4d4cee-2b29-4d53-addf-e6c4b93b4c0d"; + // Focused bridge regression: relay mode must return the real kind-40100 + // canvas event. Mock mode intentionally keeps the deterministic null/error + // behavior covered by relay-connectivity.spec.ts. + const canvas = (await page.evaluate(async (id) => { + const tauriWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + const invoke = + tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? + tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + return invoke("get_canvas", { channelId: id }); + }, channelId)) as { content?: string | null }; + expect(canvas.content).toContain("B3 linked PR"); + + const card = page.getByTestId(`workstream-card-${channelId}`); + await expect(card).toBeVisible(); + await expect(card).toContainText( + "Show a real linked PR loading then live status", + ); + + const pullRequest = card.getByTestId("workstream-pull-request"); + await expect(pullRequest).toBeVisible(); + await expect(pullRequest).toContainText("Loading…"); + await page.waitForTimeout(1_000); + await page.screenshot({ + path: testInfo.outputPath("b3-loading.png"), + fullPage: true, + }); + + await expect(pullRequest).toContainText("B3 linked PR"); + await expect(pullRequest).toContainText("Open"); + await expect(pullRequest).toContainText("No reviews"); + await page.screenshot({ + path: testInfo.outputPath("b3-live.png"), + fullPage: true, + }); + await page.waitForTimeout(3_000); + + await pullRequest.click(); + await expect(page).toHaveURL(/\/projects\//); + await expect( + page.locator("[data-project-detail-panel]:visible").first(), + ).toBeVisible({ timeout: 15_000 }); + await page.screenshot({ + path: testInfo.outputPath("b3-canonical-open.png"), + fullPage: true, + }); + await page.waitForTimeout(3_000); +});