Adds a per-project forced provider (github|gitlab|gitea) on top of the per-project API base URL overrides: stored under gitProviders.provider in projects/<projectId>.json, sanitized server-side, and winning over remote-host detection both in useGitProvider and in server repo resolution (parseGitLabRemoteUrl/parseGiteaRemoteUrl accept any host when the provider is forced). The Projects settings page replaces the three always-visible URL fields with a provider selector (auto-detect + the three forges) and one URL override for the active provider. Global provider override fields on the GitHub/GitLab/Gitea settings tabs now render only once an account is connected, and Settings search availability matches that gating. Also fixes the useConfigStore/useDirectoryStore circular-import TDZ in the bundled chunk via the window-registered store handle and defers the directory subscription to a microtask; fixes the Gitea PR merge payload (Do carries the merge-style string enum, not a boolean + MergeMethod); and adds a documented Gitea client live-test harness (scripts/gitea-live-test.ts + client.d.ts).
1693 lines
68 KiB
JavaScript
1693 lines
68 KiB
JavaScript
// Route-level budget for composite Gitea calls (lists, comments, PR context).
|
|
// The client bounds each individual request at 8s; this caps the whole route
|
|
// so a slow self-hosted instance cannot hold a response (and a client socket)
|
|
// open indefinitely. The client keeps its last-known state on error.
|
|
const ROUTE_TIMEOUT_MS = 15_000;
|
|
|
|
// PR list pagination cap for the source-branch scan: never loop more than 10
|
|
// pages when aggregating PRs for a local branch.
|
|
const PRS_MAX_PAGES = 10;
|
|
|
|
function withTimeout(promise, timeoutMs, label) {
|
|
let timer;
|
|
const timeout = new Promise((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
error.code = 'ETIMEDOUT';
|
|
reject(error);
|
|
}, timeoutMs);
|
|
if (typeof timer.unref === 'function') timer.unref();
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
|
|
// Resolve the requested repo from the query (read routes) or the JSON body
|
|
// (write routes). `owner`/`repo` override the directory-local git remote for
|
|
// repos checked out from non-Gitea remotes.
|
|
const getRequestedRepo = (req) => {
|
|
const owner = asString(req.query?.owner) || asString(req.body?.owner);
|
|
const repo = asString(req.query?.repo) || asString(req.body?.repo);
|
|
return owner && repo ? { owner, repo } : null;
|
|
};
|
|
|
|
const getRequiredNumber = (req) => {
|
|
const raw = typeof req.query?.number === 'string' ? req.query.number : '';
|
|
const number = Number(raw);
|
|
return Number.isFinite(number) && number > 0 ? number : null;
|
|
};
|
|
|
|
// Gitea/Forgejo `GET /user` (and user sub-objects on issues/PRs) carry the
|
|
// username in `login` (plus `full_name`, `avatar_url`, `html_url`). Tolerate
|
|
// the `username`/`web_url` variants so mapping stays robust across API versions.
|
|
const mapGiteaUser = (data) => {
|
|
if (!data || typeof data !== 'object') {
|
|
return null;
|
|
}
|
|
return {
|
|
username: typeof data.login === 'string' ? data.login : (typeof data.username === 'string' ? data.username : null),
|
|
id: typeof data.id === 'number' ? data.id : null,
|
|
name: typeof data.full_name === 'string' ? data.full_name : (typeof data.name === 'string' ? data.name : null),
|
|
avatarUrl: typeof data.avatar_url === 'string' ? data.avatar_url : null,
|
|
webUrl: typeof data.html_url === 'string' ? data.html_url : (typeof data.web_url === 'string' ? data.web_url : null),
|
|
email: typeof data.email === 'string' ? data.email : null,
|
|
};
|
|
};
|
|
|
|
const mapGiteaAuthor = (user) => {
|
|
if (!user || typeof user !== 'object') {
|
|
return null;
|
|
}
|
|
return {
|
|
username: typeof user.login === 'string' ? user.login : (typeof user.username === 'string' ? user.username : ''),
|
|
id: typeof user.id === 'number' ? user.id : undefined,
|
|
};
|
|
};
|
|
|
|
const mapGiteaLabels = (labels) => (
|
|
Array.isArray(labels)
|
|
? labels.map((label) => (label && typeof label.name === 'string' ? label.name : '')).filter(Boolean)
|
|
: []
|
|
);
|
|
|
|
const mapGiteaIssueSummary = (item) => ({
|
|
number: typeof item.number === 'number' ? item.number : Number(item.number),
|
|
title: typeof item.title === 'string' ? item.title : '',
|
|
url: typeof item.html_url === 'string' ? item.html_url : '',
|
|
state: typeof item.state === 'string' ? item.state : 'open',
|
|
author: mapGiteaAuthor(item.user) || {},
|
|
labels: mapGiteaLabels(item.labels),
|
|
assignees: Array.isArray(item.assignees)
|
|
? item.assignees.map(mapGiteaAuthor).filter((user) => user?.username)
|
|
: [],
|
|
milestone: item.milestone && typeof item.milestone === 'object'
|
|
? {
|
|
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
|
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
|
}
|
|
: null,
|
|
commentsCount: typeof item.comments === 'number' ? item.comments : undefined,
|
|
});
|
|
|
|
const mapGiteaPullRequestSummary = (item) => {
|
|
const merged = Boolean(item.merged);
|
|
const closed = item.state === 'closed';
|
|
return {
|
|
number: typeof item.number === 'number' ? item.number : Number(item.number),
|
|
title: typeof item.title === 'string' ? item.title : '',
|
|
url: typeof item.html_url === 'string' ? item.html_url : '',
|
|
state: merged ? 'merged' : (closed ? 'closed' : 'open'),
|
|
draft: Boolean(item.draft),
|
|
author: mapGiteaAuthor(item.user) || {},
|
|
labels: mapGiteaLabels(item.labels),
|
|
assignees: Array.isArray(item.assignees)
|
|
? item.assignees.map(mapGiteaAuthor).filter((user) => user?.username)
|
|
: [],
|
|
milestone: item.milestone && typeof item.milestone === 'object'
|
|
? {
|
|
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
|
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
|
}
|
|
: null,
|
|
commentsCount: typeof item.comments === 'number' ? item.comments : undefined,
|
|
sourceBranch: typeof item.head?.ref === 'string' ? item.head.ref : '',
|
|
targetBranch: typeof item.base?.ref === 'string' ? item.base.ref : '',
|
|
};
|
|
};
|
|
|
|
const mapGiteaPullRequest = (item) => ({
|
|
...mapGiteaPullRequestSummary(item),
|
|
body: typeof item.body === 'string' ? item.body : undefined,
|
|
mergeable: typeof item.mergeable === 'boolean' ? item.mergeable : undefined,
|
|
merged: Boolean(item.merged),
|
|
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
|
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
|
});
|
|
|
|
const mapGiteaComment = (comment) => ({
|
|
id: typeof comment.id === 'number' ? comment.id : Number(comment.id),
|
|
body: typeof comment.body === 'string' ? comment.body : '',
|
|
url: typeof comment.html_url === 'string' ? comment.html_url : undefined,
|
|
author: mapGiteaAuthor(comment.user) || {},
|
|
createdAt: typeof comment.created_at === 'string' ? comment.created_at : undefined,
|
|
});
|
|
|
|
const mapGiteaIssue = (item) => ({
|
|
...mapGiteaIssueSummary(item),
|
|
body: typeof item.body === 'string' ? item.body : '',
|
|
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
|
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
|
});
|
|
|
|
// Gitea's pull-files endpoint returns capitalized JSON fields
|
|
// (Filename/Status/Additions/Deletions/Patch); tolerate the lowercase GitHub
|
|
// style too for Forgejo versions that match GitHub output.
|
|
const mapGiteaFile = (file) => ({
|
|
filename: typeof file.Filename === 'string' ? file.Filename : (typeof file.filename === 'string' ? file.filename : ''),
|
|
status: typeof file.Status === 'string' ? file.Status : (typeof file.status === 'string' ? file.status : undefined),
|
|
additions: typeof file.Additions === 'number' ? file.Additions : undefined,
|
|
deletions: typeof file.Deletions === 'number' ? file.Deletions : undefined,
|
|
patch: typeof file.Patch === 'string' ? file.Patch : (typeof file.patch === 'string' ? file.patch : undefined),
|
|
});
|
|
|
|
// Gitea error bodies carry `message` (string) or `error` (string). Flatten
|
|
// whichever shape is present into one readable string for write routes.
|
|
const giteaErrorMessage = (data) => {
|
|
if (!data || typeof data !== 'object') {
|
|
return null;
|
|
}
|
|
if (typeof data.message === 'string' && data.message) {
|
|
return data.message;
|
|
}
|
|
if (typeof data.error === 'string' && data.error) {
|
|
return data.error;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Gitea issue/PR update endpoints take `milestone` (numeric), not the title.
|
|
// Resolve a title via the repo milestones list (first page is enough for
|
|
// title-based lookups); unmatched titles yield `milestoneId: null` so routes
|
|
// can surface `400 { error: 'Milestone not found' }`.
|
|
const resolveMilestoneId = async (client, owner, repo, title) => {
|
|
const resp = await client.milestones(owner, repo, { state: 'all', limit: 50 });
|
|
if (resp.status === 429) {
|
|
return { milestoneId: null, rateLimited: true };
|
|
}
|
|
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
|
return { milestoneId: null, rateLimited: false };
|
|
}
|
|
const match = resp.data.find(
|
|
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === title.toLowerCase(),
|
|
);
|
|
return { milestoneId: typeof match?.id === 'number' ? match.id : null, rateLimited: false };
|
|
};
|
|
|
|
const repoRefFromOwnerRepo = (owner, repo, baseUrl) => {
|
|
let host = null;
|
|
let normalizedBaseUrl = null;
|
|
let url = null;
|
|
if (baseUrl) {
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
host = parsed.hostname;
|
|
normalizedBaseUrl = parsed.href.replace(/\/+$/, '');
|
|
url = `${normalizedBaseUrl}/${owner}/${repo}`;
|
|
} catch {
|
|
// fall back to unknown host
|
|
}
|
|
}
|
|
return { owner, repo, host, baseUrl: normalizedBaseUrl, url };
|
|
};
|
|
|
|
export function registerGiteaRoutes(app, options = {}) {
|
|
let giteaLibraries = null;
|
|
const getGiteaLibraries = async () => {
|
|
if (!giteaLibraries) {
|
|
giteaLibraries = await import('./index.js');
|
|
}
|
|
return giteaLibraries;
|
|
};
|
|
|
|
const getClient = async (directory) => {
|
|
const { getGiteaClientOrNull } = await getGiteaLibraries();
|
|
return getGiteaClientOrNull(directory);
|
|
};
|
|
|
|
// Resolve which Gitea repo a request targets. A directory-local git remote
|
|
// is the primary source; `owner`/`repo` query params override it (needed for
|
|
// repos checked out from non-Gitea remotes).
|
|
const resolveRepoForRequest = async (directory, requestedRepo) => {
|
|
if (requestedRepo) {
|
|
return { owner: requestedRepo.owner, repo: requestedRepo.repo, repoRef: null, fromDirectory: false };
|
|
}
|
|
if (!directory) {
|
|
return { owner: null, repo: null, repoRef: null, fromDirectory: false };
|
|
}
|
|
const { resolveGiteaRepoFromDirectory } = await getGiteaLibraries();
|
|
const { repo } = await resolveGiteaRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return { owner: null, repo: null, repoRef: null, fromDirectory: false };
|
|
}
|
|
return { owner: repo.owner, repo: repo.repo, repoRef: repo, fromDirectory: true };
|
|
};
|
|
|
|
// ================= Gitea Auth APIs =================
|
|
|
|
app.get('/api/gitea/auth/status', async (_req, res) => {
|
|
try {
|
|
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
|
|
const auth = getGiteaAuth();
|
|
const accounts = getGiteaAuthAccounts();
|
|
if (!auth?.accessToken) {
|
|
return res.json({ connected: false, accounts });
|
|
}
|
|
|
|
const client = await getClient();
|
|
let user = null;
|
|
if (client) {
|
|
const resp = await client.user();
|
|
if (resp.status === 401 || resp.status === 403) {
|
|
clearGiteaAuth();
|
|
return res.json({ connected: false, accounts: getGiteaAuthAccounts() });
|
|
}
|
|
if (resp.status === 200 && resp.data) {
|
|
user = mapGiteaUser(resp.data);
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
...(user ? { user } : {}),
|
|
accounts,
|
|
defaultBaseUrl: getGiteaDefaultBaseUrl(),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get Gitea auth status:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to get Gitea auth status' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/auth/connect', async (req, res) => {
|
|
try {
|
|
const accessToken = asString(req.body?.accessToken);
|
|
if (!accessToken) {
|
|
return res.status(400).json({ error: 'accessToken is required' });
|
|
}
|
|
|
|
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
|
|
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGiteaDefaultBaseUrl();
|
|
if (!baseUrl) {
|
|
return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' });
|
|
}
|
|
|
|
const { createGiteaClient } = await getGiteaLibraries();
|
|
const client = createGiteaClient({ token: accessToken, baseUrl });
|
|
const resp = await client.user();
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 401 || resp.status === 403 || resp.status >= 400 || !mapGiteaUser(resp.data)?.username) {
|
|
return res.status(400).json({ error: 'Invalid Gitea access token' });
|
|
}
|
|
|
|
setGiteaAuth({ accessToken, baseUrl, user: resp.data });
|
|
return res.json({
|
|
connected: true,
|
|
user: mapGiteaUser(resp.data),
|
|
accounts: getGiteaAuthAccounts(),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to connect Gitea:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to connect Gitea' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/auth/activate', async (req, res) => {
|
|
try {
|
|
const accountId = asString(req.body?.accountId);
|
|
if (!accountId) {
|
|
return res.status(400).json({ error: 'accountId is required' });
|
|
}
|
|
|
|
const { activateGiteaAuth, getGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries();
|
|
const activated = activateGiteaAuth(accountId);
|
|
if (!activated) {
|
|
return res.status(404).json({ error: 'Gitea account not found' });
|
|
}
|
|
|
|
const auth = getGiteaAuth();
|
|
const accounts = getGiteaAuthAccounts();
|
|
if (!auth?.accessToken) {
|
|
return res.json({ connected: false, accounts });
|
|
}
|
|
|
|
let user = auth.username
|
|
? {
|
|
username: auth.username,
|
|
id: null,
|
|
name: auth.name,
|
|
avatarUrl: auth.avatarUrl,
|
|
webUrl: auth.webUrl,
|
|
email: auth.email,
|
|
}
|
|
: null;
|
|
const client = await getClient();
|
|
if (client) {
|
|
const resp = await client.user();
|
|
if (resp.status === 200 && resp.data) {
|
|
user = mapGiteaUser(resp.data);
|
|
}
|
|
}
|
|
|
|
return res.json({ connected: true, user, accounts });
|
|
} catch (error) {
|
|
console.error('Failed to activate Gitea account:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to activate Gitea account' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/gitea/auth', async (_req, res) => {
|
|
try {
|
|
const { clearGiteaAuth } = await getGiteaLibraries();
|
|
const removed = clearGiteaAuth();
|
|
return res.json({ removed });
|
|
} catch (error) {
|
|
console.error('Failed to disconnect Gitea:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to disconnect Gitea' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/me', async (_req, res) => {
|
|
try {
|
|
const { clearGiteaAuth } = await getGiteaLibraries();
|
|
const client = await getClient();
|
|
if (!client) {
|
|
return res.status(401).json({ error: 'Gitea not connected' });
|
|
}
|
|
const resp = await client.user();
|
|
if (resp.status === 401 || resp.status === 403) {
|
|
clearGiteaAuth();
|
|
return res.status(401).json({ error: 'Gitea token expired or revoked' });
|
|
}
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200 || !resp.data) {
|
|
return res.status(500).json({ error: 'Failed to fetch Gitea user' });
|
|
}
|
|
return res.json(mapGiteaUser(resp.data));
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea user:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea user' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Issue APIs =================
|
|
|
|
app.get('/api/gitea/issues/list', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
|
|
const searchQuery = asString(req.query?.query);
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, issues: [], page: effectivePage, hasMore: false });
|
|
}
|
|
|
|
// `type=issues` excludes pull requests from the issue list; the client-side
|
|
// filter is a backstop for instances that ignore it.
|
|
const params = { state: 'open', type: 'issues', limit: 50, page: effectivePage };
|
|
if (searchQuery) {
|
|
params.q = searchQuery;
|
|
}
|
|
const resp = await withTimeout(client.issues(owner, repo, params), ROUTE_TIMEOUT_MS, 'gitea issues list');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while listing issues' });
|
|
}
|
|
|
|
const issues = (Array.isArray(resp.data) ? resp.data : [])
|
|
.filter((item) => !item?.pull_request)
|
|
.map(mapGiteaIssueSummary);
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
issues,
|
|
page: effectivePage,
|
|
hasMore: Boolean(resp.page?.hasMore),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to list Gitea issues:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to list Gitea issues' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/issues/get', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, issue: null });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, issue: null });
|
|
}
|
|
|
|
const resp = await withTimeout(client.issue(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea issue get');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Issue not found' });
|
|
}
|
|
if (resp.status !== 200 || !resp.data) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching the issue' });
|
|
}
|
|
|
|
const item = resp.data;
|
|
const issue = mapGiteaIssue(item);
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), issue });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea issue:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea issue' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/issues/comments', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, comments: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, comments: [] });
|
|
}
|
|
|
|
const commentsResp = await withTimeout(
|
|
client.issueComments(owner, repo, number, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea issue comments',
|
|
);
|
|
if (commentsResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (commentsResp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching issue comments' });
|
|
}
|
|
|
|
const comments = (Array.isArray(commentsResp.data) ? commentsResp.data : []).map(mapGiteaComment);
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), comments });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea issue comments:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea issue comments' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/issues/comment', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
|
if (!directory || !number || !body) {
|
|
return res.status(400).json({ error: 'directory, number, body are required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const resp = await client.createIssueComment(owner, repo, number, body);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on issues' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Issue not found' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
comment: mapGiteaComment(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create Gitea issue comment:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue comment' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/issues/create', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|
if (!directory || !title) {
|
|
return res.status(400).json({ error: 'directory and title are required' });
|
|
}
|
|
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : undefined;
|
|
const labels = Array.isArray(req.body?.labels)
|
|
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
|
|
: undefined;
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const params = {
|
|
title,
|
|
...(body !== undefined ? { body } : {}),
|
|
...(labels !== undefined ? { labels } : {}),
|
|
};
|
|
const resp = await client.createIssue(owner, repo, params);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to create issues' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the issue' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while creating the issue' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
issue: mapGiteaIssue(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create Gitea issue:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/gitea/issues/update', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
if (!directory || !number) {
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const body = {};
|
|
if (typeof req.body?.title === 'string') {
|
|
body.title = req.body.title.trim();
|
|
}
|
|
if (typeof req.body?.body === 'string') {
|
|
body.body = req.body.body;
|
|
}
|
|
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
|
body.state = req.body.state;
|
|
}
|
|
// Gitea accepts label names (not ids) in the edit-issue payload.
|
|
if (Array.isArray(req.body?.labels)) {
|
|
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
|
}
|
|
if (Array.isArray(req.body?.assignees)) {
|
|
body.assignees = req.body.assignees.filter((login) => typeof login === 'string');
|
|
}
|
|
if (req.body?.milestone !== undefined) {
|
|
if (req.body.milestone === null) {
|
|
body.unset_milestone = true;
|
|
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
|
const { milestoneId, rateLimited } = await resolveMilestoneId(client, owner, repo, req.body.milestone.trim());
|
|
if (rateLimited) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (milestoneId === null) {
|
|
return res.status(400).json({ error: 'Milestone not found' });
|
|
}
|
|
body.milestone = milestoneId;
|
|
}
|
|
}
|
|
|
|
const resp = await client.updateIssue(owner, repo, number, body);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to update issues' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Issue not found' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the issue' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while updating the issue' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
issue: mapGiteaIssue(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update Gitea issue:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to update Gitea issue' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Pull Request APIs =================
|
|
|
|
app.get('/api/gitea/prs/list', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
|
|
const searchQuery = asString(req.query?.query);
|
|
const sourceBranch = asString(req.query?.sourceBranch);
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, prs: [], page: effectivePage, hasMore: false });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, prs: [], page: effectivePage, hasMore: false });
|
|
}
|
|
|
|
if (sourceBranch) {
|
|
// Gitea has no server-side source-branch filter on pulls, so fetch all
|
|
// states and filter by head.ref client-side — this returns open and
|
|
// merged PRs for the local branch so the UI can prefer the open one.
|
|
const matching = [];
|
|
let hasMore = false;
|
|
let page = 1;
|
|
for (let depth = 0; depth < PRS_MAX_PAGES; depth += 1) {
|
|
const resp = await withTimeout(
|
|
client.pullRequests(owner, repo, { state: 'all', limit: 50, page }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea prs list',
|
|
);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
|
break;
|
|
}
|
|
for (const item of resp.data) {
|
|
if (typeof item.head?.ref === 'string' && item.head.ref === sourceBranch) {
|
|
matching.push(mapGiteaPullRequestSummary(item));
|
|
}
|
|
}
|
|
hasMore = Boolean(resp.page?.hasMore);
|
|
if (!hasMore || resp.data.length === 0) {
|
|
break;
|
|
}
|
|
page += 1;
|
|
}
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
prs: matching,
|
|
page: effectivePage,
|
|
hasMore,
|
|
});
|
|
}
|
|
|
|
const params = { state: 'open', limit: 50, page: effectivePage };
|
|
if (searchQuery) {
|
|
params.q = searchQuery;
|
|
}
|
|
const resp = await withTimeout(client.pullRequests(owner, repo, params), ROUTE_TIMEOUT_MS, 'gitea prs list');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while listing pull requests' });
|
|
}
|
|
|
|
const prs = (Array.isArray(resp.data) ? resp.data : []).map(mapGiteaPullRequestSummary);
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
prs,
|
|
page: effectivePage,
|
|
hasMore: Boolean(resp.page?.hasMore),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to list Gitea pull requests:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to list Gitea pull requests' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/pr/context', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
const includeDiff = req.query?.includeDiff === '1' || req.query?.includeDiff === 'true';
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, pr: null, comments: [], files: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, pr: null, comments: [], files: [] });
|
|
}
|
|
|
|
const prResp = await withTimeout(client.pullRequest(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr context');
|
|
if (prResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (prResp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (prResp.status !== 200 || !prResp.data) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching the pull request' });
|
|
}
|
|
const pr = mapGiteaPullRequest(prResp.data);
|
|
|
|
// PR comments live on the issue comments endpoint in Gitea.
|
|
const commentsResp = await withTimeout(
|
|
client.issueComments(owner, repo, number, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea pr context comments',
|
|
);
|
|
if (commentsResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
const comments = (commentsResp.status === 200 && Array.isArray(commentsResp.data) ? commentsResp.data : [])
|
|
.map(mapGiteaComment);
|
|
|
|
// Per-file patches. Older Gitea instances 404 on this endpoint; fall back
|
|
// to an empty list rather than failing the whole context.
|
|
const filesResp = await withTimeout(
|
|
client.pullRequestFiles(owner, repo, number, { patch: 'true' }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea pr context files',
|
|
);
|
|
if (filesResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
const files = (filesResp.status === 200 && Array.isArray(filesResp.data) ? filesResp.data : [])
|
|
.map(mapGiteaFile);
|
|
|
|
let diff;
|
|
if (includeDiff) {
|
|
// Raw unified diff; fall back to concatenated per-file patches.
|
|
const diffResp = await withTimeout(client.pullRequestDiff(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr context diff');
|
|
if (diffResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (diffResp.status === 200 && typeof diffResp.data === 'string' && diffResp.data.trim()) {
|
|
diff = diffResp.data;
|
|
} else {
|
|
const patches = files.map((file) => file.patch || '').filter(Boolean);
|
|
if (patches.length > 0) {
|
|
diff = patches.join('\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
pr,
|
|
comments,
|
|
files,
|
|
...(diff ? { diff } : {}),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load Gitea pull request context:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to load Gitea pull request context' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Pull Request Enrichment APIs =================
|
|
|
|
// Gitea commit objects carry a top-level `author` (the GitHub-style user,
|
|
// with `login`) plus a `commit.author` (git identity: name/email/date).
|
|
// Prefer the user when present, else fall back to the git identity so the
|
|
// author chip still renders something useful.
|
|
const mapGiteaCommitAuthor = (commit) => {
|
|
const userAuthor = mapGiteaAuthor(commit?.author);
|
|
if (userAuthor?.username) {
|
|
return userAuthor;
|
|
}
|
|
const gitAuthor = commit?.commit?.author;
|
|
if (gitAuthor && typeof gitAuthor.name === 'string' && gitAuthor.name) {
|
|
return {
|
|
username: gitAuthor.name,
|
|
...(typeof gitAuthor.email === 'string' ? { email: gitAuthor.email } : {}),
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
app.get('/api/gitea/prs/commits', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, commits: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, commits: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(
|
|
client.pullRequestCommits(owner, repo, number, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea pr commits',
|
|
);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching pull request commits' });
|
|
}
|
|
|
|
const commits = (Array.isArray(resp.data) ? resp.data : []).map((commit) => {
|
|
const message = typeof commit.commit?.message === 'string' ? commit.commit.message : '';
|
|
return {
|
|
sha: typeof commit.sha === 'string' ? commit.sha : '',
|
|
message,
|
|
...(message.split('\n')[0] ? { summary: message.split('\n')[0] } : {}),
|
|
author: mapGiteaCommitAuthor(commit),
|
|
...(typeof commit.commit?.author?.date === 'string' ? { committedAt: commit.commit.author.date } : {}),
|
|
parents: Array.isArray(commit.parents) ? commit.parents.map((parent) => parent.sha) : [],
|
|
};
|
|
});
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), commits });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea pull request commits:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request commits' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/prs/reviews', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, reviews: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, reviews: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(
|
|
client.pullRequestReviews(owner, repo, number, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea pr reviews',
|
|
);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching pull request reviews' });
|
|
}
|
|
|
|
const reviews = (Array.isArray(resp.data) ? resp.data : []).map((review) => ({
|
|
id: String(review.id),
|
|
state: typeof review.state === 'string' ? review.state : 'PENDING',
|
|
author: mapGiteaAuthor(review.user) || null,
|
|
...(typeof review.submitted_at === 'string' ? { submittedAt: review.submitted_at } : {}),
|
|
body: typeof review.body === 'string' ? review.body : null,
|
|
...(typeof review.commit_id === 'string' ? { commitSha: review.commit_id } : null),
|
|
}));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), reviews });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea pull request reviews:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request reviews' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/prs/statuses', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const number = getRequiredNumber(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
if (!number) {
|
|
return res.status(400).json({ error: 'number is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, statuses: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, statuses: [] });
|
|
}
|
|
|
|
// Resolve the PR head SHA first — Gitea's commit-status endpoint is
|
|
// keyed by commit SHA, not PR number. The PR payload's `head.sha` is the
|
|
// head commit (same field family as `head.ref` used elsewhere).
|
|
const prResp = await withTimeout(client.pullRequest(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr statuses');
|
|
if (prResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (prResp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (prResp.status !== 200 || !prResp.data) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching the pull request' });
|
|
}
|
|
const headSha = typeof prResp.data.head?.sha === 'string' ? prResp.data.head.sha : null;
|
|
if (!headSha) {
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), statuses: [] });
|
|
}
|
|
|
|
const statusesResp = await withTimeout(
|
|
client.commitStatuses(owner, repo, headSha, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea commit statuses',
|
|
);
|
|
if (statusesResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (statusesResp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching commit statuses' });
|
|
}
|
|
|
|
const statuses = (Array.isArray(statusesResp.data) ? statusesResp.data : []).map((status) => ({
|
|
state: typeof status.state === 'string' ? status.state.toLowerCase() : 'unknown',
|
|
name: typeof status.context === 'string' ? status.context : '',
|
|
...(typeof status.description === 'string' ? { description: status.description } : null),
|
|
...(typeof status.target_url === 'string' ? { url: status.target_url } : null),
|
|
...(typeof status.created_at === 'string' ? { createdAt: status.created_at } : {}),
|
|
}));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), statuses });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea pull request statuses:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request statuses' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Pull Request Write APIs =================
|
|
|
|
app.post('/api/gitea/pr/create', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const title = asString(req.body?.title);
|
|
const sourceBranch = asString(req.body?.sourceBranch);
|
|
const targetBranch = asString(req.body?.targetBranch);
|
|
if (!directory || !title || !sourceBranch || !targetBranch) {
|
|
return res.status(400).json({ error: 'directory, title, sourceBranch, targetBranch are required' });
|
|
}
|
|
const description = typeof req.body?.description === 'string' && req.body.description
|
|
? req.body.description
|
|
: undefined;
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const body = {
|
|
title,
|
|
head: sourceBranch,
|
|
base: targetBranch,
|
|
};
|
|
if (description !== undefined) {
|
|
body.body = description;
|
|
}
|
|
|
|
const resp = await withTimeout(client.createPullRequest(owner, repo, body), ROUTE_TIMEOUT_MS, 'gitea pr create');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to create pull requests' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the pull request' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while creating the pull request' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
pr: mapGiteaPullRequest(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create Gitea pull request:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/gitea/pr/update', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
if (!directory || !number) {
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|
}
|
|
const title = asString(req.body?.title);
|
|
const description = typeof req.body?.description === 'string' ? req.body.description : undefined;
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const body = {};
|
|
if (title) {
|
|
body.title = title;
|
|
}
|
|
if (description !== undefined) {
|
|
body.body = description;
|
|
}
|
|
// PRs are issues at the API level in Gitea (the PR number IS the issue
|
|
// index), so the edit-issue `state` transition applies directly.
|
|
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
|
body.state = req.body.state;
|
|
}
|
|
|
|
const resp = await withTimeout(client.updatePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr update');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to update pull requests' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the pull request' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while updating the pull request' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
pr: mapGiteaPullRequest(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update Gitea pull request:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to update Gitea pull request' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/pr/merge', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
if (!directory || !number) {
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|
}
|
|
const method = ['merge', 'squash', 'rebase'].includes(req.body?.method) ? req.body.method : 'merge';
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
// Gitea's merge endpoint takes the merge style directly in `Do` (a
|
|
// string enum: merge/rebase/rebase-merge/squash/fast-forward-only/
|
|
// manually-merged). There is no separate `MergeMethod` field.
|
|
const body = { Do: method };
|
|
|
|
const resp = await withTimeout(client.mergePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr merge');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to merge pull requests' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
// Gitea rejects non-mergeable requests with 405/409/422 and a `message`
|
|
// in the body — surface it as a merge rejection (mirrors the GitHub
|
|
// pr/merge contract) instead of a generic error.
|
|
if (resp.status === 405 || resp.status === 409 || resp.status === 422) {
|
|
return res.status(resp.status).json({
|
|
connected: true,
|
|
merged: false,
|
|
message: giteaErrorMessage(resp.data) || 'Pull request not mergeable',
|
|
});
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while merging the pull request' });
|
|
}
|
|
|
|
return res.json({ connected: true, merged: true });
|
|
} catch (error) {
|
|
console.error('Failed to merge Gitea pull request:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to merge Gitea pull request' });
|
|
}
|
|
});
|
|
|
|
// PRs are issues at the API level in Gitea, so a PR comment is an issue
|
|
// comment addressed by the PR number (which IS the issue index).
|
|
app.post('/api/gitea/prs/comment', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
|
if (!directory || !number || !body) {
|
|
return res.status(400).json({ error: 'directory, number, body are required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const resp = await client.createIssueComment(owner, repo, number, body);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on pull requests' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
comment: mapGiteaComment(resp.data),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create Gitea pull request comment:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request comment' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/gitea/prs/review', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.body?.directory);
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
const event = typeof req.body?.event === 'string' ? req.body.event : '';
|
|
if (!directory || !number || !event) {
|
|
return res.status(400).json({ error: 'directory, number, event are required' });
|
|
}
|
|
if (event !== 'APPROVED' && event !== 'REQUEST_CHANGES' && event !== 'COMMENT') {
|
|
return res.status(400).json({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const requestedRepo = getRequestedRepo(req);
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
|
}
|
|
|
|
const params = { event };
|
|
if (typeof req.body?.body === 'string' && req.body.body) {
|
|
params.body = req.body.body;
|
|
}
|
|
|
|
const resp = await client.createPullReview(owner, repo, number, params);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status === 403) {
|
|
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to review pull requests' });
|
|
}
|
|
if (resp.status === 404) {
|
|
return res.status(404).json({ error: 'Pull request not found' });
|
|
}
|
|
if (resp.status !== 200 && resp.status !== 201) {
|
|
const status = resp.status >= 500 ? 500 : 400;
|
|
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while submitting the review' });
|
|
}
|
|
if (!resp.data) {
|
|
return res.status(500).json({ error: 'Gitea returned an empty response while submitting the review' });
|
|
}
|
|
|
|
const review = resp.data;
|
|
return res.json({
|
|
connected: true,
|
|
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
|
review: {
|
|
id: String(review.id),
|
|
state: typeof review.state === 'string' ? review.state : event,
|
|
author: mapGiteaAuthor(review.user) || null,
|
|
...(typeof review.submitted_at === 'string' ? { submittedAt: review.submitted_at } : {}),
|
|
body: typeof review.body === 'string' ? review.body : null,
|
|
...(typeof review.commit_id === 'string' ? { commitSha: review.commit_id } : null),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to submit Gitea pull request review:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to submit Gitea pull request review' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Repo APIs =================
|
|
|
|
app.get('/api/gitea/repo/branches', async (req, res) => {
|
|
try {
|
|
const owner = asString(req.query?.owner);
|
|
const repo = asString(req.query?.repo);
|
|
if (!owner || !repo) {
|
|
return res.status(400).json({ error: 'owner and repo are required' });
|
|
}
|
|
|
|
const client = await getClient();
|
|
if (!client) {
|
|
return res.json({ branches: [], defaultBranch: null });
|
|
}
|
|
|
|
const branches = [];
|
|
let page = 1;
|
|
while (page <= 10) {
|
|
const resp = await withTimeout(client.branches(owner, repo, { limit: 50, page }), ROUTE_TIMEOUT_MS, 'gitea repo branches');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
|
break;
|
|
}
|
|
const chunk = resp.data;
|
|
for (const branch of chunk) {
|
|
if (typeof branch?.name === 'string') {
|
|
branches.push(branch.name);
|
|
}
|
|
}
|
|
if (chunk.length < 50 || !resp.page?.hasMore) {
|
|
break;
|
|
}
|
|
page += 1;
|
|
}
|
|
|
|
// Gitea branch objects carry no default flag; read `default_branch` from
|
|
// the repo object instead.
|
|
let defaultBranch = null;
|
|
const repoResp = await withTimeout(client.repo(owner, repo), ROUTE_TIMEOUT_MS, 'gitea repo');
|
|
if (repoResp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (repoResp.status === 200 && repoResp.data) {
|
|
defaultBranch = typeof repoResp.data.default_branch === 'string' ? repoResp.data.default_branch : null;
|
|
}
|
|
|
|
return res.json({ branches, defaultBranch });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea repo branches:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo branches' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/repo/labels', async (req, res) => {
|
|
try {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
if (!directory && !requestedRepo) {
|
|
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
|
}
|
|
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return res.json({ connected: false, labels: [] });
|
|
}
|
|
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, labels: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(
|
|
client.repoLabels(owner, repo, { limit: 100 }),
|
|
ROUTE_TIMEOUT_MS,
|
|
'gitea repo labels',
|
|
);
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while fetching repo labels' });
|
|
}
|
|
|
|
const labels = (Array.isArray(resp.data) ? resp.data : []).map((label) => ({
|
|
...(typeof label.id === 'number' ? { id: label.id } : {}),
|
|
name: typeof label.name === 'string' ? label.name : '',
|
|
...(typeof label.color === 'string' ? { color: label.color } : {}),
|
|
...(typeof label.description === 'string' ? { description: label.description } : {}),
|
|
}));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Gitea repo labels:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
|
|
}
|
|
});
|
|
|
|
// ================= Gitea Rich Lookup APIs =================
|
|
|
|
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
|
|
// (directory remote + owner/repo override) and hits a Gitea endpoint that
|
|
// does not support server-side search, then filters client-side
|
|
// (case-insensitive substring on the primary field). `connected: false`
|
|
// means the lookup could not be performed — never an authoritative empty list.
|
|
|
|
const lookupRepo = async (req) => {
|
|
const directory = asString(req.query?.directory);
|
|
const requestedRepo = getRequestedRepo(req);
|
|
if (!directory && !requestedRepo) {
|
|
return { error: 'directory or owner/repo is required' };
|
|
}
|
|
const client = await getClient(directory);
|
|
if (!client) {
|
|
return { client: null };
|
|
}
|
|
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
|
return { client, owner, repo, repoRef };
|
|
};
|
|
|
|
app.get('/api/gitea/users/search', async (req, res) => {
|
|
try {
|
|
const query = asString(req.query?.query);
|
|
const resolved = await lookupRepo(req);
|
|
if (resolved.error) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
if (!resolved.client) {
|
|
return res.json({ connected: false, users: [] });
|
|
}
|
|
const { client, owner, repo, repoRef } = resolved;
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, users: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(client.assignees(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea users search');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while searching users' });
|
|
}
|
|
const needle = query.toLowerCase();
|
|
const users = (Array.isArray(resp.data) ? resp.data : [])
|
|
.map(mapGiteaUser)
|
|
.filter((user) => user && user.username)
|
|
.filter((user) => !needle || user.username.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), users });
|
|
} catch (error) {
|
|
console.error('Failed to search Gitea users:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to search Gitea users' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/labels/search', async (req, res) => {
|
|
try {
|
|
const query = asString(req.query?.query);
|
|
const resolved = await lookupRepo(req);
|
|
if (resolved.error) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
if (!resolved.client) {
|
|
return res.json({ connected: false, labels: [] });
|
|
}
|
|
const { client, owner, repo, repoRef } = resolved;
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, labels: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(client.repoLabels(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea labels search');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while searching labels' });
|
|
}
|
|
const needle = query.toLowerCase();
|
|
const labels = (Array.isArray(resp.data) ? resp.data : [])
|
|
.map((label) => ({
|
|
...(typeof label.id === 'number' ? { id: label.id } : {}),
|
|
name: typeof label.name === 'string' ? label.name : '',
|
|
...(typeof label.color === 'string' ? { color: label.color } : {}),
|
|
...(typeof label.description === 'string' ? { description: label.description } : {}),
|
|
}))
|
|
.filter((label) => label.name && (!needle || label.name.toLowerCase().includes(needle)));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
|
|
} catch (error) {
|
|
console.error('Failed to search Gitea labels:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to search Gitea labels' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/milestones/search', async (req, res) => {
|
|
try {
|
|
const query = asString(req.query?.query);
|
|
const resolved = await lookupRepo(req);
|
|
if (resolved.error) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
if (!resolved.client) {
|
|
return res.json({ connected: false, milestones: [] });
|
|
}
|
|
const { client, owner, repo, repoRef } = resolved;
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, milestones: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(client.milestones(owner, repo, { state: 'all', limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea milestones search');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while searching milestones' });
|
|
}
|
|
const needle = query.toLowerCase();
|
|
const milestones = (Array.isArray(resp.data) ? resp.data : [])
|
|
.map((item) => ({
|
|
title: typeof item?.title === 'string' ? item.title : '',
|
|
...(typeof item?.state === 'string' ? { state: item.state } : {}),
|
|
}))
|
|
.filter((item) => item.title && (!needle || item.title.toLowerCase().includes(needle)));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), milestones });
|
|
} catch (error) {
|
|
console.error('Failed to search Gitea milestones:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to search Gitea milestones' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/branches/search', async (req, res) => {
|
|
try {
|
|
const query = asString(req.query?.query);
|
|
const resolved = await lookupRepo(req);
|
|
if (resolved.error) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
if (!resolved.client) {
|
|
return res.json({ connected: false, branches: [] });
|
|
}
|
|
const { client, owner, repo, repoRef } = resolved;
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, branches: [] });
|
|
}
|
|
|
|
const branches = [];
|
|
const needle = query.toLowerCase();
|
|
let page = 1;
|
|
while (page <= 10) {
|
|
const resp = await withTimeout(client.branches(owner, repo, { limit: 50, page }), ROUTE_TIMEOUT_MS, 'gitea branches search');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
|
break;
|
|
}
|
|
const chunk = resp.data;
|
|
for (const branch of chunk) {
|
|
const name = typeof branch?.name === 'string' ? branch.name : '';
|
|
if (!name) continue;
|
|
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
|
|
}
|
|
if (chunk.length < 50 || !resp.page?.hasMore) {
|
|
break;
|
|
}
|
|
page += 1;
|
|
}
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), branches });
|
|
} catch (error) {
|
|
console.error('Failed to search Gitea branches:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to search Gitea branches' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/gitea/tags/search', async (req, res) => {
|
|
try {
|
|
const query = asString(req.query?.query);
|
|
const resolved = await lookupRepo(req);
|
|
if (resolved.error) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
if (!resolved.client) {
|
|
return res.json({ connected: false, tags: [] });
|
|
}
|
|
const { client, owner, repo, repoRef } = resolved;
|
|
if (!owner || !repo) {
|
|
return res.json({ connected: true, repo: null, tags: [] });
|
|
}
|
|
|
|
const resp = await withTimeout(client.tags(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea tags search');
|
|
if (resp.status === 429) {
|
|
return res.status(503).json({ error: 'Gitea rate limited' });
|
|
}
|
|
if (resp.status !== 200) {
|
|
return res.status(502).json({ error: 'Gitea returned an error while searching tags' });
|
|
}
|
|
const needle = query.toLowerCase();
|
|
const tags = (Array.isArray(resp.data) ? resp.data : [])
|
|
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
|
|
.filter(Boolean)
|
|
.filter((name) => !needle || name.toLowerCase().includes(needle));
|
|
|
|
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), tags });
|
|
} catch (error) {
|
|
console.error('Failed to search Gitea tags:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to search Gitea tags' });
|
|
}
|
|
});
|
|
}
|