Add GitHub integration for PRs, issues and AI PR description (#205)

* 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
This commit is contained in:
Bohdan Triapitsyn
2026-01-23 16:08:58 +02:00
committed by GitHub
parent 0e715be7d6
commit 463e9ec4e3
43 changed files with 4999 additions and 106 deletions
+116
View File
@@ -269,6 +269,11 @@ export interface GeneratedCommitMessage {
highlights: string[];
}
export interface GeneratedPullRequestDescription {
title: string;
body: string;
}
export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string): Promise<GitStatus>;
@@ -280,6 +285,10 @@ export interface GitAPI {
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
@@ -478,6 +487,112 @@ export interface PushAPI {
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
}
export type GitHubUserSummary = {
login: string;
id?: number;
avatarUrl?: string;
name?: string;
email?: string;
};
export type GitHubRepoRef = {
owner: string;
repo: string;
url: string;
};
export type GitHubChecksSummary = {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
};
export type GitHubPullRequest = {
number: number;
title: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
base: string;
head: string;
headSha?: string;
mergeable?: boolean | null;
mergeableState?: string | null;
};
export type GitHubPullRequestStatus = {
connected: boolean;
repo?: GitHubRepoRef | null;
branch?: string;
pr?: GitHubPullRequest | null;
checks?: GitHubChecksSummary | null;
canMerge?: boolean;
};
export type GitHubPullRequestCreateInput = {
directory: string;
title: string;
head: string;
base: string;
body?: string;
draft?: boolean;
};
export type GitHubPullRequestMergeInput = {
directory: string;
number: number;
method: 'merge' | 'squash' | 'rebase';
};
export type GitHubPullRequestReadyInput = {
directory: string;
number: number;
};
export type GitHubPullRequestReadyResult = {
ready: boolean;
};
export type GitHubPullRequestMergeResult = {
merged: boolean;
message?: string;
};
export type GitHubAuthStatus = {
connected: boolean;
user?: GitHubUserSummary | null;
scope?: string;
};
export type GitHubDeviceFlowStart = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresIn: number;
interval: number;
scope?: string;
};
export type GitHubDeviceFlowComplete =
| { connected: true; user: GitHubUserSummary; scope?: string }
| { connected: false; status?: string; error?: string };
export interface GitHubAPI {
authStatus(): Promise<GitHubAuthStatus>;
authStart(): Promise<GitHubDeviceFlowStart>;
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
authDisconnect(): Promise<{ removed: boolean }>;
me?(): Promise<GitHubUserSummary>;
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
}
export interface RuntimeAPIs {
runtime: RuntimeDescriptor;
terminal: TerminalAPI;
@@ -486,6 +601,7 @@ export interface RuntimeAPIs {
settings: SettingsAPI;
permissions: PermissionsAPI;
notifications: NotificationsAPI;
github?: GitHubAPI;
push?: PushAPI;
diagnostics?: DiagnosticsAPI;
tools: ToolsAPI;
+11
View File
@@ -104,6 +104,17 @@ export async function generateCommitMessage(
return gitHttp.generateCommitMessage(directory, files);
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> {
const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) {
return runtime.generatePullRequestDescription(directory, payload);
}
return gitHttp.generatePullRequestDescription(directory, payload);
}
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.listGitWorktrees(directory);
+29
View File
@@ -248,6 +248,35 @@ export async function generateCommitMessage(
};
}
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) {