feat: add post-run Gitea lifecycle (#5)
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
export const GitBranchName = MeaningfulString.pipe(
|
||||
Schema.check(
|
||||
Schema.makeFilter(
|
||||
(value) =>
|
||||
!value.startsWith("-") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes(" ") &&
|
||||
!value.includes("~") &&
|
||||
!value.includes("^") &&
|
||||
!value.includes(":") &&
|
||||
!value.includes("\\"),
|
||||
{ expected: "a valid Git branch name" }
|
||||
)
|
||||
)
|
||||
);
|
||||
export type GitBranchName = typeof GitBranchName.Type;
|
||||
|
||||
export const GitStatusEntry = Schema.Struct({
|
||||
index: Schema.String,
|
||||
path: MeaningfulString,
|
||||
raw: Schema.String,
|
||||
worktree: Schema.String,
|
||||
});
|
||||
export type GitStatusEntry = typeof GitStatusEntry.Type;
|
||||
|
||||
export const GitWorkspaceInspection = Schema.Struct({
|
||||
branch: GitBranchName,
|
||||
diff: Schema.String,
|
||||
hasChanges: Schema.Boolean,
|
||||
status: Schema.Array(GitStatusEntry),
|
||||
});
|
||||
export type GitWorkspaceInspection = typeof GitWorkspaceInspection.Type;
|
||||
|
||||
export const GitVerification = Schema.Literals(["passed", "failed", "not-run"]);
|
||||
export type GitVerification = typeof GitVerification.Type;
|
||||
|
||||
export const PullRequestStatus = Schema.Literals(["open", "closed", "merged"]);
|
||||
export type PullRequestStatus = typeof PullRequestStatus.Type;
|
||||
|
||||
export const PullRequestMetadata = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
branch: GitBranchName,
|
||||
number: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
status: PullRequestStatus,
|
||||
url: Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
return new URL(value).protocol.startsWith("http");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "a valid HTTP pull request URL" }
|
||||
)
|
||||
),
|
||||
});
|
||||
export type PullRequestMetadata = typeof PullRequestMetadata.Type;
|
||||
|
||||
export const GitLifecycleStatus = Schema.Literals([
|
||||
"no_changes",
|
||||
"committed",
|
||||
"pushed",
|
||||
"pull_request_open",
|
||||
"failed",
|
||||
]);
|
||||
export type GitLifecycleStatus = typeof GitLifecycleStatus.Type;
|
||||
|
||||
export const GitLifecycleResult = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
branch: GitBranchName,
|
||||
commitSha: Schema.optional(MeaningfulString),
|
||||
pullRequest: Schema.optional(PullRequestMetadata),
|
||||
status: GitLifecycleStatus,
|
||||
});
|
||||
export type GitLifecycleResult = typeof GitLifecycleResult.Type;
|
||||
|
||||
export const GitLifecycleDecision = Schema.Literals([
|
||||
"finish",
|
||||
"commit",
|
||||
"push",
|
||||
"create_pull_request",
|
||||
"manual_merge",
|
||||
]);
|
||||
export type GitLifecycleDecision = typeof GitLifecycleDecision.Type;
|
||||
|
||||
export const GitLifecycleDecisionInput = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
commitSha: Schema.optional(MeaningfulString),
|
||||
inspection: GitWorkspaceInspection,
|
||||
pullRequest: Schema.optional(PullRequestMetadata),
|
||||
pushed: Schema.Boolean,
|
||||
verification: GitVerification,
|
||||
});
|
||||
export type GitLifecycleDecisionInput = typeof GitLifecycleDecisionInput.Type;
|
||||
|
||||
export const GitLifecycleDecisionResult = Schema.Struct({
|
||||
decision: GitLifecycleDecision,
|
||||
status: GitLifecycleStatus,
|
||||
});
|
||||
export type GitLifecycleDecisionResult = typeof GitLifecycleDecisionResult.Type;
|
||||
|
||||
export const GitLifecycleErrorReason = Schema.Literals([
|
||||
"Authentication",
|
||||
"CommandFailed",
|
||||
"InvalidInput",
|
||||
"InvalidBranch",
|
||||
"NoChanges",
|
||||
"InvalidPullRequest",
|
||||
"RemoteRejected",
|
||||
"RequestFailed",
|
||||
"VerificationRequired",
|
||||
]);
|
||||
export type GitLifecycleErrorReason = typeof GitLifecycleErrorReason.Type;
|
||||
|
||||
export class GitLifecycleError extends Schema.TaggedErrorClass<GitLifecycleError>()(
|
||||
"GitLifecycleError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: GitLifecycleErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
const invalidInput = (message: string) =>
|
||||
new GitLifecycleError({ message, reason: "InvalidInput" });
|
||||
|
||||
export const parseGitStatus = (rawStatus: string): GitStatusEntry[] =>
|
||||
rawStatus
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((raw) => ({
|
||||
index: raw.slice(0, 1),
|
||||
path: raw.slice(3).trim(),
|
||||
raw,
|
||||
worktree: raw.slice(1, 2),
|
||||
}));
|
||||
|
||||
export const inspectGitWorkspace = Effect.fn("Git.inspectWorkspace")(
|
||||
function* inspectGitWorkspace(input: unknown) {
|
||||
const decoded = yield* Schema.decodeUnknownEffect(
|
||||
Schema.Struct({
|
||||
branch: GitBranchName,
|
||||
diff: Schema.String,
|
||||
status: Schema.String,
|
||||
})
|
||||
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
const status = parseGitStatus(decoded.status);
|
||||
return {
|
||||
branch: decoded.branch,
|
||||
diff: decoded.diff,
|
||||
hasChanges: status.length > 0 || decoded.diff.length > 0,
|
||||
status,
|
||||
} satisfies GitWorkspaceInspection;
|
||||
}
|
||||
);
|
||||
|
||||
export const decideGitLifecycle = Effect.fn("Git.decideLifecycle")(
|
||||
function* decideGitLifecycle(input: unknown) {
|
||||
const decoded = yield* Schema.decodeUnknownEffect(
|
||||
GitLifecycleDecisionInput
|
||||
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
if (decoded.inspection.branch === decoded.baseBranch) {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: `Refusing to publish the default branch ${decoded.baseBranch}`,
|
||||
reason: "InvalidBranch",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded.verification !== "passed") {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: "Verified changes are required before publishing",
|
||||
reason: "VerificationRequired",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!decoded.inspection.hasChanges && decoded.commitSha === undefined) {
|
||||
return {
|
||||
decision: "finish" as const,
|
||||
status: "no_changes" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (decoded.commitSha === undefined) {
|
||||
return {
|
||||
decision: "commit" as const,
|
||||
status: "committed" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (!decoded.pushed) {
|
||||
return {
|
||||
decision: "push" as const,
|
||||
status: "pushed" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (decoded.pullRequest === undefined) {
|
||||
return {
|
||||
decision: "create_pull_request" as const,
|
||||
status: "pull_request_open" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
decision: "manual_merge" as const,
|
||||
status: "pull_request_open" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
);
|
||||
|
||||
export const validatePullRequestMetadata = Effect.fn(
|
||||
"Git.validatePullRequestMetadata"
|
||||
)(function* validatePullRequestMetadata(input: unknown) {
|
||||
const metadata = yield* Schema.decodeUnknownEffect(PullRequestMetadata)(
|
||||
input
|
||||
).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
if (metadata.branch === metadata.baseBranch) {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: "A pull request must target a different branch",
|
||||
reason: "InvalidPullRequest",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
});
|
||||
|
||||
export const makeCommitMessage = (
|
||||
issueNumber: number,
|
||||
title: string
|
||||
): string => {
|
||||
const normalizedTitle = title.replaceAll(/\s+/gu, " ").trim();
|
||||
return `feat(issue-${issueNumber}): ${normalizedTitle}`.slice(0, 120);
|
||||
};
|
||||
Reference in New Issue
Block a user