Zopu dev bootstrap: git runtimes, Codex agent-os, dev agent
Merge t3code/explore-primitives-package onto master.
This commit is contained in:
@@ -32,3 +32,7 @@ AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
||||
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
# Self-hosted git (Gitea) — canonical repository host and API token
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -246,6 +246,7 @@
|
||||
"name": "@code/primitives",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@agentos-software/codex-cli": "0.3.4",
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@rivet-dev/agentos": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"dev": "bun --env-file=../../.env flue dev",
|
||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
||||
"run": "bun --env-file=../../.env flue run",
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu"
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu",
|
||||
"run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/opencode": "0.2.7",
|
||||
|
||||
51
packages/agents/src/agents/zopu-dev.ts
Normal file
51
packages/agents/src/agents/zopu-dev.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import { createGitRemoteTools, makeGitRemoteConfig } from "../tools/git-remote";
|
||||
import { createWorkflowTools } from "../tools/workflow";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu Dev, the development agent for the canonical zopu-code repository (self-hosted Gitea: puter/zopu-code).
|
||||
|
||||
## Your job
|
||||
|
||||
You talk with the user and turn agreed-upon work into tracked issues, then kick off autonomous implementation. You operate on exactly one repository.
|
||||
|
||||
## Loop
|
||||
|
||||
1. Understand the request. Ask one focused clarifying question only when genuinely ambiguous. Do not create work from casual chat.
|
||||
|
||||
2. Check for duplicates. Call list_issues to see what already exists.
|
||||
|
||||
3. Create the issue. Call create_issue with a clear title and a body that captures the acceptance criteria. Report the issue number and URL back to the user.
|
||||
|
||||
4. Start work only on explicit confirmation. When the user says to start (e.g. "go", "start", "work on it"), call start_workflow with the issue number. This spins up a Codex agent in an isolated worktree that implements, verifies, commits, and opens a pull request.
|
||||
|
||||
5. Report outcomes plainly: "Created issue #N: <title> — <url>" and "Started run <id> (status <status>)".
|
||||
|
||||
## Rules
|
||||
|
||||
- Never invent issue numbers. Always create or list first.
|
||||
- Never start work without explicit user confirmation.
|
||||
- Titles are concise; bodies carry the acceptance criteria.
|
||||
- If a tool returns an error with a reason, surface it and suggest next steps instead of retrying blindly.`;
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
const agentEnv = parseAgentEnv(env);
|
||||
const gitConfig = makeGitRemoteConfig(agentEnv);
|
||||
|
||||
return {
|
||||
description:
|
||||
"Development agent that creates issues on the canonical zopu-code repo and starts autonomous Codex work runs.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${agentEnv.AGENT_MODEL_PROVIDER}/${agentEnv.AGENT_MODEL_NAME}`,
|
||||
sandbox: local({ cwd: process.cwd() }),
|
||||
tools: [
|
||||
...createGitRemoteTools(gitConfig),
|
||||
...createWorkflowTools({
|
||||
convexUrl: agentEnv.CONVEX_URL,
|
||||
token: agentEnv.FLUE_DB_TOKEN,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
119
packages/agents/src/tools/git-remote.ts
Normal file
119
packages/agents/src/tools/git-remote.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
createBranch,
|
||||
createIssue,
|
||||
createPullRequest,
|
||||
fetchTransport,
|
||||
listIssues,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import type {
|
||||
GitRemoteConfig,
|
||||
GitRemoteError,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { Effect } from "effect";
|
||||
import * as v from "valibot";
|
||||
|
||||
/**
|
||||
* Canonical repo for the bootstrap: one self-hosted Gitea repo hosts all work.
|
||||
* Hard-coded owner/repo keeps the agent off credential-shaped free input.
|
||||
*/
|
||||
export const CANONICAL_REPO = { owner: "puter", repo: "zopu-code" } as const;
|
||||
|
||||
export const makeGitRemoteConfig = (runtimeEnv: {
|
||||
readonly GITEA_TOKEN?: string;
|
||||
readonly GITEA_URL: string;
|
||||
}): GitRemoteConfig => {
|
||||
if (!runtimeEnv.GITEA_TOKEN) {
|
||||
throw new Error(
|
||||
"GITEA_TOKEN is required to configure the git remote tools"
|
||||
);
|
||||
}
|
||||
return {
|
||||
baseUrl: runtimeEnv.GITEA_URL,
|
||||
owner: CANONICAL_REPO.owner,
|
||||
repo: CANONICAL_REPO.repo,
|
||||
token: runtimeEnv.GITEA_TOKEN,
|
||||
};
|
||||
};
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> =>
|
||||
Effect.runPromise(eff);
|
||||
|
||||
const describeError = (error: GitRemoteError) => ({
|
||||
error: error.message,
|
||||
reason: error.reason,
|
||||
});
|
||||
|
||||
// Tool returns are serialized to the model; round-trip through JSON so the
|
||||
// Effect-branded/readonly value objects become plain mutable JsonValue.
|
||||
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone keeps readonly modifiers; JSON erases them for JsonValue
|
||||
const json = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/**
|
||||
* Git-remote tools bound to the canonical repo. The agent creates issues,
|
||||
* branches, and pull requests on puter/zopu-code via the Gitea API using the
|
||||
* server-side token — it never accepts credentials or repo paths as input.
|
||||
*/
|
||||
export const createGitRemoteTools = (config: GitRemoteConfig) => [
|
||||
defineTool({
|
||||
description:
|
||||
"Create an issue on the canonical zopu-code repository. Use this to turn an agreed-upon piece of work into a tracked issue. Returns the issue number and URL. Always call list_issues first to avoid duplicates.",
|
||||
input: v.object({
|
||||
body: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
name: "create_issue",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createIssue(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"List open issues on the canonical zopu-code repository. Call this before creating an issue to check for duplicates and understand existing work.",
|
||||
name: "list_issues",
|
||||
async run() {
|
||||
const result = await run(listIssues(fetchTransport, config)).catch(
|
||||
describeError
|
||||
);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Create a remote branch on the canonical zopu-code repository. Usually you do not need this directly — start_workflow handles branching for a work run. Use only when explicitly setting up a branch by hand.",
|
||||
input: v.object({
|
||||
from: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
}),
|
||||
name: "create_branch",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createBranch(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Open a pull request on the canonical zopu-code repository. Provide the work branch, the base branch (usually main), a title, and a body. Returns the PR number and URL.",
|
||||
input: v.object({
|
||||
baseBranch: v.string(),
|
||||
body: v.string(),
|
||||
branch: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
name: "create_pull_request",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createPullRequest(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
];
|
||||
37
packages/agents/src/tools/workflow.ts
Normal file
37
packages/agents/src/tools/workflow.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import * as v from "valibot";
|
||||
|
||||
// The control-plane mutation that enqueues a work run for an issue. Implemented
|
||||
// in the backend Convex workflow module; referenced by name so the agent never
|
||||
// imports generated bindings.
|
||||
const startIssueWorkRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly issueNumber: number; readonly token: string },
|
||||
{ readonly runId: string; readonly status: string }
|
||||
>("workflows:startIssueWork");
|
||||
|
||||
export const createWorkflowTools = (clientOptions: {
|
||||
readonly convexUrl: string;
|
||||
readonly token: string;
|
||||
}) => {
|
||||
const client = new ConvexHttpClient(clientOptions.convexUrl);
|
||||
|
||||
return [
|
||||
defineTool({
|
||||
description:
|
||||
"Start the autonomous work workflow for an existing zopu-code issue. This spawns a Codex agent in an isolated worktree of the canonical repo, gives it the issue, lets it implement and verify the change, then commits and opens a pull request. Use it after create_issue (or for an existing issue number) once the user confirms they want work to begin. Returns the run id and initial status.",
|
||||
input: v.object({
|
||||
issueNumber: v.number(),
|
||||
}),
|
||||
name: "start_workflow",
|
||||
async run({ input }) {
|
||||
return await client.mutation(startIssueWorkRef, {
|
||||
issueNumber: input.issueNumber,
|
||||
token: clientOptions.token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -7,6 +7,8 @@ const app = defineApp({
|
||||
NATIVE_APP_URL: v.optional(v.string()),
|
||||
SITE_URL: v.string(),
|
||||
FLUE_DB_TOKEN: v.string(),
|
||||
GITEA_URL: v.optional(v.string()),
|
||||
GITEA_TOKEN: v.optional(v.string()),
|
||||
},
|
||||
});
|
||||
app.use(betterAuth);
|
||||
|
||||
64
packages/backend/convex/workflows.ts
Normal file
64
packages/backend/convex/workflows.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
getIssue,
|
||||
fetchTransport,
|
||||
type GitRemoteConfig,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation } from "./_generated/server";
|
||||
|
||||
// Agent token guard (service-level auth for Zopu tools), matching signalRouting.
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
// Canonical repo for the bootstrap work run.
|
||||
const remoteConfig = (): GitRemoteConfig => {
|
||||
if (!env.GITEA_TOKEN) {
|
||||
throw new ConvexError("GITEA_TOKEN is not configured");
|
||||
}
|
||||
return {
|
||||
baseUrl: env.GITEA_URL,
|
||||
owner: "puter",
|
||||
repo: "zopu-code",
|
||||
token: env.GITEA_TOKEN,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a work run for an existing Gitea issue on the canonical repo. Fetches
|
||||
* the issue server-side (never trusts caller-supplied content), admits the run,
|
||||
* and returns the run id plus an initial status.
|
||||
*
|
||||
* The Codex VM spawn (clone the canonical repo into an isolated worktree, prompt
|
||||
* Codex with the issue, verify, commit, and open a Gitea PR) is the durable
|
||||
* workflow body. It requires the AgentOS registry hosted on the Rivet endpoint
|
||||
* and is wired as the next step.
|
||||
*/
|
||||
export const startIssueWork = mutation({
|
||||
args: {
|
||||
issueNumber: v.number(),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const config = remoteConfig();
|
||||
|
||||
const issue = await Effect.runPromise(
|
||||
getIssue(fetchTransport, config, args.issueNumber)
|
||||
);
|
||||
|
||||
const runId = `run_${Date.now().toString(36)}_${args.issueNumber}`;
|
||||
|
||||
return {
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
runId,
|
||||
status: "queued",
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/env/src/convex.ts
vendored
2
packages/env/src/convex.ts
vendored
@@ -7,6 +7,8 @@ export const env = createEnv({
|
||||
server: {
|
||||
CONVEX_SITE_URL: z.url(),
|
||||
FLUE_DB_TOKEN: z.string().min(1),
|
||||
GITEA_TOKEN: z.string().min(1).optional(),
|
||||
GITEA_URL: z.url().default("https://git.openputer.com"),
|
||||
NATIVE_APP_URL: z.string().min(1).default("code://"),
|
||||
SITE_URL: z.url(),
|
||||
},
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./git": "./src/git.ts",
|
||||
"./git-local-runtime": "./src/git-local-runtime.ts",
|
||||
"./git-remote-runtime": "./src/git-remote-runtime.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./project-issue": "./src/project-issue.ts",
|
||||
"./project-workspace": "./src/project-workspace.ts",
|
||||
"./project-work": "./src/project-work.ts",
|
||||
"./project-workspace": "./src/project-workspace.ts",
|
||||
"./signal": "./src/signal.ts",
|
||||
"./smoke": "./src/smoke.ts"
|
||||
},
|
||||
@@ -22,7 +24,8 @@
|
||||
"dependencies": {
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@rivet-dev/agentos": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"@agentos-software/codex-cli": "0.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
25
packages/primitives/src/agent-os.test.ts
Normal file
25
packages/primitives/src/agent-os.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { codexSessionEnv, makeCodexAgentOsConfig } from "./agent-os";
|
||||
|
||||
describe("Codex agent-os config", () => {
|
||||
it("always includes the codex + git software bundle", () => {
|
||||
const config = makeCodexAgentOsConfig();
|
||||
expect(config.software).toHaveLength(2);
|
||||
expect(config.software?.[0]).toBeTypeOf("object");
|
||||
});
|
||||
|
||||
it("builds model-provider session env", () => {
|
||||
const env = codexSessionEnv(
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://ai.example.com/v1",
|
||||
model: "glm-5.2",
|
||||
},
|
||||
{ CODEX_MODEL: "glm-5.2" }
|
||||
);
|
||||
expect(env.OPENAI_API_KEY).toBe("sk-test");
|
||||
expect(env.OPENAI_BASE_URL).toBe("https://ai.example.com/v1");
|
||||
expect(env.CODEX_MODEL).toBe("glm-5.2");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
|
||||
import codex from "@agentos-software/codex-cli";
|
||||
import git from "@agentos-software/git";
|
||||
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
|
||||
import type { AgentOSConfigInput } from "@rivet-dev/agentos";
|
||||
@@ -69,3 +70,52 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
|
||||
return yield* agentOs.createActor(config);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codex harness configuration
|
||||
//
|
||||
// The canonical execution registry runs exactly one AgentOS actor for the
|
||||
// puter/zopu-code repo, booted with the Codex CLI harness + git. The model
|
||||
// provider is injected as VM env (OpenAI-compatible base URL + key) so Codex
|
||||
// authenticates against the configured gateway without a second credentials
|
||||
// path. This is a pure config builder; the RivetKit registry/server that hosts
|
||||
// the actor lives in the agents package.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Software bundle for a Codex-capable VM: the Codex harness plus git. */
|
||||
export const codexSoftware = [codex, git] as const;
|
||||
|
||||
/** Model-provider env that Codex reads inside the VM. Pass this to the session's
|
||||
* `env` when opening a Codex session (`agent: "codex"`). */
|
||||
export interface CodexModelEnv {
|
||||
readonly apiKey: string;
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
}
|
||||
|
||||
/** Builds the VM env record Codex reads to authenticate against the model gateway. */
|
||||
export const codexSessionEnv = (
|
||||
model: CodexModelEnv,
|
||||
extra: Readonly<Record<string, string>> = {}
|
||||
): Record<string, string> => ({
|
||||
OPENAI_API_KEY: model.apiKey,
|
||||
OPENAI_BASE_URL: model.baseUrl,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export interface CodexAgentOsConfigInput {
|
||||
/** Extra software merged after the Codex+git bundle. */
|
||||
readonly software?: NonNullable<AgentOSConfigInput<undefined>["software"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AgentOS actor config for a Codex-backed VM. The Codex CLI and git
|
||||
* are always present; software the caller passes is appended, never replacing
|
||||
* the harness or git. Model-provider env is supplied per session via
|
||||
* `codexSessionEnv`, not here — the actor config has no env field.
|
||||
*/
|
||||
export const makeCodexAgentOsConfig = (
|
||||
input: CodexAgentOsConfigInput = {}
|
||||
): AgentOSConfigInput<undefined> => ({
|
||||
software: [...codexSoftware, ...(input.software ?? [])],
|
||||
});
|
||||
|
||||
116
packages/primitives/src/git-local-runtime.test.ts
Normal file
116
packages/primitives/src/git-local-runtime.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
// oxlint-disable-next-line unicorn/import-style -- named import avoids shadowing local `path` vars
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Effect } from "effect";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
addWorktree,
|
||||
commit,
|
||||
currentBranch,
|
||||
nodeShell,
|
||||
push,
|
||||
} from "./git-local-runtime";
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E>) => Effect.runPromise(eff);
|
||||
|
||||
const execPlain = async (cwd: string, cmd: string) => {
|
||||
const res = await run(nodeShell.exec("sh", ["-c", cmd], { cwd }));
|
||||
return res.stdout.trim();
|
||||
};
|
||||
|
||||
let root = "";
|
||||
let origin = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "git-local-"));
|
||||
origin = join(root, "origin.git");
|
||||
await execPlain(root, `git init --bare -b main ${origin}`);
|
||||
const seed = join(root, "seed");
|
||||
await execPlain(root, `git clone ${origin} ${seed}`);
|
||||
await execPlain(
|
||||
seed,
|
||||
"git config user.email t@t.t && git config user.name t"
|
||||
);
|
||||
await writeFile(join(seed, "README.md"), "# hi\n");
|
||||
await execPlain(
|
||||
seed,
|
||||
"git add -A && git commit -m init && git push -u origin main"
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await execPlain(root, "chmod -R u+w . 2>/dev/null; true");
|
||||
});
|
||||
|
||||
describe("GitLocalRuntime", () => {
|
||||
it("creates a worktree on a new branch", async () => {
|
||||
const cloneDir = join(root, "work");
|
||||
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
||||
const worktree = join(root, "wt-1");
|
||||
const result = await run(
|
||||
addWorktree(nodeShell, {
|
||||
branch: "work/1/fix",
|
||||
path: worktree,
|
||||
repositoryPath: cloneDir,
|
||||
})
|
||||
);
|
||||
expect(result).toBe(worktree);
|
||||
expect(await execPlain(worktree, "git rev-parse --abbrev-ref HEAD")).toBe(
|
||||
"work/1/fix"
|
||||
);
|
||||
});
|
||||
|
||||
it("commits staged changes and returns the new sha", async () => {
|
||||
const cloneDir = join(root, "work2");
|
||||
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
||||
await run(
|
||||
nodeShell.exec(
|
||||
"git",
|
||||
["worktree", "add", "-b", "work/2", join(root, "wt-2")],
|
||||
{ cwd: cloneDir }
|
||||
)
|
||||
);
|
||||
const worktree = join(root, "wt-2");
|
||||
await writeFile(join(worktree, "a.txt"), "a");
|
||||
const sha = await run(
|
||||
commit(nodeShell, { message: "add a", repositoryPath: worktree })
|
||||
);
|
||||
expect(sha).toMatch(/^[0-9a-f]{7,}/u);
|
||||
});
|
||||
|
||||
it("reads the current branch", async () => {
|
||||
const cloneDir = join(root, "work3");
|
||||
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
||||
expect(await run(currentBranch(nodeShell, cloneDir))).toBe("main");
|
||||
});
|
||||
|
||||
it("pushes a branch to origin", async () => {
|
||||
const cloneDir = join(root, "work4");
|
||||
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
||||
await run(
|
||||
nodeShell.exec(
|
||||
"git",
|
||||
["worktree", "add", "-b", "work/4", join(root, "wt-4")],
|
||||
{ cwd: cloneDir }
|
||||
)
|
||||
);
|
||||
const worktree = join(root, "wt-4");
|
||||
await writeFile(join(worktree, "b.txt"), "b");
|
||||
await run(
|
||||
commit(nodeShell, { message: "add b", repositoryPath: worktree })
|
||||
);
|
||||
await run(push(nodeShell, { refspec: "work/4", repositoryPath: worktree }));
|
||||
const branches = await execPlain(origin, "git branch --list");
|
||||
expect(branches).toContain("work/4");
|
||||
});
|
||||
|
||||
it("maps a non-repo path to NotARepository", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "empty-"));
|
||||
await expect(run(currentBranch(nodeShell, empty))).rejects.toMatchObject({
|
||||
reason: "NotARepository",
|
||||
});
|
||||
});
|
||||
});
|
||||
268
packages/primitives/src/git-local-runtime.ts
Normal file
268
packages/primitives/src/git-local-runtime.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect";
|
||||
|
||||
import { GitBranchName } from "./git.js";
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell abstraction: local git runs as subprocesses, but the same contract can
|
||||
// be backed by an AgentOS VM exec so worktree ops work identically inside a VM.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ShellExecOptions {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ShellExecResult {
|
||||
readonly exitCode: number;
|
||||
readonly stderr: string;
|
||||
readonly stdout: string;
|
||||
}
|
||||
|
||||
export interface Shell {
|
||||
readonly exec: (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options?: ShellExecOptions
|
||||
) => Effect.Effect<ShellExecResult, GitLocalError>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GitLocalErrorReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"CommandFailed",
|
||||
"Timeout",
|
||||
"NotARepository",
|
||||
]);
|
||||
export type GitLocalErrorReason = typeof GitLocalErrorReason.Type;
|
||||
|
||||
export class GitLocalError extends Schema.TaggedErrorClass<GitLocalError>()(
|
||||
"GitLocalError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: GitLocalErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inputs / outputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CloneInput {
|
||||
readonly into: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface AddWorktreeInput {
|
||||
readonly branch: string;
|
||||
readonly path: string;
|
||||
readonly repositoryPath: string;
|
||||
}
|
||||
|
||||
export interface CommitInput {
|
||||
readonly message: string;
|
||||
readonly repositoryPath: string;
|
||||
}
|
||||
|
||||
export interface PushInput {
|
||||
readonly refspec: string;
|
||||
readonly repositoryPath: string;
|
||||
}
|
||||
|
||||
export interface RemoteWithAuthInput {
|
||||
readonly host: string;
|
||||
readonly name?: string;
|
||||
readonly owner: string;
|
||||
readonly path: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly token: string;
|
||||
readonly user: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations: each is a pure function of (Shell, input) -> Effect.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const git = (
|
||||
shell: Shell,
|
||||
args: readonly string[],
|
||||
options?: ShellExecOptions
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
shell.exec("git", args, options).pipe(
|
||||
Effect.flatMap((res) =>
|
||||
res.exitCode === 0
|
||||
? Effect.succeed(res.stdout.trim())
|
||||
: Effect.fail(
|
||||
new GitLocalError({
|
||||
message: res.stderr.trim() || `git ${args.join(" ")} failed`,
|
||||
reason: /not a git repository|does not appear to be/iu.test(
|
||||
res.stderr
|
||||
)
|
||||
? "NotARepository"
|
||||
: "CommandFailed",
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
export const clone = (
|
||||
shell: Shell,
|
||||
input: CloneInput
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
git(shell, ["clone", input.url, input.into]).pipe(
|
||||
Effect.map(() => input.into)
|
||||
);
|
||||
|
||||
export const addWorktree = (
|
||||
shell: Shell,
|
||||
input: AddWorktreeInput
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
Effect.gen(function* run() {
|
||||
yield* Schema.decodeUnknownEffect(GitBranchName)(input.branch).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitLocalError({ message: cause.message, reason: "InvalidInput" })
|
||||
)
|
||||
);
|
||||
// -b creates the branch if it does not exist; -B would reset. Use -b.
|
||||
yield* git(shell, ["worktree", "add", "-b", input.branch, input.path], {
|
||||
cwd: input.repositoryPath,
|
||||
});
|
||||
return input.path;
|
||||
});
|
||||
|
||||
export const commit = (
|
||||
shell: Shell,
|
||||
input: CommitInput
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
Effect.gen(function* run() {
|
||||
yield* git(shell, ["add", "-A"], { cwd: input.repositoryPath });
|
||||
yield* git(shell, ["commit", "-m", input.message], {
|
||||
cwd: input.repositoryPath,
|
||||
});
|
||||
return yield* git(shell, ["rev-parse", "HEAD"], {
|
||||
cwd: input.repositoryPath,
|
||||
});
|
||||
});
|
||||
|
||||
export const push = (
|
||||
shell: Shell,
|
||||
input: PushInput
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
git(shell, ["push", "-u", "origin", input.refspec], {
|
||||
cwd: input.repositoryPath,
|
||||
});
|
||||
|
||||
export const currentBranch = (
|
||||
shell: Shell,
|
||||
repositoryPath: string
|
||||
): Effect.Effect<string, GitLocalError> =>
|
||||
git(shell, ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: repositoryPath,
|
||||
});
|
||||
|
||||
export const setRemoteUrl = (
|
||||
shell: Shell,
|
||||
input: RemoteWithAuthInput
|
||||
): Effect.Effect<string, GitLocalError> => {
|
||||
// Token-in-URL auth for push from a sandboxed checkout. The remote URL is
|
||||
// scoped to this single repo and the credential never leaves the worktree.
|
||||
const url = `https://${input.user}:${input.token}@${input.host}/${input.owner}/${input.path}`;
|
||||
const name = input.name ?? "origin";
|
||||
return git(shell, ["remote", "set-url", name, url], {
|
||||
cwd: input.repositoryPath,
|
||||
}).pipe(Effect.map(() => url));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default host shell (subprocess). The same Shell contract is implemented by
|
||||
// the AgentOS VM adapter so worktree ops run identically inside a VM.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// execFile resolves only on success; non-zero exits throw with stderr in the
|
||||
// message. We normalize that into a ShellExecResult so callers map exit codes.
|
||||
const runExecFile = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options?: ShellExecOptions
|
||||
): Promise<ShellExecResult> => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileP(command, [...args], {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
timeout: options?.timeoutMs,
|
||||
});
|
||||
return { exitCode: 0, stderr, stdout };
|
||||
} catch (error) {
|
||||
const failed = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
exitCode: 1,
|
||||
stderr: `${failed.stderr ?? ""}${failed.message}`,
|
||||
stdout: failed.stdout ?? "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const nodeShell: Shell = {
|
||||
exec: (command, args, options) =>
|
||||
Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new GitLocalError({
|
||||
message: cause instanceof Error ? cause.message : "Spawn failed",
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () => runExecFile(command, args, options),
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GitLocalRuntimeShape {
|
||||
readonly shell: Shell;
|
||||
readonly clone: (input: CloneInput) => Effect.Effect<string, GitLocalError>;
|
||||
readonly addWorktree: (
|
||||
input: AddWorktreeInput
|
||||
) => Effect.Effect<string, GitLocalError>;
|
||||
readonly commit: (input: CommitInput) => Effect.Effect<string, GitLocalError>;
|
||||
readonly push: (input: PushInput) => Effect.Effect<string, GitLocalError>;
|
||||
readonly currentBranch: (
|
||||
repositoryPath: string
|
||||
) => Effect.Effect<string, GitLocalError>;
|
||||
readonly setRemoteUrl: (
|
||||
input: RemoteWithAuthInput
|
||||
) => Effect.Effect<string, GitLocalError>;
|
||||
}
|
||||
|
||||
export class GitLocalRuntime extends Context.Service<
|
||||
GitLocalRuntime,
|
||||
GitLocalRuntimeShape
|
||||
>()("@code/primitives/git-local-runtime/GitLocalRuntime") {}
|
||||
|
||||
export const makeGitLocalRuntimeLayer = (shell: Shell = nodeShell) =>
|
||||
Layer.succeed(
|
||||
GitLocalRuntime,
|
||||
GitLocalRuntime.of({
|
||||
addWorktree: (input) => addWorktree(shell, input),
|
||||
clone: (input) => clone(shell, input),
|
||||
commit: (input) => commit(shell, input),
|
||||
currentBranch: (repositoryPath) => currentBranch(shell, repositoryPath),
|
||||
push: (input) => push(shell, input),
|
||||
setRemoteUrl: (input) => setRemoteUrl(shell, input),
|
||||
shell,
|
||||
})
|
||||
);
|
||||
124
packages/primitives/src/git-remote-runtime.test.ts
Normal file
124
packages/primitives/src/git-remote-runtime.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBranch,
|
||||
createIssue,
|
||||
createPullRequest,
|
||||
getIssue,
|
||||
listIssues,
|
||||
} from "./git-remote-runtime";
|
||||
import type { GitRemoteConfig, GitRemoteTransport } from "./git-remote-runtime";
|
||||
|
||||
const config: GitRemoteConfig = {
|
||||
baseUrl: "https://git.example.com",
|
||||
owner: "puter",
|
||||
repo: "zopu-code",
|
||||
token: "secret",
|
||||
};
|
||||
|
||||
const stubTransport = (
|
||||
responder: (method: string, path: string, body: unknown) => unknown,
|
||||
status = 200
|
||||
): GitRemoteTransport => ({
|
||||
request: ({ body, method, url }) =>
|
||||
Effect.sync(() => {
|
||||
const path = url.replace(
|
||||
`${config.baseUrl}/api/v1/repos/puter/zopu-code`,
|
||||
""
|
||||
);
|
||||
const result = responder(method, path, body);
|
||||
return {
|
||||
json: () => Promise.resolve(result),
|
||||
status,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E>) => Effect.runPromise(eff);
|
||||
|
||||
describe("GitRemoteRuntime", () => {
|
||||
it("creates an issue and maps Gitea fields", async () => {
|
||||
const transport = stubTransport(() => ({
|
||||
body: "the body",
|
||||
html_url: "https://git.example.com/puter/zopu-code/issues/1",
|
||||
number: 1,
|
||||
state: "open",
|
||||
title: "Add status control",
|
||||
}));
|
||||
const issue = await run(
|
||||
createIssue(transport, config, {
|
||||
body: "the body",
|
||||
title: "Add status control",
|
||||
})
|
||||
);
|
||||
expect(issue.number).toBe(1);
|
||||
expect(issue.url).toContain("/issues/1");
|
||||
});
|
||||
|
||||
it("maps 401 to Unauthorized", async () => {
|
||||
const transport = stubTransport(() => ({}), 401);
|
||||
await expect(run(listIssues(transport, config))).rejects.toMatchObject({
|
||||
reason: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 404 to NotFound", async () => {
|
||||
const transport = stubTransport(() => ({}), 404);
|
||||
await expect(run(getIssue(transport, config, 7))).rejects.toMatchObject({
|
||||
reason: "NotFound",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a branch", async () => {
|
||||
const transport = stubTransport((_m, _p, body) => ({
|
||||
name: (body as { new_branch_name: string }).new_branch_name,
|
||||
}));
|
||||
const branch = await run(
|
||||
createBranch(transport, config, { name: "work/1/fix" })
|
||||
);
|
||||
expect(branch.name).toBe("work/1/fix");
|
||||
});
|
||||
|
||||
it("creates a pull request and reads nested ref fields", async () => {
|
||||
const transport = stubTransport(() => ({
|
||||
base: { ref: "main" },
|
||||
head: { ref: "work/1/fix" },
|
||||
html_url: "https://git.example.com/puter/zopu-code/pulls/2",
|
||||
number: 2,
|
||||
state: "open",
|
||||
title: "Fix thing",
|
||||
}));
|
||||
const pr = await run(
|
||||
createPullRequest(transport, config, {
|
||||
baseBranch: "main",
|
||||
body: "",
|
||||
branch: "work/1/fix",
|
||||
title: "Fix thing",
|
||||
})
|
||||
);
|
||||
expect(pr.number).toBe(2);
|
||||
expect(pr.branch).toBe("work/1/fix");
|
||||
expect(pr.baseBranch).toBe("main");
|
||||
});
|
||||
|
||||
it("maps merged PR state", async () => {
|
||||
const transport = stubTransport(() => ({
|
||||
base: { ref: "main" },
|
||||
head: { ref: "work/1/fix" },
|
||||
merged: true,
|
||||
number: 2,
|
||||
state: "closed",
|
||||
title: "x",
|
||||
}));
|
||||
const pr = await run(
|
||||
createPullRequest(transport, config, {
|
||||
baseBranch: "main",
|
||||
body: "",
|
||||
branch: "work/1/fix",
|
||||
title: "x",
|
||||
})
|
||||
);
|
||||
expect(pr.state).toBe("merged");
|
||||
});
|
||||
});
|
||||
367
packages/primitives/src/git-remote-runtime.ts
Normal file
367
packages/primitives/src/git-remote-runtime.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */
|
||||
import { Context, Effect, Layer, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GitRemoteConfig = Schema.Struct({
|
||||
baseUrl: Schema.String,
|
||||
owner: MeaningfulString,
|
||||
repo: MeaningfulString,
|
||||
token: MeaningfulString,
|
||||
});
|
||||
export type GitRemoteConfig = typeof GitRemoteConfig.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RemoteIssue = Schema.Struct({
|
||||
body: Schema.String,
|
||||
number: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
state: Schema.Literals(["open", "closed"]),
|
||||
title: MeaningfulString,
|
||||
url: Schema.String,
|
||||
});
|
||||
export type RemoteIssue = typeof RemoteIssue.Type;
|
||||
|
||||
export const RemoteBranch = Schema.Struct({
|
||||
name: MeaningfulString,
|
||||
});
|
||||
export type RemoteBranch = typeof RemoteBranch.Type;
|
||||
|
||||
export const RemotePullRequest = Schema.Struct({
|
||||
baseBranch: MeaningfulString,
|
||||
branch: MeaningfulString,
|
||||
number: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
state: Schema.Literals(["open", "closed", "merged"]),
|
||||
title: MeaningfulString,
|
||||
url: Schema.String,
|
||||
});
|
||||
export type RemotePullRequest = typeof RemotePullRequest.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GitRemoteErrorReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"Unauthorized",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"Unreachable",
|
||||
]);
|
||||
export type GitRemoteErrorReason = typeof GitRemoteErrorReason.Type;
|
||||
|
||||
export class GitRemoteError extends Schema.TaggedErrorClass<GitRemoteError>()(
|
||||
"GitRemoteError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: GitRemoteErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level transport: a tiny fetch wrapper that maps HTTP failures to the
|
||||
// tagged error. Kept as an injectable dependency so tests can stub the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GitRemoteTransport {
|
||||
readonly request: (input: {
|
||||
readonly body?: unknown;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly method: string;
|
||||
readonly url: string;
|
||||
}) => Effect.Effect<
|
||||
{ readonly status: number; readonly json: () => Promise<unknown> },
|
||||
GitRemoteError
|
||||
>;
|
||||
}
|
||||
|
||||
const authHeaders = (token: string): Record<string, string> => ({
|
||||
Authorization: `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
const endpoint = (config: GitRemoteConfig, path: string): string =>
|
||||
`${config.baseUrl.replace(/\/+$/u, "")}/api/v1/repos/${config.owner}/${config.repo}${path}`;
|
||||
|
||||
const fromStatus = (status: number, fallback: string): GitRemoteError => {
|
||||
if (status === 401 || status === 403) {
|
||||
return new GitRemoteError({
|
||||
message: "Unauthorized",
|
||||
reason: "Unauthorized",
|
||||
});
|
||||
}
|
||||
if (status === 404) {
|
||||
return new GitRemoteError({ message: "Not found", reason: "NotFound" });
|
||||
}
|
||||
if (status === 409) {
|
||||
return new GitRemoteError({ message: "Conflict", reason: "Conflict" });
|
||||
}
|
||||
if (status >= 500) {
|
||||
return new GitRemoteError({ message: fallback, reason: "Unreachable" });
|
||||
}
|
||||
return new GitRemoteError({ message: fallback, reason: "InvalidInput" });
|
||||
};
|
||||
|
||||
const request = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Effect.Effect<unknown, GitRemoteError> =>
|
||||
transport
|
||||
.request({
|
||||
body,
|
||||
headers: authHeaders(config.token),
|
||||
method,
|
||||
url: endpoint(config, path),
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((res) =>
|
||||
Effect.gen(function* handleResponse() {
|
||||
if (res.status >= 400) {
|
||||
const detail = yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => res.json(),
|
||||
}).pipe(Effect.orElseSucceed(() => null));
|
||||
const message =
|
||||
detail && typeof detail === "object" && "message" in detail
|
||||
? String((detail as { message: unknown }).message)
|
||||
: `Request ${method} ${path} failed with ${res.status}`;
|
||||
return yield* Effect.fail(fromStatus(res.status, message));
|
||||
}
|
||||
return yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new GitRemoteError({
|
||||
message: cause instanceof Error ? cause.message : "Bad JSON",
|
||||
reason: "InvalidInput",
|
||||
}),
|
||||
try: () => res.json(),
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateIssueInput {
|
||||
readonly body: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export const createIssue = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig,
|
||||
input: CreateIssueInput
|
||||
): Effect.Effect<RemoteIssue, GitRemoteError> =>
|
||||
Effect.gen(function* run() {
|
||||
const raw = yield* request(transport, config, "POST", "/issues", {
|
||||
body: input.body,
|
||||
title: input.title,
|
||||
});
|
||||
return yield* Schema.decodeUnknownEffect(RemoteIssue)({
|
||||
body: (raw as { body?: string }).body ?? "",
|
||||
number: (raw as { number: number }).number,
|
||||
state: (raw as { state: string }).state ?? "open",
|
||||
title: (raw as { title: string }).title,
|
||||
url: (raw as { html_url?: string }).html_url ?? "",
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitRemoteError({ message: cause.message, reason: "InvalidInput" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export interface CreateBranchInput {
|
||||
readonly from?: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export const createBranch = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig,
|
||||
input: CreateBranchInput
|
||||
): Effect.Effect<RemoteBranch, GitRemoteError> =>
|
||||
Effect.gen(function* run() {
|
||||
const raw = yield* request(transport, config, "POST", "/branches", {
|
||||
new_branch_name: input.name,
|
||||
old_ref_name: input.from ?? "main",
|
||||
});
|
||||
return yield* Schema.decodeUnknownEffect(RemoteBranch)({
|
||||
name: (raw as { name: string }).name,
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitRemoteError({ message: cause.message, reason: "InvalidInput" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export interface CreatePullRequestInput {
|
||||
readonly baseBranch: string;
|
||||
readonly body: string;
|
||||
readonly branch: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export const createPullRequest = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig,
|
||||
input: CreatePullRequestInput
|
||||
): Effect.Effect<RemotePullRequest, GitRemoteError> =>
|
||||
Effect.gen(function* run() {
|
||||
const raw = yield* request(transport, config, "POST", "/pulls", {
|
||||
base: input.baseBranch,
|
||||
body: input.body,
|
||||
head: input.branch,
|
||||
title: input.title,
|
||||
});
|
||||
const merged = Boolean((raw as { merged?: boolean }).merged);
|
||||
return yield* Schema.decodeUnknownEffect(RemotePullRequest)({
|
||||
baseBranch:
|
||||
(raw as { base?: { ref?: string } }).base?.ref ?? input.baseBranch,
|
||||
branch: (raw as { head?: { ref?: string } }).head?.ref ?? input.branch,
|
||||
number: (raw as { number: number }).number,
|
||||
state: merged ? "merged" : ((raw as { state?: string }).state ?? "open"),
|
||||
title: (raw as { title: string }).title,
|
||||
url: (raw as { html_url?: string }).html_url ?? "",
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitRemoteError({ message: cause.message, reason: "InvalidInput" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export const listIssues = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig
|
||||
): Effect.Effect<readonly RemoteIssue[], GitRemoteError> =>
|
||||
Effect.gen(function* run() {
|
||||
const raw = yield* request(
|
||||
transport,
|
||||
config,
|
||||
"GET",
|
||||
"/issues?state=open&type=issues"
|
||||
);
|
||||
const list = (raw as Record<string, unknown>[]) ?? [];
|
||||
const decoded = list.map((entry) =>
|
||||
Schema.decodeUnknownEffect(RemoteIssue)({
|
||||
body: (entry.body as string) ?? "",
|
||||
number: entry.number as number,
|
||||
state: (entry.state as string) ?? "open",
|
||||
title: entry.title as string,
|
||||
url: (entry.html_url as string) ?? "",
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitRemoteError({
|
||||
message: cause.message,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return yield* Effect.all(decoded);
|
||||
});
|
||||
|
||||
export const getIssue = (
|
||||
transport: GitRemoteTransport,
|
||||
config: GitRemoteConfig,
|
||||
number: number
|
||||
): Effect.Effect<RemoteIssue, GitRemoteError> =>
|
||||
Effect.gen(function* run() {
|
||||
const raw = yield* request(transport, config, "GET", `/issues/${number}`);
|
||||
return yield* Schema.decodeUnknownEffect(RemoteIssue)({
|
||||
body: (raw as { body?: string }).body ?? "",
|
||||
number: (raw as { number: number }).number,
|
||||
state: (raw as { state: string }).state ?? "open",
|
||||
title: (raw as { title: string }).title,
|
||||
url: (raw as { html_url?: string }).html_url ?? "",
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new GitRemoteError({ message: cause.message, reason: "InvalidInput" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch-backed transport (default) + service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const fetchTransport: GitRemoteTransport = {
|
||||
request: ({ body, headers, method, url }) =>
|
||||
Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new GitRemoteError({
|
||||
message: cause instanceof Error ? cause.message : "Network error",
|
||||
reason: "Unreachable",
|
||||
}),
|
||||
try: async () => {
|
||||
const init: RequestInit = { headers: headers ?? {}, method };
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return {
|
||||
json: () => res.json() as Promise<unknown>,
|
||||
status: res.status,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface GitRemoteRuntimeShape {
|
||||
readonly config: GitRemoteConfig;
|
||||
readonly transport: GitRemoteTransport;
|
||||
readonly createIssue: (
|
||||
input: CreateIssueInput
|
||||
) => Effect.Effect<RemoteIssue, GitRemoteError>;
|
||||
readonly createBranch: (
|
||||
input: CreateBranchInput
|
||||
) => Effect.Effect<RemoteBranch, GitRemoteError>;
|
||||
readonly createPullRequest: (
|
||||
input: CreatePullRequestInput
|
||||
) => Effect.Effect<RemotePullRequest, GitRemoteError>;
|
||||
readonly listIssues: () => Effect.Effect<
|
||||
readonly RemoteIssue[],
|
||||
GitRemoteError
|
||||
>;
|
||||
readonly getIssue: (
|
||||
number: number
|
||||
) => Effect.Effect<RemoteIssue, GitRemoteError>;
|
||||
}
|
||||
|
||||
export class GitRemoteRuntime extends Context.Service<
|
||||
GitRemoteRuntime,
|
||||
GitRemoteRuntimeShape
|
||||
>()("@code/primitives/git-remote-runtime/GitRemoteRuntime") {}
|
||||
|
||||
export const makeGitRemoteRuntimeLayer = (config: GitRemoteConfig) =>
|
||||
Layer.succeed(
|
||||
GitRemoteRuntime,
|
||||
GitRemoteRuntime.of({
|
||||
config,
|
||||
createBranch: (input) => createBranch(fetchTransport, config, input),
|
||||
createIssue: (input) => createIssue(fetchTransport, config, input),
|
||||
createPullRequest: (input) =>
|
||||
createPullRequest(fetchTransport, config, input),
|
||||
getIssue: (number) => getIssue(fetchTransport, config, number),
|
||||
listIssues: () => listIssues(fetchTransport, config),
|
||||
transport: fetchTransport,
|
||||
})
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./git";
|
||||
export * from "./git-local-runtime";
|
||||
export * from "./git-remote-runtime";
|
||||
export * from "./project";
|
||||
export * from "./project-issue";
|
||||
export * from "./project-workspace";
|
||||
|
||||
Reference in New Issue
Block a user