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(); 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) { const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : ''; const repo = 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; } 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 } = 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 payload = await startDeviceFlow({ clientId, scope, }); 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 } = 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 payload = await exchangeDeviceCode({ clientId, deviceCode }); 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); 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(); 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 }); } // Checks summary: prefer check-runs (Actions), fallback to classic statuses. let checks = null; const sha = prData.head?.sha; if (sha) { 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; 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; } const isMerged = Boolean(prData.merged || prData.merged_at); const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); return res.json({ connected: true, repo: searchRepo, branch, pr: { number: prData.number, title: prData.title, body: prData.body || '', 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, }, 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(); 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(); 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' }); } let updated; try { 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); 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(); 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(); 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' }); } }); // ================= 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(); 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 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(); 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, 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('https://api.github.com/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(); 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) : [], }, }); } 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(); 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' }); } }); // ================= 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(); 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, 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(); 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, 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' }); } }); }