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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,不再把"跳过验证"当作"验证通过"。
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions agent-runner/config/repos.json
Original file line number Diff line number Diff line change
@@ -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[<owner>/<repo>] < 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"
}
}
}
16 changes: 8 additions & 8 deletions agent-runner/src/learnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -107,7 +107,7 @@ export function renderLearnings(workDir: string, query: string): string {
``,
`---`,
``,
`## 仓库经验(来自 ${LEARNINGS_FILE},仅作提示)`,
`## 仓库经验(来自 ${file},仅作提示)`,
``,
blocks.join("\n\n"),
``,
Expand Down
32 changes: 22 additions & 10 deletions agent-runner/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -170,6 +171,12 @@ async function main(): Promise<void> {
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();
Expand All @@ -180,11 +187,11 @@ async function main(): Promise<void> {
// 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);
Expand Down Expand Up @@ -283,18 +290,20 @@ async function main(): Promise<void> {
...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"})`);

Expand Down Expand Up @@ -327,7 +336,7 @@ async function main(): Promise<void> {
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 --------------------------
Expand Down Expand Up @@ -463,7 +472,7 @@ async function main(): Promise<void> {
}

// --- 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)) {
Expand All @@ -477,7 +486,7 @@ async function main(): Promise<void> {
}

// 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(
Expand All @@ -499,6 +508,9 @@ async function main(): Promise<void> {
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}`);

Expand Down
38 changes: 27 additions & 11 deletions agent-runner/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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"
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading
Loading