Files
bot-hermes 66edc74fac feat(web,ui): per-project git provider API base URL overrides
Per provider (github|gitlab|gitea) a project override stored in
~/.config/openchamber/projects/<projectId>.json under gitProviders wins
over the global settings.json value (precedence: project override > global
> built-in default). Server forge routes resolve the override per request
directory (worktree-aware via git-common-dir + containment + path fallback,
60s TTL cache); the override host is also accepted for remote parsing and
client detection. New GET/PUT /api/projects/:projectId/git-providers route;
client openchamberConfig preserves the server-owned gitProviders key;
Projects page gains a Git provider API base URLs section; detection store
hydrates per-project overrides (memory-only, server-authoritative).
2026-08-17 09:57:54 +00:00

1699 lines
69 KiB
JavaScript

import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
// Route-level budget for composite GitLab calls (lists, comments, MR 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;
// MR diff pagination caps: never loop more than 10 pages / 3000 files.
const MR_DIFFS_MAX_PAGES = 10;
const MR_DIFFS_MAX_FILES = 3000;
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 project from the query (read routes) or the JSON body
// (write routes). `namespace`/`project` override the directory-local git
// remote for repos checked out from non-GitLab remotes.
const getRequestedProject = (req) => {
const namespace = asString(req.query?.namespace) || asString(req.body?.namespace);
const project = asString(req.query?.project) || asString(req.body?.project);
return namespace && project ? `${namespace}/${project}` : 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;
};
const mapGitLabUser = (data) => {
if (!data || typeof data !== 'object') {
return null;
}
return {
username: typeof data.username === 'string' ? data.username : null,
id: typeof data.id === 'number' ? data.id : null,
name: typeof data.name === 'string' ? data.name : null,
avatarUrl: typeof data.avatar_url === 'string' ? data.avatar_url : null,
webUrl: typeof data.web_url === 'string' ? data.web_url : null,
email: typeof data.email === 'string' ? data.email : null,
};
};
const mapAuthor = (author) => {
if (!author || typeof author !== 'object') {
return null;
}
return {
username: typeof author.username === 'string' ? author.username : null,
name: typeof author.name === 'string' ? author.name : null,
avatarUrl: typeof author.avatar_url === 'string' ? author.avatar_url : null,
webUrl: typeof author.web_url === 'string' ? author.web_url : null,
id: typeof author.id === 'number' ? author.id : null,
};
};
const mapIssueSummary = (item) => ({
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
author: mapAuthor(item.author) || {},
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
});
const mapIssue = (item) => ({
...mapIssueSummary(item),
body: typeof item.description === 'string' ? item.description : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapAuthor).filter(Boolean)
: [],
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.user_notes_count === 'number' ? item.user_notes_count : undefined,
});
const mapMergeRequestSummary = (item) => ({
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
draft: Boolean(item.draft) || Boolean(item.work_in_progress),
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapAuthor).filter(Boolean)
: [],
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.user_notes_count === 'number' ? item.user_notes_count : undefined,
});
// GitLab v4 MR notes expose `system: boolean` but carry no machine-readable
// action field — the timeline event type must be inferred from the system
// note's rendered body text. Match the prefixes GitLab produces for known
// actions and fall back to 'other'. Best-effort heuristic; it may drift across
// GitLab versions, so the UI must treat unknown types generically.
const mapGitLabSystemNoteType = (note) => {
const body = typeof note?.body === 'string' ? note.body.toLowerCase() : '';
if (!body) {
return 'other';
}
if (body.includes('merged')) return 'merged';
if (body.includes('closed')) return 'closed';
if (body.includes('reopened')) return 'reopened';
if (body.includes('approved')) return 'approved';
if (body.includes('unassigned')) return 'unassigned';
if (body.includes('assigned')) return 'assigned';
if (body.includes('label')) return body.includes('removed') ? 'unlabeled' : 'labeled';
if (body.includes('milestone')) return body.includes('removed') ? 'demilestoned' : 'milestoned';
if (body.includes('merge request') && body.includes('created')) return 'opened';
return 'other';
};
const mapComment = (note, webUrl) => ({
id: typeof note.id === 'number' ? note.id : Number(note.id),
url: webUrl ? `${webUrl}#note_${note.id}` : '',
body: typeof note.body === 'string' ? note.body : '',
createdAt: typeof note.created_at === 'string' ? note.created_at : undefined,
updatedAt: typeof note.updated_at === 'string' ? note.updated_at : undefined,
author: mapAuthor(note.author) || {},
});
// GitLab error bodies carry `message` as a string ("405 Method Not Allowed") or
// as a field->errors object ({ title: ['is invalid'] }); some endpoints use an
// `error` field instead. Flatten whichever shape is present into one readable
// string so write routes can surface it in { error } or { message }.
const gitLabErrorMessage = (data) => {
if (!data || typeof data !== 'object') {
return null;
}
const message = data.message;
if (typeof message === 'string' && message) {
return message;
}
if (message && typeof message === 'object') {
const parts = Object.entries(message).map(([field, errors]) => {
const list = Array.isArray(errors) ? errors : [errors];
const detail = list.filter((item) => typeof item === 'string' && item).join(', ');
return detail ? `${field}: ${detail}` : field;
});
if (parts.length > 0) {
return parts.join('; ');
}
}
if (typeof data.error === 'string' && data.error) {
return data.error;
}
return null;
};
// GitLab update endpoints take `milestone_id` (numeric), not the title. Resolve
// a title via the project 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, projectPath, title) => {
const resp = await client.milestones(projectPath, { state: 'all', per_page: 100 });
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 countDiffLines = (diffText) => {
if (typeof diffText !== 'string') {
return { additions: 0, deletions: 0, changes: 0 };
}
let additions = 0;
let deletions = 0;
let inHunk = false;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
inHunk = true;
continue;
}
if (!inHunk) {
continue;
}
if (line.startsWith('+++') || line.startsWith('---')) {
continue;
}
if (line.startsWith('+')) {
additions += 1;
} else if (line.startsWith('-')) {
deletions += 1;
}
}
return { additions, deletions, changes: additions + deletions };
};
const mapDiffItem = (item) => {
const counts = countDiffLines(item.diff);
const status = item.new_file
? 'added'
: (item.deleted_file ? 'deleted' : (item.renamed_file ? 'renamed' : 'modified'));
return {
filename: typeof item.new_path === 'string' ? item.new_path : (typeof item.old_path === 'string' ? item.old_path : ''),
status,
additions: counts.additions,
deletions: counts.deletions,
changes: counts.changes,
patch: typeof item.diff === 'string' ? item.diff : '',
};
};
// GitLab assigns by numeric user ID, while the facade deals in logins. Resolve
// a set of assignee logins to IDs via the project members list; unmatched
// logins yield `{ unknown: login }` so update routes can surface a precise
// `400 { error: 'Unknown assignee: ...' }` instead of silently dropping a user.
const resolveAssigneeIds = async (client, projectPath, logins) => {
const uniqueLogins = [...new Set(logins)];
const ids = [];
for (const login of uniqueLogins) {
const resp = await client.members(projectPath, { per_page: 100, query: login });
if (resp.status === 429) {
return { ids: null, rateLimited: true, unknown: null };
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
return { ids: null, rateLimited: false, unknown: null };
}
const match = resp.data.find(
(item) => typeof item?.username === 'string' && item.username.toLowerCase() === login.toLowerCase(),
);
if (typeof match?.id !== 'number') {
return { ids: null, rateLimited: false, unknown: login };
}
ids.push(match.id);
}
return { ids, rateLimited: false, unknown: null };
};
const repoRefFromProjectPath = (projectPath, baseUrl) => {
const segments = projectPath.split('/');
const project = segments[segments.length - 1] || '';
const namespace = segments.slice(0, -1).join('/');
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}/${projectPath}`;
} catch {
// fall back to unknown host
}
}
return { namespace, project, host, baseUrl: normalizedBaseUrl, url };
};
export function registerGitLabRoutes(app, options = {}) {
let gitlabLibraries = null;
const getGitLabLibraries = async () => {
if (!gitlabLibraries) {
gitlabLibraries = await import('./index.js');
}
return gitlabLibraries;
};
const hostFromBaseUrl = (baseUrl) => {
try {
return new URL(baseUrl).hostname || null;
} catch {
return null;
}
};
const getClient = async (directory) => {
const { getGitLabClientOrNull, createGitLabClient, getGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
const auth = getGitLabAuth();
if (!auth?.accessToken) {
return null;
}
const effectiveBaseUrl = directory ? getEffectiveProviderApiBaseUrl('gitlab', directory) : null;
// No project override: the account's own base URL keeps driving requests
// exactly as before.
if (!effectiveBaseUrl || effectiveBaseUrl === getGitLabDefaultBaseUrl()) {
return getGitLabClientOrNull();
}
// A per-project override is in play. A connected account whose host
// matches the remote still wins; otherwise the override serves as the API
// base (it makes its host acceptable even with no account covering it).
const accountHost = hostFromBaseUrl(auth.baseUrl);
const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries();
const { repo } = await resolveGitLabRepoFromDirectory(directory).catch(() => ({ repo: null }));
if (repo?.host && accountHost && accountHost === repo.host) {
return getGitLabClientOrNull();
}
return createGitLabClient({ token: auth.accessToken, baseUrl: effectiveBaseUrl });
};
// Resolve which GitLab project a request targets. A directory-local git
// remote is the primary source; `namespace`/`project` query params override
// it (needed for repos checked out from non-GitLab remotes).
const resolveProjectForRequest = async (directory, requestedProject) => {
if (requestedProject) {
return { projectPath: requestedProject, repo: null, fromDirectory: false };
}
if (!directory) {
return { projectPath: null, repo: null, fromDirectory: false };
}
const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries();
const { repo } = await resolveGitLabRepoFromDirectory(directory);
if (!repo) {
return { projectPath: null, repo: null, fromDirectory: false };
}
return { projectPath: `${repo.namespace}/${repo.project}`, repo, fromDirectory: true };
};
// ================= GitLab Auth APIs =================
app.get('/api/gitlab/auth/status', async (_req, res) => {
try {
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
const auth = getGitLabAuth();
const accounts = getGitLabAuthAccounts();
const defaultBaseUrl = getGitLabDefaultBaseUrl();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts, defaultBaseUrl });
}
const client = await getClient();
let user = null;
if (client) {
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGitLabAuth();
return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl });
}
if (resp.status === 200 && resp.data) {
user = mapGitLabUser(resp.data);
}
}
return res.json({
connected: true,
...(user ? { user } : {}),
accounts,
defaultBaseUrl,
});
} catch (error) {
console.error('Failed to get GitLab auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get GitLab auth status' });
}
});
app.post('/api/gitlab/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, getGitLabDefaultBaseUrl, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGitLabDefaultBaseUrl();
const { createGitLabClient } = await getGitLabLibraries();
const client = createGitLabClient({ token: accessToken, baseUrl });
const resp = await client.user();
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 401 || resp.status === 403 || resp.status >= 400 || !resp.data?.username) {
return res.status(400).json({ error: 'Invalid GitLab access token' });
}
setGitLabAuth({ accessToken, baseUrl, user: resp.data });
return res.json({
connected: true,
user: mapGitLabUser(resp.data),
accounts: getGitLabAuthAccounts(),
defaultBaseUrl: getGitLabDefaultBaseUrl(),
});
} catch (error) {
console.error('Failed to connect GitLab:', error);
return res.status(500).json({ error: error.message || 'Failed to connect GitLab' });
}
});
app.post('/api/gitlab/auth/activate', async (req, res) => {
try {
const accountId = asString(req.body?.accountId);
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
const activated = activateGitLabAuth(accountId);
if (!activated) {
return res.status(404).json({ error: 'GitLab account not found' });
}
const auth = getGitLabAuth();
const accounts = getGitLabAuthAccounts();
const defaultBaseUrl = getGitLabDefaultBaseUrl();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts, defaultBaseUrl });
}
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 = mapGitLabUser(resp.data);
}
}
return res.json({ connected: true, user, accounts, defaultBaseUrl });
} catch (error) {
console.error('Failed to activate GitLab account:', error);
return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' });
}
});
app.delete('/api/gitlab/auth', async (_req, res) => {
try {
const { clearGitLabAuth } = await getGitLabLibraries();
const removed = clearGitLabAuth();
return res.json({ removed });
} catch (error) {
console.error('Failed to disconnect GitLab:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect GitLab' });
}
});
app.get('/api/gitlab/me', async (_req, res) => {
try {
const { clearGitLabAuth } = await getGitLabLibraries();
const client = await getClient();
if (!client) {
return res.status(401).json({ error: 'GitLab not connected' });
}
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGitLabAuth();
return res.status(401).json({ error: 'GitLab token expired or revoked' });
}
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200 || !resp.data) {
return res.status(500).json({ error: 'Failed to fetch GitLab user' });
}
return res.json(mapGitLabUser(resp.data));
} catch (error) {
console.error('Failed to fetch GitLab user:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab user' });
}
});
// ================= GitLab Issue APIs =================
app.get('/api/gitlab/issues/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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 { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, issues: [], page: effectivePage, hasMore: false });
}
const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage };
if (searchQuery) {
params.search = searchQuery;
}
const resp = await withTimeout(client.issues(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab issues list');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while listing issues' });
}
const issues = (Array.isArray(resp.data) ? resp.data : []).map(mapIssueSummary);
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issues,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list GitLab issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitLab issues' });
}
});
app.get('/api/gitlab/issues/get', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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 { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, issue: null });
}
const resp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue get');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab 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: 'GitLab returned an error while fetching the issue' });
}
const item = resp.data;
const issue = mapIssue(item);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue });
} catch (error) {
console.error('Failed to fetch GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue' });
}
});
app.get('/api/gitlab/issues/comments', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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 { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, comments: [] });
}
// GitLab notes carry no web URL; resolve it from the issue so each note
// links as `{issue_web_url}#note_{id}`.
const issueResp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue comments issue');
if (issueResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (issueResp.status === 404) {
return res.status(404).json({ error: 'Issue not found' });
}
if (issueResp.status !== 200 || !issueResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
}
const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : '';
const notesResp = await withTimeout(
client.issueNotes(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab issue comments notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (notesResp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching issue comments' });
}
const comments = (Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => !note.system)
.map((note) => mapComment(note, webUrl));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), comments });
} catch (error) {
console.error('Failed to fetch GitLab issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue comments' });
}
});
app.post('/api/gitlab/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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
// GitLab notes carry no web URL; resolve the issue web_url first so the
// note links as `{issue_web_url}#note_{id}` (mirrors issues/comments).
const issueResp = await client.issue(projectPath, number);
if (issueResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (issueResp.status === 404) {
return res.status(404).json({ error: 'Issue not found' });
}
if (issueResp.status !== 200 || !issueResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
}
const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : '';
const resp = await client.createIssueNote(projectPath, number, body);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create issue comments' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the comment' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
comment: mapComment(resp.data, webUrl),
});
} catch (error) {
console.error('Failed to create GitLab issue comment:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue comment' });
}
});
app.post('/api/gitlab/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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const params = {
title,
...(body !== undefined ? { description: body } : {}),
...(labels !== undefined ? { labels } : {}),
};
const resp = await client.createIssue(projectPath, params);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create issues' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the issue' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issue: mapIssue(resp.data),
});
} catch (error) {
console.error('Failed to create GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue' });
}
});
app.put('/api/gitlab/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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const body = {};
if (typeof req.body?.title === 'string') {
body.title = req.body.title.trim();
}
if (typeof req.body?.body === 'string') {
body.description = req.body.body;
}
// GitLab maps state transitions through `state_event` ('close'/'reopen').
if (req.body?.state === 'open' || req.body?.state === 'closed') {
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
}
if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string');
}
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
}
if (req.body?.milestone !== undefined) {
if (req.body.milestone === null) {
body.milestone_id = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (milestoneId === null) {
return res.status(400).json({ error: 'Milestone not found' });
}
body.milestone_id = milestoneId;
}
}
const resp = await client.updateIssue(projectPath, number, body);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api 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: gitLabErrorMessage(resp.data) || 'GitLab returned an error while updating the issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while updating the issue' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issue: mapIssue(resp.data),
});
} catch (error) {
console.error('Failed to update GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to update GitLab issue' });
}
});
// ================= GitLab Merge Request APIs =================
app.get('/api/gitlab/mrs/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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, mrs: [], page: effectivePage, hasMore: false });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, mrs: [], page: effectivePage, hasMore: false });
}
const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage };
if (searchQuery) {
params.search = searchQuery;
}
if (sourceBranch) {
params.source_branch = sourceBranch;
}
const resp = await withTimeout(client.mergeRequests(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab mrs list');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while listing merge requests' });
}
const mrs = (Array.isArray(resp.data) ? resp.data : []).map(mapMergeRequestSummary);
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mrs,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list GitLab merge requests:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitLab merge requests' });
}
});
app.get('/api/gitlab/mrs/context', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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, mr: null, comments: [], files: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, mr: null, comments: [], files: [] });
}
const mrResp = await withTimeout(client.mergeRequest(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab mr context');
if (mrResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (mrResp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (mrResp.status !== 200 || !mrResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' });
}
const item = mrResp.data;
const mr = {
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
draft: Boolean(item.draft) || Boolean(item.work_in_progress),
body: typeof item.description === 'string' ? item.description : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapAuthor).filter(Boolean)
: [],
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.user_notes_count === 'number' ? item.user_notes_count : undefined,
headSha: typeof item.sha === 'string' ? item.sha : (typeof item.diff_head_sha === 'string' ? item.diff_head_sha : undefined),
};
const notesResp = await withTimeout(
client.request(`/projects/${encodeURIComponent(projectPath)}/merge_requests/${number}/notes`, { query: { per_page: 100 } }),
ROUTE_TIMEOUT_MS,
'gitlab mr context notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
const comments = (notesResp.status === 200 && Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => !note.system)
.map((note) => mapComment(note, mr.url));
// Diffs are paginated; loop pages but cap the total work.
const files = [];
for (let page = 1; page <= MR_DIFFS_MAX_PAGES; page += 1) {
const diffsResp = await client.mergeRequestDiffs(projectPath, number, { per_page: 100, page });
if (diffsResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (diffsResp.status !== 200 || !Array.isArray(diffsResp.data)) {
break;
}
const chunk = diffsResp.data;
for (const diffItem of chunk) {
files.push(mapDiffItem(diffItem));
if (files.length >= MR_DIFFS_MAX_FILES) {
break;
}
}
if (files.length >= MR_DIFFS_MAX_FILES) {
break;
}
if (chunk.length < 100 || !diffsResp.page?.hasMore) {
break;
}
}
let diff;
if (includeDiff) {
const patches = files.map((file) => file.patch || '').filter(Boolean);
diff = patches.length > 0 ? patches.join('\n') : undefined;
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mr,
comments,
files,
...(diff ? { diff } : {}),
});
} catch (error) {
console.error('Failed to load GitLab merge request context:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitLab merge request context' });
}
});
app.get('/api/gitlab/mrs/commits', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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 { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, commits: [] });
}
const resp = await withTimeout(
client.mergeRequestCommits(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab mr commits',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching merge request commits' });
}
const commits = (Array.isArray(resp.data) ? resp.data : []).map((commit) => ({
sha: typeof commit.id === 'string' ? commit.id : '',
shortSha: typeof commit.short_id === 'string' ? commit.short_id : '',
message: typeof commit.message === 'string' ? commit.message : '',
...(typeof commit.title === 'string' && commit.title ? { summary: commit.title } : {}),
...(typeof commit.author_name === 'string' && commit.author_name ? { authorName: commit.author_name } : {}),
...(typeof commit.committed_date === 'string' ? { committedAt: commit.committed_date } : {}),
parents: Array.isArray(commit.parent_ids) ? commit.parent_ids : [],
}));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), commits });
} catch (error) {
console.error('Failed to fetch GitLab merge request commits:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab merge request commits' });
}
});
app.get('/api/gitlab/mrs/timeline', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project 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, events: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, events: [] });
}
const notesResp = await withTimeout(
client.mergeRequestNotes(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab mr timeline notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (notesResp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (notesResp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching merge request timeline' });
}
// Timeline = system notes only (GitLab records state changes as system
// notes; human comments are surfaced by mrs/context).
const events = (Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => note.system === true)
.map((note) => ({
id: String(note.id),
type: mapGitLabSystemNoteType(note),
body: typeof note.body === 'string' ? note.body : null,
author: mapAuthor(note.author),
createdAt: typeof note.created_at === 'string' ? note.created_at : undefined,
}));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), events });
} catch (error) {
console.error('Failed to fetch GitLab merge request timeline:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab merge request timeline' });
}
});
// ================= GitLab Merge Request Write APIs =================
app.post('/api/gitlab/mrs/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 removeSourceBranch = typeof req.body?.removeSourceBranch === 'boolean'
? req.body.removeSourceBranch
: false;
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
const requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const body = {
source_branch: sourceBranch,
target_branch: targetBranch,
title,
remove_source_branch: removeSourceBranch,
};
if (description !== undefined) {
body.description = description;
}
const resp = await withTimeout(client.createMergeRequest(projectPath, body), ROUTE_TIMEOUT_MS, 'gitlab mr create');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create merge requests' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the merge request' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the merge request' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mr: mapMergeRequestSummary(resp.data),
});
} catch (error) {
console.error('Failed to create GitLab merge request:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab merge request' });
}
});
app.put('/api/gitlab/mrs/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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const body = {};
if (title) {
body.title = title;
}
if (description !== undefined) {
body.description = description;
}
// GitLab maps state transitions through `state_event` ('close'/'reopen').
if (req.body?.state === 'open' || req.body?.state === 'closed') {
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
}
if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string');
}
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
}
if (req.body?.milestone !== undefined) {
if (req.body.milestone === null) {
body.milestone_id = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (milestoneId === null) {
return res.status(400).json({ error: 'Milestone not found' });
}
body.milestone_id = milestoneId;
}
}
const resp = await withTimeout(client.updateMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr update');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to update merge requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while updating the merge request' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while updating the merge request' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mr: mapMergeRequestSummary(resp.data),
});
} catch (error) {
console.error('Failed to update GitLab merge request:', error);
return res.status(500).json({ error: error.message || 'Failed to update GitLab merge request' });
}
});
app.put('/api/gitlab/mrs/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 squash = typeof req.body?.squash === 'boolean' ? req.body.squash : undefined;
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
const requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const body = {};
if (squash !== undefined) {
body.squash = squash;
}
const resp = await withTimeout(client.mergeMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr merge');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create merge requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
// GitLab rejects non-mergeable requests with 405/406/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 === 406 || resp.status === 409 || resp.status === 422) {
return res.status(resp.status).json({
connected: true,
merged: false,
message: gitLabErrorMessage(resp.data) || 'Merge request not mergeable',
});
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while merging the merge request' });
}
return res.json({ connected: true, merged: true });
} catch (error) {
console.error('Failed to merge GitLab merge request:', error);
return res.status(500).json({ error: error.message || 'Failed to merge GitLab merge request' });
}
});
app.post('/api/gitlab/mrs/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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
// MR notes carry no web URL; resolve the MR web_url first so the note
// links as `{mr_web_url}#note_{id}` (mirrors mrs/context).
const mrResp = await client.mergeRequest(projectPath, number);
if (mrResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (mrResp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (mrResp.status !== 200 || !mrResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' });
}
const webUrl = typeof mrResp.data.web_url === 'string' ? mrResp.data.web_url : '';
const resp = await client.createMrNote(projectPath, number, body);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to comment on merge requests' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the comment' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
comment: mapComment(resp.data, webUrl),
});
} catch (error) {
console.error('Failed to create GitLab merge request comment:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab merge request comment' });
}
});
app.post('/api/gitlab/mrs/approve', 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 requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const resp = await client.approveMr(projectPath, number);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to approve merge requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while approving the merge request' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
approved: true,
});
} catch (error) {
console.error('Failed to approve GitLab merge request:', error);
return res.status(500).json({ error: error.message || 'Failed to approve GitLab merge request' });
}
});
// ================= GitLab Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target project
// (directory remote + namespace/project override) and hits a GitLab endpoint
// that supports server-side `query` filtering where available. `connected:
// false` means the lookup could not be performed — never an authoritative
// empty list.
const lookupProject = async (req) => {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return { error: 'directory or namespace/project is required' };
}
const client = await getClient(directory);
if (!client) {
return { client: null };
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
return { client, projectPath, repo };
};
app.get('/api/gitlab/users/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, users: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, users: [] });
}
const resp = await withTimeout(
client.members(projectPath, { per_page: 100, ...(query ? { query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab users search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching users' });
}
const users = (Array.isArray(resp.data) ? resp.data : [])
.map(mapGitLabUser)
.filter((user) => user && user.username);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), users });
} catch (error) {
console.error('Failed to search GitLab users:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab users' });
}
});
app.get('/api/gitlab/labels/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, labels: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, labels: [] });
}
const resp = await withTimeout(
client.labels(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab labels search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching labels' });
}
const labels = (Array.isArray(resp.data) ? resp.data : [])
.map((label) => (typeof label?.name === 'string' ? label.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), labels });
} catch (error) {
console.error('Failed to search GitLab labels:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab labels' });
}
});
app.get('/api/gitlab/milestones/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, milestones: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const resp = await withTimeout(
client.milestones(projectPath, { state: 'all', per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab milestones search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching milestones' });
}
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);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), milestones });
} catch (error) {
console.error('Failed to search GitLab milestones:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab milestones' });
}
});
app.get('/api/gitlab/branches/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, branches: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, branches: [] });
}
const resp = await withTimeout(
client.branches(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab branches search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching branches' });
}
const branches = (Array.isArray(resp.data) ? resp.data : [])
.map((branch) => (typeof branch?.name === 'string' ? branch.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), branches });
} catch (error) {
console.error('Failed to search GitLab branches:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab branches' });
}
});
app.get('/api/gitlab/tags/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, tags: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, tags: [] });
}
const resp = await withTimeout(
client.tags(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab tags search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching tags' });
}
const tags = (Array.isArray(resp.data) ? resp.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), tags });
} catch (error) {
console.error('Failed to search GitLab tags:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab tags' });
}
});
// ================= GitLab Repo APIs =================
app.get('/api/gitlab/repo/branches', async (req, res) => {
try {
const namespace = asString(req.query?.namespace);
const project = asString(req.query?.project);
if (!namespace || !project) {
return res.status(400).json({ error: 'namespace and project are required' });
}
const client = await getClient();
if (!client) {
return res.json({ branches: [], defaultBranch: null });
}
const branches = [];
let defaultBranch = null;
let page = 1;
while (page <= 10) {
const resp = await client.branches(`${namespace}/${project}`, { per_page: 100, page });
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab 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 (defaultBranch === null && branch.default === true) {
defaultBranch = branch.name;
}
}
}
if (chunk.length < 100 || !resp.page?.hasMore) {
break;
}
page += 1;
}
return res.json({ branches, defaultBranch });
} catch (error) {
console.error('Failed to fetch GitLab repo branches:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab repo branches' });
}
});
}