Files
openchamber/packages/vscode/src/githubPulls.ts
T
Iuliia Ivashko 081be1b7d0 feat(worktrees): ship upstream-first worktree flow across web + vscode (#418)
* feat: add worktree validation and deleteLocalBranch option

Add API to validate and create worktrees with new payload types
Allow deleting local branches when removing worktrees via UI and API
Introduce OpenCode style random names for worktrees when not provided

* feat: enable SSH/HTTPS transport detection for PR picker

Load remotes for the current project directory to inform PR picker options.
Determine preferred push transport from remotes and apply it.
Expose sshUrl in API for frontend to build SSH clone URLs

* feat: extend head repo with sshUrl and improve push error messages

Add sshUrl field to head repo mapping
Enhance push failure handling to display stderr or stdout details
Return push details on success

* fix: worktree path

* feat: worktree set upstream on creation

Enable pushing to upstream by default when no remote is specified
Remove per-remote dropdown for push actions and auto-use first/upstream remote
Update server and VSCode git services to support push without explicit remote and set upstream

* fix: worktree-name sanitization

* feat: rename worktree path field and branch prefix

* feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution

* refactor(git): use git.worktree namespace in branch picker

* feat(worktrees): sync OpenCode sandbox metadata on create/remove

* fix(worktrees): accept new path key in workspace guard and validate remote startRef

* chore(docs): remove temporary worktree testing plan

* feat: add git worktree management API (list/create/delete/validate) for vscode

* feat: wire root tracking remote and defaults for new worktrees

Add resolveRootTrackingRemote to detect upstream remote for root branch
Apply upstream defaults when creating new worktrees to auto-set upstream
Replace validation and creation flow to use new worktreeCreate APIs

* feat(worktrees): enable root tracking remote handling
2026-02-13 19:18:22 +02:00

608 lines
21 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;
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,
): 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[]);
}
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 };
};