feat: add post-run Gitea lifecycle (#5)

This commit is contained in:
-Puter
2026-07-24 02:05:30 +05:30
parent 7974bd5f3e
commit a17d53cb41
16 changed files with 1281 additions and 7 deletions

View File

@@ -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"
},

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

View 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);
};

View File

@@ -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";