fjtui/src/api.ts

525 lines
15 KiB
TypeScript
Raw Normal View History

2026-06-01 00:59:22 +00:00
import axios from 'axios';
2026-06-01 02:02:23 +00:00
import { Config, Issue, Comment, RepoItem } from './types.js';
2026-06-01 00:59:22 +00:00
/**
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
* and ensuring it has a protocol (defaults to https).
*/
export function normalizeUrl(url: string): string {
let cleaned = url.trim();
if (!cleaned) return '';
if (!/^https?:\/\//i.test(cleaned)) {
cleaned = 'https://' + cleaned;
}
return cleaned.replace(/\/+$/, '');
}
/**
* Creates an Axios instance with standard Gitea/Forgejo headers.
*/
function createAxiosInstance(config: Config) {
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (config.token) {
headers['Authorization'] = `token ${config.token.trim()}`;
}
return axios.create({
baseURL: `${normalizeUrl(config.url)}/api/v1`,
headers,
timeout: 10000,
});
}
/**
* Tests the connection to the Gitea/Forgejo instance and validates that the repository exists.
* Returns the repository full name on success.
*/
export async function validateConnection(config: Config): Promise<string> {
const client = createAxiosInstance(config);
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}`);
return response.data.full_name || `${config.owner}/${config.repo}`;
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: Invalid API Token or authentication failed.');
}
if (error.response.status === 404) {
throw new Error(`Repository "${config.owner}/${config.repo}" not found or is private.`);
}
throw new Error(`API Error: ${error.response.data?.message || error.message}`);
}
throw new Error(`Failed to connect to ${config.url}: ${error.message}`);
}
}
/**
* Fetches a page of issues for a specific repository.
*/
export async function fetchIssues(
config: Config,
options: {
page: number;
limit: number;
state: 'open' | 'closed' | 'all';
type: 'issues' | 'pulls' | 'all';
q: string;
sortField: 'created' | 'updated' | 'comments';
sortOrder: 'asc' | 'desc';
}
): Promise<{ issues: Issue[]; totalCount: number }> {
const client = createAxiosInstance(config);
// Map our internal sort fields to Gitea API sort parameters
let sort = 'newest';
if (options.sortField === 'created') {
sort = options.sortOrder === 'asc' ? 'oldest' : 'newest';
} else if (options.sortField === 'updated') {
sort = options.sortOrder === 'asc' ? 'leastupdate' : 'recentupdate';
} else if (options.sortField === 'comments') {
sort = options.sortOrder === 'asc' ? 'leastcomment' : 'mostcomment';
}
const params: Record<string, any> = {
page: options.page,
limit: options.limit,
state: options.state,
sort: sort,
};
// Filter by type: pulls or issues
if (options.type !== 'all') {
params.type = options.type;
}
// Filter by search query if present
if (options.q.trim()) {
params.q = options.q.trim();
}
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues`, { params });
// Parse x-total-count header
const totalCountHeader = response.headers['x-total-count'];
const totalCount = totalCountHeader ? parseInt(totalCountHeader, 10) : response.data.length;
const issues: Issue[] = response.data.map((item: any) => ({
id: item.id,
number: item.number,
title: item.title,
state: item.state,
body: item.body || '',
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
created_at: item.created_at,
updated_at: item.updated_at,
comments: item.comments_count || item.comments || 0,
labels: (item.labels || []).map((l: any) => ({
id: l.id,
name: l.name,
color: l.color,
})),
pull_request: item.pull_request,
2026-06-01 23:34:20 +00:00
total_tracked_time: 0,
2026-06-01 00:59:22 +00:00
}));
2026-06-01 23:34:20 +00:00
// Fetch tracked times concurrently for all issues
await Promise.all(
issues.map(async (issue) => {
issue.total_tracked_time = await fetchIssueTrackedTimeTotal(config, issue.number);
})
);
2026-06-02 00:02:10 +00:00
// Apply local sorting as the Forgejo API does not inherently sort via endpoint parameters
issues.sort((a, b) => {
let diff = 0;
if (options.sortField === 'created') {
diff = new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
} else if (options.sortField === 'updated') {
diff = new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
} else if (options.sortField === 'comments') {
diff = b.comments - a.comments;
}
return options.sortOrder === 'desc' ? diff : -diff;
});
2026-06-01 00:59:22 +00:00
return { issues, totalCount };
} catch (error: any) {
if (error.response) {
throw new Error(`API Error: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 23:20:58 +00:00
/**
* Fetches a single issue by number.
*/
export async function fetchIssue(
config: Config,
issueNumber: number
): Promise<Issue> {
const client = createAxiosInstance(config);
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`);
const item = response.data;
2026-06-01 23:34:20 +00:00
const trackedTime = await fetchIssueTrackedTimeTotal(config, issueNumber);
2026-06-01 23:20:58 +00:00
return {
id: item.id,
number: item.number,
title: item.title,
state: item.state,
body: item.body || '',
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
created_at: item.created_at,
updated_at: item.updated_at,
comments: item.comments_count || item.comments || 0,
labels: (item.labels || []).map((l: any) => ({
id: l.id,
name: l.name,
color: l.color,
})),
pull_request: item.pull_request,
2026-06-01 23:34:20 +00:00
total_tracked_time: trackedTime,
2026-06-01 23:20:58 +00:00
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: Please check your token.');
}
throw new Error(`Failed to fetch issue: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 00:59:22 +00:00
/**
* Fetches comments for a specific issue.
*/
export async function fetchIssueComments(
config: Config,
issueNumber: number
): Promise<Comment[]> {
const client = createAxiosInstance(config);
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/comments`);
const comments: Comment[] = response.data.map((item: any) => ({
id: item.id,
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
body: item.body || '',
created_at: item.created_at,
updated_at: item.updated_at,
}));
return comments;
} catch (error: any) {
if (error.response) {
throw new Error(`Failed to fetch comments: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 02:02:23 +00:00
/**
* Validates connection and fetches all repositories accessible by the user.
*/
export async function authenticateAndFetchRepos(
url: string,
userid: string,
token: string
): Promise<RepoItem[]> {
const normalized = normalizeUrl(url);
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (token.trim()) {
headers['Authorization'] = `token ${token.trim()}`;
}
const client = axios.create({
baseURL: `${normalized}/api/v1`,
headers,
timeout: 10000,
});
// 1. Verify credentials / user existence
try {
if (token.trim()) {
await client.get('/user');
} else if (userid.trim()) {
await client.get(`/users/${userid.trim()}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: Invalid API Token.');
}
if (error.response.status === 404) {
throw new Error(`User "${userid}" not found.`);
}
throw new Error(`Authentication failed: ${error.response.data?.message || error.message}`);
}
throw new Error(`Failed to connect to ${normalized}: ${error.message}`);
}
// 2. Fetch repositories
try {
let repos: any[] = [];
if (token.trim()) {
// Fetch authenticated user's repos (lists all they have access to)
const response = await client.get('/user/repos', { params: { limit: 100 } });
repos = response.data;
} else if (userid.trim()) {
// Fetch public repos of the target username
const response = await client.get(`/users/${userid.trim()}/repos`, { params: { limit: 100 } });
repos = response.data;
}
if (!Array.isArray(repos)) {
throw new Error('Invalid response from server (expected list of repositories).');
}
return repos.map((r: any) => ({
id: r.id,
name: r.name,
full_name: r.full_name || `${r.owner?.login}/${r.name}`,
private: !!r.private,
description: r.description || '',
}));
} catch (error: any) {
if (error.response) {
throw new Error(`Failed to fetch repositories: ${error.response.data?.message || error.message}`);
}
throw new Error(`Failed to fetch repositories: ${error.message}`);
}
}
/**
* Creates a new issue in the specified repository.
*/
export async function createIssue(
config: Config,
title: string,
body: string
): Promise<Issue> {
const client = createAxiosInstance(config);
try {
const response = await client.post(`/repos/${config.owner}/${config.repo}/issues`, {
title,
body,
});
const item = response.data;
return {
id: item.id,
number: item.number,
title: item.title,
state: item.state,
body: item.body || '',
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
created_at: item.created_at,
updated_at: item.updated_at,
comments: item.comments_count || item.comments || 0,
labels: (item.labels || []).map((l: any) => ({
id: l.id,
name: l.name,
color: l.color,
})),
pull_request: item.pull_request,
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to create an issue.');
}
throw new Error(`Failed to create issue: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 03:18:12 +00:00
/**
* Creates a comment on a specific issue.
*/
export async function createIssueComment(
config: Config,
issueNumber: number,
body: string
): Promise<Comment> {
const client = createAxiosInstance(config);
try {
const response = await client.post(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/comments`, {
body,
});
const item = response.data;
return {
id: item.id,
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
body: item.body,
created_at: item.created_at,
updated_at: item.updated_at,
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to comment.');
}
throw new Error(`Failed to create comment: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 23:15:01 +00:00
/**
* Edits an existing issue.
*/
export async function editIssue(
config: Config,
issueNumber: number,
title: string,
body: string
): Promise<Issue> {
const client = createAxiosInstance(config);
try {
const response = await client.patch(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`, {
title,
body,
});
const item = response.data;
return {
id: item.id,
number: item.number,
title: item.title,
state: item.state,
body: item.body || '',
user: {
id: item.user?.id || 0,
login: item.user?.login || 'unknown',
full_name: item.user?.full_name || '',
},
created_at: item.created_at,
updated_at: item.updated_at,
comments: item.comments_count || item.comments || 0,
labels: (item.labels || []).map((l: any) => ({
id: l.id,
name: l.name,
color: l.color,
})),
pull_request: item.pull_request,
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to edit an issue.');
}
throw new Error(`Failed to edit issue: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 23:34:20 +00:00
2026-06-02 00:15:30 +00:00
/**
* Changes the state of an issue (open/closed).
*/
export async function changeIssueState(
config: Config,
issueNumber: number,
state: 'open' | 'closed'
): Promise<void> {
const client = createAxiosInstance(config);
try {
await client.patch(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`, {
state,
});
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to change issue state.');
}
throw new Error(`Failed to change issue state: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
2026-06-01 23:34:20 +00:00
/**
* Adds time to an issue.
* @param time Time in seconds
*/
export async function addIssueTime(
config: Config,
issueNumber: number,
time: number
): Promise<any> {
const client = createAxiosInstance(config);
try {
const response = await client.post(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`, {
time,
});
return response.data;
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to add time.');
}
throw new Error(`Failed to add time: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
/**
* Fetches the total tracked time for a specific issue.
* @returns Total time in seconds
*/
export async function fetchIssueTrackedTimeTotal(
config: Config,
issueNumber: number
): Promise<number> {
const client = createAxiosInstance(config);
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`);
const times: any[] = response.data;
let total = 0;
for (const t of times) {
if (t && typeof t.time === 'number') {
total += t.time;
}
}
return total;
} catch (error: any) {
// If times are not supported or missing, just return 0
return 0;
}
}