/** * Typed client for the open-swe dashboard backend. * * All requests are sent with credentials so the httpOnly `osw_session` * cookie set by the OAuth callback rides along on cross-origin calls. */ const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace( /\/$/, "" ) if (!API_BASE && typeof window !== "undefined") { console.warn("VITE_DASHBOARD_API_BASE_URL is not set") } const GITHUB_IMAGE_HOST_RE = /^(?:www\.)?github\.com$|\.githubusercontent\.com$/i /** * Build an authenticated proxy URL for GitHub-hosted PR images. Private-repo * attachments can't be loaded directly by the browser, so they're routed * through the dashboard backend which holds the App token. Non-GitHub image * URLs are returned unchanged. */ export function reviewImageProxyUrl( owner: string, repo: string, number: number, src: string ): string { let parsed: URL try { parsed = new URL(src) } catch { return src } if ( parsed.protocol !== "https:" || !GITHUB_IMAGE_HOST_RE.test(parsed.hostname) ) { return src } if ( /^(?:www\.)?github\.com$/i.test(parsed.hostname) && !parsed.pathname.startsWith("/user-attachments/") ) { return src } const path = `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/image` return `${API_BASE}/dashboard/api${path}?url=${encodeURIComponent(src)}` } export class ApiError extends Error { constructor( public readonly status: number, message: string ) { super(message) this.name = "ApiError" } } export function isGithubReauthError(error: unknown): boolean { if (!(error instanceof ApiError)) return false if (error.status === 401) return true return /github token|re-login required/i.test(error.message) } async function request(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API_BASE}/dashboard/api${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers ?? {}), }, }) if (!res.ok) { let message = res.statusText try { const body = await res.json() if (body?.detail) message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail) } catch { /* ignore */ } throw new ApiError(res.status, message) } if (res.status === 204) return undefined as T return (await res.json()) as T } export interface SessionUser { login: string email: string | null avatar_url: string | null is_admin: boolean slack_oauth_enabled?: boolean } export interface ModelOption { id: string label: string efforts: Array default_effort: string supports_images: boolean } export interface OptionsPayload { models: Array default_agent_model: string default_agent_reasoning_effort: string default_agent_subagent_model: string default_agent_subagent_reasoning_effort: string } export interface Profile { login?: string email?: string default_model?: string reasoning_effort?: string default_subagent_model?: string | null subagent_reasoning_effort?: string | null default_repo?: string | null base_branch?: string | null branch_prefix?: string | null auto_fix_ci?: boolean create_prs?: boolean review_draft_prs?: boolean | null updated_at?: string } export interface ProfileUpdate { default_model: string reasoning_effort: string default_subagent_model?: string | null subagent_reasoning_effort?: string | null default_repo?: string | null base_branch?: string | null branch_prefix?: string | null auto_fix_ci?: boolean create_prs?: boolean review_draft_prs?: boolean | null } export interface TeamSettings { review_draft_prs: boolean pr_summaries: boolean review_trace_links: boolean org_guidelines?: string | null default_agent_model?: string | null default_agent_reasoning_effort?: string | null default_agent_subagent_model?: string | null default_agent_subagent_reasoning_effort?: string | null default_repo?: string | null default_reviewer_model?: string | null default_reviewer_reasoning_effort?: string | null default_reviewer_subagent_model?: string | null default_reviewer_subagent_reasoning_effort?: string | null default_grouping_model?: string | null default_grouping_reasoning_effort?: string | null default_chat_model?: string | null default_chat_reasoning_effort?: string | null updated_at?: string | null } export interface ProviderCredentialStatus { connected: boolean site?: string endpoint?: string api_key_last4?: string updated_at?: string | null } export interface TeamCredentialsStatus { datadog: ProviderCredentialStatus langsmith: ProviderCredentialStatus } export interface DatadogConnectBody { site: string api_key: string app_key: string } export interface LangSmithConnectBody { api_key: string endpoint?: string | null } export interface CurrentsCredentialStatus { connected: boolean api_key_last4?: string updated_at?: string | null } export interface CurrentsConnectBody { api_key: string } export interface NotionCredentialStatus { connected: boolean token_expires_at?: string | null updated_at?: string | null } export interface UserMapping { github_login: string work_email: string slack_user_id?: string | null source?: string status?: string created_at?: string updated_at?: string } export interface UserMappingsPage { items: Array total: number page: number page_size: number } export type UsageLeaderboardPeriod = "7d" | "30d" | "all" export interface UsageLeaderboardRow { rank: number user: { name: string github_login: string | null email: string | null } favorite_model: string agent_runs: number prs_opened: number merged_prs: number agent_loc: number additions: number deletions: number } export interface ReviewerStatsCounterRow { name: string count: number } export interface ReviewerStatsPayload { period: UsageLeaderboardPeriod reviewed_prs: number prs_with_findings: number findings_recorded: number surfaced_findings: number addressed_findings: number resolved_after_update: number dismissed_findings: number unresolved_surfaced_findings: number resolution_rate: number human_replies: number severity_counts: Record top_categories: Array generated_at_ms: number | null } export interface UsageLeaderboardPayload { period: UsageLeaderboardPeriod rows: Array total_members: number current_user_rank: number | null generated_at_ms: number | null reviewer_stats: ReviewerStatsPayload } export interface Repository { full_name: string private: boolean } export interface Installation { id: number account: string | null account_type: string | null } export interface ReposPayload { installations: Array repositories: Array } export type ReviewStyleStatus = "idle" | "running" | "completed" | "failed" export interface ReviewStyle { full_name: string owner?: string name?: string status: ReviewStyleStatus custom_prompt: string | null analysis_summary: string | null top_reviewers: Array prs_sampled: number reviews_sampled: number analysis_thread_id: string | null analysis_run_id: string | null error: string | null created_by?: string created_at?: string updated_at?: string } export interface AgentInstructions { full_name: string owner?: string name?: string instructions: string created_by?: string created_at?: string updated_at?: string } export type RepoSnapshotStatus = "none" | "building" | "ready" | "failed" export interface RepoSnapshot { full_name: string owner?: string name?: string dockerfile: string snapshot_id: string | null snapshot_name: string | null status: RepoSnapshotStatus status_message: string | null build_log: string | null fs_capacity_bytes?: number vcpus?: number mem_bytes?: number target?: string | null build_args?: Record | null build_started_at?: string | null last_built_at?: string | null created_by?: string created_at?: string updated_at?: string } export interface RepoSnapshotUpdateBody { dockerfile: string fs_capacity_bytes?: number | null vcpus?: number | null mem_bytes?: number | null target?: string | null build_args?: Record | null } export type FindingSeverity = "low" | "medium" | "high" | "critical" export type FindingConfidence = "low" | "medium" | "high" export type FindingStatus = "open" | "resolved" | "dismissed" export type FindingGroup = "bug" | "investigate" | "informational" export interface FindingInteraction { kind: "human_reply" | "bot_reply" author?: string body?: string created_at?: string } export interface ReviewFinding { id: string severity: FindingSeverity confidence: FindingConfidence category: string title: string description: string suggestion: string | null file: string start_line: number | null end_line: number | null side: "LEFT" | "RIGHT" in_diff: boolean status: FindingStatus outdated: boolean resolution_note: string | null diff_hunk: string | null github_thread_resolved: boolean github_review_comment_id: number | null interactions: Array group: FindingGroup } export interface ReviewCommentCreate { path: string line: number side: "LEFT" | "RIGHT" body: string start_line?: number | null start_side?: "LEFT" | "RIGHT" | null } export interface ReviewCommentResult { id: number html_url: string } export interface PrReviewComment { id: number author: string author_avatar_url: string path: string line: number | null side: "LEFT" | "RIGHT" body: string html_url: string created_at: string is_open_swe: boolean // Outdated: the line no longer appears in the current diff, so it can't render inline. is_outdated: boolean } export interface ReviewCommentsPayload { comments: Array } export interface ReviewCounts { open: number resolved: number dismissed: number bugs: number flags: number } export interface ReviewSummary { thread_id: string owner: string repo: string number: number title: string url: string head_ref: string base_ref: string author: string head_sha: string watch: boolean status: "running" | "error" | "idle" counts: ReviewCounts updated_at: string | null full_name?: string } export interface ReviewListPayload { reviews: Array page: number has_more: boolean } export interface ReviewUserRef { login: string avatar_url?: string | null } export interface ReviewCheckRun { name: string status: string conclusion: string | null url: string | null } export interface ReviewPrDetails { state: string title: string body: string additions: number deletions: number changed_files: number commits: number head_sha: string head_ref: string base_ref: string author: ReviewUserRef | null assignees: Array requested_reviewers: Array labels: Array<{ name: string; color: string | null }> } export interface ReviewDiffGroup { index: number title: string summary: string files: Array } export interface ReviewDetail extends ReviewSummary { pr: ReviewPrDetails checks: Array findings: Array diff_groups: Array diff_groups_stale: boolean } export interface ReviewDiffFile { path: string previousPath: string | null status: "added" | "removed" | "modified" | "renamed" additions: number deletions: number originalContent: string modifiedContent: string unrenderable?: boolean } export interface ReviewDiffPayload { files: Array total_additions: number total_deletions: number truncated: boolean } export interface ReviewChatMeta { available: boolean assistant_id: string } export interface ReviewChatThread { thread_id: string title: string updated_at?: string | null } /** * Absolute base URL for the PR chat's LangGraph StreamProvider. The SDK builds * request URLs as `new URL(apiUrl + path)`, so this must be absolute — a * same-origin base is promoted using the current origin. */ export function reviewChatApiBase( owner: string, repo: string, number: number ): string { const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat` if (/^https?:\/\//.test(path)) return path if (typeof window !== "undefined") { return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}` } return path } export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings" export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical" export interface ReviewerEvalConfig { dataset_name: string experiment_prefix: string max_concurrency: number langsmith_project: string langgraph_url: string assistant_id: string model_id: string reasoning_effort: string score_mode: ReviewerEvalScoreMode severity_threshold: ReviewerEvalSeverity cap: number } export interface ReviewerEvalProgress { completed: number total: number | null } export interface ReviewerEvalStatus { name: string status: "idle" | "running" | "completed" | "failed" run_name?: string langsmith_project: string limit: number | null config_snapshot?: ReviewerEvalConfig started_at: string | null finished_at: string | null created_by: string | null pid: number | null exit_code: number | null experiment_url: string | null error: string | null log_tail: string | null progress?: ReviewerEvalProgress | null github_run_url?: string | null trigger?: string | null updated_at: string } export const api = { me: () => request("/me"), options: () => request("/options"), profile: () => request("/profile"), saveProfile: (body: ProfileUpdate) => request("/profile", { method: "PUT", body: JSON.stringify(body) }), repos: () => request("/repos"), listReviewStyles: () => request>("/review-styles"), createReviewStyle: (full_name: string) => request("/review-styles", { method: "POST", body: JSON.stringify({ full_name }), }), getReviewStyle: (full_name: string) => request(`/review-styles/${encodeURIComponent(full_name)}`), saveReviewStylePrompt: (full_name: string, custom_prompt: string) => request(`/review-styles/${encodeURIComponent(full_name)}`, { method: "PUT", body: JSON.stringify({ custom_prompt }), }), analyzeReviewStyle: (full_name: string) => request( `/review-styles/${encodeURIComponent(full_name)}/analyze`, { method: "POST", } ), cancelReviewStyle: (full_name: string) => request( `/review-styles/${encodeURIComponent(full_name)}/cancel`, { method: "POST", } ), deleteReviewStyle: (full_name: string) => request(`/review-styles/${encodeURIComponent(full_name)}`, { method: "DELETE", }), listAgentInstructions: () => request>("/agent-instructions"), createAgentInstructions: (full_name: string) => request("/agent-instructions", { method: "POST", body: JSON.stringify({ full_name }), }), getAgentInstructions: (full_name: string) => request( `/agent-instructions/${encodeURIComponent(full_name)}` ), saveAgentInstructions: (full_name: string, instructions: string) => request( `/agent-instructions/${encodeURIComponent(full_name)}`, { method: "PUT", body: JSON.stringify({ instructions }), } ), deleteAgentInstructions: (full_name: string) => request(`/agent-instructions/${encodeURIComponent(full_name)}`, { method: "DELETE", }), listRepoSnapshots: () => request>("/repo-snapshots"), createRepoSnapshot: (full_name: string) => request("/repo-snapshots", { method: "POST", body: JSON.stringify({ full_name }), }), getRepoSnapshot: (full_name: string) => request(`/repo-snapshots/${encodeURIComponent(full_name)}`), getRepoSnapshotTemplate: (full_name: string) => request<{ dockerfile: string }>( `/repo-snapshots/template?full_name=${encodeURIComponent(full_name)}` ), saveRepoSnapshot: (full_name: string, body: RepoSnapshotUpdateBody) => request(`/repo-snapshots/${encodeURIComponent(full_name)}`, { method: "PUT", body: JSON.stringify(body), }), buildRepoSnapshot: (full_name: string) => request( `/repo-snapshots/${encodeURIComponent(full_name)}/build`, { method: "POST" } ), deleteRepoSnapshot: (full_name: string) => request(`/repo-snapshots/${encodeURIComponent(full_name)}`, { method: "DELETE", }), getTeamSettings: () => request("/team-settings"), saveTeamSettings: (body: TeamSettings) => request("/team-settings", { method: "PUT", body: JSON.stringify(body), }), getTeamCredentials: () => request("/team-credentials"), connectDatadog: (body: DatadogConnectBody) => request("/team-credentials/datadog", { method: "PUT", body: JSON.stringify(body), }), disconnectDatadog: () => request("/team-credentials/datadog", { method: "DELETE", }), connectLangSmith: (body: LangSmithConnectBody) => request("/team-credentials/langsmith", { method: "PUT", body: JSON.stringify(body), }), disconnectLangSmith: () => request("/team-credentials/langsmith", { method: "DELETE", }), getMyCurrentsStatus: () => request("/my-credentials/currents"), connectCurrents: (body: CurrentsConnectBody) => request("/my-credentials/currents", { method: "PUT", body: JSON.stringify(body), }), disconnectCurrents: () => request("/my-credentials/currents", { method: "DELETE", }), getMyNotionStatus: () => request("/my-credentials/notion"), disconnectNotion: () => request("/my-credentials/notion", { method: "DELETE", }), listEnabledReviewRepos: () => request<{ repos: Array }>("/enabled-review-repos"), setEnabledReviewRepo: (full_name: string, enabled: boolean) => request<{ repos: Array }>("/enabled-review-repos", { method: "PUT", body: JSON.stringify({ full_name, enabled }), }), usageLeaderboard: (period: UsageLeaderboardPeriod = "30d", limit = 10) => request( `/agent-usage-leaderboard?period=${encodeURIComponent(period)}&limit=${limit}` ), myMapping: () => request>("/my-mapping"), adminListUserMappings: (page = 1, pageSize = 20) => request( `/admin/user-mappings?page=${page}&page_size=${pageSize}` ), adminDeleteUserMapping: (github_login: string) => request<{ deleted: boolean }>( `/admin/user-mappings/${encodeURIComponent(github_login)}`, { method: "DELETE" } ), listReviews: (page: number, mine: boolean) => request(`/reviews?page=${page}&mine=${mine}`), getReview: (owner: string, repo: string, number: number) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}` ), getReviewDiff: (owner: string, repo: string, number: number) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff` ), getReviewChat: (owner: string, repo: string, number: number) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat` ), listReviewChatThreads: (owner: string, repo: string, number: number) => request<{ threads: Array }>( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads` ), deleteReviewChatThread: ( owner: string, repo: string, number: number, threadId: string ) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`, { method: "DELETE" } ), reReview: (owner: string, repo: string, number: number) => request<{ success: boolean queued: boolean thread_id: string pr_url: string }>( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`, { method: "POST" } ), createReviewComment: ( owner: string, repo: string, number: number, body: ReviewCommentCreate ) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments`, { method: "POST", body: JSON.stringify(body) } ), listReviewComments: (owner: string, repo: string, number: number) => request( `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments` ), getReviewerEval: () => request("/admin/evals/reviewer"), logout: () => request("/auth/logout", { method: "POST" }), } export function loginUrl(redirectTo?: string): string { const target = redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "") const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : "" return `${API_BASE}/dashboard/api/auth/login${qs}` } export function slackConnectUrl(): string { return `${API_BASE}/dashboard/api/slack/login` } export function notionConnectUrl(): string { return `${API_BASE}/dashboard/api/notion/login` }