feat(github): support per-workspace GitHub App registrations (#1810)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user