feat(web,ui): per-provider git forge API base URL and detection URLs

Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
This commit is contained in:
2026-08-17 09:55:11 +00:00
parent a87b3fd228
commit 0fc857959e
55 changed files with 1685 additions and 272 deletions
@@ -32,18 +32,26 @@
### Device flow
- `startDeviceFlow({ clientId, scope })`: request device code.
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code.
- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token.
### Octokit
- `getOctokitOrNull()`: current Octokit or `null`.
- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor.
### Repo
- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
- `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`.
## Auth storage and config
- Auth storage: `~/.config/openchamber/github-auth.json`
@@ -1,6 +1,5 @@
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 DEFAULT_WEB_ORIGIN = 'https://github.com';
const encodeForm = (params) => {
const body = new URLSearchParams();
@@ -32,16 +31,19 @@ async function postForm(url, params) {
return payload;
}
export async function startDeviceFlow({ clientId, scope }) {
return postForm(DEVICE_CODE_URL, {
const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`;
const accessTokenUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/oauth/access_token`;
export async function startDeviceFlow({ clientId, scope, webOrigin }) {
return postForm(deviceCodeUrl(webOrigin), {
client_id: clientId,
scope,
});
}
export async function exchangeDeviceCode({ clientId, deviceCode }) {
export async function exchangeDeviceCode({ clientId, deviceCode, webOrigin }) {
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
const payload = await postForm(ACCESS_TOKEN_URL, {
const payload = await postForm(accessTokenUrl(webOrigin), {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
+5
View File
@@ -28,3 +28,8 @@ export {
parseGitHubRemoteUrl,
resolveGitHubRepoFromDirectory,
} from './repo/index.js';
export {
getProviderApiBaseUrl,
githubWebOriginFromApiBase,
} from '../git-providers/config.js';
+8 -3
View File
@@ -1,6 +1,7 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
import { getGhCliToken } from './gh-cli-credential.js';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
// which has no built-in timeout — without this, a stuck connection hangs until
@@ -69,8 +70,12 @@ const createConditionalFetch = (token) => async (url, options = {}) => {
};
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
export function createOctokit(token) {
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
export function createOctokit(token, baseUrl) {
return new Octokit({
auth: token,
...(baseUrl ? { baseUrl } : {}),
request: { fetch: createConditionalFetch(token) },
});
}
export function getOctokitOrNull() {
@@ -80,5 +85,5 @@ export function getOctokitOrNull() {
if (!token) {
return null;
}
return createOctokit(token);
return createOctokit(token, getProviderApiBaseUrl('github'));
}
+5 -2
View File
@@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises';
import { getRemotes, getStatus } from '../git/index.js';
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
import { noteIfGitHubRateLimit } from './rate-limit.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js';
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const directoryExists = async (dir) => {
if (!dir) return false;
@@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
pushCandidate({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
}, candidate.remoteName, candidate.priority + 0.1);
}
@@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
pushCandidate({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
}, candidate.remoteName, candidate.priority + 0.2);
}
}
@@ -1,4 +1,7 @@
import { resolveGitHubRepoFromDirectory } from './index.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const REPO_METADATA_TTL_MS = 5 * 60_000;
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
@@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
result.push({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
source: 'upstream',
});
}
@@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
result.push({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
source: 'upstream',
});
}
+25 -10
View File
@@ -1,6 +1,17 @@
import { getRemoteUrl } from '../../git/index.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
export const parseGitHubRemoteUrl = (raw) => {
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const webHostFromOrigin = (webOrigin) => {
try {
return new URL(webOrigin).hostname || 'github.com';
} catch {
return 'github.com';
}
};
export const parseGitHubRemoteUrl = (raw, { host = 'github.com', webOrigin = 'https://github.com' } = {}) => {
if (typeof raw !== 'string') {
return null;
}
@@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => {
}
// git@github.com:OWNER/REPO.git
if (value.startsWith('git@github.com:')) {
const rest = value.slice('git@github.com:'.length);
const scpPrefix = `git@${host}:`;
if (value.startsWith(scpPrefix)) {
const rest = value.slice(scpPrefix.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}` };
return { owner, repo, url: `${webOrigin}/${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 sshPrefix = `ssh://git@${host}/`;
if (value.startsWith(sshPrefix)) {
const rest = value.slice(sshPrefix.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}` };
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
}
// https://github.com/OWNER/REPO(.git)
try {
const url = new URL(value);
if (url.hostname !== 'github.com') {
if (url.hostname !== host) {
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}` };
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
} catch {
return null;
}
@@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
const webOrigin = getGitHubWebOrigin();
const host = webHostFromOrigin(webOrigin);
return {
repo: parseGitHubRemoteUrl(remoteUrl),
repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }),
remoteUrl,
};
}
+13 -5
View File
@@ -321,7 +321,7 @@ export function registerGitHubRoutes(app) {
app.post('/api/github/auth/start', async (_req, res) => {
try {
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
const { getGitHubClientId, getGitHubScopes, startDeviceFlow, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
@@ -330,10 +330,12 @@ export function registerGitHubRoutes(app) {
}
const scope = getGitHubScopes();
const webOrigin = githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const payload = await startDeviceFlow({
clientId,
scope,
webOrigin,
});
return res.json({
@@ -353,7 +355,7 @@ export function registerGitHubRoutes(app) {
app.post('/api/github/auth/complete', async (req, res) => {
try {
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
@@ -369,7 +371,10 @@ export function registerGitHubRoutes(app) {
return res.status(400).json({ error: 'deviceCode is required' });
}
const payload = await exchangeDeviceCode({ clientId, deviceCode });
const apiBase = getProviderApiBaseUrl('github');
const webOrigin = githubWebOriginFromApiBase(apiBase);
const payload = await exchangeDeviceCode({ clientId, deviceCode, webOrigin });
if (payload?.error) {
return res.json({
@@ -385,7 +390,7 @@ export function registerGitHubRoutes(app) {
}
const { createOctokit } = await import('./octokit.js');
const octokit = createOctokit(accessToken);
const octokit = createOctokit(accessToken, apiBase);
const user = await getGitHubUserSummary(octokit);
setGitHubAuth({
@@ -1773,6 +1778,9 @@ export function registerGitHubRoutes(app) {
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
const { getProviderApiBaseUrl } = await getGitHubLibraries();
const apiBase = getProviderApiBaseUrl('github');
const repoNetwork = await resolveRepoNetwork(octokit, directory);
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
@@ -1817,7 +1825,7 @@ export function registerGitHubRoutes(app) {
const issues = items
.filter((item) => !item?.pull_request)
.map((item) => {
const repoFullName = (item.repository_url || '').replace('https://api.github.com/repos/', '');
const repoFullName = (item.repository_url || '').replace(`${apiBase}/repos/`, '');
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
return mapIssueSummary(item, matched || reposToQuery[0]);
});