mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
feat(desktop): show pull request status on workstream cards
Signed-off-by: loganj <loganj@squareup.com> Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
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,
|
||||
"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, "unsupported");
|
||||
assert.equal(refs[2].reason, "malformed");
|
||||
assert.equal(refs[3].reason, "unsupported-provider");
|
||||
assert.equal(refs[4].reason, "malformed");
|
||||
assert.equal(refs[5].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();
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
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 {
|
||||
return typeof value === "string" ? value.trim() : "[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<Response>;
|
||||
|
||||
type EventFetcher = (
|
||||
filter: Parameters<typeof relayClient.fetchEvents>[0],
|
||||
) => Promise<RelayEvent[]>;
|
||||
|
||||
function isGithubReview(value: unknown): value is GithubReview {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function reviewState(
|
||||
reviews: unknown[],
|
||||
requestedReviewers: unknown,
|
||||
): WorkstreamReviewState {
|
||||
const latestByReviewer = new Map<string, GithubReview>();
|
||||
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<unknown> {
|
||||
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<WorkstreamPullRequestReference, { kind: "github" }>,
|
||||
options: { fetcher?: GithubFetch; signal?: AbortSignal } = {},
|
||||
): Promise<WorkstreamPullRequestStatus | WorkstreamPullRequestUnknown> {
|
||||
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<WorkstreamPullRequestReference, { kind: "buzz" }>,
|
||||
fetchEvents: EventFetcher = (filter) => relayClient.fetchEvents(filter),
|
||||
): Promise<WorkstreamPullRequestStatus | WorkstreamPullRequestUnknown> {
|
||||
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<PollerListener>;
|
||||
state: WorkstreamPullRequestDisplayState;
|
||||
controller: AbortController | null;
|
||||
timer: ReturnType<typeof setTimeout> | 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<typeof setTimeout>;
|
||||
cancel?: (timer: ReturnType<typeof setTimeout>) => 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<string, PollerEntry>();
|
||||
|
||||
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();
|
||||
|
||||
export function useWorkstreamPullRequestStatuses(
|
||||
references: readonly WorkstreamPullRequestReference[],
|
||||
): ReadonlyMap<string, WorkstreamPullRequestDisplayState> {
|
||||
const [states, setStates] = React.useState<
|
||||
ReadonlyMap<string, WorkstreamPullRequestDisplayState>
|
||||
>(() => new Map());
|
||||
const referencesByIdentity = React.useMemo(() => {
|
||||
const next = new Map<string, WorkstreamPullRequestReference>();
|
||||
for (const reference of references) next.set(reference.identity, reference);
|
||||
return next;
|
||||
}, [references]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const next = new Map<string, WorkstreamPullRequestDisplayState>();
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
for (const reference of referencesByIdentity.values()) {
|
||||
if (reference.kind === "unsupported") {
|
||||
next.set(reference.identity, {
|
||||
status: "unsupported",
|
||||
href: reference.href,
|
||||
reason: reference.reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const unsubscribe = defaultWorkstreamPollingRegistry.subscribe(
|
||||
reference,
|
||||
(state) => {
|
||||
setStates((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(reference.identity, state);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
);
|
||||
unsubscribers.push(unsubscribe);
|
||||
}
|
||||
setStates(next);
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
};
|
||||
}, [referencesByIdentity]);
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
export const workstreamPollingLimits = {
|
||||
intervalMs: DEFAULT_GITHUB_POLL_INTERVAL_MS,
|
||||
maxLifetimeMs: DEFAULT_GITHUB_POLL_LIFETIME_MS,
|
||||
maxPolls: MAX_GITHUB_POLLS,
|
||||
};
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -73,6 +83,9 @@ export function WorkstreamCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{references.length > 0 ? (
|
||||
<WorkstreamPullRequests references={references} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : viewModel.status === "loading" ? (
|
||||
|
||||
@@ -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<WorkstreamPullRequestDisplayState, { status: "live" }>,
|
||||
) {
|
||||
return state.lifecycle[0].toUpperCase() + state.lifecycle.slice(1);
|
||||
}
|
||||
|
||||
function reviewLabel(
|
||||
state: Extract<WorkstreamPullRequestDisplayState, { status: "live" }>,
|
||||
) {
|
||||
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 (
|
||||
<div
|
||||
className="pointer-events-auto mt-4 space-y-1.5"
|
||||
data-testid="workstream-pull-requests"
|
||||
>
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Pull requests
|
||||
</p>
|
||||
{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 (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border/50 bg-background/60 px-2 py-1.5 text-left text-2xs transition-colors hover:bg-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="workstream-pull-request"
|
||||
key={reference.identity}
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={reference.rawUrl}>
|
||||
{state?.status === "live" && state.title
|
||||
? `${label} · ${state.title}`
|
||||
: label}
|
||||
</span>
|
||||
{state?.status === "loading" ? (
|
||||
<span className="shrink-0 text-muted-foreground">Loading…</span>
|
||||
) : state?.status === "live" ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{lifecycleLabel(state)} · {reviewLabel(state)}
|
||||
</span>
|
||||
) : state?.status === "error" ? (
|
||||
<span className="shrink-0 text-red-400">Unavailable</span>
|
||||
) : state?.status === "unknown" ? (
|
||||
<span className="shrink-0 text-muted-foreground">Unknown</span>
|
||||
) : state?.status === "unsupported" ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
Unsupported
|
||||
</span>
|
||||
) : null}
|
||||
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user