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,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 03:06:53 +00:00
|
|
|
/**
|
|
|
|
|
* 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|