import type { FetchLike } from '@radial/atproto' import type { ForgeAdapter, GitHubRepo, PullRequestState } from './forge.js' export function parseGitHubRepo(gitUrl: string): GitHubRepo { const fail = (): never => { throw new Error(`not a GitHub repository URL: ${gitUrl}`) } let host: string let path: string if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(gitUrl)) { let url: URL try { url = new URL(gitUrl) } catch { return fail() } host = url.hostname.toLowerCase() path = url.pathname } else { const scp = /^(?:[^@/:]+@)?([^@/:]+):(.+)$/.exec(gitUrl) if (!scp) return fail() host = (scp[1] as string).toLowerCase() path = scp[2] as string } if (host !== 'github.com' && host !== 'www.github.com') return fail() const [owner, repo] = path.replace(/^\/+/, '').replace(/\.git$/, '').split('/') if (!owner || !repo) return fail() return { owner, repo } } export function parseGitHubPullUrl(prUrl: string): { repo: GitHubRepo; number: number } { let url: URL try { url = new URL(prUrl) } catch { throw new Error(`invalid pull request URL: ${prUrl}`) } if ( url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com' || url.username || url.password || url.port || url.search || url.hash ) { throw new Error(`not a canonical GitHub pull request URL: ${prUrl}`) } const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9][0-9]*)$/.exec(url.pathname) if (!match) { throw new Error(`not a canonical github.com/owner/repo/pull/ URL: ${prUrl}`) } return { repo: { owner: match[1] as string, repo: match[2] as string }, number: Number(match[3]), } } export function compareUrl(repo: GitHubRepo, base: string, commit: string): string { return `https://github.com/${repo.owner}/${repo.repo}/compare/${base}...${commit}` } /** `owner/repo`, lowercased — how GitHub names a repository in both `projectIdentity` and * `PullRequestState.headRepoFullName`, so the dispatcher's reuse guard compares like with like. */ const fullName = (repo: GitHubRepo): string => `${repo.owner}/${repo.repo}`.toLowerCase() /** * The GitHub adapter. Observation-only by design (design §10): a turn pushes its branch and opens * or updates its own pull request with `gh` from inside the container, and this adapter only ever * reads. That is why it implements neither `openPullRequest` nor `turnEnvironment` — `--pr` stays * required on a GitHub implementation submit, exactly as before. * * `token` is optional so the daemon can still reason about GitHub URLs (which pull belongs to which * project, what a compare link looks like) when operator authentication is unavailable. Without one * `canObserve` is false and `getPullRequestState` throws rather than making an unauthenticated call * that would silently 404 on a private repository and read as "no such pull request". */ export class GitHubForge implements ForgeAdapter { readonly kind = 'github' as const readonly #fetch: FetchLike readonly #token: string | undefined readonly #apiBase: string constructor(options: { fetch: FetchLike; token?: string; apiBase?: string }) { this.#fetch = options.fetch this.#token = options.token this.#apiBase = options.apiBase ?? 'https://api.github.com' } get canObserve(): boolean { return Boolean(this.#token) } matchesGitUrl(gitUrl: string): boolean { try { parseGitHubRepo(gitUrl) return true } catch { return false } } ownsPullUrl(prUrl: string): boolean { try { parseGitHubPullUrl(prUrl) return true } catch { return false } } async pullBelongsToProject(prUrl: string, gitUrl: string): Promise { const project = parseGitHubRepo(gitUrl) const parsed = parseGitHubPullUrl(prUrl) return fullName(parsed.repo) === fullName(project) } async projectIdentity(gitUrl: string): Promise { return fullName(parseGitHubRepo(gitUrl)) } implementationBlockedReason(): string | undefined { return this.#token ? undefined : 'GitHub authentication is unavailable' } async getPullRequestState(prUrl: string): Promise { if (!this.#token) throw new Error('GitHub authentication is unavailable') const { repo, number } = parseGitHubPullUrl(prUrl) const response = await this.#fetch( `${this.#apiBase}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, { headers: { authorization: `Bearer ${this.#token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', }, }, ) if (response.status !== 200) { throw new Error(`getPullRequestState #${number} failed (${response.status})`) } const value = JSON.parse(await response.text()) as { state?: string merged?: boolean merged_at?: string | null head?: { ref?: string; repo?: { full_name?: string } | null } base?: { ref?: string } } return { state: value.merged ? 'merged' : value.state === 'open' ? 'open' : 'closed', headRef: value.head?.ref ?? '', headRepoFullName: value.head?.repo?.full_name ?? '', baseRef: value.base?.ref ?? '', ...(value.merged_at ? { mergedAt: value.merged_at } : {}), } } compareUrl(gitUrl: string, base: string, commit: string): string { return compareUrl(parseGitHubRepo(gitUrl), base, commit) } } export class FakeForge implements ForgeAdapter { readonly kind = 'github' as const readonly canObserve = true readonly pulls = new Map() matchesGitUrl(gitUrl: string): boolean { try { parseGitHubRepo(gitUrl) return true } catch { return false } } ownsPullUrl(prUrl: string): boolean { try { parseGitHubPullUrl(prUrl) return true } catch { return false } } async pullBelongsToProject(prUrl: string, gitUrl: string): Promise { return fullName(parseGitHubPullUrl(prUrl).repo) === fullName(parseGitHubRepo(gitUrl)) } async projectIdentity(gitUrl: string): Promise { return fullName(parseGitHubRepo(gitUrl)) } async getPullRequestState(url: string): Promise { parseGitHubPullUrl(url) const pull = this.pulls.get(url) if (!pull) throw new Error(`FakeForge: no pull request at ${url}`) return pull } compareUrl(gitUrl: string, base: string, commit: string): string { return compareUrl(parseGitHubRepo(gitUrl), base, commit) } } /** URL reasoning with no credential behind it: which pull belongs to which project, and what a * compare link looks like. `dispatch.ts` and `turn-socket.ts` fall back to this when no adapter is * configured for a project, so their refusals keep the exact shape they had before the registry. */ export function urlOnlyForgeAdapters(): ForgeAdapter[] { return [new GitHubForge({ fetch })] }