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).
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.jsloads it lazily withawait 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 ornull. Whendirectoryis 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 optionalbaseUrl(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.webOrigindefault togithub.com/https://github.comand 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-> defaulthttps://api.github.com. The configured value drives the OctokitbaseUrl(getOctokitOrNull, device-flow account activation). - Device flow web origin: derived from the API base via
githubWebOriginFromApiBase— the public host maps tohttps://github.com; an Enterprise base (https://host/api/v3orhttps://host/api) maps tohttps://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?)frompackages/web/src/api/github.ts. - That hits
GET /api/github/pr/statusinpackages/web/server/index.js. - The route calls
resolveGitHubPrStatus(...)inpackages/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[] }(viaoctokit.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[] }(viaoctokit.rest.issues.listEventsForTimeline, each event{ id, type, author, createdAt, body, commitSha }with the event name lowercased).- Both follow the
issues/commentsenvelope pattern: unauthenticated ->connected: false, unresolvable repo ->repo: nullwith an empty list,429->503 { error: 'GitHub rate limited' }, other provider4xx->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? }(viaoctokit.rest.issues.createComment, mapped toGitHubIssueComment).POST /api/github/issues/create— body{ directory, title, body?, labels?, owner?, repo? }->{ connected, repo?, issue? }(viaoctokit.rest.issues.create;labelsis 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? }(viaoctokit.rest.issues.update;labels/assigneesreplace the full set,milestoneis a title resolved to a milestone number —400 { error: 'Milestone not found' }when it matches nothing,nullclears 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 asissues/comment; posts to the PR's issue thread viaoctokit.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? }(viaoctokit.rest.pulls.createReviewComment). WithinReplyToIdit is a reply; otherwisepath+lineare 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? }(viaoctokit.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 optionalstate,draft,labels,assignees,milestone. When any extended field is present it branches tooctokit.rest.issues.update(milestone title -> number;draftapplied separately viaoctokit.rest.pulls.update); title/body-only updates keep usingpulls.update. Invalidates the PR context cache and the repo pulls cache.
Consumers of PR data
packages/ui/src/components/session/SessionSidebar.tsxreads all PR entries and maps them todirectory::branch.packages/ui/src/components/session/sidebar/SessionGroupSection.tsxrenders the compact badge, PR number, title, checks summary, and GitHub link.packages/ui/src/components/views/git/PullRequestSection.tsxuses the same shared entry for the full PR workflow.packages/ui/src/components/ui/MemoryDebugPanel.tsxreads 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
parentandsourceso 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
12sresolve 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" for10m. 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.
403and404during 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
useGitHubPrStatusStoreand repo scanning inuseGitHubPrBackgroundTracking. - 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
2sand5s. - 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
90sTTL. - Failed non-forced attempts also observe the
90sTTL 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
lastDiscoveryPollAtfor them, so restored history revalidates on the first watcher tick instead of waiting out a discovery interval.
Background tracking rules
- Track up to
50likely 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, andbehindfrom git status. - If any of those branch signals change, that branch's PR status refreshes immediately.
- After that, one more delayed refresh runs after
5sto 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 after2sand5s. - After
Merge PR-> refresh now, then after2sand5s. - After
Mark ready for review-> refresh now, then after2sand5s. - After
Update PR-> refresh now, then after2sand5s.
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
originis enough. - Device flow handles GitHub
authorization_pendingat caller level. - Repo parser supports
git@github.com:,ssh://git@github.com/, andhttps://github.com/.