import { decideGitLifecycle, GitLifecycleError, inspectGitWorkspace, makeCommitMessage, validatePullRequestMetadata, } from "@code/primitives/git"; import type { GitLifecycleDecisionResult, GitLifecycleResult, GitWorkspaceInspection, } from "@code/primitives/git"; import { Effect } from "effect"; export interface GitCommandResult { readonly exitCode: number; readonly stderr: string; readonly stdout: string; } export interface GitCommandRunner { readonly run: ( command: string, options?: { readonly cwd?: string; readonly env?: Record; } ) => Promise; } export interface GiteaRepository { readonly cloneUrl: string; readonly defaultBranch: string; readonly htmlUrl: string; readonly name: string; readonly sshUrl: string; } export interface GiteaPullRequest { readonly base: { readonly ref: string }; readonly head: { readonly ref: string }; readonly htmlUrl: string; readonly number: number; readonly state: "open" | "closed" | "merged"; } export interface GiteaTransport { readonly createPullRequest: (input: { readonly base: string; readonly body: string; readonly head: string; readonly repositoryPath: string; readonly title: string; }) => Promise; readonly getRepository: (repositoryPath: string) => Promise; } export interface GiteaHttpTransportOptions { readonly baseUrl: string; readonly fetch?: ( input: string | URL | Request, init?: RequestInit ) => Promise; readonly token: string; } const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`; const providerFailure = ( message: string, reason: "CommandFailed" | "RemoteRejected" ) => new GitLifecycleError({ message, reason }); const runChecked = async ( runner: GitCommandRunner, command: string, options?: Parameters[1] ): Promise => { let result: GitCommandResult; try { result = await runner.run(command, options); } catch (error) { throw new GitLifecycleError({ message: `Git command could not run: ${ error instanceof Error ? error.message : String(error) }`, reason: "CommandFailed", }); } if (result.exitCode !== 0) { throw providerFailure( result.stderr.trim() || `Git command failed: ${command}`, command.startsWith("git push") ? "RemoteRejected" : "CommandFailed" ); } return result.stdout; }; const toHttpPath = (repositoryPath: string): string => repositoryPath .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); const requestJson = async ( options: GiteaHttpTransportOptions, path: string, init?: RequestInit ): Promise => { let response: Response; try { response = await (options.fetch ?? globalThis.fetch)( `${options.baseUrl.replace(/\/$/u, "")}${path}`, { ...init, headers: { Accept: "application/json", Authorization: `token ${options.token}`, "Content-Type": "application/json", ...init?.headers, }, } ); } catch (error) { throw new GitLifecycleError({ message: `Gitea request could not run: ${ error instanceof Error ? error.message : String(error) }`, reason: "RequestFailed", }); } if (!response.ok) { const detail = await response.text(); throw new GitLifecycleError({ message: `Gitea request failed (${response.status}): ${detail}`, reason: response.status === 401 || response.status === 403 ? "Authentication" : "RequestFailed", }); } return (await response.json()) as T; }; export const createGiteaHttpTransport = ( options: GiteaHttpTransportOptions ): GiteaTransport => ({ async createPullRequest(input) { const response = await requestJson<{ base: { ref: string }; head: { ref: string }; html_url: string; number: number; state: "open" | "closed" | "merged"; }>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, { body: JSON.stringify({ base: input.base, body: input.body, head: input.head, title: input.title, }), method: "POST", }); return { base: response.base, head: response.head, htmlUrl: response.html_url, number: response.number, state: response.state, }; }, async getRepository(repositoryPath) { const response = await requestJson<{ clone_url: string; default_branch: string; html_url: string; name: string; ssh_url: string; }>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`); return { cloneUrl: response.clone_url, defaultBranch: response.default_branch, htmlUrl: response.html_url, name: response.name, sshUrl: response.ssh_url, }; }, }); export interface RunPostRunGiteaLifecycleInput { readonly baseBranch: string; readonly body: string; readonly commitMessage?: string; readonly issueNumber: number; readonly issueTitle: string; readonly repositoryPath: string; readonly runner: GitCommandRunner; readonly title?: string; readonly transport: GiteaTransport; readonly verification: "passed" | "failed" | "not-run"; readonly workspace: string; } const inspect = async ( input: RunPostRunGiteaLifecycleInput ): Promise => { const [branch, status, diff] = await Promise.all([ runChecked(input.runner, "git branch --show-current", { cwd: input.workspace, }), runChecked(input.runner, "git status --porcelain=v1", { cwd: input.workspace, }), runChecked(input.runner, "git diff --no-ext-diff --binary", { cwd: input.workspace, }), ]); return await Effect.runPromise( inspectGitWorkspace({ branch: branch.trim(), diff, status, }) ); }; export const runPostRunGiteaLifecycle = async ( input: RunPostRunGiteaLifecycleInput ): Promise => { const inspection = await inspect(input); const initialDecision = (await Effect.runPromise( decideGitLifecycle({ baseBranch: input.baseBranch, inspection, pushed: false, verification: input.verification, }) )) as GitLifecycleDecisionResult; if (initialDecision.decision === "finish") { return { baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"], branch: inspection.branch, commitSha: undefined, pullRequest: undefined, status: "no_changes", }; } const repository = await input.transport.getRepository(input.repositoryPath); await runChecked( input.runner, `git add --all && git commit -m ${shellQuote( input.commitMessage ?? makeCommitMessage(input.issueNumber, input.issueTitle) )}`, { cwd: input.workspace } ); const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", { cwd: input.workspace, }); const commitSha = commitOutput.trim(); const cleanStatus = await runChecked( input.runner, "git status --porcelain=v1", { cwd: input.workspace } ); if (cleanStatus.trim().length > 0) { throw providerFailure( "Git workspace remained dirty after commit", "CommandFailed" ); } await runChecked( input.runner, `git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`, { cwd: input.workspace } ); const pullRequest = await input.transport.createPullRequest({ base: input.baseBranch, body: input.body, head: inspection.branch, repositoryPath: input.repositoryPath, title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`, }); const metadata = await Effect.runPromise( validatePullRequestMetadata({ baseBranch: pullRequest.base.ref, branch: pullRequest.head.ref, number: pullRequest.number, status: pullRequest.state, url: pullRequest.htmlUrl, }) ); return { baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"], branch: inspection.branch, commitSha, pullRequest: metadata, status: "pull_request_open", }; };