251 lines
7.1 KiB
TypeScript
251 lines
7.1 KiB
TypeScript
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);
|
|
};
|