feat(forgejo): add validation, cached issues, sync service, and human issue APIs (Issues 5-8); add connection and repository admin UI (Issues 3-4); fix migration graph and client bugs
Backend Issues 5-8:
- POST /forgejo/connections/{id}/validate and /repositories/{id}/validate
- ForgejoIssue model with unique constraint (repo_id, issue_number)
- IssueSyncService with pagination and upsert
- GET /forgejo/issues with filtering, search, pagination
- GET /forgejo/issues/{id} with org-scoped access
- Fixed ForgejoAPIClient /api/v1 path prefix
- Fixed get_forgejo_client async context (was async def, now regular def)
- Fixed Forgejo API response parsing (list, not dict with items)
- Fixed None-safe handling for labels/assignees
Frontend Issues 3-4:
- Connections list, new, edit pages with token masking
- Repositories list, new, edit pages with connection selector
- ForgejoConnectionForm, ForgejoConnectionsTable components
- ForgejoRepositoryForm, ForgejoRepositoriesTable components
- api-forgejo.ts client library
Migration cleanup:
- Consolidated forgejo_issues table into f5a2b3c4d5e6 migration
- Removed orphan branch migrations for already-existing tables
- Fixed migration graph to single head (f5a2b3c4d5e6)
- Stamped DB to correct revision
2026-05-19 03:10:32 -05:00
|
|
|
import { getApiBaseUrl } from "./api-base";
|
|
|
|
|
|
|
|
|
|
// Forgejo Connection types
|
|
|
|
|
export interface ForgejoConnection {
|
|
|
|
|
id: string;
|
|
|
|
|
organization_id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
base_url: string;
|
|
|
|
|
token: null; // Always null in responses
|
|
|
|
|
active: boolean;
|
|
|
|
|
has_token: boolean;
|
|
|
|
|
token_last_eight: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoConnectionCreate {
|
|
|
|
|
name: string;
|
|
|
|
|
base_url: string;
|
|
|
|
|
token: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoConnectionUpdate {
|
|
|
|
|
name?: string;
|
|
|
|
|
base_url?: string;
|
|
|
|
|
token?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo Repository types
|
|
|
|
|
export interface ForgejoRepository {
|
|
|
|
|
id: string;
|
|
|
|
|
organization_id: string;
|
|
|
|
|
connection_id: string;
|
|
|
|
|
owner: string;
|
|
|
|
|
repo: string;
|
|
|
|
|
display_name: string;
|
|
|
|
|
default_branch: string;
|
|
|
|
|
active: boolean;
|
|
|
|
|
connection: ForgejoConnection;
|
|
|
|
|
last_sync_at: string | null;
|
|
|
|
|
last_sync_error: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoRepositoryCreate {
|
|
|
|
|
connection_id: string;
|
|
|
|
|
owner: string;
|
|
|
|
|
repo: string;
|
|
|
|
|
display_name?: string;
|
|
|
|
|
default_branch?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoRepositoryUpdate {
|
|
|
|
|
display_name?: string;
|
|
|
|
|
default_branch?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API client
|
|
|
|
|
const API_BASE_URL = getApiBaseUrl();
|
|
|
|
|
|
|
|
|
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
...init,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
...(init?.headers || {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo Connection API
|
|
|
|
|
export async function getForgejoConnections(): Promise<ForgejoConnection[]> {
|
|
|
|
|
return fetchJson<ForgejoConnection[]>(`${API_BASE_URL}/api/v1/forgejo/connections`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createForgejoConnection(
|
|
|
|
|
data: ForgejoConnectionCreate,
|
|
|
|
|
): Promise<ForgejoConnection> {
|
|
|
|
|
return fetchJson<ForgejoConnection>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/connections`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getForgejoConnection(
|
|
|
|
|
connectionId: string,
|
|
|
|
|
): Promise<ForgejoConnection> {
|
|
|
|
|
return fetchJson<ForgejoConnection>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateForgejoConnection(
|
|
|
|
|
connectionId: string,
|
|
|
|
|
data: ForgejoConnectionUpdate,
|
|
|
|
|
): Promise<ForgejoConnection> {
|
|
|
|
|
return fetchJson<ForgejoConnection>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
|
|
|
|
{
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteForgejoConnection(connectionId: string): Promise<void> {
|
|
|
|
|
await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo Repository API
|
|
|
|
|
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
|
|
|
|
|
return fetchJson<ForgejoRepository[]>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createForgejoRepository(
|
|
|
|
|
data: ForgejoRepositoryCreate,
|
|
|
|
|
): Promise<ForgejoRepository> {
|
|
|
|
|
return fetchJson<ForgejoRepository>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getForgejoRepository(
|
|
|
|
|
repositoryId: string,
|
|
|
|
|
): Promise<ForgejoRepository> {
|
|
|
|
|
return fetchJson<ForgejoRepository>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateForgejoRepository(
|
|
|
|
|
repositoryId: string,
|
|
|
|
|
data: ForgejoRepositoryUpdate,
|
|
|
|
|
): Promise<ForgejoRepository> {
|
|
|
|
|
return fetchJson<ForgejoRepository>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
|
|
|
|
{
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteForgejoRepository(repositoryId: string): Promise<void> {
|
|
|
|
|
await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-19 03:32:54 -05:00
|
|
|
|
|
|
|
|
// Forgejo Sync & Validation API
|
|
|
|
|
export async function syncRepository(
|
|
|
|
|
repositoryId: string,
|
|
|
|
|
): Promise<{
|
|
|
|
|
created: number;
|
|
|
|
|
updated: number;
|
|
|
|
|
open: number;
|
|
|
|
|
closed: number;
|
|
|
|
|
total: number;
|
|
|
|
|
}> {
|
|
|
|
|
return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function validateConnection(
|
|
|
|
|
connectionId: string,
|
|
|
|
|
): Promise<{
|
|
|
|
|
ok: boolean;
|
|
|
|
|
error_message?: string;
|
|
|
|
|
response_time_ms: number;
|
|
|
|
|
}> {
|
|
|
|
|
return fetchJson<{
|
|
|
|
|
ok: boolean;
|
|
|
|
|
error_message?: string;
|
|
|
|
|
response_time_ms: number;
|
|
|
|
|
}>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function validateRepository(
|
|
|
|
|
repositoryId: string,
|
|
|
|
|
): Promise<{
|
|
|
|
|
ok: boolean;
|
|
|
|
|
repo_exists: boolean;
|
|
|
|
|
error_message?: string;
|
|
|
|
|
}> {
|
|
|
|
|
return fetchJson<{
|
|
|
|
|
ok: boolean;
|
|
|
|
|
repo_exists: boolean;
|
|
|
|
|
error_message?: string;
|
|
|
|
|
}>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo Issue types
|
|
|
|
|
export interface ForgejoIssue {
|
|
|
|
|
id: string;
|
|
|
|
|
organization_id: string;
|
|
|
|
|
repository_id: string;
|
|
|
|
|
forgejo_issue_number: number;
|
|
|
|
|
title: string;
|
|
|
|
|
body_preview: string | null;
|
|
|
|
|
state: string;
|
|
|
|
|
is_pull_request: boolean;
|
|
|
|
|
labels: Record<string, unknown>[];
|
|
|
|
|
assignees: Record<string, unknown>[];
|
|
|
|
|
author: string;
|
|
|
|
|
html_url: string;
|
|
|
|
|
forgejo_created_at: string;
|
|
|
|
|
forgejo_updated_at: string;
|
|
|
|
|
forgejo_closed_at: string | null;
|
|
|
|
|
last_synced_at: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoIssueListResponse {
|
|
|
|
|
items: ForgejoIssue[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo Issue API
|
|
|
|
|
export async function getForgejoIssues(params?: {
|
|
|
|
|
repository_id?: string;
|
|
|
|
|
state?: string;
|
|
|
|
|
search?: string;
|
|
|
|
|
page?: number;
|
|
|
|
|
limit?: number;
|
|
|
|
|
}): Promise<ForgejoIssueListResponse> {
|
|
|
|
|
const searchParams = new URLSearchParams();
|
|
|
|
|
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
|
|
|
|
|
if (params?.state) searchParams.set("state", params.state);
|
|
|
|
|
if (params?.search) searchParams.set("search", params.search);
|
|
|
|
|
if (params?.page) searchParams.set("page", params.page.toString());
|
|
|
|
|
if (params?.limit) searchParams.set("limit", params.limit.toString());
|
|
|
|
|
|
|
|
|
|
const qs = searchParams.toString();
|
|
|
|
|
return fetchJson<ForgejoIssueListResponse>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
|
|
|
|
return fetchJson<ForgejoIssue>(
|
|
|
|
|
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|