chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -120,7 +120,7 @@ async function buildGitEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
/**
|
||||
* Initialize the git extension API
|
||||
*/
|
||||
export async function initGitExtension(): Promise<GitAPI | null> {
|
||||
async function initGitExtension(): Promise<GitAPI | null> {
|
||||
if (gitApi && gitExtensionEnabled) {
|
||||
return gitApi;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export async function initGitExtension(): Promise<GitAPI | null> {
|
||||
/**
|
||||
* Get the git API, initializing if necessary
|
||||
*/
|
||||
export async function getGitApi(): Promise<GitAPI | null> {
|
||||
async function getGitApi(): Promise<GitAPI | null> {
|
||||
if (gitApi && gitExtensionEnabled) {
|
||||
return gitApi;
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export async function getGitApi(): Promise<GitAPI | null> {
|
||||
/**
|
||||
* Get repository for a given directory
|
||||
*/
|
||||
export async function getRepository(directory: string): Promise<Repository | null> {
|
||||
async function getRepository(directory: string): Promise<Repository | null> {
|
||||
const api = await getGitApi();
|
||||
if (!api) return null;
|
||||
|
||||
@@ -333,20 +333,20 @@ export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
|
||||
// ============== Status Operations ==============
|
||||
|
||||
export interface GitStatusFile {
|
||||
interface GitStatusFile {
|
||||
path: string;
|
||||
index: string;
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitMergeInProgress {
|
||||
interface GitMergeInProgress {
|
||||
/** Short SHA of MERGE_HEAD */
|
||||
head: string;
|
||||
/** First line of MERGE_MSG */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseInProgress {
|
||||
interface GitRebaseInProgress {
|
||||
/** Branch name being rebased */
|
||||
headName: string;
|
||||
/** Short SHA of the onto commit */
|
||||
@@ -591,7 +591,7 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
|
||||
// ============== Branch Operations ==============
|
||||
|
||||
export interface GitBranchDetails {
|
||||
interface GitBranchDetails {
|
||||
current: boolean;
|
||||
name: string;
|
||||
commit: string;
|
||||
@@ -719,43 +719,6 @@ export async function checkoutBranch(directory: string, branch: string): Promise
|
||||
return { success: result.exitCode === 0, branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach HEAD at current commit
|
||||
* This allows the current branch to be used in a worktree
|
||||
*/
|
||||
export async function detachHead(directory: string): Promise<{ success: boolean; commit: string }> {
|
||||
// Get current HEAD commit
|
||||
const headResult = await execGit(['rev-parse', 'HEAD'], directory);
|
||||
if (headResult.exitCode !== 0) {
|
||||
return { success: false, commit: '' };
|
||||
}
|
||||
|
||||
const commit = headResult.stdout.trim();
|
||||
|
||||
// Checkout the commit directly to detach HEAD
|
||||
const result = await execGit(['checkout', '--detach', 'HEAD'], directory);
|
||||
return { success: result.exitCode === 0, commit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HEAD branch name (null if detached)
|
||||
*/
|
||||
export async function getCurrentBranch(directory: string): Promise<string | null> {
|
||||
const repo = await getRepository(directory);
|
||||
|
||||
if (repo) {
|
||||
const head = repo.state.HEAD;
|
||||
return head?.name || null;
|
||||
}
|
||||
|
||||
// Fallback to raw git
|
||||
const result = await execGit(['symbolic-ref', '--short', 'HEAD'], directory);
|
||||
if (result.exitCode === 0) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
return null; // Detached HEAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch
|
||||
*/
|
||||
@@ -825,7 +788,7 @@ type WorktreeListEntry = {
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
export interface GitWorktreeValidationError {
|
||||
interface GitWorktreeValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -2071,43 +2034,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branches that are available for worktree checkout
|
||||
* (branches not already checked out in any worktree)
|
||||
*/
|
||||
export async function getAvailableBranchesForWorktree(directory: string): Promise<GitBranchDetails[]> {
|
||||
const [branches, worktrees] = await Promise.all([
|
||||
getGitBranches(directory),
|
||||
listGitWorktrees(directory),
|
||||
]);
|
||||
|
||||
// Get set of branches already checked out in worktrees
|
||||
const checkedOutBranches = new Set<string>();
|
||||
for (const wt of worktrees) {
|
||||
if (wt.branch) {
|
||||
checkedOutBranches.add(wt.branch.replace(/^refs\/heads\//, ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out branches that are already checked out
|
||||
const availableBranches: GitBranchDetails[] = [];
|
||||
for (const name of branches.all) {
|
||||
// Skip remote branches for worktree creation
|
||||
if (name.startsWith('remotes/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!checkedOutBranches.has(name)) {
|
||||
const details = branches.branches[name];
|
||||
if (details) {
|
||||
availableBranches.push(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableBranches;
|
||||
}
|
||||
|
||||
// ============== Diff Operations ==============
|
||||
|
||||
/**
|
||||
@@ -2280,10 +2206,6 @@ export async function revertGitFile(
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
await stageGitFiles(directory, [filePath]);
|
||||
}
|
||||
|
||||
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
||||
|
||||
@@ -2319,10 +2241,6 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro
|
||||
}
|
||||
}
|
||||
|
||||
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
await unstageGitFiles(directory, [filePath]);
|
||||
}
|
||||
|
||||
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
||||
|
||||
@@ -3647,38 +3565,6 @@ export async function resetToCommit(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
/**
|
||||
* Stash changes
|
||||
*/
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const args = ['stash', 'push'];
|
||||
|
||||
// Include untracked files by default
|
||||
if (options?.includeUntracked !== false) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
|
||||
if (options?.message) {
|
||||
args.push('-m', options.message);
|
||||
}
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash
|
||||
*/
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['stash', 'pop'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
// ============== Worktree Validation & Canonicalization ==============
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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 = 'Ov23lizomPOC3eFYo56r';
|
||||
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 };
|
||||
accountId?: string;
|
||||
current?: boolean;
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const resolveAccountId = (auth: StoredAuth): string => {
|
||||
if (typeof auth.accountId === 'string' && auth.accountId.trim()) {
|
||||
return auth.accountId.trim();
|
||||
}
|
||||
if (auth.user?.login) {
|
||||
return auth.user.login.trim();
|
||||
}
|
||||
if (typeof auth.user?.id === 'number') {
|
||||
return String(auth.user.id);
|
||||
}
|
||||
if (auth.accessToken) {
|
||||
return `token:${auth.accessToken.slice(0, 8)}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeAuthList = (list: StoredAuth[]): { list: StoredAuth[]; changed: boolean } => {
|
||||
let changed = false;
|
||||
let currentFound = false;
|
||||
const normalized = list
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
accountId: resolveAccountId(entry),
|
||||
current: Boolean(entry.current),
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.accessToken));
|
||||
|
||||
normalized.forEach((entry) => {
|
||||
if (entry.current && !currentFound) {
|
||||
currentFound = true;
|
||||
} else if (entry.current && currentFound) {
|
||||
entry.current = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentFound && normalized.length > 0) {
|
||||
normalized[0].current = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { list: normalized, changed };
|
||||
};
|
||||
|
||||
export const readGitHubAuthList = async (context: vscode.ExtensionContext): Promise<StoredAuth[]> => {
|
||||
try {
|
||||
const raw = await fs.readFile(authFilePath(context), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed) return [];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const { list: normalized, changed } = normalizeAuthList(list as StoredAuth[]);
|
||||
if (changed) {
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(normalized, null, 2), 'utf8');
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return null;
|
||||
return list.find((entry) => entry.current) ?? list[0] ?? null;
|
||||
};
|
||||
|
||||
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
const next = {
|
||||
...auth,
|
||||
accountId: resolveAccountId(auth),
|
||||
current: true,
|
||||
};
|
||||
const index = list.findIndex((entry) => entry.accountId === next.accountId);
|
||||
if (index >= 0) {
|
||||
list[index] = next;
|
||||
} else {
|
||||
list.push(next);
|
||||
}
|
||||
list.forEach((entry) => {
|
||||
entry.current = entry.accountId === next.accountId;
|
||||
});
|
||||
|
||||
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
|
||||
try {
|
||||
// best-effort perms on unix
|
||||
await fs.chmod(authFilePath(context), 0o600);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const activateGitHubAuth = async (context: vscode.ExtensionContext, accountId: string): Promise<boolean> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return false;
|
||||
const id = accountId.trim();
|
||||
if (!id) return false;
|
||||
let found = false;
|
||||
list.forEach((entry) => {
|
||||
if (entry.accountId === id) {
|
||||
entry.current = true;
|
||||
found = true;
|
||||
} else {
|
||||
entry.current = false;
|
||||
}
|
||||
});
|
||||
if (!found) return false;
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
|
||||
try {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return true;
|
||||
const remaining = list.filter((entry) => !entry.current);
|
||||
if (!remaining.length) {
|
||||
await fs.rm(authFilePath(context));
|
||||
return true;
|
||||
}
|
||||
remaining[0].current = true;
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(remaining, null, 2), 'utf8');
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,280 +0,0 @@
|
||||
import { resolveRepoFromDirectory } from './githubPr';
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubIssuesListResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
issues?: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed';
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
labels?: Array<{ name: string; color?: string }>;
|
||||
}>;
|
||||
page?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
type GitHubIssueGetResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
issue?: {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed';
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
labels?: Array<{ name: string; color?: string }>;
|
||||
body?: string;
|
||||
assignees?: Array<{ login: string; id?: number; avatarUrl?: string; name?: string; email?: string }>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type GitHubIssueCommentsResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
comments?: Array<{
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
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 : '');
|
||||
|
||||
const mapUser = (raw: unknown) => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const login = readString(rec?.login);
|
||||
if (!login) return null;
|
||||
return {
|
||||
login,
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
avatarUrl: readString(rec?.avatar_url) || undefined,
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => {
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
return list
|
||||
.map((item) => {
|
||||
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
color: readString(rec?.color) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ name: string; color?: string }>;
|
||||
};
|
||||
|
||||
export const listIssues = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubIssuesListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, issues: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:issue state:open`;
|
||||
const url = new URL(`${API_BASE}/search/issues`);
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const json = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to search issues');
|
||||
}
|
||||
|
||||
const totalCount = typeof json.total_count === 'number' ? json.total_count : 0;
|
||||
const items = Array.isArray(json.items) ? json.items : [];
|
||||
const issues = items
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec || rec.pull_request) return null;
|
||||
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||
if (!number) return null;
|
||||
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||
return {
|
||||
number,
|
||||
title: readString(rec.title) || '',
|
||||
url: readString(rec.html_url) || '',
|
||||
state,
|
||||
author: mapUser(rec.user),
|
||||
labels: mapLabels(rec.labels),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + items.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const link = resp.headers.get('link') || '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) {
|
||||
throw new Error('Failed to load issues');
|
||||
}
|
||||
|
||||
const issues = json
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec || rec.pull_request) return null;
|
||||
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||
if (!number) return null;
|
||||
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||
return {
|
||||
number,
|
||||
title: readString(rec.title) || '',
|
||||
url: readString(rec.html_url) || '',
|
||||
state,
|
||||
author: mapUser(rec.user),
|
||||
labels: mapLabels(rec.labels),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||
|
||||
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||
};
|
||||
|
||||
export const getIssue = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<GitHubIssueGetResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, issue: null };
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}`, accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to load issue');
|
||||
}
|
||||
if (json.pull_request) {
|
||||
throw new Error('Not a GitHub issue');
|
||||
}
|
||||
|
||||
const state = readString(json.state) === 'closed' ? 'closed' : 'open';
|
||||
const assigneesRaw = Array.isArray(json.assignees) ? json.assignees : [];
|
||||
const assignees = assigneesRaw.map(mapUser).filter(Boolean) as Array<NonNullable<ReturnType<typeof mapUser>>>;
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
repo,
|
||||
issue: {
|
||||
number: typeof json.number === 'number' ? json.number : number,
|
||||
title: readString(json.title) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state,
|
||||
author: mapUser(json.user),
|
||||
labels: mapLabels(json.labels),
|
||||
body: readString(json.body) || '',
|
||||
assignees,
|
||||
createdAt: readString(json.created_at) || undefined,
|
||||
updatedAt: readString(json.updated_at) || undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const listIssueComments = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<GitHubIssueCommentsResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, comments: [] };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments`);
|
||||
url.searchParams.set('per_page', '100');
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) {
|
||||
throw new Error('Failed to load issue comments');
|
||||
}
|
||||
|
||||
const comments = json
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec) return null;
|
||||
const id = typeof rec.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec.html_url) || '',
|
||||
body: readString(rec.body) || '',
|
||||
author: mapUser(rec.user),
|
||||
createdAt: readString(rec.created_at) || undefined,
|
||||
updatedAt: readString(rec.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssueCommentsResult['comments'];
|
||||
|
||||
return { connected: true, repo, comments: comments || [] };
|
||||
};
|
||||
@@ -1,486 +0,0 @@
|
||||
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;
|
||||
body?: 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 GitHubPullRequestUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
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 listNumberByHead = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('head', `${repo.owner}:${branch}`);
|
||||
url.searchParams.set('per_page', '10');
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return null;
|
||||
}
|
||||
const list = await jsonOrNull<Array<{ number: number }>>(resp);
|
||||
return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null;
|
||||
};
|
||||
|
||||
const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('per_page', '100');
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return null;
|
||||
}
|
||||
const list = await jsonOrNull<Array<JsonRecord>>(resp);
|
||||
if (!resp.ok || !Array.isArray(list)) return null;
|
||||
|
||||
const match = list.find((prItem) => {
|
||||
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
|
||||
return readString(head?.ref) === branch;
|
||||
});
|
||||
return match && typeof match.number === 'number' ? match.number : null;
|
||||
};
|
||||
|
||||
// PR status by branch:
|
||||
// - Prefer open PRs.
|
||||
// - If none, surface closed/merged PRs.
|
||||
// - Fork PR support: head owner differs -> head filter yields empty; fall back to matching head.ref.
|
||||
let number = await listNumberByHead('open');
|
||||
if (!number) number = await listNumberByHead('closed');
|
||||
if (!number) number = await listNumberByHeadRef('open');
|
||||
if (!number) number = await listNumberByHeadRef('closed');
|
||||
|
||||
// Detect auth revocation (best-effort)
|
||||
if (number === null) {
|
||||
const probeUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
probeUrl.searchParams.set('state', 'open');
|
||||
probeUrl.searchParams.set('per_page', '1');
|
||||
const probeResp = await githubFetch(probeUrl.toString(), accessToken);
|
||||
if (probeResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!number) {
|
||||
return { connected: true, repo, branch, pr: null, checks: null, canMerge: false };
|
||||
}
|
||||
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 || prJson.merged_at);
|
||||
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) || '',
|
||||
body: readString(prJson.body) || '',
|
||||
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) {
|
||||
// Prefer check-runs (Actions)
|
||||
const runsResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/check-runs`,
|
||||
accessToken,
|
||||
);
|
||||
const runsJson = await jsonOrNull<JsonRecord>(runsResp);
|
||||
const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
|
||||
? ((runsJson as JsonRecord).check_runs as unknown[])
|
||||
: [];
|
||||
|
||||
if (runsResp.ok && runs.length > 0) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
runs.forEach((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const status = readString(rec?.status);
|
||||
const conclusion = readString(rec?.conclusion);
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 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 };
|
||||
}
|
||||
|
||||
// Fallback: classic statuses
|
||||
if (!checks) {
|
||||
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) || '',
|
||||
body: readString(json.body) || '',
|
||||
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 updatePullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestUpdateInput,
|
||||
): 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/${payload.number}`, accessToken, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to edit this PR');
|
||||
}
|
||||
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) {
|
||||
const message = readString(json?.message);
|
||||
const firstError = Array.isArray(json?.errors) && json.errors.length > 0
|
||||
? readString((json.errors[0] as JsonRecord)?.message || (json.errors[0] as JsonRecord)?.code)
|
||||
: '';
|
||||
const details = [message, firstError].filter(Boolean).join(' · ');
|
||||
throw new Error(details || 'Failed to update PR');
|
||||
}
|
||||
|
||||
const merged = Boolean(json.merged || json.merged_at);
|
||||
const state = merged ? 'merged' : (readString(json.state) === 'closed' ? 'closed' : 'open');
|
||||
|
||||
return {
|
||||
number: typeof json.number === 'number' ? json.number : payload.number,
|
||||
title: readString(json.title) || payload.title,
|
||||
body: readString(json.body) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(json.draft),
|
||||
base: readString((json.base as JsonRecord | undefined)?.ref) || '',
|
||||
head: readString((json.head as JsonRecord | undefined)?.ref) || '',
|
||||
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 };
|
||||
};
|
||||
@@ -1,672 +0,0 @@
|
||||
import { resolveRepoFromDirectory } from './githubPr';
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubUserSummary = { login: string; id?: number; avatarUrl?: string; name?: string; email?: string };
|
||||
|
||||
type GitHubChecksSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
type GitHubCheckRun = {
|
||||
id?: number;
|
||||
name: string;
|
||||
app?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
detailsUrl?: string;
|
||||
output?: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
text?: string;
|
||||
};
|
||||
job?: {
|
||||
runId?: number;
|
||||
jobId?: number;
|
||||
url?: string;
|
||||
name?: string;
|
||||
conclusion?: string | null;
|
||||
steps?: Array<{
|
||||
name: string;
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
number?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
};
|
||||
annotations?: Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string; sshUrl?: string };
|
||||
|
||||
type GitHubPullRequestSummary = {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
base: string;
|
||||
head: string;
|
||||
headSha?: string;
|
||||
mergeable?: boolean | null;
|
||||
mergeableState?: string | null;
|
||||
author?: GitHubUserSummary | null;
|
||||
body?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
headLabel?: string;
|
||||
headRepo?: GitHubPullRequestHeadRepo | null;
|
||||
};
|
||||
|
||||
type GitHubIssueComment = {
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type GitHubPullRequestReviewComment = {
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
path?: string;
|
||||
line?: number | null;
|
||||
position?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type GitHubPullRequestFile = {
|
||||
filename: string;
|
||||
status?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changes?: number;
|
||||
patch?: string;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestsListResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
prs?: GitHubPullRequestSummary[];
|
||||
page?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestContextResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
pr?: GitHubPullRequestSummary | null;
|
||||
issueComments?: GitHubIssueComment[];
|
||||
reviewComments?: GitHubPullRequestReviewComment[];
|
||||
files?: GitHubPullRequestFile[];
|
||||
diff?: string;
|
||||
checks?: GitHubChecksSummary | null;
|
||||
checkRuns?: GitHubCheckRun[];
|
||||
};
|
||||
|
||||
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 githubFetchText = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
accept: string,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Accept: accept,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 : '');
|
||||
|
||||
const mapUser = (raw: unknown): GitHubUserSummary | null => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const login = readString(rec?.login);
|
||||
if (!login) return null;
|
||||
return {
|
||||
login,
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
avatarUrl: readString(rec?.avatar_url) || undefined,
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const ownerLogin = readString((rec?.owner as JsonRecord | undefined)?.login);
|
||||
const repo = readString(rec?.name);
|
||||
const url = readString(rec?.html_url);
|
||||
if (!ownerLogin || !repo || !url) return null;
|
||||
return {
|
||||
owner: ownerLogin,
|
||||
repo,
|
||||
url,
|
||||
cloneUrl: readString(rec?.clone_url) || undefined,
|
||||
sshUrl: readString(rec?.ssh_url) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const computeChecks = async (
|
||||
accessToken: string,
|
||||
repo: GitHubRepoRef,
|
||||
sha: string
|
||||
): Promise<{ summary: GitHubChecksSummary | null; runs: GitHubCheckRun[] }> => {
|
||||
const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken);
|
||||
if (runsResp.status === 401) {
|
||||
return { summary: null, runs: [] };
|
||||
}
|
||||
const runsJson = await jsonOrNull<JsonRecord>(runsResp);
|
||||
const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
|
||||
? ((runsJson as JsonRecord).check_runs as unknown[])
|
||||
: [];
|
||||
|
||||
const mappedRuns: GitHubCheckRun[] = runs
|
||||
.map((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
const output = rec?.output && typeof rec.output === 'object' ? (rec.output as JsonRecord) : null;
|
||||
const app = rec?.app && typeof rec.app === 'object' ? (rec.app as JsonRecord) : null;
|
||||
return {
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
name,
|
||||
app: app
|
||||
? {
|
||||
name: readString(app.name) || undefined,
|
||||
slug: readString(app.slug) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
status: readString(rec?.status) || undefined,
|
||||
conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') ? (rec?.conclusion as string | null) : undefined,
|
||||
detailsUrl: readString(rec?.details_url) || undefined,
|
||||
output: output
|
||||
? {
|
||||
title: readString(output.title) || undefined,
|
||||
summary: readString(output.summary) || undefined,
|
||||
text: readString(output.text) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubCheckRun[];
|
||||
|
||||
if (runsResp.ok && runs.length > 0) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
runs.forEach((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const status = readString(rec?.status);
|
||||
const conclusion = readString(rec?.conclusion);
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { summary: { state, total, ...counts }, runs: mappedRuns };
|
||||
}
|
||||
|
||||
const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken);
|
||||
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||
if (!statusResp.ok || !statusJson) return { summary: null, runs: mappedRuns };
|
||||
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 state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { summary: { state, total, ...counts }, runs: mappedRuns };
|
||||
};
|
||||
|
||||
export const listPullRequests = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubPullRequestsListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, prs: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:pr state:open`;
|
||||
const searchUrl = new URL(`${API_BASE}/search/issues`);
|
||||
searchUrl.searchParams.set('q', q);
|
||||
searchUrl.searchParams.set('per_page', '50');
|
||||
searchUrl.searchParams.set('page', String(page));
|
||||
|
||||
const searchResp = await githubFetch(searchUrl.toString(), accessToken);
|
||||
if (searchResp.status === 401) return { connected: false };
|
||||
|
||||
const searchJson = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(searchResp);
|
||||
if (!searchResp.ok || !searchJson) {
|
||||
throw new Error('Failed to search PRs');
|
||||
}
|
||||
|
||||
const totalCount = typeof searchJson.total_count === 'number' ? searchJson.total_count : 0;
|
||||
const searchItems = Array.isArray(searchJson.items) ? searchJson.items : [];
|
||||
const prNumbers = searchItems.map((item) => {
|
||||
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||
return typeof rec?.number === 'number' ? rec.number : 0;
|
||||
}).filter((n) => n > 0);
|
||||
|
||||
let prs: GitHubPullRequestSummary[] = [];
|
||||
if (prNumbers.length > 0) {
|
||||
const entries = await Promise.all(prNumbers.map(async (number) => {
|
||||
const prUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`);
|
||||
const prResp = await githubFetch(prUrl.toString(), accessToken);
|
||||
if (prResp.status === 401) return 'unauthorized' as const;
|
||||
if (!prResp.ok) return null;
|
||||
return jsonOrNull<JsonRecord>(prResp);
|
||||
}));
|
||||
if (entries.includes('unauthorized')) return { connected: false };
|
||||
prs = entries
|
||||
.filter((entry): entry is JsonRecord => Boolean(entry) && entry !== 'unauthorized')
|
||||
.map((rec) => {
|
||||
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||
const mergedAt = readString(rec?.merged_at);
|
||||
const stateRaw = readString(rec?.state);
|
||||
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||
return {
|
||||
number,
|
||||
title: readString(rec?.title) || '',
|
||||
url: readString(rec?.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(rec?.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||
author: mapUser(rec?.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
} as GitHubPullRequestSummary;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + searchItems.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, prs, page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) return { connected: false };
|
||||
|
||||
const link = resp.headers.get('link') || '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) throw new Error('Failed to load PRs');
|
||||
|
||||
const prs = json.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||
const mergedAt = readString(rec?.merged_at);
|
||||
const stateRaw = readString(rec?.state);
|
||||
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||
|
||||
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||
|
||||
return {
|
||||
number,
|
||||
title: readString(rec?.title) || '',
|
||||
url: readString(rec?.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(rec?.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||
author: mapUser(rec?.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
} as GitHubPullRequestSummary;
|
||||
});
|
||||
|
||||
return { connected: true, repo, prs, page, hasMore };
|
||||
};
|
||||
|
||||
export const getPullRequestContext = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
includeDiff: boolean,
|
||||
includeCheckDetails: boolean,
|
||||
): Promise<GitHubPullRequestContextResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, pr: null };
|
||||
}
|
||||
|
||||
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_at) || Boolean(prJson.merged);
|
||||
const prState = readString(prJson.state);
|
||||
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||
const base = prJson.base && typeof prJson.base === 'object' ? (prJson.base as JsonRecord) : null;
|
||||
const head = prJson.head && typeof prJson.head === 'object' ? (prJson.head as JsonRecord) : null;
|
||||
|
||||
const pr: GitHubPullRequestSummary = {
|
||||
number: typeof prJson.number === 'number' ? prJson.number : number,
|
||||
title: readString(prJson.title) || '',
|
||||
url: readString(prJson.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(prJson.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||
author: mapUser(prJson.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
body: readString(prJson.body) || '',
|
||||
createdAt: readString(prJson.created_at) || undefined,
|
||||
updatedAt: readString(prJson.updated_at) || undefined,
|
||||
};
|
||||
|
||||
const issueCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments?per_page=100`, accessToken);
|
||||
if (issueCommentsResp.status === 401) return { connected: false };
|
||||
const issueCommentsJson = await jsonOrNull<unknown[]>(issueCommentsResp);
|
||||
if (!issueCommentsResp.ok || !Array.isArray(issueCommentsJson)) throw new Error('Failed to load PR issue comments');
|
||||
const issueComments: GitHubIssueComment[] = issueCommentsJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec?.html_url) || '',
|
||||
body: readString(rec?.body) || '',
|
||||
author: mapUser(rec?.user),
|
||||
createdAt: readString(rec?.created_at) || undefined,
|
||||
updatedAt: readString(rec?.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssueComment[];
|
||||
|
||||
const reviewCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/comments?per_page=100`, accessToken);
|
||||
if (reviewCommentsResp.status === 401) return { connected: false };
|
||||
const reviewCommentsJson = await jsonOrNull<unknown[]>(reviewCommentsResp);
|
||||
if (!reviewCommentsResp.ok || !Array.isArray(reviewCommentsJson)) throw new Error('Failed to load PR review comments');
|
||||
const reviewComments: GitHubPullRequestReviewComment[] = reviewCommentsJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec?.html_url) || '',
|
||||
body: readString(rec?.body) || '',
|
||||
author: mapUser(rec?.user),
|
||||
path: readString(rec?.path) || undefined,
|
||||
line: typeof rec?.line === 'number' ? rec.line : null,
|
||||
position: typeof rec?.position === 'number' ? rec.position : null,
|
||||
createdAt: readString(rec?.created_at) || undefined,
|
||||
updatedAt: readString(rec?.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubPullRequestReviewComment[];
|
||||
|
||||
const filesResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/files?per_page=100`, accessToken);
|
||||
if (filesResp.status === 401) return { connected: false };
|
||||
const filesJson = await jsonOrNull<unknown[]>(filesResp);
|
||||
if (!filesResp.ok || !Array.isArray(filesJson)) throw new Error('Failed to load PR files');
|
||||
const files: GitHubPullRequestFile[] = filesJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const filename = readString(rec?.filename);
|
||||
if (!filename) return null;
|
||||
return {
|
||||
filename,
|
||||
status: readString(rec?.status) || undefined,
|
||||
additions: typeof rec?.additions === 'number' ? rec.additions : undefined,
|
||||
deletions: typeof rec?.deletions === 'number' ? rec.deletions : undefined,
|
||||
changes: typeof rec?.changes === 'number' ? rec.changes : undefined,
|
||||
patch: readString(rec?.patch) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubPullRequestFile[];
|
||||
|
||||
const checksResult = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : { summary: null, runs: [] };
|
||||
const checks = checksResult.summary;
|
||||
const checkRuns = checksResult.runs;
|
||||
|
||||
if (includeCheckDetails && checkRuns.length > 0) {
|
||||
const parseIds = (url: string | undefined): { runId: number | null; jobId: number | null } => {
|
||||
if (!url) return { runId: null, jobId: null };
|
||||
const match = url.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
||||
if (!match) return { runId: null, jobId: null };
|
||||
const runId = Number(match[1]);
|
||||
const jobId = match[2] ? Number(match[2]) : null;
|
||||
return {
|
||||
runId: Number.isFinite(runId) && runId > 0 ? runId : null,
|
||||
jobId: jobId && Number.isFinite(jobId) && jobId > 0 ? jobId : null,
|
||||
};
|
||||
};
|
||||
|
||||
const jobsByRunId = new Map<number, JsonRecord[]>();
|
||||
const runIds = new Set<number>();
|
||||
checkRuns.forEach((r) => {
|
||||
const ids = parseIds(r.detailsUrl);
|
||||
if (ids.runId) runIds.add(ids.runId);
|
||||
});
|
||||
|
||||
for (const runId of runIds) {
|
||||
const jobsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/actions/runs/${runId}/jobs?per_page=100`, accessToken);
|
||||
if (jobsResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const jobsJson = await jsonOrNull<JsonRecord>(jobsResp);
|
||||
const jobs = Array.isArray(jobsJson?.jobs) ? (jobsJson?.jobs as unknown[]) : [];
|
||||
jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]);
|
||||
}
|
||||
|
||||
const annotationsByRunId = new Map<number, Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}>>();
|
||||
|
||||
for (const run of checkRuns) {
|
||||
const runId = typeof run.id === 'number' ? run.id : 0;
|
||||
const conclusion = (run.conclusion || '').toLowerCase();
|
||||
const shouldLoadAnnotations = Boolean(
|
||||
runId > 0
|
||||
&& conclusion
|
||||
&& !['success', 'neutral', 'skipped'].includes(conclusion),
|
||||
);
|
||||
if (!shouldLoadAnnotations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const annotations: Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}> = [];
|
||||
|
||||
for (let page = 1; page <= 3; page += 1) {
|
||||
const annotationsResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/check-runs/${runId}/annotations?per_page=50&page=${page}`,
|
||||
accessToken,
|
||||
);
|
||||
if (annotationsResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const annotationsJson = await jsonOrNull<unknown[]>(annotationsResp);
|
||||
const chunk = Array.isArray(annotationsJson) ? annotationsJson : [];
|
||||
chunk.forEach((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const message = readString(rec?.message);
|
||||
if (!message) return;
|
||||
annotations.push({
|
||||
path: readString(rec?.path) || undefined,
|
||||
startLine: typeof rec?.start_line === 'number' ? rec.start_line : undefined,
|
||||
endLine: typeof rec?.end_line === 'number' ? rec.end_line : undefined,
|
||||
level: readString(rec?.annotation_level) || undefined,
|
||||
message,
|
||||
title: readString(rec?.title) || undefined,
|
||||
rawDetails: readString(rec?.raw_details) || undefined,
|
||||
});
|
||||
});
|
||||
if (chunk.length < 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (annotations.length > 0) {
|
||||
annotationsByRunId.set(runId, annotations);
|
||||
}
|
||||
}
|
||||
|
||||
for (const run of checkRuns) {
|
||||
if (run.id && annotationsByRunId.has(run.id)) {
|
||||
run.annotations = annotationsByRunId.get(run.id);
|
||||
}
|
||||
|
||||
const ids = parseIds(run.detailsUrl);
|
||||
if (!ids.runId) continue;
|
||||
const jobs = jobsByRunId.get(ids.runId) ?? [];
|
||||
const picked = ids.jobId
|
||||
? jobs.find((j) => typeof j.id === 'number' && j.id === ids.jobId)
|
||||
: jobs.find((j) => readString(j.name) === run.name);
|
||||
if (!picked) {
|
||||
run.job = { runId: ids.runId, ...(ids.jobId ? { jobId: ids.jobId } : {}), url: run.detailsUrl };
|
||||
continue;
|
||||
}
|
||||
const stepsRaw = Array.isArray(picked.steps) ? (picked.steps as unknown[]) : [];
|
||||
const steps = stepsRaw
|
||||
.map((s) => {
|
||||
const rec = s && typeof s === 'object' ? (s as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
status: readString(rec?.status) || undefined,
|
||||
conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string')
|
||||
? (rec?.conclusion as string | null)
|
||||
: undefined,
|
||||
number: typeof rec?.number === 'number' ? rec.number : undefined,
|
||||
startedAt: readString(rec?.started_at) || undefined,
|
||||
completedAt: readString(rec?.completed_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
name: string;
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
number?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
|
||||
run.job = {
|
||||
runId: ids.runId,
|
||||
jobId: typeof picked.id === 'number' ? picked.id : undefined,
|
||||
url: readString(picked.html_url) || undefined,
|
||||
name: readString(picked.name) || undefined,
|
||||
conclusion: (picked.conclusion === null || typeof picked.conclusion === 'string')
|
||||
? (picked.conclusion as string | null)
|
||||
: undefined,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
let diff: string | undefined;
|
||||
if (includeDiff) {
|
||||
const diffResp = await githubFetchText(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken, 'application/vnd.github.v3.diff');
|
||||
if (diffResp.status === 401) return { connected: false };
|
||||
if (diffResp.ok) {
|
||||
diff = await diffResp.text().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks, checkRuns };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionStatus, OpenCodeManager } from './opencode';
|
||||
|
||||
export const API_URL_WAIT_TIMEOUT_MS = 30000;
|
||||
const API_URL_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export async function waitForApiUrl(
|
||||
manager: OpenCodeManager | undefined,
|
||||
|
||||
@@ -21,7 +21,7 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.C
|
||||
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type OpenCodeDebugInfo = {
|
||||
type OpenCodeDebugInfo = {
|
||||
mode: 'managed' | 'external';
|
||||
status: ConnectionStatus;
|
||||
lastError?: string;
|
||||
@@ -47,7 +47,7 @@ export type OpenCodeDebugInfo = {
|
||||
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
||||
};
|
||||
|
||||
export type SetWorkingDirectoryResult =
|
||||
type SetWorkingDirectoryResult =
|
||||
| { success: true; path: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
|
||||
@@ -63,10 +63,3 @@ export const getProviderAuth = (providerId: string): AuthEntry | null => {
|
||||
const auth = readAuthFile();
|
||||
return auth[providerId] || null;
|
||||
};
|
||||
|
||||
export const listProviderAuths = (): string[] => {
|
||||
const auth = readAuthFile();
|
||||
return Object.keys(auth);
|
||||
};
|
||||
|
||||
export { AUTH_FILE, OPENCODE_DATA_DIR };
|
||||
|
||||
@@ -45,7 +45,7 @@ export type Snippet = {
|
||||
};
|
||||
|
||||
export type PluginScope = 'user' | 'project';
|
||||
export type PluginParsedKind = 'npm' | 'path';
|
||||
type PluginParsedKind = 'npm' | 'path';
|
||||
|
||||
export type PluginEntry = {
|
||||
id: string;
|
||||
@@ -200,7 +200,7 @@ const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = glo
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
export const getAgentScope = (
|
||||
const getAgentScope = (
|
||||
agentName: string,
|
||||
workingDirectory?: string,
|
||||
lookupCache: AgentLookupCache = globalAgentLookupCache
|
||||
@@ -273,7 +273,7 @@ const getUserCommandPath = (commandName: string): string => {
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
||||
const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
||||
if (workingDirectory) {
|
||||
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
@@ -1194,23 +1194,6 @@ export const queryPluginRegistry = async (
|
||||
return { results };
|
||||
};
|
||||
|
||||
export type McpLocalConfig = {
|
||||
type: 'local';
|
||||
command?: string[];
|
||||
environment?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpRemoteConfig = {
|
||||
type: 'remote';
|
||||
url?: string;
|
||||
environment?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpConfigPayload = McpLocalConfig | McpRemoteConfig;
|
||||
|
||||
export type McpConfigEntry = {
|
||||
name: string;
|
||||
scope?: AgentScope | null;
|
||||
@@ -2241,7 +2224,7 @@ export const SKILL_SCOPE = {
|
||||
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
|
||||
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
||||
|
||||
export type SupportingFile = {
|
||||
type SupportingFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
fullPath: string;
|
||||
@@ -2382,9 +2365,9 @@ const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string):
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
};
|
||||
|
||||
export const getSkillScope = (skillName: string, workingDirectory?: string): {
|
||||
scope: SkillScope | null;
|
||||
path: string | null;
|
||||
const getSkillScope = (skillName: string, workingDirectory?: string): {
|
||||
scope: SkillScope | null;
|
||||
path: string | null;
|
||||
source: SkillSource | null;
|
||||
} => {
|
||||
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
|
||||
@@ -456,7 +456,7 @@ export const listConfiguredQuotaProviders = () => {
|
||||
return Array.from(configured);
|
||||
};
|
||||
|
||||
export const fetchCodexQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchCodexQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])) as Record<string, unknown> | null;
|
||||
const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -723,7 +723,7 @@ const fetchGoogleModels = async (accessToken: string, projectId?: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const fetchGoogleQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchGoogleQuota = async (): Promise<ProviderResult> => {
|
||||
const authSources = resolveGoogleAuthSources();
|
||||
if (!authSources.length) {
|
||||
return buildResult({
|
||||
@@ -851,7 +851,7 @@ export const fetchGoogleQuota = async (): Promise<ProviderResult> => {
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record<string, unknown> | null;
|
||||
const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -969,7 +969,7 @@ const buildCopilotWindows = (payload: Record<string, unknown>) => {
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchCopilotQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchCopilotQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record<string, unknown> | null;
|
||||
const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1024,7 +1024,7 @@ export const fetchCopilotQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record<string, unknown> | null;
|
||||
const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1082,7 +1082,7 @@ export const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchKimiQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchKimiQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1293,14 +1293,14 @@ const fetchMiniMaxQuota = async (data: {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMiniMaxCodingPlanQuota = () => fetchMiniMaxQuota({
|
||||
const fetchMiniMaxCodingPlanQuota = () => fetchMiniMaxQuota({
|
||||
providerId: 'minimax-coding-plan',
|
||||
providerName: 'MiniMax Coding Plan (minimax.io)',
|
||||
endpoint: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
|
||||
usageFieldsAreRemaining: false,
|
||||
});
|
||||
|
||||
export const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({
|
||||
const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({
|
||||
providerId: 'minimax-cn-coding-plan',
|
||||
providerName: 'MiniMax Coding Plan (minimaxi.com)',
|
||||
endpoint: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
||||
@@ -1343,7 +1343,7 @@ const parseOllamaSettingsHtml = (html: string) => {
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH);
|
||||
|
||||
if (!cookie) {
|
||||
@@ -1393,7 +1393,7 @@ export const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1491,7 +1491,7 @@ const resolveWindowLabel = (windowSeconds: number | null) => {
|
||||
return `${windowSeconds}s`;
|
||||
};
|
||||
|
||||
export const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1560,7 +1560,7 @@ export const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchZhipuaiCodingPlanQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchZhipuaiCodingPlanQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['zhipuai-coding-plan'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1649,7 +1649,7 @@ export const fetchZhipuaiCodingPlanQuota = async (): Promise<ProviderResult> =>
|
||||
|
||||
const NANO_GPT_DAILY_WINDOW_SECONDS = 86400;
|
||||
|
||||
export const fetchNanoGptQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchNanoGptQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['nano-gpt', 'nanogpt', 'nano_gpt'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
@@ -1755,7 +1755,7 @@ export const fetchNanoGptQuota = async (): Promise<ProviderResult> => {
|
||||
const WAFER_QUOTA_URL = 'https://pass.wafer.ai/v1/inference/quota';
|
||||
const WAFER_WINDOW_SECONDS = 5 * 3600;
|
||||
|
||||
export const fetchWaferQuota = async (): Promise<ProviderResult> => {
|
||||
const fetchWaferQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import yaml from 'yaml';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
import { discoverSkills } from './opencodeConfig';
|
||||
|
||||
@@ -34,7 +33,7 @@ type SkillFrontmatter = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ClawdHubSkillMetadata = {
|
||||
type ClawdHubSkillMetadata = {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
@@ -43,7 +42,7 @@ export type ClawdHubSkillMetadata = {
|
||||
stars?: number;
|
||||
};
|
||||
|
||||
export type SkillsCatalogItem = {
|
||||
type SkillsCatalogItem = {
|
||||
repoSource: string;
|
||||
repoSubpath?: string;
|
||||
skillDir: string;
|
||||
@@ -76,7 +75,7 @@ type SkillsInstallResult =
|
||||
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> }
|
||||
| { ok: false; error: SkillsRepoError };
|
||||
|
||||
export const CURATED_SOURCES: CuratedSource[] = [
|
||||
const CURATED_SOURCES: CuratedSource[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
@@ -222,172 +221,6 @@ async function scanClawdHub(): Promise<SkillsRepoScanResult> {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadClawdHubSkill(slug: string, version: string): Promise<Buffer> {
|
||||
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`;
|
||||
const response = await clawdhubFetch(url, { headers: { Accept: 'application/zip' } });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub download error: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
type ClawdHubSkillInfoResponse = {
|
||||
skill?: { tags?: { latest?: string } };
|
||||
latestVersion?: { version?: string };
|
||||
};
|
||||
|
||||
async function fetchClawdHubSkillInfo(slug: string): Promise<ClawdHubSkillInfoResponse> {
|
||||
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
|
||||
const response = await clawdhubFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub skill error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ClawdHubSkillInfoResponse>;
|
||||
}
|
||||
|
||||
export async function installSkillsFromClawdHub(options: {
|
||||
scope: SkillScope;
|
||||
targetSource?: SkillInstallSource;
|
||||
workingDirectory?: string;
|
||||
selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
}): Promise<SkillsInstallResult> {
|
||||
if (options.scope === 'project' && !options.workingDirectory) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
|
||||
const userSkillDir = getUserSkillBaseDir();
|
||||
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
const requestedSkills = options.selections || [];
|
||||
|
||||
if (requestedSkills.length === 0) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
for (const sel of requestedSkills) {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
if (!validateSkillName(slug)) continue;
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = options.conflictDecisions?.[slug];
|
||||
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } };
|
||||
}
|
||||
|
||||
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
const skipped: Array<{ skillName: string; reason: string }> = [];
|
||||
|
||||
for (const sel of requestedSkills) {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
let version = sel.clawdhub?.version || 'latest';
|
||||
|
||||
if (!validateSkillName(slug)) {
|
||||
skipped.push({ skillName: slug, reason: 'Invalid skill name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve 'latest' version
|
||||
if (version === 'latest') {
|
||||
try {
|
||||
const info = await fetchClawdHubSkillInfo(slug);
|
||||
const latest = info.skill?.tags?.latest || info.latestVersion?.version || null;
|
||||
if (latest) {
|
||||
version = latest;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (version === 'latest') {
|
||||
skipped.push({ skillName: slug, reason: 'Unable to resolve latest version' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
const exists = fs.existsSync(targetDir);
|
||||
let decision = options.conflictDecisions?.[slug] || null;
|
||||
if (!decision) {
|
||||
if (exists && options.conflictPolicy === 'skipAll') decision = 'skip';
|
||||
if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
||||
if (!exists) decision = 'overwrite';
|
||||
}
|
||||
|
||||
if (exists && decision === 'skip') {
|
||||
skipped.push({ skillName: slug, reason: 'Already installed (skipped)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exists && decision === 'overwrite') {
|
||||
await safeRm(targetDir);
|
||||
}
|
||||
|
||||
// Download and extract
|
||||
const zipBuffer = await downloadClawdHubSkill(slug, version);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${slug}-`));
|
||||
|
||||
try {
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
zip.extractAllTo(tempDir, true);
|
||||
|
||||
// Verify SKILL.md exists
|
||||
const skillMdPath = path.join(tempDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) {
|
||||
skipped.push({ skillName: slug, reason: 'SKILL.md not found in downloaded package' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move to target directory
|
||||
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fs.promises.rename(tempDir, targetDir);
|
||||
|
||||
installed.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
} catch (extractError) {
|
||||
await safeRm(tempDir);
|
||||
throw extractError;
|
||||
}
|
||||
} catch (error) {
|
||||
skipped.push({
|
||||
skillName: slug,
|
||||
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, installed, skipped };
|
||||
}
|
||||
|
||||
function validateSkillName(skillName: string): boolean {
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
@@ -957,5 +790,3 @@ export async function getSkillsCatalog(
|
||||
|
||||
return { ok: true as const, sources, itemsBySource };
|
||||
}
|
||||
|
||||
export { isClawdHubSource };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getThemeKindName } from './theme';
|
||||
import type { ConnectionStatus } from './opencode';
|
||||
import type { WorkspaceFolderCandidate } from './workspaceResolver';
|
||||
|
||||
export type PanelType = 'chat' | 'agentManager';
|
||||
type PanelType = 'chat' | 'agentManager';
|
||||
|
||||
export interface WebviewHtmlOptions {
|
||||
webview: vscode.Webview;
|
||||
|
||||
Reference in New Issue
Block a user