Files
openchamber/packages/vscode/src/githubPulls.ts
T
Bohdan Triapitsyn 0e11ea3f83 feat: extend PR context to include check details
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
2026-01-24 17:59:08 +02:00

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 };
};