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