Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
170
web/server/lib/github/DOCUMENTATION.md
Normal file
170
web/server/lib/github/DOCUMENTATION.md
Normal 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/`.
|
||||
307
web/server/lib/github/auth.js
Normal file
307
web/server/lib/github/auth.js
Normal 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;
|
||||
50
web/server/lib/github/device-flow.js
Normal file
50
web/server/lib/github/device-flow.js
Normal 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;
|
||||
}
|
||||
24
web/server/lib/github/index.js
Normal file
24
web/server/lib/github/index.js
Normal 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';
|
||||
10
web/server/lib/github/octokit.js
Normal file
10
web/server/lib/github/octokit.js
Normal 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 });
|
||||
}
|
||||
478
web/server/lib/github/pr-status.js
Normal file
478
web/server/lib/github/pr-status.js
Normal 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,
|
||||
};
|
||||
}
|
||||
55
web/server/lib/github/repo/index.js
Normal file
55
web/server/lib/github/repo/index.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user