import { fetchJson, fetchJsonWithRetry, ApiError, type ApiRequestOptions } from "../client"; import type { GitHubOrg, GitHubRepoInfo, GitHubPR, TaskPRsResponse, TaskPR, PRWatchesResponse, ReviewWatch, ReviewWatchesResponse, CreateReviewWatchRequest, UpdateReviewWatchRequest, TriggerReviewResponse, PRStatsResponse, IssueWatch, IssueWatchesResponse, CreateIssueWatchRequest, UpdateIssueWatchRequest, TriggerIssueResponse, GitHubIssue, TaskIssueLink, TaskIssueLinksResponse, SearchPRsResponse, SearchIssuesResponse, GitHubActionPresets, UpdateGitHubActionPresetsRequest, GitHubWorkspaceSettings, UpdateGitHubWorkspaceSettingsRequest, CleanupTasksResponse, TaskCIAutomationOptions, TaskCIAutomationPatch, } from "@/lib/types/github"; import { invalidateIntegrationAvailabilityAfter } from "@/lib/integrations/integration-availability-events"; export * from "./github-auth-api"; export * from "./github-pr-api"; // Token configuration export async function configureGitHubToken(workspaceId: string, token: string) { const query = new URLSearchParams({ workspace_id: workspaceId }); return invalidateIntegrationAvailabilityAfter( fetchJson<{ configured: boolean }>(`/api/v1/github/token?${query}`, { init: { method: "POST", body: JSON.stringify({ token }), }, }), ); } export async function clearGitHubToken(workspaceId: string) { const query = new URLSearchParams({ workspace_id: workspaceId }); return invalidateIntegrationAvailabilityAfter( fetchJson<{ cleared: boolean }>(`/api/v1/github/token?${query}`, { init: { method: "DELETE" }, }), ); } // Task PR associations export async function listTaskPRs(taskIds: string[], options?: ApiRequestOptions) { const query = new URLSearchParams(); query.set("task_ids", taskIds.join(",")); return fetchJson(`/api/v1/github/task-prs?${query.toString()}`, options); } export async function listWorkspaceTaskPRs(workspaceId: string, options?: ApiRequestOptions) { return fetchJson( `/api/v1/github/task-prs?workspace_id=${encodeURIComponent(workspaceId)}`, options, ); } export async function getTaskPR(taskId: string, options?: ApiRequestOptions) { return fetchJson(`/api/v1/github/task-prs/${taskId}`, options); } export async function createTaskPR( data: { workspace_id: string; task_id: string; repository_id?: string; pr_url: string }, options?: ApiRequestOptions, ) { return fetchJson(`/api/v1/github/task-prs`, { ...options, init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(data), }, }); } export async function linkTaskIssue( taskId: string, data: { issue: string; owner?: string; repo?: string; number?: number }, options?: ApiRequestOptions, ) { return fetchJson(`/api/v1/github/tasks/${encodeURIComponent(taskId)}/issue`, { ...options, init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(data), }, }); } export async function listWorkspaceTaskIssues(workspaceId: string, options?: ApiRequestOptions) { return fetchJson( `/api/v1/github/task-issues?workspace_id=${encodeURIComponent(workspaceId)}`, options, ); } export async function unlinkTaskIssue(taskId: string, options?: ApiRequestOptions) { return fetchJson<{ unlinked: boolean }>( `/api/v1/github/tasks/${encodeURIComponent(taskId)}/issue`, { ...options, init: { ...(options?.init ?? {}), method: "DELETE", }, }, ); } export async function getTaskCIAutomationOptions(taskId: string, options?: ApiRequestOptions) { return fetchJson( `/api/v1/github/tasks/${encodeURIComponent(taskId)}/ci-options`, options, ); } export async function updateTaskCIAutomationOptions( taskId: string, patch: TaskCIAutomationPatch, options?: ApiRequestOptions, ) { return fetchJson( `/api/v1/github/tasks/${encodeURIComponent(taskId)}/ci-options`, { ...options, init: { ...(options?.init ?? {}), method: "PATCH", body: JSON.stringify(patch), }, }, ); } // PR watches export async function listPRWatches(workspaceId: string, options?: ApiRequestOptions) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/pr?${params}`, options); } export async function deletePRWatch(id: string, workspaceId: string, options?: ApiRequestOptions) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ success: boolean }>(`/api/v1/github/watches/pr/${id}?${params}`, { ...options, init: { method: "DELETE", ...(options?.init ?? {}) }, }); } // Review watches export async function listReviewWatches(workspaceId: string, options?: ApiRequestOptions) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/review?${params}`, options); } export async function createReviewWatch( payload: CreateReviewWatchRequest, options?: ApiRequestOptions, ) { return fetchJson("/api/v1/github/watches/review", { ...options, init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } export async function updateReviewWatch( id: string, workspaceId: string, payload: UpdateReviewWatchRequest, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/review/${id}?${params}`, { ...options, init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } export async function deleteReviewWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}?${params}`, { ...options, init: { method: "DELETE", ...(options?.init ?? {}) }, }); } export async function triggerReviewWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/review/${id}/trigger?${params}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); } // previewResetReviewWatch returns how many tasks would be deleted if the // review watch were reset. Used by the confirmation dialog. // workspaceId is the row's owning workspace; the backend rejects mismatches // with 404 so cross-workspace IDOR is closed off. export async function previewResetReviewWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ taskCount: number }>( `/api/v1/github/watches/review/${id}/reset/preview?${query.toString()}`, options, ); } // resetReviewWatch deletes every task previously created by the review // watch (including archived), wipes its dedup table, and schedules the // watch to re-run so currently-matching PRs are queued for task creation. export async function resetReviewWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ tasksDeleted: number }>( `/api/v1/github/watches/review/${id}/reset?${query.toString()}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }, ); } export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson( `/api/v1/github/watches/review/trigger-all?${query.toString()}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }, ); } // Accessible repos — the union of repos the authenticated user can reach // (own + each org's), as served by `GET /api/v1/github/repos`. The // provider-tagged shape is forward-compat for a future GitLab variant; today // the backend only returns GitHub repos, so we stamp `provider: "github"` on // every entry at the client boundary. // // `default_branch` is always present on the wire (GitHub's API returns it on // every repo) so the picker can pre-fill the row's branch on selection // without a round-trip. `description` is optional because the backend uses // `omitempty` — null/empty descriptions are dropped from the JSON. export type AccessibleRepo = { provider: "github" | "gitlab"; owner: string; name: string; full_name: string; default_branch: string; description?: string; pushed_at?: string; private: boolean; }; // Backend response shape for `GET /api/v1/github/repos` — narrow alias used // only at the parse boundary. We keep it scoped to this module to avoid // leaking the wire-only shape into the rest of the app. type AccessibleReposResponse = { repos: Array>; }; // GitHubUnavailableError signals that the backend reported GitHub is not // configured (HTTP 503 with `code: "github_not_configured"`). Callers use the // instanceof check to render a "Connect GitHub" CTA instead of a generic // error toast. export class GitHubUnavailableError extends Error { constructor(message = "GitHub is not configured") { super(message); this.name = "GitHubUnavailableError"; } } function isGitHubNotConfigured(err: unknown): boolean { if (!(err instanceof ApiError)) return false; if (err.status !== 503) return false; const body = err.body; if (body && typeof body === "object" && "code" in body) { return (body as { code?: unknown }).code === "github_not_configured"; } return false; } export async function fetchAccessibleRepos(opts: { workspaceId: string; q?: string; limit?: number; signal?: AbortSignal; }): Promise { const params = new URLSearchParams({ workspace_id: opts.workspaceId }); if (opts.q) params.set("q", opts.q); if (typeof opts.limit === "number") params.set("limit", String(opts.limit)); const suffix = params.toString(); const path = `/api/v1/github/repos${suffix ? `?${suffix}` : ""}`; try { const res = await fetchJson(path, { cache: "no-store", init: opts.signal ? { signal: opts.signal } : undefined, }); const repos = res?.repos ?? []; return repos.map((r) => ({ ...r, provider: "github" as const })); } catch (err) { if (isGitHubNotConfigured(err)) { throw new GitHubUnavailableError(err instanceof Error ? err.message : undefined); } throw err; } } // Orgs & repo search export async function listUserOrgs(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ orgs: GitHubOrg[] }>(`/api/v1/github/orgs?${query}`, options); } export async function searchOrgRepos( workspaceId: string, org: string, query?: string, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId, org }); if (query) params.set("q", query); return fetchJson<{ repos: GitHubRepoInfo[] }>( `/api/v1/github/repos/search?${params.toString()}`, options, ); } // PR info (lightweight) export async function fetchPRInfo( workspaceId: string, owner: string, repo: string, number: number, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJsonWithRetry( `/api/v1/github/prs/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info?${query}`, options, ); } // Issue info (lightweight) export async function fetchIssueInfo( workspaceId: string, owner: string, repo: string, number: number, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJsonWithRetry( `/api/v1/github/issues/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info?${query}`, options, ); } // Remote repo branches export async function fetchRepoBranches( workspaceId: string, owner: string, repo: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJsonWithRetry<{ branches: { name: string }[] }>( `/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches?${query}`, options, ); } // Stats export async function fetchGitHubStats( params?: { workspace_id?: string; start_date?: string; end_date?: string }, options?: ApiRequestOptions, ) { const query = new URLSearchParams(); if (params?.workspace_id) query.set("workspace_id", params.workspace_id); if (params?.start_date) query.set("start_date", params.start_date); if (params?.end_date) query.set("end_date", params.end_date); const suffix = query.toString(); return fetchJson(`/api/v1/github/stats${suffix ? `?${suffix}` : ""}`, options); } // Issue watches export async function listIssueWatches(workspaceId: string, options?: ApiRequestOptions) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/issue?${params}`, options); } export async function createIssueWatch( payload: CreateIssueWatchRequest, options?: ApiRequestOptions, ) { return fetchJson("/api/v1/github/watches/issue", { ...options, init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } export async function updateIssueWatch( id: string, workspaceId: string, payload: UpdateIssueWatchRequest, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/issue/${id}?${params}`, { ...options, init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } export async function deleteIssueWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ deleted: boolean }>(`/api/v1/github/watches/issue/${id}?${params}`, { ...options, init: { method: "DELETE", ...(options?.init ?? {}) }, }); } export async function triggerIssueWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const params = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/watches/issue/${id}/trigger?${params}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); } // previewResetIssueWatch returns how many tasks would be deleted if the // issue watch were reset. Used by the confirmation dialog. workspaceId is // the row's owning workspace; the backend rejects mismatches with 404. export async function previewResetIssueWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ taskCount: number }>( `/api/v1/github/watches/issue/${id}/reset/preview?${query.toString()}`, options, ); } // resetIssueWatch deletes every task previously created by the issue // watch (including archived), wipes its dedup table, and nulls // last_polled_at so the next poll re-imports every currently-matching // issue. export async function resetIssueWatch( id: string, workspaceId: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson<{ tasksDeleted: number }>( `/api/v1/github/watches/issue/${id}/reset?${query.toString()}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }, ); } export async function triggerAllIssueWatches(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson( `/api/v1/github/watches/issue/trigger-all?${query.toString()}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }, ); } // Manual cleanup sweeps. The poller runs these every 5min per watch, but a // user with a pile of legacy merged-PR tasks (created before the cleanup // policy was in place) can invoke them on demand from the settings page. export async function cleanupMergedReviewTasks(workspaceId?: string, options?: ApiRequestOptions) { const query = workspaceId ? `?${new URLSearchParams({ workspace_id: workspaceId })}` : ""; return fetchJson(`/api/v1/github/cleanup/review-tasks${query}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); } export async function cleanupClosedIssueTasks(workspaceId?: string, options?: ApiRequestOptions) { const query = workspaceId ? `?${new URLSearchParams({ workspace_id: workspaceId })}` : ""; return fetchJson(`/api/v1/github/cleanup/issue-tasks${query}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); } // User PR / issue search (for the /github page). // Pass `query` to use a verbatim GitHub search string, or `filter` to append to // the default (type:pr state:open / type:issue state:open). type SearchParams = { workspaceId: string; query?: string; filter?: string; page?: number; perPage?: number; }; function buildSearchQuery(params: SearchParams) { const search = new URLSearchParams(); if (params.query) search.set("query", params.query); if (params.filter) search.set("filter", params.filter); if (params.page && params.page > 1) search.set("page", String(params.page)); if (params.perPage) search.set("per_page", String(params.perPage)); search.set("workspace_id", params.workspaceId); return search.toString(); } export async function searchUserPRs(params: SearchParams, options?: ApiRequestOptions) { const suffix = buildSearchQuery(params); return fetchJson( `/api/v1/github/user/prs${suffix ? `?${suffix}` : ""}`, options, ); } export async function searchUserIssues(params: SearchParams, options?: ApiRequestOptions) { const suffix = buildSearchQuery(params); return fetchJson( `/api/v1/github/user/issues${suffix ? `?${suffix}` : ""}`, options, ); } // Workspace settings for GitHub repo visibility/scope. export async function fetchGitHubWorkspaceSettings( workspaceId: string, options?: ApiRequestOptions, ) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson( `/api/v1/github/workspace-settings?${query.toString()}`, options, ); } export async function updateGitHubWorkspaceSettings( payload: UpdateGitHubWorkspaceSettingsRequest, options?: ApiRequestOptions, ) { return fetchJson("/api/v1/github/workspace-settings", { ...options, init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) }, }); } // copyGitHubWorkspaceSettings copies the per-workspace GitHub settings (repo // scope + presets) from the source workspace (options.workspaceId) to // targetWorkspaceId. GitHub auth is install-wide, so there are no credentials to // copy. The signature mirrors the other integrations' copy helpers // (targetWorkspaceId first, source via options) for consistency. export async function copyGitHubWorkspaceSettings( targetWorkspaceId: string, options: { workspaceId: string } & ApiRequestOptions, ) { const { workspaceId: sourceWorkspaceId, ...requestOptions } = options; const query = new URLSearchParams({ workspace_id: sourceWorkspaceId }); return fetchJson( `/api/v1/github/workspace-settings/copy?${query.toString()}`, { ...requestOptions, init: { ...(requestOptions.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }), }, }, ); } // Action presets (quick-launch prompts on the /github page). export async function fetchGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson( `/api/v1/github/action-presets?${query.toString()}`, options, ); } export async function updateGitHubActionPresets( payload: UpdateGitHubActionPresetsRequest, options?: ApiRequestOptions, ) { return fetchJson("/api/v1/github/action-presets", { ...options, init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) }, }); } export async function resetGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); return fetchJson(`/api/v1/github/action-presets/reset?${query.toString()}`, { ...options, init: { ...(options?.init ?? {}), method: "POST" }, }); }