Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 1 addition & 96 deletions src/lib/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,100 +4,10 @@ import { getRandomColor } from '$lib/utils';
import { get } from 'svelte/store';
import { areas } from '$lib/store';

import type { GiteaLabel, GiteaIssue } from '$lib/types';

// Cache structure with TTL
type IssuesCache = {
timestamp: number;
data: GiteaIssue[];
totalCount: number;
};

let issuesCache: IssuesCache | null = null;
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds
import type { GiteaLabel } from '$lib/types';

export type GiteaRepo = 'btcmap-data' | 'btcmap-infra';

async function syncIssuesFromGitea(): Promise<IssuesCache> {
// Check if required environment variables are available
if (!env.GITEA_API_URL || !env.GITEA_API_KEY) {
console.warn(
'Gitea API configuration missing (GITEA_API_URL or GITEA_API_KEY). Returning empty cache.'
);
return {
timestamp: Date.now(),
data: [],
totalCount: 0
};
}

const headers = {
Authorization: `token ${env.GITEA_API_KEY}`
};

const [issuesResponse, repoResponse] = await Promise.all([
axios.get(`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data/issues?state=open`, {
headers
}),
axios.get(`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data`, { headers })
]);

const giteaIssues = issuesResponse.data.map((issue: GiteaIssue) => ({
id: issue.id,
number: issue.number,
title: issue.title,
created_at: issue.created_at,
html_url: issue.html_url,
labels: issue.labels,
user: {
login: issue.user.login,
avatar_url: issue.user.avatar_url,
html_url: issue.user.html_url
},
comments: issue.comments,
assignees: issue.assignees
}));

return {
timestamp: Date.now(),
data: giteaIssues,
totalCount: repoResponse.data.open_issues_count
};
}

export async function getIssues(
labelNames?: string[]
): Promise<{ issues: GiteaIssue[]; totalCount: number }> {
// Refresh cache if expired or doesn't exist
if (!issuesCache || Date.now() - issuesCache.timestamp > CACHE_DURATION) {
try {
issuesCache = await syncIssuesFromGitea();
} catch (error) {
console.error('Failed to sync issues from Gitea:', error);
throw error;
}
}

// If no labels specified, return all issues
if (!labelNames || labelNames.length === 0) {
return {
issues: issuesCache.data,
totalCount: issuesCache.totalCount
};
}

// Filter issues by labels
const filteredIssues = issuesCache.data.filter((issue) => {
const issueLabels = new Set(issue.labels.map((l) => l.name.toLowerCase()));
return labelNames.every((labelName) => issueLabels.has(labelName.toLowerCase()));
});

return {
issues: filteredIssues,
totalCount: filteredIssues.length
};
}

async function getLabels(repo: GiteaRepo = 'btcmap-data'): Promise<GiteaLabel[]> {
const headers = {
Authorization: `token ${env.GITEA_API_KEY}`
Expand Down Expand Up @@ -180,11 +90,6 @@ export async function createIssueWithLabels(
{ headers }
);

// Only invalidate cache for btcmap-data repo
if (repo === 'btcmap-data') {
issuesCache = null;
}

return response;
} catch (error) {
console.error(`Failed to create issue in ${repo}:`, error);
Expand Down
70 changes: 70 additions & 0 deletions src/routes/api/tickets/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import axios from 'axios';
import { env } from '$env/dynamic/private';

import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';

Comment on lines +2 to +7

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing axios-retry configuration for resilience. Other API endpoints in this codebase use axiosRetry with exponential backoff for better reliability. Consider adding similar retry logic here to handle transient Gitea API failures, especially since this endpoint is cached for 24 hours.

Suggested change
import axios from 'axios';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import type { GiteaIssue } from '$lib/types';
axiosRetry(axios, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) =>
axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error)
});

Copilot uses AI. Check for mistakes.
async function fetchAllIssuesFromGitea(): Promise<GiteaIssue[]> {
if (!env.GITEA_API_URL || !env.GITEA_API_KEY) {
console.warn(
'Gitea API configuration missing (GITEA_API_URL or GITEA_API_KEY). Returning empty array.'
);
return [];
}

const headers = {
Authorization: `token ${env.GITEA_API_KEY}`
};

const allIssues: GiteaIssue[] = [];
let page = 1;
// Gitea default max is often 50, request 50 per page to be safe
const limit = 50;

while (true) {
const response = await axios.get(
`${env.GITEA_API_URL}/api/v1/repos/teambtcmap/btcmap-data/issues?state=open&limit=${limit}&page=${page}`,
{ headers }
);

const issues = response.data.map((issue: GiteaIssue) => ({
id: issue.id,
number: issue.number,
title: issue.title,
created_at: issue.created_at,
html_url: issue.html_url,
labels: issue.labels,
user: {
login: issue.user.login,
avatar_url: issue.user.avatar_url,
html_url: issue.user.html_url
},
comments: issue.comments,
assignees: issue.assignees
}));

allIssues.push(...issues);

// If we got fewer than requested, we've reached the end
if (response.data.length < limit) break;
page++;
}
Comment on lines +25 to +52

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pagination loop lacks safeguards against infinite loops. If the API response is malformed or if the limit check fails, this could loop indefinitely. Consider adding a maximum page limit (e.g., 100 pages) to prevent potential runaway execution in serverless environments.

Copilot uses AI. Check for mistakes.

return allIssues;
}

export const GET: RequestHandler = async ({ setHeaders }) => {
// Cache at edge for 24 hours, serve stale for another 24 hours while revalidating
setHeaders({
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=86400'
});

try {
const issues = await fetchAllIssuesFromGitea();
return json({ issues, totalCount: issues.length });
} catch (error) {
console.error('Failed to fetch issues from Gitea:', error);
return json({ issues: [], totalCount: 0, error: 'Failed to fetch issues' }, { status: 500 });
}
};
29 changes: 24 additions & 5 deletions src/routes/community/[area]/[section]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { error, redirect } from '@sveltejs/kit';
import axios from 'axios';
import axiosRetry from 'axios-retry';

import type { PageServerLoad } from './$types';
import { getIssues } from '$lib/gitea';
import type { GiteaIssue, Tickets } from '$lib/types';

axiosRetry(axios, { retries: 3, retryDelay: axiosRetry.exponentialDelay });

export const load: PageServerLoad = async ({ params }) => {
type TicketsResponse = {
issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Comment on lines +10 to +14

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across multiple files. This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.

function filterIssuesByLabel(issues: GiteaIssue[], labelName: string): GiteaIssue[] {
return issues.filter((issue) =>
issue.labels.some((label) => label.name.toLowerCase() === labelName.toLowerCase())
);
}
Comment on lines +16 to +20

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterIssuesByLabel function is duplicated in both country and community page server files. This duplicated logic should be extracted to a shared utility module to avoid maintenance issues and ensure consistent behavior.

Copilot uses AI. Check for mistakes.

export const load: PageServerLoad = async ({ params, fetch }) => {
const { area, section } = params;

// Validate section parameter
Expand All @@ -18,9 +31,15 @@ export const load: PageServerLoad = async ({ params }) => {
const areaResponse = await axios.get(`https://api.btcmap.org/v2/areas/${area}`);
const fetchedArea = areaResponse.data;

const { issues: tickets } = await getIssues([fetchedArea.tags.url_alias]).catch(() => ({
issues: 'error'
}));
// Fetch from cached /api/tickets endpoint and filter by area label
let tickets: Tickets;
try {
const ticketsResponse = await fetch('/api/tickets');
const ticketsData: TicketsResponse = await ticketsResponse.json();
Comment on lines +37 to +38

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Copilot uses AI. Check for mistakes.
tickets = filterIssuesByLabel(ticketsData.issues, fetchedArea.tags.url_alias);
} catch {
tickets = 'error';
}

const issuesResponse = await fetch('https://api.btcmap.org/rpc', {
method: 'POST',
Expand Down
29 changes: 24 additions & 5 deletions src/routes/country/[area]/[section]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { error, redirect } from '@sveltejs/kit';
import axios from 'axios';
import axiosRetry from 'axios-retry';

import type { PageServerLoad } from './$types';
import { getIssues } from '$lib/gitea';
import type { GiteaIssue, Tickets } from '$lib/types';

axiosRetry(axios, { retries: 3, retryDelay: axiosRetry.exponentialDelay });

export const load: PageServerLoad = async ({ params }) => {
type TicketsResponse = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already defined above, can we move this to types or gitea.ts?

issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Comment on lines +10 to +14

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across multiple files. This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.

function filterIssuesByLabel(issues: GiteaIssue[], labelName: string): GiteaIssue[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and below also duplicates above. Maybe the AI has an idea how to DRY this reasonabale?

return issues.filter((issue) =>
issue.labels.some((label) => label.name.toLowerCase() === labelName.toLowerCase())
);
}
Comment on lines +16 to +20

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterIssuesByLabel function is duplicated in both country and community page server files. This duplicated logic should be extracted to a shared utility module to avoid maintenance issues and ensure consistent behavior.

Copilot uses AI. Check for mistakes.

export const load: PageServerLoad = async ({ params, fetch }) => {
const { area, section } = params;

// Validate section parameter - default to merchants if not provided
Expand All @@ -20,9 +33,15 @@ export const load: PageServerLoad = async ({ params }) => {
const areaResponse = await axios.get(`https://api.btcmap.org/v2/areas/${area}`);
const fetchedArea = areaResponse.data;

const { issues: tickets } = await getIssues([fetchedArea.tags.url_alias]).catch(() => ({
issues: 'error'
}));
// Fetch from cached /api/tickets endpoint and filter by area label
let tickets: Tickets;
try {
const ticketsResponse = await fetch('/api/tickets');
const ticketsData: TicketsResponse = await ticketsResponse.json();
Comment on lines +39 to +40

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Copilot uses AI. Check for mistakes.
tickets = filterIssuesByLabel(ticketsData.issues, fetchedArea.tags.url_alias);
} catch {
tickets = 'error';
}

const issuesResponse = await fetch('https://api.btcmap.org/rpc', {
method: 'POST',
Expand Down
18 changes: 13 additions & 5 deletions src/routes/tickets/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { getIssues } from '$lib/gitea';
import type { GiteaIssue } from '$lib/types';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
type TicketsResponse = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

issues: GiteaIssue[];
totalCount: number;
error?: string;
};
Comment on lines +4 to +8

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TicketsResponse type is duplicated across three files (tickets page, country page, and community page). This violates the DRY principle and makes maintenance harder. Consider moving this type to a shared location like src/lib/types.ts where GiteaIssue is already defined.

Copilot uses AI. Check for mistakes.

export const load: PageServerLoad = async ({ fetch }) => {
try {
const { issues, totalCount } = await getIssues();
const response = await fetch('/api/tickets');

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't check the response status before calling json(). If the API returns a non-200 status (e.g., 500 error), this will still attempt to parse JSON and may not properly propagate the error. Consider checking response.ok before parsing.

Suggested change
const response = await fetch('/api/tickets');
const response = await fetch('/api/tickets');
if (!response.ok) {
throw new Error(`Failed to fetch tickets: ${response.status} ${response.statusText}`);
}

Copilot uses AI. Check for mistakes.
const data: TicketsResponse = await response.json();

return {
tickets: issues,
totalTickets: totalCount
tickets: data.issues,
totalTickets: data.totalCount
};
} catch (error) {
console.error('Failed to fetch issues:', error);
Expand Down
Loading