Skip to content
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ EasyGithub is an AI software-development pipeline: a GitHub Issue tagged `ai` is

## Layout

- `agent-runner/` — TypeScript runner. No runtime dependencies; uses Node built-ins plus the `gh`, `git`, and `pi`/`claude`/`codex` CLIs. Run with Node >= 22.19 (native type stripping). Entry point: `agent-runner/src/main.ts`; cross-repo dispatcher: `agent-runner/src/dispatcher.ts`. Guardrails: `src/verify.ts` (frozen gate), `src/policy.ts` (path/secret audit), `src/risk.ts` (risk tiers + auto-merge), `src/profile.ts` (per-repo profile), `src/env.ts` (agent env allow-list). Support tools: `src/eval.ts` (frozen tasks), `src/retro.ts` (weekly report), `src/watch.ts` (cross-repo post-merge watchdog), `src/metrics.ts`, `src/learnings.ts`.
- `agent-runner/` — TypeScript runner. No runtime dependencies; uses Node built-ins plus the `gh`, `git`, and `pi`/`claude`/`codex` CLIs. Run with Node >= 22.19 (native type stripping). Entry point: `agent-runner/src/main.ts`; cross-repo dispatcher: `agent-runner/src/dispatcher.ts`. Guardrails: `src/verify.ts` (frozen gate), `src/policy.ts` (path/secret audit), `src/risk.ts` (risk tiers + auto-merge), `src/profile.ts` (per-repo profile), `src/env.ts` (agent env allow-list). Providers: `src/providers/` (platform seam; GitHub implemented, others must implement `Provider` and register). Support tools: `src/eval.ts` (frozen tasks), `src/retro.ts` (weekly report), `src/watch.ts` (cross-repo post-merge watchdog), `src/metrics.ts`, `src/learnings.ts`.
- `agent-runner/prompts/` — task templates rendered by the runner (bug-fix / feature / review).
- `agent-runner/config/agents.json` — agent routing rules.
- `agent-runner/config/repos.json` — per-repository profile (verify commands, protected/blocked paths, dependency manifests, learnings file, default agent/model); see `docs/portability.md`. Legacy `config/verify.json` and the `EASYGH_VERIFY_CMDS` variable still override it.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
- `ghapp-scan` 增加 `actions: write`(仅用于 dispatch;App token 无法 dispatch hub 工作流)。
- 无匹配 runner 时任务保持排队(不静默回退 ubuntu),文档给出取消/补 runner 的指引。

### Added (provider seam)

- `agent-runner/src/providers/`:平台抽象层(`types.ts` 接口 + capabilities、`github.ts` 唯一实现、`index.ts` 注册表)。
- `main.ts` 所有平台调用改为走 `resolveProvider()`;`EASYGH_PROVIDER` 选择实现,未实现的平台会明确报错,不静默回退。
- 抽象范围:Issue/标签/评论/PR/auto-merge/认领锁/事故闭锁;GitHub 专属能力(App 发现、workflow dispatch、rulesets)通过 capabilities 标记。
- 测试:注册表、默认值、环境变量、未实现平台报错、自定义 provider 可注册(63 个用例)。

### Changed

- 检查命令在 agent 运行前冻结;若检查配置被改动,执行冻结版本并标记 `ai-verify-changed`,不再把"跳过验证"当作"验证通过"。
Expand Down
69 changes: 33 additions & 36 deletions agent-runner/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,11 @@ import type { AgentResult } from "./agents/types.ts";
import { ensureAgentCli } from "./cli.ts";
import { agentEnv, withheldSecretNames } from "./env.ts";
import * as git from "./git.ts";
import {
acquireLock,
addLabel,
commentFromFile,
countRecentAiPrs,
createPr,
getIssue,
hasOpenIncident,
listLabels,
mergePrAuto,
recentHumanComments,
releaseLock,
removeLabel,
type PrInfo,
} from "./github.ts";
import type { PrInfo } from "./github.ts";
import { renderLearnings } from "./learnings.ts";
import { metricsLine, writeMetrics } from "./metrics.ts";
import { auditStagedChanges, describeAudit, stageChanges } from "./policy.ts";
import { resolveProvider } from "./providers/index.ts";
import { profileFor } from "./profile.ts";
import { classifyTask, renderTaskMarkdown, type TaskKind } from "./prompt.ts";
import { assessRisk, autoMergeDecision, parseAutoMergeMode, type RiskAssessment } from "./risk.ts";
Expand Down Expand Up @@ -185,6 +172,15 @@ async function main(): Promise<void> {
const runDir = join(process.env.RUNNER_TEMP ?? ".tmp/runner", `ai-issue-${issueNumber}`);
mkdirSync(runDir, { recursive: true });

// All platform calls go through the provider seam (GitHub today; GitLab or
// Gitea would implement the same interface).
const platform = resolveProvider();
const ref = { repo, number: issueNumber };
const caps = Object.entries(platform.capabilities)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
log(`provider=${platform.name} capabilities=${caps.join(",") || "(none)"}`);

// Time budget: the job timeout must cover attempts * (agent + verify) plus
// install/push overhead. Every step below is clamped by the remaining budget
// so a slow run reports "budget exhausted" instead of being killed mid-flight.
Expand Down Expand Up @@ -223,28 +219,28 @@ async function main(): Promise<void> {
let automergeArmed = false;

const finish = (result: Outcome) => {
removeLabel(repo, issueNumber, L.running);
if (result === "pr") addLabel(repo, issueNumber, L.pr);
else if (result === "failed") addLabel(repo, issueNumber, L.failed);
else if (result === "blocked") addLabel(repo, issueNumber, L.needsInput);
else if (result === "deferred") addLabel(repo, issueNumber, L.deferred);
else addLabel(repo, issueNumber, L.done);
platform.issues.removeLabel(ref, L.running);
if (result === "pr") platform.issues.addLabel(ref, L.pr);
else if (result === "failed") platform.issues.addLabel(ref, L.failed);
else if (result === "blocked") platform.issues.addLabel(ref, L.needsInput);
else if (result === "deferred") platform.issues.addLabel(ref, L.deferred);
else platform.issues.addLabel(ref, L.done);
};

const postComment = (body: string, name = "comment.md") =>
commentFromFile(repo, issueNumber, writeText(join(runDir, name), body));
platform.issues.commentFromFile(ref, writeText(join(runDir, name), body));

const retryHint = `如需重试:在工作流 \`ai-decide\` 选择动作 \`retry\`,或移除 \`${L.failed}\`/\`${L.needsInput}\`/\`${L.deferred}\` 标签后重新添加 \`${L.trigger}\`。`;

try {
// --- claim: labels (fast) + atomic lock (authoritative) -------------------
const labels = listLabels(repo, issueNumber);
const labels = platform.issues.labels(ref);
if (HANDLED.some((x) => labels.includes(x))) {
log(`skip: issue already handled/in-flight (labels: ${labels.join(",")})`);
return;
}

const lockState = acquireLock(repo, issueNumber, { defaultBranch, ttlMs: lockTtlMs });
const lockState = platform.locks.acquire(ref, { defaultBranch, ttlMs: lockTtlMs });
if (lockState === "busy") {
log("skip: another run holds the claim lock");
return;
Expand All @@ -254,14 +250,14 @@ async function main(): Promise<void> {
log("warning: claim lock API unavailable (permissions/ruleset); falling back to labels only");
}

addLabel(repo, issueNumber, L.running);
removeLabel(repo, issueNumber, L.trigger);
platform.issues.addLabel(ref, L.running);
platform.issues.removeLabel(ref, L.trigger);
claimed = true;
log(locked ? "claimed (lock + ai-running set, ai removed)" : "claimed (labels only, ai removed)");

// --- quota: cap how much work one day may spend ---------------------------
if (maxPrsPerDay > 0) {
const recent = countRecentAiPrs(repo, 24);
const recent = platform.pulls.countRecent(repo, 24);
if (recent >= maxPrsPerDay) {
log(`deferred: ${recent}/${maxPrsPerDay} AI PRs in the last 24h`);
postComment(
Expand All @@ -274,12 +270,12 @@ async function main(): Promise<void> {
}

// --- context -------------------------------------------------------------
const issue = getIssue(repo, issueNumber);
const issue = platform.issues.get(ref);
kind = classifyTask(issue);
log(`issue: ${issue.title} | kind=${kind} | labels=${issue.labels.join(",") || "(none)"}`);

// H2 replies live in the issue thread; replay them into the task.
const humanComments = recentHumanComments(repo, issueNumber);
const humanComments = platform.issues.recentHumanComments(ref);
const humanContext =
humanComments.length === 0
? ""
Expand Down Expand Up @@ -517,11 +513,11 @@ async function main(): Promise<void> {
log(`risk=${risk.level} (${risk.reasons.join("; ")}) files=${changedFiles} lines=${changedLines}`);

if (audit.sensitive.length > 0 || risk.level === "high") {
addLabel(repo, issueNumber, L.core);
platform.issues.addLabel(ref, L.core);
log(`flagged ${L.core} (${audit.sensitive.length} sensitive path(s), risk=${risk.level})`);
}
if (verifyResult?.tampered) {
addLabel(repo, issueNumber, L.verifyChanged);
platform.issues.addLabel(ref, L.verifyChanged);
log(`flagged ${L.verifyChanged}: the check configuration changed during the run`);
}

Expand Down Expand Up @@ -559,7 +555,8 @@ async function main(): Promise<void> {

let pr: PrInfo;
try {
pr = createPr(repo, {
pr = platform.pulls.create({
repo,
base: defaultBranch,
head: branch,
title: `[AI] ${kindPrefix}: #${issueNumber} ${shortTitle}`.slice(0, 110),
Expand Down Expand Up @@ -587,13 +584,13 @@ async function main(): Promise<void> {
: null,
paths: audit.paths,
});
const incidentOpen = hasOpenIncident(repo);
const incidentOpen = platform.incidents.hasOpen(repo);
if (incidentOpen) log("auto-merge suppressed: open ai-incident (circuit breaker latch)");
if (decision.allowed && !draft && !incidentOpen) {
const armed = mergePrAuto(repo, pr.number);
const armed = platform.pulls.armAutoMerge(repo, pr.number);
automergeArmed = armed.ok;
if (armed.ok) {
addLabel(repo, issueNumber, L.automerge);
platform.issues.addLabel(ref, L.automerge);
log(`auto-merge armed on PR #${pr.number}`);
} else {
log(`warning: auto-merge could not be armed: ${armed.detail}`);
Expand Down Expand Up @@ -635,7 +632,7 @@ async function main(): Promise<void> {
git.restoreCredentials(workDir);
if (locked) {
try {
releaseLock(repo, issueNumber);
platform.locks.release(ref);
} catch (err) {
log(`warning: failed to release claim lock: ${err instanceof Error ? err.message : err}`);
}
Expand Down
63 changes: 63 additions & 0 deletions agent-runner/src/providers/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
acquireLock,
addLabel,
commentFromFile,
countRecentAiPrs,
createPr,
ensureLabel,
getIssue,
hasOpenIncident,
listLabels,
mergePrAuto,
recentHumanComments,
releaseLock,
removeLabel,
} from "../github.ts";
import type { Provider } from "./types.ts";

/**
* GitHub implementation: the `gh` CLI (repo issue/PR APIs) plus the tag-ref
* claim lock the dispatcher also uses.
*
* App-mode discovery, workflow dispatch and rulesets stay in
* `dispatcher.ts` / `watch.ts` / `.github/workflows` — they are GitHub-only and
* are advertised through `capabilities.zeroFileMode`.
*/
export const githubProvider: Provider = {
name: "github",
capabilities: {
zeroFileMode: true,
atomicClaim: true,
autoMerge: true,
draftPullRequests: true,
incidentLatch: true,
},
issues: {
get: (ref) => getIssue(ref.repo, ref.number),
labels: (ref) => listLabels(ref.repo, ref.number),
addLabel: (ref, label) => addLabel(ref.repo, ref.number, label),
removeLabel: (ref, label) => removeLabel(ref.repo, ref.number, label),
ensureLabel: (repo, label) => ensureLabel(repo, label),
commentFromFile: (ref, bodyFile) => commentFromFile(ref.repo, ref.number, bodyFile),
recentHumanComments: (ref, limit) => recentHumanComments(ref.repo, ref.number, limit),
},
pulls: {
create: (spec) =>
createPr(spec.repo, {
base: spec.base,
head: spec.head,
title: spec.title,
bodyFile: spec.bodyFile,
...(spec.draft ? { draft: true } : {}),
}),
armAutoMerge: (repo, prNumber) => mergePrAuto(repo, prNumber),
countRecent: (repo, hours) => countRecentAiPrs(repo, hours),
},
locks: {
acquire: (ref, opts) => acquireLock(ref.repo, ref.number, opts),
release: (ref) => releaseLock(ref.repo, ref.number),
},
incidents: {
hasOpen: (repo) => hasOpenIncident(repo),
},
};
38 changes: 38 additions & 0 deletions agent-runner/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { githubProvider } from "./github.ts";
import type { Provider } from "./types.ts";

export type { Provider } from "./types.ts";

/**
* Provider registry.
*
* Only GitHub is implemented today. Adding GitLab/Gitea means writing
* `src/providers/<name>.ts` that satisfies `Provider`, registering it here and
* documenting which capabilities are missing (no app mode, no tag-ref lock, …).
*/
const registry: Record<string, Provider> = {
github: githubProvider,
};

export const knownProviders = (): string[] => Object.keys(registry);

/** Register an additional provider (tests, or a future platform). */
export function registerProvider(provider: Provider): void {
registry[provider.name] = provider;
}

/**
* Resolve the provider named by `EASYGH_PROVIDER` (default `github`).
* Fails loudly instead of silently falling back to GitHub.
*/
export function resolveProvider(name?: string): Provider {
const key = (name ?? process.env.EASYGH_PROVIDER ?? "github").trim().toLowerCase() || "github";
const provider = registry[key];
if (!provider) {
throw new Error(
`Unknown provider "${key}". Implemented: ${knownProviders().join(", ")}. ` +
`A new platform must implement src/providers/<name>.ts (see docs/portability.md).`,
);
}
return provider;
}
105 changes: 105 additions & 0 deletions agent-runner/src/providers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Platform abstraction.
*
* The runner talks to a "provider" for everything that is not plain git:
* issues, labels, comments, pull requests, auto-merge and the claim lock.
* `github.ts` is the current implementation; GitLab/Gitea would implement the
* same interface in `src/providers/<name>.ts` and be selected with
* `EASYGH_PROVIDER`.
*
* Deliberately small and capability-flagged: a provider that cannot do
* auto-merge or a server-side claim lock advertises that, so the runner can
* degrade instead of guessing.
*/

export interface IssueRef {
/** owner/name */
repo: string;
number: number;
}

export interface IssueInfo {
number: number;
title: string;
body: string;
url: string;
labels: string[];
}

export interface HumanComment {
author: string;
association: string;
body: string;
createdAt: string;
}

export interface PrInfo {
number: number;
url: string;
}

export interface CreatePrSpec {
repo: string;
base: string;
head: string;
title: string;
bodyFile: string;
draft?: boolean;
}

export type LockState = "acquired" | "busy" | "unavailable";

export interface LockOptions {
defaultBranch: string;
ttlMs: number;
}

/** What a provider can do; the runner degrades gracefully when a flag is false. */
export interface ProviderCapabilities {
/** discover repositories through an app installation (zero-file mode) */
zeroFileMode: boolean;
/** server-side compare-and-swap claim lock */
atomicClaim: boolean;
/** arm merge-when-checks-pass without a human click */
autoMerge: boolean;
/** create draft pull requests (unmergeable until marked ready) */
draftPullRequests: boolean;
/** open incidents latch auto-merge off */
incidentLatch: boolean;
}

export interface IssueApi {
get(ref: IssueRef): IssueInfo;
labels(ref: IssueRef): string[];
addLabel(ref: IssueRef, label: string): void;
removeLabel(ref: IssueRef, label: string): void;
ensureLabel(repo: string, label: string): void;
commentFromFile(ref: IssueRef, bodyFile: string): void;
/** trusted (maintainer) comments, oldest first */
recentHumanComments(ref: IssueRef, limit?: number): HumanComment[];
}

export interface PullRequestApi {
create(spec: CreatePrSpec): PrInfo;
armAutoMerge(repo: string, prNumber: number): { ok: boolean; detail: string };
/** how many pipeline PRs were opened in the last `hours` (quota) */
countRecent(repo: string, hours: number): number;
}

export interface LockApi {
acquire(ref: IssueRef, opts: LockOptions): LockState;
release(ref: IssueRef): void;
}

export interface IncidentApi {
hasOpen(repo: string): boolean;
}

export interface Provider {
name: string;
capabilities: ProviderCapabilities;
issues: IssueApi;
pulls: PullRequestApi;
locks: LockApi;
incidents: IncidentApi;
}
Loading
Loading