Merge branch 'puter/issue-5-gitea-lifecycle' into puter/issue-14-integration

# Conflicts:
#	bun.lock
#	packages/agents/package.json
#	packages/agents/src/agents/project-manager.ts
#	packages/backend/convex/agentWorkspace.ts
This commit is contained in:
-Puter
2026-07-24 02:31:21 +05:30
15 changed files with 1264 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import {
ExternalLink,
FileText,
GitFork,
GitPullRequest,
Play,
Plus,
} from "lucide-react";
@@ -139,7 +140,9 @@ interface ArtifactGridProps {
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
if (artifacts === undefined) {
return <p className="text-xs text-muted-foreground">Loading artifacts...</p>;
return (
<p className="text-xs text-muted-foreground">Loading artifacts...</p>
);
}
if (artifacts.length === 0) {
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
@@ -168,6 +171,55 @@ const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
</section>
);
interface PullRequestPanelProps {
readonly pullRequest:
| {
readonly baseBranch: string;
readonly branch: string;
readonly number: number;
readonly status: "open" | "closed" | "merged";
readonly url: string;
}
| undefined;
}
const PullRequestPanel = ({ pullRequest }: PullRequestPanelProps) => {
if (!pullRequest) {
return null;
}
return (
<section aria-labelledby="pull-request-heading" className="space-y-3">
<div className="flex items-center gap-2">
<GitPullRequest className="size-4 text-muted-foreground" />
<h2 id="pull-request-heading" className="text-sm font-semibold">
Pull request
</h2>
</div>
<div className="border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<a
className="text-sm font-medium underline underline-offset-4"
href={pullRequest.url}
rel="noreferrer"
target="_blank"
>
#{pullRequest.number}
</a>
<span className="border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{pullRequest.status}
</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{pullRequest.branch} {pullRequest.baseBranch}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Merge remains manual.
</p>
</div>
</section>
);
};
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
@@ -354,6 +406,8 @@ export const ProjectWorkspacePage = () => {
<ArtifactGrid artifacts={workspace.artifacts} />
<PullRequestPanel pullRequest={workspace.pullRequest} />
<div className="grid items-start gap-5 lg:grid-cols-[minmax(280px,380px)_1fr]">
<IssueComposer
body={workspace.issueBody}

View File

@@ -33,6 +33,30 @@ export const useProjectWorkspace = () => {
api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const events = useQuery(
api.projectIssues.events,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const pullRequest = events
?.map((event) => event.data)
.find(
(
data
): data is {
pullRequest: {
baseBranch: string;
branch: string;
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof data === "object" &&
data !== null &&
"pullRequest" in data &&
typeof data.pullRequest === "object" &&
data.pullRequest !== null
)?.pullRequest;
const connectRepository = async () => {
setPendingAction("connect");
@@ -99,11 +123,13 @@ export const useProjectWorkspace = () => {
artifacts,
connectRepository,
error,
events,
issueBody,
issueTitle,
issues,
pendingAction,
projects,
pullRequest,
raiseIssue,
repository,
selectedProject,

View File

@@ -153,8 +153,8 @@
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"effect": "catalog:",
"hono": "4.12.31",
"effect": "catalog:",
"hono": "4.12.31",
"valibot": "^1.4.2",
},
"devDependencies": {

View File

@@ -0,0 +1,132 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent";
import { defineAction } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import * as v from "valibot";
import {
createGiteaHttpTransport,
runPostRunGiteaLifecycle,
} from "../git/gitea";
const pullRequestOutput = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.picklist(["open", "closed", "merged"]),
url: v.string(),
});
const output = v.object({
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
pullRequest: v.optional(pullRequestOutput),
status: v.picklist([
"no_changes",
"committed",
"pushed",
"pull_request_open",
"failed",
]),
});
export const createFinalizeGiteaLifecycle = (
issueAgentId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const issueId = issueAgentId as Id<"projectIssues">;
return defineAction({
description:
"After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
input: v.object({
commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
verified: v.boolean(),
}),
name: "finalize_gitea_lifecycle",
output,
async run({ harness, input, log }) {
const context = await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
});
if (!context.source) {
throw new Error("Project has no Git source configured");
}
if (!context.source.defaultBranch) {
throw new Error("Project Git source has no default branch");
}
if (!env.GITEA_TOKEN) {
throw new Error(
"GITEA_TOKEN is required for Gitea pull request creation"
);
}
const runner = {
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
return await harness.shell(command, options);
},
};
const transport = createGiteaHttpTransport({
baseUrl: env.GITEA_URL,
token: env.GITEA_TOKEN,
});
try {
const result = await runPostRunGiteaLifecycle({
baseBranch: context.source.defaultBranch,
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
commitMessage: input.commitMessage,
issueNumber: context.issue.number,
issueTitle: context.issue.title,
repositoryPath: context.source.repositoryPath,
runner,
title: `Issue #${context.issue.number}: ${context.issue.title}`,
transport,
verification: input.verified ? "passed" : "failed",
workspace: "/workspace/repository",
});
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: result.baseBranch,
branch: result.branch,
...(result.commitSha === undefined
? {}
: { commitSha: result.commitSha }),
issueId,
...(result.pullRequest === undefined
? {}
: { pullRequest: result.pullRequest }),
status: result.status,
token: env.FLUE_DB_TOKEN,
});
log.info("Gitea lifecycle completed", {
branch: result.branch,
status: result.status,
});
return result;
} catch (error) {
const branchResult = await runner.run("git branch --show-current", {
cwd: "/workspace/repository",
});
const branch = branchResult.stdout.trim() || "unknown";
const message = error instanceof Error ? error.message : String(error);
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: context.source.defaultBranch,
branch,
error: message,
issueId,
status: "failed",
token: env.FLUE_DB_TOKEN,
});
throw error;
}
},
});
};

View File

@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
import { agentOs } from "../sandboxes/agent-os";
import { createProjectTools } from "../tools/project";
@@ -14,6 +15,7 @@ export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
actions: [createFinalizeGiteaLifecycle(id, env)],
cwd: "/workspace/repository",
description,
instructions: `You are the issue-scoped project manager and coding agent.
@@ -22,7 +24,7 @@ Start every run by calling lookup_issue_context, reading /workspace/control/issu
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env),
tools: createProjectTools(id, env),

View File

@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
import type { GitCommandRunner, GiteaTransport } from "./gitea";
const repository = {
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
defaultBranch: "main",
htmlUrl: "https://git.openputer.com/puter/zopu-code",
name: "zopu-code",
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
};
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
const commands: string[] = [];
let statusCalls = 0;
return {
commands,
run(command) {
commands.push(command);
if (command === "git branch --show-current") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "work/issue-5\n",
});
}
if (command === "git status --porcelain=v1") {
statusCalls += 1;
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
});
}
if (command === "git diff --no-ext-diff --binary") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "diff --git ...",
});
}
if (command === "git rev-parse HEAD") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "abc123\n",
});
}
if (command.includes("git status --porcelain=v1")) {
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
},
};
};
describe("Gitea lifecycle adapter", () => {
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
const runner = makeRunner();
const pullRequests: unknown[] = [];
const transport: GiteaTransport = {
createPullRequest(input) {
pullRequests.push(input);
return Promise.resolve({
base: { ref: input.base },
head: { ref: input.head },
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
});
},
getRepository() {
return Promise.resolve(repository);
},
};
const result = await runPostRunGiteaLifecycle({
baseBranch: "main",
body: "Generated by the verified project run.",
issueNumber: 5,
issueTitle: "Publish verified changes",
repositoryPath: "puter/zopu-code",
runner,
transport,
verification: "passed",
workspace: "/workspace",
});
expect(result).toMatchObject({
branch: "work/issue-5",
commitSha: "abc123",
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
},
status: "pull_request_open",
});
expect(runner.commands).toEqual([
"git branch --show-current",
"git status --porcelain=v1",
"git diff --no-ext-diff --binary",
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD",
"git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
]);
expect(pullRequests).toHaveLength(1);
});
test("mocks the HTTP Gitea boundary without calling the network", async () => {
const requests: Request[] = [];
const transport = createGiteaHttpTransport({
baseUrl: "https://git.openputer.com",
fetch: (input, init) => {
const request = new Request(String(input), init);
requests.push(request);
return Promise.resolve(
new Response(
request.method === "GET"
? JSON.stringify(repository)
: JSON.stringify({
base: { ref: "main" },
head: { ref: "work/issue-5" },
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
}),
{ headers: { "content-type": "application/json" } }
)
);
},
token: "scoped-test-token",
});
await transport.getRepository("puter/zopu-code");
await transport.createPullRequest({
base: "main",
body: "body",
head: "work/issue-5",
repositoryPath: "puter/zopu-code",
title: "title",
});
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
);
expect(requests[1]?.headers.get("authorization")).toBe(
"token scoped-test-token"
);
});
});

View File

@@ -0,0 +1,307 @@
import {
decideGitLifecycle,
GitLifecycleError,
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
} from "@code/primitives/git";
import type {
GitLifecycleDecisionResult,
GitLifecycleResult,
GitWorkspaceInspection,
} from "@code/primitives/git";
import { Effect } from "effect";
export interface GitCommandResult {
readonly exitCode: number;
readonly stderr: string;
readonly stdout: string;
}
export interface GitCommandRunner {
readonly run: (
command: string,
options?: {
readonly cwd?: string;
readonly env?: Record<string, string>;
}
) => Promise<GitCommandResult>;
}
export interface GiteaRepository {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly htmlUrl: string;
readonly name: string;
readonly sshUrl: string;
}
export interface GiteaPullRequest {
readonly base: { readonly ref: string };
readonly head: { readonly ref: string };
readonly htmlUrl: string;
readonly number: number;
readonly state: "open" | "closed" | "merged";
}
export interface GiteaTransport {
readonly createPullRequest: (input: {
readonly base: string;
readonly body: string;
readonly head: string;
readonly repositoryPath: string;
readonly title: string;
}) => Promise<GiteaPullRequest>;
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
}
export interface GiteaHttpTransportOptions {
readonly baseUrl: string;
readonly fetch?: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
readonly token: string;
}
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const providerFailure = (
message: string,
reason: "CommandFailed" | "RemoteRejected"
) => new GitLifecycleError({ message, reason });
const runChecked = async (
runner: GitCommandRunner,
command: string,
options?: Parameters<GitCommandRunner["run"]>[1]
): Promise<string> => {
let result: GitCommandResult;
try {
result = await runner.run(command, options);
} catch (error) {
throw new GitLifecycleError({
message: `Git command could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "CommandFailed",
});
}
if (result.exitCode !== 0) {
throw providerFailure(
result.stderr.trim() || `Git command failed: ${command}`,
command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
);
}
return result.stdout;
};
const toHttpPath = (repositoryPath: string): string =>
repositoryPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const requestJson = async <T>(
options: GiteaHttpTransportOptions,
path: string,
init?: RequestInit
): Promise<T> => {
let response: Response;
try {
response = await (options.fetch ?? globalThis.fetch)(
`${options.baseUrl.replace(/\/$/u, "")}${path}`,
{
...init,
headers: {
Accept: "application/json",
Authorization: `token ${options.token}`,
"Content-Type": "application/json",
...init?.headers,
},
}
);
} catch (error) {
throw new GitLifecycleError({
message: `Gitea request could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "RequestFailed",
});
}
if (!response.ok) {
const detail = await response.text();
throw new GitLifecycleError({
message: `Gitea request failed (${response.status}): ${detail}`,
reason:
response.status === 401 || response.status === 403
? "Authentication"
: "RequestFailed",
});
}
return (await response.json()) as T;
};
export const createGiteaHttpTransport = (
options: GiteaHttpTransportOptions
): GiteaTransport => ({
async createPullRequest(input) {
const response = await requestJson<{
base: { ref: string };
head: { ref: string };
html_url: string;
number: number;
state: "open" | "closed" | "merged";
}>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
body: JSON.stringify({
base: input.base,
body: input.body,
head: input.head,
title: input.title,
}),
method: "POST",
});
return {
base: response.base,
head: response.head,
htmlUrl: response.html_url,
number: response.number,
state: response.state,
};
},
async getRepository(repositoryPath) {
const response = await requestJson<{
clone_url: string;
default_branch: string;
html_url: string;
name: string;
ssh_url: string;
}>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
return {
cloneUrl: response.clone_url,
defaultBranch: response.default_branch,
htmlUrl: response.html_url,
name: response.name,
sshUrl: response.ssh_url,
};
},
});
export interface RunPostRunGiteaLifecycleInput {
readonly baseBranch: string;
readonly body: string;
readonly commitMessage?: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly runner: GitCommandRunner;
readonly title?: string;
readonly transport: GiteaTransport;
readonly verification: "passed" | "failed" | "not-run";
readonly workspace: string;
}
const inspect = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitWorkspaceInspection> => {
const [branch, status, diff] = await Promise.all([
runChecked(input.runner, "git branch --show-current", {
cwd: input.workspace,
}),
runChecked(input.runner, "git status --porcelain=v1", {
cwd: input.workspace,
}),
runChecked(input.runner, "git diff --no-ext-diff --binary", {
cwd: input.workspace,
}),
]);
return await Effect.runPromise(
inspectGitWorkspace({
branch: branch.trim(),
diff,
status,
})
);
};
export const runPostRunGiteaLifecycle = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitLifecycleResult> => {
const inspection = await inspect(input);
const initialDecision = (await Effect.runPromise(
decideGitLifecycle({
baseBranch: input.baseBranch,
inspection,
pushed: false,
verification: input.verification,
})
)) as GitLifecycleDecisionResult;
if (initialDecision.decision === "finish") {
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha: undefined,
pullRequest: undefined,
status: "no_changes",
};
}
const repository = await input.transport.getRepository(input.repositoryPath);
await runChecked(
input.runner,
`git add --all && git commit -m ${shellQuote(
input.commitMessage ??
makeCommitMessage(input.issueNumber, input.issueTitle)
)}`,
{ cwd: input.workspace }
);
const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
cwd: input.workspace,
});
const commitSha = commitOutput.trim();
const cleanStatus = await runChecked(
input.runner,
"git status --porcelain=v1",
{ cwd: input.workspace }
);
if (cleanStatus.trim().length > 0) {
throw providerFailure(
"Git workspace remained dirty after commit",
"CommandFailed"
);
}
await runChecked(
input.runner,
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
{ cwd: input.workspace }
);
const pullRequest = await input.transport.createPullRequest({
base: input.baseBranch,
body: input.body,
head: inspection.branch,
repositoryPath: input.repositoryPath,
title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
});
const metadata = await Effect.runPromise(
validatePullRequestMetadata({
baseBranch: pullRequest.base.ref,
branch: pullRequest.head.ref,
number: pullRequest.number,
status: pullRequest.state,
url: pullRequest.htmlUrl,
})
);
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha,
pullRequest: metadata,
status: "pull_request_open",
};
};

View File

@@ -278,3 +278,111 @@ export const setStatus = mutation({
});
},
});
const lifecycleStatus = v.union(
v.literal("no_changes"),
v.literal("committed"),
v.literal("pushed"),
v.literal("pull_request_open"),
v.literal("failed")
);
const pullRequestMetadata = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
url: v.string(),
});
export const recordGiteaLifecycle = mutation({
args: {
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
error: v.optional(v.string()),
issueId: v.id("projectIssues"),
pullRequest: v.optional(pullRequestMetadata),
status: lifecycleStatus,
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
const run = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
if (!run) {
throw new ConvexError("Agent work run not found");
}
const events = await ctx.db
.query("projectEvents")
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
.order("desc")
.take(100);
const existing = events.find((event) => {
if (!event.kind.startsWith("gitea.")) {
return false;
}
const data = event.data as {
branch?: unknown;
commitSha?: unknown;
status?: unknown;
};
return (
data.branch === args.branch &&
data.commitSha === args.commitSha &&
data.status === args.status
);
});
if (existing) {
return existing.data;
}
const timestamp = Date.now();
const data = {
baseBranch: args.baseBranch,
branch: args.branch,
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
...(args.pullRequest === undefined
? {}
: { pullRequest: args.pullRequest }),
status: args.status,
};
const artifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
)
.unique();
if (artifact) {
const pullRequestLine = args.pullRequest
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
: "";
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
await ctx.db.patch("projectArtifacts", artifact._id, {
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
revision: artifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data,
issueId: issue._id,
kind:
args.pullRequest === undefined
? "gitea.lifecycle.updated"
: "gitea.pull_request.created",
projectId: issue.projectId,
runId: run._id,
});
return data;
},
});

View File

@@ -223,4 +223,63 @@ describe("projectEvents", () => {
const events = await eventsForProject(t, projectId);
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
});
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
const t = convexTest({ schema, modules });
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1",
issueId,
token: FLUE_DB_TOKEN,
});
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: "main",
branch: "work/issue-1",
commitSha: "abc123",
issueId,
pullRequest: {
baseBranch: "main",
branch: "work/issue-1",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
},
status: "pull_request_open",
token: FLUE_DB_TOKEN,
});
const events = await eventsForProject(t, projectId);
const event = events.find(
(item) => item.kind === "gitea.pull_request.created"
);
expect(event?.data).toMatchObject({
branch: "work/issue-1",
commitSha: "abc123",
pullRequest: {
number: 5,
status: "open",
},
status: "pull_request_open",
});
const artifacts = await t.query((ctx) =>
ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", "artifacts.md")
)
.unique()
);
expect(artifacts?.content).toContain(
"https://git.openputer.com/puter/zopu-code/pulls/5"
);
});
});

View File

@@ -191,3 +191,21 @@ export const list = query({
.take(100);
},
});
export const events = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
},
});

View File

@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
CONVEX_URL: z.url(),
DAEMON_ID: z.string().min(1),
FLUE_DB_TOKEN: z.string().min(1),
GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"),
});
export type AgentEnv = z.infer<typeof agentEnvSchema>;

View File

@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
"./git": "./src/git.ts",
"./project": "./src/project.ts",
"./project-issue": "./src/project-issue.ts",
"./project-workspace": "./src/project-workspace.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,5 +1,6 @@
// 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 "./project-issue";
export * from "./project-workspace";