Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
# GitHub Module Documentation
## Purpose
- This module owns GitHub auth, Octokit access, repo resolution, and Pull Request status resolution for OpenChamber.
- From user perspective, this is the layer that lets the app know which PR belongs to a local branch and keeps that UI feeling current.
## Entrypoints and structure
- `packages/web/server/lib/github/index.js`: public server entrypoint.
- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config.
- `packages/web/server/lib/github/device-flow.js`: OAuth device flow.
- `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth.
- `packages/web/server/lib/github/repo/index.js`: remote URL parsing and directory-to-repo resolution.
- `packages/web/server/lib/github/pr-status.js`: PR lookup across remotes, forks, and upstreams.
- `packages/web/server/index.js`: API route layer that calls this module.
- `packages/web/src/api/github.ts`: web client wrapper for GitHub endpoints.
## Public exports
### Auth
- `getGitHubAuth()`: current auth entry.
- `getGitHubAuthAccounts()`: all configured accounts.
- `setGitHubAuth({ accessToken, scope, tokenType, user, accountId })`: save or update account.
- `activateGitHubAuth(accountId)`: switch active account.
- `clearGitHubAuth()`: clear current account.
- `getGitHubClientId()`: resolve client id.
- `getGitHubScopes()`: resolve scopes.
- `GITHUB_AUTH_FILE`: auth file path.
### Device flow
- `startDeviceFlow({ clientId, scope })`: request device code.
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
### Octokit
- `getOctokitOrNull()`: current Octokit or `null`.
### Repo
- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
- `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote.
## Auth storage and config
- Auth storage: `~/.config/openchamber/github-auth.json`
- Writes are atomic and file mode is `0o600`.
- Client ID resolution order: `OPENCHAMBER_GITHUB_CLIENT_ID` -> `settings.json` -> default.
- Scope resolution order: `OPENCHAMBER_GITHUB_SCOPES` -> `settings.json` -> default.
- Account id resolution order: explicit `accountId` -> user login -> user id -> token prefix.
## PR integration overview
- The UI asks `github.prStatus(directory, branch, remote?)` from `packages/web/src/api/github.ts`.
- That hits `GET /api/github/pr/status` in `packages/web/server/index.js`.
- The route calls `resolveGitHubPrStatus(...)` in `packages/web/server/lib/github/pr-status.js`.
- The resolver finds the most likely repo and PR for a local branch.
- The route then enriches that result with checks, mergeability, and permission-related fields.
- The client caches and shares the result between sidebar and Git view.
## Consumers of PR data
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
- `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` renders the compact badge, PR number, title, checks summary, and GitHub link.
- `packages/ui/src/components/views/git/PullRequestSection.tsx` uses the same shared entry for the full PR workflow.
- `packages/ui/src/components/ui/MemoryDebugPanel.tsx` reads request counters for debugging.
## How PR resolution works
- It reads local git status and remotes first.
- It ranks remotes in this order: explicit remote, tracking remote, `origin`, `upstream`, then the rest.
- It resolves those remotes into GitHub repos.
- It expands each repo through `parent` and `source` so PRs in upstream repos can still be found.
- It skips PR lookup when the current branch matches that repo's default branch.
- It first searches for PRs by likely source owner plus exact head branch.
- If that fails, it falls back to broader GitHub search for the branch name.
- `403` and `404` during repo lookups are treated as expected gaps, not hard errors.
## Shared client state model
- Client key is effectively `directory::branch`.
- One entry stores last known status, loading state, error, timestamps, watcher count, identity, and resolved remote.
- Requests are deduplicated by branch signature, not by component instance.
- This keeps sidebar and Git view aligned and avoids duplicated fetches.
## Persistence
- PR state is persisted in local storage under `openchamber.github-pr-status`.
- Persisted fields include status, timestamps, identity, and resolved remote.
- Runtime-only details are not persisted.
- Persisted entries expire after 12 hours.
- On reload, users get last known state first, then background refresh resumes.
## Polling and refresh model
- There are two layers: entry-level polling in `useGitHubPrStatusStore` and repo scanning in `useGitHubPrBackgroundTracking`.
- Entry-level polling decides when a known branch should revalidate PR state.
- Background tracking decides which directories and branches should even be watched.
## Entry-level polling rules
- Start watching -> immediate refresh.
- If no PR is found yet -> retry after `2s` and `5s`.
- Still no PR -> discovery refresh every `5m`.
- Open PR with pending checks -> refresh about every `1m`.
- Open PR with non-pending checks -> refresh about every `5m`.
- Open PR without a stable checks signal -> refresh about every `2m`.
- Closed or merged PR -> stop regular polling.
- Hidden tab -> skip polling.
- Non-forced refreshes use a `90s` TTL.
## Background tracking rules
- Track up to `50` likely directories.
- Sources are current directory, projects, worktrees, active sessions, and archived sessions.
- Active directory branch TTL is `15s`.
- Background directory branch TTL is `2m`.
- Background scan wakes every `15s`, but only fetches directories whose TTL expired.
- Each scan reads `branch`, `tracking`, `ahead`, and `behind` from git status.
- If any of those branch signals change, that branch's PR status refreshes immediately.
- After that, one more delayed refresh runs after `5s` to catch GitHub eventual consistency.
## UI refresh triggers
- App or tab becomes visible.
- Window regains focus.
- Current branch changes.
- Tracking branch changes.
- Ahead or behind changes.
- User selects a different remote in Git view.
- GitHub auth state changes.
## Action-based refreshes in Git view
- After `Create PR` -> refresh now, then after `2s` and `5s`.
- After `Merge PR` -> refresh now, then after `2s` and `5s`.
- After `Mark ready for review` -> refresh now, then after `2s` and `5s`.
- After `Update PR` -> refresh now, then after `2s` and `5s`.
## Sidebar behavior
- Sidebar shows only compact PR state.
- Aggregation is by `directory::branch`, so multiple sessions on one branch share one signal.
- If multiple entries exist, sidebar keeps the strongest visible PR state.
- Visual state is based on PR health, not merge permissions.
## Git view behavior
- Git view watches one branch directly.
- It supports create, edit, mark ready, and merge.
- It can probe alternate remotes so fork-heavy setups still find the right PR.
- It uses the same shared store as the sidebar.
## Failure handling
- If GitHub is disconnected, API returns `connected: false`.
- If a repo is private or inaccessible, resolver calls may quietly return no PR.
- Sidebar stays quiet on missing or inaccessible PR state.
- Git view is where explicit PR-level problems should be shown.
## Notes for contributors
- Keep the UI calm. Do not add noisy diagnostics to the sidebar.
- Prefer shared state over per-component fetches.
- Prefer event-shaped refreshes over blind frequent polling.
- Prefer correctness for fork and multi-remote setups over assuming `origin` is enough.
- Device flow handles GitHub `authorization_pending` at caller level.
- Repo parser supports `git@github.com:`, `ssh://git@github.com/`, and `https://github.com/`.

View File

@@ -0,0 +1,307 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function readJsonFile() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read GitHub auth file:', error);
return null;
}
}
function writeJsonFile(payload) {
ensureStorageDir();
// Atomic write so multiple OpenChamber instances can safely share the same file.
const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, STORAGE_FILE);
try {
fs.chmodSync(STORAGE_FILE, 0o600);
} catch {
// best-effort
}
}
function resolveAccountId({ user, accessToken, accountId }) {
if (typeof accountId === 'string' && accountId.trim()) {
return accountId.trim();
}
if (user && typeof user.login === 'string' && user.login.trim()) {
return user.login.trim();
}
if (user && typeof user.id === 'number') {
return String(user.id);
}
if (typeof accessToken === 'string' && accessToken.trim()) {
return `token:${accessToken.slice(0, 8)}`;
}
return '';
}
function normalizeAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
if (!accessToken) return null;
const user = entry.user && typeof entry.user === 'object'
? {
login: typeof entry.user.login === 'string' ? entry.user.login : null,
avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
id: typeof entry.user.id === 'number' ? entry.user.id : null,
name: typeof entry.user.name === 'string' ? entry.user.name : null,
email: typeof entry.user.email === 'string' ? entry.user.email : null,
}
: null;
const accountId = resolveAccountId({
user,
accessToken,
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
});
return {
accessToken,
scope: typeof entry.scope === 'string' ? entry.scope : '',
tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
user,
current: Boolean(entry.current),
accountId,
};
}
function normalizeAuthList(raw) {
const list = (Array.isArray(raw) ? raw : [raw])
.map((entry) => normalizeAuthEntry(entry))
.filter(Boolean);
if (!list.length) {
return { list: [], changed: false };
}
let changed = false;
let currentFound = false;
list.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && list[0]) {
list[0].current = true;
changed = true;
}
list.forEach((entry) => {
if (!entry.accountId) {
entry.accountId = resolveAccountId(entry);
changed = true;
}
});
return { list, changed };
}
function readAuthList() {
const data = readJsonFile();
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeJsonFile(list);
}
return list;
}
function writeAuthList(list) {
writeJsonFile(list);
}
export function getGitHubAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
const current = list.find((entry) => entry.current) || list[0];
if (!current?.accessToken) {
return null;
}
return current;
}
export function getGitHubAuthAccounts() {
const list = readAuthList();
return list
.filter((entry) => entry?.user && entry.accountId)
.map((entry) => ({
id: entry.accountId,
user: entry.user,
scope: entry.scope || '',
current: Boolean(entry.current),
}));
}
export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
const normalizedUser = user && typeof user === 'object'
? {
login: typeof user.login === 'string' ? user.login : undefined,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
id: typeof user.id === 'number' ? user.id : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined;
const resolvedAccountId = resolveAccountId({
user: normalizedUser,
accessToken,
accountId,
});
const list = readAuthList();
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
const nextEntry = {
accessToken,
scope: typeof scope === 'string' ? scope : '',
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
createdAt: Date.now(),
user: normalizedUser || null,
current: true,
accountId: resolvedAccountId,
};
if (existingIndex >= 0) {
list[existingIndex] = nextEntry;
} else {
list.push(nextEntry);
}
list.forEach((entry, index) => {
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
});
writeAuthList(list);
return nextEntry;
}
export function activateGitHubAuth(accountId) {
if (typeof accountId !== 'string' || !accountId.trim()) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearGitHubAuth() {
try {
const list = readAuthList();
if (!list.length) {
return true;
}
const remaining = list.filter((entry) => !entry.current);
if (!remaining.length) {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
}
remaining.forEach((entry, index) => {
entry.current = index === 0;
});
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear GitHub auth file:', error);
return false;
}
}
export function getGitHubClientId() {
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
const clientId = typeof raw === 'string' ? raw.trim() : '';
if (clientId) return clientId;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_CLIENT_ID;
}
export function getGitHubScopes() {
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
if (fromEnv) return fromEnv;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_SCOPES;
}
export const GITHUB_AUTH_FILE = STORAGE_FILE;

View File

@@ -0,0 +1,50 @@
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
const encodeForm = (params) => {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
body.set(key, String(value));
}
return body.toString();
};
async function postForm(url, params) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: encodeForm(params),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error_description || payload?.error || response.statusText;
const error = new Error(message || 'GitHub request failed');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function startDeviceFlow({ clientId, scope }) {
return postForm(DEVICE_CODE_URL, {
client_id: clientId,
scope,
});
}
export async function exchangeDeviceCode({ clientId, deviceCode }) {
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
const payload = await postForm(ACCESS_TOKEN_URL, {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
});
return payload;
}

View File

@@ -0,0 +1,24 @@
export {
getGitHubAuth,
getGitHubAuthAccounts,
setGitHubAuth,
activateGitHubAuth,
clearGitHubAuth,
getGitHubClientId,
getGitHubScopes,
GITHUB_AUTH_FILE,
} from './auth.js';
export {
startDeviceFlow,
exchangeDeviceCode,
} from './device-flow.js';
export {
getOctokitOrNull,
} from './octokit.js';
export {
parseGitHubRemoteUrl,
resolveGitHubRepoFromDirectory,
} from './repo/index.js';

View File

@@ -0,0 +1,10 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth } from './auth.js';
export function getOctokitOrNull() {
const auth = getGitHubAuth();
if (!auth?.accessToken) {
return null;
}
return new Octokit({ auth: auth.accessToken });
}

View File

@@ -0,0 +1,478 @@
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 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;
}
};
const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
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,
});
} catch (error) {
if (error?.status === 403 || 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 rankedRemoteNames = rankRemoteNames(
Array.isArray(remotes) ? remotes.map((remote) => remote?.name).filter(Boolean) : [],
normalizedRemoteName,
trackingRemoteName,
);
const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames.slice(0, 3));
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;
}
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 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,
};
}
return {
repo: fallbackRepo,
pr: null,
defaultBranch: fallbackDefaultBranch,
resolvedRemoteName: fallbackRemoteName,
};
}

View File

@@ -0,0 +1,55 @@
import { getRemoteUrl } from '../../git/index.js';
export const parseGitHubRemoteUrl = (raw) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
// git@github.com:OWNER/REPO.git
if (value.startsWith('git@github.com:')) {
const rest = value.slice('git@github.com:'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
// ssh://git@github.com/OWNER/REPO.git
if (value.startsWith('ssh://git@github.com/')) {
const rest = value.slice('ssh://git@github.com/'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
// https://github.com/OWNER/REPO(.git)
try {
const url = new URL(value);
if (url.hostname !== 'github.com') {
return null;
}
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
} catch {
return null;
}
};
export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'origin') {
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
return {
repo: parseGitHubRemoteUrl(remoteUrl),
remoteUrl,
};
}