Add check details to PR context via includeCheckDetails flag Open a checks dialog showing check run summaries and steps Improve PR lookup for forked repos by matching head branch
511 lines
18 KiB
TypeScript
511 lines
18 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 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 }>;
|
|
};
|
|
};
|
|
|
|
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: 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,
|
|
};
|
|
};
|
|
|
|
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,
|
|
): Promise<GitHubPullRequestsListResult> => {
|
|
const repo = await resolveRepoFromDirectory(directory);
|
|
if (!repo) {
|
|
return { connected: true, repo: null, prs: [] };
|
|
}
|
|
|
|
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[]);
|
|
}
|
|
|
|
for (const run of checkRuns) {
|
|
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,
|
|
};
|
|
})
|
|
.filter(Boolean) as Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
|
|
|
|
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 };
|
|
};
|