diff --git a/docs/agentgit.md b/docs/agentgit.md new file mode 100644 index 0000000..c04dfe6 --- /dev/null +++ b/docs/agentgit.md @@ -0,0 +1,136 @@ +# AgentGit + +AgentGit is the LogicSRC agent-native source collaboration contract: a thin, +machine-first layer over plain git transport. It is **not** a new git host. +Storage and transport are delegated to a backend forge (Forgejo by default, +with GitHub and bare git/ssh adapters), while AgentGit defines the +agent-facing contracts for repositories, pull requests, reviews, and +policy-gated merges. + +The reference deployment runs at `git.profullstack.com`, a self-hosted Forgejo +instance whose access is gated to CommandBoard.run / BBS members. Membership is +proven with a LogicSRC DID (via the `coinpay` identity plugin), not a forge +username — agents and humans authenticate the same way. + +## Why + +GitHub and other forges are human-first: web-UI reviews, human usernames, and +"a person clicks approve" merge gates. Agents are bolted on. AgentGit inverts +that: + +- **Identity is a DID.** Reuse `coinpay` `did.auth`; no separate forge accounts. +- **Access is membership.** Only BBS members can read/write; gated at the layer, + not by hand-managed forge ACLs. +- **PRs and reviews are machine-readable** contracts (`logicsrc.pull_request`), + consumable over CLI / TUI / MCP / curl-API — the same surfaces as the rest of + LogicSRC. +- **Merge is policy, not a click.** A PR merges when its repo `merge_policy` is + satisfied (passing checks, reviewer reputation, optional escrow), and may be + merged by an agent when `allow_agent_merge` is set. +- **Work maps to git.** A `logicsrc.task` links to a branch and a PR; task + approval can release escrow and emit a reputation event. + +## Architecture + +```txt +agents / humans (DID) + │ CLI · TUI · MCP · curl-API + ▼ + AgentGit plugin ──► merge-policy engine ──► reputation / escrow (coinpay) + │ backend adapter + ▼ + Forgejo @ git.profullstack.com (or GitHub / bare git+ssh) +``` + +The plugin owns the contract, membership gate, and policy engine. The backend +adapter is the only forge-specific code; swapping Forgejo for GitHub is an +adapter change, not a contract change. + +## Capabilities + +```txt +repo.create +repo.list +repo.get +repo.archive +branch.create +branch.list +pr.open +pr.list +pr.get +pr.review +pr.merge +pr.close +merge.evaluate +access.gate +webhook.push +webhook.pr_status +reputation.merge_event +audit.log +``` + +## Membership gate + +Every route runs through `access.gate`: the caller's DID is checked against the +repo `members` list (and the BBS membership roster) before any backend call. +Non-members get nothing — there is no anonymous read at `git.profullstack.com`. + +## Account provisioning (AgentBBS integration) + +Every AgentBBS member gets a `git.profullstack.com` account automatically — +**free and paid users alike**. There is no separate git signup; BBS membership +*is* the git account. Two hook points, both in the `agentbbs` repo: + +1. **Host provisioning — `agentbbs/setup.sh`.** When agentgit is ready, the + droplet provisioner also stands up the Forgejo backend reachable at + `git.profullstack.com` (its own service + Caddy vhost, alongside the existing + `bbs.profullstack.com` front end). Idempotent, like the rest of `setup.sh`. + +2. **Per-user provisioning — the AgentBBS signup/verify flow (Go).** When a + `user@bbs.profullstack.com` account is created/verified, agentgit creates the + matching Forgejo account, keyed to the member's DID (reuse the `join@` email + verification that already gates BBS signup). Free vs. paid affects quotas/ + limits, **not** whether the account exists. Re-running is a no-op if the + account already exists. + +Plan/quota differences (private repo count, CI minutes, storage) are enforced by +`merge_policy` and backend limits, never by withholding the account itself. + +## Merge policy + +Repos carry a `merge_policy` (see `logicsrc-repo.schema.json`): + +- `min_reviews` — required approving reviews. +- `require_passing_checks` — all `checks` must be `passing`. +- `reviewer_reputation_min` — minimum reviewer reputation for an approval to + count. +- `escrow_required` — a funded escrow must exist for the linked task. +- `allow_agent_merge` — whether an agent DID may perform the merge. +- `allowed_merge_methods` — `merge` | `squash` | `rebase`. + +`merge.evaluate` returns whether a PR satisfies its policy and why; `pr.merge` +refuses unless it does. + +## Schemas + +- `packages/schemas/schemas/logicsrc-repo.schema.json` +- `packages/schemas/schemas/logicsrc-pull-request.schema.json` + +## Status + +**M1 landed** (code, not yet deployed). Implemented in `plugins/agentgit/src/`: + +- `forge/adapter.ts` — the `ForgeAdapter` contract (the only forge-specific + surface). +- `forge/forgejo.ts` — `ForgejoAdapter` against the Forgejo/Gitea REST v1 API + (injectable `fetch`, typed errors). +- `access.ts` — `gateAccess`, the DID membership gate. +- `merge-policy.ts` — `evaluateMergePolicy`, the pure policy engine. +- `service.ts` — `AgentGitService`, ties gate + policy to the adapter + (incl. `provisionMember` for the AgentBBS hook). +- `repo` / `pull-request` schemas registered in `@logicsrc/validators` with + fixtures; 27 unit tests pass. + +Next (M2): deploy Forgejo at `git.profullstack.com`, wire `provisionMember` into +the AgentBBS signup/verify flow and `setup.sh`, and resolve forge logins ↔ DIDs +and reviewer reputation against live CoinPay/CommandBoard data. diff --git a/docs/plugins.md b/docs/plugins.md index a13744b..7f9f332 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -14,6 +14,7 @@ Default plugins: Coming soon plugin specs: - AgentByte: candidate, contractor, and agent capability screening for AI-era workflows. See `docs/agent-screening.md`. +- AgentGit: agent-native source collaboration — a DID-gated layer over a Forgejo/git backend (reference: `git.profullstack.com`, BBS members only) with policy-gated merges. See `docs/agentgit.md`. - Credential Sharing: replacement architecture for .env, Doppler, Railway variables, GitHub Secrets, and future providers. See `docs/credential-sharing.md`. - Communication Accounts: shared social and email account management contracts. See `docs/communication-accounts.md`. diff --git a/package-lock.json b/package-lock.json index e81ccff..87ef36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1446,6 +1446,10 @@ "resolved": "apps/commandboard-web", "link": true }, + "node_modules/@logicsrc/plugin-agentgit": { + "resolved": "plugins/agentgit", + "link": true + }, "node_modules/@logicsrc/plugin-c0mpute": { "resolved": "plugins/c0mpute", "link": true @@ -6127,7 +6131,8 @@ }, "packages/schemas": { "name": "@logicsrc/schemas", - "version": "0.1.0" + "version": "0.1.0", + "license": "MIT" }, "packages/sdk": { "name": "@logicsrc/sdk", @@ -6162,6 +6167,16 @@ "vitest": "^4.0.8" } }, + "plugins/agentgit": { + "name": "@logicsrc/plugin-agentgit", + "version": "0.1.0", + "dependencies": { + "@logicsrc/plugin-core": "file:../../packages/plugin-core" + }, + "devDependencies": { + "vitest": "^4.0.8" + } + }, "plugins/c0mpute": { "name": "@logicsrc/plugin-c0mpute", "version": "0.1.0", diff --git a/package.json b/package.json index 63d7572..8d7dd52 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/packages/schemas/fixtures/pull-request.yaml b/packages/schemas/fixtures/pull-request.yaml new file mode 100644 index 0000000..7741d92 --- /dev/null +++ b/packages/schemas/fixtures/pull-request.yaml @@ -0,0 +1,24 @@ +type: logicsrc.pull_request +version: "1.0" +repo: agentgit-demo +number: 7 +title: Add Forgejo backend adapter +description: Implements the M1 ForgeAdapter against the Forgejo REST API. +author_did: contributor.profullstack +source_branch: feat/forgejo-adapter +target_branch: main +head_sha: abc1234 +task: task-42 +status: approved +checks: + - name: ci + status: passing + url: https://git.profullstack.com/owner/agentgit-demo/actions +reviews: + - reviewer_did: maintainer.profullstack + decision: approve + reputation: 25 + summary: Looks good, policy-gated merge verified. + created_at: 2026-06-14T01:00:00Z +created_at: 2026-06-14T00:30:00Z +updated_at: 2026-06-14T01:05:00Z diff --git a/packages/schemas/fixtures/repo.yaml b/packages/schemas/fixtures/repo.yaml new file mode 100644 index 0000000..27152e4 --- /dev/null +++ b/packages/schemas/fixtures/repo.yaml @@ -0,0 +1,27 @@ +type: logicsrc.repo +version: "1.0" +name: AgentGit Demo +slug: agentgit-demo +description: Reference AgentGit repository hosted on git.profullstack.com. +owner_did: owner.profullstack +board: /agentgit +visibility: members_only +default_branch: main +backend: + provider: forgejo + url: https://git.profullstack.com + external_id: owner/agentgit-demo +members: + - did: maintainer.profullstack + role: maintainer + - did: contributor.profullstack + role: contributor +merge_policy: + min_reviews: 1 + require_passing_checks: true + reviewer_reputation_min: 0 + escrow_required: false + allow_agent_merge: true + allowed_merge_methods: + - squash +created_at: 2026-06-14T00:00:00Z diff --git a/packages/schemas/schemas/logicsrc-pull-request.schema.json b/packages/schemas/schemas/logicsrc-pull-request.schema.json new file mode 100644 index 0000000..ebd64da --- /dev/null +++ b/packages/schemas/schemas/logicsrc-pull-request.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-pull-request.schema.json", + "title": "LogicSRC Pull Request", + "type": "object", + "required": ["type", "version", "repo", "title", "author_did", "source_branch", "target_branch", "status"], + "additionalProperties": false, + "properties": { + "type": { "const": "logicsrc.pull_request" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+(\\.\\d+)?$" }, + "repo": { "type": "string", "pattern": "^[a-z0-9][a-z0-9/_-]*$" }, + "number": { "type": "integer", "minimum": 1 }, + "title": { "type": "string", "minLength": 1, "maxLength": 160 }, + "description": { "type": "string" }, + "author_did": { "$ref": "#/$defs/did" }, + "source_branch": { "type": "string", "minLength": 1 }, + "target_branch": { "type": "string", "minLength": 1 }, + "head_sha": { "type": "string", "pattern": "^[0-9a-f]{7,64}$" }, + "task": { "type": "string", "description": "Linked logicsrc.task id." }, + "status": { + "type": "string", + "enum": ["draft", "open", "approved", "changes_requested", "merged", "closed"] + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "status"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "enum": ["pending", "passing", "failing"] }, + "url": { "type": "string", "format": "uri" } + } + } + }, + "reviews": { + "type": "array", + "items": { + "type": "object", + "required": ["reviewer_did", "decision"], + "additionalProperties": false, + "properties": { + "reviewer_did": { "$ref": "#/$defs/did" }, + "decision": { "type": "string", "enum": ["approve", "request_changes", "comment"] }, + "reputation": { "type": "number", "minimum": 0 }, + "summary": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + } + } + }, + "merge": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { "type": "string", "enum": ["merge", "squash", "rebase"] }, + "merged_by_did": { "$ref": "#/$defs/did" }, + "merge_sha": { "type": "string", "pattern": "^[0-9a-f]{7,64}$" }, + "policy_satisfied": { "type": "boolean" }, + "merged_at": { "type": "string", "format": "date-time" } + } + }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "logicsrc_version": { "type": "string" } + }, + "$defs": { + "did": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*\\.[a-z0-9][a-z0-9._-]*$" + } + } +} diff --git a/packages/schemas/schemas/logicsrc-repo.schema.json b/packages/schemas/schemas/logicsrc-repo.schema.json new file mode 100644 index 0000000..4d91dc9 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-repo.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-repo.schema.json", + "title": "LogicSRC Repo", + "type": "object", + "required": ["type", "version", "name", "slug", "owner_did", "backend", "visibility"], + "additionalProperties": false, + "properties": { + "type": { "const": "logicsrc.repo" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+(\\.\\d+)?$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 160 }, + "slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9/_-]*$" }, + "description": { "type": "string" }, + "owner_did": { "$ref": "#/$defs/did" }, + "board": { "type": "string", "pattern": "^/[a-z0-9][a-z0-9/_-]*$" }, + "visibility": { + "type": "string", + "enum": ["members_only", "private", "public"], + "default": "members_only" + }, + "default_branch": { "type": "string", "minLength": 1, "default": "main" }, + "backend": { + "type": "object", + "required": ["provider", "url"], + "additionalProperties": false, + "properties": { + "provider": { "type": "string", "enum": ["forgejo", "github", "git"] }, + "url": { "type": "string", "format": "uri" }, + "external_id": { "type": "string" } + } + }, + "members": { + "type": "array", + "items": { + "type": "object", + "required": ["did", "role"], + "additionalProperties": false, + "properties": { + "did": { "$ref": "#/$defs/did" }, + "role": { + "type": "string", + "enum": ["maintainer", "contributor", "reviewer", "reader"] + } + } + } + }, + "merge_policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "min_reviews": { "type": "integer", "minimum": 0, "default": 1 }, + "require_passing_checks": { "type": "boolean", "default": true }, + "reviewer_reputation_min": { "type": "number", "minimum": 0, "default": 0 }, + "escrow_required": { "type": "boolean", "default": false }, + "allow_agent_merge": { "type": "boolean", "default": true }, + "allowed_merge_methods": { + "type": "array", + "items": { "type": "string", "enum": ["merge", "squash", "rebase"] }, + "uniqueItems": true, + "default": ["squash"] + } + } + }, + "created_at": { "type": "string", "format": "date-time" }, + "logicsrc_version": { "type": "string" } + }, + "$defs": { + "did": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*\\.[a-z0-9][a-z0-9._-]*$" + } + } +} diff --git a/packages/validators/package.json b/packages/validators/package.json index 6d5577b..d847b5c 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run src", - "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml" + "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml" }, "dependencies": { "ajv": "^8.17.1", diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index d0a50e9..748dc98 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -6,6 +6,8 @@ import connectedAccountSchema from "../../schemas/schemas/logicsrc-connected-acc import emailMessageSchema from "../../schemas/schemas/logicsrc-email-message.schema.json" with { type: "json" }; import eventSchema from "../../schemas/schemas/logicsrc-event.schema.json" with { type: "json" }; import pluginSchema from "../../schemas/schemas/logicsrc-plugin.schema.json" with { type: "json" }; +import pullRequestSchema from "../../schemas/schemas/logicsrc-pull-request.schema.json" with { type: "json" }; +import repoSchema from "../../schemas/schemas/logicsrc-repo.schema.json" with { type: "json" }; import runSchema from "../../schemas/schemas/logicsrc-run.schema.json" with { type: "json" }; import socialPostSchema from "../../schemas/schemas/logicsrc-social-post.schema.json" with { type: "json" }; import taskSchema from "../../schemas/schemas/logicsrc-task.schema.json" with { type: "json" }; @@ -26,6 +28,8 @@ export const schemas = { "email-message": emailMessageSchema, event: eventSchema, plugin: pluginSchema, + "pull-request": pullRequestSchema, + repo: repoSchema, run: runSchema, "social-post": socialPostSchema, task: taskSchema, diff --git a/plugins/agentgit/README.md b/plugins/agentgit/README.md new file mode 100644 index 0000000..d773d5b --- /dev/null +++ b/plugins/agentgit/README.md @@ -0,0 +1,28 @@ +# AgentGit Plugin + +AgentGit is the agent-native source collaboration plugin for CommandBoard.run / +LogicSRC. It is a thin, DID-gated layer over a git backend (Forgejo by default, +GitHub or bare git/ssh via adapters) — **not** a new git host. + +The reference deployment is `git.profullstack.com`, a self-hosted Forgejo +instance gated to BBS members. Callers authenticate with a LogicSRC DID +(via `coinpay`), repos default to `members_only`, and pull requests merge by +policy (`merge_policy`) rather than a human click. + +See `docs/agentgit.md` for the contract, architecture, and merge-policy rules. + +Schemas: + +```txt +packages/schemas/schemas/logicsrc-repo.schema.json +packages/schemas/schemas/logicsrc-pull-request.schema.json +``` + +Required environment: + +```txt +AGENTGIT_API_URL +AGENTGIT_FORGE_URL +AGENTGIT_API_KEY +AGENTGIT_WEBHOOK_SECRET +``` diff --git a/plugins/agentgit/package.json b/plugins/agentgit/package.json new file mode 100644 index 0000000..6546492 --- /dev/null +++ b/plugins/agentgit/package.json @@ -0,0 +1,18 @@ +{ + "name": "@logicsrc/plugin-agentgit", + "version": "0.1.0", + "description": "Agent-native source collaboration: a thin DID-gated layer over a Forgejo/git backend for BBS members.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src --passWithNoTests" + }, + "dependencies": { + "@logicsrc/plugin-core": "file:../../packages/plugin-core" + }, + "devDependencies": { + "vitest": "^4.0.8" + } +} diff --git a/plugins/agentgit/src/access.test.ts b/plugins/agentgit/src/access.test.ts new file mode 100644 index 0000000..3019f15 --- /dev/null +++ b/plugins/agentgit/src/access.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MERGE_POLICY, type Repo } from "./domain.js"; +import { gateAccess } from "./access.js"; + +function repo(overrides: Partial = {}): Repo { + return { + type: "logicsrc.repo", + version: "1.0", + name: "Demo", + slug: "demo", + owner_did: "owner.example", + visibility: "members_only", + default_branch: "main", + backend: { provider: "forgejo", url: "https://git.profullstack.com" }, + members: [ + { did: "reader.example", role: "reader" }, + { did: "contributor.example", role: "contributor" }, + { did: "maintainer.example", role: "maintainer" } + ], + merge_policy: DEFAULT_MERGE_POLICY, + ...overrides + }; +} + +describe("gateAccess", () => { + it("treats the owner as an implicit maintainer", () => { + expect(gateAccess(repo(), "owner.example", "merge")).toEqual({ allowed: true, role: "maintainer" }); + }); + + it("denies anonymous access to members_only repos", () => { + const result = gateAccess(repo(), undefined, "read"); + expect(result.allowed).toBe(false); + }); + + it("allows anonymous read on public repos only", () => { + expect(gateAccess(repo({ visibility: "public" }), undefined, "read").allowed).toBe(true); + expect(gateAccess(repo({ visibility: "public" }), undefined, "write").allowed).toBe(false); + }); + + it("denies non-members", () => { + const result = gateAccess(repo(), "stranger.example", "read"); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/not a member/); + }); + + it("enforces role capabilities", () => { + expect(gateAccess(repo(), "reader.example", "write").allowed).toBe(false); + expect(gateAccess(repo(), "contributor.example", "write").allowed).toBe(true); + expect(gateAccess(repo(), "contributor.example", "merge").allowed).toBe(false); + expect(gateAccess(repo(), "maintainer.example", "merge").allowed).toBe(true); + }); +}); diff --git a/plugins/agentgit/src/access.ts b/plugins/agentgit/src/access.ts new file mode 100644 index 0000000..1b12075 --- /dev/null +++ b/plugins/agentgit/src/access.ts @@ -0,0 +1,51 @@ +import type { MemberRole, Repo } from "./domain.js"; + +export type Action = "read" | "write" | "review" | "merge" | "admin"; + +export interface GateResult { + allowed: boolean; + role?: MemberRole; + reason?: string; +} + +const ROLE_ACTIONS: Record = { + reader: ["read"], + reviewer: ["read", "review"], + contributor: ["read", "review", "write"], + maintainer: ["read", "review", "write", "merge", "admin"] +}; + +function rolePermits(role: MemberRole, action: Action): boolean { + return ROLE_ACTIONS[role].includes(action); +} + +/** + * The membership gate. Every AgentGit operation runs through this before any + * backend call. The owner is an implicit maintainer; public repos allow `read` + * to anyone; everything else requires a matching member role. There is no + * anonymous access to members_only/private repos. + */ +export function gateAccess(repo: Repo, callerDid: string | undefined, action: Action): GateResult { + if (callerDid && callerDid === repo.owner_did) { + return { allowed: true, role: "maintainer" }; + } + + if (action === "read" && repo.visibility === "public") { + return { allowed: true, role: "reader" }; + } + + if (!callerDid) { + return { allowed: false, reason: "authentication required (DID)" }; + } + + const member = repo.members.find((entry) => entry.did === callerDid); + if (!member) { + return { allowed: false, reason: `${callerDid} is not a member of ${repo.slug}` }; + } + + if (!rolePermits(member.role, action)) { + return { allowed: false, role: member.role, reason: `role "${member.role}" cannot ${action}` }; + } + + return { allowed: true, role: member.role }; +} diff --git a/plugins/agentgit/src/domain.ts b/plugins/agentgit/src/domain.ts new file mode 100644 index 0000000..8438593 --- /dev/null +++ b/plugins/agentgit/src/domain.ts @@ -0,0 +1,98 @@ +// Domain types for AgentGit, mirroring the LogicSRC repo and pull-request +// schemas (packages/schemas/schemas/logicsrc-{repo,pull-request}.schema.json). + +export type MemberRole = "maintainer" | "contributor" | "reviewer" | "reader"; +export type MergeMethod = "merge" | "squash" | "rebase"; +export type Visibility = "members_only" | "private" | "public"; +export type ReviewDecision = "approve" | "request_changes" | "comment"; +export type CheckStatus = "pending" | "passing" | "failing"; +export type PullRequestStatus = + | "draft" + | "open" + | "approved" + | "changes_requested" + | "merged" + | "closed"; + +export interface MergePolicy { + min_reviews: number; + require_passing_checks: boolean; + reviewer_reputation_min: number; + escrow_required: boolean; + allow_agent_merge: boolean; + allowed_merge_methods: MergeMethod[]; +} + +export const DEFAULT_MERGE_POLICY: MergePolicy = { + min_reviews: 1, + require_passing_checks: true, + reviewer_reputation_min: 0, + escrow_required: false, + allow_agent_merge: true, + allowed_merge_methods: ["squash"] +}; + +export interface RepoMember { + did: string; + role: MemberRole; +} + +export interface Repo { + type: "logicsrc.repo"; + version: string; + name: string; + slug: string; + description?: string; + owner_did: string; + board?: string; + visibility: Visibility; + default_branch: string; + backend: { + provider: "forgejo" | "github" | "git"; + url: string; + external_id?: string; + }; + members: RepoMember[]; + merge_policy: MergePolicy; + created_at?: string; +} + +export interface Review { + reviewer_did: string; + decision: ReviewDecision; + reputation?: number; + summary?: string; + created_at?: string; +} + +export interface Check { + name: string; + status: CheckStatus; + url?: string; +} + +export interface PullRequest { + type: "logicsrc.pull_request"; + version: string; + repo: string; + number?: number; + title: string; + description?: string; + author_did: string; + source_branch: string; + target_branch: string; + head_sha?: string; + task?: string; + status: PullRequestStatus; + checks: Check[]; + reviews: Review[]; + merge?: { + method: MergeMethod; + merged_by_did: string; + merge_sha?: string; + policy_satisfied: boolean; + merged_at?: string; + }; + created_at?: string; + updated_at?: string; +} diff --git a/plugins/agentgit/src/forge/adapter.ts b/plugins/agentgit/src/forge/adapter.ts new file mode 100644 index 0000000..b660414 --- /dev/null +++ b/plugins/agentgit/src/forge/adapter.ts @@ -0,0 +1,128 @@ +import type { CheckStatus, MergeMethod, ReviewDecision } from "../domain.js"; + +// Forge-shaped types: what a backend forge actually returns, before mapping to +// LogicSRC domain objects (reviewers are forge logins, not DIDs). The service +// layer resolves logins -> DIDs and reputation. + +export interface ForgeRepo { + owner: string; + name: string; + defaultBranch: string; + cloneUrl: string; + htmlUrl: string; + private: boolean; + archived: boolean; +} + +export interface ForgeBranch { + name: string; + commitSha: string; +} + +export interface ForgeReview { + reviewerLogin: string; + decision: ReviewDecision; + body?: string; + submittedAt?: string; +} + +export interface ForgeCheck { + name: string; + status: CheckStatus; + url?: string; +} + +export interface ForgePullRequest { + number: number; + title: string; + body?: string; + authorLogin: string; + sourceBranch: string; + targetBranch: string; + headSha: string; + state: "open" | "closed"; + merged: boolean; + reviews: ForgeReview[]; + checks: ForgeCheck[]; + createdAt?: string; + updatedAt?: string; +} + +export interface CreateRepoInput { + owner: string; + name: string; + description?: string; + private: boolean; + defaultBranch?: string; +} + +export interface CreateBranchInput { + owner: string; + repo: string; + newBranch: string; + fromBranch: string; +} + +export interface OpenPullRequestInput { + owner: string; + repo: string; + title: string; + body?: string; + sourceBranch: string; + targetBranch: string; +} + +export interface AddReviewInput { + owner: string; + repo: string; + number: number; + decision: ReviewDecision; + body?: string; +} + +export interface MergePullRequestInput { + owner: string; + repo: string; + number: number; + method: MergeMethod; +} + +export interface MergeResult { + merged: boolean; + mergeSha?: string; +} + +export interface EnsureUserInput { + username: string; + email: string; + /** Initial password; accounts are provisioned with must-change-password. */ + password: string; + fullName?: string; +} + +export interface ForgeUser { + username: string; + email: string; + created: boolean; +} + +/** + * Backend-forge contract. The only forge-specific surface in AgentGit — + * swapping Forgejo for GitHub or bare git is an implementation of this + * interface, not a change to the AgentGit contract. + */ +export interface ForgeAdapter { + ensureUser(input: EnsureUserInput): Promise; + createRepo(input: CreateRepoInput): Promise; + getRepo(owner: string, repo: string): Promise; + listRepos(owner: string): Promise; + archiveRepo(owner: string, repo: string): Promise; + createBranch(input: CreateBranchInput): Promise; + listBranches(owner: string, repo: string): Promise; + openPullRequest(input: OpenPullRequestInput): Promise; + getPullRequest(owner: string, repo: string, number: number): Promise; + listPullRequests(owner: string, repo: string, state?: "open" | "closed" | "all"): Promise; + addReview(input: AddReviewInput): Promise; + mergePullRequest(input: MergePullRequestInput): Promise; + closePullRequest(owner: string, repo: string, number: number): Promise; +} diff --git a/plugins/agentgit/src/forge/forgejo.test.ts b/plugins/agentgit/src/forge/forgejo.test.ts new file mode 100644 index 0000000..caddd30 --- /dev/null +++ b/plugins/agentgit/src/forge/forgejo.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { ForgejoAdapter, type FetchLike } from "./forgejo.js"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +function makeFetch(routes: Record): { fetch: FetchLike; calls: Call[] } { + const calls: Call[] = []; + const fetch: FetchLike = async (url, init) => { + const method = init?.method ?? "GET"; + const path = url.replace("https://git.example.com/api/v1", ""); + calls.push({ method, path, body: init?.body ? JSON.parse(init.body) : undefined }); + const route = routes[`${method} ${path}`] ?? routes[path]; + const status = route?.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => (route?.json === undefined ? "" : JSON.stringify(route.json)) + }; + }; + return { fetch, calls }; +} + +function adapterWith(routes: Record) { + const { fetch, calls } = makeFetch(routes); + const adapter = new ForgejoAdapter({ baseUrl: "https://git.example.com", token: "t", fetch }); + return { adapter, calls }; +} + +describe("ForgejoAdapter", () => { + it("creates a user only when missing", async () => { + const { adapter, calls } = adapterWith({ + "GET /users/alice": { status: 404 }, + "POST /admin/users": { json: { id: 1 } } + }); + const result = await adapter.ensureUser({ username: "alice", email: "alice@x.com", password: "pw" }); + expect(result.created).toBe(true); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual(["GET /users/alice", "POST /admin/users"]); + const createBody = calls[1].body as Record; + expect(createBody.must_change_password).toBe(true); + }); + + it("is a no-op when the user already exists", async () => { + const { adapter, calls } = adapterWith({ "GET /users/alice": { json: { id: 1 } } }); + const result = await adapter.ensureUser({ username: "alice", email: "alice@x.com", password: "pw" }); + expect(result.created).toBe(false); + expect(calls).toHaveLength(1); + }); + + it("creates a repo for a user via the admin endpoint", async () => { + const { adapter, calls } = adapterWith({ + "POST /admin/users/alice/repos": { + json: { name: "demo", default_branch: "main", clone_url: "c", html_url: "h", private: true, owner: { login: "alice" } } + } + }); + const repo = await adapter.createRepo({ owner: "alice", name: "demo", private: true }); + expect(repo).toMatchObject({ owner: "alice", name: "demo", private: true }); + expect((calls[0].body as Record).auto_init).toBe(true); + }); + + it("assembles a pull request from pulls + reviews + status", async () => { + const { adapter } = adapterWith({ + "GET /repos/alice/demo/pulls/7": { + json: { + number: 7, + title: "Add feature", + user: { login: "bob" }, + head: { ref: "feature", sha: "abc1234" }, + base: { ref: "main" }, + state: "open", + merged: false + } + }, + "GET /repos/alice/demo/pulls/7/reviews": { + json: [ + { state: "APPROVED", user: { login: "carol" } }, + { state: "PENDING", user: { login: "dave" } } + ] + }, + "GET /repos/alice/demo/commits/abc1234/status": { + json: { statuses: [{ context: "ci", status: "success", target_url: "u" }] } + } + }); + + const pr = await adapter.getPullRequest("alice", "demo", 7); + expect(pr.number).toBe(7); + expect(pr.authorLogin).toBe("bob"); + expect(pr.reviews).toEqual([{ reviewerLogin: "carol", decision: "approve", body: undefined, submittedAt: undefined }]); + expect(pr.checks).toEqual([{ name: "ci", status: "passing", url: "u" }]); + }); + + it("throws a typed error on non-2xx", async () => { + const { adapter } = adapterWith({ "GET /repos/alice/demo": { status: 500, json: { message: "boom" } } }); + await expect(adapter.getRepo("alice", "demo")).rejects.toThrow(/Forgejo GET .* 500/); + }); + + it("uses the injected fetch, never a real network", async () => { + const fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => "[]" })); + const adapter = new ForgejoAdapter({ baseUrl: "https://git.example.com/", token: "t", fetch }); + await adapter.listBranches("alice", "demo"); + expect(fetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/plugins/agentgit/src/forge/forgejo.ts b/plugins/agentgit/src/forge/forgejo.ts new file mode 100644 index 0000000..5fd10d9 --- /dev/null +++ b/plugins/agentgit/src/forge/forgejo.ts @@ -0,0 +1,300 @@ +import type { CheckStatus, MergeMethod, ReviewDecision } from "../domain.js"; +import type { + AddReviewInput, + CreateBranchInput, + CreateRepoInput, + EnsureUserInput, + ForgeAdapter, + ForgeBranch, + ForgePullRequest, + ForgeRepo, + ForgeReview, + ForgeUser, + MergePullRequestInput, + MergeResult, + OpenPullRequestInput +} from "./adapter.js"; + +export type FetchLike = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + } +) => Promise<{ + ok: boolean; + status: number; + text(): Promise; +}>; + +export interface ForgejoAdapterOptions { + /** Base URL of the Forgejo instance, e.g. https://git.profullstack.com */ + baseUrl: string; + /** Admin/personal access token used for server-side operations. */ + token: string; + fetch?: FetchLike; +} + +export class ForgejoApiError extends Error { + constructor( + readonly status: number, + readonly method: string, + readonly path: string, + body: string + ) { + super(`Forgejo ${method} ${path} -> ${status}: ${body.slice(0, 300)}`); + this.name = "ForgejoApiError"; + } +} + +const REVIEW_EVENT: Record = { + approve: "APPROVED", + request_changes: "REQUEST_CHANGES", + comment: "COMMENT" +}; + +const MERGE_DO: Record = { + merge: "merge", + squash: "squash", + rebase: "rebase" +}; + +/** Maps a Forgejo combined-status state to our check status vocabulary. */ +function mapCheckStatus(state: string): CheckStatus { + switch (state) { + case "success": + return "passing"; + case "pending": + return "pending"; + default: + // failure, error, warning + return "failing"; + } +} + +function mapReviewDecision(state: string): ReviewDecision { + switch (state) { + case "APPROVED": + return "approve"; + case "REQUEST_CHANGES": + return "request_changes"; + default: + return "comment"; + } +} + +/** Forgejo / Gitea REST API (v1) implementation of the ForgeAdapter. */ +export class ForgejoAdapter implements ForgeAdapter { + private readonly baseUrl: string; + private readonly token: string; + private readonly fetchImpl: FetchLike; + + constructor(options: ForgejoAdapterOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.token = options.token; + const injected = options.fetch; + if (injected) { + this.fetchImpl = injected; + } else if (typeof globalThis.fetch === "function") { + this.fetchImpl = globalThis.fetch.bind(globalThis) as unknown as FetchLike; + } else { + throw new Error("No fetch implementation available; pass options.fetch"); + } + } + + private async request(method: string, path: string, body?: unknown): Promise { + const response = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + Authorization: `token ${this.token}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }) + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + + const text = await response.text(); + if (!response.ok) { + throw new ForgejoApiError(response.status, method, path, text); + } + return (text ? JSON.parse(text) : undefined) as T; + } + + private mapRepo(raw: Record): ForgeRepo { + const owner = (raw.owner as Record | undefined)?.login as string; + return { + owner, + name: raw.name as string, + defaultBranch: (raw.default_branch as string) ?? "main", + cloneUrl: raw.clone_url as string, + htmlUrl: raw.html_url as string, + private: Boolean(raw.private), + archived: Boolean(raw.archived) + }; + } + + async ensureUser(input: EnsureUserInput): Promise { + try { + await this.request("GET", `/users/${encodeURIComponent(input.username)}`); + return { username: input.username, email: input.email, created: false }; + } catch (error) { + if (!(error instanceof ForgejoApiError) || error.status !== 404) { + throw error; + } + } + + await this.request("POST", "/admin/users", { + username: input.username, + email: input.email, + password: input.password, + full_name: input.fullName, + must_change_password: true + }); + return { username: input.username, email: input.email, created: true }; + } + + async createRepo(input: CreateRepoInput): Promise { + const raw = await this.request>("POST", `/admin/users/${encodeURIComponent(input.owner)}/repos`, { + name: input.name, + description: input.description, + private: input.private, + default_branch: input.defaultBranch ?? "main", + auto_init: true + }); + return this.mapRepo(raw); + } + + async getRepo(owner: string, repo: string): Promise { + const raw = await this.request>("GET", `/repos/${owner}/${repo}`); + return this.mapRepo(raw); + } + + async listRepos(owner: string): Promise { + const raw = await this.request>>("GET", `/users/${encodeURIComponent(owner)}/repos`); + return raw.map((entry) => this.mapRepo(entry)); + } + + async archiveRepo(owner: string, repo: string): Promise { + const raw = await this.request>("PATCH", `/repos/${owner}/${repo}`, { archived: true }); + return this.mapRepo(raw); + } + + async createBranch(input: CreateBranchInput): Promise { + const raw = await this.request>("POST", `/repos/${input.owner}/${input.repo}/branches`, { + new_branch_name: input.newBranch, + old_branch_name: input.fromBranch + }); + return { + name: raw.name as string, + commitSha: ((raw.commit as Record | undefined)?.id as string) ?? "" + }; + } + + async listBranches(owner: string, repo: string): Promise { + const raw = await this.request>>("GET", `/repos/${owner}/${repo}/branches`); + return raw.map((entry) => ({ + name: entry.name as string, + commitSha: ((entry.commit as Record | undefined)?.id as string) ?? "" + })); + } + + private async fetchReviews(owner: string, repo: string, number: number): Promise { + const raw = await this.request>>("GET", `/repos/${owner}/${repo}/pulls/${number}/reviews`); + return raw + .filter((entry) => entry.state !== "PENDING") + .map((entry) => ({ + reviewerLogin: (entry.user as Record | undefined)?.login as string, + decision: mapReviewDecision(entry.state as string), + body: entry.body as string | undefined, + submittedAt: entry.submitted_at as string | undefined + })); + } + + private async fetchChecks(owner: string, repo: string, sha: string): Promise { + if (!sha) { + return []; + } + const raw = await this.request<{ statuses?: Array> }>( + "GET", + `/repos/${owner}/${repo}/commits/${sha}/status` + ); + return (raw.statuses ?? []).map((entry) => ({ + name: (entry.context as string) ?? "status", + status: mapCheckStatus(entry.status as string), + url: entry.target_url as string | undefined + })); + } + + private async mapPullRequest(owner: string, repo: string, raw: Record): Promise { + const number = raw.number as number; + const headSha = ((raw.head as Record | undefined)?.sha as string) ?? ""; + const [reviews, checks] = await Promise.all([ + this.fetchReviews(owner, repo, number), + this.fetchChecks(owner, repo, headSha) + ]); + return { + number, + title: raw.title as string, + body: raw.body as string | undefined, + authorLogin: (raw.user as Record | undefined)?.login as string, + sourceBranch: (raw.head as Record | undefined)?.ref as string, + targetBranch: (raw.base as Record | undefined)?.ref as string, + headSha, + state: (raw.state as "open" | "closed") ?? "open", + merged: Boolean(raw.merged), + reviews, + checks, + createdAt: raw.created_at as string | undefined, + updatedAt: raw.updated_at as string | undefined + }; + } + + async openPullRequest(input: OpenPullRequestInput): Promise { + const raw = await this.request>("POST", `/repos/${input.owner}/${input.repo}/pulls`, { + title: input.title, + body: input.body, + head: input.sourceBranch, + base: input.targetBranch + }); + return this.mapPullRequest(input.owner, input.repo, raw); + } + + async getPullRequest(owner: string, repo: string, number: number): Promise { + const raw = await this.request>("GET", `/repos/${owner}/${repo}/pulls/${number}`); + return this.mapPullRequest(owner, repo, raw); + } + + async listPullRequests(owner: string, repo: string, state: "open" | "closed" | "all" = "open"): Promise { + const raw = await this.request>>("GET", `/repos/${owner}/${repo}/pulls?state=${state}`); + return Promise.all(raw.map((entry) => this.mapPullRequest(owner, repo, entry))); + } + + async addReview(input: AddReviewInput): Promise { + const raw = await this.request>("POST", `/repos/${input.owner}/${input.repo}/pulls/${input.number}/reviews`, { + event: REVIEW_EVENT[input.decision], + body: input.body + }); + return { + reviewerLogin: (raw.user as Record | undefined)?.login as string, + decision: input.decision, + body: input.body, + submittedAt: raw.submitted_at as string | undefined + }; + } + + async mergePullRequest(input: MergePullRequestInput): Promise { + await this.request("POST", `/repos/${input.owner}/${input.repo}/pulls/${input.number}/merge`, { + Do: MERGE_DO[input.method] + }); + const merged = await this.getPullRequest(input.owner, input.repo, input.number); + return { merged: merged.merged, mergeSha: merged.headSha }; + } + + async closePullRequest(owner: string, repo: string, number: number): Promise { + const raw = await this.request>("PATCH", `/repos/${owner}/${repo}/pulls/${number}`, { + state: "closed" + }); + return this.mapPullRequest(owner, repo, raw); + } +} diff --git a/plugins/agentgit/src/index.ts b/plugins/agentgit/src/index.ts new file mode 100644 index 0000000..6ae6f9b --- /dev/null +++ b/plugins/agentgit/src/index.ts @@ -0,0 +1,70 @@ +import type { PluginDefinition } from "@logicsrc/plugin-core"; +import { agentGitManifest } from "./manifest.js"; + +export const agentGitPlugin: PluginDefinition = { + manifest: agentGitManifest, + configDefaults: { + enabled: false, + backend_provider: "forgejo", + forge_url: "${AGENTGIT_FORGE_URL}", + api_url: "${AGENTGIT_API_URL}", + api_key: "${AGENTGIT_API_KEY}", + webhook_secret: "${AGENTGIT_WEBHOOK_SECRET}", + members_only: true, + default_merge_policy: { + min_reviews: 1, + require_passing_checks: true, + reviewer_reputation_min: 0, + escrow_required: false, + allow_agent_merge: true, + allowed_merge_methods: ["squash"] + } + }, + routes: [ + { method: "POST", path: "/api/plugins/agentgit/repos", capability: "repo.create" }, + { method: "GET", path: "/api/plugins/agentgit/repos", capability: "repo.list" }, + { method: "POST", path: "/api/plugins/agentgit/repos/:repo/pulls", capability: "pr.open" }, + { method: "POST", path: "/api/plugins/agentgit/repos/:repo/pulls/:number/reviews", capability: "pr.review" }, + { method: "POST", path: "/api/plugins/agentgit/repos/:repo/pulls/:number/merge", capability: "pr.merge" }, + { method: "POST", path: "/api/plugins/agentgit/webhooks/push", capability: "webhook.push" }, + { method: "POST", path: "/api/plugins/agentgit/webhooks/pr-status", capability: "webhook.pr_status" } + ], + events: [ + { event: "task.claimed", capability: "branch.create" }, + { event: "pull_request.merged", capability: "reputation.merge_event" } + ], + permissions: [ + "repos:read", + "repos:create", + "pulls:open", + "pulls:review", + "pulls:merge" + ], + tuiPanels: [{ id: "agentgit-status", title: "AgentGit" }] +}; + +export { agentGitManifest }; + +// M1: Forgejo adapter, membership gate, merge-policy engine, and service. +export * from "./domain.js"; +export * from "./access.js"; +export * from "./merge-policy.js"; +export * from "./service.js"; +export type { + ForgeAdapter, + ForgeRepo, + ForgeBranch, + ForgeReview, + ForgeCheck, + ForgePullRequest, + ForgeUser, + CreateRepoInput, + CreateBranchInput, + OpenPullRequestInput, + AddReviewInput, + MergePullRequestInput, + MergeResult, + EnsureUserInput +} from "./forge/adapter.js"; +export { ForgejoAdapter, ForgejoApiError } from "./forge/forgejo.js"; +export type { FetchLike, ForgejoAdapterOptions } from "./forge/forgejo.js"; diff --git a/plugins/agentgit/src/manifest.ts b/plugins/agentgit/src/manifest.ts new file mode 100644 index 0000000..575ebff --- /dev/null +++ b/plugins/agentgit/src/manifest.ts @@ -0,0 +1,31 @@ +import type { PluginManifest } from "@logicsrc/plugin-core"; + +export const agentGitManifest: PluginManifest = { + id: "agentgit", + name: "AgentGit", + version: "0.1.0", + type: ["scm", "collaboration", "review"], + default: false, + capabilities: [ + "repo.create", + "repo.list", + "repo.get", + "repo.archive", + "branch.create", + "branch.list", + "pr.open", + "pr.list", + "pr.get", + "pr.review", + "pr.merge", + "pr.close", + "merge.evaluate", + "access.gate", + "webhook.push", + "webhook.pr_status", + "reputation.merge_event", + "audit.log" + ], + commands: ["repo", "pr", "clone", "review", "merge"], + env: ["AGENTGIT_API_URL", "AGENTGIT_FORGE_URL", "AGENTGIT_API_KEY", "AGENTGIT_WEBHOOK_SECRET"] +}; diff --git a/plugins/agentgit/src/merge-policy.test.ts b/plugins/agentgit/src/merge-policy.test.ts new file mode 100644 index 0000000..416aa8a --- /dev/null +++ b/plugins/agentgit/src/merge-policy.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MERGE_POLICY, type MergePolicy, type PullRequest } from "./domain.js"; +import { evaluateMergePolicy } from "./merge-policy.js"; + +const basePr: Pick = { + status: "open", + reviews: [{ reviewer_did: "carol.example", decision: "approve", reputation: 10 }], + checks: [{ name: "ci", status: "passing" }] +}; + +const human = { did: "alice.example", isAgent: false }; +const agent = { did: "bot.example", isAgent: true }; + +describe("evaluateMergePolicy", () => { + it("passes a clean PR with the default policy", () => { + const result = evaluateMergePolicy({ policy: DEFAULT_MERGE_POLICY, pr: basePr, method: "squash", actor: human }); + expect(result).toEqual({ satisfied: true, reasons: [] }); + }); + + it("blocks when there are not enough approvals", () => { + const result = evaluateMergePolicy({ + policy: DEFAULT_MERGE_POLICY, + pr: { ...basePr, reviews: [] }, + method: "squash", + actor: human + }); + expect(result.satisfied).toBe(false); + expect(result.reasons.join(" ")).toMatch(/approving review/); + }); + + it("ignores approvals below the reputation floor", () => { + const policy: MergePolicy = { ...DEFAULT_MERGE_POLICY, reviewer_reputation_min: 50 }; + const result = evaluateMergePolicy({ policy, pr: basePr, method: "squash", actor: human }); + expect(result.satisfied).toBe(false); + expect(result.reasons.join(" ")).toMatch(/reputation >= 50/); + }); + + it("blocks when changes are requested", () => { + const result = evaluateMergePolicy({ + policy: DEFAULT_MERGE_POLICY, + pr: { ...basePr, reviews: [{ reviewer_did: "carol.example", decision: "request_changes" }] }, + method: "squash", + actor: human + }); + expect(result.satisfied).toBe(false); + expect(result.reasons).toContain("changes requested by a reviewer"); + }); + + it("blocks on non-passing checks when required", () => { + const result = evaluateMergePolicy({ + policy: DEFAULT_MERGE_POLICY, + pr: { ...basePr, checks: [{ name: "ci", status: "failing" }] }, + method: "squash", + actor: human + }); + expect(result.satisfied).toBe(false); + expect(result.reasons.join(" ")).toMatch(/checks not passing/); + }); + + it("requires a funded escrow when escrow_required", () => { + const policy: MergePolicy = { ...DEFAULT_MERGE_POLICY, escrow_required: true }; + expect(evaluateMergePolicy({ policy, pr: basePr, method: "squash", actor: human }).satisfied).toBe(false); + expect(evaluateMergePolicy({ policy, pr: basePr, method: "squash", actor: human, escrowFunded: true }).satisfied).toBe(true); + }); + + it("rejects disallowed merge methods", () => { + const result = evaluateMergePolicy({ policy: DEFAULT_MERGE_POLICY, pr: basePr, method: "merge", actor: human }); + expect(result.satisfied).toBe(false); + expect(result.reasons.join(" ")).toMatch(/not allowed/); + }); + + it("blocks agent merges when allow_agent_merge is false", () => { + const policy: MergePolicy = { ...DEFAULT_MERGE_POLICY, allow_agent_merge: false }; + expect(evaluateMergePolicy({ policy, pr: basePr, method: "squash", actor: agent }).satisfied).toBe(false); + expect(evaluateMergePolicy({ policy, pr: basePr, method: "squash", actor: human }).satisfied).toBe(true); + }); + + it("never satisfies an already merged PR", () => { + const result = evaluateMergePolicy({ + policy: DEFAULT_MERGE_POLICY, + pr: { ...basePr, status: "merged" }, + method: "squash", + actor: human + }); + expect(result.satisfied).toBe(false); + expect(result.reasons).toContain("pull request is merged"); + }); +}); diff --git a/plugins/agentgit/src/merge-policy.ts b/plugins/agentgit/src/merge-policy.ts new file mode 100644 index 0000000..bf851ed --- /dev/null +++ b/plugins/agentgit/src/merge-policy.ts @@ -0,0 +1,67 @@ +import type { MergeMethod, MergePolicy, PullRequest } from "./domain.js"; + +export interface MergeEvaluationInput { + policy: MergePolicy; + pr: Pick; + method: MergeMethod; + actor: { did: string; isAgent: boolean }; + /** Whether a funded escrow exists for the linked task; resolved by the caller. */ + escrowFunded?: boolean; +} + +export interface MergeEvaluation { + satisfied: boolean; + reasons: string[]; +} + +/** + * Pure evaluation of a repo's merge policy against a pull request. Returns + * whether the PR may be merged and, if not, every blocking reason. The actual + * merge (pr.merge) refuses unless `satisfied` is true. + */ +export function evaluateMergePolicy(input: MergeEvaluationInput): MergeEvaluation { + const { policy, pr, method, actor, escrowFunded } = input; + const reasons: string[] = []; + + if (pr.status === "merged" || pr.status === "closed") { + reasons.push(`pull request is ${pr.status}`); + return { satisfied: false, reasons }; + } + if (pr.status === "draft") { + reasons.push("pull request is a draft"); + } + + if (pr.reviews.some((review) => review.decision === "request_changes")) { + reasons.push("changes requested by a reviewer"); + } + + const qualifyingApprovals = pr.reviews.filter( + (review) => review.decision === "approve" && (review.reputation ?? 0) >= policy.reviewer_reputation_min + ).length; + if (qualifyingApprovals < policy.min_reviews) { + reasons.push( + `needs ${policy.min_reviews} approving review(s) at reputation >= ${policy.reviewer_reputation_min}, has ${qualifyingApprovals}` + ); + } + + if (policy.require_passing_checks) { + const notPassing = pr.checks.filter((check) => check.status !== "passing"); + if (notPassing.length > 0) { + reasons.push(`checks not passing: ${notPassing.map((check) => `${check.name}(${check.status})`).join(", ")}`); + } + } + + if (policy.escrow_required && escrowFunded !== true) { + reasons.push("escrow required but not funded"); + } + + if (!policy.allowed_merge_methods.includes(method)) { + reasons.push(`merge method "${method}" not allowed (allowed: ${policy.allowed_merge_methods.join(", ")})`); + } + + if (actor.isAgent && !policy.allow_agent_merge) { + reasons.push("agent merges are disabled for this repo"); + } + + return { satisfied: reasons.length === 0, reasons }; +} diff --git a/plugins/agentgit/src/service.test.ts b/plugins/agentgit/src/service.test.ts new file mode 100644 index 0000000..f195444 --- /dev/null +++ b/plugins/agentgit/src/service.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MERGE_POLICY, type Repo } from "./domain.js"; +import { AccessDeniedError, AgentGitService, MergePolicyError } from "./service.js"; +import type { + ForgeAdapter, + ForgePullRequest, + ForgeRepo, + ForgeUser, + MergeResult +} from "./forge/adapter.js"; + +function fakePr(overrides: Partial = {}): ForgePullRequest { + return { + number: 1, + title: "PR", + authorLogin: "contributor", + sourceBranch: "feature", + targetBranch: "main", + headSha: "abc", + state: "open", + merged: false, + reviews: [{ reviewerLogin: "maintainer", decision: "approve" }], + checks: [{ name: "ci", status: "passing" }], + ...overrides + }; +} + +class FakeAdapter implements ForgeAdapter { + pr: ForgePullRequest = fakePr(); + merged = false; + ensuredUsers: string[] = []; + + async ensureUser(input: { username: string; email: string }): Promise { + this.ensuredUsers.push(input.username); + return { username: input.username, email: input.email, created: true }; + } + async createRepo(input: { owner: string; name: string }): Promise { + return { owner: input.owner, name: input.name, defaultBranch: "main", cloneUrl: "c", htmlUrl: "h", private: true, archived: false }; + } + async getRepo(owner: string, name: string): Promise { + return { owner, name, defaultBranch: "main", cloneUrl: "c", htmlUrl: "h", private: true, archived: false }; + } + async listRepos(): Promise { + return []; + } + async archiveRepo(owner: string, name: string): Promise { + return this.getRepo(owner, name); + } + async createBranch() { + return { name: "feature", commitSha: "abc" }; + } + async listBranches() { + return []; + } + async openPullRequest(): Promise { + return this.pr; + } + async getPullRequest(): Promise { + return this.pr; + } + async listPullRequests(): Promise { + return [this.pr]; + } + async addReview() { + return { reviewerLogin: "maintainer", decision: "approve" as const }; + } + async mergePullRequest(): Promise { + this.merged = true; + return { merged: true, mergeSha: "deadbeef" }; + } + async closePullRequest(): Promise { + return this.pr; + } +} + +function repo(overrides: Partial = {}): Repo { + return { + type: "logicsrc.repo", + version: "1.0", + name: "Demo", + slug: "demo", + owner_did: "owner.example", + visibility: "members_only", + default_branch: "main", + backend: { provider: "forgejo", url: "https://git.profullstack.com" }, + members: [ + { did: "contributor.example", role: "contributor" }, + { did: "maintainer.example", role: "maintainer" } + ], + merge_policy: DEFAULT_MERGE_POLICY, + ...overrides + }; +} + +describe("AgentGitService", () => { + it("provisions a member account keyed to the DID login", async () => { + const adapter = new FakeAdapter(); + const service = new AgentGitService({ adapter }); + await service.provisionMember({ did: "newbie.example", email: "n@x.com", password: "pw" }); + expect(adapter.ensuredUsers).toEqual(["newbie"]); + }); + + it("denies repo creation by a non-owner", async () => { + const service = new AgentGitService({ adapter: new FakeAdapter() }); + await expect(service.createRepo(repo(), "contributor.example")).rejects.toBeInstanceOf(AccessDeniedError); + }); + + it("lets the owner create a repo", async () => { + const service = new AgentGitService({ adapter: new FakeAdapter() }); + const created = await service.createRepo(repo(), "owner.example"); + expect(created.name).toBe("demo"); + }); + + it("denies opening a PR for a reader-less stranger", async () => { + const service = new AgentGitService({ adapter: new FakeAdapter() }); + await expect( + service.openPullRequest(repo(), { title: "x", sourceBranch: "f" }, "stranger.example") + ).rejects.toBeInstanceOf(AccessDeniedError); + }); + + it("merges when the policy is satisfied", async () => { + const adapter = new FakeAdapter(); + const service = new AgentGitService({ adapter }); + const merged = await service.mergePullRequest(repo(), 1, "squash", "maintainer.example"); + expect(adapter.merged).toBe(true); + expect(merged.status).toBe("merged"); + expect(merged.merge?.policy_satisfied).toBe(true); + }); + + it("refuses to merge when checks fail and never calls the backend merge", async () => { + const adapter = new FakeAdapter(); + adapter.pr = fakePr({ checks: [{ name: "ci", status: "failing" }] }); + const service = new AgentGitService({ adapter }); + await expect(service.mergePullRequest(repo(), 1, "squash", "maintainer.example")).rejects.toBeInstanceOf(MergePolicyError); + expect(adapter.merged).toBe(false); + }); + + it("denies merge by a contributor (role lacks merge)", async () => { + const service = new AgentGitService({ adapter: new FakeAdapter() }); + await expect(service.mergePullRequest(repo(), 1, "squash", "contributor.example")).rejects.toBeInstanceOf(AccessDeniedError); + }); +}); diff --git a/plugins/agentgit/src/service.ts b/plugins/agentgit/src/service.ts new file mode 100644 index 0000000..2d87fb8 --- /dev/null +++ b/plugins/agentgit/src/service.ts @@ -0,0 +1,233 @@ +import { gateAccess, type Action } from "./access.js"; +import type { MergeMethod, PullRequest, Repo, Review } from "./domain.js"; +import { evaluateMergePolicy, type MergeEvaluation } from "./merge-policy.js"; +import type { ForgeAdapter, ForgePullRequest, ForgeRepo, ForgeUser } from "./forge/adapter.js"; + +export class AccessDeniedError extends Error { + constructor(reason: string) { + super(`access denied: ${reason}`); + this.name = "AccessDeniedError"; + } +} + +export class MergePolicyError extends Error { + constructor(readonly reasons: string[]) { + super(`merge policy not satisfied: ${reasons.join("; ")}`); + this.name = "MergePolicyError"; + } +} + +export interface ProvisionMemberInput { + did: string; + email: string; + password: string; + fullName?: string; +} + +export interface AgentGitServiceOptions { + adapter: ForgeAdapter; + /** Resolve a DID to a forge login. Default: segment before the first dot. */ + didToLogin?: (did: string) => string; + /** Resolve a forge login back to a DID. Default: append the operator domain. */ + loginToDid?: (login: string) => string; + /** Reputation lookup for a reviewer DID. Default: 0. */ + reputationOf?: (did: string) => number | Promise; + /** Whether a DID belongs to an agent (vs. a human). Default: false. */ + isAgentDid?: (did: string) => boolean | Promise; + /** Whether the PR's linked task has a funded escrow. Default: false. */ + escrowFundedFor?: (pr: PullRequest) => boolean | Promise; +} + +const defaultDidToLogin = (did: string) => did.split(".")[0]; + +/** + * AgentGit application service. Ties the membership gate and merge-policy engine + * to a backend ForgeAdapter so every operation is DID-gated and every merge is + * policy-gated. Forge-specific code lives only in the adapter. + */ +export class AgentGitService { + private readonly adapter: ForgeAdapter; + private readonly didToLogin: (did: string) => string; + private readonly loginToDid: (login: string) => string; + private readonly reputationOf: (did: string) => number | Promise; + private readonly isAgentDid: (did: string) => boolean | Promise; + private readonly escrowFundedFor: (pr: PullRequest) => boolean | Promise; + + constructor(options: AgentGitServiceOptions) { + this.adapter = options.adapter; + this.didToLogin = options.didToLogin ?? defaultDidToLogin; + this.loginToDid = options.loginToDid ?? ((login) => login); + this.reputationOf = options.reputationOf ?? (() => 0); + this.isAgentDid = options.isAgentDid ?? (() => false); + this.escrowFundedFor = options.escrowFundedFor ?? (() => false); + } + + private coordinates(repo: Repo): { owner: string; name: string } { + if (repo.slug.includes("/")) { + const [owner, ...rest] = repo.slug.split("/"); + return { owner, name: rest.join("/") }; + } + return { owner: this.didToLogin(repo.owner_did), name: repo.slug }; + } + + private gate(repo: Repo, callerDid: string | undefined, action: Action): void { + const result = gateAccess(repo, callerDid, action); + if (!result.allowed) { + throw new AccessDeniedError(result.reason ?? "not permitted"); + } + } + + /** AgentBBS provisioning hook: ensure a member has a backend git account. */ + async provisionMember(input: ProvisionMemberInput): Promise { + return this.adapter.ensureUser({ + username: this.didToLogin(input.did), + email: input.email, + password: input.password, + fullName: input.fullName + }); + } + + async createRepo(repo: Repo, callerDid: string | undefined): Promise { + this.gate(repo, callerDid, "admin"); + const { owner, name } = this.coordinates(repo); + return this.adapter.createRepo({ + owner, + name, + description: repo.description, + private: repo.visibility !== "public", + defaultBranch: repo.default_branch + }); + } + + async listRepos(repo: Repo, callerDid: string | undefined): Promise { + this.gate(repo, callerDid, "read"); + return this.adapter.listRepos(this.coordinates(repo).owner); + } + + async openPullRequest( + repo: Repo, + input: { title: string; body?: string; sourceBranch: string; targetBranch?: string; task?: string }, + callerDid: string + ): Promise { + this.gate(repo, callerDid, "write"); + const { owner, name } = this.coordinates(repo); + const fpr = await this.adapter.openPullRequest({ + owner, + repo: name, + title: input.title, + body: input.body, + sourceBranch: input.sourceBranch, + targetBranch: input.targetBranch ?? repo.default_branch + }); + return this.toDomainPullRequest(repo, fpr, callerDid, input.task); + } + + async reviewPullRequest( + repo: Repo, + number: number, + decision: Review["decision"], + callerDid: string, + body?: string + ): Promise { + this.gate(repo, callerDid, "review"); + const { owner, name } = this.coordinates(repo); + await this.adapter.addReview({ owner, repo: name, number, decision, body }); + const fpr = await this.adapter.getPullRequest(owner, name, number); + return this.toDomainPullRequest(repo, fpr, callerDid); + } + + async evaluateMerge( + repo: Repo, + number: number, + method: MergeMethod, + callerDid: string + ): Promise<{ pr: PullRequest; evaluation: MergeEvaluation }> { + this.gate(repo, callerDid, "merge"); + const { owner, name } = this.coordinates(repo); + const fpr = await this.adapter.getPullRequest(owner, name, number); + const pr = await this.toDomainPullRequest(repo, fpr, callerDid); + const evaluation = evaluateMergePolicy({ + policy: repo.merge_policy, + pr, + method, + actor: { did: callerDid, isAgent: await this.isAgentDid(callerDid) }, + escrowFunded: await this.escrowFundedFor(pr) + }); + return { pr, evaluation }; + } + + async mergePullRequest(repo: Repo, number: number, method: MergeMethod, callerDid: string): Promise { + const { pr, evaluation } = await this.evaluateMerge(repo, number, method, callerDid); + if (!evaluation.satisfied) { + throw new MergePolicyError(evaluation.reasons); + } + const { owner, name } = this.coordinates(repo); + const result = await this.adapter.mergePullRequest({ owner, repo: name, number, method }); + return { + ...pr, + status: "merged", + merge: { + method, + merged_by_did: callerDid, + merge_sha: result.mergeSha, + policy_satisfied: true, + merged_at: new Date().toISOString() + } + }; + } + + private async toDomainPullRequest( + repo: Repo, + fpr: ForgePullRequest, + fallbackAuthorDid: string, + task?: string + ): Promise { + const reviews: Review[] = await Promise.all( + fpr.reviews.map(async (review) => { + const reviewerDid = this.loginToDid(review.reviewerLogin); + return { + reviewer_did: reviewerDid, + decision: review.decision, + reputation: await this.reputationOf(reviewerDid), + summary: review.body, + created_at: review.submittedAt + } satisfies Review; + }) + ); + + return { + type: "logicsrc.pull_request", + version: "1.0", + repo: repo.slug, + number: fpr.number, + title: fpr.title, + description: fpr.body, + author_did: fpr.authorLogin ? this.loginToDid(fpr.authorLogin) : fallbackAuthorDid, + source_branch: fpr.sourceBranch, + target_branch: fpr.targetBranch, + head_sha: fpr.headSha || undefined, + task, + status: deriveStatus(fpr, reviews), + checks: fpr.checks, + reviews, + created_at: fpr.createdAt, + updated_at: fpr.updatedAt + }; + } +} + +function deriveStatus(fpr: ForgePullRequest, reviews: Review[]): PullRequest["status"] { + if (fpr.merged) { + return "merged"; + } + if (fpr.state === "closed") { + return "closed"; + } + if (reviews.some((review) => review.decision === "request_changes")) { + return "changes_requested"; + } + if (reviews.some((review) => review.decision === "approve")) { + return "approved"; + } + return "open"; +} diff --git a/plugins/agentgit/tsconfig.json b/plugins/agentgit/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/plugins/agentgit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}