Files
OPC/clarity.controlplane/src/api/opcApi.ts
T
2026-04-26 11:32:23 -04:00

344 lines
13 KiB
TypeScript

import type { Opc, OpcNote, OpcArtifact, OpcType, OpcStatus, OpcPriority } from '../types/opc';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
// ── OPC CRUD ──────────────────────────────────────────────────────────────────
export async function getNextNumber(): Promise<string> {
const res = await fetch(`${BASE_URL}/api/opc/next-number`);
if (!res.ok) throw new Error('Failed to fetch next OPC number');
const data = await res.json();
return data.number as string;
}
export async function listOpcs(type?: string, status?: string): Promise<Opc[]> {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (status && status !== 'all') params.set('status', status);
const res = await fetch(`${BASE_URL}/api/opc?${params}`);
if (!res.ok) throw new Error(`Failed to load OPCs: ${res.statusText}`);
// API returns OpcRecord (camelCase from .NET JsonSerializerDefaults.Web)
return (await res.json()).map(mapRecord);
}
export async function getOpc(id: string): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc/${id}`);
if (!res.ok) throw new Error(`Failed to load OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
export async function createOpc(req: {
title: string; type: OpcType; priority: OpcPriority;
assignee: string; description: string;
}): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to create OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
export async function updateOpc(id: string, req: {
title?: string; description?: string; type?: OpcType;
status?: OpcStatus; priority?: OpcPriority; assignee?: string;
}): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to update OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
// ── Notes ─────────────────────────────────────────────────────────────────────
export async function listNotes(opcId: string): Promise<OpcNote[]> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`);
if (!res.ok) throw new Error(`Failed to load notes: ${res.statusText}`);
return (await res.json()).map(mapNote);
}
export async function addNote(opcId: string, author: string, content: string): Promise<OpcNote> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, content }),
});
if (!res.ok) throw new Error(`Failed to add note: ${res.statusText}`);
return mapNote(await res.json());
}
// ── Artifacts ─────────────────────────────────────────────────────────────────
export async function listArtifacts(opcId: string, type?: string): Promise<OpcArtifact[]> {
const params = type ? `?type=${type}` : '';
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts${params}`);
if (!res.ok) throw new Error(`Failed to load artifacts: ${res.statusText}`);
return (await res.json()).map(mapArtifact);
}
export async function createArtifact(opcId: string, req: {
artifactType: string; title: string; content: string;
}): Promise<OpcArtifact> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to create artifact: ${res.statusText}`);
return mapArtifact(await res.json());
}
export async function updateArtifact(artifactId: string, req: {
artifactType: string; title: string; content: string;
}): Promise<OpcArtifact> {
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to update artifact: ${res.statusText}`);
return mapArtifact(await res.json());
}
export async function deleteArtifact(artifactId: string): Promise<void> {
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`Failed to delete artifact: ${res.statusText}`);
}
// ── Git commit linkage ────────────────────────────────────────────────────────
// Commits are linked by convention: developers include "OPC # XXXX" in their commit message.
// The git log endpoint supports ?grep=OPC+%230001 to filter.
export interface LinkedCommit {
repoKey: string;
hash: string;
shortHash: string;
author: string;
date: string;
subject: string;
files: string[];
}
export async function getLinkedCommits(opcNumber: string): Promise<LinkedCommit[]> {
const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50&repo=all`);
if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`);
return res.json(); // backend now returns { repoKey, hash, shortHash, author, date, subject, files }
}
// ── Pinned commits ────────────────────────────────────────────────────────────
export interface PinnedCommit {
opcId: string;
hash: string;
shortHash: string;
subject: string;
author: string;
pinnedAt: string;
pinnedBy: string;
}
function mapPinnedCommit(d: Record<string, unknown>): PinnedCommit {
return {
opcId: d.opcId as string,
hash: d.hash as string,
shortHash: d.shortHash as string,
subject: d.subject as string,
author: d.author as string,
pinnedAt: d.pinnedAt as string,
pinnedBy: d.pinnedBy as string,
};
}
export async function getPinnedCommits(opcId: string): Promise<PinnedCommit[]> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`);
if (!res.ok) throw new Error(`Failed to load pinned commits: ${res.statusText}`);
return (await res.json()).map(mapPinnedCommit);
}
export async function pinCommit(opcId: string, hash: string, pinnedBy: string): Promise<PinnedCommit> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, pinnedBy }),
});
if (!res.ok) {
const body = await res.text().catch(() => res.statusText);
throw new Error(body || res.statusText);
}
return mapPinnedCommit(await res.json());
}
export async function unpinCommit(opcId: string, hash: string): Promise<void> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits/${hash}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`Failed to unpin commit: ${res.statusText}`);
}
// ── Branch coverage ───────────────────────────────────────────────────────────
export interface BranchCoverage {
branch: string;
contains: boolean;
tipHash: string;
isHead: boolean;
}
export async function getBranchCoverage(hashes: string[]): Promise<BranchCoverage[]> {
if (hashes.length === 0) return [];
const res = await fetch(`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}`);
if (!res.ok) throw new Error(`Failed to get branch coverage: ${res.statusText}`);
return res.json();
}
// Query branch coverage against a specific repo from the Git:Repos registry.
// The backend ignores hashes it cannot resolve, so passing cross-repo hashes is safe.
export async function getBranchCoverageForRepo(repoKey: string, hashes: string[]): Promise<BranchCoverage[]> {
if (hashes.length === 0) return [];
const res = await fetch(
`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}&repo=${encodeURIComponent(repoKey)}`,
);
if (!res.ok) throw new Error(`Failed to get branch coverage for ${repoKey}: ${res.statusText}`);
return res.json();
}
// ── Changesets (paginated cross-repo commit log) ───────────────────────────────
// Reuses LinkedCommit shape — repoKey identifies which repo each commit came from.
export async function getChangesets(
page = 1,
limit = 25,
repo = 'all',
grep?: string,
): Promise<LinkedCommit[]> {
const params = new URLSearchParams({ page: String(page), limit: String(limit), repo });
if (grep) params.set('grep', grep);
const res = await fetch(`${BASE_URL}/api/git/log?${params}`);
if (!res.ok) throw new Error(`Failed to load changesets: ${res.statusText}`);
return res.json();
}
// ── Commit detail (full diff) ─────────────────────────────────────────────────
export interface CommitFile {
path: string;
oldPath: string;
status: string;
additions: number;
deletions: number;
patch: string;
}
export interface CommitDetail {
hash: string;
shortHash: string;
author: string;
email: string;
date: string;
subject: string;
body: string;
files: CommitFile[];
}
export async function getCommitDetail(hash: string): Promise<CommitDetail> {
const res = await fetch(`${BASE_URL}/api/git/commits/${hash}`);
if (!res.ok) throw new Error(`Failed to load commit ${hash}: ${res.statusText}`);
return res.json();
}
// ── AI assist ─────────────────────────────────────────────────────────────────
export async function aiAssist(prompt: string, context?: string): Promise<string> {
const res = await fetch(`${BASE_URL}/api/opc/ai-assist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, context }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`AI assist failed: ${err}`);
}
const data = await res.json();
return data.text as string;
}
// ── Field mappers (snake_case/PascalCase → camelCase) ─────────────────────────
// .NET JsonSerializerDefaults.Web produces camelCase already, so these are
// lightweight guards in case of null/missing fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapRecord(r: any): Opc {
return {
id: r.id,
number: r.number,
title: r.title,
description: r.description ?? '',
type: r.type as OpcType,
status: r.status as OpcStatus,
priority: r.priority as OpcPriority,
assignee: r.assignee ?? '',
createdAt: r.createdAt,
updatedAt: r.updatedAt,
notes: [], // loaded separately on drawer open
commits: [], // loaded separately on drawer open
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapNote(r: any): OpcNote {
return {
id: r.id,
author: r.author,
timestamp: r.createdAt,
content: r.content,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapArtifact(r: any): OpcArtifact {
return {
id: r.id,
opcId: r.opcId,
artifactType: r.artifactType,
title: r.title,
content: r.content,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
}
// ── Gitea branch integration ───────────────────────────────────────────────────
export interface GiteaBranch {
repoKey?: string; // present when querying ?repo=all
name: string;
commitSha: string;
protected: boolean;
}
export async function listGiteaBranches(repoKey?: string): Promise<GiteaBranch[]> {
const params = repoKey ? `?repo=${encodeURIComponent(repoKey)}` : '';
const res = await fetch(`${BASE_URL}/api/gitea/branches${params}`);
if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`);
return res.json();
}
export async function createGiteaBranch(
opcNumber: string,
opcTitle: string,
from = 'main',
): Promise<GiteaBranch> {
const res = await fetch(`${BASE_URL}/api/gitea/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opcNumber, opcTitle, from }),
});
if (!res.ok) {
const body = await res.text().catch(() => res.statusText);
throw new Error(body || res.statusText);
}
return res.json();
}