Files
openchamber/packages/web/src/api/github.ts
T
Tom Rochette 33e614c76b Fallback to gh CLI credentials if available (#1515)
Adds `gh` CLI as a GitHub credential fallback for users who already have
`gh auth login` configured locally. OpenChamber-owned OAuth credentials
remain the primary source of truth; the `gh` token is only used when no
stored OpenChamber GitHub access token exists and the fallback is not
disabled.

The fallback is implemented as a credential provider only: GitHub features
continue to use the existing Octokit/GitHub API paths for issues, pull
requests, checks, merges, and related operations. The PR does not replace
those endpoints with `gh issue` or `gh pr` CLI commands.

Server changes:
- Add `gh-cli-credential.js` to read `gh auth token` with a bounded timeout.
- Cache the `gh` token lookup for 30 seconds, including negative results,
  to avoid repeated subprocess spawning on status/polling paths.
- Hide the subprocess window on Windows via `windowsHide: true`.
- Clear the gh CLI token cache when the fallback setting changes.
- Update `getOctokitOrNull()` to prefer stored OpenChamber OAuth tokens and
  fall back to the `gh` token only when enabled.
- Add `ghCliDisabled` persistence in the existing settings file with atomic
  writes and `0o600` file permissions.
- Add `POST /api/github/auth/gh-cli` to enable or disable the fallback.
- Extend `/api/github/auth/status` with `ghCli` metadata: availability,
  disabled state, active state, and active user when applicable.

UI/runtime changes:
- Extend `GitHubAuthStatus` and `GitHubAPI` with gh CLI fallback metadata
  and toggle support.
- Add web RuntimeAPI support for toggling the gh CLI fallback through
  `runtimeFetch`, preserving active runtime/remote target behavior.
- Add deterministic VS Code unsupported handling for the gh CLI toggle.
- Update GitHub Settings to show gh CLI availability and active status.
- When gh CLI is the active auth source, show it in the connected account
  card and offer Disable instead of Disconnect.
- Keep Add Account available so users can still connect an OpenChamber OAuth
  account, which then takes priority over gh CLI.
- Add localized gh CLI settings strings across supported settings locales.

Fixes addressed during review:
- Removed unreachable UI branches in the inactive gh CLI card.
- Avoided duplicate and repeated `gh auth token` subprocess calls.
- Hardened settings file permissions for the new persisted flag.
- Routed the gh CLI toggle through the RuntimeAPI/runtimeFetch path instead
  of direct browser `fetch`.
- Added targeted tests for hidden subprocess options and negative-result
  cache behavior.
- Fixed a VS Code webview Response body typing issue that blocked type-check.
2026-06-11 18:43:41 +03:00

299 lines
13 KiB
TypeScript

import type {
GitHubAPI,
GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequestContextResult,
GitHubPullRequestsListResult,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestUpdateInput,
GitHubPullRequestStatus,
GitHubRepoUpstreamResult,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
interface WebGitHubAPIOptions {
urls: RuntimeUrlResolver;
}
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
return (await response.json().catch(() => null)) as T | null;
};
export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI => ({
async authStatus(): Promise<GitHubAuthStatus> {
const response = await runtimeFetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
return payload;
},
async authStart(): Promise<GitHubDeviceFlowStart> {
const response = await runtimeFetch('/api/github/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const payload = await jsonOrNull<GitHubDeviceFlowStart & { error?: string }>(response);
if (!response.ok || !payload || !('deviceCode' in payload)) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to start GitHub auth');
}
return payload;
},
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
const response = await runtimeFetch('/api/github/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ deviceCode }),
});
const payload = await jsonOrNull<GitHubDeviceFlowComplete & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to complete GitHub auth');
}
return payload;
},
async authDisconnect(): Promise<{ removed: boolean }> {
const response = await runtimeFetch('/api/github/auth', { method: 'DELETE', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
if (!response.ok) {
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitHub');
}
return { removed: Boolean(payload?.removed) };
},
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accountId }),
});
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to activate GitHub account');
}
return payload;
},
async authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }> {
const response = await runtimeFetch('/api/github/auth/gh-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ disabled }),
});
const payload = await jsonOrNull<{ disabled?: boolean; error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to update gh CLI setting');
}
return { disabled: Boolean(payload.disabled) };
},
async me(): Promise<GitHubUserSummary> {
const response = await runtimeFetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitHub user');
}
return payload;
},
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
const params = new URLSearchParams({
directory,
branch,
...(remote ? { remote } : {}),
...(options?.force ? { force: 'true' } : {}),
});
const response = await runtimeFetch(
`/api/github/pr/status?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load PR status');
}
return payload;
},
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
const response = await runtimeFetch('/api/github/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to create PR');
}
return body;
},
async prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest> {
const response = await runtimeFetch('/api/github/pr/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to update PR');
}
return body;
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const response = await runtimeFetch('/api/github/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestMergeResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to merge PR');
}
return body;
},
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
const response = await runtimeFetch('/api/github/pr/ready', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestReadyResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to mark PR ready');
}
return body;
},
async repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult> {
const response = await runtimeFetch(
`/api/github/repo/upstream?directory=${encodeURIComponent(directory)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GitHubRepoUpstreamResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to detect upstream repo');
}
return body;
},
async repoBranches(owner: string, repo: string): Promise<string[]> {
const response = await runtimeFetch(
`/api/github/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<{ branches?: string[]; error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to fetch repo branches');
}
return body.branches ?? [];
},
async prsList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubPullRequestsListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
directory,
page: String(page),
});
if (options?.query) {
params.set('query', options.query);
}
const response = await runtimeFetch(
`/api/github/pulls/list?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GitHubPullRequestsListResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to load pull requests');
}
return body;
},
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: { owner: string; repo: string } | null }
): Promise<GitHubPullRequestContextResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.includeDiff) {
params.set('diff', '1');
}
if (options?.includeCheckDetails) {
params.set('checkDetails', '1');
}
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/pulls/context', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubPullRequestContextResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to load pull request context');
}
return body;
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubIssuesListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
directory,
page: String(page),
});
if (options?.query) {
params.set('query', options.query);
}
const response = await runtimeFetch(
`/api/github/issues/list?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issues');
}
return payload;
},
async issueGet(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubIssueGetResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/issues/get', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubIssueGetResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue');
}
return payload;
},
async issueComments(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubIssueCommentsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/issues/comments', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubIssueCommentsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');
}
return payload;
},
});