mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
Add AgentGit M1: agent-native git layer over a Forgejo backend
AgentGit is a thin, DID-gated source-collaboration layer over a backend forge (default Forgejo at git.profullstack.com, BBS-members-only) — not a new git host. M1 implements the contract and engines: - forge/adapter.ts: ForgeAdapter interface (only forge-specific surface) - forge/forgejo.ts: ForgejoAdapter over Forgejo/Gitea REST v1 (injectable fetch, typed errors), incl. ensureUser for member provisioning - access.ts: gateAccess DID membership gate (owner/role/visibility) - merge-policy.ts: evaluateMergePolicy pure engine (reviews, reputation floor, checks, escrow, merge method, agent-merge toggle) - service.ts: AgentGitService ties gate + policy to the adapter; refuses policy-failing merges; provisionMember hook for AgentBBS - schemas: logicsrc-repo + logicsrc-pull-request, registered in @logicsrc/validators with fixtures - docs/agentgit.md spec; plugin wired into root build (default/disabled) 27 vitest tests pass; full monorepo build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
14fe9d608f
commit
bf046ae280
25 changed files with 1776 additions and 3 deletions
136
docs/agentgit.md
Normal file
136
docs/agentgit.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -14,6 +14,7 @@ Default plugins:
|
||||||
Coming soon plugin specs:
|
Coming soon plugin specs:
|
||||||
|
|
||||||
- AgentByte: candidate, contractor, and agent capability screening for AI-era workflows. See `docs/agent-screening.md`.
|
- 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`.
|
- 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`.
|
- Communication Accounts: shared social and email account management contracts. See `docs/communication-accounts.md`.
|
||||||
|
|
||||||
|
|
|
||||||
17
package-lock.json
generated
17
package-lock.json
generated
|
|
@ -1446,6 +1446,10 @@
|
||||||
"resolved": "apps/commandboard-web",
|
"resolved": "apps/commandboard-web",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@logicsrc/plugin-agentgit": {
|
||||||
|
"resolved": "plugins/agentgit",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@logicsrc/plugin-c0mpute": {
|
"node_modules/@logicsrc/plugin-c0mpute": {
|
||||||
"resolved": "plugins/c0mpute",
|
"resolved": "plugins/c0mpute",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -6127,7 +6131,8 @@
|
||||||
},
|
},
|
||||||
"packages/schemas": {
|
"packages/schemas": {
|
||||||
"name": "@logicsrc/schemas",
|
"name": "@logicsrc/schemas",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"packages/sdk": {
|
"packages/sdk": {
|
||||||
"name": "@logicsrc/sdk",
|
"name": "@logicsrc/sdk",
|
||||||
|
|
@ -6162,6 +6167,16 @@
|
||||||
"vitest": "^4.0.8"
|
"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": {
|
"plugins/c0mpute": {
|
||||||
"name": "@logicsrc/plugin-c0mpute",
|
"name": "@logicsrc/plugin-c0mpute",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"start": "npm --workspace @logicsrc/web run start",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"check": "npm run build && npm run test",
|
"check": "npm run build && npm run test",
|
||||||
|
|
|
||||||
24
packages/schemas/fixtures/pull-request.yaml
Normal file
24
packages/schemas/fixtures/pull-request.yaml
Normal file
|
|
@ -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
|
||||||
27
packages/schemas/fixtures/repo.yaml
Normal file
27
packages/schemas/fixtures/repo.yaml
Normal file
|
|
@ -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
|
||||||
73
packages/schemas/schemas/logicsrc-pull-request.schema.json
Normal file
73
packages/schemas/schemas/logicsrc-pull-request.schema.json
Normal file
|
|
@ -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._-]*$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/schemas/schemas/logicsrc-repo.schema.json
Normal file
73
packages/schemas/schemas/logicsrc-repo.schema.json
Normal file
|
|
@ -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._-]*$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"test": "vitest run src",
|
"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": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
|
|
||||||
|
|
@ -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 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 eventSchema from "../../schemas/schemas/logicsrc-event.schema.json" with { type: "json" };
|
||||||
import pluginSchema from "../../schemas/schemas/logicsrc-plugin.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 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 socialPostSchema from "../../schemas/schemas/logicsrc-social-post.schema.json" with { type: "json" };
|
||||||
import taskSchema from "../../schemas/schemas/logicsrc-task.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,
|
"email-message": emailMessageSchema,
|
||||||
event: eventSchema,
|
event: eventSchema,
|
||||||
plugin: pluginSchema,
|
plugin: pluginSchema,
|
||||||
|
"pull-request": pullRequestSchema,
|
||||||
|
repo: repoSchema,
|
||||||
run: runSchema,
|
run: runSchema,
|
||||||
"social-post": socialPostSchema,
|
"social-post": socialPostSchema,
|
||||||
task: taskSchema,
|
task: taskSchema,
|
||||||
|
|
|
||||||
28
plugins/agentgit/README.md
Normal file
28
plugins/agentgit/README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
18
plugins/agentgit/package.json
Normal file
18
plugins/agentgit/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
plugins/agentgit/src/access.test.ts
Normal file
52
plugins/agentgit/src/access.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
plugins/agentgit/src/access.ts
Normal file
51
plugins/agentgit/src/access.ts
Normal file
|
|
@ -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<MemberRole, Action[]> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
98
plugins/agentgit/src/domain.ts
Normal file
98
plugins/agentgit/src/domain.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
128
plugins/agentgit/src/forge/adapter.ts
Normal file
128
plugins/agentgit/src/forge/adapter.ts
Normal file
|
|
@ -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<ForgeUser>;
|
||||||
|
createRepo(input: CreateRepoInput): Promise<ForgeRepo>;
|
||||||
|
getRepo(owner: string, repo: string): Promise<ForgeRepo>;
|
||||||
|
listRepos(owner: string): Promise<ForgeRepo[]>;
|
||||||
|
archiveRepo(owner: string, repo: string): Promise<ForgeRepo>;
|
||||||
|
createBranch(input: CreateBranchInput): Promise<ForgeBranch>;
|
||||||
|
listBranches(owner: string, repo: string): Promise<ForgeBranch[]>;
|
||||||
|
openPullRequest(input: OpenPullRequestInput): Promise<ForgePullRequest>;
|
||||||
|
getPullRequest(owner: string, repo: string, number: number): Promise<ForgePullRequest>;
|
||||||
|
listPullRequests(owner: string, repo: string, state?: "open" | "closed" | "all"): Promise<ForgePullRequest[]>;
|
||||||
|
addReview(input: AddReviewInput): Promise<ForgeReview>;
|
||||||
|
mergePullRequest(input: MergePullRequestInput): Promise<MergeResult>;
|
||||||
|
closePullRequest(owner: string, repo: string, number: number): Promise<ForgePullRequest>;
|
||||||
|
}
|
||||||
106
plugins/agentgit/src/forge/forgejo.test.ts
Normal file
106
plugins/agentgit/src/forge/forgejo.test.ts
Normal file
|
|
@ -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<string, { status?: number; json?: unknown }>): { 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<string, { status?: number; json?: unknown }>) {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>).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<FetchLike>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
300
plugins/agentgit/src/forge/forgejo.ts
Normal file
300
plugins/agentgit/src/forge/forgejo.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
text(): Promise<string>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
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<ReviewDecision, string> = {
|
||||||
|
approve: "APPROVED",
|
||||||
|
request_changes: "REQUEST_CHANGES",
|
||||||
|
comment: "COMMENT"
|
||||||
|
};
|
||||||
|
|
||||||
|
const MERGE_DO: Record<MergeMethod, string> = {
|
||||||
|
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
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<string, unknown>): ForgeRepo {
|
||||||
|
const owner = (raw.owner as Record<string, unknown> | 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<ForgeUser> {
|
||||||
|
try {
|
||||||
|
await this.request<unknown>("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<unknown>("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<ForgeRepo> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("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<ForgeRepo> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("GET", `/repos/${owner}/${repo}`);
|
||||||
|
return this.mapRepo(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepos(owner: string): Promise<ForgeRepo[]> {
|
||||||
|
const raw = await this.request<Array<Record<string, unknown>>>("GET", `/users/${encodeURIComponent(owner)}/repos`);
|
||||||
|
return raw.map((entry) => this.mapRepo(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveRepo(owner: string, repo: string): Promise<ForgeRepo> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("PATCH", `/repos/${owner}/${repo}`, { archived: true });
|
||||||
|
return this.mapRepo(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBranch(input: CreateBranchInput): Promise<ForgeBranch> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("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<string, unknown> | undefined)?.id as string) ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBranches(owner: string, repo: string): Promise<ForgeBranch[]> {
|
||||||
|
const raw = await this.request<Array<Record<string, unknown>>>("GET", `/repos/${owner}/${repo}/branches`);
|
||||||
|
return raw.map((entry) => ({
|
||||||
|
name: entry.name as string,
|
||||||
|
commitSha: ((entry.commit as Record<string, unknown> | undefined)?.id as string) ?? ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchReviews(owner: string, repo: string, number: number): Promise<ForgeReview[]> {
|
||||||
|
const raw = await this.request<Array<Record<string, unknown>>>("GET", `/repos/${owner}/${repo}/pulls/${number}/reviews`);
|
||||||
|
return raw
|
||||||
|
.filter((entry) => entry.state !== "PENDING")
|
||||||
|
.map((entry) => ({
|
||||||
|
reviewerLogin: (entry.user as Record<string, unknown> | 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<ForgePullRequest["checks"]> {
|
||||||
|
if (!sha) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const raw = await this.request<{ statuses?: Array<Record<string, unknown>> }>(
|
||||||
|
"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<string, unknown>): Promise<ForgePullRequest> {
|
||||||
|
const number = raw.number as number;
|
||||||
|
const headSha = ((raw.head as Record<string, unknown> | 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<string, unknown> | undefined)?.login as string,
|
||||||
|
sourceBranch: (raw.head as Record<string, unknown> | undefined)?.ref as string,
|
||||||
|
targetBranch: (raw.base as Record<string, unknown> | 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<ForgePullRequest> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("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<ForgePullRequest> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("GET", `/repos/${owner}/${repo}/pulls/${number}`);
|
||||||
|
return this.mapPullRequest(owner, repo, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPullRequests(owner: string, repo: string, state: "open" | "closed" | "all" = "open"): Promise<ForgePullRequest[]> {
|
||||||
|
const raw = await this.request<Array<Record<string, unknown>>>("GET", `/repos/${owner}/${repo}/pulls?state=${state}`);
|
||||||
|
return Promise.all(raw.map((entry) => this.mapPullRequest(owner, repo, entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReview(input: AddReviewInput): Promise<ForgeReview> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("POST", `/repos/${input.owner}/${input.repo}/pulls/${input.number}/reviews`, {
|
||||||
|
event: REVIEW_EVENT[input.decision],
|
||||||
|
body: input.body
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reviewerLogin: (raw.user as Record<string, unknown> | undefined)?.login as string,
|
||||||
|
decision: input.decision,
|
||||||
|
body: input.body,
|
||||||
|
submittedAt: raw.submitted_at as string | undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergePullRequest(input: MergePullRequestInput): Promise<MergeResult> {
|
||||||
|
await this.request<unknown>("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<ForgePullRequest> {
|
||||||
|
const raw = await this.request<Record<string, unknown>>("PATCH", `/repos/${owner}/${repo}/pulls/${number}`, {
|
||||||
|
state: "closed"
|
||||||
|
});
|
||||||
|
return this.mapPullRequest(owner, repo, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
plugins/agentgit/src/index.ts
Normal file
70
plugins/agentgit/src/index.ts
Normal file
|
|
@ -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";
|
||||||
31
plugins/agentgit/src/manifest.ts
Normal file
31
plugins/agentgit/src/manifest.ts
Normal file
|
|
@ -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"]
|
||||||
|
};
|
||||||
88
plugins/agentgit/src/merge-policy.test.ts
Normal file
88
plugins/agentgit/src/merge-policy.test.ts
Normal file
|
|
@ -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<PullRequest, "status" | "reviews" | "checks"> = {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
67
plugins/agentgit/src/merge-policy.ts
Normal file
67
plugins/agentgit/src/merge-policy.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { MergeMethod, MergePolicy, PullRequest } from "./domain.js";
|
||||||
|
|
||||||
|
export interface MergeEvaluationInput {
|
||||||
|
policy: MergePolicy;
|
||||||
|
pr: Pick<PullRequest, "status" | "reviews" | "checks">;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
142
plugins/agentgit/src/service.test.ts
Normal file
142
plugins/agentgit/src/service.test.ts
Normal file
|
|
@ -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> = {}): 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<ForgeUser> {
|
||||||
|
this.ensuredUsers.push(input.username);
|
||||||
|
return { username: input.username, email: input.email, created: true };
|
||||||
|
}
|
||||||
|
async createRepo(input: { owner: string; name: string }): Promise<ForgeRepo> {
|
||||||
|
return { owner: input.owner, name: input.name, defaultBranch: "main", cloneUrl: "c", htmlUrl: "h", private: true, archived: false };
|
||||||
|
}
|
||||||
|
async getRepo(owner: string, name: string): Promise<ForgeRepo> {
|
||||||
|
return { owner, name, defaultBranch: "main", cloneUrl: "c", htmlUrl: "h", private: true, archived: false };
|
||||||
|
}
|
||||||
|
async listRepos(): Promise<ForgeRepo[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async archiveRepo(owner: string, name: string): Promise<ForgeRepo> {
|
||||||
|
return this.getRepo(owner, name);
|
||||||
|
}
|
||||||
|
async createBranch() {
|
||||||
|
return { name: "feature", commitSha: "abc" };
|
||||||
|
}
|
||||||
|
async listBranches() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async openPullRequest(): Promise<ForgePullRequest> {
|
||||||
|
return this.pr;
|
||||||
|
}
|
||||||
|
async getPullRequest(): Promise<ForgePullRequest> {
|
||||||
|
return this.pr;
|
||||||
|
}
|
||||||
|
async listPullRequests(): Promise<ForgePullRequest[]> {
|
||||||
|
return [this.pr];
|
||||||
|
}
|
||||||
|
async addReview() {
|
||||||
|
return { reviewerLogin: "maintainer", decision: "approve" as const };
|
||||||
|
}
|
||||||
|
async mergePullRequest(): Promise<MergeResult> {
|
||||||
|
this.merged = true;
|
||||||
|
return { merged: true, mergeSha: "deadbeef" };
|
||||||
|
}
|
||||||
|
async closePullRequest(): Promise<ForgePullRequest> {
|
||||||
|
return this.pr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function repo(overrides: Partial<Repo> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
233
plugins/agentgit/src/service.ts
Normal file
233
plugins/agentgit/src/service.ts
Normal file
|
|
@ -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<number>;
|
||||||
|
/** Whether a DID belongs to an agent (vs. a human). Default: false. */
|
||||||
|
isAgentDid?: (did: string) => boolean | Promise<boolean>;
|
||||||
|
/** Whether the PR's linked task has a funded escrow. Default: false. */
|
||||||
|
escrowFundedFor?: (pr: PullRequest) => boolean | Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number>;
|
||||||
|
private readonly isAgentDid: (did: string) => boolean | Promise<boolean>;
|
||||||
|
private readonly escrowFundedFor: (pr: PullRequest) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
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<ForgeUser> {
|
||||||
|
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<ForgeRepo> {
|
||||||
|
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<ForgeRepo[]> {
|
||||||
|
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<PullRequest> {
|
||||||
|
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<PullRequest> {
|
||||||
|
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<PullRequest> {
|
||||||
|
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<PullRequest> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
8
plugins/agentgit/tsconfig.json
Normal file
8
plugins/agentgit/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue