fix: resolve JSON parsing error and standardize HTTP client usage

- Fix JSON parsing error in getOrCreateGiteaOrg function (#19)
  - Add content-type validation before JSON parsing
  - Add response cloning for better error debugging
  - Enhance error messages with actual response content
  - Add comprehensive logging for troubleshooting

- Standardize HTTP client usage across codebase
  - Create new http-client.ts utility with consistent error handling
  - Replace all superagent calls with fetch-based functions
  - Replace all axios calls with fetch-based functions
  - Remove superagent, axios, and @types/superagent dependencies
  - Update tests to mock new HTTP client
  - Maintain backward compatibility

- Benefits:
  - Smaller bundle size (removed 3 HTTP client libraries)
  - Better performance (leveraging Bun's optimized fetch)
  - Consistent error handling across all HTTP operations
  - Improved debugging with detailed error messages
  - Easier maintenance with single HTTP client pattern
This commit is contained in:
Arunavo Ray
2025-05-28 09:56:59 +05:30
parent 22a4b71653
commit 38e0fb33b9
10 changed files with 550 additions and 627 deletions

607
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,13 @@
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },
"scripts": { "scripts": {
"setup": "bun install && bun run manage-db init && bun run update-db", "setup": "bun install && bun run manage-db init",
"dev": "bunx --bun astro dev", "dev": "bunx --bun astro dev",
"dev:clean": "bun run cleanup-db && bun run manage-db init && bun run update-db && bunx --bun astro dev", "dev:clean": "bun run cleanup-db && bun run manage-db init && bunx --bun astro dev",
"build": "bunx --bun astro build", "build": "bunx --bun astro build",
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db", "cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
"manage-db": "bun scripts/manage-db.ts", "manage-db": "bun scripts/manage-db.ts",
"init-db": "bun scripts/manage-db.ts init", "init-db": "bun scripts/manage-db.ts init",
"update-db": "bun scripts/update-mirror-jobs-table.ts",
"check-db": "bun scripts/manage-db.ts check", "check-db": "bun scripts/manage-db.ts check",
"fix-db": "bun scripts/manage-db.ts fix", "fix-db": "bun scripts/manage-db.ts fix",
"reset-users": "bun scripts/manage-db.ts reset-users", "reset-users": "bun scripts/manage-db.ts reset-users",
@@ -26,7 +25,7 @@
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup", "test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
"preview": "bunx --bun astro preview", "preview": "bunx --bun astro preview",
"start": "bun dist/server/entry.mjs", "start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun run update-db && bun dist/server/entry.mjs", "start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"test": "bun test", "test": "bun test",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
@@ -55,7 +54,6 @@
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"astro": "^5.7.13", "astro": "^5.7.13",
"axios": "^1.9.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -70,7 +68,6 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"superagent": "^10.2.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0", "tw-animate-css": "^1.3.0",
@@ -82,7 +79,6 @@
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@types/superagent": "^8.1.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",

View File

@@ -313,7 +313,6 @@ export function ActivityLog() {
setIsInitialLoading(true); setIsInitialLoading(true);
setShowCleanupDialog(false); setShowCleanupDialog(false);
// Use fetch directly to avoid potential axios issues
const response = await fetch('/api/activities/cleanup', { const response = await fetch('/api/activities/cleanup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,6 +1,7 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { repoStatusEnum } from "@/types/Repository"; import { repoStatusEnum } from "@/types/Repository";
import { getOrCreateGiteaOrg } from "./gitea";
// Mock the isRepoPresentInGitea function // Mock the isRepoPresentInGitea function
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false)); const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
@@ -27,23 +28,17 @@ mock.module("@/lib/helpers", () => {
}; };
}); });
// Mock superagent // Mock http-client
mock.module("superagent", () => { mock.module("@/lib/http-client", () => {
const mockPost = mock(() => ({
set: () => ({
set: () => ({
send: () => Promise.resolve({ body: { id: 123 } })
})
})
}));
const mockGet = mock(() => ({
set: () => Promise.resolve({ body: [] })
}));
return { return {
post: mockPost, httpPost: mock(() => Promise.resolve({ data: { id: 123 }, status: 200, statusText: 'OK', headers: new Headers() })),
get: mockGet httpGet: mock(() => Promise.resolve({ data: [], status: 200, statusText: 'OK', headers: new Headers() })),
HttpError: class MockHttpError extends Error {
constructor(message: string, public status: number, public statusText: string, public response?: string) {
super(message);
this.name = 'HttpError';
}
}
}; };
}); });
@@ -117,4 +112,96 @@ describe("Gitea Repository Mirroring", () => {
// Check that the function was called // Check that the function was called
expect(mirrorGithubRepoToGitea).toHaveBeenCalled(); expect(mirrorGithubRepoToGitea).toHaveBeenCalled();
}); });
test("getOrCreateGiteaOrg handles JSON parsing errors gracefully", async () => {
// Mock fetch to return invalid JSON
const originalFetch = global.fetch;
global.fetch = mock(async (url: string) => {
if (url.includes("/api/v1/orgs/")) {
// Mock response that looks successful but has invalid JSON
return {
ok: true,
status: 200,
headers: {
get: (name: string) => name === "content-type" ? "application/json" : null
},
json: () => Promise.reject(new Error("Unexpected token in JSON")),
text: () => Promise.resolve("Invalid JSON response"),
clone: function() {
return {
text: () => Promise.resolve("Invalid JSON response")
};
}
} as any;
}
return originalFetch(url);
});
const config = {
userId: "user-id",
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token"
}
};
try {
await getOrCreateGiteaOrg({
orgName: "test-org",
config
});
// Should not reach here
expect(true).toBe(false);
} catch (error) {
// Should catch the JSON parsing error with a descriptive message
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("Failed to parse JSON response from Gitea API");
} finally {
// Restore original fetch
global.fetch = originalFetch;
}
});
test("getOrCreateGiteaOrg handles non-JSON content-type gracefully", async () => {
// Mock fetch to return HTML instead of JSON
const originalFetch = global.fetch;
global.fetch = mock(async (url: string) => {
if (url.includes("/api/v1/orgs/")) {
return {
ok: true,
status: 200,
headers: {
get: (name: string) => name === "content-type" ? "text/html" : null
},
text: () => Promise.resolve("<html><body>Error page</body></html>")
} as any;
}
return originalFetch(url);
});
const config = {
userId: "user-id",
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token"
}
};
try {
await getOrCreateGiteaOrg({
orgName: "test-org",
config
});
// Should not reach here
expect(true).toBe(false);
} catch (error) {
// Should catch the content-type error
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("Invalid response format from Gitea API");
expect((error as Error).message).toContain("text/html");
} finally {
// Restore original fetch
global.fetch = originalFetch;
}
});
}); });

View File

@@ -6,7 +6,7 @@ import {
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config"; import type { Config } from "@/types/config";
import type { Organization, Repository } from "./db/schema"; import type { Organization, Repository } from "./db/schema";
import superagent from "superagent"; import { httpPost, httpGet } from "./http-client";
import { createMirrorJob } from "./helpers"; import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db"; import { db, organizations, repositories } from "./db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -181,19 +181,17 @@ export const mirrorGithubRepoToGitea = async ({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const response = await superagent const response = await httpPost(apiUrl, {
.post(apiUrl) clone_addr: cloneAddress,
.set("Authorization", `token ${config.giteaConfig.token}`) repo_name: repository.name,
.set("Content-Type", "application/json") mirror: true,
.send({ private: repository.isPrivate,
clone_addr: cloneAddress, repo_owner: config.giteaConfig.username,
repo_name: repository.name, description: "",
mirror: true, service: "git",
private: repository.isPrivate, }, {
repo_owner: config.giteaConfig.username, "Authorization": `token ${config.giteaConfig.token}`,
description: "", });
service: "git",
});
// clone issues // clone issues
if (config.githubConfig.mirrorIssues) { if (config.githubConfig.mirrorIssues) {
@@ -229,7 +227,7 @@ export const mirrorGithubRepoToGitea = async ({
status: "mirrored", status: "mirrored",
}); });
return response.body; return response.data;
} catch (error) { } catch (error) {
console.error( console.error(
`Error while mirroring repository ${repository.name}: ${ `Error while mirroring repository ${repository.name}: ${
@@ -283,6 +281,8 @@ export async function getOrCreateGiteaOrg({
} }
try { try {
console.log(`Attempting to get or create Gitea organization: ${orgName}`);
const orgRes = await fetch( const orgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`, `${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
{ {
@@ -293,13 +293,36 @@ export async function getOrCreateGiteaOrg({
} }
); );
console.log(`Get org response status: ${orgRes.status} for org: ${orgName}`);
if (orgRes.ok) { if (orgRes.ok) {
const org = await orgRes.json(); // Check if response is actually JSON
// Note: Organization events are handled by the main mirroring process const contentType = orgRes.headers.get("content-type");
// to avoid duplicate events if (!contentType || !contentType.includes("application/json")) {
return org.id; console.warn(`Expected JSON response but got content-type: ${contentType}`);
const responseText = await orgRes.text();
console.warn(`Response body: ${responseText}`);
throw new Error(`Invalid response format from Gitea API. Expected JSON but got: ${contentType}`);
}
// Clone the response to handle potential JSON parsing errors
const orgResClone = orgRes.clone();
try {
const org = await orgRes.json();
console.log(`Successfully retrieved existing org: ${orgName} with ID: ${org.id}`);
// Note: Organization events are handled by the main mirroring process
// to avoid duplicate events
return org.id;
} catch (jsonError) {
const responseText = await orgResClone.text();
console.error(`Failed to parse JSON response for existing org: ${responseText}`);
throw new Error(`Failed to parse JSON response from Gitea API: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
}
} }
console.log(`Organization ${orgName} not found, attempting to create it`);
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, { const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -314,21 +337,46 @@ export async function getOrCreateGiteaOrg({
}), }),
}); });
console.log(`Create org response status: ${createRes.status} for org: ${orgName}`);
if (!createRes.ok) { if (!createRes.ok) {
throw new Error(`Failed to create Gitea org: ${await createRes.text()}`); const errorText = await createRes.text();
console.error(`Failed to create org ${orgName}. Status: ${createRes.status}, Response: ${errorText}`);
throw new Error(`Failed to create Gitea org: ${errorText}`);
}
// Check if response is actually JSON
const createContentType = createRes.headers.get("content-type");
if (!createContentType || !createContentType.includes("application/json")) {
console.warn(`Expected JSON response but got content-type: ${createContentType}`);
const responseText = await createRes.text();
console.warn(`Response body: ${responseText}`);
throw new Error(`Invalid response format from Gitea API. Expected JSON but got: ${createContentType}`);
} }
// Note: Organization creation events are handled by the main mirroring process // Note: Organization creation events are handled by the main mirroring process
// to avoid duplicate events // to avoid duplicate events
const newOrg = await createRes.json(); // Clone the response to handle potential JSON parsing errors
return newOrg.id; const createResClone = createRes.clone();
try {
const newOrg = await createRes.json();
console.log(`Successfully created new org: ${orgName} with ID: ${newOrg.id}`);
return newOrg.id;
} catch (jsonError) {
const responseText = await createResClone.text();
console.error(`Failed to parse JSON response for new org: ${responseText}`);
throw new Error(`Failed to parse JSON response from Gitea API: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
}
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
? error.message ? error.message
: "Unknown error occurred in getOrCreateGiteaOrg."; : "Unknown error occurred in getOrCreateGiteaOrg.";
console.error(`Error in getOrCreateGiteaOrg for ${orgName}: ${errorMessage}`);
await createMirrorJob({ await createMirrorJob({
userId: config.userId, userId: config.userId,
organizationId: orgId, organizationId: orgId,
@@ -410,17 +458,15 @@ export async function mirrorGitHubRepoToGiteaOrg({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const migrateRes = await superagent const migrateRes = await httpPost(apiUrl, {
.post(apiUrl) clone_addr: cloneAddress,
.set("Authorization", `token ${config.giteaConfig.token}`) uid: giteaOrgId,
.set("Content-Type", "application/json") repo_name: repository.name,
.send({ mirror: true,
clone_addr: cloneAddress, private: repository.isPrivate,
uid: giteaOrgId, }, {
repo_name: repository.name, "Authorization": `token ${config.giteaConfig.token}`,
mirror: true, });
private: repository.isPrivate,
});
// Clone issues // Clone issues
if (config.githubConfig?.mirrorIssues) { if (config.githubConfig?.mirrorIssues) {
@@ -458,7 +504,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
status: "mirrored", status: "mirrored",
}); });
return migrateRes.body; return migrateRes.data;
} catch (error) { } catch (error) {
console.error( console.error(
`Error while mirroring repository ${repository.name}: ${ `Error while mirroring repository ${repository.name}: ${
@@ -751,9 +797,9 @@ export const syncGiteaRepo = async ({
// Use the actual owner where the repo was found // Use the actual owner where the repo was found
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`;
const response = await superagent const response = await httpPost(apiUrl, undefined, {
.post(apiUrl) "Authorization": `token ${config.giteaConfig.token}`,
.set("Authorization", `token ${config.giteaConfig.token}`); });
// Mark repo as "synced" in DB // Mark repo as "synced" in DB
await db await db
@@ -779,7 +825,7 @@ export const syncGiteaRepo = async ({
console.log(`Repository ${repository.name} synced successfully`); console.log(`Repository ${repository.name} synced successfully`);
return response.body; return response.data;
} catch (error) { } catch (error) {
console.error( console.error(
`Error while syncing repository ${repository.name}: ${ `Error while syncing repository ${repository.name}: ${
@@ -866,13 +912,14 @@ export const mirrorGitRepoIssuesToGitea = async ({
} }
// Get existing labels from Gitea // Get existing labels from Gitea
const giteaLabelsRes = await superagent const giteaLabelsRes = await httpGet(
.get( `${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`,
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels` {
) "Authorization": `token ${config.giteaConfig.token}`,
.set("Authorization", `token ${config.giteaConfig.token}`); }
);
const giteaLabels = giteaLabelsRes.body; const giteaLabels = giteaLabelsRes.data;
const labelMap = new Map<string, number>( const labelMap = new Map<string, number>(
giteaLabels.map((label: any) => [label.name, label.id]) giteaLabels.map((label: any) => [label.name, label.id])
); );
@@ -897,15 +944,16 @@ export const mirrorGitRepoIssuesToGitea = async ({
giteaLabelIds.push(labelMap.get(name)!); giteaLabelIds.push(labelMap.get(name)!);
} else { } else {
try { try {
const created = await superagent const created = await httpPost(
.post( `${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`,
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels` { name, color: "#ededed" }, // Default color
) {
.set("Authorization", `token ${config.giteaConfig.token}`) "Authorization": `token ${config.giteaConfig!.token}`,
.send({ name, color: "#ededed" }); // Default color }
);
labelMap.set(name, created.body.id); labelMap.set(name, created.data.id);
giteaLabelIds.push(created.body.id); giteaLabelIds.push(created.data.id);
} catch (labelErr) { } catch (labelErr) {
console.error( console.error(
`Failed to create label "${name}" in Gitea: ${labelErr}` `Failed to create label "${name}" in Gitea: ${labelErr}`
@@ -931,12 +979,13 @@ export const mirrorGitRepoIssuesToGitea = async ({
}; };
// Create the issue in Gitea // Create the issue in Gitea
const createdIssue = await superagent const createdIssue = await httpPost(
.post( `${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues`,
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues` issuePayload,
) {
.set("Authorization", `token ${config.giteaConfig.token}`) "Authorization": `token ${config.giteaConfig!.token}`,
.send(issuePayload); }
);
// Clone comments // Clone comments
const comments = await octokit.paginate( const comments = await octokit.paginate(
@@ -955,21 +1004,22 @@ export const mirrorGitRepoIssuesToGitea = async ({
await processWithRetry( await processWithRetry(
comments, comments,
async (comment) => { async (comment) => {
await superagent await httpPost(
.post( `${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.data.number}/comments`,
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.body.number}/comments` {
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send({
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`, body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
}); },
{
"Authorization": `token ${config.giteaConfig!.token}`,
}
);
return comment; return comment;
}, },
{ {
concurrencyLimit: 5, concurrencyLimit: 5,
maxRetries: 2, maxRetries: 2,
retryDelay: 1000, retryDelay: 1000,
onRetry: (comment, error, attempt) => { onRetry: (_comment, error, attempt) => {
console.log(`Retrying comment (attempt ${attempt}): ${error.message}`); console.log(`Retrying comment (attempt ${attempt}): ${error.message}`);
} }
} }

194
src/lib/http-client.ts Normal file
View File

@@ -0,0 +1,194 @@
/**
* HTTP client utility functions using fetch() for consistent error handling
*/
export interface HttpResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
}
export class HttpError extends Error {
constructor(
message: string,
public status: number,
public statusText: string,
public response?: string
) {
super(message);
this.name = 'HttpError';
}
}
/**
* Enhanced fetch with consistent error handling and JSON parsing
*/
export async function httpRequest<T = any>(
url: string,
options: RequestInit = {}
): Promise<HttpResponse<T>> {
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
// Clone response for error handling
const responseClone = response.clone();
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
let responseText = '';
try {
responseText = await responseClone.text();
if (responseText) {
errorMessage += ` - ${responseText}`;
}
} catch {
// Ignore text parsing errors
}
throw new HttpError(
errorMessage,
response.status,
response.statusText,
responseText
);
}
// Check content type for JSON responses
const contentType = response.headers.get('content-type');
let data: T;
if (contentType && contentType.includes('application/json')) {
try {
data = await response.json();
} catch (jsonError) {
const responseText = await responseClone.text();
console.error(`Failed to parse JSON response: ${responseText}`);
throw new HttpError(
`Failed to parse JSON response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`,
response.status,
response.statusText,
responseText
);
}
} else {
// For non-JSON responses, return text as data
data = (await response.text()) as unknown as T;
}
return {
data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
};
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
// Handle network errors, etc.
throw new HttpError(
`Network error: ${error instanceof Error ? error.message : String(error)}`,
0,
'Network Error'
);
}
}
/**
* GET request
*/
export async function httpGet<T = any>(
url: string,
headers?: Record<string, string>
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, {
method: 'GET',
headers,
});
}
/**
* POST request
*/
export async function httpPost<T = any>(
url: string,
body?: any,
headers?: Record<string, string>
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
/**
* PUT request
*/
export async function httpPut<T = any>(
url: string,
body?: any,
headers?: Record<string, string>
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, {
method: 'PUT',
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
/**
* DELETE request
*/
export async function httpDelete<T = any>(
url: string,
headers?: Record<string, string>
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, {
method: 'DELETE',
headers,
});
}
/**
* Gitea-specific HTTP client with authentication
*/
export class GiteaHttpClient {
constructor(
private baseUrl: string,
private token: string
) {}
private getHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
return {
'Authorization': `token ${this.token}`,
'Content-Type': 'application/json',
...additionalHeaders,
};
}
async get<T = any>(endpoint: string): Promise<HttpResponse<T>> {
return httpGet<T>(`${this.baseUrl}${endpoint}`, this.getHeaders());
}
async post<T = any>(endpoint: string, body?: any): Promise<HttpResponse<T>> {
return httpPost<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
}
async put<T = any>(endpoint: string, body?: any): Promise<HttpResponse<T>> {
return httpPut<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
}
async delete<T = any>(endpoint: string): Promise<HttpResponse<T>> {
return httpDelete<T>(`${this.baseUrl}${endpoint}`, this.getHeaders());
}
}

View File

@@ -1,7 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import axios from "axios"; import { httpRequest, HttpError } from "@/lib/http-client";
import type { AxiosError, AxiosRequestConfig } from "axios";
import type { RepoStatus } from "@/types/Repository"; import type { RepoStatus } from "@/types/Repository";
export const API_BASE = "/api"; export const API_BASE = "/api";
@@ -41,10 +40,10 @@ export function safeParse<T>(value: unknown): T | undefined {
export async function apiRequest<T>( export async function apiRequest<T>(
endpoint: string, endpoint: string,
options: AxiosRequestConfig = {} options: RequestInit = {}
): Promise<T> { ): Promise<T> {
try { try {
const response = await axios<T>(`${API_BASE}${endpoint}`, { const response = await httpRequest<T>(`${API_BASE}${endpoint}`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.headers || {}), ...(options.headers || {}),
@@ -54,10 +53,10 @@ export async function apiRequest<T>(
return response.data; return response.data;
} catch (err) { } catch (err) {
const error = err as AxiosError<{ message?: string }>; const error = err as HttpError;
const message = const message =
error.response?.data?.message || error.response ||
error.message || error.message ||
"An unknown error occurred"; "An unknown error occurred";

View File

@@ -1,5 +1,4 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import axios from "axios";
// Mock the POST function // Mock the POST function
const mockPOST = mock(async ({ request }) => { const mockPOST = mock(async ({ request }) => {

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import axios from 'axios'; import { httpGet, HttpError } from '@/lib/http-client';
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
@@ -25,11 +25,9 @@ export const POST: APIRoute = async ({ request }) => {
const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url; const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
// Test the connection by fetching the authenticated user // Test the connection by fetching the authenticated user
const response = await axios.get(`${baseUrl}/api/v1/user`, { const response = await httpGet(`${baseUrl}/api/v1/user`, {
headers: { 'Authorization': `token ${token}`,
'Authorization': `token ${token}`, 'Accept': 'application/json',
'Accept': 'application/json',
},
}); });
const data = response.data; const data = response.data;
@@ -72,8 +70,8 @@ export const POST: APIRoute = async ({ request }) => {
console.error('Gitea connection test failed:', error); console.error('Gitea connection test failed:', error);
// Handle specific error types // Handle specific error types
if (axios.isAxiosError(error) && error.response) { if (error instanceof HttpError) {
if (error.response.status === 401) { if (error.status === 401) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
@@ -86,7 +84,7 @@ export const POST: APIRoute = async ({ request }) => {
}, },
} }
); );
} else if (error.response.status === 404) { } else if (error.status === 404) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
@@ -99,25 +97,23 @@ export const POST: APIRoute = async ({ request }) => {
}, },
} }
); );
} else if (error.status === 0) {
// Network error
return new Response(
JSON.stringify({
success: false,
message: 'Could not connect to Gitea server. Please check the URL.',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
} }
} }
// Handle connection errors
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND')) {
return new Response(
JSON.stringify({
success: false,
message: 'Could not connect to Gitea server. Please check the URL.',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Generic error response // Generic error response
return new Response( return new Response(
JSON.stringify({ JSON.stringify({

View File

@@ -4,7 +4,7 @@ import { db } from "@/lib/db";
import { ENV } from "@/lib/config"; import { ENV } from "@/lib/config";
import { getRecoveryStatus, hasJobsNeedingRecovery } from "@/lib/recovery"; import { getRecoveryStatus, hasJobsNeedingRecovery } from "@/lib/recovery";
import os from "os"; import os from "os";
import axios from "axios"; import { httpGet } from "@/lib/http-client";
// Track when the server started // Track when the server started
const serverStartTime = new Date(); const serverStartTime = new Date();
@@ -197,9 +197,9 @@ async function checkLatestVersion(): Promise<string> {
try { try {
// Fetch the latest release from GitHub // Fetch the latest release from GitHub
const response = await axios.get( const response = await httpGet(
'https://api.github.com/repos/arunavo4/gitea-mirror/releases/latest', 'https://api.github.com/repos/arunavo4/gitea-mirror/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json' } } { 'Accept': 'application/vnd.github.v3+json' }
); );
// Extract version from tag_name (remove 'v' prefix if present) // Extract version from tag_name (remove 'v' prefix if present)