Files
kandev/apps/web/hooks/domains/github/use-task-pr.ts
T
João Salavisa e9a7869463 refactor(web): drop dead taskPRs loading state and unused removeTaskPR (#791)
* refactor(web): drop dead taskPRs loading state and unused removeTaskPR

The taskPRs.loaded/loading fields were written but never read — useWorkspacePRs
uses its own fetchedRef for fetch-once gating instead. removeTaskPR was defined
but never called. Removing all three avoids a footgun in multi-repo tasks where
removeTaskPR would have wiped every repo's PR state at once.

* refactor(web): use plain deepMerge for taskPRs hydration

taskPRs no longer carries loading/loaded fields, so the mergeWithLoading helper has nothing to preserve. Switching to deepMerge makes the intent explicit.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-03 12:00:22 +01:00

139 lines
4.5 KiB
TypeScript

"use client";
import { useEffect, useCallback, useRef } from "react";
import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
/** Fetch all PR associations for a workspace. */
export function useWorkspacePRs(workspaceId: string | null) {
const setTaskPRs = useAppStore((state) => state.setTaskPRs);
const fetchedRef = useRef<string | null>(null);
const requestRef = useRef(0);
useEffect(() => {
if (!workspaceId) {
fetchedRef.current = null;
return;
}
if (fetchedRef.current === workspaceId) return;
const requestId = ++requestRef.current;
fetchedRef.current = workspaceId;
listWorkspaceTaskPRs(workspaceId, { cache: "no-store" })
.then((response) => {
if (requestRef.current !== requestId) return;
setTaskPRs(response?.task_prs ?? {});
})
.catch(() => {
if (requestRef.current === requestId) {
fetchedRef.current = null; // allow retry on failure
}
});
}, [workspaceId, setTaskPRs]);
}
const SYNC_RETRY_DELAY = 5_000; // 5 seconds
const SYNC_MAX_RETRIES = 6; // Up to 30 seconds of retries
/**
* Returns the primary PR (first by created_at) for a task. Multi-repo tasks
* may have additional PRs — use `useTaskPRs` to get the full list.
*/
export function getPrimaryTaskPR(prs: TaskPR[] | undefined): TaskPR | null {
return prs && prs.length > 0 ? prs[0] : null;
}
/**
* Normalises the WS sync response into an array of TaskPR rows. Backend
* returns `{prs: TaskPR[]}` (current shape) for multi-repo support, but we
* accept the legacy bare-TaskPR shape too in case an older backend is
* still running. Empty / null / unknown shapes return an empty array.
*/
function normalizeSyncResponse(result: { prs?: TaskPR[] } | TaskPR | null | undefined): TaskPR[] {
if (!result) return [];
const envelope = result as { prs?: TaskPR[] };
if (Array.isArray(envelope.prs)) return envelope.prs;
const single = result as TaskPR;
if (single.task_id) return [single];
return [];
}
/** Fetch a single task's PR associations, with on-demand sync via WS. */
export function useTaskPR(taskId: string | null) {
const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null));
const pr = getPrimaryTaskPR(prs ?? undefined);
const setTaskPR = useAppStore((state) => state.setTaskPR);
const retryRef = useRef(0);
const refresh = useCallback(() => {
if (!taskId) return;
const client = getWebSocketClient();
if (!client) return;
// Backend returns `{prs: TaskPR[]}` — multi-repo tasks have one row per
// repo. We push each into the store so the per-repo PR icon stays in
// sync. Empty array means no watches yet (the freshness retry below
// handles the polling cadence). Legacy single-PR shape (`TaskPR` only)
// is detected via the absence of `.prs`.
client
.request<{ prs?: TaskPR[] } | TaskPR | null>("github.task_pr.sync", { task_id: taskId })
.then((result) => {
const list = normalizeSyncResponse(result);
if (list.length === 0) return;
for (const pr of list) {
if (pr.task_id) setTaskPR(taskId, pr);
}
retryRef.current = 0;
})
.catch(() => {
// Ignore - sync may fail if no watch exists
});
}, [taskId, setTaskPR]);
// Reset retry count when taskId changes
useEffect(() => {
retryRef.current = 0;
}, [taskId]);
// Sync once when the task becomes active (freshness check).
// Intentionally excludes `pr` so WS-driven store updates don't re-trigger.
useEffect(() => {
if (!taskId) return;
refresh();
}, [taskId, refresh]);
// Retry polling when no PR is in the store yet.
useEffect(() => {
if (!taskId || pr) return;
const interval = setInterval(() => {
if (retryRef.current >= SYNC_MAX_RETRIES) {
clearInterval(interval);
return;
}
retryRef.current++;
refresh();
}, SYNC_RETRY_DELAY);
return () => clearInterval(interval);
}, [taskId, pr, refresh]);
return { pr, prs: prs ?? [], refresh } as {
pr: TaskPR | null;
prs: TaskPR[];
refresh: () => void;
};
}
/** Read the active task's primary PR from the store (no fetching). */
export function useActiveTaskPR(): TaskPR | null {
return useAppStore((s) => {
const taskId = s.tasks.activeTaskId;
if (!taskId) return null;
return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]);
});
}