mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +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
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