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:
Anthony Ettinger 2026-06-14 12:45:38 +00:00
parent 14fe9d608f
commit bf046ae280
25 changed files with 1776 additions and 3 deletions

View 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
```

View 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"
}
}

View 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);
});
});

View 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 };
}

View 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;
}

View 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>;
}

View 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();
});
});

View 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);
}
}

View 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";

View 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"]
};

View 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");
});
});

View 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 };
}

View 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);
});
});

View 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";
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}