Files
mp-silma-ai-aide/src/api.ts
T
2026-07-18 13:14:12 -05:00

533 lines
14 KiB
TypeScript

import axios from "axios";
const rawGiteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL?.trim() ?? "";
const giteaToken = import.meta.env.VITE_GITEA_TOKEN?.trim() ?? "";
const rawGiteaRepoOwner = import.meta.env.VITE_GITEA_REPO_OWNER?.trim() ?? "";
const rawGiteaRepoName = import.meta.env.VITE_GITEA_REPO_NAME?.trim() ?? "";
export interface GiteaUser {
id: number;
login: string;
full_name?: string;
email?: string;
}
interface GiteaRepoOwner {
login: string;
}
interface GiteaRepoResponse {
name: string;
full_name?: string;
description?: string;
html_url?: string;
clone_url?: string;
ssh_url?: string;
updated_at?: string;
default_branch?: string;
private?: boolean;
owner?: GiteaRepoOwner;
}
interface GiteaBranchResponse {
name: string;
commit?: {
id?: string;
message?: string;
timestamp?: string;
url?: string;
};
}
interface GiteaIssueResponse {
number?: number;
title?: string;
body?: string;
content_version?: number;
state?: string;
html_url?: string;
created_at?: string;
updated_at?: string;
user?: {
login?: string;
};
comments?: number;
labels?: GiteaLabelResponse[];
}
interface GiteaIssueCommentResponse {
id?: number;
body?: string;
html_url?: string;
created_at?: string;
updated_at?: string;
user?: {
login?: string;
};
}
interface GiteaLabelResponse {
id: number;
name: string;
color?: string;
}
interface GiteaAttachmentResponse {
id?: number;
name?: string;
browser_download_url?: string;
download_url?: string;
size?: number;
created_at?: string;
}
export interface CreatedGiteaIssue {
number: number;
webUrl: string;
}
export interface GiteaIssueSummary {
number: number;
title: string;
body: string;
state: string;
webUrl: string;
createdAt: string;
updatedAt: string;
author: string;
comments: number;
labels: string[];
}
export interface GiteaIssueAttachment {
id: number;
name: string;
downloadUrl: string;
size: number;
createdAt: string;
}
export interface GiteaIssueComment {
id: number;
body: string;
webUrl: string;
createdAt: string;
updatedAt: string;
author: string;
}
export interface GiteaIssueDetail {
issue: GiteaIssueSummary;
comments: GiteaIssueComment[];
attachments: GiteaIssueAttachment[];
}
export interface GiteaRepoSummary {
name: string;
fullName: string;
description: string;
webUrl: string;
cloneUrl: string;
defaultBranch: string;
isPrivate: boolean;
lastCommitAt: string;
lastCommitSha: string;
lastCommitMessage: string;
}
export type GiteaIssueStateFilter = "open" | "closed" | "all";
function toIssueSummary(issue: GiteaIssueResponse, owner: string, repo: string, issueNumber = issue.number ?? 0): GiteaIssueSummary {
return {
number: issue.number ?? issueNumber,
title: issue.title || "Untitled issue",
body: issue.body || "",
state: issue.state || "unknown",
webUrl: issue.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${issueNumber}`,
createdAt: issue.created_at || "",
updatedAt: issue.updated_at || "",
author: issue.user?.login || "unknown",
comments: issue.comments ?? 0,
labels: issue.labels?.map((label) => label.name).filter(Boolean) ?? []
};
}
export const giteaConfig = {
baseUrl: rawGiteaBaseUrl,
hasToken: giteaToken.length > 0,
repoOwner: rawGiteaRepoOwner,
repoName: rawGiteaRepoName,
targetRepo:
rawGiteaRepoOwner.length > 0 && rawGiteaRepoName.length > 0
? `${rawGiteaRepoOwner}/${rawGiteaRepoName}`
: "",
isConfigured: rawGiteaBaseUrl.length > 0 && giteaToken.length > 0
};
export function getApiBaseUrl(baseUrl: string): string {
const normalized = baseUrl.replace(/\/+$/, "");
return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`;
}
function assertGiteaConfigured(): void {
if (!giteaConfig.isConfigured) {
throw new Error("Set VITE_GITEA_BASE_URL and VITE_GITEA_TOKEN before testing the connection.");
}
}
function createGiteaClient() {
assertGiteaConfigured();
return axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
headers: {
Authorization: `token ${giteaToken}`
}
});
}
function getGiteaErrorMessage(error: unknown, fallback: string): string {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error.message : fallback;
}
const status = error.response?.status;
const data = error.response?.data;
const detail =
typeof data === "string"
? data
: typeof data?.message === "string"
? data.message
: typeof data?.error === "string"
? data.error
: error.message;
return status ? `${fallback} Gitea returned ${status}: ${detail}` : `${fallback} ${detail}`;
}
function getLabelName(label: unknown): string {
if (typeof label !== "object" || !label || typeof (label as { name?: unknown }).name !== "string") {
return "";
}
return (label as { name: string }).name;
}
function normalizeIssueText(value: string): string {
return value.replace(/\r\n/g, "\n");
}
export async function getCurrentGiteaUser(): Promise<GiteaUser> {
const client = createGiteaClient();
const response = await client.get<GiteaUser>("/user");
return response.data;
}
export async function createGiteaIssue(
owner: string,
repo: string,
title: string,
body: string,
labelIds: number[] = []
): Promise<CreatedGiteaIssue> {
const client = createGiteaClient();
const response = await client.post<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues`, {
title,
body,
labels: labelIds
});
const number = response.data.number;
if (!number) {
throw new Error("Gitea did not return an issue number.");
}
return {
number,
webUrl: response.data.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${number}`
};
}
export async function updateGiteaIssue(
owner: string,
repo: string,
issueNumber: number,
updates: { title: string; body: string }
): Promise<GiteaIssueSummary> {
const client = createGiteaClient();
try {
const currentResponse = await client.get<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues/${issueNumber}`);
const updatePayload =
typeof currentResponse.data.content_version === "number"
? {
...updates,
content_version: currentResponse.data.content_version
}
: updates;
await client.patch<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues/${issueNumber}`, updatePayload);
const savedResponse = await client.get<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues/${issueNumber}`);
const savedIssue = toIssueSummary(savedResponse.data, owner, repo, issueNumber);
if (
savedIssue.title !== updates.title ||
normalizeIssueText(savedIssue.body) !== normalizeIssueText(updates.body)
) {
throw new Error("Gitea accepted the update request but did not persist the requested issue content.");
}
return savedIssue;
} catch (error) {
throw new Error(getGiteaErrorMessage(error, "Could not update the Gitea issue."));
}
}
export async function setGiteaIssueLabels(
owner: string,
repo: string,
issueNumber: number,
labelNames: string[]
): Promise<string[]> {
const client = createGiteaClient();
try {
const response = await client.put<unknown>(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
labels: labelNames
});
if (Array.isArray(response.data)) {
return response.data.map(getLabelName).filter(Boolean);
}
if (
response.data &&
typeof response.data === "object" &&
Array.isArray((response.data as { labels?: unknown[] }).labels)
) {
return (response.data as { labels: unknown[] }).labels.map(getLabelName).filter(Boolean);
}
return labelNames;
} catch (error) {
throw new Error(getGiteaErrorMessage(error, "Could not update the Gitea issue labels."));
}
}
export async function getGiteaIssues(
owner: string,
repo: string,
options: { limit?: number; sort?: "created" | "updated"; order?: "asc" | "desc"; state?: GiteaIssueStateFilter } = {}
): Promise<GiteaIssueSummary[]> {
const client = createGiteaClient();
const response = await client.get<GiteaIssueResponse[]>(`/repos/${owner}/${repo}/issues`, {
params: {
limit: options.limit ?? 25,
order: options.order ?? "desc",
page: 1,
sort: options.sort ?? "created",
state: options.state ?? "all",
type: "issues"
}
});
return response.data
.filter((issue) => issue.number !== undefined)
.map((issue) => toIssueSummary(issue, owner, repo, issue.number));
}
export async function getGiteaIssueDetail(owner: string, repo: string, issueNumber: number): Promise<GiteaIssueDetail> {
const client = createGiteaClient();
const [issueResponse, commentsResponse, attachmentsResponse] = await Promise.all([
client.get<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues/${issueNumber}`),
client.get<GiteaIssueCommentResponse[]>(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
params: {
limit: 100,
page: 1
}
}),
client.get<GiteaAttachmentResponse[]>(`/repos/${owner}/${repo}/issues/${issueNumber}/assets`, {
params: {
limit: 100,
page: 1
}
})
]);
const issue = issueResponse.data;
return {
issue: {
...toIssueSummary(issue, owner, repo, issueNumber),
comments: issue.comments ?? commentsResponse.data.length
},
comments: commentsResponse.data.map((comment) => ({
id: comment.id ?? 0,
body: comment.body || "",
webUrl: comment.html_url || "",
createdAt: comment.created_at || "",
updatedAt: comment.updated_at || "",
author: comment.user?.login || "unknown"
})),
attachments: attachmentsResponse.data.map((attachment) => ({
id: attachment.id ?? 0,
name: attachment.name || "attachment",
downloadUrl: attachment.browser_download_url || attachment.download_url || "",
size: attachment.size ?? 0,
createdAt: attachment.created_at || ""
}))
};
}
export async function uploadGiteaIssueAttachment(
owner: string,
repo: string,
issueNumber: number,
file: Blob,
fileName: string
): Promise<GiteaIssueAttachment> {
const client = createGiteaClient();
const formData = new FormData();
formData.append("attachment", file, fileName);
const response = await client.post<GiteaAttachmentResponse>(
`/repos/${owner}/${repo}/issues/${issueNumber}/assets`,
formData
);
return {
id: response.data.id ?? 0,
name: response.data.name || fileName,
downloadUrl: response.data.browser_download_url || response.data.download_url || "",
size: response.data.size ?? file.size,
createdAt: response.data.created_at || ""
};
}
export async function deleteGiteaIssueAttachment(
owner: string,
repo: string,
issueNumber: number,
attachmentId: number
): Promise<void> {
const client = createGiteaClient();
await client.delete(`/repos/${owner}/${repo}/issues/${issueNumber}/assets/${attachmentId}`);
}
export async function createGiteaIssueComment(
owner: string,
repo: string,
issueNumber: number,
body: string
): Promise<void> {
const client = createGiteaClient();
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
body
});
}
export async function deleteGiteaIssue(owner: string, repo: string, issueNumber: number): Promise<void> {
const client = createGiteaClient();
await client.delete(`/repos/${owner}/${repo}/issues/${issueNumber}`);
}
async function getGiteaLabels(owner: string, repo: string): Promise<GiteaLabelResponse[]> {
const client = createGiteaClient();
const response = await client.get<GiteaLabelResponse[]>(`/repos/${owner}/${repo}/labels`, {
params: {
limit: 100
}
});
return response.data;
}
export async function ensureGiteaLabel(owner: string, repo: string, name: string, color = "0e8a16"): Promise<GiteaLabelResponse> {
const existingLabels = await getGiteaLabels(owner, repo);
const existing = existingLabels.find((label) => label.name.toLowerCase() === name.toLowerCase());
if (existing) {
return existing;
}
const client = createGiteaClient();
try {
const response = await client.post<GiteaLabelResponse>(`/repos/${owner}/${repo}/labels`, {
name,
color
});
return response.data;
} catch {
const refreshedLabels = await getGiteaLabels(owner, repo);
const refreshed = refreshedLabels.find((label) => label.name.toLowerCase() === name.toLowerCase());
if (refreshed) {
return refreshed;
}
throw new Error(`Could not create or find the ${name} label.`);
}
}
export async function getReposOrderedByLastCommit(): Promise<GiteaRepoSummary[]> {
const client = axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
headers: {
Authorization: `token ${giteaToken}`
}
});
assertGiteaConfigured();
const repos: GiteaRepoResponse[] = [];
let page = 1;
while (page <= 10) {
const response = await client.get<GiteaRepoResponse[]>("/user/repos", {
params: {
limit: 50,
page
}
});
repos.push(...response.data);
if (response.data.length < 50) {
break;
}
page += 1;
}
const repoDetails = await Promise.all(
repos.map(async (repo): Promise<GiteaRepoSummary> => {
const owner = repo.owner?.login || giteaConfig.repoOwner;
const defaultBranch = repo.default_branch || "main";
const fullName = repo.full_name || `${owner}/${repo.name}`;
let branch: GiteaBranchResponse | null = null;
try {
const branchResponse = await client.get<GiteaBranchResponse>(
`/repos/${owner}/${repo.name}/branches/${defaultBranch}`
);
branch = branchResponse.data;
} catch {
branch = null;
}
return {
name: repo.name,
fullName,
description: repo.description || "",
webUrl: repo.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${fullName}`,
cloneUrl: repo.clone_url || "",
defaultBranch,
isPrivate: Boolean(repo.private),
lastCommitAt: branch?.commit?.timestamp || repo.updated_at || "",
lastCommitSha: branch?.commit?.id || "",
lastCommitMessage: branch?.commit?.message?.split("\n")[0] || "No commit message available"
};
})
);
return repoDetails.sort((a, b) => {
const aTime = a.lastCommitAt ? Date.parse(a.lastCommitAt) : 0;
const bTime = b.lastCommitAt ? Date.parse(b.lastCommitAt) : 0;
return bTime - aTime || a.fullName.localeCompare(b.fullName);
});
}