Files
openchamber/packages/web/server/lib/github/DOCUMENTATION.md
T
bot-hermes 66edc74fac feat(web,ui): per-project git provider API base URL overrides
Per provider (github|gitlab|gitea) a project override stored in
~/.config/openchamber/projects/<projectId>.json under gitProviders wins
over the global settings.json value (precedence: project override > global
> built-in default). Server forge routes resolve the override per request
directory (worktree-aware via git-common-dir + containment + path fallback,
60s TTL cache); the override host is also accepted for remote parsing and
client detection. New GET/PUT /api/projects/:projectId/git-providers route;
client openchamberConfig preserves the server-owned gitProviders key;
Projects page gains a Git provider API base URLs section; detection store
hydrates per-project overrides (memory-only, server-authoritative).
2026-08-17 09:57:54 +00:00

14 KiB

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. routes.js loads it lazily with await import('./index.js') and destructures the handler it needs, so a re-export removed from here breaks a route at request time rather than at build time. Static "unused export" reports do not see these consumers.
  • packages/web/server/lib/github/routes.js: Express route registration for /api/github/* endpoints.
  • 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, webOrigin? }): request device code.
  • exchangeDeviceCode({ clientId, deviceCode, webOrigin? }): poll for access token.

Octokit

  • getOctokitOrNull(directory?): current Octokit or null. When directory is provided the API base resolution is directory-aware (see "Per-project overrides" below); without it the global base URL is used.
  • createOctokit(token, baseUrl?): Octokit factory; the optional baseUrl (GitHub Enterprise API base) is passed to the Octokit constructor.

Repo

  • parseGitHubRemoteUrl(raw, options?): parse SSH or HTTPS remote URL into { owner, repo, url }; options.host / options.webOrigin default to github.com / https://github.com and are used for self-hosted (Enterprise) remotes.
  • resolveGitHubRepoFromDirectory(directory, remoteName): resolve GitHub repo from a local git remote.

Git provider configuration

Per-provider settings come from ~/.config/openchamber/settings.json under gitProviders (validated in packages/web/server/lib/git-providers/config.js, persisted via the settings GET/PUT routes). GitHub resolution:

  • API base URL: configured gitProviders.github.apiBaseUrl -> default https://api.github.com. The configured value drives the Octokit baseUrl (getOctokitOrNull, device-flow account activation).
  • Device flow web origin: derived from the API base via githubWebOriginFromApiBase — the public host maps to https://github.com; an Enterprise base (https://host/api/v3 or https://host/api) maps to https://host.

Per-project overrides

API base resolution is directory-aware for project-scoped routes: getOctokitOrNull(directory) resolves the effective base via getEffectiveProviderApiBaseUrl('github', directory) (in packages/web/server/lib/git-providers/project-config.js), which prefers a per-project gitProviders.github.apiBaseUrl override (stored under projects/<projectId>.json) over the global settings.json value and the built-in default. Global routes (auth/status, auth/activate, me, repo/branches) and the device flow keep using the global base URL unchanged.

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.

Enrichment read APIs

  • GET /api/github/pulls/commits?directory&number&owner&repo -> { connected, repo?, commits[] } (via octokit.rest.pulls.listCommits, mapped to { sha, shortSha, message, summary, author, committer, committedAt, parents }).
  • GET /api/github/pulls/timeline?directory&number&owner&repo -> { connected, repo?, events[] } (via octokit.rest.issues.listEventsForTimeline, each event { id, type, author, createdAt, body, commitSha } with the event name lowercased).
  • Both follow the issues/comments envelope pattern: unauthenticated -> connected: false, unresolvable repo -> repo: null with an empty list, 429 -> 503 { error: 'GitHub rate limited' }, other provider 4xx -> 502.

Write APIs

All write routes accept an optional owner/repo in the body to target a fork-network repo; otherwise the repo is resolved from directory. Unauthenticated -> { connected: false }; 429 -> 503 { error: 'GitHub rate limited' }; generic failures -> 500 with a generic error (raw upstream text is never leaked).

  • POST /api/github/issues/comment — body { directory, number, body, owner?, repo? } -> { connected, repo?, comment? } (via octokit.rest.issues.createComment, mapped to GitHubIssueComment).
  • POST /api/github/issues/create — body { directory, title, body?, labels?, owner?, repo? } -> { connected, repo?, issue? } (via octokit.rest.issues.create; labels is a full-set list of names).
  • PATCH /api/github/issues/update — body { directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? } -> { connected, repo?, issue? } (via octokit.rest.issues.update; labels/assignees replace the full set, milestone is a title resolved to a milestone number — 400 { error: 'Milestone not found' } when it matches nothing, null clears it). Also works for pull requests (PRs are issues), so it serves PR metadata/state changes too.
  • POST /api/github/pulls/comment — same input/result shape as issues/comment; posts to the PR's issue thread via octokit.rest.issues.createComment. Invalidates the PR context cache.
  • POST /api/github/pulls/review-comment — body { directory, number, body, inReplyToId?, path?, line?, owner?, repo? } -> { connected, repo?, comment? } (via octokit.rest.pulls.createReviewComment). With inReplyToId it is a reply; otherwise path + line are required and the PR head commit is resolved first. Invalidates the PR context cache.
  • POST /api/github/pulls/review — body { directory, number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?, owner?, repo? } -> { connected, repo?, review? } (via octokit.rest.pulls.createReview, mapped to { id, state, author, submittedAt, body, commitSha }). Invalidates the PR context cache.
  • POST /api/github/pr/update — existing route extended with optional state, draft, labels, assignees, milestone. When any extended field is present it branches to octokit.rest.issues.update (milestone title -> number; draft applied separately via octokit.rest.pulls.update); title/body-only updates keep using pulls.update. Invalidates the PR context cache and the repo pulls cache.

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 open PRs by likely source owner plus exact head branch.
  • If that fails, it falls back to broader GitHub search for open PRs on the branch name.
  • An open PR from any candidate repo always wins over a closed/merged one, so a merged fork PR can never hide an open upstream PR for the same head.
  • Only when no target has an open PR does it return the branch's newest closed/merged PR, as history.
  • History is looked up only for the ranked-first remote and the branch's own name — the repo it actually pushes to. Live status is worth searching the whole fork network for; history is not, and asking every target for it multiplies serial GitHub calls until the route hits its 12s resolve timeout and returns no status at all.
  • The history answer is remembered per repo+branch so discovery polls do not re-query it: a found closed/merged record for 6h, and "no history yet" for 10m. A found record only changes if a second PR appears on the same head, and while that one is open the open-PR path wins without ever reading this cache.
  • Creating, merging, or closing a PR invalidates both the shared repo pull list and that remembered history.
  • The route skips the checks summary and the merge-permission lookup for a closed/merged PR: neither is actionable, and both cost extra GitHub calls.
  • 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 -> discovery refresh every 5m (do not permanently stop polling).
  • Hidden tab -> skip polling.
  • Non-forced refreshes use a 90s TTL.
  • Failed non-forced attempts also observe the 90s TTL so transient server or rate-limit failures cannot retry on every sidebar update. Forced user/action refreshes bypass this guard.

Persistence notes for terminal PRs

  • Closed/merged branch associations are persisted like open ones, so a reload still shows that the branch's PR was merged.
  • Hydrate resets lastDiscoveryPollAt for them, so restored history revalidates on the first watcher tick instead of waiting out a discovery interval.

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/.