308 lines
8.1 KiB
TypeScript
308 lines
8.1 KiB
TypeScript
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",
|
|
};
|
|
};
|