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:
@@ -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,
|
||||
|
||||
@@ -28,3 +28,8 @@ export {
|
||||
parseGitHubRemoteUrl,
|
||||
resolveGitHubRepoFromDirectory,
|
||||
} from './repo/index.js';
|
||||
|
||||
export {
|
||||
getProviderApiBaseUrl,
|
||||
githubWebOriginFromApiBase,
|
||||
} from '../git-providers/config.js';
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user