diff --git a/AGENTS.md b/AGENTS.md index 08b710f..d0c6b09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01720dc..e524d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`,不再把"跳过验证"当作"验证通过"。 diff --git a/agent-runner/src/main.ts b/agent-runner/src/main.ts index c938c92..cbc1613 100644 --- a/agent-runner/src/main.ts +++ b/agent-runner/src/main.ts @@ -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"; @@ -185,6 +172,15 @@ async function main(): Promise { 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. @@ -223,28 +219,28 @@ async function main(): Promise { 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; @@ -254,14 +250,14 @@ async function main(): Promise { 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( @@ -274,12 +270,12 @@ async function main(): Promise { } // --- 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 ? "" @@ -517,11 +513,11 @@ async function main(): Promise { 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`); } @@ -559,7 +555,8 @@ async function main(): Promise { let pr: PrInfo; try { - pr = createPr(repo, { + pr = platform.pulls.create({ + repo, base: defaultBranch, head: branch, title: `[AI] ${kindPrefix}: #${issueNumber} ${shortTitle}`.slice(0, 110), @@ -587,13 +584,13 @@ async function main(): Promise { : 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}`); @@ -635,7 +632,7 @@ async function main(): Promise { 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}`); } diff --git a/agent-runner/src/providers/github.ts b/agent-runner/src/providers/github.ts new file mode 100644 index 0000000..568b6b8 --- /dev/null +++ b/agent-runner/src/providers/github.ts @@ -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), + }, +}; diff --git a/agent-runner/src/providers/index.ts b/agent-runner/src/providers/index.ts new file mode 100644 index 0000000..7a40526 --- /dev/null +++ b/agent-runner/src/providers/index.ts @@ -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/.ts` that satisfies `Provider`, registering it here and + * documenting which capabilities are missing (no app mode, no tag-ref lock, …). + */ +const registry: Record = { + 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/.ts (see docs/portability.md).`, + ); + } + return provider; +} diff --git a/agent-runner/src/providers/types.ts b/agent-runner/src/providers/types.ts new file mode 100644 index 0000000..ae1a771 --- /dev/null +++ b/agent-runner/src/providers/types.ts @@ -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/.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; +} diff --git a/agent-runner/test/providers.test.ts b/agent-runner/test/providers.test.ts new file mode 100644 index 0000000..dc2dce7 --- /dev/null +++ b/agent-runner/test/providers.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { knownProviders, registerProvider, resolveProvider } from "../src/providers/index.ts"; +import type { Provider } from "../src/providers/types.ts"; + +const noop = () => undefined; + +function fakeProvider(name: string): Provider { + return { + name, + capabilities: { + zeroFileMode: false, + atomicClaim: false, + autoMerge: false, + draftPullRequests: false, + incidentLatch: false, + }, + issues: { + get: () => ({ number: 1, title: "", body: "", url: "", labels: [] }), + labels: () => [], + addLabel: noop, + removeLabel: noop, + ensureLabel: noop, + commentFromFile: noop, + recentHumanComments: () => [], + }, + pulls: { + create: () => ({ number: 1, url: "" }), + armAutoMerge: () => ({ ok: false, detail: "not supported" }), + countRecent: () => 0, + }, + locks: { + acquire: () => "unavailable", + release: noop, + }, + incidents: { hasOpen: () => false }, + }; +} + +describe("provider registry", () => { + const saved = process.env.EASYGH_PROVIDER; + afterEach(() => { + if (saved === undefined) delete process.env.EASYGH_PROVIDER; + else process.env.EASYGH_PROVIDER = saved; + }); + + it("defaults to github", () => { + assert.deepEqual(knownProviders(), ["github"]); + assert.equal(resolveProvider().name, "github"); + }); + + it("honours EASYGH_PROVIDER", () => { + process.env.EASYGH_PROVIDER = "GitHub"; + assert.equal(resolveProvider().name, "github"); + }); + + it("fails loudly for a platform that is not implemented", () => { + assert.throws(() => resolveProvider("gitlab"), /Unknown provider "gitlab"/); + assert.throws(() => resolveProvider("gitlab"), /src\/providers/); + }); + + it("github advertises the capabilities the runner relies on", () => { + const caps = resolveProvider("github").capabilities; + assert.equal(caps.atomicClaim, true); + assert.equal(caps.autoMerge, true); + assert.equal(caps.draftPullRequests, true); + assert.equal(caps.incidentLatch, true); + assert.equal(caps.zeroFileMode, true); + }); + + it("accepts another implementation through the seam", () => { + const fake = fakeProvider("fake"); + registerProvider(fake); + assert.equal(resolveProvider("fake"), fake); + assert.equal(fake.issues.labels({ repo: "a/b", number: 1 }).length, 0); + assert.equal(fake.locks.acquire({ repo: "a/b", number: 1 }, { defaultBranch: "main", ttlMs: 1 }), "unavailable"); + }); +}); diff --git a/docs/portability.md b/docs/portability.md index 244cf7f..4104da9 100644 --- a/docs/portability.md +++ b/docs/portability.md @@ -129,7 +129,26 @@ macOS(Xcode)、Windows(MSVC)这类无法用 Linux 容器的项目,可 8. `mix.exs` → `mix test` 9. 都没有 → 跳过(未验证) -## 4. 新项目接入清单 +## 4. 平台抽象(provider) + +Runner 里所有"平台侧"的调用都收在 `agent-runner/src/providers/`: + +```text +providers/ + types.ts # Provider 接口 + capabilities(零文件发现/原子锁/auto-merge/草稿 PR/事故闭锁) + github.ts # 当前唯一实现(gh CLI + tag-ref 锁) + index.ts # 注册表 + resolveProvider()(EASYGH_PROVIDER,默认 github) +``` + +- 抽象范围:Issue 读取、标签、评论、可信评论、PR 创建、auto-merge、认领锁、事故闭锁。 +- 仍未抽象(GitHub 专属,通过 capabilities 标记):App 安装发现、workflow_dispatch、rulesets/分支保护、`ai-watch` 的 run 查询。 +- 选择实现:`EASYGH_PROVIDER=gitlab`(未实现会**明确报错**,不会静默回退到 GitHub)。 +- 新增平台:实现 `Provider` → 在 `providers/index.ts` 注册 → 补上该平台的"发现仓库"机制 + (GitHub 用 App installation;GitLab 可用 group token + 项目列表)。 + +> 目前**只实现了 GitHub**。GitLab/Gitea 留作接口,未做实际适配(按当前需求)。 + +## 5. 新项目接入清单 ``` [ ] 触发标签 `ai` 已建(App 模式会自动补建) @@ -142,7 +161,7 @@ macOS(Xcode)、Windows(MSVC)这类无法用 Linux 容器的项目,可 [ ] 冒烟:一个 docs-only 的 `ai` Issue,确认 PR → auto-merge → Issue 关闭 ``` -## 5. 典型画像示例 +## 6. 典型画像示例 **Node / TypeScript 库** @@ -185,14 +204,14 @@ macOS(Xcode)、Windows(MSVC)这类无法用 Linux 容器的项目,可 } ``` -## 6. 仍然是"项目相关"的部分(有意保留) +## 7. 仍然是“项目相关”的部分(有意保留) - **提示词语气与语言**:`agent-runner/prompts/*.md` 是模板,按需改(属于流水线自身,改它需要走 PR)。 - **`docs/learnings.md` 的内容**:每个仓库自己积累,流水线不自动写。 - **标签契约**(`ai`、`ai-*`):固定,避免每个仓库一套状态机。 - **周报/评论的语言**:目前中文;需要多语言时再加 `AI_LOCALE`。 -## 7. 给 EasyGithub 自己加项目时的心态 +## 8. 给 EasyGithub 自己加项目时的心态 1. 先跑**自动探测**,看它选了什么命令(日志里 `gate frozen: cmd=...`)。 2. 不可用/太慢 → 写 profile,而不是改 runner 代码。