Files
openchamber/packages/web/server/lib/github/routes.js
T
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

2851 lines
111 KiB
JavaScript

const PR_STATUS_CACHE_TTL_MS = 90_000;
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many
// serial GitHub API calls; under GitHub secondary-rate-limiting a single request
// can otherwise hang 20s+. We bound it so the route fails fast instead of holding
// the response (and a client socket) open — the client keeps its last-known
// status on error, and a later poll fills it in.
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
const prStatusCache = new Map();
let resolvedAuthLoginPromise = null;
const PR_CONTEXT_CACHE_TTL_MS = 30_000;
const PR_CONTEXT_CACHE_MAX_ENTRIES = 50;
const prContextCache = new Map();
// Route-level budget for single-call enrichment routes (pulls/commits,
// pulls/timeline). Octokit bounds each request at 8s; this caps the whole
// route so a slow upstream cannot hold a response (and a client socket) open.
const ROUTE_TIMEOUT_MS = 15_000;
function invalidatePrContextCache(directory, number) {
for (const key of prContextCache.keys()) {
try {
const [cachedDirectory, cachedNumber] = JSON.parse(key);
if (cachedDirectory === directory && (number == null || cachedNumber === number)) {
prContextCache.delete(key);
}
} catch {
prContextCache.delete(key);
}
}
}
// Aggregate check runs into the summary shape shared by pr/status and
// pulls/context. Keeps `pending` as queued+in_progress+unconcluded for
// existing consumers while exposing the split and the earliest start time so
// the UI can show live "running for N minutes" state.
// A re-run leaves the previous completed check run in the listForRef payload
// alongside the new in-progress one. GitHub's UI shows only the latest run
// per (app, name); mirror that so counts match what users see on github.com.
function dedupeCheckRuns(checkRuns) {
const byName = new Map();
for (const run of checkRuns) {
const key = `${run?.app?.id ?? run?.app?.slug ?? ''}::${run?.name ?? ''}`;
const previous = byName.get(key);
if (!previous) {
byName.set(key, run);
continue;
}
const previousStartedAt = Date.parse(previous?.started_at || '') || 0;
const startedAt = Date.parse(run?.started_at || '') || 0;
if (startedAt > previousStartedAt
|| (startedAt === previousStartedAt && (run?.id ?? 0) > (previous?.id ?? 0))) {
byName.set(key, run);
}
}
return Array.from(byName.values());
}
function summarizeCheckRuns(checkRuns) {
const counts = { success: 0, failure: 0, pending: 0, inProgress: 0, queued: 0 };
let startedAt = null;
for (const run of checkRuns) {
const status = run?.status;
const conclusion = run?.conclusion;
if (status === 'in_progress') {
counts.pending += 1;
counts.inProgress += 1;
const runStartedAt = typeof run?.started_at === 'string' ? run.started_at : null;
if (runStartedAt && (!startedAt || runStartedAt < startedAt)) {
startedAt = runStartedAt;
}
continue;
}
if (status === 'queued') {
counts.pending += 1;
counts.queued += 1;
continue;
}
if (!conclusion) {
counts.pending += 1;
continue;
}
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
counts.success += 1;
} else {
counts.failure += 1;
}
}
const total = counts.success + counts.failure + counts.pending;
const state = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
return { state, total, ...counts, ...(startedAt ? { startedAt } : {}) };
}
function summarizeCombinedStatuses(statuses) {
const counts = { success: 0, failure: 0, pending: 0 };
statuses.forEach((s) => {
if (s.state === 'success') counts.success += 1;
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
else if (s.state === 'pending') counts.pending += 1;
});
const total = counts.success + counts.failure + counts.pending;
const state = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
return { state, total, ...counts, inProgress: counts.pending, queued: 0 };
}
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));
}
function getRequestedRepo(req) {
// GET routes carry owner/repo in the query string; write routes (POST/PATCH)
// carry them in the body. Accept both so the same resolver guards every route.
const owner = typeof req.body?.owner === 'string' && req.body.owner.trim()
? req.body.owner.trim()
: (typeof req.query?.owner === 'string' ? req.query.owner.trim() : '');
const repo = typeof req.body?.repo === 'string' && req.body.repo.trim()
? req.body.repo.trim()
: (typeof req.query?.repo === 'string' ? req.query.repo.trim() : '');
return owner && repo ? { owner, repo } : null;
}
async function resolveRepoForRequest(octokit, directory, requestedRepo) {
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!requestedRepo) {
return repo;
}
if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) {
return requestedRepo;
}
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const network = await resolveRepoNetwork(octokit, directory).catch(() => null);
const allowed = Array.isArray(network)
? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo)
: false;
return allowed ? requestedRepo : null;
}
// Shared mappers for PR/issue enrichment fields (labels/assignees/milestone/
// comments) used by pulls/list, pulls/context, and issues/get.
const mapGitHubUserSummary = (user) => (
user && typeof user === 'object'
? { login: user.login, id: user.id, avatarUrl: user.avatar_url }
: null
);
const mapGitHubLabels = (labels) => (
Array.isArray(labels)
? labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: []
);
const mapGitHubMilestone = (milestone) => (
milestone && typeof milestone === 'object'
? {
title: typeof milestone.title === 'string' ? milestone.title : '',
...(typeof milestone.state === 'string' ? { state: milestone.state } : {}),
}
: null
);
const mapGitHubAssignees = (assignees) => (
Array.isArray(assignees) ? assignees.map(mapGitHubUserSummary).filter(Boolean) : []
);
function setPrStatusCache(key, data, fetchedAt) {
// Evict oldest entry when cache exceeds max size
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
const oldest = prStatusCache.entries().next().value;
if (oldest) {
prStatusCache.delete(oldest[0]);
}
}
prStatusCache.set(key, { data, fetchedAt });
}
export function registerGitHubRoutes(app) {
let githubLibraries = null;
const getGitHubLibraries = async () => {
if (!githubLibraries) {
githubLibraries = await import('./index.js');
}
return githubLibraries;
};
const getGitHubUserSummary = async (octokit) => {
const me = await octokit.rest.users.getAuthenticated();
let email = typeof me.data.email === 'string' ? me.data.email : null;
if (!email) {
try {
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
const list = Array.isArray(emails?.data) ? emails.data : [];
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
email = primaryVerified?.email || anyVerified?.email || null;
} catch {
// ignore (scope might be missing)
}
}
return {
login: me.data.login,
id: me.data.id,
avatarUrl: me.data.avatar_url,
name: typeof me.data.name === 'string' ? me.data.name : null,
email,
};
};
const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403;
const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404;
app.get('/api/github/auth/status', async (_req, res) => {
try {
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliActive, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
const { getGhCliToken } = await import('./gh-cli-credential.js');
const auth = getGitHubAuth();
let accounts = getGitHubAuthAccounts();
const ghCliDisabled = isGhCliDisabled();
const ghCliActive = isGhCliActive();
const ghToken = getGhCliToken();
const usingOwnToken = Boolean(auth?.accessToken);
let ghCliUser = null;
if (ghToken !== null && !ghCliDisabled) {
try {
const { createOctokit } = await import('./octokit.js');
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
} catch {
ghCliUser = null;
}
}
if (ghCliActive && !ghCliUser) {
setGhCliActive(false);
}
const ghCliCurrent = ghToken !== null && !ghCliDisabled && Boolean(ghCliUser) && (ghCliActive || !usingOwnToken);
if (ghCliUser) {
accounts = accounts
.map((account) => ({ ...account, current: ghCliCurrent ? false : Boolean(account.current) }))
.concat({
id: GH_CLI_ACCOUNT_ID,
user: ghCliUser,
current: ghCliCurrent,
source: 'gh-cli',
});
}
const buildGhCli = (activeUser = null) => ({
available: ghToken !== null,
disabled: ghCliDisabled,
active: ghCliCurrent,
...(!ghCliDisabled && (activeUser || ghCliUser) ? { user: activeUser || ghCliUser } : {}),
});
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, accounts, ghCli: buildGhCli() });
}
let user = null;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (isGitHubAuthInvalid(error)) {
if (usingOwnToken) clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() });
}
}
const fallback = usingOwnToken ? auth.user : null;
const mergedUser = user || fallback;
return res.json({
connected: true,
user: mergedUser,
scope: ghCliCurrent ? undefined : auth?.scope,
accounts,
ghCli: buildGhCli(ghCliCurrent ? mergedUser : null),
});
} catch (error) {
console.error('Failed to get GitHub auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
}
});
app.post('/api/github/auth/gh-cli', async (req, res) => {
try {
const { setGhCliDisabled, isGhCliDisabled } = await getGitHubLibraries();
const { clearGhCliTokenCache } = await import('./gh-cli-credential.js');
const disabled = Boolean(req.body?.disabled);
setGhCliDisabled(disabled);
clearGhCliTokenCache();
return res.json({ disabled: isGhCliDisabled() });
} catch (error) {
console.error('Failed to update gh CLI setting:', error);
return res.status(500).json({ error: error.message || 'Failed to update gh CLI setting' });
}
});
app.post('/api/github/auth/start', async (_req, res) => {
try {
const { getGitHubClientId, getGitHubScopes, startDeviceFlow, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const scope = getGitHubScopes();
const webOrigin = githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const payload = await startDeviceFlow({
clientId,
scope,
webOrigin,
});
return res.json({
deviceCode: payload.device_code,
userCode: payload.user_code,
verificationUri: payload.verification_uri,
verificationUriComplete: payload.verification_uri_complete,
expiresIn: payload.expires_in,
interval: payload.interval,
scope,
});
} catch (error) {
console.error('Failed to start GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
}
});
app.post('/api/github/auth/complete', async (req, res) => {
try {
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const deviceCode = typeof req.body?.deviceCode === 'string'
? req.body.deviceCode
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
if (!deviceCode) {
return res.status(400).json({ error: 'deviceCode is required' });
}
const apiBase = getProviderApiBaseUrl('github');
const webOrigin = githubWebOriginFromApiBase(apiBase);
const payload = await exchangeDeviceCode({ clientId, deviceCode, webOrigin });
if (payload?.error) {
return res.json({
connected: false,
status: payload.error,
error: payload.error_description || payload.error,
});
}
const accessToken = payload?.access_token;
if (!accessToken) {
return res.status(500).json({ error: 'Missing access_token from GitHub' });
}
const { createOctokit } = await import('./octokit.js');
const octokit = createOctokit(accessToken, apiBase);
const user = await getGitHubUserSummary(octokit);
setGitHubAuth({
accessToken,
scope: typeof payload.scope === 'string' ? payload.scope : '',
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
user,
});
return res.json({
connected: true,
user,
scope: typeof payload.scope === 'string' ? payload.scope : '',
accounts: getGitHubAuthAccounts(),
});
} catch (error) {
console.error('Failed to complete GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
}
});
app.post('/api/github/auth/activate', async (req, res) => {
try {
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
if (accountId === GH_CLI_ACCOUNT_ID) {
const { getGhCliToken } = await import('./gh-cli-credential.js');
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
if (!ghToken) {
return res.status(404).json({ error: 'GitHub CLI account not found' });
}
const { createOctokit } = await import('./octokit.js');
const user = await getGitHubUserSummary(createOctokit(ghToken));
setGhCliActive(true);
const accounts = getGitHubAuthAccounts()
.map((account) => ({ ...account, current: false }))
.concat({ id: GH_CLI_ACCOUNT_ID, user, current: true, source: 'gh-cli' });
return res.json({
connected: true,
user,
accounts,
ghCli: {
available: true,
disabled: false,
active: true,
user,
},
});
}
const activated = activateGitHubAuth(accountId);
if (!activated) {
return res.status(404).json({ error: 'GitHub account not found' });
}
const auth = getGitHubAuth();
let accounts = getGitHubAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts });
}
const { getGhCliToken } = await import('./gh-cli-credential.js');
const ghCliDisabled = isGhCliDisabled();
const ghToken = !ghCliDisabled ? getGhCliToken() : null;
let ghCliUser = null;
if (ghToken) {
try {
const { createOctokit } = await import('./octokit.js');
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
accounts = accounts.concat({
id: GH_CLI_ACCOUNT_ID,
user: ghCliUser,
current: false,
source: 'gh-cli',
});
} catch {
ghCliUser = null;
}
}
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, accounts, ghCli: { available: ghToken !== null, disabled: ghCliDisabled, active: false, ...(ghCliUser ? { user: ghCliUser } : {}) } });
}
let user = auth.user || null;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (isGitHubAuthInvalid(error)) {
clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
}
}
return res.json({
connected: true,
user,
scope: auth.scope,
accounts,
ghCli: {
available: ghToken !== null,
disabled: ghCliDisabled,
active: false,
...(ghCliUser ? { user: ghCliUser } : {}),
},
});
} catch (error) {
console.error('Failed to activate GitHub account:', error);
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
}
});
app.delete('/api/github/auth', async (_req, res) => {
try {
const { clearGitHubAuth } = await getGitHubLibraries();
const removed = clearGitHubAuth();
return res.json({ success: true, removed });
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
}
});
app.get('/api/github/me', async (_req, res) => {
try {
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
let user;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (isGitHubAuthInvalid(error)) {
clearGitHubAuth();
return res.status(401).json({ error: 'GitHub token expired or revoked' });
}
throw error;
}
return res.json(user);
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
}
});
// ================= GitHub PR APIs =================
app.get('/api/github/pr/status', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
const force = req.query?.force === 'true' || req.query?.force === '1';
if (!directory || !branch) {
return res.status(400).json({ error: 'directory and branch are required' });
}
// Check cache (skip when force=true to allow manual refresh bypass)
const cacheKey = `${directory}::${branch}::${remote}`;
const cached = prStatusCache.get(cacheKey);
if (!force && cached && Date.now() - cached.fetchedAt < PR_STATUS_CACHE_TTL_MS) {
return res.json(cached.data);
}
// If GitHub recently rate-limited us, don't pile on more calls that will
// also fail. Serve whatever we last cached (even if stale); otherwise
// report a transient failure so the client keeps its last-known status.
const { isGitHubRateLimited } = await import('./rate-limit.js');
if (isGitHubRateLimited()) {
if (cached) {
return res.json(cached.data);
}
return res.status(503).json({ error: 'GitHub rate limited' });
}
// Intercept res.json to cache successful responses before sending
// Only caches responses with connected:true — error/edge-case responses are not cached
const originalJson = res.json.bind(res);
res.json = (data) => {
if (data && data.connected === true) {
// Freshness stamp travels with the payload (and survives cache
// serves) so clients can refuse to overwrite newer data with a
// stale cached response.
if (typeof data.fetchedAt !== 'number') {
data.fetchedAt = Date.now();
}
setPrStatusCache(cacheKey, data, data.fetchedAt);
}
return originalJson(data);
};
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubPrStatus } = await import('./pr-status.js');
const resolvedStatus = await withTimeout(
resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
force,
}),
PR_STATUS_RESOLVE_TIMEOUT_MS,
'resolveGitHubPrStatus',
);
const searchRepo = resolvedStatus.repo;
const first = resolvedStatus.pr;
if (!searchRepo) {
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null });
}
if (!first) {
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null });
}
// Enrich with mergeability fields
const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number });
const prData = prFull?.data;
if (!prData) {
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false });
}
const isMerged = Boolean(prData.merged || prData.merged_at);
const prState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
// A closed/merged PR is a historical record for this branch: its checks
// are no longer actionable and it can never be merged from here, so skip
// the extra GitHub calls those two fields would cost.
const isHistorical = prState !== 'open';
// Checks summary: prefer check-runs (Actions), fallback to classic statuses.
let checks = null;
const sha = prData.head?.sha;
if (sha && !isHistorical) {
try {
const runs = await octokit.rest.checks.listForRef({
owner: searchRepo.owner,
repo: searchRepo.repo,
ref: sha,
per_page: 100,
});
const checkRuns = dedupeCheckRuns(Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []);
if (checkRuns.length > 0) {
checks = summarizeCheckRuns(checkRuns);
}
} catch {
// ignore and fall back
}
if (!checks) {
try {
const combined = await octokit.rest.repos.getCombinedStatusForRef({
owner: searchRepo.owner,
repo: searchRepo.repo,
ref: sha,
});
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
checks = summarizeCombinedStatuses(statuses);
} catch {
checks = null;
}
}
}
// Permission check (best-effort)
let canMerge = false;
if (!isHistorical) {
try {
const auth = getGitHubAuth();
// gh-CLI tokens have no persisted user record; resolve the login from
// the API once (memoized) so permissions still resolve for them.
let username = auth?.user?.login;
if (!username) {
if (!resolvedAuthLoginPromise) {
resolvedAuthLoginPromise = octokit.rest.users.getAuthenticated()
.then((resp) => resp?.data?.login || null)
.catch(() => {
resolvedAuthLoginPromise = null;
return null;
});
}
username = await resolvedAuthLoginPromise;
}
if (username) {
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
owner: searchRepo.owner,
repo: searchRepo.repo,
username,
});
const level = perm?.data?.permission;
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
}
} catch {
canMerge = false;
}
}
return res.json({
connected: true,
repo: searchRepo,
branch,
pr: {
number: prData.number,
title: prData.title,
body: prData.body || '',
url: prData.html_url,
state: prState,
draft: Boolean(prData.draft),
base: prData.base?.ref,
head: prData.head?.ref,
headSha: prData.head?.sha,
mergeable: prData.mergeable,
mergeableState: prData.mergeable_state,
},
checks,
canMerge,
defaultBranch: resolvedStatus.defaultBranch ?? null,
resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null,
});
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
// Transient failures — a rate limit, or the overall resolve timeout
// firing — are expected under heavy load and should not be logged as hard
// errors. Record a rate-limit cooldown when applicable, then serve the
// last cached status (even if stale) or a 503 so the client keeps its
// last-known value instead of clearing the badge.
const { noteIfGitHubRateLimit } = await import('./rate-limit.js');
const wasRateLimited = noteIfGitHubRateLimit(error);
const wasTimeout = error?.code === 'ETIMEDOUT';
if (wasRateLimited || wasTimeout) {
const dir = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const br = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
const rem = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
const cached = prStatusCache.get(`${dir}::${br}::${rem}`);
if (cached) {
return res.json(cached.data);
}
return res.status(503).json({ error: wasRateLimited ? 'GitHub rate limited' : 'GitHub request timed out' });
}
if (isGitHubResourceUnavailable(error)) {
return res.json({
connected: true,
repo: null,
branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '',
pr: null,
checks: null,
canMerge: false,
defaultBranch: null,
resolvedRemoteName: null,
});
}
console.error('Failed to load GitHub PR status:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
}
});
app.post('/api/github/pr/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
// remote = target repo (where PR is created, e.g., 'upstream' for forks)
const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin';
// headRemote = source repo (where head branch lives, e.g., 'origin' for forks)
const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : '';
// targetRepo = explicit target repo (alternative to remote, for auto-detected upstream)
const targetRepo = req.body?.targetRepo && typeof req.body.targetRepo.owner === 'string' && typeof req.body.targetRepo.repo === 'string'
? { owner: req.body.targetRepo.owner.trim(), repo: req.body.targetRepo.repo.trim() }
: null;
if (!directory || !title || !head || !requestedBase) {
return res.status(400).json({ error: 'directory, title, head, base are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
let repo;
if (targetRepo) {
repo = targetRepo;
} else {
const resolved = await resolveGitHubRepoFromDirectory(directory, remote);
repo = resolved.repo;
}
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const normalizeBranchRef = (value, remoteNames = new Set()) => {
if (!value) {
return value;
}
let normalized = value.trim();
if (normalized.startsWith('refs/heads/')) {
normalized = normalized.substring('refs/heads/'.length);
}
if (normalized.startsWith('heads/')) {
normalized = normalized.substring('heads/'.length);
}
if (normalized.startsWith('remotes/')) {
normalized = normalized.substring('remotes/'.length);
}
const slashIndex = normalized.indexOf('/');
if (slashIndex > 0) {
const maybeRemote = normalized.slice(0, slashIndex);
if (remoteNames.has(maybeRemote)) {
const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim();
if (withoutRemotePrefix) {
normalized = withoutRemotePrefix;
}
}
}
return normalized;
};
// Determine the source remote for the head branch
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
let sourceRemote = headRemote;
const { getStatus, getRemotes } = await import('../git/index.js');
// If no explicit headRemote, check the branch's tracking info
if (!sourceRemote) {
const status = await getStatus(directory).catch(() => null);
if (status?.tracking) {
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
const trackingRemote = status.tracking.split('/')[0];
if (trackingRemote) {
sourceRemote = trackingRemote;
}
}
}
// Fallback: if targeting non-origin and no tracking info, try 'origin'
if (!sourceRemote && remote !== 'origin') {
sourceRemote = 'origin';
}
const remoteNames = new Set([remote]);
const remotes = await getRemotes(directory).catch(() => []);
for (const item of remotes) {
if (item?.name) {
remoteNames.add(item.name);
}
}
if (sourceRemote) {
remoteNames.add(sourceRemote);
}
const base = normalizeBranchRef(requestedBase, remoteNames);
if (!base) {
return res.status(400).json({ error: 'Invalid base branch name' });
}
// For fork workflows: we need to determine the correct head reference
let headRef = head;
let headRepo = null;
if (sourceRemote) {
// The branch is on a different remote than the target - this is a cross-repo PR
const resolved = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
headRepo = resolved.repo;
if (!headRepo) {
return res.status(400).json({
error: `Cannot resolve GitHub repo for remote "${sourceRemote}". Check that the remote URL is a valid GitHub repository.`,
});
}
// Always use owner:branch format for cross-repo PRs
// GitHub API requires this when head is from a different repo/fork
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
headRef = `${headRepo.owner}:${head}`;
}
}
// For cross-repo PRs, verify the branch exists on the head repo first
if (headRef.includes(':')) {
const [headOwner] = headRef.split(':');
const headRepoName = headRepo?.repo || repo.repo;
if (headRepoName) {
try {
await octokit.rest.repos.getBranch({
owner: headOwner,
repo: headRepoName,
branch: head,
});
} catch (branchError) {
if (branchError?.status === 404) {
return res.status(400).json({
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
});
}
// For other errors, continue - let the PR create attempt handle it
}
}
}
const created = await octokit.rest.pulls.create({
owner: repo.owner,
repo: repo.repo,
title,
head: headRef,
base,
...(typeof body === 'string' ? { body } : {}),
...(typeof draft === 'boolean' ? { draft } : {}),
});
const pr = created?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to create PR' });
}
// Invalidate PR status cache so subsequent prStatus calls fetch fresh data
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
const createCacheKey = `${directory}::${headBranch}::${remote}`;
prStatusCache.delete(createCacheKey);
if (repo?.owner && repo?.repo) {
const { invalidateRepoPullsCache } = await import('./pr-status.js');
invalidateRepoPullsCache(repo.owner, repo.repo);
}
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.state === 'closed' ? 'closed' : 'open',
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to create GitHub PR:', error);
// Check for head validation error (common with fork PRs)
const errorMessage = error.message || '';
const isHeadValidationError =
errorMessage.includes('Validation Failed') &&
errorMessage.includes('"field":"head"') &&
errorMessage.includes('"code":"invalid"');
if (isHeadValidationError) {
return res.status(400).json({
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
});
}
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
}
});
app.post('/api/github/pr/update', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
if (!directory || !number || !title) {
return res.status(400).json({ error: 'directory, number, title are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const state = req.body?.state === 'open' || req.body?.state === 'closed' ? req.body.state : undefined;
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
const labels = Array.isArray(req.body?.labels) ? req.body.labels : undefined;
const assignees = Array.isArray(req.body?.assignees) ? req.body.assignees : undefined;
const milestoneProvided = req.body?.milestone !== undefined;
const hasExtendedFields = state !== undefined
|| draft !== undefined
|| labels !== undefined
|| assignees !== undefined
|| milestoneProvided;
let updated;
try {
if (hasExtendedFields) {
// PRs are issues: issues.update carries state/labels/assignees/milestone
// (plus title/body) and works on pull requests. draft is not an
// issues.update field, so it is applied through pulls.update instead.
const issuesParams = {
owner: repo.owner,
repo: repo.repo,
issue_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
...(state !== undefined ? { state } : {}),
...(labels !== undefined ? { labels } : {}),
...(assignees !== undefined ? { assignees } : {}),
};
if (milestoneProvided) {
if (req.body.milestone === null) {
issuesParams.milestone = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
// issues.update accepts the milestone number, not the title — resolve it.
const milestoneTitle = req.body.milestone.trim();
const milestones = await octokit.rest.issues.listMilestonesForRepo({
owner: repo.owner,
repo: repo.repo,
state: 'all',
per_page: 100,
});
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
);
if (!milestone) {
return res.status(400).json({ error: 'Milestone not found' });
}
issuesParams.milestone = milestone.number;
}
}
updated = await octokit.rest.issues.update(issuesParams);
if (draft !== undefined) {
const draftUpdated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
draft,
});
// The draft write returns the freshest full PR payload.
updated = draftUpdated;
}
} else {
updated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
});
}
} catch (error) {
if (error?.status === 401) {
return res.status(401).json({ error: 'GitHub not connected' });
}
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to edit this PR' });
}
if (error?.status === 404) {
return res.status(404).json({ error: 'PR not found in this repository' });
}
if (error?.status === 422) {
const apiMessage = error?.response?.data?.message;
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
: null;
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
return res.status(422).json({ error: message });
}
throw error;
}
const pr = updated?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to update PR' });
}
invalidatePrContextCache(directory, number);
const { invalidateRepoPullsCache } = await import('./pr-status.js');
invalidateRepoPullsCache(repo.owner, repo.repo);
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to update GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
}
});
app.post('/api/github/pr/merge', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
try {
const result = await octokit.rest.pulls.merge({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
merge_method: method,
});
invalidatePrContextCache(directory, number);
const { invalidateRepoPullsCache } = await import('./pr-status.js');
invalidateRepoPullsCache(repo.owner, repo.repo);
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to merge this PR' });
}
if (error?.status === 405 || error?.status === 409) {
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
}
throw error;
}
} catch (error) {
console.error('Failed to merge GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
}
});
app.post('/api/github/pr/ready', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const nodeId = pr?.data?.node_id;
if (!nodeId) {
return res.status(500).json({ error: 'Failed to resolve PR node id' });
}
if (pr?.data?.draft === false) {
return res.json({ ready: true });
}
try {
await octokit.graphql(
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
{ pullRequestId: nodeId }
);
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
}
throw error;
}
invalidatePrContextCache(directory, number);
{
const { invalidateRepoPullsCache } = await import('./pr-status.js');
invalidateRepoPullsCache(repo.owner, repo.repo);
}
return res.json({ ready: true });
} catch (error) {
console.error('Failed to mark PR ready:', error);
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
}
});
// PRs are issues at the API level, so issues.createComment posts a PR
// "comment" (the issue-thread comment, not a review comment).
app.post('/api/github/pulls/comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, comment: null });
}
const result = await octokit.rest.issues.createComment({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
body,
});
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to create GitHub PR comment:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.post('/api/github/pulls/review-comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
const inReplyToId = typeof req.body?.inReplyToId === 'number' ? req.body.inReplyToId : undefined;
if (!directory || !number || !body) {
return res.status(400).json({ error: 'directory, number, body are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, comment: null });
}
const createParams = {
owner: repo.owner,
repo: repo.repo,
pull_number: number,
body,
};
if (inReplyToId !== undefined) {
createParams.in_reply_to_id = inReplyToId;
} else {
// New inline comment: requires a path/line anchor and the PR head commit.
const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
const line = typeof req.body?.line === 'number' ? req.body.line : null;
if (!path || !line) {
return res.status(400).json({ error: 'path and line are required for a new review comment' });
}
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const headSha = prResp?.data?.head?.sha;
if (!headSha) {
return res.status(500).json({ error: 'GitHub returned an error while resolving the PR head commit' });
}
createParams.commit_id = headSha;
createParams.path = path;
createParams.line = line;
}
const result = await octokit.rest.pulls.createReviewComment(createParams);
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the review comment' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
path: comment.path,
line: typeof comment.line === 'number' ? comment.line : null,
position: typeof comment.position === 'number' ? comment.position : null,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to create GitHub review comment:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.post('/api/github/pulls/review', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const event = typeof req.body?.event === 'string' ? req.body.event : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
if (!directory || !number || !event) {
return res.status(400).json({ error: 'directory, number, event are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, review: null });
}
const result = await octokit.rest.pulls.createReview({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
event,
...(typeof body === 'string' ? { body } : {}),
});
const review = result?.data;
if (!review) {
return res.status(500).json({ error: 'GitHub returned an error while submitting the review' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
review: {
id: String(review.id),
state: typeof review.state === 'string' ? review.state : '',
author: mapGitHubUserSummary(review.user),
submittedAt: review.submitted_at,
body: typeof review.body === 'string' ? review.body : null,
commitSha: typeof review.commit_id === 'string' ? review.commit_id : null,
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to submit GitHub review:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
// ================= GitHub Repo APIs =================
app.get('/api/github/repo/upstream', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, isFork: false, upstream: null });
}
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const network = await resolveRepoNetwork(octokit, directory);
if (!network || network.length <= 1) {
return res.json({ connected: true, isFork: false, upstream: null });
}
const upstream = network.find((r) => r.source === 'upstream') || null;
let defaultBranch = 'main';
let defaultBranchSha = null;
if (upstream) {
try {
const metadata = await octokit.rest.repos.get({ owner: upstream.owner, repo: upstream.repo });
defaultBranch = metadata?.data?.default_branch || 'main';
const ref = await octokit.rest.git.getRef({ owner: upstream.owner, repo: upstream.repo, ref: `heads/${defaultBranch}` });
defaultBranchSha = ref?.data?.object?.sha || null;
} catch {
// Fall back if metadata/ref fetch fails
}
}
// Check if a configured git remote points to the upstream repo
let upstreamRemoteName = null;
if (upstream) {
try {
const { getRemotes } = await import('../git/index.js');
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const remotes = await getRemotes(directory);
for (const r of remotes) {
if (r?.name) {
const resolved = await resolveGitHubRepoFromDirectory(directory, r.name).catch(() => ({ repo: null }));
if (resolved.repo && resolved.repo.owner === upstream.owner && resolved.repo.repo === upstream.repo) {
upstreamRemoteName = r.name;
break;
}
}
}
} catch {
// Ignore errors finding remote name
}
}
return res.json({
connected: true,
isFork: Boolean(upstream),
upstream: upstream ? { owner: upstream.owner, repo: upstream.repo, url: upstream.url, defaultBranch, defaultBranchSha, remoteName: upstreamRemoteName } : null,
});
} catch (error) {
console.error('Failed to detect upstream repo:', error);
return res.status(500).json({ error: error.message || 'Failed to detect upstream repo' });
}
});
app.get('/api/github/repo/branches', async (req, res) => {
try {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
if (!owner || !repo) {
return res.status(400).json({ error: 'owner and repo are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ branches: [] });
}
const branches = [];
let page = 1;
while (true) {
const response = await octokit.rest.repos.listBranches({ owner, repo, per_page: 100, page });
if (!response.data || response.data.length === 0) break;
for (const branch of response.data) {
branches.push(branch.name);
}
if (response.data.length < 100) break;
page++;
}
return res.json({ branches });
} catch (error) {
console.error('Failed to fetch repo branches:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch repo branches' });
}
});
// ================= GitHub Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
// (directory remote + owner/repo override), hits a GitHub 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.
app.get('/api/github/users/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, users: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, users: [] });
}
const list = await withTimeout(
octokit.rest.issues.listAssignees({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github users search',
);
const needle = query.toLowerCase();
const users = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubUserSummary)
.filter(Boolean)
.filter((user) => !needle || user.login.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
return res.json({ connected: true, repo, users });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, users: [] });
}
console.error('Failed to search GitHub users:', error);
return res.json({ connected: false, users: [] });
}
});
app.get('/api/github/labels/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, labels: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, labels: [] });
}
const list = await withTimeout(
octokit.rest.issues.listLabelsForRepo({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github labels search',
);
const needle = query.toLowerCase();
const labels = mapGitHubLabels(list?.data).filter(
(label) => !needle || label.name.toLowerCase().includes(needle),
);
return res.json({ connected: true, repo, labels });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, labels: [] });
}
console.error('Failed to search GitHub labels:', error);
return res.json({ connected: false, labels: [] });
}
});
app.get('/api/github/milestones/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, milestones: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const list = await withTimeout(
octokit.rest.issues.listMilestonesForRepo({ owner: repo.owner, repo: repo.repo, state: 'all', per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github milestones search',
);
const needle = query.toLowerCase();
const milestones = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubMilestone)
.filter(Boolean)
.filter((milestone) => !needle || milestone.title.toLowerCase().includes(needle));
return res.json({ connected: true, repo, milestones });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, milestones: [] });
}
console.error('Failed to search GitHub milestones:', error);
return res.json({ connected: false, milestones: [] });
}
});
app.get('/api/github/branches/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, branches: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, branches: [] });
}
const branches = [];
const needle = query.toLowerCase();
let page = 1;
while (true) {
const response = await withTimeout(
octokit.rest.repos.listBranches({ owner: repo.owner, repo: repo.repo, per_page: 100, page }),
ROUTE_TIMEOUT_MS,
'github branches search',
);
if (!response.data || response.data.length === 0) break;
for (const branch of response.data) {
const name = typeof branch?.name === 'string' ? branch.name : '';
if (!name) continue;
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
}
if (response.data.length < 100) break;
page++;
}
return res.json({ connected: true, repo, branches });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, branches: [] });
}
console.error('Failed to search GitHub branches:', error);
return res.json({ connected: false, branches: [] });
}
});
app.get('/api/github/tags/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false, tags: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, tags: [] });
}
const list = await withTimeout(
octokit.rest.repos.listTags({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github tags search',
);
const needle = query.toLowerCase();
const tags = (Array.isArray(list?.data) ? list.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle));
return res.json({ connected: true, repo, tags });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, tags: [] });
}
console.error('Failed to search GitHub tags:', error);
return res.json({ connected: false, tags: [] });
}
});
// ================= GitHub Issue APIs =================
app.get('/api/github/issues/list', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const { getProviderApiBaseUrl } = await getGitHubLibraries();
const apiBase = getProviderApiBaseUrl('github');
const repoNetwork = await resolveRepoNetwork(octokit, directory);
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, issues: [] });
}
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
const mapIssueSummary = (item, repoRef) => ({
number: item.number,
title: item.title,
url: item.html_url,
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
labels: Array.isArray(item.labels)
? item.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
});
if (searchQuery) {
const repoQualifiers = reposToQuery
.map((r) => `repo:${r.owner}/${r.repo}`)
.join(' ');
const q = `${repoQualifiers} ${searchQuery} type:issue state:open`;
try {
const searchResult = await octokit.rest.search.issuesAndPullRequests({
q,
per_page: 50,
page: effectivePage,
});
const totalCount = searchResult.data.total_count;
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
const issues = items
.filter((item) => !item?.pull_request)
.map((item) => {
const repoFullName = (item.repository_url || '').replace(`${apiBase}/repos/`, '');
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
return mapIssueSummary(item, matched || reposToQuery[0]);
});
const fetchedCount = (effectivePage - 1) * 50 + items.length;
const hasMore = fetchedCount < totalCount;
return res.json({ connected: true, repo, issues, page: effectivePage, hasMore });
} catch (error) {
console.error('Failed to search GitHub issues:', error);
return res.json({ connected: true, repo, issues: [], page: effectivePage, hasMore: false });
}
}
const queryRepo = async (repoRef) => {
try {
const list = await octokit.rest.issues.listForRepo({
owner: repoRef.owner,
repo: repoRef.repo,
state: 'open',
per_page: 50,
page: effectivePage,
});
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
const hasMore = /rel="next"/.test(link);
const issues = (Array.isArray(list?.data) ? list.data : [])
.filter((item) => !item?.pull_request)
.map((item) => mapIssueSummary(item, repoRef));
return { issues, hasMore };
} catch (error) {
console.warn(`Failed to list issues for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
return { issues: [], hasMore: false };
}
};
const results = await Promise.all(reposToQuery.map(queryRepo));
const allIssues = results.flatMap((r) => r.issues);
const anyHasMore = results.some((r) => r.hasMore);
return res.json({ connected: true, repo, issues: allIssues, page: effectivePage, hasMore: anyHasMore });
} catch (error) {
console.error('Failed to list GitHub issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
}
});
app.get('/api/github/issues/get', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
const issue = result?.data;
if (!issue || issue.pull_request) {
return res.status(400).json({ error: 'Not a GitHub issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state === 'closed' ? 'closed' : 'open',
body: issue.body || '',
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
assignees: Array.isArray(issue.assignees)
? issue.assignees
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
.filter(Boolean)
: [],
labels: Array.isArray(issue.labels)
? issue.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
milestone: issue.milestone && typeof issue.milestone === 'object'
? {
title: typeof issue.milestone.title === 'string' ? issue.milestone.title : '',
...(typeof issue.milestone.state === 'string' ? { state: issue.milestone.state } : {}),
}
: null,
commentsCount: typeof issue.comments === 'number' ? issue.comments : undefined,
},
});
} catch (error) {
console.error('Failed to fetch GitHub issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
}
});
app.get('/api/github/issues/comments', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, comments: [] });
}
const result = await octokit.rest.issues.listComments({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
per_page: 100,
});
const comments = (Array.isArray(result?.data) ? result.data : [])
.map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
return res.json({ connected: true, repo, comments });
} catch (error) {
console.error('Failed to fetch GitHub issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
}
});
app.post('/api/github/issues/comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, comment: null });
}
const result = await octokit.rest.issues.createComment({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
body,
});
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
}
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to create GitHub issue comment:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.post('/api/github/issues/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const result = await octokit.rest.issues.create({
owner: repo.owner,
repo: repo.repo,
title,
...(body !== undefined ? { body } : {}),
...(labels !== undefined ? { labels } : {}),
});
const item = result?.data;
if (!item) {
return res.status(500).json({ error: 'GitHub returned an error while creating the issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: item.number,
title: typeof item.title === 'string' ? item.title : title,
url: typeof item.html_url === 'string' ? item.html_url : '',
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
body: typeof item.body === 'string' ? item.body : '',
createdAt: item.created_at,
updatedAt: item.updated_at,
labels: Array.isArray(item.labels)
? item.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to create GitHub issue:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.patch('/api/github/issues/update', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const params = {
owner: repo.owner,
repo: repo.repo,
issue_number: number,
};
if (typeof req.body?.title === 'string') {
params.title = req.body.title.trim();
}
if (typeof req.body?.body === 'string') {
params.body = req.body.body;
}
if (req.body?.state === 'open' || req.body?.state === 'closed') {
params.state = req.body.state;
}
if (Array.isArray(req.body?.labels)) {
params.labels = req.body.labels;
}
if (Array.isArray(req.body?.assignees)) {
params.assignees = req.body.assignees;
}
if (req.body?.milestone !== undefined) {
if (req.body.milestone === null) {
params.milestone = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
// issues.update accepts the milestone number, not the title — resolve it.
const milestoneTitle = req.body.milestone.trim();
const milestones = await octokit.rest.issues.listMilestonesForRepo({
owner: repo.owner,
repo: repo.repo,
state: 'all',
per_page: 100,
});
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
);
if (!milestone) {
return res.status(400).json({ error: 'Milestone not found' });
}
params.milestone = milestone.number;
}
}
const result = await octokit.rest.issues.update(params);
const issue = result?.data;
if (!issue) {
return res.status(500).json({ error: 'GitHub returned an error while updating the issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state === 'closed' ? 'closed' : 'open',
body: issue.body || '',
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
assignees: Array.isArray(issue.assignees)
? issue.assignees
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
.filter(Boolean)
: [],
labels: Array.isArray(issue.labels)
? issue.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
milestone: issue.milestone && typeof issue.milestone === 'object'
? {
title: typeof issue.milestone.title === 'string' ? issue.milestone.title : '',
...(typeof issue.milestone.state === 'string' ? { state: issue.milestone.state } : {}),
}
: null,
commentsCount: typeof issue.comments === 'number' ? issue.comments : undefined,
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to update GitHub issue:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
// ================= GitHub Pull Request Context APIs =================
app.get('/api/github/pulls/list', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const repoNetwork = await resolveRepoNetwork(octokit, directory);
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, prs: [] });
}
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
const mapPrSummary = (pr, repoRef) => {
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
const headRepo = pr.head?.repo
? {
owner: pr.head.repo.owner?.login,
repo: pr.head.repo.name,
url: pr.head.repo.html_url,
cloneUrl: pr.head.repo.clone_url,
sshUrl: pr.head.repo.ssh_url,
}
: null;
return {
number: pr.number,
title: pr.title,
url: pr.html_url,
state: mergedState,
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
labels: mapGitHubLabels(pr.labels),
assignees: mapGitHubAssignees(pr.assignees),
milestone: mapGitHubMilestone(pr.milestone),
commentsCount: typeof pr.comments === 'number' ? pr.comments : undefined,
headLabel: pr.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
? headRepo
: null,
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
};
};
if (searchQuery) {
const repoQualifiers = reposToQuery
.map((r) => `repo:${r.owner}/${r.repo}`)
.join(' ');
const q = `${repoQualifiers} ${searchQuery} type:pr state:open`;
try {
const searchResult = await octokit.rest.search.issuesAndPullRequests({
q,
per_page: 50,
page: effectivePage,
});
const totalCount = searchResult.data.total_count;
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
const findRepoForSearchItem = (item) => {
const repositoryUrl = typeof item?.repository_url === 'string' ? item.repository_url : '';
const match = repositoryUrl.match(/\/repos\/([^/]+)\/([^/]+)$/);
if (!match) return reposToQuery[0];
return reposToQuery.find((repoRef) => repoRef.owner === match[1] && repoRef.repo === match[2]) || reposToQuery[0];
};
const prRefs = items
.map((item) => ({ number: item.number, repoRef: findRepoForSearchItem(item) }))
.filter((ref) => Number.isFinite(ref.number) && ref.number > 0 && ref.repoRef);
let prs;
if (prRefs.length === 0) {
prs = [];
} else {
const results = await Promise.all(prRefs.map(async ({ number, repoRef }) => {
try {
const pr = await octokit.rest.pulls.get({
owner: repoRef.owner,
repo: repoRef.repo,
pull_number: number,
});
return mapPrSummary(pr.data, repoRef);
} catch {
return null;
}
}));
prs = results.filter(Boolean);
}
const fetchedCount = (effectivePage - 1) * 50 + items.length;
const hasMore = fetchedCount < totalCount;
return res.json({ connected: true, repo, prs, page: effectivePage, hasMore });
} catch (error) {
console.error('Failed to search GitHub PRs:', error);
return res.json({ connected: true, repo, prs: [], page: effectivePage, hasMore: false });
}
}
const queryRepo = async (repoRef) => {
try {
const list = await octokit.rest.pulls.list({
owner: repoRef.owner,
repo: repoRef.repo,
state: 'open',
per_page: 50,
page: effectivePage,
});
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
const hasMore = /rel="next"/.test(link);
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => mapPrSummary(pr, repoRef));
return { prs, hasMore };
} catch (error) {
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
return { prs: [], hasMore: false };
}
};
const results = await Promise.all(reposToQuery.map(queryRepo));
const allPrs = results.flatMap((r) => r.prs);
const anyHasMore = results.some((r) => r.hasMore);
return res.json({ connected: true, repo, prs: allPrs, page: effectivePage, hasMore: anyHasMore });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to list GitHub pull requests:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitHub pull requests' });
}
});
app.get('/api/github/pulls/context', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true';
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
// Short response cache: the checks view, comments view, and the
// send-to-chat actions request the same context within seconds of each
// other. Detail-inclusive responses satisfy detail-free requests.
const contextCacheKey = JSON.stringify([
directory,
number,
includeDiff,
requestedRepo ? `${requestedRepo.owner}/${requestedRepo.repo}` : null,
]);
const cachedContext = prContextCache.get(contextCacheKey);
if (cachedContext
&& Date.now() - cachedContext.fetchedAt < PR_CONTEXT_CACHE_TTL_MS
&& (cachedContext.includeCheckDetails || !includeCheckDetails)) {
return res.json(cachedContext.data);
}
const originalJson = res.json.bind(res);
res.json = (data) => {
if (data && data.pr) {
if (typeof data.fetchedAt !== 'number') {
data.fetchedAt = Date.now();
}
prContextCache.delete(contextCacheKey);
prContextCache.set(contextCacheKey, { data, includeCheckDetails, fetchedAt: data.fetchedAt });
if (prContextCache.size > PR_CONTEXT_CACHE_MAX_ENTRIES) {
const oldest = prContextCache.keys().next().value;
if (oldest !== undefined) {
prContextCache.delete(oldest);
}
}
}
return originalJson(data);
};
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, pr: null });
}
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const prData = prResp?.data;
if (!prData) {
return res.status(404).json({ error: 'PR not found' });
}
const headRepo = prData.head?.repo
? {
owner: prData.head.repo.owner?.login,
repo: prData.head.repo.name,
url: prData.head.repo.html_url,
cloneUrl: prData.head.repo.clone_url,
sshUrl: prData.head.repo.ssh_url,
}
: null;
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
const pr = {
number: prData.number,
title: prData.title,
url: prData.html_url,
state: mergedState,
draft: Boolean(prData.draft),
base: prData.base?.ref,
head: prData.head?.ref,
headSha: prData.head?.sha,
mergeable: prData.mergeable,
mergeableState: prData.mergeable_state,
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
labels: mapGitHubLabels(prData.labels),
assignees: mapGitHubAssignees(prData.assignees),
milestone: mapGitHubMilestone(prData.milestone),
commentsCount: typeof prData.comments === 'number' ? prData.comments : undefined,
headLabel: prData.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
body: prData.body || '',
createdAt: prData.created_at,
updatedAt: prData.updated_at,
};
const issueCommentsResp = await octokit.rest.issues.listComments({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
per_page: 100,
});
const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
per_page: 100,
});
const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
path: comment.path,
line: typeof comment.line === 'number' ? comment.line : null,
position: typeof comment.position === 'number' ? comment.position : null,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
const filesResp = await octokit.rest.pulls.listFiles({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
per_page: 100,
});
const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
filename: f.filename,
status: f.status,
additions: f.additions,
deletions: f.deletions,
changes: f.changes,
patch: f.patch,
}));
// checks summary (same logic as status endpoint)
let checks = null;
let checkRunsOut = undefined;
const sha = prData.head?.sha;
if (sha) {
try {
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
const checkRuns = dedupeCheckRuns(Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []);
if (checkRuns.length > 0) {
const parsedJobs = new Map();
const parsedAnnotations = new Map();
if (includeCheckDetails) {
// Prefetch actions jobs per runId.
const runIds = new Set();
const jobIds = new Map();
for (const run of checkRuns) {
const details = typeof run.details_url === 'string' ? run.details_url : '';
const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
if (match) {
const runId = Number(match[1]);
const jobId = match[2] ? Number(match[2]) : null;
if (Number.isFinite(runId) && runId > 0) {
runIds.add(runId);
if (jobId && Number.isFinite(jobId) && jobId > 0) {
jobIds.set(details, { runId, jobId });
} else {
jobIds.set(details, { runId, jobId: null });
}
}
}
}
for (const runId of runIds) {
try {
const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({
owner: repo.owner,
repo: repo.repo,
run_id: runId,
per_page: 100,
});
const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : [];
parsedJobs.set(runId, jobs);
} catch {
parsedJobs.set(runId, []);
}
}
for (const run of checkRuns) {
const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
const shouldLoadAnnotations = Boolean(
run?.id
&& runConclusion
&& !['success', 'neutral', 'skipped'].includes(runConclusion)
);
if (!shouldLoadAnnotations) {
continue;
}
const checkRunId = Number(run.id);
if (!Number.isFinite(checkRunId) || checkRunId <= 0) {
continue;
}
const annotations = [];
for (let page = 1; page <= 3; page += 1) {
try {
const annotationsResp = await octokit.rest.checks.listAnnotations({
owner: repo.owner,
repo: repo.repo,
check_run_id: checkRunId,
per_page: 50,
page,
});
const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : [];
annotations.push(...chunk);
if (chunk.length < 50) {
break;
}
} catch {
break;
}
}
if (annotations.length > 0) {
parsedAnnotations.set(checkRunId, annotations);
}
}
}
checkRunsOut = checkRuns.map((run) => {
const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined;
let job = undefined;
if (includeCheckDetails && detailsUrl) {
const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
const runId = match ? Number(match[1]) : null;
const jobId = match && match[2] ? Number(match[2]) : null;
if (runId && Number.isFinite(runId)) {
const jobs = parsedJobs.get(runId) || [];
const matched = jobId
? jobs.find((j) => j.id === jobId)
: null;
const picked = matched || jobs.find((j) => j.name === run.name) || null;
if (picked) {
job = {
runId,
jobId: picked.id,
url: picked.html_url,
name: picked.name,
workflowName: picked.workflow_name || undefined,
conclusion: picked.conclusion,
steps: Array.isArray(picked.steps)
? picked.steps.map((s) => ({
name: s.name,
status: s.status,
conclusion: s.conclusion,
number: s.number,
startedAt: s.started_at || undefined,
completedAt: s.completed_at || undefined,
}))
: undefined,
};
} else {
job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl };
}
}
}
return {
id: run.id,
name: run.name,
startedAt: run.started_at || undefined,
completedAt: run.completed_at || undefined,
app: run.app
? {
name: run.app.name || undefined,
slug: run.app.slug || undefined,
}
: undefined,
status: run.status,
conclusion: run.conclusion,
detailsUrl,
output: run.output
? {
title: run.output.title || undefined,
summary: run.output.summary || undefined,
text: run.output.text || undefined,
}
: undefined,
...(job ? { job } : {}),
...(run.id && parsedAnnotations.has(run.id)
? {
annotations: parsedAnnotations.get(run.id).map((a) => ({
path: a.path || undefined,
startLine: typeof a.start_line === 'number' ? a.start_line : undefined,
endLine: typeof a.end_line === 'number' ? a.end_line : undefined,
level: a.annotation_level || undefined,
message: a.message || '',
title: a.title || undefined,
rawDetails: a.raw_details || undefined,
})).filter((a) => a.message),
}
: {}),
};
});
checks = summarizeCheckRuns(checkRuns);
}
} catch {
// ignore and fall back
}
if (!checks) {
try {
const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
checks = summarizeCombinedStatuses(statuses);
} catch {
checks = null;
}
}
}
let diff = undefined;
if (includeDiff) {
const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
owner: repo.owner,
repo: repo.repo,
pull_number: number,
headers: { accept: 'application/vnd.github.v3.diff' },
});
diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
}
return res.json({
connected: true,
repo,
pr,
issueComments,
reviewComments,
files,
...(diff ? { diff } : {}),
checks,
...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}),
});
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to load GitHub PR context:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
}
});
// ================= GitHub Pull Request Enrichment APIs =================
app.get('/api/github/pulls/commits', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, commits: [] });
}
const result = await withTimeout(
octokit.rest.pulls.listCommits({ owner: repo.owner, repo: repo.repo, pull_number: number, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'GitHub PR commits',
);
const commits = (Array.isArray(result?.data) ? result.data : []).map((commit) => {
const message = typeof commit.commit?.message === 'string' ? commit.commit.message : '';
return {
sha: commit.sha,
shortSha: typeof commit.sha === 'string' ? commit.sha.slice(0, 7) : commit.sha,
message,
...(message.split('\n')[0] ? { summary: message.split('\n')[0] } : {}),
author: mapGitHubUserSummary(commit.author),
committer: mapGitHubUserSummary(commit.committer),
...(typeof commit.commit?.committer?.date === 'string' ? { committedAt: commit.commit.committer.date } : {}),
parents: Array.isArray(commit.parents) ? commit.parents.map((parent) => parent.sha) : [],
};
});
return res.json({ connected: true, repo, commits });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (Number.isInteger(error?.status) && error.status >= 400) {
return res.status(502).json({ error: 'GitHub returned an error while fetching pull request commits' });
}
console.error('Failed to load GitHub pull request commits:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub pull request commits' });
}
});
app.get('/api/github/pulls/timeline', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull(directory);
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, events: [] });
}
const result = await withTimeout(
octokit.rest.issues.listEventsForTimeline({ owner: repo.owner, repo: repo.repo, issue_number: number, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'GitHub PR timeline',
);
const events = (Array.isArray(result?.data) ? result.data : []).map((event) => ({
id: String(event.id),
type: typeof event.event === 'string' ? event.event.toLowerCase() : 'other',
author: mapGitHubUserSummary(event.actor),
createdAt: event.created_at,
body: typeof event.body === 'string' ? event.body : null,
commitSha: typeof event.commit_id === 'string' ? event.commit_id : null,
}));
return res.json({ connected: true, repo, events });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (Number.isInteger(error?.status) && error.status >= 400) {
return res.status(502).json({ error: 'GitHub returned an error while fetching the pull request timeline' });
}
console.error('Failed to load GitHub pull request timeline:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub pull request timeline' });
}
});
}