344 lines
13 KiB
TypeScript
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();
|
|
}
|