feat: add post-run Gitea lifecycle (#5)

This commit is contained in:
-Puter
2026-07-24 02:05:30 +05:30
parent 7974bd5f3e
commit a17d53cb41
16 changed files with 1281 additions and 7 deletions
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
import type { GitCommandRunner, GiteaTransport } from "./gitea";
const repository = {
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
defaultBranch: "main",
htmlUrl: "https://git.openputer.com/puter/zopu-code",
name: "zopu-code",
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
};
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
const commands: string[] = [];
let statusCalls = 0;
return {
commands,
run(command) {
commands.push(command);
if (command === "git branch --show-current") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "work/issue-5\n",
});
}
if (command === "git status --porcelain=v1") {
statusCalls += 1;
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
});
}
if (command === "git diff --no-ext-diff --binary") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "diff --git ...",
});
}
if (command === "git rev-parse HEAD") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "abc123\n",
});
}
if (command.includes("git status --porcelain=v1")) {
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
},
};
};
describe("Gitea lifecycle adapter", () => {
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
const runner = makeRunner();
const pullRequests: unknown[] = [];
const transport: GiteaTransport = {
createPullRequest(input) {
pullRequests.push(input);
return Promise.resolve({
base: { ref: input.base },
head: { ref: input.head },
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
});
},
getRepository() {
return Promise.resolve(repository);
},
};
const result = await runPostRunGiteaLifecycle({
baseBranch: "main",
body: "Generated by the verified project run.",
issueNumber: 5,
issueTitle: "Publish verified changes",
repositoryPath: "puter/zopu-code",
runner,
transport,
verification: "passed",
workspace: "/workspace",
});
expect(result).toMatchObject({
branch: "work/issue-5",
commitSha: "abc123",
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
},
status: "pull_request_open",
});
expect(runner.commands).toEqual([
"git branch --show-current",
"git status --porcelain=v1",
"git diff --no-ext-diff --binary",
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD",
"git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
]);
expect(pullRequests).toHaveLength(1);
});
test("mocks the HTTP Gitea boundary without calling the network", async () => {
const requests: Request[] = [];
const transport = createGiteaHttpTransport({
baseUrl: "https://git.openputer.com",
fetch: (input, init) => {
const request = new Request(String(input), init);
requests.push(request);
return Promise.resolve(
new Response(
request.method === "GET"
? JSON.stringify(repository)
: JSON.stringify({
base: { ref: "main" },
head: { ref: "work/issue-5" },
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
}),
{ headers: { "content-type": "application/json" } }
)
);
},
token: "scoped-test-token",
});
await transport.getRepository("puter/zopu-code");
await transport.createPullRequest({
base: "main",
body: "body",
head: "work/issue-5",
repositoryPath: "puter/zopu-code",
title: "title",
});
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
);
expect(requests[1]?.headers.get("authorization")).toBe(
"token scoped-test-token"
);
});
});
+307
View File
@@ -0,0 +1,307 @@
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<string, string>;
}
) => Promise<GitCommandResult>;
}
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<GiteaPullRequest>;
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
}
export interface GiteaHttpTransportOptions {
readonly baseUrl: string;
readonly fetch?: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
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<GitCommandRunner["run"]>[1]
): Promise<string> => {
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 <T>(
options: GiteaHttpTransportOptions,
path: string,
init?: RequestInit
): Promise<T> => {
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<GitWorkspaceInspection> => {
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<GitLifecycleResult> => {
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",
};
};