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:
Islam Nofl
2026-04-29 12:03:39 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 17650becc0
commit 21253d7fc2
27 changed files with 1042 additions and 296 deletions
+19 -12
View File
@@ -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);
+51 -29
View File
@@ -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;
}
+299 -88
View File
@@ -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 });
}