OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export type ServiceStatus = 'running' | 'stopped' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface InfraService {
|
||||
name: string;
|
||||
container: string;
|
||||
status: ServiceStatus;
|
||||
ports: string[];
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
export interface InfraStatusResponse {
|
||||
services: InfraService[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export async function getInfraStatus(): Promise<InfraStatusResponse> {
|
||||
const res = await fetch(`${BASE_URL}/api/infra/status`);
|
||||
if (!res.ok) throw new Error('Failed to fetch infra status');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function infraServiceAction(
|
||||
service: string,
|
||||
action: 'start' | 'stop' | 'restart'
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/infra/${service}/${action}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed to ${action} ${service}`);
|
||||
}
|
||||
|
||||
export function streamComposeUp(onLine: (line: string) => void, onDone: () => void): EventSource {
|
||||
const src = new EventSource(`${BASE_URL}/api/infra/compose/up/stream`);
|
||||
src.onmessage = (e) => onLine(e.data);
|
||||
src.onerror = () => { onDone(); src.close(); };
|
||||
return src;
|
||||
}
|
||||
|
||||
export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource {
|
||||
const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`);
|
||||
src.onmessage = (e) => onLine(e.data);
|
||||
src.onerror = () => { onDone(); src.close(); };
|
||||
return src;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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 {
|
||||
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`);
|
||||
if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
name: string;
|
||||
commitSha: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export async function listGiteaBranches(): Promise<GiteaBranch[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/gitea/branches`);
|
||||
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 = 'master',
|
||||
): 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();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ProvisioningProgressEvent, ProvisioningRequest, TenantRecord } from '../types/provisioning';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export async function submitProvisioningJob(request: ProvisioningRequest): Promise<string> {
|
||||
const res = await fetch(`${BASE_URL}/api/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to queue job: ${res.statusText}`);
|
||||
|
||||
const data = await res.json();
|
||||
return data.id as string;
|
||||
}
|
||||
|
||||
export async function getTenants(): Promise<TenantRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/tenants`);
|
||||
if (!res.ok) throw new Error(`Failed to load tenants: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function subscribeToTenantLogs(
|
||||
subdomain: string,
|
||||
onLine: (line: string) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/tenants/${subdomain}/logs`);
|
||||
source.onmessage = (e) => { if (e.data) onLine(e.data); };
|
||||
source.onerror = onError;
|
||||
return source;
|
||||
}
|
||||
|
||||
export function subscribeToJobStream(
|
||||
jobId: string,
|
||||
onEvent: (event: ProvisioningProgressEvent) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/provision/${jobId}/stream`);
|
||||
|
||||
source.onmessage = (e) => {
|
||||
try { onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
source.onerror = onError;
|
||||
return source;
|
||||
}
|
||||
|
||||
export interface ImageBuildStatus {
|
||||
imageName: string | null;
|
||||
builtAt: string | null;
|
||||
lastMessage: string;
|
||||
isBuilding: boolean;
|
||||
}
|
||||
|
||||
export async function getImageStatus(): Promise<ImageBuildStatus> {
|
||||
const res = await fetch(`${BASE_URL}/api/image/status`);
|
||||
if (!res.ok) throw new Error(`Failed to get image status: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a build and streams log lines. Calls onLine for each log chunk, onDone when finished. */
|
||||
export function triggerImageBuild(
|
||||
onLine: (line: string) => void,
|
||||
onDone: (success: boolean) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/image/build-stream`);
|
||||
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done) { onDone(true); source.close(); }
|
||||
else if (msg.line) onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
source.onerror = (e) => { onDone(false); onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
/** POST to kick off the build — returns immediately; use subscribeToJobStream for progress */
|
||||
export async function startImageBuild(): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Build trigger failed: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ── Release API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TenantReleaseResult {
|
||||
subdomain: string;
|
||||
containerName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseRecord {
|
||||
id: string;
|
||||
environment: string;
|
||||
imageName: string;
|
||||
status: 'Running' | 'Succeeded' | 'PartialFailure' | 'Failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
tenants: TenantReleaseResult[];
|
||||
}
|
||||
|
||||
export async function getReleaseHistory(): Promise<ReleaseRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/release/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get release history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a release to the given environment and streams log lines as SSE. */
|
||||
export function triggerRelease(
|
||||
env: string,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: ReleaseRecord) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/release/${env}`);
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done && msg.release) { onDone(msg.release as ReleaseRecord); source.close(); }
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
source.onerror = (e) => { onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
// ── Project Build API ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectDefinition {
|
||||
name: string;
|
||||
kind: 'DotnetProject' | 'NpmProject';
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
kind: 'DockerImage' | 'DotnetProject' | 'NpmProject';
|
||||
target: string;
|
||||
status: 'Running' | 'Succeeded' | 'Failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<ProjectDefinition[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/builds/projects`);
|
||||
if (!res.ok) throw new Error(`Failed to get projects: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getBuildHistory(): Promise<BuildRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/builds/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a project build and streams log lines. */
|
||||
export function triggerProjectBuild(
|
||||
projectName: string,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: BuildRecord) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/builds/${encodeURIComponent(projectName)}`);
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done && msg.build) { onDone(msg.build as BuildRecord); source.close(); }
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
source.onerror = (e) => { onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
// ── Git History API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export async function getGitLog(path?: string, limit = 20): Promise<GitCommit[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (path) params.set('path', path);
|
||||
const res = await fetch(`${BASE_URL}/api/git/log?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to get git log: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Promotion / Branch Ladder API ────────────────────────────────────────────
|
||||
|
||||
export interface BranchStatus {
|
||||
branch: string;
|
||||
exists: boolean;
|
||||
shortHash: string | null;
|
||||
lastCommitSummary: string | null;
|
||||
aheadOfNext: number;
|
||||
behindNext: number;
|
||||
unreleasedLines: string[];
|
||||
}
|
||||
|
||||
export interface PromotionRecord {
|
||||
id: string;
|
||||
fromBranch: string;
|
||||
toBranch: string;
|
||||
requestedBy: string;
|
||||
note: string | null;
|
||||
status: 'Pending' | 'Running' | 'Succeeded' | 'Failed';
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
commitCount: number;
|
||||
commitLines: string[];
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export async function getLadderStatus(): Promise<BranchStatus[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/ladder`);
|
||||
if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPromotionHistory(): Promise<PromotionRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get promotion history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a promotion and streams SSE lines. Calls onDone with the final record. */
|
||||
export function triggerPromotion(
|
||||
from: string,
|
||||
to: string,
|
||||
requestedBy: string,
|
||||
note: string | undefined,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: PromotionRecord) => void,
|
||||
onError: (err: string) => void,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, requestedBy, note }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) { onError(res.statusText); return; }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!cancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const chunk of parts) {
|
||||
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const msg = JSON.parse(dataLine);
|
||||
if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord);
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}
|
||||
Reference in New Issue
Block a user