Add PRs list API with pagination Provide PR context API with optional diff Integrate PR picker in session UI
238 lines
7.0 KiB
TypeScript
238 lines
7.0 KiB
TypeScript
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,
|
|
): Promise<GitHubIssuesListResult> => {
|
|
const repo = await resolveRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return { connected: true, repo: null, issues: [] };
|
|
}
|
|
|
|
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 || [] };
|
|
};
|