jacquardSnapshot

← snapshot

6191 bytes
import type {
  ApiErrorBody,
  Awaiting,
  BlobDto,
  DecisionDto,
  InitRepoRequest,
  ImportReport,
  InitRepoResponse,
  IntroductionRow,
  PromotePreview,
  PublicationRow,
  RefDto,
  RemarkDto,
  ShelfIndex,
  RemarkKind,
  RepoDetail,
  RepoSummary,
  SnapshotDetail,
  SnapshotDto,
  TreeEntryDto,
  VerdictDto,
} from "./types";

/**
 * One typed client for both sides: server components hit jac-serve
 * directly; the browser goes through the Next /api rewrite.
 */
function base(): string {
  if (typeof window !== "undefined") return "";
  return process.env.JAC_SERVE_URL ?? "http://localhost:8787";
}

export class ApiError extends Error {
  readonly code: string;
  readonly status: number;

  constructor(status: number, code: string, message: string) {
    super(message);
    this.code = code;
    this.status = status;
  }
}

async function request<T>(
  method: "GET" | "POST",
  path: string,
  body?: unknown,
): Promise<T> {
  const res = await fetch(`${base()}${path}`, {
    method,
    cache: "no-store",
    headers: body === undefined ? undefined : { "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!res.ok) {
    let code = "unknown";
    let message = `${res.status} ${res.statusText}`;
    try {
      const parsed = (await res.json()) as ApiErrorBody;
      code = parsed.error.code;
      message = parsed.error.message;
    } catch {
      // non-JSON error body; keep the status line
    }
    throw new ApiError(res.status, code, message);
  }
  return (await res.json()) as T;
}

const get = <T>(path: string) => request<T>("GET", path);
const post = <T>(path: string, body?: unknown) => request<T>("POST", path, body);

const enc = encodeURIComponent;

export const api = {
  awaiting: () => get<Awaiting>("/api/awaiting"),
  listRepos: () => get<{ repos: RepoSummary[] }>("/api/repos"),
  repo: (slug: string) => get<RepoDetail>(`/api/repos/${enc(slug)}`),
  initRepo: (req: InitRepoRequest) => post<InitRepoResponse>("/api/repos", req),
  seedDemo: () => post<{ repos: string[] }>("/api/demo"),

  refs: (slug: string) => get<{ refs: RefDto[] }>(`/api/repos/${enc(slug)}/refs`),
  branch: (slug: string, name: string, fromRef: string) =>
    post<RefDto>(`/api/repos/${enc(slug)}/refs`, { name, from: { ref: fromRef } }),

  log: (slug: string, ref: string, limit = 200) =>
    get<{ ref: string; head: string; entries: SnapshotDto[] }>(
      `/api/repos/${enc(slug)}/log?ref=${enc(ref)}&limit=${limit}`,
    ),
  snapshot: (slug: string, id: string) =>
    get<SnapshotDetail>(`/api/repos/${enc(slug)}/snapshots/${id}`),
  tree: (slug: string, id: string, path: string) =>
    get<{ path: string; entries: TreeEntryDto[] }>(
      `/api/repos/${enc(slug)}/snapshots/${id}/tree${path ? `?path=${enc(path)}` : ""}`,
    ),
  blob: (slug: string, id: string) => get<BlobDto>(`/api/repos/${enc(slug)}/blobs/${id}`),

  decisions: (slug: string, state?: "unsettled" | "settled") =>
    get<{ decisions: DecisionDto[] }>(
      `/api/repos/${enc(slug)}/decisions${state ? `?state=${state}` : ""}`,
    ),
  decision: (slug: string, id: string) =>
    get<DecisionDto>(`/api/repos/${enc(slug)}/decisions/${id}`),
  attest: (slug: string, id: string, by: string, statement: string) =>
    post<DecisionDto>(`/api/repos/${enc(slug)}/decisions/${id}/attest`, {
      by,
      statement,
    }),

  promotePreview: (slug: string, from: string, into: string) =>
    get<PromotePreview>(
      `/api/repos/${enc(slug)}/promote/preview?from=${enc(from)}&into=${enc(into)}`,
    ),
  promote: (slug: string, from: string, into: string) =>
    post<VerdictDto>(`/api/repos/${enc(slug)}/promote`, { from, into }),

  addHuman: (slug: string, display_name: string) =>
    post<{ kind: string; id: string; display_name?: string }>(
      `/api/repos/${enc(slug)}/identities`,
      { human: { display_name } },
    ),
  addAgent: (slug: string, model: string) =>
    post<{ kind: string; id: string; display_name?: string }>(
      `/api/repos/${enc(slug)}/identities`,
      { agent: { model } },
    ),

  shelf: (path: string) => get<ShelfIndex>(`/api/shelf?path=${enc(path)}`),

  importGit: (body: { path: string; commits?: number; paths?: number }) =>
    post<{
      slug: string;
      source: string;
      snapshots: {
        id: string;
        git_sha: string;
        message: string;
        provenance: string;
        because: string;
        assumed: boolean;
        files: number;
      }[];
      provenance: { human: number; agent: number; mixed: number; assumed: number };
      bounds: { commits: number; paths: number };
      honesty: string;
    }>("/api/import/git", body),

  importGithub: (body: {
    owner: string;
    repo: string;
    pr: number;
    token?: string;
  }) => post<ImportReport>("/api/import/github", body),

  remarks: (slug: string, state?: "open" | "settled") =>
    get<{ remarks: RemarkDto[]; open: number }>(
      `/api/repos/${enc(slug)}/remarks${state ? `?state=${state}` : ""}`,
    ),
  raiseRemark: (
    slug: string,
    body: {
      anchor: { kind: string; id: string; quote?: string };
      body: string;
      kind: RemarkKind;
      by: string;
    },
  ) => post<RemarkDto>(`/api/repos/${enc(slug)}/remarks`, body),
  settleRemark: (
    slug: string,
    id: string,
    outcome: { outcome: "drafted"; decision: string } | { outcome: "declined"; because?: string },
  ) => post<RemarkDto>(`/api/repos/${enc(slug)}/remarks/${enc(id)}/settle`, outcome),

  proposeDecision: (
    slug: string,
    body: {
      title: string;
      rationale: string;
      families: string[];
      actor: { kind: "human" | "agent"; id: string };
      scope: string[];
    },
  ) => post<DecisionDto>(`/api/repos/${enc(slug)}/decisions`, body),

  sketch: (slug: string, kind: "snapshot" | "decision", id: string) =>
    get<{ kind: string; lanes: string[] }>(
      `/api/repos/${enc(slug)}/sketches/${kind}/${id}`,
    ),
  publications: () =>
    get<{ publications: PublicationRow[]; note: string }>("/api/rendezvous/publications"),
  introductions: () =>
    get<{ introductions: IntroductionRow[] }>("/api/rendezvous/introductions"),
};