* feat: integrate GitHub OAuth device flow across runtimes Add GitHub OAuth device flow endpoints across runtimes Introduce GitHubSettings UI panel and sidebar entry Persist GitHub auth state in per-runtime storage * feat: add GitHub PR status and PR description generation Show PR status for the current branch in the Git view Generate a pull request description from the diff between base and head Expose prStatus, prCreate, and prMerge APIs in web and desktop clients * feat: add GitHub PR ready for review Add API to mark pull requests as ready for review Show a Ready button for draft PRs and reflect status in UI Handle token expiration and GraphQL errors when marking ready
611 lines
19 KiB
TypeScript
611 lines
19 KiB
TypeScript
|
|
|
|
import type {
|
|
GitStatus,
|
|
GitDiffResponse,
|
|
GetGitDiffOptions,
|
|
GitFileDiffResponse,
|
|
GetGitFileDiffOptions,
|
|
GitBranch,
|
|
GitDeleteBranchPayload,
|
|
GitDeleteRemoteBranchPayload,
|
|
GeneratedCommitMessage,
|
|
GitWorktreeInfo,
|
|
GitAddWorktreePayload,
|
|
GitRemoveWorktreePayload,
|
|
CreateGitCommitOptions,
|
|
GitCommitResult,
|
|
GitPushResult,
|
|
GitPullResult,
|
|
GitLogOptions,
|
|
GitLogResponse,
|
|
GitCommitFilesResponse,
|
|
GitIdentityProfile,
|
|
GitIdentitySummary,
|
|
DiscoveredGitCredential,
|
|
} from './api/types';
|
|
|
|
declare global {
|
|
interface Window {
|
|
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
|
origin: string;
|
|
opencodePort: number | null;
|
|
apiPrefix: string;
|
|
cliAvailable: boolean;
|
|
};
|
|
}
|
|
}
|
|
|
|
const resolveBaseOrigin = (): string => {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
const desktopOrigin = window.__OPENCHAMBER_DESKTOP_SERVER__?.origin;
|
|
if (desktopOrigin) {
|
|
return desktopOrigin;
|
|
}
|
|
return window.location.origin;
|
|
};
|
|
|
|
const API_BASE = '/api/git';
|
|
|
|
function buildUrl(
|
|
path: string,
|
|
directory: string | null | undefined,
|
|
params?: Record<string, string | number | boolean | undefined>
|
|
): string {
|
|
const url = new URL(path, resolveBaseOrigin());
|
|
if (directory) {
|
|
url.searchParams.set('directory', directory);
|
|
}
|
|
|
|
if (params) {
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined) continue;
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
}
|
|
|
|
return url.toString();
|
|
}
|
|
|
|
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/check`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to check git repository: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
return data.isGitRepository;
|
|
}
|
|
|
|
export async function getGitStatus(directory: string): Promise<GitStatus> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/status`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git status: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
|
const { path, staged, contextLines } = options;
|
|
if (!path) {
|
|
throw new Error('path is required to fetch git diff');
|
|
}
|
|
|
|
const response = await fetch(
|
|
buildUrl(`${API_BASE}/diff`, directory, {
|
|
path,
|
|
staged: staged ? 'true' : undefined,
|
|
context: contextLines,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git diff: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
|
const { path, staged } = options;
|
|
if (!path) {
|
|
throw new Error('path is required to fetch git file diff');
|
|
}
|
|
|
|
const response = await fetch(
|
|
buildUrl(`${API_BASE}/file-diff`, directory, {
|
|
path,
|
|
staged: staged ? 'true' : undefined,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git file diff: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
|
if (!filePath) {
|
|
throw new Error('path is required to revert git changes');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: filePath }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response
|
|
.json()
|
|
.catch(() => ({ error: response.statusText }));
|
|
throw new Error(message.error || 'Failed to revert git changes');
|
|
}
|
|
}
|
|
|
|
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
|
if (!directory) {
|
|
return false;
|
|
}
|
|
const response = await fetch(buildUrl(`${API_BASE}/worktree-type`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to detect worktree type: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
return Boolean(data.linked);
|
|
}
|
|
|
|
export async function getGitBranches(directory: string): Promise<GitBranch> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get branches: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
|
if (!payload?.branch) {
|
|
throw new Error('branch is required to delete a branch');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete branch');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
|
if (!payload?.branch) {
|
|
throw new Error('branch is required to delete remote branch');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete remote branch');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function generateCommitMessage(
|
|
directory: string,
|
|
files: string[]
|
|
): Promise<{ message: GeneratedCommitMessage }> {
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
throw new Error('No files provided to generate commit message');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ files }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to generate commit message');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data?.message || typeof data.message !== 'object') {
|
|
throw new Error('Malformed commit generation response');
|
|
}
|
|
|
|
const subject =
|
|
typeof data.message.subject === 'string' && data.message.subject.trim().length > 0
|
|
? data.message.subject.trim()
|
|
: '';
|
|
|
|
const highlights: string[] = Array.isArray(data.message.highlights)
|
|
? (data.message.highlights as unknown[])
|
|
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
.map((item) => (item as string).trim())
|
|
: [];
|
|
|
|
return {
|
|
message: {
|
|
subject,
|
|
highlights,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function generatePullRequestDescription(
|
|
directory: string,
|
|
payload: { base: string; head: string }
|
|
): Promise<{ title: string; body: string }> {
|
|
const { base, head } = payload;
|
|
if (!base || !head) {
|
|
throw new Error('base and head are required');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ base, head }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to generate PR description');
|
|
}
|
|
|
|
const data = await response.json().catch(() => null);
|
|
const title = typeof data?.title === 'string' ? data.title : '';
|
|
const body = typeof data?.body === 'string' ? data.body : '';
|
|
if (!title && !body) {
|
|
throw new Error('Malformed PR description response');
|
|
}
|
|
return { title, body };
|
|
}
|
|
|
|
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to list worktrees');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
|
if (!payload?.path || !payload?.branch) {
|
|
throw new Error('path and branch are required to add a worktree');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to add worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
|
if (!payload?.path) {
|
|
throw new Error('path is required to remove a worktree');
|
|
}
|
|
|
|
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to remove worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to update git ignore');
|
|
}
|
|
}
|
|
|
|
export async function createGitCommit(
|
|
directory: string,
|
|
message: string,
|
|
options: CreateGitCommitOptions = {}
|
|
): Promise<GitCommitResult> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/commit`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
message,
|
|
addAll: options.addAll ?? false,
|
|
files: options.files,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create commit');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitPush(
|
|
directory: string,
|
|
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
|
|
): Promise<GitPushResult> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/push`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to push');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitPull(
|
|
directory: string,
|
|
options: { remote?: string; branch?: string } = {}
|
|
): Promise<GitPullResult> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to pull');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitFetch(
|
|
directory: string,
|
|
options: { remote?: string; branch?: string } = {}
|
|
): Promise<{ success: boolean }> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/fetch`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to fetch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ branch }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to checkout branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function createBranch(
|
|
directory: string,
|
|
name: string,
|
|
startPoint?: string
|
|
): Promise<{ success: boolean; branch: string }> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, startPoint }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function renameBranch(
|
|
directory: string,
|
|
oldName: string,
|
|
newName: string
|
|
): Promise<{ success: boolean; branch: string }> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldName, newName }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to rename branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitLog(
|
|
directory: string,
|
|
options: GitLogOptions = {}
|
|
): Promise<GitLogResponse> {
|
|
const response = await fetch(
|
|
buildUrl(`${API_BASE}/log`, directory, {
|
|
maxCount: options.maxCount,
|
|
from: options.from,
|
|
to: options.to,
|
|
file: options.file,
|
|
})
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git log: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getCommitFiles(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<GitCommitFilesResponse> {
|
|
const response = await fetch(
|
|
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get commit files: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git identities: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(profile),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to update git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteGitIdentity(id: string): Promise<void> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
|
method: 'DELETE',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete git identity');
|
|
}
|
|
}
|
|
|
|
export async function getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
|
if (!directory) {
|
|
return null;
|
|
}
|
|
const response = await fetch(buildUrl(`${API_BASE}/current-identity`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get current git identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
return {
|
|
userName: data.userName ?? null,
|
|
userEmail: data.userEmail ?? null,
|
|
sshCommand: data.sshCommand ?? null,
|
|
};
|
|
}
|
|
|
|
export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
|
if (!directory) {
|
|
return false;
|
|
}
|
|
const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to check local identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
return data?.hasLocalIdentity === true;
|
|
}
|
|
|
|
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get global git identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
if (!data || (!data.userName && !data.userEmail)) {
|
|
return null;
|
|
}
|
|
return {
|
|
userName: data.userName ?? null,
|
|
userEmail: data.userEmail ?? null,
|
|
sshCommand: data.sshCommand ?? null,
|
|
};
|
|
}
|
|
|
|
export async function setGitIdentity(
|
|
directory: string,
|
|
profileId: string
|
|
): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/set-identity`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profileId }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to set git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
|
const response = await fetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to discover git credentials: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
|
if (!directory) {
|
|
return null;
|
|
}
|
|
const response = await fetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const data = await response.json();
|
|
return data.url ?? null;
|
|
}
|