diff --git a/apps/web/src/components/projects/project-workspace-page.tsx b/apps/web/src/components/projects/project-workspace-page.tsx index b171063..bc22d97 100644 --- a/apps/web/src/components/projects/project-workspace-page.tsx +++ b/apps/web/src/components/projects/project-workspace-page.tsx @@ -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

Loading artifacts...

; + return ( +

Loading artifacts...

+ ); } if (artifacts.length === 0) { return

No artifacts yet.

; @@ -168,6 +171,55 @@ const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => ( ); +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 ( +
+
+ +

+ Pull request +

+
+
+
+ + #{pullRequest.number} + + + {pullRequest.status} + +
+

+ {pullRequest.branch} → {pullRequest.baseBranch} +

+

+ Merge remains manual. +

+
+
+ ); +}; + interface IssueComposerProps { readonly body: string; readonly busy: boolean; @@ -354,6 +406,8 @@ export const ProjectWorkspacePage = () => { + +
{ 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, diff --git a/bun.lock b/bun.lock index e142821..7e76667 100644 --- a/bun.lock +++ b/bun.lock @@ -149,9 +149,11 @@ "dependencies": { "@code/backend": "workspace:*", "@code/env": "workspace:*", + "@code/primitives": "workspace:*", "@flue/runtime": "latest", "@rivet-dev/agentos-core": "catalog:", "convex": "catalog:", + "effect": "catalog:", "hono": "4.12.30", "valibot": "^1.4.2", }, diff --git a/packages/agents/package.json b/packages/agents/package.json index 6c7185e..a454ebe 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -14,9 +14,11 @@ "dependencies": { "@code/backend": "workspace:*", "@code/env": "workspace:*", + "@code/primitives": "workspace:*", "@flue/runtime": "latest", "@rivet-dev/agentos-core": "catalog:", "convex": "catalog:", + "effect": "catalog:", "hono": "4.12.30", "valibot": "^1.4.2" }, diff --git a/packages/agents/src/actions/finalize-gitea-lifecycle.ts b/packages/agents/src/actions/finalize-gitea-lifecycle.ts new file mode 100644 index 0000000..a1e3fb3 --- /dev/null +++ b/packages/agents/src/actions/finalize-gitea-lifecycle.ts @@ -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 +) => { + 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 } + ) { + 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; + } + }, + }); +}; diff --git a/packages/agents/src/agents/project-manager.ts b/packages/agents/src/agents/project-manager.ts index 10316da..1e7f8d5 100644 --- a/packages/agents/src/agents/project-manager.ts +++ b/packages/agents/src/agents/project-manager.ts @@ -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", 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 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. -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}`, sandbox: agentOs(env), tools: createProjectTools(id, env), diff --git a/packages/agents/src/git/gitea.test.ts b/packages/agents/src/git/gitea.test.ts new file mode 100644 index 0000000..b49e32e --- /dev/null +++ b/packages/agents/src/git/gitea.test.ts @@ -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" + ); + }); +}); diff --git a/packages/agents/src/git/gitea.ts b/packages/agents/src/git/gitea.ts new file mode 100644 index 0000000..18eefaf --- /dev/null +++ b/packages/agents/src/git/gitea.ts @@ -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; + } + ) => Promise; +} + +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; + readonly getRepository: (repositoryPath: string) => Promise; +} + +export interface GiteaHttpTransportOptions { + readonly baseUrl: string; + readonly fetch?: ( + input: string | URL | Request, + init?: RequestInit + ) => Promise; + 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[1] +): Promise => { + 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 ( + options: GiteaHttpTransportOptions, + path: string, + init?: RequestInit +): Promise => { + 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 => { + 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 => { + 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", + }; +}; diff --git a/packages/backend/convex/agentWorkspace.ts b/packages/backend/convex/agentWorkspace.ts index 3a39e1b..2dc2690 100644 --- a/packages/backend/convex/agentWorkspace.ts +++ b/packages/backend/convex/agentWorkspace.ts @@ -29,11 +29,21 @@ export const get = query({ if (!project) { throw new ConvexError("Project not found"); } - const artifacts = await ctx.db - .query("projectArtifacts") - .withIndex("by_project", (q) => q.eq("projectId", project._id)) - .take(25); - return { artifacts, issue, project }; + const [artifacts, source, run] = await Promise.all([ + ctx.db + .query("projectArtifacts") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .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; + }, +}); diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts index d9695ce..5ddb981 100644 --- a/packages/backend/convex/projectEvents.test.ts +++ b/packages/backend/convex/projectEvents.test.ts @@ -217,4 +217,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" + ); + }); }); diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index 0ac5a54..f1438f2 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -130,3 +130,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); + }, +}); diff --git a/packages/env/src/agent.ts b/packages/env/src/agent.ts index 8651cc6..8710d78 100644 --- a/packages/env/src/agent.ts +++ b/packages/env/src/agent.ts @@ -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; diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 7b3f661..ed1315b 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -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" }, diff --git a/packages/primitives/src/git.test.ts b/packages/primitives/src/git.test.ts new file mode 100644 index 0000000..cfa42c9 --- /dev/null +++ b/packages/primitives/src/git.test.ts @@ -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" + ); + }); +}); diff --git a/packages/primitives/src/git.ts b/packages/primitives/src/git.ts new file mode 100644 index 0000000..9cfda4e --- /dev/null +++ b/packages/primitives/src/git.ts @@ -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", + { + 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); +}; diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index d252f41..1444dce 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -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";