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
+34
View File
@@ -432,6 +432,40 @@ export async function getDiff(directory, { path, staged = false, contextLines =
}
}
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
args.push(`${baseRef}...${headRef}`);
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
return diff;
}
export async function getRangeFiles(directory, { base, head } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
const raw = await git.raw(['diff', '--name-only', `${baseRef}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
function isImageFile(filePath) {
+149
View File
@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function readJsonFile() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read GitHub auth file:', error);
return null;
}
}
function writeJsonFile(payload) {
ensureStorageDir();
fs.writeFileSync(STORAGE_FILE, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(STORAGE_FILE, 0o600);
} catch {
// best-effort
}
}
export function getGitHubAuth() {
const data = readJsonFile();
if (!data) {
return null;
}
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
if (!accessToken) {
return null;
}
return {
accessToken,
scope: typeof data.scope === 'string' ? data.scope : '',
tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
user: data.user && typeof data.user === 'object'
? {
login: typeof data.user.login === 'string' ? data.user.login : null,
avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
id: typeof data.user.id === 'number' ? data.user.id : null,
name: typeof data.user.name === 'string' ? data.user.name : null,
email: typeof data.user.email === 'string' ? data.user.email : null,
}
: null,
};
}
export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
writeJsonFile({
accessToken,
scope: typeof scope === 'string' ? scope : '',
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
createdAt: Date.now(),
user: user && typeof user === 'object'
? {
login: typeof user.login === 'string' ? user.login : undefined,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
id: typeof user.id === 'number' ? user.id : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined,
});
}
export function clearGitHubAuth() {
try {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
} catch (error) {
console.error('Failed to clear GitHub auth file:', error);
return false;
}
}
export function getGitHubClientId() {
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
const clientId = typeof raw === 'string' ? raw.trim() : '';
if (clientId) return clientId;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_CLIENT_ID;
}
export function getGitHubScopes() {
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
if (fromEnv) return fromEnv;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_SCOPES;
}
export const GITHUB_AUTH_FILE = STORAGE_FILE;
@@ -0,0 +1,50 @@
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
const encodeForm = (params) => {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
body.set(key, String(value));
}
return body.toString();
};
async function postForm(url, params) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: encodeForm(params),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error_description || payload?.error || response.statusText;
const error = new Error(message || 'GitHub request failed');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function startDeviceFlow({ clientId, scope }) {
return postForm(DEVICE_CODE_URL, {
client_id: clientId,
scope,
});
}
export async function exchangeDeviceCode({ clientId, deviceCode }) {
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
const payload = await postForm(ACCESS_TOKEN_URL, {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
});
return payload;
}
+10
View File
@@ -0,0 +1,10 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth } from './github-auth.js';
export function getOctokitOrNull() {
const auth = getGitHubAuth();
if (!auth?.accessToken) {
return null;
}
return new Octokit({ auth: auth.accessToken });
}
+55
View File
@@ -0,0 +1,55 @@
import { getRemoteUrl } from './git-service.js';
export const parseGitHubRemoteUrl = (raw) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
// git@github.com:OWNER/REPO.git
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}` };
}
// ssh://git@github.com/OWNER/REPO.git
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}` };
}
// https://github.com/OWNER/REPO(.git)
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;
}
};
export async function resolveGitHubRepoFromDirectory(directory) {
const remoteUrl = await getRemoteUrl(directory).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
return {
repo: parseGitHubRemoteUrl(remoteUrl),
remoteUrl,
};
}