feat: add post-run Gitea lifecycle (#5)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./git": "./src/git.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
},
|
||||
|
||||
145
packages/primitives/src/git.test.ts
Normal file
145
packages/primitives/src/git.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Effect, Exit } from "effect";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
decideGitLifecycle,
|
||||
inspectGitWorkspace,
|
||||
makeCommitMessage,
|
||||
validatePullRequestMetadata,
|
||||
} from "./git";
|
||||
|
||||
const inspectionInput = {
|
||||
branch: "work/issue-5",
|
||||
diff: "diff --git a/file.ts b/file.ts",
|
||||
status: " M file.ts",
|
||||
};
|
||||
|
||||
const inspection = await Effect.runPromise(
|
||||
inspectGitWorkspace(inspectionInput)
|
||||
);
|
||||
|
||||
describe("Git lifecycle primitives", () => {
|
||||
test("parses workspace status and treats a diff as publishable changes", () => {
|
||||
expect(inspection).toMatchObject({
|
||||
branch: "work/issue-5",
|
||||
hasChanges: true,
|
||||
status: [{ index: " ", path: "file.ts", worktree: "M" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("requires verification before any publish action", async () => {
|
||||
const result = await Effect.runPromiseExit(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "not-run",
|
||||
})
|
||||
);
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true);
|
||||
if (Exit.isFailure(result)) {
|
||||
const failure = result.cause as unknown as {
|
||||
readonly reasons: readonly [
|
||||
{ readonly error: { readonly reason: string } },
|
||||
];
|
||||
};
|
||||
expect(failure.reasons[0]?.error.reason).toBe("VerificationRequired");
|
||||
}
|
||||
});
|
||||
|
||||
test("never publishes the default branch", async () => {
|
||||
const result = await Effect.runPromiseExit(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "work/issue-5",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true);
|
||||
if (Exit.isFailure(result)) {
|
||||
const failure = result.cause as unknown as {
|
||||
readonly reasons: readonly [
|
||||
{ readonly error: { readonly reason: string } },
|
||||
];
|
||||
};
|
||||
expect(failure.reasons[0]?.error.reason).toBe("InvalidBranch");
|
||||
}
|
||||
});
|
||||
|
||||
test("moves verified changes through commit, push, PR, then manual merge", async () => {
|
||||
const commit = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(commit).toEqual({ decision: "commit", status: "committed" });
|
||||
|
||||
const push = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(push).toEqual({ decision: "push", status: "pushed" });
|
||||
|
||||
const pr = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pushed: true,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(pr).toEqual({
|
||||
decision: "create_pull_request",
|
||||
status: "pull_request_open",
|
||||
});
|
||||
|
||||
const merge = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
},
|
||||
pushed: true,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(merge).toEqual({
|
||||
decision: "manual_merge",
|
||||
status: "pull_request_open",
|
||||
});
|
||||
});
|
||||
|
||||
test("validates PR metadata and keeps merge manual", async () => {
|
||||
const metadata = await Effect.runPromise(
|
||||
validatePullRequestMetadata({
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
})
|
||||
);
|
||||
expect(metadata.status).toBe("open");
|
||||
expect(makeCommitMessage(5, "Ship Git lifecycle")).toBe(
|
||||
"feat(issue-5): Ship Git lifecycle"
|
||||
);
|
||||
});
|
||||
});
|
||||
250
packages/primitives/src/git.ts
Normal file
250
packages/primitives/src/git.ts
Normal file
@@ -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);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./git";
|
||||
export * from "./project";
|
||||
export * from "./signal";
|
||||
|
||||
Reference in New Issue
Block a user