diff --git a/.env.example b/.env.example index c97e98f..55a3013 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ AGENT_ROUTER_WORKSPACE_ROOT=~/Projects AGENT_ROUTER_WEBHOOK_SECRET=change-me AGENT_ROUTER_RUNNER_TOKEN=change-me-too AGENT_ROUTER_SETUP_SECRET=change-me-three -AGENT_ROUTER_LOCAL_AGENT=main +AGENT_ROUTER_PULLY_EVENT_URL=http://127.0.0.1:17655/github-event +AGENT_ROUTER_HUMA_AGENT=main GITHUB_APP_ID= GITHUB_PRIVATE_KEY_B64= +AGENT_ROUTER_GITHUB_APPS_JSON= diff --git a/AGENTS.md b/AGENTS.md index c73c0d3..e9fb975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,20 +17,25 @@ pnpm runner --agent codex --execute --once - `src/server/` starts the HTTP webhook, setup callback, and runner API. - `src/github/` parses GitHub events, manifests, and checks actor permissions. - `src/store/` persists jobs in SQLite. -- `src/runner/` claims jobs for Codex, Claude, or a local OpenClaw-backed lane. +- `src/runner/` claims jobs for configured agent lanes. ## Project Rules -- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or the configured local third lane. +- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or `@SummonAgent huma`. - Raw `@codex`, `@claude`, and raw third-lane mentions are ignored because individual agents are not separate GitHub Apps. - Whoever opened the PR owns the fix. PR jobs must stay on the matching fixed lane branch: - `codex/workspace` - `claude/workspace` - - `local/workspace` + - `huma/workspace` +- Do not push follow-up work onto a branch after its PR is merged or closed unless that branch is still the active fixed workspace lane for new work. - Never act on bot-authored events. - Keep repository allowlisting enabled before wiring real agent execution. - Do not store GitHub private keys, webhook secrets, runner tokens, or agent API keys in git. +## Worktrees + +Fixed worktrees stay put: `main/`, `codex/`, `claude/`, and `huma/`. Agents work only in their assigned worktree on the fixed workspace branches: `codex/workspace`, `claude/workspace`, and `huma/workspace`. Do not create extra worktrees or topic branches for normal work. + ## Definition Of Done Changes are complete when `pnpm verify` passes and any GitHub App behavior has been tested with a signed webhook fixture or a live test repository. diff --git a/README.md b/README.md index 31dbc3e..528b5bc 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # AgentRouter -AgentRouter is a GitHub App router for explicit SummonAgent commands across personal and organization repositories. It lets one installed app listen across selected repos and queue work only when someone deliberately calls an agent lane. +AgentRouter is a GitHub App router for explicit SummonAgent commands across personal and organization repositories. It lets installed private apps listen across their accounts and queue work only when someone deliberately calls a configured lane. It listens for GitHub comments and reviews, validates that the sender is allowed to dispatch work, and creates jobs from commands like: - `@SummonAgent codex fix this` - `@SummonAgent claude review this` -- `@SummonAgent local handle this` +- `@SummonAgent huma handle this` The first version is intentionally conservative: it stores jobs locally, exposes a small runner API, and only dispatches when `@SummonAgent` is mentioned with a supported lane name. No ambient code review, no surprise bot activity, no repo-by-repo workflow file required. @@ -24,13 +24,13 @@ Set the required values in `.env`, then start the router: pnpm dev ``` -In another terminal, run a local worker lane: +In another terminal, run a worker lane: ```bash pnpm runner --agent codex ``` -By default the runner previews the next queued job without claiming it. Add `--execute` to claim the job and start the local agent. +By default the runner previews the next queued job without claiming it. Add `--execute` to claim the job and start the selected agent. ## GitHub App Setup @@ -70,16 +70,18 @@ pnpm verify # typecheck and test ## Configuration -Copy `.env.example` to `.env` and set: +For the background service, keep private runtime values in `~/.agentrouter/.env`. For one-off development, a repo-local `.env` also works. Set: - `AGENT_ROUTER_WEBHOOK_SECRET` - `AGENT_ROUTER_RUNNER_TOKEN` - `AGENT_ROUTER_SETUP_SECRET` - `GITHUB_APP_ID` - `GITHUB_PRIVATE_KEY_B64` -- `AGENT_ROUTER_LOCAL_AGENT` optionally selects the OpenClaw agent id for the local third lane. It defaults to `main`. +- `AGENT_ROUTER_PULLY_EVENT_URL` points at Pully's local event listener. The default is `http://127.0.0.1:17655/github-event`. +- `AGENT_ROUTER_HUMA_AGENT` optionally selects the OpenClaw agent id for the Huma lane. It defaults to `main`. +- `AGENT_ROUTER_GITHUB_APPS_JSON` optionally configures multiple private GitHub Apps for one router. -`GITHUB_PRIVATE_KEY_B64` should be the GitHub App private key PEM encoded as base64. +`GITHUB_PRIVATE_KEY_B64` should be the GitHub App private key PEM encoded as base64. For private apps across multiple accounts, set `AGENT_ROUTER_GITHUB_APPS_JSON` to an array of app credentials; each app stays private to its owning account while AgentRouter serves them together. Use `agent-router.config.json` to restrict which repositories can dispatch jobs. Start from: @@ -87,7 +89,7 @@ Use `agent-router.config.json` to restrict which repositories can dispatch jobs. cp agent-router.config.example.json agent-router.config.json ``` -The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repositories`: +The runner resolves jobs to fixed worktrees from `workspaceRoot` and `repositories`: ```json { @@ -99,7 +101,7 @@ The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repo "laneBranches": { "codex": "codex/workspace", "claude": "claude/workspace", - "local": "local/workspace" + "huma": "huma/workspace" } } ``` @@ -108,9 +110,9 @@ For a `@SummonAgent codex` PR job on `OWNER/REPOSITORY`, that maps to `~/Project ## Safety Rules -- No agent runs unless a comment or review contains `@SummonAgent` plus `codex`, `claude`, or `local`. -- Raw `@codex`, `@claude`, and `@local` mentions are ignored. -- Codex, Claude, and the local third lane are runner lanes behind this one GitHub App. They are not separate GitHub Apps. +- No agent runs unless a comment or review contains `@SummonAgent` plus `codex`, `claude`, or `huma`. +- Raw `@codex`, `@claude`, and `@huma` mentions are ignored. +- Codex, Claude, and Huma are runner lanes behind SummonAgent. They are not separate GitHub Apps. - Bot users are ignored. - Senders must have `write`, `maintain`, or `admin` repository permission. - Repositories can be allowlisted in `agent-router.config.json`. @@ -118,7 +120,7 @@ For a `@SummonAgent codex` PR job on `OWNER/REPOSITORY`, that maps to `~/Project - PR fix jobs only run on fixed lane branches: - `@SummonAgent codex` -> `codex/workspace` - `@SummonAgent claude` -> `claude/workspace` - - `@SummonAgent local` -> `local/workspace` + - `@SummonAgent huma` -> `huma/workspace` - Deployment-specific branch names can be set in `laneBranches`. - AgentRouter blocks fork PRs and unexpected PR branches instead of creating new branches. @@ -135,7 +137,30 @@ When a valid mention is accepted, AgentRouter stores a queued job. Runners claim 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. -The third lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_LOCAL_AGENT:-main} --message ...`. +The Huma lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_HUMA_AGENT:-main} --message ...`. + +## Pully Feed + +Pully can read open PRs from AgentRouter instead of searching GitHub directly: + +```bash +curl -H "Authorization: Bearer $AGENT_ROUTER_RUNNER_TOKEN" \ + http://127.0.0.1:8787/pully/pull-requests +``` + +The feed discovers repositories from the installed GitHub Apps, so new repos appear automatically after the relevant app is installed on that account. If installation discovery fails, AgentRouter falls back to `agent-router.config.json` repositories and reports the discovery error in `failures`. + +AgentRouter also pings Pully's local event listener when GitHub sends PR/comment/review webhooks. That replaces the old per-repo Pully workflow path for live menu bar updates. + +## Launch Agent + +After changes are merged into `main` and `~/.agentrouter/.env` exists, install the login service from the `main/` worktree: + +```bash +scripts/install_launch_agent.sh +``` + +The LaunchAgent runs from `/Volumes/Thorium/Projects/AgentRouter/main`, not an agent worktree. ## Development diff --git a/agent-router.config.example.json b/agent-router.config.example.json index c085a73..759a859 100644 --- a/agent-router.config.example.json +++ b/agent-router.config.example.json @@ -11,6 +11,6 @@ "laneBranches": { "codex": "codex/workspace", "claude": "claude/workspace", - "local": "local/workspace" + "huma": "huma/workspace" } } diff --git a/package.json b/package.json index 116c51d..e10f321 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "GitHub App router for @SummonAgent codex, claude, and local commands.", + "description": "GitHub App router for explicit @SummonAgent commands.", "scripts": { "dev": "tsx src/server/main.ts", "runner": "tsx src/runner/main.ts", diff --git a/scripts/install_launch_agent.sh b/scripts/install_launch_agent.sh new file mode 100755 index 0000000..6ddc3b9 --- /dev/null +++ b/scripts/install_launch_agent.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +LABEL="co.fischer.agentrouter" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +PROJECT_DIR="/Volumes/Thorium/Projects/AgentRouter/main" +PNPM_BIN="${PNPM_BIN:-/opt/homebrew/bin/pnpm}" +LOG_DIR="$HOME/Library/Logs/AgentRouter" + +if [ ! -x "$PNPM_BIN" ]; then + echo "pnpm not found at $PNPM_BIN" >&2 + exit 1 +fi + +if [ ! -d "$PROJECT_DIR" ]; then + echo "missing AgentRouter main worktree: $PROJECT_DIR" >&2 + exit 1 +fi + +if [ ! -f "$HOME/.agentrouter/.env" ]; then + echo "missing $HOME/.agentrouter/.env" >&2 + echo "Create it from the private runtime values before installing the LaunchAgent." >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" + +cat > "$PLIST" < + + + + Label + $LABEL + ProgramArguments + + $PNPM_BIN + dev + + WorkingDirectory + $PROJECT_DIR + RunAtLoad + + KeepAlive + + StandardOutPath + $LOG_DIR/stdout.log + StandardErrorPath + $LOG_DIR/stderr.log + + +PLIST + +chmod 644 "$PLIST" + +launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true +launchctl bootstrap "gui/$(id -u)" "$PLIST" +launchctl kickstart -k "gui/$(id -u)/$LABEL" +launchctl print "gui/$(id -u)/$LABEL" >/dev/null + +echo "installed: $PLIST" diff --git a/src/config.ts b/src/config.ts index 381e19b..520bb80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,10 @@ import fs from "node:fs"; import path from "node:path"; +import dotenv from "dotenv"; import { z } from "zod"; -import "dotenv/config"; + +dotenv.config({ path: path.join(process.env.HOME ?? "", ".agentrouter/.env"), override: false, quiet: true }); +dotenv.config({ override: false, quiet: true }); const envSchema = z.object({ AGENT_ROUTER_PORT: z.coerce.number().int().positive().default(8787), @@ -11,10 +14,19 @@ const envSchema = z.object({ AGENT_ROUTER_WEBHOOK_SECRET: z.string().min(1), AGENT_ROUTER_RUNNER_TOKEN: z.string().min(1), AGENT_ROUTER_SETUP_SECRET: z.string().min(1).optional(), + AGENT_ROUTER_PULLY_EVENT_URL: z.string().url().default("http://127.0.0.1:17655/github-event"), + AGENT_ROUTER_GITHUB_APPS_JSON: z.string().optional(), GITHUB_APP_ID: z.string().optional(), GITHUB_PRIVATE_KEY_B64: z.string().optional(), }); +const githubAppCredentialSchema = z.object({ + name: z.string().min(1).default("default"), + appId: z.string().min(1), + privateKeyB64: z.string().min(1), + webhookSecret: z.string().min(1), +}); + const fileConfigSchema = z.object({ allowedRepositories: z.array(z.string()).default([]), workspaceRoot: z.string().optional(), @@ -30,13 +42,22 @@ export interface AppConfig { webhookSecret: string; runnerToken: string; setupSecret: string; + pullyEventUrl: string; githubAppId?: string; githubPrivateKey?: string; + githubApps: GitHubAppCredential[]; allowedRepositories: Set; repositories: Map; laneBranches: Map; } +export interface GitHubAppCredential { + name: string; + appId: string; + privateKey: string; + webhookSecret: string; +} + export function loadConfig(cwd = process.cwd()): AppConfig { const env = envSchema.parse(process.env); const configPath = path.join(cwd, "agent-router.config.json"); @@ -52,10 +73,12 @@ export function loadConfig(cwd = process.cwd()): AppConfig { webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET, runnerToken: env.AGENT_ROUTER_RUNNER_TOKEN, setupSecret: env.AGENT_ROUTER_SETUP_SECRET ?? env.AGENT_ROUTER_RUNNER_TOKEN, + pullyEventUrl: env.AGENT_ROUTER_PULLY_EVENT_URL, githubAppId: env.GITHUB_APP_ID, githubPrivateKey: env.GITHUB_PRIVATE_KEY_B64 ? Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8") : undefined, + githubApps: loadGitHubApps(env), allowedRepositories: new Set(fileConfig.allowedRepositories.map((repo) => repo.toLowerCase())), repositories: new Map(Object.entries(fileConfig.repositories).map(([repo, localName]) => [repo.toLowerCase(), localName])), laneBranches: new Map(Object.entries(fileConfig.laneBranches).map(([lane, branch]) => [lane.toLowerCase(), branch])), @@ -72,3 +95,34 @@ function expandHome(value: string): string { if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2)); return value; } + +function loadGitHubApps(env: z.infer): GitHubAppCredential[] { + const apps: GitHubAppCredential[] = []; + + if (env.AGENT_ROUTER_GITHUB_APPS_JSON) { + const parsed = z.array(githubAppCredentialSchema).parse(JSON.parse(env.AGENT_ROUTER_GITHUB_APPS_JSON)); + apps.push(...parsed.map((app) => ({ + name: app.name, + appId: app.appId, + privateKey: Buffer.from(app.privateKeyB64, "base64").toString("utf8"), + webhookSecret: app.webhookSecret, + }))); + } + + if (env.GITHUB_APP_ID && env.GITHUB_PRIVATE_KEY_B64) { + apps.push({ + name: "default", + appId: env.GITHUB_APP_ID, + privateKey: Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8"), + webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET, + }); + } + + const seen = new Set(); + return apps.filter((app) => { + const key = app.appId; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/github/client.ts b/src/github/client.ts index 38afce2..d91337f 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -1,6 +1,7 @@ import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; -import type { AppConfig } from "../config.js"; +import { verify } from "@octokit/webhooks-methods"; +import type { AppConfig, GitHubAppCredential } from "../config.js"; import type { TriggerRequest } from "../types.js"; export interface PullRequestContext { @@ -10,20 +11,140 @@ export interface PullRequestContext { } export class GitHubClientFactory { + private installationIdsByRepository = new Map(); + private appNamesByInstallationId = new Map(); + constructor(private config: AppConfig) {} + async verifyWebhookSignature(signature: string, rawBody: string): Promise { + for (const app of this.appCredentials()) { + if (await verify(app.webhookSecret, rawBody, signature)) { + return true; + } + } + + return false; + } + + async forRepository(repository: string): Promise { + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + throw new Error(`invalid repository: ${repository}`); + } + + const key = repository.toLowerCase(); + let cached = this.installationIdsByRepository.get(key); + if (!cached) { + let lastError: unknown; + for (const credential of this.appCredentials()) { + try { + const app = await this.forApp(credential); + const response = await app.request("GET /repos/{owner}/{repo}/installation", { + owner, + repo, + }); + cached = { appName: credential.name, installationId: response.data.id }; + this.installationIdsByRepository.set(key, cached); + this.appNamesByInstallationId.set(cached.installationId, cached.appName); + break; + } catch (error) { + lastError = error; + } + } + if (!cached) { + throw lastError instanceof Error ? lastError : new Error(`no GitHub App installation found for ${repository}`); + } + } + + return this.forInstallation(cached.installationId); + } + async forInstallation(installationId: number): Promise { - if (!this.config.githubAppId || !this.config.githubPrivateKey) { - throw new Error("GitHub App credentials are not configured"); + const cachedAppName = this.appNamesByInstallationId.get(installationId); + const candidates = cachedAppName + ? this.appCredentials().filter((app) => app.name === cachedAppName) + : this.appCredentials(); + + let lastError: unknown; + for (const credential of candidates) { + try { + const auth = createAppAuth({ + appId: credential.appId, + privateKey: credential.privateKey, + installationId, + }); + const installationAuth = await auth({ type: "installation" }); + this.appNamesByInstallationId.set(installationId, credential.name); + return new Octokit({ auth: installationAuth.token }); + } catch (error) { + lastError = error; + } + } + + throw lastError instanceof Error ? lastError : new Error(`GitHub App installation ${installationId} is not configured`); + } + + async listInstalledRepositories(): Promise { + const repositories = new Set(); + + for (const credential of this.appCredentials()) { + const app = await this.forApp(credential); + const installations = await app.paginate(app.apps.listInstallations, { + per_page: 100, + }); + + for (const installation of installations) { + this.appNamesByInstallationId.set(installation.id, credential.name); + const octokit = await this.forInstallationWithCredential(credential, installation.id); + const accessibleRepositories = await octokit.paginate(octokit.apps.listReposAccessibleToInstallation, { + per_page: 100, + }); + + for (const repository of accessibleRepositories) { + if (typeof repository.full_name !== "string") continue; + const fullName = repository.full_name.toLowerCase(); + repositories.add(fullName); + this.installationIdsByRepository.set(fullName, { + appName: credential.name, + installationId: installation.id, + }); + } + } } + + return Array.from(repositories).sort(); + } + + private async forApp(credential: GitHubAppCredential): Promise { + const auth = createAppAuth({ + appId: credential.appId, + privateKey: credential.privateKey, + }); + const appAuth = await auth({ type: "app" }); + return new Octokit({ auth: appAuth.token }); + } + + private async forInstallationWithCredential( + credential: GitHubAppCredential, + installationId: number, + ): Promise { const auth = createAppAuth({ - appId: this.config.githubAppId, - privateKey: this.config.githubPrivateKey, + appId: credential.appId, + privateKey: credential.privateKey, installationId, }); const installationAuth = await auth({ type: "installation" }); + this.appNamesByInstallationId.set(installationId, credential.name); return new Octokit({ auth: installationAuth.token }); } + + private appCredentials(): GitHubAppCredential[] { + if (this.config.githubApps.length === 0) { + throw new Error("GitHub App credentials are not configured"); + } + + return this.config.githubApps; + } } export async function actorCanTrigger(octokit: Octokit, trigger: TriggerRequest): Promise { diff --git a/src/github/events.ts b/src/github/events.ts index 5301707..de00ef4 100644 --- a/src/github/events.ts +++ b/src/github/events.ts @@ -6,7 +6,7 @@ const summonMention = /\B@summonagent\b/i; const commandPatterns: Array<[AgentName, RegExp]> = [ ["codex", /\bcodex\b/i], ["claude", /\bclaude\b/i], - ["local", /\b(?:local|openclaw)\b/i], + ["huma", /\b(?:huma|openclaw)\b/i], ]; export function detectAgentMention(body: string): AgentName | null { diff --git a/src/github/manifest.ts b/src/github/manifest.ts index ab6aee9..a7cf13d 100644 --- a/src/github/manifest.ts +++ b/src/github/manifest.ts @@ -29,7 +29,7 @@ export function buildManifest(config: AppConfig, params: ManifestParams): Record redirect_url: `${config.publicUrl}/setup/callback`, callback_urls: [`${config.publicUrl}/setup/callback`], setup_url: `${config.publicUrl}/setup/done`, - description: "Routes explicit @SummonAgent codex/claude/local commands to local agent lanes.", + description: "Routes explicit @SummonAgent commands to configured agent lanes.", public: false, default_permissions: { contents: "read", @@ -40,6 +40,7 @@ export function buildManifest(config: AppConfig, params: ManifestParams): Record default_events: [ "issues", "issue_comment", + "pull_request", "pull_request_review", "pull_request_review_comment", ], diff --git a/src/pully/feed.ts b/src/pully/feed.ts new file mode 100644 index 0000000..000ac01 --- /dev/null +++ b/src/pully/feed.ts @@ -0,0 +1,131 @@ +import type { AppConfig } from "../config.js"; + +export interface PullyGitHubClientFactory { + forRepository(repository: string): Promise; + listInstalledRepositories(): Promise; +} + +export interface PullyPullRequest { + id: string; + repo: string; + number: number; + title: string; + author: string; + headRefName: string | null; + url: string; + updatedAt: string; + isDraft: boolean; + mergeStateStatus: string; + checkState: "success" | "pending" | "failure" | "unknown"; + lifecycleState: "open" | "closed" | "merged"; + closedAt: string | null; +} + +export interface PullyPullRequestFeed { + pullRequests: PullyPullRequest[]; + failures: Array<{ repository: string; error: string }>; +} + +export async function listPullyOpenPullRequests( + config: AppConfig, + githubFactory: PullyGitHubClientFactory, +): Promise { + let repositories = pullyRepositories(config); + const failures: PullyPullRequestFeed["failures"] = []; + + try { + repositories = pullyRepositories(config, await githubFactory.listInstalledRepositories()); + } catch (error) { + failures.push({ + repository: "github-app-installations", + error: error instanceof Error ? error.message : String(error), + }); + } + + const results = await Promise.allSettled(repositories.map(async (repository) => { + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + throw new Error(`invalid repository: ${repository}`); + } + + const octokit = await githubFactory.forRepository(repository); + const pulls = await octokit.paginate(octokit.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }); + + const hydrated = await Promise.all(pulls.map(async (pull: any) => { + const response = await octokit.pulls.get({ + owner, + repo, + pull_number: pull.number, + }); + return mapPullRequestForPully(repository, response.data); + })); + + return hydrated.filter((pullRequest) => pullRequest.lifecycleState === "open"); + })); + + const pullRequests: PullyPullRequest[] = []; + + for (let index = 0; index < results.length; index += 1) { + const repository = repositories[index] ?? "unknown"; + const result = results[index]; + if (!result) continue; + + if (result.status === "fulfilled") { + pullRequests.push(...result.value); + } else { + failures.push({ + repository, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + + pullRequests.sort((lhs, rhs) => rhs.updatedAt.localeCompare(lhs.updatedAt)); + return { pullRequests, failures }; +} + +export function pullyRepositories(config: AppConfig, installedRepositories: string[] = []): string[] { + if (installedRepositories.length > 0) { + return Array.from(new Set(installedRepositories.map((repository) => repository.trim().toLowerCase()).filter(Boolean))); + } + + const configured = Array.from(config.repositories.keys()); + const allowed = Array.from(config.allowedRepositories.values()); + const source = configured.length > 0 ? configured : allowed; + return Array.from(new Set(source.map((repository) => repository.trim()).filter(Boolean))); +} + +export function mapPullRequestForPully(repository: string, pull: any): PullyPullRequest { + const state = String(pull.state ?? "open").toLowerCase(); + const mergedAt = typeof pull.merged_at === "string" ? pull.merged_at : null; + const closedAt = typeof pull.closed_at === "string" ? pull.closed_at : null; + + return { + id: `${repository}#${pull.number}`, + repo: repository, + number: Number(pull.number), + title: String(pull.title ?? ""), + author: String(pull.user?.login ?? "unknown"), + headRefName: typeof pull.head?.ref === "string" ? pull.head.ref : null, + url: String(pull.html_url ?? ""), + updatedAt: String(pull.updated_at ?? ""), + isDraft: Boolean(pull.draft), + mergeStateStatus: normalizeMergeState(pull.mergeable_state), + checkState: "unknown", + lifecycleState: mergedAt ? "merged" : state === "closed" ? "closed" : "open", + closedAt, + }; +} + +function normalizeMergeState(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") { + return "UNKNOWN"; + } + + return value.toUpperCase(); +} diff --git a/src/runner/main.ts b/src/runner/main.ts index 2736128..dbf7562 100644 --- a/src/runner/main.ts +++ b/src/runner/main.ts @@ -7,16 +7,16 @@ import { buildAgentPrompt } from "./prompt.js"; import { resolveWorktreeTarget, type WorktreeTarget } from "./workspace.js"; const program = new Command() - .requiredOption("--agent ", "agent lane: codex, claude, or local") + .requiredOption("--agent ", "agent lane: codex, claude, or huma") .option("--server ", "AgentRouter server URL", "http://localhost:8787") .option("--once", "claim one job and exit", false) - .option("--execute", "execute the local agent instead of only previewing the next job", false); + .option("--execute", "execute the agent instead of only previewing the next job", false); program.parse(); const options = program.opts<{ agent: AgentName; server: string; once: boolean; execute: boolean }>(); -if (!["codex", "claude", "local"].includes(options.agent)) { - throw new Error("agent must be codex, claude, or local"); +if (!["codex", "claude", "huma"].includes(options.agent)) { + throw new Error("agent must be codex, claude, or huma"); } const config = loadConfig(); @@ -159,11 +159,11 @@ function runAgent(agent: AgentName, target: WorktreeTarget, prompt: string): voi return; } - const localAgent = process.env.AGENT_ROUTER_LOCAL_AGENT ?? "main"; + const humaAgent = process.env.AGENT_ROUTER_HUMA_AGENT ?? process.env.AGENT_ROUTER_LOCAL_AGENT ?? "main"; runStreaming("openclaw", [ "agent", "--agent", - localAgent, + humaAgent, "--message", prompt, ], target.worktreeDir); diff --git a/src/server/main.ts b/src/server/main.ts index 901d42b..47c9980 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -1,6 +1,7 @@ import http from "node:http"; import { loadConfig } from "../config.js"; import { GitHubClientFactory } from "../github/client.js"; +import { listPullyOpenPullRequests } from "../pully/feed.js"; import { JobStore } from "../store/job-store.js"; import { handleWebhook, isRunnerAuthorized, parseAgent } from "./router.js"; import { handleSetupCallback, renderSetupPage } from "./setup.js"; @@ -32,12 +33,18 @@ const server = http.createServer(async (req, res) => { if (req.method === "POST" && url.pathname === "/webhooks/github") { const rawBody = await readBody(req); + const eventName = String(req.headers["x-github-event"] ?? ""); const result = await handleWebhook( { config, store, githubFactory }, - String(req.headers["x-github-event"] ?? ""), + eventName, req.headers["x-hub-signature-256"] as string | undefined, rawBody, ); + if (result.status !== 401 && shouldNotifyPully(eventName)) { + notifyPully(config.pullyEventUrl, eventName).catch((error: unknown) => { + console.warn(`Pully event ping failed: ${error instanceof Error ? error.message : String(error)}`); + }); + } return json(res, result.status, result.body); } @@ -59,6 +66,14 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { job: store.peekNext(agent) }); } + if (req.method === "GET" && url.pathname === "/pully/pull-requests") { + if (!isRunnerAuthorized(config.runnerToken, req.headers.authorization?.replace(/^Bearer\s+/i, ""))) { + return json(res, 401, { error: "unauthorized" }); + } + const feed = await listPullyOpenPullRequests(config, githubFactory); + return json(res, 200, feed); + } + if (req.method === "POST" && url.pathname.startsWith("/runner/jobs/")) { if (!isRunnerAuthorized(config.runnerToken, req.headers.authorization?.replace(/^Bearer\s+/i, ""))) { return json(res, 401, { error: "unauthorized" }); @@ -83,6 +98,31 @@ server.listen(config.port, () => { console.log(`AgentRouter listening on http://localhost:${config.port}`); }); +function shouldNotifyPully(eventName: string): boolean { + return [ + "pull_request", + "issues", + "issue_comment", + "pull_request_review", + "pull_request_review_comment", + ].includes(eventName); +} + +async function notifyPully(url: string, eventName: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: eventName, source: "AgentRouter" }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + function json(res: http.ServerResponse, status: number, body: unknown): void { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); diff --git a/src/server/router.ts b/src/server/router.ts index bc0c893..c9725e6 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,5 +1,4 @@ import { timingSafeEqual } from "node:crypto"; -import { verify } from "@octokit/webhooks-methods"; import { isRepositoryAllowed, type AppConfig } from "../config.js"; import { actorCanTrigger, getPullRequestContext, GitHubClientFactory, postIssueComment, type PullRequestContext } from "../github/client.js"; import { normalizeGitHubEvent } from "../github/events.js"; @@ -18,7 +17,7 @@ export async function handleWebhook( signature: string | undefined, rawBody: string, ): Promise<{ status: number; body: unknown }> { - if (!signature || !(await verify(deps.config.webhookSecret, rawBody, signature))) { + if (!signature || !(await deps.githubFactory.verifyWebhookSignature(signature, rawBody))) { return { status: 401, body: { error: "invalid signature" } }; } @@ -61,7 +60,7 @@ export function isRunnerAuthorized(expected: string, provided: string | undefine } export function parseAgent(value: string | null): AgentName | null { - if (value === "codex" || value === "claude" || value === "local") return value; + if (value === "codex" || value === "claude" || value === "huma") return value; return null; } diff --git a/src/types.ts b/src/types.ts index 07a578f..d66be02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type AgentName = "codex" | "claude" | "local"; +export type AgentName = "codex" | "claude" | "huma"; export type TriggerSource = | "issue_comment" diff --git a/test/events.test.ts b/test/events.test.ts index cf4bf96..8859766 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -5,13 +5,13 @@ describe("detectAgentMention", () => { it("detects supported agent commands after SummonAgent is mentioned", () => { expect(detectAgentMention("@SummonAgent codex fix this")).toBe("codex"); expect(detectAgentMention("@summonagent claude review this")).toBe("claude"); - expect(detectAgentMention("route to @SummonAgent local")).toBe("local"); + expect(detectAgentMention("route to @SummonAgent huma")).toBe("huma"); }); it("does not trigger on raw agent mentions", () => { expect(detectAgentMention("please @codex fix this")).toBeNull(); expect(detectAgentMention("@claude review this")).toBeNull(); - expect(detectAgentMention("route to @local")).toBeNull(); + expect(detectAgentMention("route to @huma")).toBeNull(); }); it("ignores partial words and missing lane commands", () => { diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 8f68d9d..e259c49 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -10,6 +10,8 @@ const config: AppConfig = { webhookSecret: "webhook", runnerToken: "runner", setupSecret: "setup", + pullyEventUrl: "http://127.0.0.1:17655/github-event", + githubApps: [], allowedRepositories: new Set(), repositories: new Map(), laneBranches: new Map(), @@ -36,6 +38,7 @@ describe("GitHub App manifest", () => { default_events: [ "issues", "issue_comment", + "pull_request", "pull_request_review", "pull_request_review_comment", ], diff --git a/test/pully-feed.test.ts b/test/pully-feed.test.ts new file mode 100644 index 0000000..e0c2d6b --- /dev/null +++ b/test/pully-feed.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { listPullyOpenPullRequests, mapPullRequestForPully, pullyRepositories } from "../src/pully/feed.js"; +import type { AppConfig } from "../src/config.js"; + +describe("Pully feed", () => { + it("uses configured repositories as the Pully feed scope", () => { + const config = appConfig({ + repositories: new Map([ + ["fschrhunt/pully", "Pully"], + ["huntdynamics/halos", "Halos"], + ]), + allowedRepositories: new Set(["fschrhunt/ignored"]), + }); + + expect(pullyRepositories(config)).toEqual(["fschrhunt/pully", "huntdynamics/halos"]); + }); + + it("prefers installed GitHub App repositories so new repos appear automatically", () => { + const config = appConfig({ + repositories: new Map([ + ["fschrhunt/pully", "Pully"], + ]), + }); + + expect(pullyRepositories(config, ["HuntDynamics/Halos", "fschrhunt/NewRepo"])).toEqual([ + "huntdynamics/halos", + "fschrhunt/newrepo", + ]); + }); + + it("lists open pull requests from installed repositories", async () => { + const githubFactory = { + listInstalledRepositories: async () => ["fschrhunt/pully"], + forRepository: async () => ({ + pulls: { + list: "pulls.list", + get: async () => ({ + data: { + number: 12, + title: "Polish Pully", + user: { login: "codex" }, + head: { ref: "codex/workspace" }, + html_url: "https://github.com/fschrhunt/Pully/pull/12", + updated_at: "2026-04-29T01:00:00Z", + draft: false, + mergeable_state: "clean", + state: "open", + closed_at: null, + merged_at: null, + }, + }), + }, + paginate: async () => [ + { number: 12 }, + ], + }), + }; + + const feed = await listPullyOpenPullRequests(appConfig(), githubFactory); + + expect(feed.failures).toEqual([]); + expect(feed.pullRequests).toHaveLength(1); + expect(feed.pullRequests[0]).toMatchObject({ + id: "fschrhunt/pully#12", + repo: "fschrhunt/pully", + lifecycleState: "open", + }); + }); + + it("falls back to configured repositories when installation discovery fails", async () => { + const githubFactory = { + listInstalledRepositories: async () => { + throw new Error("installations unavailable"); + }, + forRepository: async () => ({ + pulls: { + list: "pulls.list", + get: async () => ({ + data: { + number: 4, + title: "Fallback", + user: { login: "claude" }, + head: { ref: "claude/workspace" }, + html_url: "https://github.com/fschrhunt/Pully/pull/4", + updated_at: "2026-04-29T01:00:00Z", + draft: false, + mergeable_state: "clean", + state: "open", + closed_at: null, + merged_at: null, + }, + }), + }, + paginate: async () => [ + { number: 4 }, + ], + }), + }; + + const feed = await listPullyOpenPullRequests( + appConfig({ repositories: new Map([["fschrhunt/pully", "Pully"]]) }), + githubFactory, + ); + + expect(feed.failures).toEqual([ + { repository: "github-app-installations", error: "installations unavailable" }, + ]); + expect(feed.pullRequests.map((pullRequest) => pullRequest.repo)).toEqual(["fschrhunt/pully"]); + }); + + it("maps open pull requests into Pully's model", () => { + const pullRequest = mapPullRequestForPully("fschrhunt/pully", { + number: 12, + title: "Polish Pully", + user: { login: "codex" }, + head: { ref: "codex/workspace" }, + html_url: "https://github.com/fschrhunt/Pully/pull/12", + updated_at: "2026-04-29T01:00:00Z", + draft: false, + mergeable_state: "clean", + state: "open", + closed_at: null, + merged_at: null, + }); + + expect(pullRequest).toMatchObject({ + id: "fschrhunt/pully#12", + repo: "fschrhunt/pully", + number: 12, + author: "codex", + headRefName: "codex/workspace", + mergeStateStatus: "CLEAN", + checkState: "unknown", + lifecycleState: "open", + }); + }); + + it("treats merged pull requests as non-open even when the source state is closed", () => { + const pullRequest = mapPullRequestForPully("fschrhunt/pully", { + number: 9, + title: "Merged", + user: { login: "codex" }, + head: { ref: "codex/workspace" }, + html_url: "https://github.com/fschrhunt/Pully/pull/9", + updated_at: "2026-04-29T01:00:00Z", + draft: false, + mergeable_state: "unknown", + state: "closed", + closed_at: "2026-04-29T01:05:31Z", + merged_at: "2026-04-29T01:05:31Z", + }); + + expect(pullRequest.lifecycleState).toBe("merged"); + expect(pullRequest.closedAt).toBe("2026-04-29T01:05:31Z"); + }); +}); + +function appConfig(overrides: Partial = {}): AppConfig { + return { + port: 8787, + publicUrl: "http://localhost:8787", + databasePath: ":memory:", + workspaceRoot: "/Projects", + webhookSecret: "webhook", + runnerToken: "runner", + setupSecret: "setup", + pullyEventUrl: "http://127.0.0.1:17655/github-event", + githubApps: [], + allowedRepositories: new Set(), + repositories: new Map(), + laneBranches: new Map(), + ...overrides, + }; +} diff --git a/test/router.test.ts b/test/router.test.ts index 3bdbc49..cdd3cf2 100644 --- a/test/router.test.ts +++ b/test/router.test.ts @@ -19,7 +19,7 @@ describe("parseAgent", () => { it("accepts supported agents", () => { expect(parseAgent("codex")).toBe("codex"); expect(parseAgent("claude")).toBe("claude"); - expect(parseAgent("local")).toBe("local"); + expect(parseAgent("huma")).toBe("huma"); }); it("rejects unknown agents", () => { @@ -31,14 +31,14 @@ 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("local")).toBe("local/workspace"); + expect(fixedLaneBranch("huma")).toBe("huma/workspace"); }); it("allows deployment-specific lane branch overrides", () => { - const config = appConfig({ laneBranches: new Map([["local", "custom/workspace"]]) }); + const config = appConfig({ laneBranches: new Map([["huma", "custom/workspace"]]) }); - expect(fixedLaneBranch("local", config)).toBe("custom/workspace"); - expect(evaluatePullRequestRouting(trigger({ agent: "local" }), pr({ headBranch: "custom/workspace" }), config)).toEqual({ allowed: true }); + expect(fixedLaneBranch("huma", config)).toBe("custom/workspace"); + expect(evaluatePullRequestRouting(trigger({ agent: "huma" }), pr({ headBranch: "custom/workspace" }), config)).toEqual({ allowed: true }); }); it("allows pushing to the matching fixed branch in the same repo", () => { @@ -92,6 +92,8 @@ function appConfig(overrides: Partial = {}): AppConfig { webhookSecret: "webhook", runnerToken: "runner", setupSecret: "setup", + pullyEventUrl: "http://127.0.0.1:17655/github-event", + githubApps: [], allowedRepositories: new Set(), repositories: new Map(), laneBranches: new Map(), diff --git a/test/runner.test.ts b/test/runner.test.ts index 4d7b55e..e78ea2c 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -42,6 +42,8 @@ function config(): AppConfig { webhookSecret: "webhook", runnerToken: "runner", setupSecret: "setup", + pullyEventUrl: "http://127.0.0.1:17655/github-event", + githubApps: [], allowedRepositories: new Set(), repositories: new Map([["owner/repository", "Repository"]]), laneBranches: new Map(),