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.
299 lines
13 KiB
TypeScript
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;
|
|
},
|
|
});
|