Loom / app /src /api /github.ts
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
10.9 kB
// Typed client for the GitHub integration backend (critique-service/github_integration.py).
// Mirrors all /api/auth/*, /api/github/*, /api/workspaces/*, /api/registry/* routes.
// Uses githubSessionId as bearer (the user_id from GitHub OAuth — not the rotation secret).
import type { Settings } from "./panel";
import { ApiError } from "./panel";
// --- Types ----------------------------------------------------------------
export interface GithubStatus {
authenticated: boolean;
github_username: string | null;
hf_username: string | null;
}
export interface Repo {
full_name: string;
name: string;
owner: string;
private: boolean;
default_branch: string;
description: string;
updated_at: string;
clone_url: string;
}
export interface Branch {
name: string;
sha: string;
}
export interface Workspace {
id: string;
user_id: string;
source_repo: string | null;
source_branch: string | null;
source_branches: string[] | null;
current_branch: string;
sandbox_path: string;
hf_space_id: string | null;
auto_sync: number;
visibility: string;
title: string;
description: string;
created_at: string;
last_modified: string;
}
export interface PushRequest {
status: string;
request_id: string;
action: string;
created_at?: number;
branch?: string;
title?: string;
}
export interface PushResult {
commit_sha: string;
branch: string;
}
export interface PrResult {
number: number;
html_url: string;
state: string;
title: string;
}
export interface PushLog {
id: string;
workspace_id: string;
target_repo: string;
target_branch: string;
commit_sha: string;
commit_message: string;
push_type: string;
pr_number: number | null;
pr_url: string | null;
approved_by_user: number;
auto_approved: number;
files_changed: number;
created_at: string;
}
export interface RegistryEntry {
workspace_id: string;
indexed_at: string;
owner_username: string;
owner_avatar: string;
title: string;
description: string;
source_repo: string | null;
hf_space_url: string | null;
}
export interface RegistryPage {
items: RegistryEntry[];
total: number;
page: number;
per_page: number;
}
// --- Client --------------------------------------------------------------
export class GitHubClient {
constructor(
private settings: Settings,
private onUnauthorized?: () => void,
) {}
private get sessionId(): string {
return this.settings.githubSessionId || "";
}
private async raw(path: string, init?: RequestInit): Promise<Response> {
const token = this.sessionId;
return fetch(this.settings.baseUrl + path, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
}
private async req<T>(path: string, init?: RequestInit): Promise<T> {
let r = await this.raw(path, init);
if (!r.ok) {
if (r.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
}
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j?.error) msg = j.error;
} catch {
/* non-json error body */
}
throw new ApiError(r.status, msg);
}
return (await r.json()) as T;
}
// -- Auth ----------------------------------------------------------------
/** URL to redirect the browser to for GitHub OAuth. */
loginUrl(): string {
return this.settings.baseUrl + "/api/auth/github/login";
}
/** Check if GitHub is connected for this session. */
status(): Promise<GithubStatus> {
return this.req<GithubStatus>("/api/auth/status");
}
/** Disconnect GitHub (removes stored token). */
disconnect(): Promise<{ disconnected: boolean }> {
return this.req<{ disconnected: boolean }>("/api/auth/github/disconnect", {
method: "POST",
});
}
// -- Repos ---------------------------------------------------------------
repos(page = 1, perPage = 30): Promise<{ repos: Repo[] }> {
return this.req<{ repos: Repo[] }>(
`/api/github/repos?page=${page}&per_page=${perPage}&sort=updated`,
);
}
branches(owner: string, repo: string): Promise<{ branches: Branch[] }> {
return this.req<{ branches: Branch[] }>(
`/api/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
);
}
contents(
owner: string,
repo: string,
path: string,
ref = "main",
): Promise<Record<string, unknown>> {
return this.req<Record<string, unknown>>(
`/api/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref)}`,
);
}
tree(
owner: string,
repo: string,
ref = "main",
): Promise<{ tree: Array<Record<string, unknown>> }> {
return this.req<{ tree: Array<Record<string, unknown>> }>(
`/api/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree?ref=${encodeURIComponent(ref)}`,
);
}
// -- Workspaces ----------------------------------------------------------
listWorkspaces(): Promise<{ workspaces: Workspace[] }> {
return this.req<{ workspaces: Workspace[] }>("/api/workspaces");
}
createWorkspace(opts: {
title: string;
description?: string;
source_repo?: string;
source_branch?: string;
source_branches?: string[];
visibility?: string;
auto_sync?: boolean;
}): Promise<Workspace> {
return this.req<Workspace>("/api/workspaces", {
method: "POST",
body: JSON.stringify(opts),
});
}
getWorkspace(id: string): Promise<Workspace> {
return this.req<Workspace>(`/api/workspaces/${encodeURIComponent(id)}`);
}
updateWorkspace(
id: string,
fields: Partial<Pick<Workspace, "title" | "description" | "visibility" | "current_branch" | "auto_sync">>,
): Promise<Workspace> {
return this.req<Workspace>(`/api/workspaces/${encodeURIComponent(id)}/update`, {
method: "POST",
body: JSON.stringify(fields),
});
}
deleteWorkspace(id: string): Promise<{ deleted: string }> {
return this.req<{ deleted: string }>(
`/api/workspaces/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
}
// -- Git Operations ------------------------------------------------------
commit(
id: string,
message: string,
): Promise<{ commit_sha: string }> {
return this.req<{ commit_sha: string }>(
`/api/workspaces/${encodeURIComponent(id)}/commit`,
{ method: "POST", body: JSON.stringify({ message }) },
);
}
push(
id: string,
opts: { branch?: string; force?: boolean; auto_approve?: boolean } = {},
): Promise<PushResult | PushRequest> {
return this.req<PushResult | PushRequest>(
`/api/workspaces/${encodeURIComponent(id)}/push`,
{ method: "POST", body: JSON.stringify(opts) },
);
}
createPr(
id: string,
opts: {
title: string;
body?: string;
head_branch?: string;
base_branch?: string;
auto_approve?: boolean;
},
): Promise<PrResult | PushRequest> {
return this.req<PrResult | PushRequest>(
`/api/workspaces/${encodeURIComponent(id)}/pr`,
{ method: "POST", body: JSON.stringify(opts) },
);
}
checkout(
id: string,
branch: string,
): Promise<{ branch: string }> {
return this.req<{ branch: string }>(
`/api/workspaces/${encodeURIComponent(id)}/checkout`,
{ method: "POST", body: JSON.stringify({ branch }) },
);
}
// -- Push Approval -------------------------------------------------------
pushRequestStatus(requestId: string): Promise<PushRequest> {
return this.req<PushRequest>(
`/api/auth/push-requests/${encodeURIComponent(requestId)}`,
);
}
resolvePushRequest(
requestId: string,
approved: boolean,
): Promise<{ status: string; commit_sha?: string; html_url?: string }> {
return this.req<{ status: string; commit_sha?: string; html_url?: string }>(
`/api/auth/push-requests/${encodeURIComponent(requestId)}/resolve`,
{ method: "POST", body: JSON.stringify({ approved }) },
);
}
// -- Push Logs -----------------------------------------------------------
pushLogs(id: string, limit = 50): Promise<{ logs: PushLog[] }> {
return this.req<{ logs: PushLog[] }>(
`/api/workspaces/${encodeURIComponent(id)}/logs?limit=${limit}`,
);
}
// -- Registry ------------------------------------------------------------
publish(id: string): Promise<RegistryEntry> {
return this.req<RegistryEntry>(
`/api/workspaces/${encodeURIComponent(id)}/publish`,
{ method: "POST" },
);
}
unpublish(id: string): Promise<{ unpublished: string }> {
return this.req<{ unpublished: string }>(
`/api/workspaces/${encodeURIComponent(id)}/unpublish`,
{ method: "POST" },
);
}
listRegistry(
params: { page?: number; per_page?: number; sort?: string; search?: string } = {},
): Promise<RegistryPage> {
const q = new URLSearchParams();
if (params.page) q.set("page", String(params.page));
if (params.per_page) q.set("per_page", String(params.per_page));
if (params.sort) q.set("sort", params.sort);
if (params.search) q.set("search", params.search);
const qs = q.toString();
return this.req<RegistryPage>(`/api/registry/spaces${qs ? "?" + qs : ""}`);
}
}
/** Exchange a GitHub OAuth code for a session ID via the backend.
* Used by the proxy flow where the main Space forwards the callback to user Spaces.
* Returns { session_id, next?: "hf" } — if next="hf", HF OAuth should follow. */
export async function exchangeGitHubCode(
code: string,
state: string,
baseUrl: string,
): Promise<{ session_id: string; next?: string }> {
const r = await fetch(
`${baseUrl}/api/auth/github/proxy-exchange?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
);
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j?.error) msg = j.error;
} catch {
/* non-json */
}
throw new ApiError(r.status, msg);
}
return r.json() as Promise<{ session_id: string; next?: string }>;
}
/** Exchange a HF OAuth code for a session ID via the backend. */
export async function exchangeHFCode(
code: string,
state: string,
baseUrl: string,
): Promise<{ session_id: string }> {
const r = await fetch(
`${baseUrl}/api/auth/hf/proxy-exchange?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
);
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j?.error) msg = j.error;
} catch {
/* non-json */
}
throw new ApiError(r.status, msg);
}
return r.json() as Promise<{ session_id: string }>;
}