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:
committed by
GitHub
parent
0e715be7d6
commit
463e9ec4e3
@@ -12,6 +12,22 @@ import {
|
||||
installSkillsFromRepository as installSkillsFromGit,
|
||||
type SkillsCatalogSourceConfig,
|
||||
} from './skillsCatalog';
|
||||
import {
|
||||
DEFAULT_GITHUB_CLIENT_ID,
|
||||
DEFAULT_GITHUB_SCOPES,
|
||||
clearGitHubAuth,
|
||||
exchangeDeviceCode,
|
||||
fetchMe,
|
||||
readGitHubAuth,
|
||||
startDeviceFlow,
|
||||
writeGitHubAuth,
|
||||
} from './githubAuth';
|
||||
import {
|
||||
createPullRequest,
|
||||
getPullRequestStatus,
|
||||
markPullRequestReady,
|
||||
mergePullRequest,
|
||||
} from './githubPr';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
@@ -77,6 +93,67 @@ const readSettings = (ctx?: BridgeContext) => {
|
||||
};
|
||||
};
|
||||
|
||||
const readStringField = (value: unknown, key: string): string => {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidate = record[key];
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
};
|
||||
|
||||
const readBooleanField = (value: unknown, key: string): boolean | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidate = record[key];
|
||||
return typeof candidate === 'boolean' ? candidate : undefined;
|
||||
};
|
||||
|
||||
const readNumberField = (value: unknown, key: string): number | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidate = record[key];
|
||||
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
|
||||
};
|
||||
|
||||
const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === 'merge' || trimmed === 'squash' || trimmed === 'rebase') return trimmed;
|
||||
return 'merge';
|
||||
};
|
||||
|
||||
const extractZenOutputText = (value: unknown): string | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const root = value as Record<string, unknown>;
|
||||
const output = root.output;
|
||||
if (!Array.isArray(output)) return null;
|
||||
|
||||
const messageItem = output.find((item) => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
return (item as Record<string, unknown>).type === 'message';
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (!messageItem) return null;
|
||||
|
||||
const content = messageItem.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
|
||||
const textItem = content.find((item) => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
return (item as Record<string, unknown>).type === 'output_text';
|
||||
}) as Record<string, unknown> | undefined;
|
||||
|
||||
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
|
||||
return text || null;
|
||||
};
|
||||
|
||||
const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
|
||||
const current = readSettings(ctx);
|
||||
const restChanges = { ...(changes || {}) };
|
||||
@@ -877,6 +954,223 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: true, data: updated };
|
||||
}
|
||||
|
||||
case 'api:github/auth:status': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) {
|
||||
return { id, type, success: true, data: { connected: false } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await fetchMe(stored.accessToken);
|
||||
return { id, type, success: true, data: { connected: true, user, scope: stored.scope } };
|
||||
} catch (error: unknown) {
|
||||
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (status === 401 || message === 'unauthorized') {
|
||||
await clearGitHubAuth(context);
|
||||
return { id, type, success: true, data: { connected: false } };
|
||||
}
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:github/auth:start': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const settings = readSettings(ctx);
|
||||
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
|
||||
const scopes = readStringField(settings, 'githubScopes') || DEFAULT_GITHUB_SCOPES;
|
||||
const flow = await startDeviceFlow(clientId, scopes);
|
||||
return { id, type, success: true, data: flow };
|
||||
}
|
||||
|
||||
case 'api:github/auth:complete': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const deviceCode = readStringField(payload, 'deviceCode');
|
||||
if (!deviceCode) return { id, type, success: false, error: 'deviceCode is required' };
|
||||
|
||||
const settings = readSettings(ctx);
|
||||
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
|
||||
|
||||
const token = await exchangeDeviceCode(clientId, deviceCode);
|
||||
const tokenRecord = token && typeof token === 'object' ? (token as Record<string, unknown>) : null;
|
||||
const tokenError = typeof tokenRecord?.error === 'string' ? tokenRecord.error : '';
|
||||
const tokenErrorDescription = typeof tokenRecord?.error_description === 'string' ? tokenRecord.error_description : '';
|
||||
if (tokenError) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
status: tokenError,
|
||||
error: tokenErrorDescription || tokenError,
|
||||
},
|
||||
};
|
||||
}
|
||||
const accessToken = typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : '';
|
||||
if (!accessToken) {
|
||||
return { id, type, success: false, error: 'Missing access_token from GitHub' };
|
||||
}
|
||||
|
||||
const user = await fetchMe(accessToken);
|
||||
await writeGitHubAuth(context, {
|
||||
accessToken,
|
||||
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
|
||||
tokenType: typeof tokenRecord?.token_type === 'string' ? tokenRecord.token_type : undefined,
|
||||
createdAt: Date.now(),
|
||||
user,
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
connected: true,
|
||||
user,
|
||||
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'api:github/auth:disconnect': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const removed = await clearGitHubAuth(context);
|
||||
return { id, type, success: true, data: { removed } };
|
||||
}
|
||||
|
||||
case 'api:github/me': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||
try {
|
||||
const user = await fetchMe(stored.accessToken);
|
||||
return { id, type, success: true, data: user };
|
||||
} catch (error: unknown) {
|
||||
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (status === 401 || message === 'unauthorized') {
|
||||
await clearGitHubAuth(context);
|
||||
return { id, type, success: false, error: 'GitHub token expired or revoked' };
|
||||
}
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:github/pr:status': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const directory = readStringField(payload, 'directory');
|
||||
const branch = readStringField(payload, 'branch');
|
||||
if (!directory || !branch) {
|
||||
return { id, type, success: false, error: 'directory and branch are required' };
|
||||
}
|
||||
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) {
|
||||
return { id, type, success: true, data: { connected: false } };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getPullRequestStatus(
|
||||
stored.accessToken,
|
||||
stored.user?.login || null,
|
||||
directory,
|
||||
branch,
|
||||
);
|
||||
if (result.connected === false) {
|
||||
await clearGitHubAuth(context);
|
||||
}
|
||||
return { id, type, success: true, data: result };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:github/pr:create': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||
const directory = readStringField(payload, 'directory');
|
||||
const title = readStringField(payload, 'title');
|
||||
const head = readStringField(payload, 'head');
|
||||
const base = readStringField(payload, 'base');
|
||||
const body = readStringField(payload, 'body');
|
||||
const draft = readBooleanField(payload, 'draft');
|
||||
if (!directory || !title || !head || !base) {
|
||||
return { id, type, success: false, error: 'directory, title, head, base are required' };
|
||||
}
|
||||
try {
|
||||
const pr = await createPullRequest(stored.accessToken, directory, {
|
||||
directory,
|
||||
title,
|
||||
head,
|
||||
base,
|
||||
...(body ? { body } : {}),
|
||||
...(typeof draft === 'boolean' ? { draft } : {}),
|
||||
});
|
||||
return { id, type, success: true, data: pr };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:github/pr:merge': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||
const directory = readStringField(payload, 'directory');
|
||||
const method = normalizeMergeMethod(readStringField(payload, 'method') || 'merge');
|
||||
const number = readNumberField(payload, 'number') ?? 0;
|
||||
if (!directory || !number) {
|
||||
return { id, type, success: false, error: 'directory and number are required' };
|
||||
}
|
||||
try {
|
||||
const result = await mergePullRequest(stored.accessToken, directory, {
|
||||
directory,
|
||||
number,
|
||||
method,
|
||||
});
|
||||
return { id, type, success: true, data: result };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:github/pr:ready': {
|
||||
const context = ctx?.context;
|
||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||
const stored = await readGitHubAuth(context);
|
||||
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||
const directory = readStringField(payload, 'directory');
|
||||
const number = readNumberField(payload, 'number') ?? 0;
|
||||
if (!directory || !number) {
|
||||
return { id, type, success: false, error: 'directory and number are required' };
|
||||
}
|
||||
try {
|
||||
const result = await markPullRequestReady(stored.accessToken, directory, number);
|
||||
return { id, type, success: true, data: result };
|
||||
} catch (error: unknown) {
|
||||
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (status === 401 || message === 'unauthorized') {
|
||||
await clearGitHubAuth(context);
|
||||
}
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:config/reload': {
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { restarted: true } };
|
||||
@@ -1614,6 +1908,91 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/pr-description': {
|
||||
const { directory, base, head } = (payload || {}) as {
|
||||
directory?: string;
|
||||
base?: string;
|
||||
head?: string;
|
||||
};
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
if (!base || !head) {
|
||||
return { id, type, success: false, error: 'base and head are required' };
|
||||
}
|
||||
|
||||
// Collect diffs (best-effort)
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const listed = await gitService.getGitRangeFiles(directory, base, head);
|
||||
files = Array.isArray(listed) ? listed : [];
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return { id, type, success: false, error: 'No diffs available for base...head' };
|
||||
}
|
||||
|
||||
let diffSummaries = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3);
|
||||
const raw = typeof diff?.diff === 'string' ? diff.diff : '';
|
||||
if (!raw.trim()) continue;
|
||||
diffSummaries += `FILE: ${file}\n${raw}\n\n`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffSummaries.trim()) {
|
||||
return { id, type, success: false, error: 'No diffs available for selected files' };
|
||||
}
|
||||
|
||||
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5-nano',
|
||||
input: [{ role: 'user', content: prompt }],
|
||||
max_output_tokens: 1200,
|
||||
stream: false,
|
||||
reasoning: { effort: 'low' },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { id, type, success: false, error: 'Failed to generate PR description' };
|
||||
}
|
||||
const data = await response.json().catch(() => null) as unknown;
|
||||
const raw = extractZenOutputText(data);
|
||||
if (!raw) {
|
||||
return { id, type, success: false, error: 'No PR description returned by generator' };
|
||||
}
|
||||
const cleaned = String(raw)
|
||||
.trim()
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
|
||||
const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw);
|
||||
if (parsed) {
|
||||
const title = typeof parsed.title === 'string' ? parsed.title : '';
|
||||
const body = typeof parsed.body === 'string' ? parsed.body : '';
|
||||
return { id, type, success: true, data: { title, body } };
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { title: '', body: String(raw) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:git/identity': {
|
||||
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
||||
directory?: string;
|
||||
|
||||
@@ -684,6 +684,48 @@ export async function getGitDiff(
|
||||
return { diff: result.stdout };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff between two refs for a file (base...head).
|
||||
*/
|
||||
export async function getGitRangeDiff(
|
||||
directory: string,
|
||||
base: string,
|
||||
head: string,
|
||||
filePath: string,
|
||||
contextLines = 3
|
||||
): Promise<{ diff: string }> {
|
||||
const baseRef = (base || '').trim();
|
||||
const headRef = (head || '').trim();
|
||||
if (!baseRef || !headRef) {
|
||||
return { diff: '' };
|
||||
}
|
||||
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${baseRef}...${headRef}`, '--', filePath];
|
||||
const result = await execGit(args, directory);
|
||||
return { diff: result.stdout };
|
||||
}
|
||||
|
||||
/**
|
||||
* List files changed between two refs (base...head).
|
||||
*/
|
||||
export async function getGitRangeFiles(
|
||||
directory: string,
|
||||
base: string,
|
||||
head: string
|
||||
): Promise<string[]> {
|
||||
const baseRef = (base || '').trim();
|
||||
const headRef = (head || '').trim();
|
||||
if (!baseRef || !headRef) {
|
||||
return [];
|
||||
}
|
||||
const args = ['diff', '--name-only', `${baseRef}...${headRef}`];
|
||||
const result = await execGit(args, directory);
|
||||
if (result.exitCode !== 0) return [];
|
||||
return String(result.stdout || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file diff with original and modified content
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const API_USER_URL = 'https://api.github.com/user';
|
||||
const API_EMAILS_URL = 'https://api.github.com/user/emails';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
|
||||
export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||
|
||||
type StoredAuth = {
|
||||
accessToken: string;
|
||||
scope?: string;
|
||||
tokenType?: string;
|
||||
createdAt?: number;
|
||||
user?: { login: string; id?: number; avatarUrl?: string };
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
const authFilePath = (context: vscode.ExtensionContext) =>
|
||||
path.join(context.globalStorageUri.fsPath, 'github-auth.json');
|
||||
|
||||
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
|
||||
try {
|
||||
const raw = await fs.readFile(authFilePath(context), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const token = typeof parsed.accessToken === 'string' ? parsed.accessToken : '';
|
||||
if (!token) return null;
|
||||
return parsed as StoredAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
|
||||
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(auth, null, 2), 'utf8');
|
||||
try {
|
||||
// best-effort perms on unix
|
||||
await fs.chmod(authFilePath(context), 0o600);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
|
||||
try {
|
||||
await fs.rm(authFilePath(context));
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const postForm = async <T extends JsonRecord>(url: string, params: Record<string, string>): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as T | null;
|
||||
if (!response.ok) {
|
||||
const errorDescription = typeof payload?.error_description === 'string' ? payload.error_description : '';
|
||||
const error = typeof payload?.error === 'string' ? payload.error : '';
|
||||
throw new Error(errorDescription || error || response.statusText);
|
||||
}
|
||||
return payload as T;
|
||||
};
|
||||
|
||||
export const startDeviceFlow = async (clientId: string, scope: string) => {
|
||||
const payload = await postForm<DeviceCodeResponse>(DEVICE_CODE_URL, { client_id: clientId, scope });
|
||||
return {
|
||||
deviceCode: payload.device_code,
|
||||
userCode: payload.user_code,
|
||||
verificationUri: payload.verification_uri,
|
||||
verificationUriComplete: payload.verification_uri_complete,
|
||||
expiresIn: payload.expires_in,
|
||||
interval: payload.interval,
|
||||
scope,
|
||||
};
|
||||
};
|
||||
|
||||
export const exchangeDeviceCode = async (clientId: string, deviceCode: string) => {
|
||||
const payload = await postForm<TokenResponse>(ACCESS_TOKEN_URL, {
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const fetchMe = async (accessToken: string) => {
|
||||
const response = await fetch(API_USER_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as JsonRecord | null;
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(`GitHub /user failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const name = typeof payload.name === 'string' ? payload.name : undefined;
|
||||
let email = typeof payload.email === 'string' ? payload.email : undefined;
|
||||
if (!email) {
|
||||
try {
|
||||
const emailsResponse = await fetch(API_EMAILS_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
if (emailsResponse.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const list = (await emailsResponse.json().catch(() => null)) as Array<Record<string, unknown>> | null;
|
||||
if (emailsResponse.ok && Array.isArray(list)) {
|
||||
const primaryVerified = list.find((e) => Boolean(e?.primary) && Boolean(e?.verified) && typeof e?.email === 'string');
|
||||
const anyVerified = list.find((e) => Boolean(e?.verified) && typeof e?.email === 'string');
|
||||
email = (primaryVerified?.email as string | undefined) || (anyVerified?.email as string | undefined);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
login: String(payload.login || ''),
|
||||
id: typeof payload.id === 'number' ? payload.id : undefined,
|
||||
avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : undefined,
|
||||
name,
|
||||
email,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubChecksSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type GitHubPullRequestStatus = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
branch?: string;
|
||||
pr?: GitHubPullRequest | null;
|
||||
checks?: GitHubChecksSummary | null;
|
||||
canMerge?: boolean;
|
||||
};
|
||||
|
||||
type GitHubPullRequestCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
head: string;
|
||||
base: string;
|
||||
body?: string;
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
type GitHubPullRequestMergeInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
method: 'merge' | 'squash' | 'rebase';
|
||||
};
|
||||
|
||||
type GitHubPullRequestMergeResult = { merged: boolean; message?: string };
|
||||
|
||||
const parseGitHubRemoteUrl = (raw: string): GitHubRepoRef | null => {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
const rest = value.slice('git@github.com:'.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}` };
|
||||
}
|
||||
|
||||
if (value.startsWith('ssh://git@github.com/')) {
|
||||
const rest = value.slice('ssh://git@github.com/'.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}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== 'github.com') 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}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginRemoteUrl = async (directory: string): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', directory, 'remote', 'get-url', 'origin']);
|
||||
const url = String(stdout || '').trim();
|
||||
return url || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveRepoFromDirectory = async (directory: string): Promise<GitHubRepoRef | null> => {
|
||||
const remote = await getOriginRemoteUrl(directory);
|
||||
if (!remote) return null;
|
||||
return parseGitHubRemoteUrl(remote);
|
||||
};
|
||||
|
||||
const githubFetch = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
export const getPullRequestStatus = async (
|
||||
accessToken: string,
|
||||
userLogin: string | null,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): Promise<GitHubPullRequestStatus> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
|
||||
}
|
||||
|
||||
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
listUrl.searchParams.set('state', 'open');
|
||||
listUrl.searchParams.set('head', `${repo.owner}:${branch}`);
|
||||
listUrl.searchParams.set('per_page', '10');
|
||||
|
||||
const listResp = await githubFetch(listUrl.toString(), accessToken);
|
||||
if (listResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const list = await jsonOrNull<Array<{ number: number }>>(listResp);
|
||||
if (!listResp.ok || !Array.isArray(list) || list.length === 0) {
|
||||
return { connected: true, repo, branch, pr: null, checks: null, canMerge: false };
|
||||
}
|
||||
|
||||
const number = list[0].number;
|
||||
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||
if (prResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||
if (!prResp.ok || !prJson) {
|
||||
throw new Error('Failed to load PR');
|
||||
}
|
||||
|
||||
const merged = Boolean(prJson.merged);
|
||||
const prState = readString(prJson.state);
|
||||
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||
const pr: GitHubPullRequest = {
|
||||
number: typeof prJson.number === 'number' ? prJson.number : 0,
|
||||
title: readString(prJson.title) || '',
|
||||
url: readString(prJson.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(prJson.draft),
|
||||
base: readString((prJson.base as JsonRecord | undefined)?.ref) || '',
|
||||
head: readString((prJson.head as JsonRecord | undefined)?.ref) || '',
|
||||
headSha: readString((prJson.head as JsonRecord | undefined)?.sha) || undefined,
|
||||
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||
};
|
||||
|
||||
let checks: GitHubChecksSummary | null = null;
|
||||
if (pr.headSha) {
|
||||
const statusResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`,
|
||||
accessToken,
|
||||
);
|
||||
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||
if (statusResp.ok && statusJson) {
|
||||
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
const st = readString((s as JsonRecord | null)?.state);
|
||||
if (st === 'success') counts.success += 1;
|
||||
else if (st === 'failure' || st === 'error') counts.failure += 1;
|
||||
else if (st === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state2 = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state: state2, total, ...counts };
|
||||
}
|
||||
}
|
||||
|
||||
let canMerge = false;
|
||||
if (userLogin) {
|
||||
const permResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/collaborators/${encodeURIComponent(userLogin)}/permission`,
|
||||
accessToken,
|
||||
);
|
||||
const permJson = await jsonOrNull<{ permission?: string }>(permResp);
|
||||
const perm = typeof permJson?.permission === 'string' ? permJson.permission : '';
|
||||
canMerge = perm === 'admin' || perm === 'maintain' || perm === 'write';
|
||||
}
|
||||
|
||||
return { connected: true, repo, branch, pr, checks, canMerge };
|
||||
};
|
||||
|
||||
export const createPullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestCreateInput,
|
||||
): Promise<GitHubPullRequest> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
head: payload.head,
|
||||
base: payload.base,
|
||||
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||
...(typeof payload.draft === 'boolean' ? { draft: payload.draft } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to create PR');
|
||||
}
|
||||
|
||||
return {
|
||||
number: typeof json.number === 'number' ? json.number : 0,
|
||||
title: readString(json.title) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state: readString(json.state) === 'closed' ? 'closed' : 'open',
|
||||
draft: Boolean(json.draft),
|
||||
base: readString((json.base as JsonRecord | undefined)?.ref) || payload.base,
|
||||
head: readString((json.head as JsonRecord | undefined)?.ref) || payload.head,
|
||||
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
|
||||
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
|
||||
mergeableState: readString(json.mergeable_state) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const mergePullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestMergeInput,
|
||||
): Promise<GitHubPullRequestMergeResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}/merge`, accessToken, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ merge_method: payload.method }),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to merge this PR');
|
||||
}
|
||||
if (resp.status === 405 || resp.status === 409) {
|
||||
return { merged: false, message: 'PR not mergeable' };
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to merge PR');
|
||||
}
|
||||
return { merged: Boolean(json.merged), message: readString(json.message) || undefined };
|
||||
};
|
||||
|
||||
export const markPullRequestReady = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<{ ready: boolean }> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||
if (prResp.status === 401) {
|
||||
return { ready: false };
|
||||
}
|
||||
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||
const nodeId = typeof prJson?.node_id === 'string' ? prJson.node_id : '';
|
||||
const isDraft = Boolean((prJson as Record<string, unknown> | null)?.draft);
|
||||
if (!prResp.ok || !nodeId) {
|
||||
throw new Error('Failed to resolve PR node id');
|
||||
}
|
||||
|
||||
if (!isDraft) {
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/graphql`, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }',
|
||||
variables: { pullRequestId: nodeId },
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to mark PR ready');
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to mark PR ready');
|
||||
}
|
||||
if (json.errors) {
|
||||
throw new Error('GitHub GraphQL error');
|
||||
}
|
||||
return { ready: true };
|
||||
};
|
||||
Reference in New Issue
Block a user