From be61a56f1af0ecbe2dbc239480c69c14806f702e Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Tue, 10 Nov 2026 12:52:37 +0800 Subject: [PATCH] feat(portability): per-repo profile instead of EasyGithub hardcoding The pipeline assumed one project: Node scripts, this repo's directory layout, and a fixed learnings file. Make those knobs data: - src/profile.ts + config/repos.json: verify commands, protected / blocked / sensitive / ignored paths, dependency manifests, learnings file, default agent/model, retry and quota. Precedence: built-in < _defaults < repo entry < legacy config/verify.json < env. Lists replace defaults; trust anchors (.github/, .git/, LICENSE) always stay. Zero-file targets keep no files in their repo - profiles live in the hub. - verify: profile command beats detection; detection gains Swift (Package.swift), Gradle (gradlew), Maven (pom.xml), Elixir (mix.exs). - risk/policy: accept per-repo path rules (OrzMCApp: Sources/, OrzMC.xcodeproj/). - verify: hash the scripts the command invokes (`bash scripts/check.sh`) into the frozen gate, so an agent cannot "pass" by weakening the judge itself. - OrzMCApp profile: Ubuntu-friendly check-platform-split.sh, Swift paths, docs/iteration-flow.md as learnings (the EASYGH_VERIFY_CMDS override is retired). - docs/portability.md: field reference, precedence, runner constraints, onboarding checklist, stack examples. --- AGENTS.md | 6 +- CHANGELOG.md | 8 ++ README.md | 3 + agent-runner/config/repos.json | 22 ++++ agent-runner/src/learnings.ts | 16 +-- agent-runner/src/main.ts | 32 ++++-- agent-runner/src/policy.ts | 38 +++++-- agent-runner/src/profile.ts | 163 ++++++++++++++++++++++++++++++ agent-runner/src/risk.ts | 17 +++- agent-runner/src/router.ts | 4 +- agent-runner/src/verify.ts | 72 +++++++++++-- agent-runner/test/policy.test.ts | 15 +++ agent-runner/test/profile.test.ts | 50 +++++++++ agent-runner/test/risk.test.ts | 12 +++ agent-runner/test/verify.test.ts | 24 +++++ docs/other-orgs.md | 3 +- docs/portability.md | 154 ++++++++++++++++++++++++++++ 17 files changed, 593 insertions(+), 46 deletions(-) create mode 100644 agent-runner/config/repos.json create mode 100644 agent-runner/src/profile.ts create mode 100644 agent-runner/test/profile.test.ts create mode 100644 docs/portability.md diff --git a/AGENTS.md b/AGENTS.md index 7a857e2..5382b06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,17 +4,17 @@ 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/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). 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/verify.json` — per-repo verification commands for zero-file (App mode) targets; overridable by hub variable `EASYGH_VERIFY_CMDS`. +- `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. - `eval/tasks/` — frozen evaluation tasks (repo + commit + issue + hidden tests) used by `src/eval.ts`. - `docs/learnings.md` — curated context injected into `TASK.md` (top matching sections). - `.github/workflows/ai-agent.yml` — the pipeline workflow (self-host). - `.github/workflows/ai-agent-reusable.yml` + `.github/actions/agent-runner` — reusable cross-repo mode (mode 2). - `.github/workflows/ghapp-scan.yml` + `scripts/ghapp-token.mjs` — GitHub-App zero-file mode (mode 3). - `scripts/` — helper scripts (`dev-run.sh`, `create-labels.sh`, `install-to-repo.sh`). -- `docs/` — `ai-workflow.md` (end-to-end workflow, risk tiers, human touchpoints), `agents.md` (multi-agent + model config), `realtime.md` (event-driven vs polling), `other-orgs.md` (using EasyGithub in other organizations; `ghapp-scan` org is `vars.EASYGH_ORG`). +- `docs/` — `ai-workflow.md` (end-to-end workflow, risk tiers, human touchpoints), `portability.md` (adapting the pipeline to other projects), `agents.md` (multi-agent + model config), `realtime.md` (event-driven vs polling), `other-orgs.md` (using EasyGithub in other organizations; `ghapp-scan` org is `vars.EASYGH_ORG`). ## Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index ec29cdf..d4f196f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,14 @@ 已有 ruleset 管检查的仓库不重复叠加,`gh api` 的错误 JSON 不会被当成检查名。 - 文档补充权限模型:组织默认 `read` + 维护者 `write`,并记录"auto-merge 不认 bypass actor"的实测结论。 +### Added (portability) + +- 仓库画像 `agent-runner/src/profile.ts` + `config/repos.json`:按仓库配置验证命令、受保护/禁止/敏感/忽略路径、 + 依赖清单、经验文件、默认 agent/model、重试与配额;默认行为不变(不配置就走自动探测)。 +- 自动探测新增 Swift(`Package.swift`)、Gradle(`gradlew`)、Maven(`pom.xml`)、Elixir(`mix.exs`)。 +- 新增 `docs/portability.md`:通用化手册、字段与优先级、验证命令的运行环境约束、新项目接入清单。 +- OrzMCApp(Swift)画像落地:Ubuntu 可跑的 `check-platform-split.sh` + Swift 目录的受保护路径 + 经验文件指向 `docs/iteration-flow.md`。 + ### Changed - 检查命令在 agent 运行前冻结;若检查配置被改动,执行冻结版本并标记 `ai-verify-changed`,不再把"跳过验证"当作"验证通过"。 diff --git a/README.md b/README.md index 6149548..51c637f 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,11 @@ Runs the exact same runner code locally (clone → branch → Pi → commit → .github/workflows/ai-retro.yml # weekly retro issue .github/CODEOWNERS # which changes always need a human agent-runner/ # Agent Runner (TypeScript, zero runtime deps) + config/repos.json # per-repo profiles (see docs/portability.md) prompts/ # task templates (bug-fix / feature / review) src/main.ts # orchestration src/risk.ts # risk tiers + auto-merge predicate + src/profile.ts # per-repo profile (verify/paths/agent/model) src/git.ts, src/github.ts # git + gh wrappers src/agents/{types,pi,registry}.ts test/ # node:test suite (runs in CI) @@ -188,6 +190,7 @@ Run `npm run check` (one-shot equivalent of `npm run typecheck`) before committi ## 参考文档 - [端到端 AI 开发工作流(自动化边界 / 人机分工)](docs/ai-workflow.md) +- [适配不同项目(仓库画像 / 自动探测 / 接入清单)](docs/portability.md) - [多 Agent 启用与模型配置](docs/agents.md) - [实时响应方案(事件驱动 vs 轮询)](docs/realtime.md) - [在其它组织中使用 EasyGithub](docs/other-orgs.md) diff --git a/agent-runner/config/repos.json b/agent-runner/config/repos.json new file mode 100644 index 0000000..1be3894 --- /dev/null +++ b/agent-runner/config/repos.json @@ -0,0 +1,22 @@ +{ + "_comment": "Per-repository profile. Lives in the hub because zero-file (App) targets cannot host pipeline files. Precedence: defaults < _defaults < repos[/] < legacy config/verify.json < env overrides (VERIFY_CMD, AI_*). List fields REPLACE the built-in value; trust anchors (.github/, .git/, LICENSE) are always kept. See docs/portability.md.", + "_defaults": {}, + "repos": { + "OrzMC/OrzMCApp": { + "_comment": "Swift/Xcode app. The hub runs on Ubuntu, so verification uses an Ubuntu-friendly script instead of xcodebuild (the macOS matrix still runs in the repo's own CI as a non-required signal).", + "verify": ["bash scripts/check-platform-split.sh"], + "protectedPaths": [ + ".github/", + "scripts/", + "Sources/", + "OrzMC/", + "OrzMCTests/", + "OrzMCUITests/", + "OrzMC.xcodeproj/", + "products/" + ], + "dependencyFiles": ["Package.swift", "Package.resolved", "OrzMC.xcodeproj/project.pbxproj"], + "learningsFile": "docs/iteration-flow.md" + } + } +} diff --git a/agent-runner/src/learnings.ts b/agent-runner/src/learnings.ts index 08fd4b9..0f815d4 100644 --- a/agent-runner/src/learnings.ts +++ b/agent-runner/src/learnings.ts @@ -75,20 +75,20 @@ export function selectLearnings(markdown: string, query: string, limit = 2): Sec return ranked.slice(0, limit).map((entry) => entry.section); } -/** Read docs/learnings.md from a worktree (optional). */ -export function readLearnings(workDir: string): string { - const file = join(workDir, LEARNINGS_FILE); - if (!existsSync(file)) return ""; +/** Read the learnings file from a worktree (optional). */ +export function readLearnings(workDir: string, file = LEARNINGS_FILE): string { + const path = join(workDir, file); + if (!existsSync(path)) return ""; try { - return readFileSync(file, "utf8"); + return readFileSync(path, "utf8"); } catch { return ""; } } /** Render the learnings block appended to TASK.md, or "" when there is none. */ -export function renderLearnings(workDir: string, query: string): string { - const markdown = readLearnings(workDir); +export function renderLearnings(workDir: string, query: string, file = LEARNINGS_FILE): string { + const markdown = readLearnings(workDir, file); if (!markdown) return ""; const picked = selectLearnings(markdown, query); @@ -107,7 +107,7 @@ export function renderLearnings(workDir: string, query: string): string { ``, `---`, ``, - `## 仓库经验(来自 ${LEARNINGS_FILE},仅作提示)`, + `## 仓库经验(来自 ${file},仅作提示)`, ``, blocks.join("\n\n"), ``, diff --git a/agent-runner/src/main.ts b/agent-runner/src/main.ts index 853af37..5ade8c4 100644 --- a/agent-runner/src/main.ts +++ b/agent-runner/src/main.ts @@ -23,6 +23,7 @@ import { import { renderLearnings } from "./learnings.ts"; import { metricsLine, writeMetrics } from "./metrics.ts"; import { auditStagedChanges, describeAudit, stageChanges } from "./policy.ts"; +import { profileFor } from "./profile.ts"; import { classifyTask, renderTaskMarkdown, type TaskKind } from "./prompt.ts"; import { assessRisk, autoMergeDecision, parseAutoMergeMode, type RiskAssessment } from "./risk.ts"; import { routeAgent } from "./router.ts"; @@ -170,6 +171,12 @@ async function main(): Promise { if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error(`Bad ISSUE_NUMBER: ${process.env.ISSUE_NUMBER}`); const startedAt = Date.now(); + // Repository profile (hub-side config/repos.json): project-specific verify + // commands, protected paths and agent/model, without touching this project. + const profile = profileFor(repo); + if (Object.keys(profile).length > 0) { + log(`profile: ${Object.keys(profile).sort().join(",")}`); + } const thinking = process.env.THINKING?.trim() || "high"; const defaultBranch = process.env.DEFAULT_BRANCH ?? "main"; const workDir = process.env.WORK_DIR ?? process.cwd(); @@ -180,11 +187,11 @@ async function main(): Promise { // 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. const piTimeoutMs = intEnv("PI_TIMEOUT_MS", 15 * 60 * 1000, 60_000, 60 * 60 * 1000); - const verifyTimeoutMs = intEnv("VERIFY_TIMEOUT_MS", 10 * 60 * 1000, 10_000, 60 * 60 * 1000); + const verifyTimeoutMs = intEnv("VERIFY_TIMEOUT_MS", profile.verifyTimeoutMs ?? 10 * 60 * 1000, 10_000, 60 * 60 * 1000); const budgetMs = intEnv("AGENT_BUDGET_MS", 75 * 60 * 1000, 60_000, 6 * 60 * 60 * 1000); const lockTtlMs = intEnv("LOCK_TTL_MS", 3 * 60 * 60 * 1000, 10 * 60 * 1000, 24 * 60 * 60 * 1000); - const maxAttempts = intEnv("AI_MAX_ATTEMPTS", 3, 1, 5); - const maxPrsPerDay = intEnv("AI_MAX_PRS_PER_DAY", 10, 0, 200); + const maxAttempts = intEnv("AI_MAX_ATTEMPTS", profile.maxAttempts ?? 3, 1, 5); + const maxPrsPerDay = intEnv("AI_MAX_PRS_PER_DAY", profile.maxPrsPerDay ?? 10, 0, 200); const autoMergeMode = parseAutoMergeMode(process.env.AI_AUTOMERGE); const reviewer = process.env.AI_REVIEWER?.trim() ?? ""; const remainingMs = () => budgetMs - (Date.now() - startedAt); @@ -283,18 +290,20 @@ async function main(): Promise { ...humanComments.map((c) => `**@${c.author}** (${c.createdAt}):\n${c.body}`), ].join("\n"); const issueBody = issue.body + humanContext; - const learnings = renderLearnings(workDir, `${issue.title} ${issue.body}`); + const learnings = renderLearnings(workDir, `${issue.title} ${issue.body}`, profile.learningsFile); if (learnings) log("injected repository learnings from docs/learnings.md"); // --- agent selection: env override > issue label agent:x > rules > default --- const explicitAgent = process.env.AGENT?.trim(); - agentName = explicitAgent ? explicitAgent.toLowerCase() : await routeAgent({ labels: issue.labels, title: issue.title }); + agentName = explicitAgent + ? explicitAgent.toLowerCase() + : await routeAgent({ labels: issue.labels, title: issue.title }, profile.agent); const agent = selectAgent(agentName); const isPi = agentName === "pi"; provider = isPi ? (process.env.PROVIDER ?? "deepseek") : agentName; model = isPi - ? process.env.MODEL?.trim() || "deepseek-v4-flash" - : (process.env[`AI_${agentName.toUpperCase()}_MODEL`]?.trim() ?? "default"); + ? process.env.MODEL?.trim() || profile.model || "deepseek-v4-flash" + : (process.env[`AI_${agentName.toUpperCase()}_MODEL`]?.trim() ?? profile.model ?? "default"); const extraArgs = isPi && thinking ? ["--thinking", thinking] : []; log(`agent=${agent.name} provider=${provider} model=${model} (override=${explicitAgent ?? "no"})`); @@ -327,7 +336,7 @@ async function main(): Promise { log(`branch ${branch} ready`); // --- freeze the referee before the agent can touch the repo -------------- - const verifySpec = await captureVerifySpec(workDir); + const verifySpec = await captureVerifySpec(workDir, profile); log(`gate frozen: cmd="${verifySpec.command ?? "(none)"}" (${verifySpec.scriptBody ? "script body captured" : "no script body"})`); // --- remove push credentials for the agent phase -------------------------- @@ -463,7 +472,7 @@ async function main(): Promise { } // --- verify passed: package & deliver -------------------------------------- - const staged = stageChanges(workDir); + const staged = stageChanges(workDir, profile); if (staged.ignored.length > 0) log(`not committing ${staged.ignored.length} generated path(s): ${staged.ignored.slice(0, 5).join(",")}`); if (!git.hasStagedChanges(workDir)) { @@ -477,7 +486,7 @@ async function main(): Promise { } // Policy gate: path allow-list + secret scan on the staged diff. - const audit = auditStagedChanges(workDir); + const audit = auditStagedChanges(workDir, profile); if (!audit.ok) { log(`policy violation: blocked=${audit.blocked.length} secrets=${audit.secrets.length}`); postComment( @@ -499,6 +508,9 @@ async function main(): Promise { verify: verifyResult ? { verified: verifyResult.verified, tampered: verifyResult.tampered, skipped: verifyResult.skipped } : null, + protectedPaths: profile.protectedPaths, + protectedFiles: profile.protectedFiles, + dependencyFiles: profile.dependencyFiles, }); log(`risk=${risk.level} (${risk.reasons.join("; ")}) files=${changedFiles} lines=${changedLines}`); diff --git a/agent-runner/src/policy.ts b/agent-runner/src/policy.ts index 06b6882..94e0fb6 100644 --- a/agent-runner/src/policy.ts +++ b/agent-runner/src/policy.ts @@ -39,6 +39,9 @@ export interface AuditResult { const BLOCKED_PREFIXES = [".git/", ".github/workflows/", ".github/actions/"]; const BLOCKED_FILES = ["LICENSE", "LICENCE", "TASK.md"]; +/** Always rejected, even when a repository profile narrows the lists. */ +const BASELINE_BLOCKED_PREFIXES = [".git/", ".github/workflows/", ".github/actions/"]; + /** Allowed, but flagged for mandatory human review. */ const SENSITIVE_PREFIXES = ["agent-runner/", ".github/", "scripts/"]; const SENSITIVE_FILES = ["package.json", "package-lock.json", "tsconfig.json"]; @@ -79,20 +82,33 @@ function normalize(path: string): string { return path.replace(/^\.\//, ""); } -function isBlocked(path: string): boolean { +interface PathRules { + blockedPaths?: string[]; + blockedFiles?: string[]; + sensitivePaths?: string[]; + sensitiveFiles?: string[]; + ignoredPaths?: string[]; +} + +function isBlocked(path: string, rules: PathRules = {}): boolean { const p = normalize(path); - return BLOCKED_FILES.includes(p) || BLOCKED_PREFIXES.some((prefix) => p.startsWith(prefix)); + const prefixes = [...BASELINE_BLOCKED_PREFIXES, ...(rules.blockedPaths ?? BLOCKED_PREFIXES)]; + const files = [...BLOCKED_FILES, ...(rules.blockedFiles ?? [])]; + return files.includes(p) || prefixes.some((prefix) => p.startsWith(prefix)); } -function isSensitive(path: string): boolean { +function isSensitive(path: string, rules: PathRules = {}): boolean { const p = normalize(path); - return SENSITIVE_FILES.includes(p) || SENSITIVE_PREFIXES.some((prefix) => p.startsWith(prefix)); + const files = [...SENSITIVE_FILES, ...(rules.sensitiveFiles ?? [])]; + const prefixes = [...SENSITIVE_PREFIXES, ...(rules.sensitivePaths ?? [])]; + return files.includes(p) || prefixes.some((prefix) => p.startsWith(prefix)); } -function isIgnored(path: string): boolean { +function isIgnored(path: string, rules: PathRules = {}): boolean { const p = normalize(path); + const ignored = [...IGNORED_PREFIXES, ...(rules.ignoredPaths ?? [])]; return ( - IGNORED_PREFIXES.some((prefix) => p.startsWith(prefix)) || + ignored.some((prefix) => p.startsWith(prefix)) || IGNORED_SUFFIXES.some((suffix) => p.endsWith(suffix)) || p === "TASK.md" || p === "EASYGH_BLOCKED.md" @@ -103,10 +119,10 @@ function isIgnored(path: string): boolean { * Stage the working tree for commit, then unstage everything the pipeline * refuses to ship (vendored trees, logs, scratch task files). */ -export function stageChanges(cwd: string): { ignored: string[] } { +export function stageChanges(cwd: string, rules: PathRules = {}): { ignored: string[] } { stageAll(cwd); const staged = stagedPaths(cwd); - const ignored = staged.filter(isIgnored); + const ignored = staged.filter((path) => isIgnored(path, rules)); if (ignored.length > 0) unstage(cwd, ignored); return { ignored }; } @@ -148,10 +164,10 @@ export function scanStagedDiff(cwd: string): SecretFinding[] { } /** Inspect the staged change set before committing. */ -export function auditStagedChanges(cwd: string): AuditResult { +export function auditStagedChanges(cwd: string, rules: PathRules = {}): AuditResult { const staged = stagedPaths(cwd); - const blocked = staged.filter(isBlocked); - const sensitive = staged.filter((path) => !isBlocked(path) && isSensitive(path)); + const blocked = staged.filter((path) => isBlocked(path, rules)); + const sensitive = staged.filter((path) => !isBlocked(path, rules) && isSensitive(path, rules)); const secrets = scanStagedDiff(cwd); return { ok: blocked.length === 0 && secrets.length === 0, paths: staged, blocked, sensitive, ignored: [], secrets }; } diff --git a/agent-runner/src/profile.ts b/agent-runner/src/profile.ts new file mode 100644 index 0000000..154f550 --- /dev/null +++ b/agent-runner/src/profile.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Per-repository profile: the project-specific knobs that used to be hardcoded + * for EasyGithub itself. + * + * Profiles live in the HUB (`agent-runner/config/repos.json`), not in the + * target repository: zero-file (App) targets cannot host pipeline files, and a + * central, reviewable file is auditable. + * + * Precedence (lowest to highest): + * built-in defaults -> config/repos.json "_defaults" -> config/repos.json + * "/" -> legacy config/verify.json -> environment overrides. + * + * Lists (protected/blocked/ignored) replace the built-in value when present; + * a few trust anchors (`.github/`, `.git/`, `LICENSE`) are always kept by the + * callers regardless of the profile. + */ +export interface RepoProfile { + /** verification commands, run in order joined by ` && ` */ + verify?: string[]; + verifyTimeoutMs?: number; + /** path prefixes that make a change high-risk (draft PR + code owner) */ + protectedPaths?: string[]; + protectedFiles?: string[]; + /** path prefixes that the pipeline refuses to commit at all */ + blockedPaths?: string[]; + blockedFiles?: string[]; + /** allowed, but always flagged for human review */ + sensitivePaths?: string[]; + sensitiveFiles?: string[]; + /** never committed (vendored / generated) */ + ignoredPaths?: string[]; + /** dependency manifests: changing them is a supply-chain decision */ + dependencyFiles?: string[]; + /** repository knowledge injected into TASK.md */ + learningsFile?: string; + agent?: string; + model?: string; + maxAttempts?: number; + maxPrsPerDay?: number; +} + +interface ProfileFile { + _defaults?: RepoProfile; + repos?: Record; +} + +const CONFIG_URL = new URL("../config/repos.json", import.meta.url); +const LEGACY_VERIFY_URL = new URL("../config/verify.json", import.meta.url); + +let cached: ProfileFile | undefined; + +function loadFile(): ProfileFile { + if (cached !== undefined) return cached; + let file: ProfileFile = {}; + try { + const parsed = JSON.parse(readFileSync(CONFIG_URL, "utf8")) as unknown; + if (parsed && typeof parsed === "object") file = parsed as ProfileFile; + } catch { + // no profile file -> defaults only + } + cached = file; + return file; +} + +/** Reset the cache (tests). */ +export function resetProfileCache(): void { + cached = undefined; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +const LIST_KEYS = [ + "verify", + "protectedPaths", + "protectedFiles", + "blockedPaths", + "blockedFiles", + "sensitivePaths", + "sensitiveFiles", + "ignoredPaths", + "dependencyFiles", +] as const; +const STRING_KEYS = ["learningsFile", "agent", "model"] as const; +const NUMBER_KEYS = ["verifyTimeoutMs", "maxAttempts", "maxPrsPerDay"] as const; + +/** Drop unknown keys / wrong types so a typo cannot silently misconfigure a repo. */ +export function sanitizeProfile(raw: unknown): RepoProfile { + if (!raw || typeof raw !== "object") return {}; + const out: RepoProfile = {}; + const source = raw as Record; + for (const key of LIST_KEYS) { + if (isStringArray(source[key])) { + const values = (source[key] as string[]).map((v) => v.trim()).filter(Boolean); + if (values.length > 0) out[key] = values; + } + } + for (const key of STRING_KEYS) { + const value = source[key]; + if (typeof value === "string" && value.trim()) out[key] = value.trim(); + } + for (const key of NUMBER_KEYS) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) out[key] = value; + } + return out; +} + +function merge(base: RepoProfile, override: RepoProfile): RepoProfile { + const out: RepoProfile = { ...base }; + for (const [key, value] of Object.entries(override)) { + if (value === undefined) continue; + (out as Record)[key] = value; + } + return out; +} + +/** Legacy `config/verify.json`: { "owner/repo": "cmd" | { cmd, timeout_ms } } */ +function legacyVerify(repo: string): RepoProfile { + try { + const map = JSON.parse(readFileSync(LEGACY_VERIFY_URL, "utf8")) as Record; + const value = map[repo]; + if (typeof value === "string" && value.trim()) return { verify: [value.trim()] }; + if (value && typeof value === "object") { + const obj = value as { cmd?: unknown; timeout_ms?: unknown }; + const profile: RepoProfile = {}; + if (typeof obj.cmd === "string" && obj.cmd.trim()) profile.verify = [obj.cmd.trim()]; + if (typeof obj.timeout_ms === "number" && obj.timeout_ms > 0) profile.verifyTimeoutMs = obj.timeout_ms; + return profile; + } + } catch { + // no legacy file -> fine + } + return {}; +} + +/** Resolve the effective profile for a repository (cached per process). */ +export function profileFor(repo: string): RepoProfile { + const file = loadFile(); + const base = sanitizeProfile(file._defaults); + const repoProfile = sanitizeProfile(file.repos?.[repo]); + return merge(merge(base, repoProfile), legacyVerify(repo)); +} + +/** Learnings file the pipeline should read, if the profile overrides it. */ +export function learningsFileFor(repo: string): string | undefined { + return profileFor(repo).learningsFile; +} + +/** Flatten configured verify commands into one shell command. */ +export function verifyCommandFromProfile(profile: RepoProfile): string | null { + if (!profile.verify || profile.verify.length === 0) return null; + return profile.verify.join(" && "); +} + +/** Convenience for tests / callers that need the config path (debug logs). */ +export function profileConfigPath(): string { + return join(new URL(".", CONFIG_URL).pathname, "repos.json"); +} diff --git a/agent-runner/src/risk.ts b/agent-runner/src/risk.ts index 75c5ab9..320c64d 100644 --- a/agent-runner/src/risk.ts +++ b/agent-runner/src/risk.ts @@ -19,6 +19,10 @@ export interface RiskInput { /** added + removed lines in the staged diff */ changedLines: number; verify: { verified: boolean; tampered: boolean; skipped: boolean } | null; + /** repository-specific path rules (see src/profile.ts) */ + protectedPaths?: string[]; + protectedFiles?: string[]; + dependencyFiles?: string[]; } export interface RiskAssessment { @@ -26,7 +30,11 @@ export interface RiskAssessment { reasons: string[]; } -/** Trust boundary + pipeline: humans own these forever. */ +/** Trust boundary + pipeline: humans own these forever, in every repository. */ +const BASELINE_PREFIXES = [".github/", ".git/"]; +const BASELINE_FILES = ["LICENSE", "LICENCE", ".github/CODEOWNERS"]; + +/** Defaults for EasyGithub itself; other projects override via config/repos.json. */ const HIGH_PREFIXES = [".github/", "agent-runner/", "scripts/", "eval/"]; const HIGH_FILES = ["LICENSE", "LICENCE", ".github/CODEOWNERS"]; @@ -62,14 +70,17 @@ export function assessRisk(input: RiskInput): RiskAssessment { }; const paths = input.paths.map(normalize); + const highPrefixes = [...BASELINE_PREFIXES, ...(input.protectedPaths ?? HIGH_PREFIXES)]; + const highFiles = [...BASELINE_FILES, ...(input.protectedFiles ?? HIGH_FILES)]; + const dependencyFiles = input.dependencyFiles ?? DEPENDENCY_FILES; const protectedPaths = paths.filter( - (p) => HIGH_FILES.includes(p) || HIGH_PREFIXES.some((prefix) => p.startsWith(prefix)), + (p) => highFiles.includes(p) || highPrefixes.some((prefix) => p.startsWith(prefix)), ); if (protectedPaths.length > 0) { bump("high", `touches protected path(s): ${protectedPaths.slice(0, 4).join(", ")}`); } - const dependencyChanges = paths.filter((p) => DEPENDENCY_FILES.includes(p)); + const dependencyChanges = paths.filter((p) => dependencyFiles.includes(p)); if (dependencyChanges.length > 0) { bump("high", `changes dependency manifest(s): ${dependencyChanges.join(", ")}`); } diff --git a/agent-runner/src/router.ts b/agent-runner/src/router.ts index cd9b294..4c4b9a7 100644 --- a/agent-runner/src/router.ts +++ b/agent-runner/src/router.ts @@ -26,7 +26,7 @@ const configUrl = new URL("../config/agents.json", import.meta.url); * 3. first matching rule in agent-runner/config/agents.json * 4. configured default ("pi") */ -export async function routeAgent(input: RouteInput): Promise { +export async function routeAgent(input: RouteInput, fallbackAgent?: string): Promise { for (const label of input.labels) { const m = /^agent:(.+)$/.exec(label.trim()); if (m?.[1]) return m[1].trim().toLowerCase(); @@ -53,5 +53,5 @@ export async function routeAgent(input: RouteInput): Promise { if (labelsOk || titleOk) return rule.agent.trim().toLowerCase(); } - return (cfg.default ?? "pi").trim().toLowerCase() || "pi"; + return (fallbackAgent ?? cfg.default ?? "pi").trim().toLowerCase() || "pi"; } diff --git a/agent-runner/src/verify.ts b/agent-runner/src/verify.ts index e64fa62..a3bf1c6 100644 --- a/agent-runner/src/verify.ts +++ b/agent-runner/src/verify.ts @@ -1,8 +1,10 @@ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { runAsync } from "./util/exec.ts"; +import { verifyCommandFromProfile, type RepoProfile } from "./profile.ts"; /** * Independent referee: run the repository's own checks on the working tree @@ -27,6 +29,8 @@ export interface VerifySpec { scriptBody: string | null; /** Hash of the gate configuration at capture time. */ fingerprint: string; + /** Content hash of scripts the command invokes (e.g. `bash scripts/check.sh`). */ + gateFiles?: Record; } export interface VerifyResult { @@ -42,17 +46,32 @@ export interface VerifyResult { timedOut: boolean; } -const GATE_FILES = ["Cargo.toml", "go.mod", "pytest.ini", "tox.ini"]; +const GATE_FILES = [ + "Cargo.toml", + "go.mod", + "pytest.ini", + "tox.ini", + "Package.swift", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "gradlew", + "mix.exs", +]; interface Gate { command: string | null; scriptBody: string | null; } -async function resolveGate(workDir: string): Promise { +async function resolveGate(workDir: string, profile?: RepoProfile): Promise { const override = process.env.VERIFY_CMD?.trim(); if (override) return { command: override, scriptBody: null }; + // Repository profile (hub-side, reviewable) wins over heuristics. + const configured = profile ? verifyCommandFromProfile(profile) : null; + if (configured) return { command: configured, scriptBody: null }; + try { const pkg = JSON.parse(await readFile(join(workDir, "package.json"), "utf8")) as { scripts?: Record; @@ -73,12 +92,18 @@ async function resolveGate(workDir: string): Promise { if (existsSync(join(workDir, "pytest.ini")) || existsSync(join(workDir, "tox.ini"))) { return { command: "python -m pytest -q", scriptBody: null }; } + // More ecosystems: only when the manifest is present. The toolchain must + // exist on the runner (Ubuntu for App mode), otherwise use a profile. + if (existsSync(join(workDir, "Package.swift"))) return { command: "swift test", scriptBody: null }; + if (existsSync(join(workDir, "gradlew"))) return { command: "./gradlew check --console=plain", scriptBody: null }; + if (existsSync(join(workDir, "pom.xml"))) return { command: "mvn -q -B test", scriptBody: null }; + if (existsSync(join(workDir, "mix.exs"))) return { command: "mix test", scriptBody: null }; return { command: null, scriptBody: null }; } /** Backwards-compatible helper: the command only. */ -export async function resolveCommand(workDir: string): Promise { - return (await resolveGate(workDir)).command; +export async function resolveCommand(workDir: string, profile?: RepoProfile): Promise { + return (await resolveGate(workDir, profile)).command; } async function gateFingerprint(workDir: string, command: string | null): Promise { @@ -99,13 +124,43 @@ async function gateFingerprint(workDir: string, command: string | null): Promise return createHash("sha256").update(parts.join("\n")).digest("hex"); } +/** + * Files the command depends on (`bash scripts/check.sh`, `python tools/x.py`, …). + * + * Without this, an agent could "pass" by weakening the script that judges it — + * the single most important self-reference hole for script-based projects. + */ +function hashedFiles(workDir: string, command: string | null): Record { + if (!command) return {}; + const out: Record = {}; + const tokens = command.split(/[\s;&|()<>]+/); + let count = 0; + for (const token of tokens) { + if (count >= 5) break; + if (!token || token.startsWith("-") || token.includes("*")) continue; + if (!/^[\w./-]+$/.test(token)) continue; + if (!/\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|php|gradle|kts)$/.test(token) && !/Makefile$/.test(token)) continue; + try { + const content = readFileSync(join(workDir, token)); + if (content.length > 1024 * 1024) continue; + out[token] = createHash("sha256").update(content).digest("hex"); + count++; + } catch { + // not a file in the worktree -> ignore + } + } + return out; +} + /** Snapshot the gate before the agent is allowed to touch anything. */ -export async function captureVerifySpec(workDir: string): Promise { - const gate = await resolveGate(workDir); +export async function captureVerifySpec(workDir: string, profile?: RepoProfile): Promise { + const gate = await resolveGate(workDir, profile); + const gateFiles = hashedFiles(workDir, gate.command); return { command: gate.command, scriptBody: gate.scriptBody, - fingerprint: await gateFingerprint(workDir, gate.command), + fingerprint: `${await gateFingerprint(workDir, gate.command)}|scripts:${JSON.stringify(gateFiles)}`, + gateFiles, }; } @@ -150,7 +205,8 @@ export async function runVerify( } const budgetMs = clampTimeout(timeoutMs); - const tampered = (await gateFingerprint(workDir, spec.command)) !== spec.fingerprint; + const fingerprintNow = `${await gateFingerprint(workDir, spec.command)}|scripts:${JSON.stringify(hashedFiles(workDir, spec.command))}`; + const tampered = fingerprintNow !== spec.fingerprint; const deps = await ensureDeps(workDir, budgetMs); if (!deps.ok) { diff --git a/agent-runner/test/policy.test.ts b/agent-runner/test/policy.test.ts index ce11b04..d6ee7fe 100644 --- a/agent-runner/test/policy.test.ts +++ b/agent-runner/test/policy.test.ts @@ -68,6 +68,21 @@ describe("policy audit", () => { assert.deepEqual(audit.sensitive.sort(), ["agent-runner/src/main.ts", "scripts/dev-run.sh"]); }); + it("honours repository-specific rules while keeping the baseline", () => { + write("Sources/generated/lexer.swift", "x\n"); + write("Sources/App.swift", "x\n"); + write(".github/workflows/ci.yml", "name: x\n"); + stageChanges(dir, { blockedPaths: ["Sources/generated/"] }); + const audit = auditStagedChanges(dir, { + blockedPaths: ["Sources/generated/"], + sensitivePaths: ["Sources/"], + }); + assert.equal(audit.ok, false); + assert.ok(audit.blocked.includes("Sources/generated/lexer.swift")); + assert.ok(audit.blocked.includes(".github/workflows/ci.yml"), "baseline stays blocked"); + assert.ok(audit.sensitive.includes("Sources/App.swift")); + }); + it("detects credentials in added lines", () => { write("src/config.ts", "const key = 'sk-abcdefghijklmnopqrstuvwx';\n"); stageChanges(dir); diff --git a/agent-runner/test/profile.test.ts b/agent-runner/test/profile.test.ts new file mode 100644 index 0000000..a94bc9f --- /dev/null +++ b/agent-runner/test/profile.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { profileFor, resetProfileCache, sanitizeProfile, verifyCommandFromProfile } from "../src/profile.ts"; + +describe("profile: sanitizeProfile", () => { + it("keeps known fields and drops unknown/wrong types", () => { + const cleaned = sanitizeProfile({ + verify: ["npm test", " ", ""], + model: " deepseek-v4-pro ", + maxAttempts: 2, + protectedPaths: ["src/generated/"], + unknownKey: "ignored", + sensitivePaths: "not-an-array", + maxPrsPerDay: -1, + }); + assert.deepEqual(cleaned, { + verify: ["npm test"], + model: "deepseek-v4-pro", + maxAttempts: 2, + protectedPaths: ["src/generated/"], + }); + }); + + it("returns {} for junk", () => { + assert.deepEqual(sanitizeProfile(null), {}); + assert.deepEqual(sanitizeProfile("x"), {}); + assert.deepEqual(sanitizeProfile([]), {}); + }); +}); + +describe("profile: config/repos.json", () => { + afterEach(() => resetProfileCache()); + + it("applies the OrzMCApp profile", () => { + const profile = profileFor("OrzMC/OrzMCApp"); + assert.equal(verifyCommandFromProfile(profile), "bash scripts/check-platform-split.sh"); + assert.equal(profile.learningsFile, "docs/iteration-flow.md"); + assert.ok(profile.protectedPaths?.includes("Sources/")); + }); + + it("returns no overrides for unknown repositories", () => { + assert.deepEqual(profileFor("nobody/nothing"), {}); + assert.equal(verifyCommandFromProfile(profileFor("nobody/nothing")), null); + }); + + it("joins multiple commands with &&", () => { + assert.equal(verifyCommandFromProfile({ verify: ["a", "b"] }), "a && b"); + assert.equal(verifyCommandFromProfile({}), null); + }); +}); diff --git a/agent-runner/test/risk.test.ts b/agent-runner/test/risk.test.ts index c48d589..4f7c266 100644 --- a/agent-runner/test/risk.test.ts +++ b/agent-runner/test/risk.test.ts @@ -33,6 +33,18 @@ describe("assessRisk", () => { assert.deepEqual(risk.reasons, ["small, verified, non-protected change"]); }); + it("honours repository-specific protected paths and keeps the baseline", () => { + const config = { protectedPaths: ["Sources/", "OrzMC.xcodeproj/"], dependencyFiles: ["Package.swift"] }; + const sources = assessRisk({ paths: ["Sources/App.swift"], changedLines: 5, verify: verified, ...config }); + assert.equal(sources.level, "high"); + const manifest = assessRisk({ paths: ["Package.swift"], changedLines: 1, verify: verified, ...config }); + assert.equal(manifest.level, "high"); + const workflows = assessRisk({ paths: [".github/workflows/ci.yml"], changedLines: 1, verify: verified, ...config }); + assert.equal(workflows.level, "high", "baseline paths stay protected"); + const readme = assessRisk({ paths: ["README.md"], changedLines: 5, verify: verified, ...config }); + assert.equal(readme.level, "low"); + }); + it("uses medium for bigger diffs", () => { const risk = assessRisk({ paths: ["src/a.ts"], changedLines: 250, verify: verified }); assert.equal(risk.level, "medium"); diff --git a/agent-runner/test/verify.test.ts b/agent-runner/test/verify.test.ts index c2246ff..61c5045 100644 --- a/agent-runner/test/verify.test.ts +++ b/agent-runner/test/verify.test.ts @@ -71,6 +71,30 @@ describe("verify gate", () => { assert.equal(result.ok, false); }); + it("prefers a repository profile command over detection", async () => { + writePackage({ check: "node -e \"process.exit(0)\"" }); + const spec = await captureVerifySpec(dir, { verify: ["bash scripts/lint.sh"] }); + assert.equal(spec.command, "bash scripts/lint.sh"); + assert.equal(spec.scriptBody, null); + }); + + it("detects additional ecosystems when no profile is set", async () => { + writeFileSync(join(dir, "Package.swift"), "// swift\n"); + const spec = await captureVerifySpec(dir); + assert.equal(spec.command, "swift test"); + }); + + it("detects a weakened verification script (self-reference hole)", async () => { + mkdirSync(join(dir, "scripts"), { recursive: true }); + writeFileSync(join(dir, "scripts", "check.sh"), "#!/usr/bin/env bash\nexit 0\n"); + const spec = await captureVerifySpec(dir, { verify: ["bash scripts/check.sh"] }); + assert.deepEqual(Object.keys(spec.gateFiles ?? {}), ["scripts/check.sh"]); + writeFileSync(join(dir, "scripts", "check.sh"), "#!/usr/bin/env bash\n# weakened\nexit 0\n"); + const result = await runVerify(dir, spec, 30_000); + assert.equal(result.tampered, true); + assert.equal(result.verified, false); + }); + it("reports 'no check command' as skipped, never as verified", async () => { const empty = mkdtempSync(join(tmpdir(), "easygh-empty-")); try { diff --git a/docs/other-orgs.md b/docs/other-orgs.md index 86200df..a016927 100644 --- a/docs/other-orgs.md +++ b/docs/other-orgs.md @@ -97,7 +97,8 @@ Fork `EasyIndie/EasyGithub`,或 `git clone` 后推到自己的组织(public > **验证命令(零文件目标)**:因为目标仓库没有自己的 variables,App 模式从 **hub** 取验证配置: > 提交在 `agent-runner/config/verify.json`(如 `{"Org/repo":{"cmd":"cargo test","timeout_ms":900000}}`), > 或用 hub variable `EASYGH_VERIFY_CMDS`(同结构,优先级更高)。 -> 未配置时自动检测:`package.json` 脚本 → `cargo test` → `go test ./...` → 跳过。 +> 未配置时自动检测:`package.json` 脚本 → `cargo test` → `go test ./...` → `swift test` → 其它(见 `docs/portability.md`)→ 跳过。 +> 推荐把长期配置写进 `agent-runner/config/repos.json`(可 review),`EASYGH_VERIFY_CMDS` 仅作临时覆盖。 > Mode 2 则在**目标仓库**设 `VERIFY_CMD`(caller 已接好 `vars.VERIFY_CMD`)。 > > **调优(实测数据,EasyBot Rust workspace)**:一次真实运行中 `cargo test` 耗时 diff --git a/docs/portability.md b/docs/portability.md new file mode 100644 index 0000000..3121973 --- /dev/null +++ b/docs/portability.md @@ -0,0 +1,154 @@ +# 适配不同项目(通用化手册) + +EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / Rust / Swift / Java / Go / Python / 多语言仓库。 +本文说明哪些是通用的、哪些需要按仓库配置,以及接入一个新项目的完整清单。 + +## 1. 已经是通用的部分 + +| 能力 | 说明 | +|---|---| +| 触发契约 | Issue 标签 `ai`(或成员写 `easygh-ai`),状态标签 `ai-*` 固定不变 | +| 三种接入模式 | Mode 1 全复制 / Mode 2 caller workflow / Mode 3 零文件(GitHub App) | +| 引擎适配 | `pi` / `claude` / `codex`,统一在 `CodingAgent` 接口后(`src/agents/`) | +| 仲裁 | 验证命令在 agent 启动前冻结;篡改检测;路径/密钥审计;凭据隔离 | +| 交付 | 风险分级(low/medium/high)、草稿 PR、auto-merge、事故看护、周报 | +| 经验注入 | `docs/learnings.md`(可按仓库改名,见 profile) | +| 评测 | `eval/tasks/*.json` 冻结任务 + 隐藏测试 | + +## 2. 需要按仓库配置的部分 = 仓库画像(profile) + +配置文件:**`agent-runner/config/repos.json`**(放在 hub,因为零文件模式的目标仓库不能有流水线文件; +集中存放也便于 review 和回滚)。 + +```json +{ + "_defaults": {}, + "repos": { + "OrzMC/OrzMCApp": { + "verify": ["bash scripts/check-platform-split.sh"], + "protectedPaths": [".github/", "scripts/", "Sources/", "OrzMC/", "OrzMC.xcodeproj/"], + "dependencyFiles": ["Package.swift", "Package.resolved"], + "learningsFile": "docs/iteration-flow.md" + } + } +} +``` + +### 字段 + +| 字段 | 作用 | 默认 | +|---|---|---| +| `verify` | 验证命令数组,按顺序用 ` && ` 连接;**在 agent 启动前冻结** | 自动探测 | +| `verifyTimeoutMs` | 单次验证超时 | 600000(10 分钟) | +| `protectedPaths` / `protectedFiles` | 改到这些路径 → **high 风险**(草稿 PR + code owner) | `.github/`、`agent-runner/`、`scripts/`、`eval/`、`LICENSE` | +| `blockedPaths` / `blockedFiles` | 直接拒绝提交(运行失败) | `.github/workflows/`、`.github/actions/`、`LICENSE` | +| `sensitivePaths` / `sensitiveFiles` | 允许提交但打 `ai-core` 标签,必须人工审查 | `agent-runner/`、`.github/`、`scripts/`、`package.json` | +| `ignoredPaths` | 永不提交(vendored/生成物),即使仓库忘了 .gitignore | `node_modules/`、`dist/`、`build/`、`target/` … | +| `dependencyFiles` | 改到视为供应链变更 → high | `package.json`、`Cargo.toml`、`go.mod` … | +| `learningsFile` | 注入 TASK.md 的经验文件 | `docs/learnings.md` | +| `agent` | 默认引擎(标签 `agent:` 仍优先) | `pi` | +| `model` | 默认模型(`MODEL` 环境变量仍优先) | `deepseek-v4-flash` | +| `maxAttempts` / `maxPrsPerDay` | 重试次数 / 每日 PR 配额 | 3 / 10 | + +### 优先级(从低到高) + +``` +内置默认 -> _defaults -> repos["owner/repo"] -> 旧配置 config/verify.json -> 环境变量覆盖 +``` + +环境变量:`VERIFY_CMD`(单仓库一次性覆盖)、`EASYGH_VERIFY_CMDS`(JSON map,运维临时覆盖)、 +`AI_MAX_ATTEMPTS`、`AI_MAX_PRS_PER_DAY`、`MODEL`、`AGENT`、`PI_TIMEOUT_MS`。 + +> 列表字段是**替换**而不是叠加:profile 里写了 `protectedPaths` 就完全取代内置值。 +> 但 `.github/`、`.git/`、`LICENSE` 这类信任锚点在任何 profile 下都保持生效。 + +## 3. 验证命令的运行环境(重要约束) + +验证在 **hub 的 runner(默认 `ubuntu-latest`)** 上执行,不是目标仓库的 CI: + +- 命令必须能在 Ubuntu 上跑,且**不依赖目标仓库的 CI 环境**(服务、密钥、macOS 工具链)。 +- 需要 macOS/Xcode 的项目(如 iOS)→ 在 profile 里给一个 Ubuntu 可跑的轻量门禁(脚本/静态检查), + 重量级构建交给目标仓库自己的 CI(作为非必需检查)。 +- 需要私有依赖/密钥的构建 → 在 hub 加 secret,或把命令收窄到静态检查。 +- 没有可跑的检查时,结果会被标记为**未验证**(high 风险 → 草稿 PR),不会被当成"通过"。 + +### 自动探测顺序(没有 profile 时) + +1. `package.json` 的 `check` → `verify` → `test` → `lint` +2. `Cargo.toml` → `cargo test` +3. `go.mod` → `go test ./...` +4. `pytest.ini` / `tox.ini` → `python -m pytest -q` +5. `Package.swift` → `swift test` +6. `gradlew` → `./gradlew check` +7. `pom.xml` → `mvn -B test` +8. `mix.exs` → `mix test` +9. 都没有 → 跳过(未验证) + +## 4. 新项目接入清单 + +``` +[ ] 触发标签 `ai` 已建(App 模式会自动补建) +[ ] 仓库画像:config/repos.json 加一条(verify 必填;否则走自动探测) +[ ] protectedPaths:把"改了必须人看"的目录写进去(CI、发布脚本、签名、生成代码…) +[ ] dependencyFiles:该项目的依赖清单(lockfile / Package.resolved / go.sum …) +[ ] 目标仓库:allow_auto_merge + 0 批准 + 必需检查(bash scripts/enable-ai-gate.sh) +[ ] 周报:把仓库加进 EASYGH_RETRO_REPOS +[ ] 冒烟:一个 docs-only 的 `ai` Issue,确认 PR → auto-merge → Issue 关闭 +``` + +## 5. 典型画像示例 + +**Node / TypeScript 库** + +```json +"org/lib": { + "verify": ["npm ci --ignore-scripts", "npm run lint && npm test"], + "protectedPaths": [".github/", "scripts/", "src/generated/"], + "dependencyFiles": ["package.json", "package-lock.json"] +} +``` + +**Rust workspace(重量级)** + +```json +"org/bot": { + "verify": ["cargo test --workspace"], + "verifyTimeoutMs": 1500000, + "protectedPaths": [".github/", "crates/core/src/", "migrations/"], + "dependencyFiles": ["Cargo.toml", "Cargo.lock"] +} +``` + +**iOS / Swift(hub 跑 Ubuntu)** + +```json +"org/app": { + "verify": ["bash scripts/check-platform-split.sh"], + "protectedPaths": [".github/", "scripts/", "Sources/", "*.xcodeproj/"], + "dependencyFiles": ["Package.swift", "Package.resolved"] +} +``` + +**多语言 monorepo** + +```json +"org/mono": { + "verify": ["make verify"], + "protectedPaths": [".github/", "infra/", "packages/shared/"], + "learningsFile": "docs/agent-notes.md" +} +``` + +## 6. 仍然是"项目相关"的部分(有意保留) + +- **提示词语气与语言**:`agent-runner/prompts/*.md` 是模板,按需改(属于流水线自身,改它需要走 PR)。 +- **`docs/learnings.md` 的内容**:每个仓库自己积累,流水线不自动写。 +- **标签契约**(`ai`、`ai-*`):固定,避免每个仓库一套状态机。 +- **周报/评论的语言**:目前中文;需要多语言时再加 `AI_LOCALE`。 + +## 7. 给 EasyGithub 自己加项目时的心态 + +1. 先跑**自动探测**,看它选了什么命令(日志里 `gate frozen: cmd=...`)。 +2. 不可用/太慢 → 写 profile,而不是改 runner 代码。 +3. 只有在"新的判定语义"出现时才改代码(例如新增一种必须人工确认的路径类型)。 +4. 每次改 profile 都算"改流水线":走 PR,`ai-core` 标签,人工审查。