Files
openchamber/packages/web/server/lib/github/routes.js
T
Bohdan Triapitsyn 5cc37d0c33 fix: restore CLI validation and test baseline (#1857)
Fixed lazy CLI helper imports for tunnel flows
Restored update command version detection
Made web test and dead-code commands run reliably
2026-06-27 09:45:34 +03:00

1746 lines
67 KiB
JavaScript

const PR_STATUS_CACHE_TTL_MS = 90_000;
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
const prStatusCache = new Map();
function getRequestedRepo(req) {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
return owner && repo ? { owner, repo } : null;
}
async function resolveRepoForRequest(octokit, directory, requestedRepo) {
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!requestedRepo) {
return repo;
}
if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) {
return requestedRepo;
}
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const network = await resolveRepoNetwork(octokit, directory).catch(() => null);
const allowed = Array.isArray(network)
? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo)
: false;
return allowed ? requestedRepo : null;
}
function setPrStatusCache(key, data, fetchedAt) {
// Evict oldest entry when cache exceeds max size
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
const oldest = prStatusCache.entries().next().value;
if (oldest) {
prStatusCache.delete(oldest[0]);
}
}
prStatusCache.set(key, { data, fetchedAt });
}
export function registerGitHubRoutes(app) {
let githubLibraries = null;
const getGitHubLibraries = async () => {
if (!githubLibraries) {
githubLibraries = await import('./index.js');
}
return githubLibraries;
};
const getGitHubUserSummary = async (octokit) => {
const me = await octokit.rest.users.getAuthenticated();
let email = typeof me.data.email === 'string' ? me.data.email : null;
if (!email) {
try {
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
const list = Array.isArray(emails?.data) ? emails.data : [];
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
email = primaryVerified?.email || anyVerified?.email || null;
} catch {
// ignore (scope might be missing)
}
}
return {
login: me.data.login,
id: me.data.id,
avatarUrl: me.data.avatar_url,
name: typeof me.data.name === 'string' ? me.data.name : null,
email,
};
};
const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403;
const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404;
app.get('/api/github/auth/status', async (_req, res) => {
try {
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliActive, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
const { getGhCliToken } = await import('./gh-cli-credential.js');
const auth = getGitHubAuth();
let accounts = getGitHubAuthAccounts();
const ghCliDisabled = isGhCliDisabled();
const ghCliActive = isGhCliActive();
const ghToken = getGhCliToken();
const usingOwnToken = Boolean(auth?.accessToken);
let ghCliUser = null;
if (ghToken !== null && !ghCliDisabled) {
try {
const { Octokit } = await import('@octokit/rest');
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
} catch {
ghCliUser = null;
}
}
if (ghCliActive && !ghCliUser) {
setGhCliActive(false);
}
const ghCliCurrent = ghToken !== null && !ghCliDisabled && Boolean(ghCliUser) && (ghCliActive || !usingOwnToken);
if (ghCliUser) {
accounts = accounts
.map((account) => ({ ...account, current: ghCliCurrent ? false : Boolean(account.current) }))
.concat({
id: GH_CLI_ACCOUNT_ID,
user: ghCliUser,
current: ghCliCurrent,
source: 'gh-cli',
});
}
const buildGhCli = (activeUser = null) => ({
available: ghToken !== null,
disabled: ghCliDisabled,
active: ghCliCurrent,
...(!ghCliDisabled && (activeUser || ghCliUser) ? { user: activeUser || ghCliUser } : {}),
});
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, accounts, ghCli: buildGhCli() });
}
let user = null;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (isGitHubAuthInvalid(error)) {
if (usingOwnToken) clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() });
}
}
const fallback = usingOwnToken ? auth.user : null;
const mergedUser = user || fallback;
return res.json({
connected: true,
user: mergedUser,
scope: ghCliCurrent ? undefined : auth?.scope,
accounts,
ghCli: buildGhCli(ghCliCurrent ? mergedUser : null),
});
} catch (error) {
console.error('Failed to get GitHub auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
}
});
app.post('/api/github/auth/gh-cli', async (req, res) => {
try {
const { setGhCliDisabled, isGhCliDisabled } = await getGitHubLibraries();
const { clearGhCliTokenCache } = await import('./gh-cli-credential.js');
const disabled = Boolean(req.body?.disabled);
setGhCliDisabled(disabled);
clearGhCliTokenCache();
return res.json({ disabled: isGhCliDisabled() });
} catch (error) {
console.error('Failed to update gh CLI setting:', error);
return res.status(500).json({ error: error.message || 'Failed to update gh CLI setting' });
}
});
app.post('/api/github/auth/start', async (_req, res) => {
try {
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const scope = getGitHubScopes();
const payload = await startDeviceFlow({
clientId,
scope,
});
return res.json({
deviceCode: payload.device_code,
userCode: payload.user_code,
verificationUri: payload.verification_uri,
verificationUriComplete: payload.verification_uri_complete,
expiresIn: payload.expires_in,
interval: payload.interval,
scope,
});
} catch (error) {
console.error('Failed to start GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
}
});
app.post('/api/github/auth/complete', async (req, res) => {
try {
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const deviceCode = typeof req.body?.deviceCode === 'string'
? req.body.deviceCode
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
if (!deviceCode) {
return res.status(400).json({ error: 'deviceCode is required' });
}
const payload = await exchangeDeviceCode({ clientId, deviceCode });
if (payload?.error) {
return res.json({
connected: false,
status: payload.error,
error: payload.error_description || payload.error,
});
}
const accessToken = payload?.access_token;
if (!accessToken) {
return res.status(500).json({ error: 'Missing access_token from GitHub' });
}
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: accessToken });
const user = await getGitHubUserSummary(octokit);
setGitHubAuth({
accessToken,
scope: typeof payload.scope === 'string' ? payload.scope : '',
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
user,
});
return res.json({
connected: true,
user,
scope: typeof payload.scope === 'string' ? payload.scope : '',
accounts: getGitHubAuthAccounts(),
});
} catch (error) {
console.error('Failed to complete GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
}
});
app.post('/api/github/auth/activate', async (req, res) => {
try {
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
if (accountId === GH_CLI_ACCOUNT_ID) {
const { getGhCliToken } = await import('./gh-cli-credential.js');
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
if (!ghToken) {
return res.status(404).json({ error: 'GitHub CLI account not found' });
}
const { Octokit } = await import('@octokit/rest');
const user = await getGitHubUserSummary(new Octokit({ auth: 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 { Octokit } = await import('@octokit/rest');
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: 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);
}
// 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) {
setPrStatusCache(cacheKey, data, Date.now());
}
return originalJson(data);
};
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubPrStatus } = await import('./pr-status.js');
const resolvedStatus = await resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
});
const searchRepo = resolvedStatus.repo;
const first = resolvedStatus.pr;
if (!searchRepo) {
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null });
}
if (!first) {
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null });
}
// Enrich with mergeability fields
const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number });
const prData = prFull?.data;
if (!prData) {
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false });
}
// Checks summary: prefer check-runs (Actions), fallback to classic statuses.
let checks = null;
const sha = prData.head?.sha;
if (sha) {
try {
const runs = await octokit.rest.checks.listForRef({
owner: searchRepo.owner,
repo: searchRepo.repo,
ref: sha,
per_page: 100,
});
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
if (checkRuns.length > 0) {
const counts = { success: 0, failure: 0, pending: 0 };
for (const run of checkRuns) {
const status = run?.status;
const conclusion = run?.conclusion;
if (status === 'queued' || status === 'in_progress') {
counts.pending += 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'));
checks = { state, total, ...counts };
}
} 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 : [];
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'));
checks = { state, total, ...counts };
} catch {
checks = null;
}
}
}
// Permission check (best-effort)
let canMerge = false;
try {
const auth = getGitHubAuth();
const username = auth?.user?.login;
if (username) {
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
owner: searchRepo.owner,
repo: searchRepo.repo,
username,
});
const level = perm?.data?.permission;
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
}
} catch {
canMerge = false;
}
const isMerged = Boolean(prData.merged || prData.merged_at);
const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
return res.json({
connected: true,
repo: searchRepo,
branch,
pr: {
number: prData.number,
title: prData.title,
body: prData.body || '',
url: prData.html_url,
state: mergedState,
draft: Boolean(prData.draft),
base: prData.base?.ref,
head: prData.head?.ref,
headSha: prData.head?.sha,
mergeable: prData.mergeable,
mergeableState: prData.mergeable_state,
},
checks,
canMerge,
defaultBranch: resolvedStatus.defaultBranch ?? null,
resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null,
});
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
if (isGitHubResourceUnavailable(error)) {
return res.json({
connected: true,
repo: null,
branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '',
pr: null,
checks: null,
canMerge: false,
defaultBranch: null,
resolvedRemoteName: null,
});
}
console.error('Failed to load GitHub PR status:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
}
});
app.post('/api/github/pr/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
// remote = target repo (where PR is created, e.g., 'upstream' for forks)
const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin';
// headRemote = source repo (where head branch lives, e.g., 'origin' for forks)
const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : '';
// targetRepo = explicit target repo (alternative to remote, for auto-detected upstream)
const targetRepo = req.body?.targetRepo && typeof req.body.targetRepo.owner === 'string' && typeof req.body.targetRepo.repo === 'string'
? { owner: req.body.targetRepo.owner.trim(), repo: req.body.targetRepo.repo.trim() }
: null;
if (!directory || !title || !head || !requestedBase) {
return res.status(400).json({ error: 'directory, title, head, base are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
let repo;
if (targetRepo) {
repo = targetRepo;
} else {
const resolved = await resolveGitHubRepoFromDirectory(directory, remote);
repo = resolved.repo;
}
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const normalizeBranchRef = (value, remoteNames = new Set()) => {
if (!value) {
return value;
}
let normalized = value.trim();
if (normalized.startsWith('refs/heads/')) {
normalized = normalized.substring('refs/heads/'.length);
}
if (normalized.startsWith('heads/')) {
normalized = normalized.substring('heads/'.length);
}
if (normalized.startsWith('remotes/')) {
normalized = normalized.substring('remotes/'.length);
}
const slashIndex = normalized.indexOf('/');
if (slashIndex > 0) {
const maybeRemote = normalized.slice(0, slashIndex);
if (remoteNames.has(maybeRemote)) {
const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim();
if (withoutRemotePrefix) {
normalized = withoutRemotePrefix;
}
}
}
return normalized;
};
// Determine the source remote for the head branch
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
let sourceRemote = headRemote;
const { getStatus, getRemotes } = await import('../git/index.js');
// If no explicit headRemote, check the branch's tracking info
if (!sourceRemote) {
const status = await getStatus(directory).catch(() => null);
if (status?.tracking) {
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
const trackingRemote = status.tracking.split('/')[0];
if (trackingRemote) {
sourceRemote = trackingRemote;
}
}
}
// Fallback: if targeting non-origin and no tracking info, try 'origin'
if (!sourceRemote && remote !== 'origin') {
sourceRemote = 'origin';
}
const remoteNames = new Set([remote]);
const remotes = await getRemotes(directory).catch(() => []);
for (const item of remotes) {
if (item?.name) {
remoteNames.add(item.name);
}
}
if (sourceRemote) {
remoteNames.add(sourceRemote);
}
const base = normalizeBranchRef(requestedBase, remoteNames);
if (!base) {
return res.status(400).json({ error: 'Invalid base branch name' });
}
// For fork workflows: we need to determine the correct head reference
let headRef = head;
let headRepo = null;
if (sourceRemote) {
// The branch is on a different remote than the target - this is a cross-repo PR
const resolved = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
headRepo = resolved.repo;
if (!headRepo) {
return res.status(400).json({
error: `Cannot resolve GitHub repo for remote "${sourceRemote}". Check that the remote URL is a valid GitHub repository.`,
});
}
// Always use owner:branch format for cross-repo PRs
// GitHub API requires this when head is from a different repo/fork
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
headRef = `${headRepo.owner}:${head}`;
}
}
// For cross-repo PRs, verify the branch exists on the head repo first
if (headRef.includes(':')) {
const [headOwner] = headRef.split(':');
const headRepoName = headRepo?.repo || repo.repo;
if (headRepoName) {
try {
await octokit.rest.repos.getBranch({
owner: headOwner,
repo: headRepoName,
branch: head,
});
} catch (branchError) {
if (branchError?.status === 404) {
return res.status(400).json({
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
});
}
// For other errors, continue - let the PR create attempt handle it
}
}
}
const created = await octokit.rest.pulls.create({
owner: repo.owner,
repo: repo.repo,
title,
head: headRef,
base,
...(typeof body === 'string' ? { body } : {}),
...(typeof draft === 'boolean' ? { draft } : {}),
});
const pr = created?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to create PR' });
}
// Invalidate PR status cache so subsequent prStatus calls fetch fresh data
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
const createCacheKey = `${directory}::${headBranch}::${remote}`;
prStatusCache.delete(createCacheKey);
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.state === 'closed' ? 'closed' : 'open',
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to create GitHub PR:', error);
// Check for head validation error (common with fork PRs)
const errorMessage = error.message || '';
const isHeadValidationError =
errorMessage.includes('Validation Failed') &&
errorMessage.includes('"field":"head"') &&
errorMessage.includes('"code":"invalid"');
if (isHeadValidationError) {
return res.status(400).json({
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
});
}
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
}
});
app.post('/api/github/pr/update', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
if (!directory || !number || !title) {
return res.status(400).json({ error: 'directory, number, title are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
let updated;
try {
updated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
});
} catch (error) {
if (error?.status === 401) {
return res.status(401).json({ error: 'GitHub not connected' });
}
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to edit this PR' });
}
if (error?.status === 404) {
return res.status(404).json({ error: 'PR not found in this repository' });
}
if (error?.status === 422) {
const apiMessage = error?.response?.data?.message;
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
: null;
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
return res.status(422).json({ error: message });
}
throw error;
}
const pr = updated?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to update PR' });
}
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to update GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
}
});
app.post('/api/github/pr/merge', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
try {
const result = await octokit.rest.pulls.merge({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
merge_method: method,
});
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to merge this PR' });
}
if (error?.status === 405 || error?.status === 409) {
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
}
throw error;
}
} catch (error) {
console.error('Failed to merge GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
}
});
app.post('/api/github/pr/ready', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const nodeId = pr?.data?.node_id;
if (!nodeId) {
return res.status(500).json({ error: 'Failed to resolve PR node id' });
}
if (pr?.data?.draft === false) {
return res.json({ ready: true });
}
try {
await octokit.graphql(
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
{ pullRequestId: nodeId }
);
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
}
throw error;
}
return res.json({ ready: true });
} catch (error) {
console.error('Failed to mark PR ready:', error);
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
}
});
// ================= GitHub Repo APIs =================
app.get('/api/github/repo/upstream', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, isFork: false, upstream: null });
}
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const network = await resolveRepoNetwork(octokit, directory);
if (!network || network.length <= 1) {
return res.json({ connected: true, isFork: false, upstream: null });
}
const upstream = network.find((r) => r.source === 'upstream') || null;
let defaultBranch = 'main';
let defaultBranchSha = null;
if (upstream) {
try {
const metadata = await octokit.rest.repos.get({ owner: upstream.owner, repo: upstream.repo });
defaultBranch = metadata?.data?.default_branch || 'main';
const ref = await octokit.rest.git.getRef({ owner: upstream.owner, repo: upstream.repo, ref: `heads/${defaultBranch}` });
defaultBranchSha = ref?.data?.object?.sha || null;
} catch {
// Fall back if metadata/ref fetch fails
}
}
// Check if a configured git remote points to the upstream repo
let upstreamRemoteName = null;
if (upstream) {
try {
const { getRemotes } = await import('../git/index.js');
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const remotes = await getRemotes(directory);
for (const r of remotes) {
if (r?.name) {
const resolved = await resolveGitHubRepoFromDirectory(directory, r.name).catch(() => ({ repo: null }));
if (resolved.repo && resolved.repo.owner === upstream.owner && resolved.repo.repo === upstream.repo) {
upstreamRemoteName = r.name;
break;
}
}
}
} catch {
// Ignore errors finding remote name
}
}
return res.json({
connected: true,
isFork: Boolean(upstream),
upstream: upstream ? { owner: upstream.owner, repo: upstream.repo, url: upstream.url, defaultBranch, defaultBranchSha, remoteName: upstreamRemoteName } : null,
});
} catch (error) {
console.error('Failed to detect upstream repo:', error);
return res.status(500).json({ error: error.message || 'Failed to detect upstream repo' });
}
});
app.get('/api/github/repo/branches', async (req, res) => {
try {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
if (!owner || !repo) {
return res.status(400).json({ error: 'owner and repo are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ branches: [] });
}
const branches = [];
let page = 1;
while (true) {
const response = await octokit.rest.repos.listBranches({ owner, repo, per_page: 100, page });
if (!response.data || response.data.length === 0) break;
for (const branch of response.data) {
branches.push(branch.name);
}
if (response.data.length < 100) break;
page++;
}
return res.json({ branches });
} catch (error) {
console.error('Failed to fetch repo branches:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch repo branches' });
}
});
// ================= GitHub Issue APIs =================
app.get('/api/github/issues/list', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const repoNetwork = await resolveRepoNetwork(octokit, directory);
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, issues: [] });
}
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
const mapIssueSummary = (item, repoRef) => ({
number: item.number,
title: item.title,
url: item.html_url,
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
labels: Array.isArray(item.labels)
? item.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
});
if (searchQuery) {
const repoQualifiers = reposToQuery
.map((r) => `repo:${r.owner}/${r.repo}`)
.join(' ');
const q = `${repoQualifiers} ${searchQuery} type:issue state:open`;
try {
const searchResult = await octokit.rest.search.issuesAndPullRequests({
q,
per_page: 50,
page: effectivePage,
});
const totalCount = searchResult.data.total_count;
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
const issues = items
.filter((item) => !item?.pull_request)
.map((item) => {
const repoFullName = (item.repository_url || '').replace('https://api.github.com/repos/', '');
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
return mapIssueSummary(item, matched || reposToQuery[0]);
});
const fetchedCount = (effectivePage - 1) * 50 + items.length;
const hasMore = fetchedCount < totalCount;
return res.json({ connected: true, repo, issues, page: effectivePage, hasMore });
} catch (error) {
console.error('Failed to search GitHub issues:', error);
return res.json({ connected: true, repo, issues: [], page: effectivePage, hasMore: false });
}
}
const queryRepo = async (repoRef) => {
try {
const list = await octokit.rest.issues.listForRepo({
owner: repoRef.owner,
repo: repoRef.repo,
state: 'open',
per_page: 50,
page: effectivePage,
});
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
const hasMore = /rel="next"/.test(link);
const issues = (Array.isArray(list?.data) ? list.data : [])
.filter((item) => !item?.pull_request)
.map((item) => mapIssueSummary(item, repoRef));
return { issues, hasMore };
} catch (error) {
console.warn(`Failed to list issues for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
return { issues: [], hasMore: false };
}
};
const results = await Promise.all(reposToQuery.map(queryRepo));
const allIssues = results.flatMap((r) => r.issues);
const anyHasMore = results.some((r) => r.hasMore);
return res.json({ connected: true, repo, issues: allIssues, page: effectivePage, hasMore: anyHasMore });
} catch (error) {
console.error('Failed to list GitHub issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
}
});
app.get('/api/github/issues/get', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
const issue = result?.data;
if (!issue || issue.pull_request) {
return res.status(400).json({ error: 'Not a GitHub issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state === 'closed' ? 'closed' : 'open',
body: issue.body || '',
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
assignees: Array.isArray(issue.assignees)
? issue.assignees
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
.filter(Boolean)
: [],
labels: Array.isArray(issue.labels)
? issue.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
},
});
} catch (error) {
console.error('Failed to fetch GitHub issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
}
});
app.get('/api/github/issues/comments', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, comments: [] });
}
const result = await octokit.rest.issues.listComments({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
per_page: 100,
});
const comments = (Array.isArray(result?.data) ? result.data : [])
.map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
return res.json({ connected: true, repo, comments });
} catch (error) {
console.error('Failed to fetch GitHub issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
}
});
// ================= GitHub Pull Request Context APIs =================
app.get('/api/github/pulls/list', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const repoNetwork = await resolveRepoNetwork(octokit, directory);
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, prs: [] });
}
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
const mapPrSummary = (pr, repoRef) => {
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
const headRepo = pr.head?.repo
? {
owner: pr.head.repo.owner?.login,
repo: pr.head.repo.name,
url: pr.head.repo.html_url,
cloneUrl: pr.head.repo.clone_url,
sshUrl: pr.head.repo.ssh_url,
}
: null;
return {
number: pr.number,
title: pr.title,
url: pr.html_url,
state: mergedState,
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
headLabel: pr.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
? headRepo
: null,
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
};
};
if (searchQuery) {
const repoQualifiers = reposToQuery
.map((r) => `repo:${r.owner}/${r.repo}`)
.join(' ');
const q = `${repoQualifiers} ${searchQuery} type:pr state:open`;
try {
const searchResult = await octokit.rest.search.issuesAndPullRequests({
q,
per_page: 50,
page: effectivePage,
});
const totalCount = searchResult.data.total_count;
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
const findRepoForSearchItem = (item) => {
const repositoryUrl = typeof item?.repository_url === 'string' ? item.repository_url : '';
const match = repositoryUrl.match(/\/repos\/([^/]+)\/([^/]+)$/);
if (!match) return reposToQuery[0];
return reposToQuery.find((repoRef) => repoRef.owner === match[1] && repoRef.repo === match[2]) || reposToQuery[0];
};
const prRefs = items
.map((item) => ({ number: item.number, repoRef: findRepoForSearchItem(item) }))
.filter((ref) => Number.isFinite(ref.number) && ref.number > 0 && ref.repoRef);
let prs;
if (prRefs.length === 0) {
prs = [];
} else {
const results = await Promise.all(prRefs.map(async ({ number, repoRef }) => {
try {
const pr = await octokit.rest.pulls.get({
owner: repoRef.owner,
repo: repoRef.repo,
pull_number: number,
});
return mapPrSummary(pr.data, repoRef);
} catch {
return null;
}
}));
prs = results.filter(Boolean);
}
const fetchedCount = (effectivePage - 1) * 50 + items.length;
const hasMore = fetchedCount < totalCount;
return res.json({ connected: true, repo, prs, page: effectivePage, hasMore });
} catch (error) {
console.error('Failed to search GitHub PRs:', error);
return res.json({ connected: true, repo, prs: [], page: effectivePage, hasMore: false });
}
}
const queryRepo = async (repoRef) => {
try {
const list = await octokit.rest.pulls.list({
owner: repoRef.owner,
repo: repoRef.repo,
state: 'open',
per_page: 50,
page: effectivePage,
});
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
const hasMore = /rel="next"/.test(link);
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => mapPrSummary(pr, repoRef));
return { prs, hasMore };
} catch (error) {
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
return { prs: [], hasMore: false };
}
};
const results = await Promise.all(reposToQuery.map(queryRepo));
const allPrs = results.flatMap((r) => r.prs);
const anyHasMore = results.some((r) => r.hasMore);
return res.json({ connected: true, repo, prs: allPrs, page: effectivePage, hasMore: anyHasMore });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to list GitHub pull requests:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitHub pull requests' });
}
});
app.get('/api/github/pulls/context', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true';
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, pr: null });
}
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const prData = prResp?.data;
if (!prData) {
return res.status(404).json({ error: 'PR not found' });
}
const headRepo = prData.head?.repo
? {
owner: prData.head.repo.owner?.login,
repo: prData.head.repo.name,
url: prData.head.repo.html_url,
cloneUrl: prData.head.repo.clone_url,
sshUrl: prData.head.repo.ssh_url,
}
: null;
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
const pr = {
number: prData.number,
title: prData.title,
url: prData.html_url,
state: mergedState,
draft: Boolean(prData.draft),
base: prData.base?.ref,
head: prData.head?.ref,
headSha: prData.head?.sha,
mergeable: prData.mergeable,
mergeableState: prData.mergeable_state,
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
headLabel: prData.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
body: prData.body || '',
createdAt: prData.created_at,
updatedAt: prData.updated_at,
};
const issueCommentsResp = await octokit.rest.issues.listComments({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
per_page: 100,
});
const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
per_page: 100,
});
const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
path: comment.path,
line: typeof comment.line === 'number' ? comment.line : null,
position: typeof comment.position === 'number' ? comment.position : null,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
const filesResp = await octokit.rest.pulls.listFiles({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
per_page: 100,
});
const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
filename: f.filename,
status: f.status,
additions: f.additions,
deletions: f.deletions,
changes: f.changes,
patch: f.patch,
}));
// checks summary (same logic as status endpoint)
let checks = null;
let checkRunsOut = undefined;
const sha = prData.head?.sha;
if (sha) {
try {
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
const checkRuns = 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,
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,
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),
}
: {}),
};
});
const counts = { success: 0, failure: 0, pending: 0 };
for (const run of checkRuns) {
const status = run?.status;
const conclusion = run?.conclusion;
if (status === 'queued' || status === 'in_progress') {
counts.pending += 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'));
checks = { state, total, ...counts };
}
} 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 : [];
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'));
checks = { state, total, ...counts };
} 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' });
}
});
}