feat(github): support per-workspace GitHub App registrations (#1810)

This commit is contained in:
Carlos Florêncio
2026-07-26 10:39:08 +01:00
committed by GitHub
parent bc218dfbbe
commit 75abf779ed
347 changed files with 36471 additions and 2896 deletions
@@ -25,6 +25,7 @@ import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
import { PRRowTaskIndicator } from "./pr-row-task-indicator";
type PRListProps = {
workspaceId: string | null;
items: GitHubPR[];
loading: boolean;
error: string | null;
@@ -150,8 +151,16 @@ function PRRow({
);
}
function PRListBody({ loading, error, items, presets, onStartTask, prKeyToTasks }: PRListProps) {
const statuses = usePRStatuses(items);
function PRListBody({
workspaceId,
loading,
error,
items,
presets,
onStartTask,
prKeyToTasks,
}: PRListProps) {
const statuses = usePRStatuses(workspaceId, items);
if (loading) {
return (
<div className="flex justify-center py-10">
@@ -206,7 +206,7 @@ export function QuickTaskLauncher({
if (!open) onClose();
};
const handleSuccess = (task: Task) => {
if (payload?.kind === "pr") {
if (payload?.kind === "pr" && workspaceId) {
const repositoryId = pickRepositoryIdForPR(
task.repositories,
payload.pr,
@@ -216,6 +216,7 @@ export function QuickTaskLauncher({
// missing GH client) shouldn't block navigation — the existing
// branch-based poller will still try once the agent starts.
void createTaskPR({
workspace_id: workspaceId,
task_id: task.id,
repository_id: repositoryId,
pr_url: prURL(payload.pr),
@@ -22,10 +22,11 @@ type FetchArgs = {
customQuery: string;
repoFilter: string;
page: number;
workspaceId: string | null;
workspaceId: string;
};
type UseGitHubSearchOptions = {
enabled?: boolean;
kind: SearchKind;
presets: PresetOption[];
preset: string;
@@ -51,6 +52,7 @@ export function buildParams(
}
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>({
enabled = true,
kind,
presets,
preset,
@@ -122,13 +124,18 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>({
);
useEffect(() => {
if (!enabled || !workspaceId) {
requestSeq.current += 1;
setState({ items: [], loading: false, error: null, lastFetchedAt: null, total: 0 });
return;
}
void fetchData({ preset, customQuery, repoFilter, page, workspaceId });
}, [fetchData, preset, customQuery, repoFilter, page, workspaceId]);
}, [enabled, fetchData, preset, customQuery, repoFilter, page, workspaceId]);
const refresh = useCallback(
() => fetchData({ preset, customQuery, repoFilter, page, workspaceId }),
[fetchData, preset, customQuery, repoFilter, page, workspaceId],
);
const refresh = useCallback(() => {
if (!enabled || !workspaceId) return Promise.resolve();
return fetchData({ preset, customQuery, repoFilter, page, workspaceId });
}, [enabled, fetchData, preset, customQuery, repoFilter, page, workspaceId]);
return { ...state, page, setPage, pageSize: SEARCH_PAGE_SIZE, refresh };
}
@@ -5,8 +5,13 @@ import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
const getPRStatusesBatch = vi.fn();
vi.mock("@/lib/api/domains/github-api", () => ({
getPRStatusesBatch: (refs: { owner: string; repo: string; number: number }[]) =>
getPRStatusesBatch(refs) as Promise<{ statuses: Record<string, GitHubPRStatus> }>,
getPRStatusesBatch: (
workspaceId: string,
refs: { owner: string; repo: string; number: number }[],
) =>
getPRStatusesBatch(workspaceId, refs) as Promise<{
statuses: Record<string, GitHubPRStatus>;
}>,
}));
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
@@ -60,7 +65,7 @@ describe("usePRStatuses", () => {
});
it("skips fetch when prs list is empty", () => {
renderHook(() => usePRStatuses([]));
renderHook(() => usePRStatuses("ws-1", []));
expect(getPRStatusesBatch).not.toHaveBeenCalled();
});
@@ -69,7 +74,7 @@ describe("usePRStatuses", () => {
getPRStatusesBatch.mockResolvedValueOnce({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { result } = renderHook(() => usePRStatuses([pr]));
const { result } = renderHook(() => usePRStatuses("ws-1", [pr]));
await waitFor(() => {
expect(result.current.size).toBe(1);
});
@@ -81,7 +86,7 @@ describe("usePRStatuses", () => {
getPRStatusesBatch.mockResolvedValue({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { rerender, result } = renderHook(({ prs }) => usePRStatuses(prs), {
const { rerender, result } = renderHook(({ prs }) => usePRStatuses("ws-1", prs), {
initialProps: { prs: [pr] },
});
await waitFor(() => expect(result.current.size).toBe(1));
@@ -102,7 +107,7 @@ describe("usePRStatuses", () => {
getPRStatusesBatch.mockResolvedValue({
statuses: { "acme/widget#7": makeStatus(pr1), "acme/widget#8": makeStatus(pr2) },
});
const { rerender } = renderHook(({ prs }) => usePRStatuses(prs), {
const { rerender } = renderHook(({ prs }) => usePRStatuses("ws-1", prs), {
initialProps: { prs: [pr1] },
});
await waitFor(() => expect(getPRStatusesBatch).toHaveBeenCalledTimes(1));
@@ -115,7 +120,7 @@ describe("usePRStatuses", () => {
getPRStatusesBatch.mockResolvedValueOnce({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { result, rerender } = renderHook(({ prs }) => usePRStatuses(prs), {
const { result, rerender } = renderHook(({ prs }) => usePRStatuses("ws-1", prs), {
initialProps: { prs: [pr] },
});
await waitFor(() => expect(result.current.size).toBe(1));
@@ -13,7 +13,10 @@ export function prStatusKey(owner: string, repo: string, number: number): string
// prStatusKey(owner, repo, number). When the PR list changes (new page,
// different preset) we refetch; the backend caches per-PR so fast
// pagination stays cheap.
export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
export function usePRStatuses(
workspaceId: string | null,
prs: GitHubPR[],
): Map<string, GitHubPRStatus> {
const [statuses, setStatuses] = useState<Map<string, GitHubPRStatus>>(new Map());
// `completedKey` is set only after a fetch resolves (success or failure), so
// a transient error can be retried on the next render, and React Strict
@@ -22,7 +25,10 @@ export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
// completedKey stays empty and the second mount retries.
const completedKey = useRef<string>("");
const key = prs.map((p) => prStatusKey(p.repo_owner, p.repo_name, p.number)).join(",");
const key =
workspaceId && prs.length > 0
? `${workspaceId}:${prs.map((p) => prStatusKey(p.repo_owner, p.repo_name, p.number)).join(",")}`
: "";
// We deliberately depend only on `key`: `prs` gets a new array identity on
// every render, and including it would cancel an in-flight request whose
@@ -30,7 +36,11 @@ export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
// authoritative content signal; when it matches we reuse the latest `prs`
// closure for building request refs.
useEffect(() => {
if (key === "") return;
if (key === "") {
completedKey.current = "";
setStatuses(new Map());
return;
}
if (completedKey.current === key) return;
const refs: PRStatusRef[] = prs.map((p) => ({
owner: p.repo_owner,
@@ -38,7 +48,7 @@ export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
number: p.number,
}));
let cancelled = false;
getPRStatusesBatch(refs)
getPRStatusesBatch(workspaceId!, refs)
.then((resp) => {
if (cancelled) return;
completedKey.current = key;
@@ -55,7 +65,7 @@ export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
}, [key, workspaceId]);
return statuses;
}