feat: add post-run Gitea lifecycle (#5)
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitFork,
|
GitFork,
|
||||||
|
GitPullRequest,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -139,7 +140,9 @@ interface ArtifactGridProps {
|
|||||||
|
|
||||||
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
|
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
|
||||||
if (artifacts === undefined) {
|
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) {
|
if (artifacts.length === 0) {
|
||||||
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
|
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
|
||||||
@@ -168,6 +171,55 @@ const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
|
|||||||
</section>
|
</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 {
|
interface IssueComposerProps {
|
||||||
readonly body: string;
|
readonly body: string;
|
||||||
readonly busy: boolean;
|
readonly busy: boolean;
|
||||||
@@ -354,6 +406,8 @@ export const ProjectWorkspacePage = () => {
|
|||||||
|
|
||||||
<ArtifactGrid artifacts={workspace.artifacts} />
|
<ArtifactGrid artifacts={workspace.artifacts} />
|
||||||
|
|
||||||
|
<PullRequestPanel pullRequest={workspace.pullRequest} />
|
||||||
|
|
||||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(280px,380px)_1fr]">
|
<div className="grid items-start gap-5 lg:grid-cols-[minmax(280px,380px)_1fr]">
|
||||||
<IssueComposer
|
<IssueComposer
|
||||||
body={workspace.issueBody}
|
body={workspace.issueBody}
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ export const useProjectWorkspace = () => {
|
|||||||
api.projectIssues.list,
|
api.projectIssues.list,
|
||||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
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 () => {
|
const connectRepository = async () => {
|
||||||
setPendingAction("connect");
|
setPendingAction("connect");
|
||||||
@@ -99,11 +123,13 @@ export const useProjectWorkspace = () => {
|
|||||||
artifacts,
|
artifacts,
|
||||||
connectRepository,
|
connectRepository,
|
||||||
error,
|
error,
|
||||||
|
events,
|
||||||
issueBody,
|
issueBody,
|
||||||
issueTitle,
|
issueTitle,
|
||||||
issues,
|
issues,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
projects,
|
projects,
|
||||||
|
pullRequest,
|
||||||
raiseIssue,
|
raiseIssue,
|
||||||
repository,
|
repository,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
|||||||
2
bun.lock
2
bun.lock
@@ -149,9 +149,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "latest",
|
||||||
"@rivet-dev/agentos-core": "catalog:",
|
"@rivet-dev/agentos-core": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"hono": "4.12.30",
|
"hono": "4.12.30",
|
||||||
"valibot": "^1.4.2",
|
"valibot": "^1.4.2",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "latest",
|
||||||
"@rivet-dev/agentos-core": "catalog:",
|
"@rivet-dev/agentos-core": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"hono": "4.12.30",
|
"hono": "4.12.30",
|
||||||
"valibot": "^1.4.2"
|
"valibot": "^1.4.2"
|
||||||
},
|
},
|
||||||
|
|||||||
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal 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",
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
|
|||||||
import { defineAgent } from "@flue/runtime";
|
import { defineAgent } from "@flue/runtime";
|
||||||
import type { AgentRouteHandler } from "@flue/runtime";
|
import type { AgentRouteHandler } from "@flue/runtime";
|
||||||
|
|
||||||
|
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||||
import { agentOs } from "../sandboxes/agent-os";
|
import { agentOs } from "../sandboxes/agent-os";
|
||||||
import { createProjectTools } from "../tools/project";
|
import { createProjectTools } from "../tools/project";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default defineAgent(({ env, id }) => {
|
|||||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
actions: [createFinalizeGiteaLifecycle(id, env)],
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
description,
|
description,
|
||||||
instructions: `You are the issue-scoped project manager and coding agent.
|
instructions: `You are the issue-scoped project manager and coding agent.
|
||||||
@@ -22,7 +24,7 @@ Start every run by calling lookup_issue_context, reading issue.md, project.md, b
|
|||||||
|
|
||||||
Work only on the bound issue. Inspect existing 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 command or scenario in AgentOS, and preserve command evidence.
|
Work only on the bound issue. Inspect existing 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 command or scenario in AgentOS, and preserve command evidence.
|
||||||
|
|
||||||
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files 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 local work.md, steps.md, artifacts.md, and context.md files 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}`,
|
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||||
sandbox: agentOs(env),
|
sandbox: agentOs(env),
|
||||||
tools: createProjectTools(id, env),
|
tools: createProjectTools(id, env),
|
||||||
|
|||||||
155
packages/agents/src/git/gitea.test.ts
Normal file
155
packages/agents/src/git/gitea.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
307
packages/agents/src/git/gitea.ts
Normal file
307
packages/agents/src/git/gitea.ts
Normal 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",
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -29,11 +29,21 @@ export const get = query({
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ConvexError("Project not found");
|
throw new ConvexError("Project not found");
|
||||||
}
|
}
|
||||||
const artifacts = await ctx.db
|
const [artifacts, source, run] = await Promise.all([
|
||||||
.query("projectArtifacts")
|
ctx.db
|
||||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
.query("projectArtifacts")
|
||||||
.take(25);
|
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||||
return { artifacts, issue, project };
|
.take(25),
|
||||||
|
ctx.db
|
||||||
|
.query("projectSources")
|
||||||
|
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||||
|
.first(),
|
||||||
|
ctx.db
|
||||||
|
.query("projectWorkRuns")
|
||||||
|
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||||
|
.unique(),
|
||||||
|
]);
|
||||||
|
return { artifacts, issue, project, run, source };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,3 +182,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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -217,4 +217,63 @@ describe("projectEvents", () => {
|
|||||||
const events = await eventsForProject(t, projectId);
|
const events = await eventsForProject(t, projectId);
|
||||||
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
|
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"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,3 +130,21 @@ export const list = query({
|
|||||||
.take(100);
|
.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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
2
packages/env/src/agent.ts
vendored
2
packages/env/src/agent.ts
vendored
@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
|
|||||||
CONVEX_URL: z.url(),
|
CONVEX_URL: z.url(),
|
||||||
DAEMON_ID: z.string().min(1),
|
DAEMON_ID: z.string().min(1),
|
||||||
FLUE_DB_TOKEN: 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>;
|
export type AgentEnv = z.infer<typeof agentEnvSchema>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./agent-os": "./src/agent-os.ts",
|
"./agent-os": "./src/agent-os.ts",
|
||||||
|
"./git": "./src/git.ts",
|
||||||
"./project": "./src/project.ts",
|
"./project": "./src/project.ts",
|
||||||
"./signal": "./src/signal.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.
|
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||||
export * from "./agent-os";
|
export * from "./agent-os";
|
||||||
|
export * from "./git";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
export * from "./signal";
|
export * from "./signal";
|
||||||
|
|||||||
Reference in New Issue
Block a user