- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle) - ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
2524 lines
98 KiB
JavaScript
2524 lines
98 KiB
JavaScript
const PR_STATUS_CACHE_TTL_MS = 90_000;
|
|
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
|
|
// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many
|
|
// serial GitHub API calls; under GitHub secondary-rate-limiting a single request
|
|
// can otherwise hang 20s+. We bound it so the route fails fast instead of holding
|
|
// the response (and a client socket) open — the client keeps its last-known
|
|
// status on error, and a later poll fills it in.
|
|
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
|
|
const prStatusCache = new Map();
|
|
let resolvedAuthLoginPromise = null;
|
|
const PR_CONTEXT_CACHE_TTL_MS = 30_000;
|
|
const PR_CONTEXT_CACHE_MAX_ENTRIES = 50;
|
|
const prContextCache = new Map();
|
|
// Route-level budget for single-call enrichment routes (pulls/commits,
|
|
// pulls/timeline). Octokit bounds each request at 8s; this caps the whole
|
|
// route so a slow upstream cannot hold a response (and a client socket) open.
|
|
const ROUTE_TIMEOUT_MS = 15_000;
|
|
|
|
function invalidatePrContextCache(directory, number) {
|
|
for (const key of prContextCache.keys()) {
|
|
try {
|
|
const [cachedDirectory, cachedNumber] = JSON.parse(key);
|
|
if (cachedDirectory === directory && (number == null || cachedNumber === number)) {
|
|
prContextCache.delete(key);
|
|
}
|
|
} catch {
|
|
prContextCache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Aggregate check runs into the summary shape shared by pr/status and
|
|
// pulls/context. Keeps `pending` as queued+in_progress+unconcluded for
|
|
// existing consumers while exposing the split and the earliest start time so
|
|
// the UI can show live "running for N minutes" state.
|
|
// A re-run leaves the previous completed check run in the listForRef payload
|
|
// alongside the new in-progress one. GitHub's UI shows only the latest run
|
|
// per (app, name); mirror that so counts match what users see on github.com.
|
|
function dedupeCheckRuns(checkRuns) {
|
|
const byName = new Map();
|
|
for (const run of checkRuns) {
|
|
const key = `${run?.app?.id ?? run?.app?.slug ?? ''}::${run?.name ?? ''}`;
|
|
const previous = byName.get(key);
|
|
if (!previous) {
|
|
byName.set(key, run);
|
|
continue;
|
|
}
|
|
const previousStartedAt = Date.parse(previous?.started_at || '') || 0;
|
|
const startedAt = Date.parse(run?.started_at || '') || 0;
|
|
if (startedAt > previousStartedAt
|
|
|| (startedAt === previousStartedAt && (run?.id ?? 0) > (previous?.id ?? 0))) {
|
|
byName.set(key, run);
|
|
}
|
|
}
|
|
return Array.from(byName.values());
|
|
}
|
|
|
|
function summarizeCheckRuns(checkRuns) {
|
|
const counts = { success: 0, failure: 0, pending: 0, inProgress: 0, queued: 0 };
|
|
let startedAt = null;
|
|
for (const run of checkRuns) {
|
|
const status = run?.status;
|
|
const conclusion = run?.conclusion;
|
|
if (status === 'in_progress') {
|
|
counts.pending += 1;
|
|
counts.inProgress += 1;
|
|
const runStartedAt = typeof run?.started_at === 'string' ? run.started_at : null;
|
|
if (runStartedAt && (!startedAt || runStartedAt < startedAt)) {
|
|
startedAt = runStartedAt;
|
|
}
|
|
continue;
|
|
}
|
|
if (status === 'queued') {
|
|
counts.pending += 1;
|
|
counts.queued += 1;
|
|
continue;
|
|
}
|
|
if (!conclusion) {
|
|
counts.pending += 1;
|
|
continue;
|
|
}
|
|
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
|
counts.success += 1;
|
|
} else {
|
|
counts.failure += 1;
|
|
}
|
|
}
|
|
const total = counts.success + counts.failure + counts.pending;
|
|
const state = counts.failure > 0
|
|
? 'failure'
|
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
return { state, total, ...counts, ...(startedAt ? { startedAt } : {}) };
|
|
}
|
|
|
|
function summarizeCombinedStatuses(statuses) {
|
|
const counts = { success: 0, failure: 0, pending: 0 };
|
|
statuses.forEach((s) => {
|
|
if (s.state === 'success') counts.success += 1;
|
|
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
|
else if (s.state === 'pending') counts.pending += 1;
|
|
});
|
|
const total = counts.success + counts.failure + counts.pending;
|
|
const state = counts.failure > 0
|
|
? 'failure'
|
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
return { state, total, ...counts, inProgress: counts.pending, queued: 0 };
|
|
}
|
|
|
|
function withTimeout(promise, timeoutMs, label) {
|
|
let timer;
|
|
const timeout = new Promise((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
error.code = 'ETIMEDOUT';
|
|
reject(error);
|
|
}, timeoutMs);
|
|
if (typeof timer.unref === 'function') timer.unref();
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
function getRequestedRepo(req) {
|
|
// GET routes carry owner/repo in the query string; write routes (POST/PATCH)
|
|
// carry them in the body. Accept both so the same resolver guards every route.
|
|
const owner = typeof req.body?.owner === 'string' && req.body.owner.trim()
|
|
? req.body.owner.trim()
|
|
: (typeof req.query?.owner === 'string' ? req.query.owner.trim() : '');
|
|
const repo = typeof req.body?.repo === 'string' && req.body.repo.trim()
|
|
? req.body.repo.trim()
|
|
: (typeof req.query?.repo === 'string' ? req.query.repo.trim() : '');
|
|
return owner && repo ? { owner, repo } : null;
|
|
}
|
|
|
|
async function resolveRepoForRequest(octokit, directory, requestedRepo) {
|
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
if (!requestedRepo) {
|
|
return repo;
|
|
}
|
|
if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) {
|
|
return requestedRepo;
|
|
}
|
|
|
|
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
|
const network = await resolveRepoNetwork(octokit, directory).catch(() => null);
|
|
const allowed = Array.isArray(network)
|
|
? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo)
|
|
: false;
|
|
return allowed ? requestedRepo : null;
|
|
}
|
|
|
|
// Shared mappers for PR/issue enrichment fields (labels/assignees/milestone/
|
|
// comments) used by pulls/list, pulls/context, and issues/get.
|
|
const mapGitHubUserSummary = (user) => (
|
|
user && typeof user === 'object'
|
|
? { login: user.login, id: user.id, avatarUrl: user.avatar_url }
|
|
: null
|
|
);
|
|
|
|
const mapGitHubLabels = (labels) => (
|
|
Array.isArray(labels)
|
|
? labels
|
|
.map((label) => {
|
|
if (typeof label === 'string') return null;
|
|
const name = typeof label?.name === 'string' ? label.name : '';
|
|
if (!name) return null;
|
|
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
|
})
|
|
.filter(Boolean)
|
|
: []
|
|
);
|
|
|
|
const mapGitHubMilestone = (milestone) => (
|
|
milestone && typeof milestone === 'object'
|
|
? {
|
|
title: typeof milestone.title === 'string' ? milestone.title : '',
|
|
...(typeof milestone.state === 'string' ? { state: milestone.state } : {}),
|
|
}
|
|
: null
|
|
);
|
|
|
|
const mapGitHubAssignees = (assignees) => (
|
|
Array.isArray(assignees) ? assignees.map(mapGitHubUserSummary).filter(Boolean) : []
|
|
);
|
|
|
|
function setPrStatusCache(key, data, fetchedAt) {
|
|
// Evict oldest entry when cache exceeds max size
|
|
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
|
|
const oldest = prStatusCache.entries().next().value;
|
|
if (oldest) {
|
|
prStatusCache.delete(oldest[0]);
|
|
}
|
|
}
|
|
prStatusCache.set(key, { data, fetchedAt });
|
|
}
|
|
|
|
export function registerGitHubRoutes(app) {
|
|
let githubLibraries = null;
|
|
const getGitHubLibraries = async () => {
|
|
if (!githubLibraries) {
|
|
githubLibraries = await import('./index.js');
|
|
}
|
|
return githubLibraries;
|
|
};
|
|
|
|
const getGitHubUserSummary = async (octokit) => {
|
|
const me = await octokit.rest.users.getAuthenticated();
|
|
|
|
let email = typeof me.data.email === 'string' ? me.data.email : null;
|
|
if (!email) {
|
|
try {
|
|
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
|
|
const list = Array.isArray(emails?.data) ? emails.data : [];
|
|
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
|
|
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
|
|
email = primaryVerified?.email || anyVerified?.email || null;
|
|
} catch {
|
|
// ignore (scope might be missing)
|
|
}
|
|
}
|
|
|
|
return {
|
|
login: me.data.login,
|
|
id: me.data.id,
|
|
avatarUrl: me.data.avatar_url,
|
|
name: typeof me.data.name === 'string' ? me.data.name : null,
|
|
email,
|
|
};
|
|
};
|
|
|
|
const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403;
|
|
const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404;
|
|
|
|
app.get('/api/github/auth/status', async (_req, res) => {
|
|
try {
|
|
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliActive, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
|
|
const { getGhCliToken } = await import('./gh-cli-credential.js');
|
|
|
|
const auth = getGitHubAuth();
|
|
let accounts = getGitHubAuthAccounts();
|
|
const ghCliDisabled = isGhCliDisabled();
|
|
const ghCliActive = isGhCliActive();
|
|
const ghToken = getGhCliToken();
|
|
const usingOwnToken = Boolean(auth?.accessToken);
|
|
let ghCliUser = null;
|
|
|
|
if (ghToken !== null && !ghCliDisabled) {
|
|
try {
|
|
const { createOctokit } = await import('./octokit.js');
|
|
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
|
|
} catch {
|
|
ghCliUser = null;
|
|
}
|
|
}
|
|
if (ghCliActive && !ghCliUser) {
|
|
setGhCliActive(false);
|
|
}
|
|
|
|
const ghCliCurrent = ghToken !== null && !ghCliDisabled && Boolean(ghCliUser) && (ghCliActive || !usingOwnToken);
|
|
if (ghCliUser) {
|
|
accounts = accounts
|
|
.map((account) => ({ ...account, current: ghCliCurrent ? false : Boolean(account.current) }))
|
|
.concat({
|
|
id: GH_CLI_ACCOUNT_ID,
|
|
user: ghCliUser,
|
|
current: ghCliCurrent,
|
|
source: 'gh-cli',
|
|
});
|
|
}
|
|
|
|
const buildGhCli = (activeUser = null) => ({
|
|
available: ghToken !== null,
|
|
disabled: ghCliDisabled,
|
|
active: ghCliCurrent,
|
|
...(!ghCliDisabled && (activeUser || ghCliUser) ? { user: activeUser || ghCliUser } : {}),
|
|
});
|
|
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.json({ connected: false, accounts, ghCli: buildGhCli() });
|
|
}
|
|
|
|
let user = null;
|
|
try {
|
|
user = await getGitHubUserSummary(octokit);
|
|
} catch (error) {
|
|
if (isGitHubAuthInvalid(error)) {
|
|
if (usingOwnToken) clearGitHubAuth();
|
|
return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() });
|
|
}
|
|
}
|
|
|
|
const fallback = usingOwnToken ? auth.user : null;
|
|
const mergedUser = user || fallback;
|
|
return res.json({
|
|
connected: true,
|
|
user: mergedUser,
|
|
scope: ghCliCurrent ? undefined : auth?.scope,
|
|
accounts,
|
|
ghCli: buildGhCli(ghCliCurrent ? mergedUser : null),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get GitHub auth status:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/auth/gh-cli', async (req, res) => {
|
|
try {
|
|
const { setGhCliDisabled, isGhCliDisabled } = await getGitHubLibraries();
|
|
const { clearGhCliTokenCache } = await import('./gh-cli-credential.js');
|
|
const disabled = Boolean(req.body?.disabled);
|
|
setGhCliDisabled(disabled);
|
|
clearGhCliTokenCache();
|
|
return res.json({ disabled: isGhCliDisabled() });
|
|
} catch (error) {
|
|
console.error('Failed to update gh CLI setting:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to update gh CLI setting' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/auth/start', async (_req, res) => {
|
|
try {
|
|
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
|
const clientId = getGitHubClientId();
|
|
if (!clientId) {
|
|
return res.status(400).json({
|
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
|
});
|
|
}
|
|
|
|
const scope = getGitHubScopes();
|
|
|
|
const payload = await startDeviceFlow({
|
|
clientId,
|
|
scope,
|
|
});
|
|
|
|
return res.json({
|
|
deviceCode: payload.device_code,
|
|
userCode: payload.user_code,
|
|
verificationUri: payload.verification_uri,
|
|
verificationUriComplete: payload.verification_uri_complete,
|
|
expiresIn: payload.expires_in,
|
|
interval: payload.interval,
|
|
scope,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to start GitHub device flow:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/auth/complete', async (req, res) => {
|
|
try {
|
|
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|
const clientId = getGitHubClientId();
|
|
if (!clientId) {
|
|
return res.status(400).json({
|
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
|
});
|
|
}
|
|
|
|
const deviceCode = typeof req.body?.deviceCode === 'string'
|
|
? req.body.deviceCode
|
|
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
|
|
|
|
if (!deviceCode) {
|
|
return res.status(400).json({ error: 'deviceCode is required' });
|
|
}
|
|
|
|
const payload = await exchangeDeviceCode({ clientId, deviceCode });
|
|
|
|
if (payload?.error) {
|
|
return res.json({
|
|
connected: false,
|
|
status: payload.error,
|
|
error: payload.error_description || payload.error,
|
|
});
|
|
}
|
|
|
|
const accessToken = payload?.access_token;
|
|
if (!accessToken) {
|
|
return res.status(500).json({ error: 'Missing access_token from GitHub' });
|
|
}
|
|
|
|
const { createOctokit } = await import('./octokit.js');
|
|
const octokit = createOctokit(accessToken);
|
|
const user = await getGitHubUserSummary(octokit);
|
|
|
|
setGitHubAuth({
|
|
accessToken,
|
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
|
|
user,
|
|
});
|
|
|
|
return res.json({
|
|
connected: true,
|
|
user,
|
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|
accounts: getGitHubAuthAccounts(),
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to complete GitHub device flow:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/auth/activate', async (req, res) => {
|
|
try {
|
|
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, GH_CLI_ACCOUNT_ID, isGhCliDisabled, setGhCliActive } = await getGitHubLibraries();
|
|
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
|
|
if (!accountId) {
|
|
return res.status(400).json({ error: 'accountId is required' });
|
|
}
|
|
if (accountId === GH_CLI_ACCOUNT_ID) {
|
|
const { getGhCliToken } = await import('./gh-cli-credential.js');
|
|
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
|
|
if (!ghToken) {
|
|
return res.status(404).json({ error: 'GitHub CLI account not found' });
|
|
}
|
|
|
|
const { createOctokit } = await import('./octokit.js');
|
|
const user = await getGitHubUserSummary(createOctokit(ghToken));
|
|
setGhCliActive(true);
|
|
const accounts = getGitHubAuthAccounts()
|
|
.map((account) => ({ ...account, current: false }))
|
|
.concat({ id: GH_CLI_ACCOUNT_ID, user, current: true, source: 'gh-cli' });
|
|
return res.json({
|
|
connected: true,
|
|
user,
|
|
accounts,
|
|
ghCli: {
|
|
available: true,
|
|
disabled: false,
|
|
active: true,
|
|
user,
|
|
},
|
|
});
|
|
}
|
|
|
|
const activated = activateGitHubAuth(accountId);
|
|
if (!activated) {
|
|
return res.status(404).json({ error: 'GitHub account not found' });
|
|
}
|
|
|
|
const auth = getGitHubAuth();
|
|
let accounts = getGitHubAuthAccounts();
|
|
if (!auth?.accessToken) {
|
|
return res.json({ connected: false, accounts });
|
|
}
|
|
|
|
const { getGhCliToken } = await import('./gh-cli-credential.js');
|
|
const ghCliDisabled = isGhCliDisabled();
|
|
const ghToken = !ghCliDisabled ? getGhCliToken() : null;
|
|
let ghCliUser = null;
|
|
if (ghToken) {
|
|
try {
|
|
const { createOctokit } = await import('./octokit.js');
|
|
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
|
|
accounts = accounts.concat({
|
|
id: GH_CLI_ACCOUNT_ID,
|
|
user: ghCliUser,
|
|
current: false,
|
|
source: 'gh-cli',
|
|
});
|
|
} catch {
|
|
ghCliUser = null;
|
|
}
|
|
}
|
|
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.json({ connected: false, accounts, ghCli: { available: ghToken !== null, disabled: ghCliDisabled, active: false, ...(ghCliUser ? { user: ghCliUser } : {}) } });
|
|
}
|
|
|
|
let user = auth.user || null;
|
|
try {
|
|
user = await getGitHubUserSummary(octokit);
|
|
} catch (error) {
|
|
if (isGitHubAuthInvalid(error)) {
|
|
clearGitHubAuth();
|
|
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
connected: true,
|
|
user,
|
|
scope: auth.scope,
|
|
accounts,
|
|
ghCli: {
|
|
available: ghToken !== null,
|
|
disabled: ghCliDisabled,
|
|
active: false,
|
|
...(ghCliUser ? { user: ghCliUser } : {}),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to activate GitHub account:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/github/auth', async (_req, res) => {
|
|
try {
|
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
|
const removed = clearGitHubAuth();
|
|
return res.json({ success: true, removed });
|
|
} catch (error) {
|
|
console.error('Failed to disconnect GitHub:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/github/me', async (_req, res) => {
|
|
try {
|
|
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|
}
|
|
let user;
|
|
try {
|
|
user = await getGitHubUserSummary(octokit);
|
|
} catch (error) {
|
|
if (isGitHubAuthInvalid(error)) {
|
|
clearGitHubAuth();
|
|
return res.status(401).json({ error: 'GitHub token expired or revoked' });
|
|
}
|
|
throw error;
|
|
}
|
|
return res.json(user);
|
|
} catch (error) {
|
|
console.error('Failed to fetch GitHub user:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
|
|
}
|
|
});
|
|
|
|
// ================= GitHub PR APIs =================
|
|
|
|
app.get('/api/github/pr/status', async (req, res) => {
|
|
try {
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
|
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
|
const force = req.query?.force === 'true' || req.query?.force === '1';
|
|
if (!directory || !branch) {
|
|
return res.status(400).json({ error: 'directory and branch are required' });
|
|
}
|
|
|
|
// Check cache (skip when force=true to allow manual refresh bypass)
|
|
const cacheKey = `${directory}::${branch}::${remote}`;
|
|
const cached = prStatusCache.get(cacheKey);
|
|
if (!force && cached && Date.now() - cached.fetchedAt < PR_STATUS_CACHE_TTL_MS) {
|
|
return res.json(cached.data);
|
|
}
|
|
|
|
// If GitHub recently rate-limited us, don't pile on more calls that will
|
|
// also fail. Serve whatever we last cached (even if stale); otherwise
|
|
// report a transient failure so the client keeps its last-known status.
|
|
const { isGitHubRateLimited } = await import('./rate-limit.js');
|
|
if (isGitHubRateLimited()) {
|
|
if (cached) {
|
|
return res.json(cached.data);
|
|
}
|
|
return res.status(503).json({ error: 'GitHub rate limited' });
|
|
}
|
|
|
|
// Intercept res.json to cache successful responses before sending
|
|
// Only caches responses with connected:true — error/edge-case responses are not cached
|
|
const originalJson = res.json.bind(res);
|
|
res.json = (data) => {
|
|
if (data && data.connected === true) {
|
|
// Freshness stamp travels with the payload (and survives cache
|
|
// serves) so clients can refuse to overwrite newer data with a
|
|
// stale cached response.
|
|
if (typeof data.fetchedAt !== 'number') {
|
|
data.fetchedAt = Date.now();
|
|
}
|
|
setPrStatusCache(cacheKey, data, data.fetchedAt);
|
|
}
|
|
return originalJson(data);
|
|
};
|
|
|
|
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.json({ connected: false });
|
|
}
|
|
|
|
const { resolveGitHubPrStatus } = await import('./pr-status.js');
|
|
const resolvedStatus = await withTimeout(
|
|
resolveGitHubPrStatus({
|
|
octokit,
|
|
directory,
|
|
branch,
|
|
remoteName: remote,
|
|
force,
|
|
}),
|
|
PR_STATUS_RESOLVE_TIMEOUT_MS,
|
|
'resolveGitHubPrStatus',
|
|
);
|
|
const searchRepo = resolvedStatus.repo;
|
|
const first = resolvedStatus.pr;
|
|
if (!searchRepo) {
|
|
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null });
|
|
}
|
|
if (!first) {
|
|
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null });
|
|
}
|
|
|
|
// Enrich with mergeability fields
|
|
const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number });
|
|
const prData = prFull?.data;
|
|
if (!prData) {
|
|
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false });
|
|
}
|
|
|
|
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();
|
|
if (!octokit) {
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|
}
|
|
|
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
let repo;
|
|
if (targetRepo) {
|
|
repo = targetRepo;
|
|
} else {
|
|
const resolved = await resolveGitHubRepoFromDirectory(directory, remote);
|
|
repo = resolved.repo;
|
|
}
|
|
if (!repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
}
|
|
|
|
const normalizeBranchRef = (value, remoteNames = new Set()) => {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
let normalized = value.trim();
|
|
if (normalized.startsWith('refs/heads/')) {
|
|
normalized = normalized.substring('refs/heads/'.length);
|
|
}
|
|
if (normalized.startsWith('heads/')) {
|
|
normalized = normalized.substring('heads/'.length);
|
|
}
|
|
if (normalized.startsWith('remotes/')) {
|
|
normalized = normalized.substring('remotes/'.length);
|
|
}
|
|
|
|
const slashIndex = normalized.indexOf('/');
|
|
if (slashIndex > 0) {
|
|
const maybeRemote = normalized.slice(0, slashIndex);
|
|
if (remoteNames.has(maybeRemote)) {
|
|
const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim();
|
|
if (withoutRemotePrefix) {
|
|
normalized = withoutRemotePrefix;
|
|
}
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
// Determine the source remote for the head branch
|
|
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
|
|
let sourceRemote = headRemote;
|
|
const { getStatus, getRemotes } = await import('../git/index.js');
|
|
|
|
// If no explicit headRemote, check the branch's tracking info
|
|
if (!sourceRemote) {
|
|
const status = await getStatus(directory).catch(() => null);
|
|
if (status?.tracking) {
|
|
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
|
|
const trackingRemote = status.tracking.split('/')[0];
|
|
if (trackingRemote) {
|
|
sourceRemote = trackingRemote;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: if targeting non-origin and no tracking info, try 'origin'
|
|
if (!sourceRemote && remote !== 'origin') {
|
|
sourceRemote = 'origin';
|
|
}
|
|
|
|
const remoteNames = new Set([remote]);
|
|
const remotes = await getRemotes(directory).catch(() => []);
|
|
for (const item of remotes) {
|
|
if (item?.name) {
|
|
remoteNames.add(item.name);
|
|
}
|
|
}
|
|
if (sourceRemote) {
|
|
remoteNames.add(sourceRemote);
|
|
}
|
|
|
|
const base = normalizeBranchRef(requestedBase, remoteNames);
|
|
if (!base) {
|
|
return res.status(400).json({ error: 'Invalid base branch name' });
|
|
}
|
|
|
|
// For fork workflows: we need to determine the correct head reference
|
|
let headRef = head;
|
|
let headRepo = null;
|
|
|
|
if (sourceRemote) {
|
|
// The branch is on a different remote than the target - this is a cross-repo PR
|
|
const resolved = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
|
|
headRepo = resolved.repo;
|
|
if (!headRepo) {
|
|
return res.status(400).json({
|
|
error: `Cannot resolve GitHub repo for remote "${sourceRemote}". Check that the remote URL is a valid GitHub repository.`,
|
|
});
|
|
}
|
|
// Always use owner:branch format for cross-repo PRs
|
|
// GitHub API requires this when head is from a different repo/fork
|
|
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
|
|
headRef = `${headRepo.owner}:${head}`;
|
|
}
|
|
}
|
|
|
|
// For cross-repo PRs, verify the branch exists on the head repo first
|
|
if (headRef.includes(':')) {
|
|
const [headOwner] = headRef.split(':');
|
|
const headRepoName = headRepo?.repo || repo.repo;
|
|
|
|
if (headRepoName) {
|
|
try {
|
|
await octokit.rest.repos.getBranch({
|
|
owner: headOwner,
|
|
repo: headRepoName,
|
|
branch: head,
|
|
});
|
|
} catch (branchError) {
|
|
if (branchError?.status === 404) {
|
|
return res.status(400).json({
|
|
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
|
|
});
|
|
}
|
|
// For other errors, continue - let the PR create attempt handle it
|
|
}
|
|
}
|
|
}
|
|
|
|
const created = await octokit.rest.pulls.create({
|
|
owner: repo.owner,
|
|
repo: repo.repo,
|
|
title,
|
|
head: headRef,
|
|
base,
|
|
...(typeof body === 'string' ? { body } : {}),
|
|
...(typeof draft === 'boolean' ? { draft } : {}),
|
|
});
|
|
|
|
const pr = created?.data;
|
|
if (!pr) {
|
|
return res.status(500).json({ error: 'Failed to create PR' });
|
|
}
|
|
|
|
// Invalidate PR status cache so subsequent prStatus calls fetch fresh data
|
|
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
|
|
const createCacheKey = `${directory}::${headBranch}::${remote}`;
|
|
prStatusCache.delete(createCacheKey);
|
|
if (repo?.owner && repo?.repo) {
|
|
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
|
invalidateRepoPullsCache(repo.owner, repo.repo);
|
|
}
|
|
|
|
return res.json({
|
|
number: pr.number,
|
|
title: pr.title,
|
|
body: pr.body || '',
|
|
url: pr.html_url,
|
|
state: pr.state === 'closed' ? 'closed' : 'open',
|
|
draft: Boolean(pr.draft),
|
|
base: pr.base?.ref,
|
|
head: pr.head?.ref,
|
|
headSha: pr.head?.sha,
|
|
mergeable: pr.mergeable,
|
|
mergeableState: pr.mergeable_state,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create GitHub PR:', error);
|
|
|
|
// Check for head validation error (common with fork PRs)
|
|
const errorMessage = error.message || '';
|
|
const isHeadValidationError =
|
|
errorMessage.includes('Validation Failed') &&
|
|
errorMessage.includes('"field":"head"') &&
|
|
errorMessage.includes('"code":"invalid"');
|
|
|
|
if (isHeadValidationError) {
|
|
return res.status(400).json({
|
|
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
|
|
});
|
|
}
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/pr/update', async (req, res) => {
|
|
try {
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
|
if (!directory || !number || !title) {
|
|
return res.status(400).json({ error: 'directory, number, title are required' });
|
|
}
|
|
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|
}
|
|
|
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
}
|
|
|
|
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();
|
|
if (!octokit) {
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|
}
|
|
|
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
}
|
|
|
|
try {
|
|
const result = await octokit.rest.pulls.merge({
|
|
owner: repo.owner,
|
|
repo: repo.repo,
|
|
pull_number: number,
|
|
merge_method: method,
|
|
});
|
|
invalidatePrContextCache(directory, number);
|
|
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
|
invalidateRepoPullsCache(repo.owner, repo.repo);
|
|
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
|
|
} catch (error) {
|
|
if (error?.status === 403) {
|
|
return res.status(403).json({ error: 'Not authorized to merge this PR' });
|
|
}
|
|
if (error?.status === 405 || error?.status === 409) {
|
|
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
|
|
}
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to merge GitHub PR:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/github/pr/ready', async (req, res) => {
|
|
try {
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
if (!directory || !number) {
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|
}
|
|
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
const octokit = getOctokitOrNull();
|
|
if (!octokit) {
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|
}
|
|
|
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
}
|
|
|
|
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
|
const nodeId = pr?.data?.node_id;
|
|
if (!nodeId) {
|
|
return res.status(500).json({ error: 'Failed to resolve PR node id' });
|
|
}
|
|
|
|
if (pr?.data?.draft === false) {
|
|
return res.json({ ready: true });
|
|
}
|
|
|
|
try {
|
|
await octokit.graphql(
|
|
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
|
|
{ pullRequestId: nodeId }
|
|
);
|
|
} catch (error) {
|
|
if (error?.status === 403) {
|
|
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
invalidatePrContextCache(directory, number);
|
|
{
|
|
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
|
invalidateRepoPullsCache(repo.owner, repo.repo);
|
|
}
|
|
return res.json({ ready: true });
|
|
} catch (error) {
|
|
console.error('Failed to mark PR ready:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
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();
|
|
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();
|
|
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();
|
|
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)
|
|
: [],
|
|
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();
|
|
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();
|
|
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.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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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' });
|
|
}
|
|
});
|
|
}
|