feat: fork-aware issue/PR listing & OpenCode startup loading indicator (#1061)
* Add design spec: OpenCode readiness loading indicator * Add implementation plan: OpenCode readiness loading indicator * feat: add useOpenCodeReadiness hook * feat: add i18n keys for common.loading * feat: add loading state to ModelSelector * feat: add loading state to AgentSelector * feat: add loading state to ModelControls chat selectors * update package-lock * feat(github): add shared fork detection utility * feat(github): make issue listing fork-aware * feat(github): make PR listing fork-aware * feat(types): add sourceRepo to issue/PR summary types * feat(ui): add source badges to GitHub integration dialog * feat(ui): add source badges to issue/PR picker dialogs * feat(github): pass headRemote in PR creation for fork support * feat(ui): add source→target label in PR tab for fork workflows * fix(github): allow PR section on base branch when upstream remote exists * fix(github): show PR section on any branch including main for fork→upstream PRs * fix(github): allow PullRequestSection to render on base branch when upstream remote exists * feat(github): auto-detect upstream repo for fork→upstream PR creation - Add GET /api/github/repo/upstream endpoint to discover fork's upstream - Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork - Add virtual upstream target in remote dropdown (no explicit upstream remote needed) - Add targetRepo parameter to /api/github/pr/create for direct upstream targeting - Add repoUpstream() API client method and GitHubRepoUpstreamResult type * feat(github): auto-detect upstream repo for fork→upstream PR creation - Add GET /api/github/repo/upstream endpoint to discover fork's upstream - Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork - Add virtual upstream target in remote dropdown (no explicit upstream remote needed) - Add targetRepo parameter to /api/github/pr/create for direct upstream targeting - Add repoUpstream() API client method and GitHubRepoUpstreamResult type * fix: complete fork→upstream PR workflow - Server: return defaultBranch from /api/github/repo/upstream endpoint - Server: fix cross-repo head ref construction (compare repos, not remote names) - Server: filterActiveRemoteBranches checks all remotes, not just origin - UI: set targetBaseBranch to upstream's default branch when using detected upstream - UI: include all remote branches in base branch dropdown when using detected upstream - UI: skip base===head check for cross-repo PRs (same branch name on different repos is valid) - Types: add defaultBranch to GitHubRepoUpstreamResult * chore: delete superpowers folder * feat: add (local)/(remote) labels to PR branch display and adapt Repository button to selected remote * feat: Repository button adapts to selected remote (upstream vs origin) * fix: complete fork→upstream PR feature gaps Server: - Extend /api/github/repo/upstream to return defaultBranchSha and remoteName - Reuse headRepo result instead of redundant resolveGitHubRepoFromDirectory call - Return clear error when headRepo is null (invalid GitHub URL) UI: - Add upstream's default branch to availableBaseBranches when using detected upstream - Use upstream's default branch SHA in git log for generate description (fixes 'No commits found in range main...main') - Show qualified names (owner/repo · branch) in base branch dropdown when using detected upstream Types: - Add defaultBranchSha and remoteName to GitHubRepoUpstreamResult * fix: move detectedUpstream state before availableBaseBranches to fix temporal dead zone * fix: fetch upstream branches from GitHub API for base branch dropdown - Add GET /api/github/repo/branches endpoint to fetch branches via Octokit - Add repoBranches() to GitHub API client and interface - Fetch upstream branches on detection and store in upstreamBranches state - Include upstreamBranches in availableBaseBranches when using detected upstream - Re-add availableBaseBranches memo and auto-correction effect that were lost - Remove unnecessary qualified names from dropdown (upstream is already selected) * fix: restore prStatusKey and statusEntry declarations lost during refactor * fix: cleanly re-apply all fork→upstream PR UI changes Restored PullRequestSection.tsx from clean base and re-applied: - Expand detectedUpstream type with defaultBranch, defaultBranchSha, remoteName - Add upstreamBranches state and fetch on upstream detection - Include upstream branches in availableBaseBranches when using detected upstream - Use upstream default branch SHA in generate description (fixes 'No commits found') - Adapt Repository button URL to selected remote - Add (local)/(remote)/(upstream) labels to branch display * fix: move detectedUpstream/upstreamBranches before availableBaseBranches to fix TDZ * style: add pill badge styling to upstream repo source labels * fix: don't cache error PR status responses, allow force-bypass of server cache * fix: resolve PR status cache bugs, stale directory fallback, and upstream re-detection * fix: keep collapse button visible when scrolling long user messages - Collapse button now sticks to top of scrollable user message content instead of scrolling away * fix: checkbox focus ring blends into sidebar background * fix: polish fork PR follow-ups * fix: remove user message collapse artifact * fix: tighten fork PR internals * fix: check all remotes for fork PR status * fix: recover sidebar PR status misses --------- Signed-off-by: Islam Nofl <islamnofl.official@gmail.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
17650becc0
commit
21253d7fc2
@@ -2084,25 +2084,32 @@ export async function getBranches(directory) {
|
||||
|
||||
async function filterActiveRemoteBranches(git, remoteBranches) {
|
||||
try {
|
||||
const remotes = await git.getRemotes();
|
||||
const branchesByRemote = new Map();
|
||||
|
||||
const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']);
|
||||
const actualRemoteBranches = new Set();
|
||||
|
||||
const lines = lsRemoteResult.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes('\trefs/heads/')) {
|
||||
const branchName = line.split('\t')[1].replace('refs/heads/', '');
|
||||
actualRemoteBranches.add(branchName);
|
||||
await Promise.all(remotes.map(async (remote) => {
|
||||
try {
|
||||
const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
|
||||
const actualRemoteBranches = new Set();
|
||||
const lines = lsRemoteResult.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes('\trefs/heads/')) {
|
||||
const branchName = line.split('\t')[1].replace('refs/heads/', '');
|
||||
actualRemoteBranches.add(branchName);
|
||||
}
|
||||
}
|
||||
branchesByRemote.set(remote.name, actualRemoteBranches);
|
||||
} catch {
|
||||
// Skip remotes that fail (e.g., unreachable)
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return remoteBranches.filter(remoteBranch => {
|
||||
|
||||
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const remoteName = remoteBranch.split('/')[1];
|
||||
const branchName = match[1];
|
||||
return actualRemoteBranches.has(branchName);
|
||||
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to filter active remote branches, returning all:', error.message);
|
||||
|
||||
@@ -27,6 +27,18 @@ const parseTrackingRemoteName = (trackingBranch) => {
|
||||
return normalized.slice(0, slashIndex).trim();
|
||||
};
|
||||
|
||||
const parseTrackingBranchName = (trackingBranch) => {
|
||||
const normalized = normalizeText(trackingBranch);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const slashIndex = normalized.indexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex >= normalized.length - 1) {
|
||||
return '';
|
||||
}
|
||||
return normalized.slice(slashIndex + 1).trim();
|
||||
};
|
||||
|
||||
const pushUnique = (collection, value, keyFn = normalizeLower) => {
|
||||
const normalizedValue = normalizeText(value);
|
||||
if (!normalizedValue) {
|
||||
@@ -421,13 +433,17 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
||||
]);
|
||||
|
||||
const trackingRemoteName = parseTrackingRemoteName(status?.tracking);
|
||||
const trackingBranchName = parseTrackingBranchName(status?.tracking);
|
||||
const branchCandidates = [];
|
||||
pushUnique(branchCandidates, normalizedBranch);
|
||||
pushUnique(branchCandidates, trackingBranchName);
|
||||
const rankedRemoteNames = rankRemoteNames(
|
||||
Array.isArray(remotes) ? remotes.map((remote) => remote?.name).filter(Boolean) : [],
|
||||
normalizedRemoteName,
|
||||
trackingRemoteName,
|
||||
);
|
||||
|
||||
const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames.slice(0, 3));
|
||||
const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames);
|
||||
const resolvedTargets = await expandRepoNetwork(
|
||||
octokit,
|
||||
resolvedRemoteTargets.map((target, index) => ({ ...target, priority: index })),
|
||||
@@ -454,38 +470,44 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
||||
fallbackRemoteName = target.remoteName;
|
||||
fallbackDefaultBranch = defaultBranch;
|
||||
}
|
||||
if (defaultBranch && defaultBranch === normalizedBranch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pr = await findFirstMatchingPr({
|
||||
octokit,
|
||||
target,
|
||||
branch: normalizedBranch,
|
||||
sourceCandidates,
|
||||
});
|
||||
if (pr) {
|
||||
return {
|
||||
repo: target.repo,
|
||||
pr,
|
||||
defaultBranch,
|
||||
resolvedRemoteName: target.remoteName,
|
||||
};
|
||||
const hasCrossRepoSource = sourceCandidates.some((candidate) => normalizeRepoKey(candidate.repo?.owner, candidate.repo?.repo) !== normalizeRepoKey(target.repo?.owner, target.repo?.repo));
|
||||
for (const candidateBranch of branchCandidates) {
|
||||
if (defaultBranch && defaultBranch === candidateBranch && !hasCrossRepoSource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pr = await findFirstMatchingPr({
|
||||
octokit,
|
||||
target,
|
||||
branch: candidateBranch,
|
||||
sourceCandidates,
|
||||
});
|
||||
if (pr) {
|
||||
return {
|
||||
repo: target.repo,
|
||||
pr,
|
||||
defaultBranch,
|
||||
resolvedRemoteName: target.remoteName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSearch = await searchFallbackPr({
|
||||
octokit,
|
||||
branch: normalizedBranch,
|
||||
repoNames: resolvedTargets.map((target) => target.repo.repo),
|
||||
});
|
||||
if (fallbackSearch) {
|
||||
return {
|
||||
repo: fallbackSearch.repo,
|
||||
pr: fallbackSearch.pr,
|
||||
defaultBranch: await getRepoDefaultBranch(octokit, fallbackSearch.repo),
|
||||
resolvedRemoteName: null,
|
||||
};
|
||||
for (const candidateBranch of branchCandidates) {
|
||||
const fallbackSearch = await searchFallbackPr({
|
||||
octokit,
|
||||
branch: candidateBranch,
|
||||
repoNames: resolvedTargets.map((target) => target.repo.repo),
|
||||
});
|
||||
if (fallbackSearch) {
|
||||
return {
|
||||
repo: fallbackSearch.repo,
|
||||
pr: fallbackSearch.pr,
|
||||
defaultBranch: await getRepoDefaultBranch(octokit, fallbackSearch.repo),
|
||||
resolvedRemoteName: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { resolveGitHubRepoFromDirectory } from './index.js';
|
||||
|
||||
const REPO_METADATA_TTL_MS = 5 * 60_000;
|
||||
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
|
||||
const repoMetadataCache = new Map();
|
||||
|
||||
const setRepoMetadataCache = (repoKey, data) => {
|
||||
if (repoMetadataCache.size >= REPO_METADATA_CACHE_MAX_ENTRIES && !repoMetadataCache.has(repoKey)) {
|
||||
const oldest = repoMetadataCache.entries().next().value;
|
||||
if (oldest) {
|
||||
repoMetadataCache.delete(oldest[0]);
|
||||
}
|
||||
}
|
||||
repoMetadataCache.set(repoKey, { data, fetchedAt: Date.now() });
|
||||
};
|
||||
|
||||
const normalizeRepoKey = (owner, repo) => {
|
||||
const o = typeof owner === 'string' ? owner.trim().toLowerCase() : '';
|
||||
const r = typeof repo === 'string' ? repo.trim().toLowerCase() : '';
|
||||
if (!o || !r) return '';
|
||||
return `${o}/${r}`;
|
||||
};
|
||||
|
||||
const getRepoMetadata = async (octokit, repo) => {
|
||||
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
|
||||
if (!repoKey) return null;
|
||||
|
||||
const cached = repoMetadataCache.get(repoKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < REPO_METADATA_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await octokit.rest.repos.get({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
});
|
||||
const data = response?.data ?? null;
|
||||
setRepoMetadataCache(repoKey, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error?.status === 403 || error?.status === 404) {
|
||||
setRepoMetadataCache(repoKey, null);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the repo network for a directory. If the origin repo is a fork,
|
||||
* includes the parent/source (upstream) repo in the result.
|
||||
*
|
||||
* @param {import('@octokit/rest').Octokit} octokit
|
||||
* @param {string} directory
|
||||
* @param {string} [remoteName='origin']
|
||||
* @returns {Promise<Array<{ owner: string, repo: string, url: string, source: string }> | null>}
|
||||
* Array of repos to query (origin first, then upstream), or null if not a fork.
|
||||
*/
|
||||
export async function resolveRepoNetwork(octokit, directory, remoteName = 'origin') {
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null }));
|
||||
if (!repo) return null;
|
||||
|
||||
const metadata = await getRepoMetadata(octokit, repo);
|
||||
if (!metadata) return [{ ...repo, source: 'origin' }];
|
||||
|
||||
const result = [{ ...repo, source: 'origin' }];
|
||||
const seenKeys = new Set([normalizeRepoKey(repo.owner, repo.repo)]);
|
||||
|
||||
const parent = metadata?.parent;
|
||||
if (parent?.owner?.login && parent?.name) {
|
||||
const key = normalizeRepoKey(parent.owner.login, parent.name);
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
result.push({
|
||||
owner: parent.owner.login,
|
||||
repo: parent.name,
|
||||
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const source = metadata?.source;
|
||||
if (source?.owner?.login && source?.name) {
|
||||
const key = normalizeRepoKey(source.owner.login, source.name);
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
result.push({
|
||||
owner: source.owner.login,
|
||||
repo: source.name,
|
||||
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no parent/source found, repo is not a fork
|
||||
if (result.length === 1) return null;
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,3 +1,42 @@
|
||||
const PR_STATUS_CACHE_TTL_MS = 90_000;
|
||||
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
|
||||
const prStatusCache = new Map();
|
||||
|
||||
function getRequestedRepo(req) {
|
||||
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
|
||||
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
|
||||
return owner && repo ? { owner, repo } : null;
|
||||
}
|
||||
|
||||
async function resolveRepoForRequest(octokit, directory, requestedRepo) {
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
if (!requestedRepo) {
|
||||
return repo;
|
||||
}
|
||||
if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) {
|
||||
return requestedRepo;
|
||||
}
|
||||
|
||||
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
||||
const network = await resolveRepoNetwork(octokit, directory).catch(() => null);
|
||||
const allowed = Array.isArray(network)
|
||||
? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo)
|
||||
: false;
|
||||
return allowed ? requestedRepo : null;
|
||||
}
|
||||
|
||||
function setPrStatusCache(key, data, fetchedAt) {
|
||||
// Evict oldest entry when cache exceeds max size
|
||||
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
|
||||
const oldest = prStatusCache.entries().next().value;
|
||||
if (oldest) {
|
||||
prStatusCache.delete(oldest[0]);
|
||||
}
|
||||
}
|
||||
prStatusCache.set(key, { data, fetchedAt });
|
||||
}
|
||||
|
||||
export function registerGitHubRoutes(app) {
|
||||
let githubLibraries = null;
|
||||
const getGitHubLibraries = async () => {
|
||||
@@ -249,10 +288,28 @@ export function registerGitHubRoutes(app) {
|
||||
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
||||
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
||||
const force = req.query?.force === 'true' || req.query?.force === '1';
|
||||
if (!directory || !branch) {
|
||||
return res.status(400).json({ error: 'directory and branch are required' });
|
||||
}
|
||||
|
||||
// Check cache (skip when force=true to allow manual refresh bypass)
|
||||
const cacheKey = `${directory}::${branch}::${remote}`;
|
||||
const cached = prStatusCache.get(cacheKey);
|
||||
if (!force && cached && Date.now() - cached.fetchedAt < PR_STATUS_CACHE_TTL_MS) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
// Intercept res.json to cache successful responses before sending
|
||||
// Only caches responses with connected:true — error/edge-case responses are not cached
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (data) => {
|
||||
if (data && data.connected === true) {
|
||||
setPrStatusCache(cacheKey, data, Date.now());
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
@@ -426,6 +483,10 @@ export function registerGitHubRoutes(app) {
|
||||
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' });
|
||||
}
|
||||
@@ -437,7 +498,13 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory, remote);
|
||||
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' });
|
||||
}
|
||||
@@ -511,25 +578,28 @@ export function registerGitHubRoutes(app) {
|
||||
|
||||
// For fork workflows: we need to determine the correct head reference
|
||||
let headRef = head;
|
||||
let headRepo = null;
|
||||
|
||||
if (sourceRemote && sourceRemote !== remote) {
|
||||
if (sourceRemote) {
|
||||
// The branch is on a different remote than the target - this is a cross-repo PR
|
||||
const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
|
||||
if (headRepo) {
|
||||
// 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}`;
|
||||
}
|
||||
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 = sourceRemote
|
||||
? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo
|
||||
: repo.repo;
|
||||
const headRepoName = headRepo?.repo || repo.repo;
|
||||
|
||||
if (headRepoName) {
|
||||
try {
|
||||
@@ -564,6 +634,11 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(500).json({ error: 'Failed to create PR' });
|
||||
}
|
||||
|
||||
// Invalidate PR status cache so subsequent prStatus calls fetch fresh data
|
||||
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
|
||||
const createCacheKey = `${directory}::${headBranch}::${remote}`;
|
||||
prStatusCache.delete(createCacheKey);
|
||||
|
||||
return res.json({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
@@ -766,6 +841,106 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
// ================= 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 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) => {
|
||||
@@ -783,41 +958,60 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
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 list = await octokit.rest.issues.listForRepo({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state: 'open',
|
||||
per_page: 50,
|
||||
page: Number.isFinite(page) && page > 0 ? page : 1,
|
||||
});
|
||||
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) => ({
|
||||
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)
|
||||
: [],
|
||||
}));
|
||||
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
||||
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
||||
|
||||
return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
|
||||
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) => ({
|
||||
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 },
|
||||
}));
|
||||
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' });
|
||||
@@ -838,8 +1032,8 @@ export function registerGitHubRoutes(app) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, issue: null });
|
||||
}
|
||||
@@ -899,8 +1093,8 @@ export function registerGitHubRoutes(app) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, comments: [] });
|
||||
}
|
||||
@@ -945,61 +1139,78 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
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 list = await octokit.rest.pulls.list({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state: 'open',
|
||||
per_page: 50,
|
||||
page: Number.isFinite(page) && page > 0 ? page : 1,
|
||||
});
|
||||
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
||||
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
||||
|
||||
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
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) => {
|
||||
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
||||
const headRepo = pr.head?.repo
|
||||
? {
|
||||
owner: pr.head.repo.owner?.login,
|
||||
repo: pr.head.repo.name,
|
||||
url: pr.head.repo.html_url,
|
||||
cloneUrl: pr.head.repo.clone_url,
|
||||
sshUrl: pr.head.repo.ssh_url,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
state: mergedState,
|
||||
draft: Boolean(pr.draft),
|
||||
base: pr.base?.ref,
|
||||
head: pr.head?.ref,
|
||||
headSha: pr.head?.sha,
|
||||
mergeable: pr.mergeable,
|
||||
mergeableState: pr.mergeable_state,
|
||||
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
||||
headLabel: pr.head?.label,
|
||||
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
||||
? headRepo
|
||||
: null,
|
||||
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
||||
};
|
||||
});
|
||||
return { prs, hasMore };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
||||
return { prs: [], hasMore: false };
|
||||
}
|
||||
};
|
||||
|
||||
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
|
||||
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
||||
const headRepo = pr.head?.repo
|
||||
? {
|
||||
owner: pr.head.repo.owner?.login,
|
||||
repo: pr.head.repo.name,
|
||||
url: pr.head.repo.html_url,
|
||||
cloneUrl: pr.head.repo.clone_url,
|
||||
sshUrl: pr.head.repo.ssh_url,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
state: mergedState,
|
||||
draft: Boolean(pr.draft),
|
||||
base: pr.base?.ref,
|
||||
head: pr.head?.ref,
|
||||
headSha: pr.head?.sha,
|
||||
mergeable: pr.mergeable,
|
||||
mergeableState: pr.mergeable_state,
|
||||
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
||||
headLabel: pr.head?.label,
|
||||
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
||||
? headRepo
|
||||
: null,
|
||||
};
|
||||
});
|
||||
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, page: Number.isFinite(page) && page > 0 ? page : 1, 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 PRs:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' });
|
||||
console.error('Failed to list GitHub pull requests:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to list GitHub pull requests' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1019,8 +1230,8 @@ export function registerGitHubRoutes(app) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, pr: null });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user