Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
.state/
.env
.env.*
!.env.example
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ cp agent-router.config.example.json agent-router.config.json
- Senders must have `write`, `maintain`, or `admin` repository permission.
- Repositories can be allowlisted in `agent-router.config.json`.
- The runner claims jobs over an authenticated local API.
- PR fix jobs only run on fixed lane branches:
- `@codex` -> `codex/workspace`
- `@claude` -> `claude/workspace`
- `@huma` -> `huma/workspace`
- AgentRouter blocks fork PRs and unexpected PR branches instead of creating new branches.

## Webhook Events

Expand All @@ -101,6 +106,8 @@ AgentRouter currently understands:

When a valid mention is accepted, AgentRouter stores a queued job. Runners claim jobs per lane and report completion back to the local API.

For pull requests, jobs include the PR head branch and base branch. The router only queues a PR job when the PR head branch matches the mentioned agent's fixed workspace branch in the same repository.

## Development

```bash
Expand Down
20 changes: 20 additions & 0 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { Octokit } from "@octokit/rest";
import type { AppConfig } from "../config.js";
import type { TriggerRequest } from "../types.js";

export interface PullRequestContext {
headBranch: string;
headRepository: string;
baseBranch: string;
}

export class GitHubClientFactory {
constructor(private config: AppConfig) {}

Expand Down Expand Up @@ -37,3 +43,17 @@ export async function postIssueComment(octokit: Octokit, trigger: TriggerRequest
body,
});
}

export async function getPullRequestContext(octokit: Octokit, trigger: TriggerRequest): Promise<PullRequestContext> {
const response = await octokit.pulls.get({
owner: trigger.owner,
repo: trigger.repo,
pull_number: trigger.targetNumber,
});
const headRepository = response.data.head.repo?.full_name ?? `${response.data.head.repo?.owner.login}/${response.data.head.repo?.name}`;
return {
headBranch: response.data.head.ref,
headRepository,
baseBranch: response.data.base.ref,
};
}
62 changes: 59 additions & 3 deletions src/server/router.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { timingSafeEqual } from "node:crypto";
import { verify } from "@octokit/webhooks-methods";
import { isRepositoryAllowed, type AppConfig } from "../config.js";
import { actorCanTrigger, GitHubClientFactory, postIssueComment } from "../github/client.js";
import { actorCanTrigger, getPullRequestContext, GitHubClientFactory, postIssueComment, type PullRequestContext } from "../github/client.js";
import { normalizeGitHubEvent } from "../github/events.js";
import { JobStore } from "../store/job-store.js";
import type { AgentName } from "../types.js";
import type { AgentName, TriggerRequest } from "../types.js";

export interface RouterDeps {
config: AppConfig;
Expand Down Expand Up @@ -36,8 +36,20 @@ export async function handleWebhook(
return { status: 403, body: { error: "actor not allowed" } };
}

if (trigger.isPullRequest) {
const pr = await getPullRequestContext(octokit, trigger);
const routing = evaluatePullRequestRouting(trigger, pr);
if (!routing.allowed) {
await postIssueComment(octokit, trigger, routing.message);
return { status: 202, body: { blocked: true, reason: routing.reason } };
}
trigger.pullRequestHeadBranch = pr.headBranch;
trigger.pullRequestHeadRepository = pr.headRepository;
trigger.pullRequestBaseBranch = pr.baseBranch;
}

const job = deps.store.createJob(trigger);
await postIssueComment(octokit, trigger, `Queued @${trigger.agent} job #${job.id}.`);
await postIssueComment(octokit, trigger, queuedMessage(job.id, trigger));
return { status: 202, body: { queued: true, jobId: job.id } };
}

Expand All @@ -52,3 +64,47 @@ export function parseAgent(value: string | null): AgentName | null {
if (value === "codex" || value === "claude" || value === "huma") return value;
return null;
}

export function fixedLaneBranch(agent: AgentName): string {
return `${agent}/workspace`;
}

export function evaluatePullRequestRouting(
trigger: TriggerRequest,
pr: PullRequestContext,
): { allowed: true } | { allowed: false; reason: string; message: string } {
const expectedBranch = fixedLaneBranch(trigger.agent);
if (pr.headRepository.toLowerCase() !== trigger.repository.toLowerCase()) {
return {
allowed: false,
reason: "fork head repository",
message: [
`Blocked @${trigger.agent} job: I only push fixes to branches in \`${trigger.repository}\`.`,
`This PR head is \`${pr.headRepository}:${pr.headBranch}\`, so I will not create a new branch or push to a fork.`,
].join("\n\n"),
};
}

if (pr.headBranch !== expectedBranch) {
return {
allowed: false,
reason: "unexpected head branch",
message: [
`Blocked @${trigger.agent} job: I only push fixes to the fixed \`${expectedBranch}\` lane branch for @${trigger.agent}.`,
`This PR head is \`${pr.headBranch}\`. I will not create a new branch or move work onto a different branch.`,
].join("\n\n"),
};
}

return { allowed: true };
}

function queuedMessage(jobId: number, trigger: TriggerRequest): string {
if (!trigger.isPullRequest) {
return `Queued @${trigger.agent} job #${jobId}.`;
}
return [
`Queued @${trigger.agent} job #${jobId}.`,
`Branch policy: fixes will use \`${trigger.pullRequestHeadBranch}\` directly. No new branch will be created.`,
].join("\n\n");
}
27 changes: 25 additions & 2 deletions src/store/job-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ interface JobRow {
body: string;
html_url: string;
installation_id: number;
pull_request_head_branch: string | null;
pull_request_head_repository: string | null;
pull_request_base_branch: string | null;
status: AgentJob["status"];
created_at: string;
updated_at: string;
Expand Down Expand Up @@ -44,6 +47,9 @@ export class JobStore {
body text not null,
html_url text not null,
installation_id integer not null,
pull_request_head_branch text,
pull_request_head_repository text,
pull_request_base_branch text,
status text not null default 'queued',
created_at text not null default (datetime('now')),
updated_at text not null default (datetime('now')),
Expand All @@ -53,14 +59,18 @@ export class JobStore {
);
create index if not exists jobs_agent_status_idx on jobs(agent, status, id);
`);
this.ensureColumn("pull_request_head_branch", "text");
this.ensureColumn("pull_request_head_repository", "text");
this.ensureColumn("pull_request_base_branch", "text");
}

createJob(trigger: TriggerRequest): AgentJob {
const stmt = this.db.prepare(`
insert into jobs (
agent, source, repository, owner, repo, actor, target_number, is_pull_request,
body, html_url, installation_id
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
body, html_url, installation_id, pull_request_head_branch,
pull_request_head_repository, pull_request_base_branch
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning *
`);
const row = asJobRow(stmt.get(
Expand All @@ -75,6 +85,9 @@ export class JobStore {
trigger.body,
trigger.htmlUrl,
trigger.installationId,
trigger.pullRequestHeadBranch ?? null,
trigger.pullRequestHeadRepository ?? null,
trigger.pullRequestBaseBranch ?? null,
));
return mapJob(row);
}
Expand Down Expand Up @@ -111,6 +124,13 @@ export class JobStore {
`).get(error, id));
return row ? mapJob(row) : null;
}

private ensureColumn(name: string, type: string): void {
const rows = this.db.prepare("pragma table_info(jobs)").all() as Array<{ name: string }>;
if (!rows.some((row) => row.name === name)) {
this.db.exec(`alter table jobs add column ${name} ${type}`);
}
}
}

function asJobRow(row: unknown): JobRow {
Expand All @@ -136,6 +156,9 @@ function mapJob(row: JobRow): AgentJob {
body: row.body,
htmlUrl: row.html_url,
installationId: row.installation_id,
pullRequestHeadBranch: row.pull_request_head_branch,
pullRequestHeadRepository: row.pull_request_head_repository,
pullRequestBaseBranch: row.pull_request_base_branch,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export interface TriggerRequest {
body: string;
htmlUrl: string;
installationId: number;
pullRequestHeadBranch?: string | null;
pullRequestHeadRepository?: string | null;
pullRequestBaseBranch?: string | null;
}

export interface AgentJob extends TriggerRequest {
Expand Down
15 changes: 15 additions & 0 deletions test/job-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ describe("JobStore", () => {
expect(store.claimNext("codex")).toBeNull();
expect(store.claimNext("claude")?.agent).toBe("claude");
});

it("preserves pull request branch context", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-router-"));
const store = new JobStore(path.join(dir, "jobs.sqlite"));
const created = store.createJob(trigger({
isPullRequest: true,
pullRequestHeadBranch: "codex/workspace",
pullRequestHeadRepository: "fschrhunt/AgentRouter",
pullRequestBaseBranch: "main",
}));

expect(created.pullRequestHeadBranch).toBe("codex/workspace");
expect(created.pullRequestHeadRepository).toBe("fschrhunt/AgentRouter");
expect(created.pullRequestBaseBranch).toBe("main");
});
});

function trigger(overrides: Partial<TriggerRequest> = {}): TriggerRequest {
Expand Down
62 changes: 61 additions & 1 deletion test/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { isRunnerAuthorized, parseAgent } from "../src/server/router.js";
import { evaluatePullRequestRouting, fixedLaneBranch, isRunnerAuthorized, parseAgent } from "../src/server/router.js";
import type { PullRequestContext } from "../src/github/client.js";
import type { TriggerRequest } from "../src/types.js";

describe("runner auth", () => {
it("accepts exact tokens", () => {
Expand All @@ -23,3 +25,61 @@ describe("parseAgent", () => {
expect(parseAgent("other")).toBeNull();
});
});

describe("fixed lane PR routing", () => {
it("maps agents to their fixed workspace branches", () => {
expect(fixedLaneBranch("codex")).toBe("codex/workspace");
expect(fixedLaneBranch("claude")).toBe("claude/workspace");
expect(fixedLaneBranch("huma")).toBe("huma/workspace");
});

it("allows pushing to the matching fixed branch in the same repo", () => {
expect(evaluatePullRequestRouting(trigger(), pr())).toEqual({ allowed: true });
});

it("blocks PRs from unexpected branches instead of creating a branch", () => {
const result = evaluatePullRequestRouting(trigger(), pr({ headBranch: "feature/random" }));

expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe("unexpected head branch");
expect(result.message).toContain("I will not create a new branch");
}
});

it("blocks fork PRs even when the branch name matches", () => {
const result = evaluatePullRequestRouting(trigger(), pr({ headRepository: "someone/AgentRouter" }));

expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe("fork head repository");
expect(result.message).toContain("push to a fork");
}
});
});

function trigger(overrides: Partial<TriggerRequest> = {}): TriggerRequest {
return {
agent: "codex",
source: "issue_comment",
repository: "fschrhunt/AgentRouter",
owner: "fschrhunt",
repo: "AgentRouter",
actor: "fschrhunt",
targetNumber: 1,
isPullRequest: true,
body: "@codex fix this",
htmlUrl: "https://github.com/fschrhunt/AgentRouter/pull/1#issuecomment-1",
installationId: 123,
...overrides,
};
}

function pr(overrides: Partial<PullRequestContext> = {}): PullRequestContext {
return {
headBranch: "codex/workspace",
headRepository: "fschrhunt/AgentRouter",
baseBranch: "main",
...overrides,
};
}
Loading