import axios from 'axios'; import { Config, Issue, Comment, RepoItem } from './types.js'; /** * 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 = { '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 { 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 = { 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, })); 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}`); } } /** * Fetches a single issue by number. */ export async function fetchIssue( config: Config, issueNumber: number ): Promise { const client = createAxiosInstance(config); try { const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`); 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: Please check your token.'); } throw new Error(`Failed to fetch issue: ${error.response.data?.message || error.message}`); } throw new Error(`Network Error: ${error.message}`); } } /** * Fetches comments for a specific issue. */ export async function fetchIssueComments( config: Config, issueNumber: number ): Promise { 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}`); } } /** * Validates connection and fetches all repositories accessible by the user. */ export async function authenticateAndFetchRepos( url: string, userid: string, token: string ): Promise { const normalized = normalizeUrl(url); const headers: Record = { '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 { 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}`); } } /** * Creates a comment on a specific issue. */ export async function createIssueComment( config: Config, issueNumber: number, body: string ): Promise { 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}`); } } /** * Edits an existing issue. */ export async function editIssue( config: Config, issueNumber: number, title: string, body: string ): Promise { 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}`); } }