Files
bot-hermes 45437a4221 feat(web,ui): per-project forced git provider and settings gating
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).
2026-08-17 09:57:54 +00:00

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' });
}
});
}