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", { 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); };