Merge origin/master into zopu chat setup
This commit is contained in:
@@ -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