Files
openchamber/packages/web/server/lib/github/pr-status.js
T
Islam NoflandBohdan Triapitsyn 21253d7fc2 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>
2026-04-29 12:03:39 +03:00

520 lines
15 KiB
JavaScript

import { getRemotes, getStatus } from '../git/index.js';
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000;
const defaultBranchCache = new Map();
const repoMetadataCache = new Map();
const normalizeText = (value) => typeof value === 'string' ? value.trim() : '';
const normalizeLower = (value) => normalizeText(value).toLowerCase();
const normalizeRepoKey = (owner, repo) => {
const normalizedOwner = normalizeLower(owner);
const normalizedRepo = normalizeLower(repo);
if (!normalizedOwner || !normalizedRepo) {
return '';
}
return `${normalizedOwner}/${normalizedRepo}`;
};
const parseTrackingRemoteName = (trackingBranch) => {
const normalized = normalizeText(trackingBranch);
if (!normalized) {
return '';
}
const slashIndex = normalized.indexOf('/');
if (slashIndex <= 0) {
return '';
}
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) {
return;
}
const nextKey = keyFn(normalizedValue);
if (!nextKey) {
return;
}
if (collection.some((item) => keyFn(item) === nextKey)) {
return;
}
collection.push(normalizedValue);
};
const rankRemoteNames = (remoteNames, explicitRemoteName, trackingRemoteName) => {
const ranked = [];
pushUnique(ranked, explicitRemoteName);
if (trackingRemoteName) {
pushUnique(ranked, trackingRemoteName);
}
pushUnique(ranked, 'origin');
pushUnique(ranked, 'upstream');
remoteNames.forEach((name) => pushUnique(ranked, name));
return ranked;
};
const getHeadOwner = (pr) => {
const repoOwner = normalizeText(pr?.head?.repo?.owner?.login);
if (repoOwner) {
return repoOwner;
}
const userOwner = normalizeText(pr?.head?.user?.login);
if (userOwner) {
return userOwner;
}
const headLabel = normalizeText(pr?.head?.label);
const separatorIndex = headLabel.indexOf(':');
if (separatorIndex > 0) {
return headLabel.slice(0, separatorIndex).trim();
}
return '';
};
const getHeadRepoKey = (pr, fallbackRepoName) => {
const repoOwner = normalizeText(pr?.head?.repo?.owner?.login);
const repoName = normalizeText(pr?.head?.repo?.name);
if (repoOwner && repoName) {
return normalizeRepoKey(repoOwner, repoName);
}
const headLabel = normalizeText(pr?.head?.label);
const separatorIndex = headLabel.indexOf(':');
if (separatorIndex > 0) {
const labelOwner = headLabel.slice(0, separatorIndex).trim();
if (labelOwner && fallbackRepoName) {
return normalizeRepoKey(labelOwner, fallbackRepoName);
}
}
return '';
};
const buildSourceMatcher = (sourceCandidates) => {
const repoRank = new Map();
const ownerRank = new Map();
sourceCandidates.forEach((candidate, index) => {
const repoKey = normalizeRepoKey(candidate.repo?.owner, candidate.repo?.repo);
if (repoKey && !repoRank.has(repoKey)) {
repoRank.set(repoKey, index);
}
const owner = normalizeLower(candidate.repo?.owner);
if (owner && !ownerRank.has(owner)) {
ownerRank.set(owner, index);
}
});
const matches = (pr, fallbackRepoName) => {
const repoKey = getHeadRepoKey(pr, fallbackRepoName);
if (repoKey && repoRank.has(repoKey)) {
return true;
}
const owner = normalizeLower(getHeadOwner(pr));
return Boolean(owner) && ownerRank.has(owner);
};
const compare = (left, right, fallbackRepoName) => {
const leftRepoRank = repoRank.get(getHeadRepoKey(left, fallbackRepoName));
const rightRepoRank = repoRank.get(getHeadRepoKey(right, fallbackRepoName));
const leftRepoScore = typeof leftRepoRank === 'number' ? leftRepoRank : Number.POSITIVE_INFINITY;
const rightRepoScore = typeof rightRepoRank === 'number' ? rightRepoRank : Number.POSITIVE_INFINITY;
if (leftRepoScore !== rightRepoScore) {
return leftRepoScore - rightRepoScore;
}
const leftOwnerRank = ownerRank.get(normalizeLower(getHeadOwner(left)));
const rightOwnerRank = ownerRank.get(normalizeLower(getHeadOwner(right)));
const leftOwnerScore = typeof leftOwnerRank === 'number' ? leftOwnerRank : Number.POSITIVE_INFINITY;
const rightOwnerScore = typeof rightOwnerRank === 'number' ? rightOwnerRank : Number.POSITIVE_INFINITY;
if (leftOwnerScore !== rightOwnerScore) {
return leftOwnerScore - rightOwnerScore;
}
return 0;
};
return { matches, compare };
};
const getRepoDefaultBranch = async (octokit, repo) => {
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
if (!repoKey) {
return null;
}
const cached = defaultBranchCache.get(repoKey);
if (cached && Date.now() - cached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) {
return cached.defaultBranch;
}
try {
const response = await octokit.rest.repos.get({
owner: repo.owner,
repo: repo.repo,
});
const defaultBranch = normalizeText(response?.data?.default_branch) || null;
defaultBranchCache.set(repoKey, {
defaultBranch,
fetchedAt: Date.now(),
});
return defaultBranch;
} catch {
return null;
}
};
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_DEFAULT_BRANCH_TTL_MS) {
return cached.data;
}
try {
const response = await octokit.rest.repos.get({
owner: repo.owner,
repo: repo.repo,
});
const data = response?.data ?? null;
repoMetadataCache.set(repoKey, {
data,
fetchedAt: Date.now(),
});
return data;
} catch (error) {
if (error?.status === 403 || error?.status === 404) {
repoMetadataCache.set(repoKey, {
data: null,
fetchedAt: Date.now(),
});
return null;
}
throw error;
}
};
const resolveRemoteCandidates = async (directory, rankedRemoteNames) => {
const results = [];
const seenRepoKeys = new Set();
for (const remoteName of rankedRemoteNames) {
const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null }));
const repo = resolved?.repo || null;
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
if (!repo || !repoKey || seenRepoKeys.has(repoKey)) {
continue;
}
seenRepoKeys.add(repoKey);
results.push({
remoteName,
repo,
});
}
return results;
};
const expandRepoNetwork = async (octokit, candidates) => {
const expanded = [];
const seenRepoKeys = new Set();
const pushCandidate = (repo, remoteName, priority) => {
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
if (!repoKey || seenRepoKeys.has(repoKey)) {
return;
}
seenRepoKeys.add(repoKey);
expanded.push({ repo, remoteName, priority });
};
for (const candidate of candidates) {
const metadata = await getRepoMetadata(octokit, candidate.repo);
if (!metadata) {
continue;
}
pushCandidate(candidate.repo, candidate.remoteName, candidate.priority);
const parent = metadata?.parent;
if (parent?.owner?.login && parent?.name) {
pushCandidate({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
}, candidate.remoteName, candidate.priority + 0.1);
}
const source = metadata?.source;
if (source?.owner?.login && source?.name) {
pushCandidate({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
}, candidate.remoteName, candidate.priority + 0.2);
}
}
return expanded.sort((left, right) => left.priority - right.priority);
};
const safeListPulls = async (octokit, options) => {
try {
const response = await octokit.rest.pulls.list(options);
return Array.isArray(response?.data) ? response.data : [];
} catch (error) {
if (error?.status === 404 || error?.status === 403) {
return [];
}
throw error;
}
};
const parseRepoFromApiUrl = (value) => {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
try {
const url = new URL(normalized);
const parts = url.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
if (parts.length < 2 || parts[0] !== 'repos') {
return null;
}
const owner = parts[1];
const repo = parts[2];
if (!owner || !repo) {
return null;
}
return { owner, repo };
} catch {
return null;
}
};
// Track repos where the GitHub Search API returned 403 (token lacks scope for that org)
const _searchApiDisabledRepos = new Map();
const SEARCH_API_RETRY_MS = 5 * 60 * 1000; // retry after 5 minutes
const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
// Build a repo key to check/store 403 status per-repo
const repoKey = [...repoNames].sort().join(',').toLowerCase();
// Skip if this repo set returned 403 recently
const disabledAt = _searchApiDisabledRepos.get(repoKey);
if (disabledAt && Date.now() - disabledAt < SEARCH_API_RETRY_MS) {
return null;
}
const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean));
for (const state of ['open', 'closed']) {
let response;
try {
response = await octokit.rest.search.issuesAndPullRequests({
q: `is:pr state:${state} head:${branch}`,
per_page: 20,
});
// If we get here, search API works for this repo — clear the disabled flag
_searchApiDisabledRepos.delete(repoKey);
} catch (error) {
if (error?.status === 403) {
_searchApiDisabledRepos.set(repoKey, Date.now());
return null;
}
if (error?.status === 404) {
continue;
}
throw error;
}
const items = Array.isArray(response?.data?.items) ? response.data.items : [];
for (const item of items) {
const repo = parseRepoFromApiUrl(item?.repository_url);
if (!repo) {
continue;
}
if (normalizedRepoNames.size > 0 && !normalizedRepoNames.has(normalizeLower(repo.repo))) {
continue;
}
try {
const prResponse = await octokit.rest.pulls.get({
owner: repo.owner,
repo: repo.repo,
pull_number: item.number,
});
const pr = prResponse?.data;
if (!pr || normalizeText(pr.head?.ref) !== branch) {
continue;
}
return {
repo: {
owner: repo.owner,
repo: repo.repo,
url: `https://github.com/${repo.owner}/${repo.repo}`,
},
pr,
};
} catch (error) {
if (error?.status === 403 || error?.status === 404) {
continue;
}
throw error;
}
}
}
return null;
};
const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }) => {
const matcher = buildSourceMatcher(sourceCandidates);
const sourceOwners = [];
sourceCandidates.forEach((candidate) => pushUnique(sourceOwners, candidate.repo?.owner));
const pickPreferred = (prs) => prs
.filter((pr) => normalizeText(pr?.head?.ref) === branch)
.filter((pr) => matcher.matches(pr, target.repo.repo))
.sort((left, right) => matcher.compare(left, right, target.repo.repo))[0] ?? null;
for (const state of ['open', 'closed']) {
for (const owner of sourceOwners) {
const directCandidates = await safeListPulls(octokit, {
owner: target.repo.owner,
repo: target.repo.repo,
state,
head: `${owner}:${branch}`,
per_page: 100,
});
const direct = pickPreferred(directCandidates);
if (direct) {
return direct;
}
}
const fallbackCandidates = await safeListPulls(octokit, {
owner: target.repo.owner,
repo: target.repo.repo,
state,
per_page: 100,
});
const fallback = pickPreferred(fallbackCandidates);
if (fallback) {
return fallback;
}
}
return null;
};
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) {
const normalizedBranch = normalizeText(branch);
const normalizedRemoteName = normalizeText(remoteName) || 'origin';
const [status, remotes] = await Promise.all([
getStatus(directory).catch(() => null),
getRemotes(directory).catch(() => []),
]);
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);
const resolvedTargets = await expandRepoNetwork(
octokit,
resolvedRemoteTargets.map((target, index) => ({ ...target, priority: index })),
);
if (resolvedTargets.length === 0) {
return {
repo: null,
pr: null,
defaultBranch: null,
resolvedRemoteName: null,
};
}
const sourceCandidates = resolvedTargets.slice();
let fallbackRepo = resolvedTargets[0].repo;
let fallbackRemoteName = resolvedTargets[0].remoteName;
let fallbackDefaultBranch = await getRepoDefaultBranch(octokit, fallbackRepo);
for (const target of resolvedTargets) {
const defaultBranch = await getRepoDefaultBranch(octokit, target.repo);
if (!fallbackRepo) {
fallbackRepo = target.repo;
fallbackRemoteName = target.remoteName;
fallbackDefaultBranch = defaultBranch;
}
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,
};
}
}
}
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 {
repo: fallbackRepo,
pr: null,
defaultBranch: fallbackDefaultBranch,
resolvedRemoteName: fallbackRemoteName,
};
}