diff --git a/CHANGELOG.md b/CHANGELOG.md index b1df300..e3a83b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.10.0 (2026-10-16) + +### Breaking +- **PR auto-merge is removed.** The `pr-automerge` heartbeat merged completed docs/tests/research task PRs, and its CI gate read only the legacy combined-status API, which doesn't include GitHub Actions check runs and reports `pending` with zero statuses on an Actions-only repo. That was treated as "no CI configured", so a PR whose checks all failed could be merged. Four review rounds on a hardened gate showed that no file category is safe to merge by rule without branch protection: docs steer later agents (`CLAUDE.md`, `AGENTS.md`, prompts, skills), Markdown is served as pages on some sites, and a new test file is code that runs in CI next to deploy credentials. A human merge is the control. + +### Added +- **PR ready flag** (`pr-ready` heartbeat, every 2 h). It labels a completed task's PR `aegis:ready-to-merge` when: + - it is open, comes from an `auto/` branch in the same repo, and targets the repo's default branch; + - GitHub reports it mergeable; + - it doesn't touch `.github/` (checked case-insensitively, including the old path of a rename), since a PR that edits its own workflows decides which checks run on it; + - on the head commit, every commit status is `success`, every check run completed and passing, and at least one GitHub Actions check run succeeded. + Optional per-repo required checks (`integrations.github.prReady.requiredChecks`: `owner/name` → GitHub Actions job names) must each be successful Actions runs. Statuses and check runs are read up to 100 each; more is refused. A labelled PR that stops qualifying (for example a new commit fails CI), or that can't be re-checked, loses the label, and a label applied while the head moved is taken back off. The label means "CI reported green on the head commit at the last check": it can lag a push by up to one run (2 h), covers the newest tracked PRs, and CI is reported by code the PR can change (package scripts, test config), so re-check CI when merging. It applies to any completed task's PR, not only docs/tests/research. Up to 25 tracked PRs are re-evaluated per run, newest first; closed or merged PRs, and task rows whose `pr_url` isn't a GitHub PR, are marked done (`/closed`) and no longer read. +- `getCheckRuns()`, `getPullRequestFileDetails()`, `removeLabelFromIssue()`; `PullRequestStats.headSha`, `headRepo`, `baseDefaultBranch` and `labels`; `getCombinedStatus()` reads 100 statuses. + ## 0.9.4 (2026-09-24) ### Added diff --git a/README.md b/README.md index 6d59bd9..3105be5 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ AEGIS runs 26 tasks on an hourly cron, split into heartbeat (always-run) and tim | CI Watcher | Hourly | Monitor GitHub Actions runs | | ARGUS Notify | Hourly | Classify webhook events, route alerts | | Cognitive Metrics | Daily | Classifier accuracy, dispatch cost tracking | -| PR Automerge | Hourly | Auto-merge approved docs/tests PRs | +| PR ready flag | Every 2 h | Labels green autonomous-task PRs `aegis:ready-to-merge` for a human merge (never merges) | | Consolidation | Hourly | Memory dedup, decay, promotion, dynamic tool GC | | Heartbeat | 6h | System health + email digest | | Product Health | Hourly | Worker availability checks | diff --git a/web/package.json b/web/package.json index ff8db2f..ff3c98f 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.9.4", + "version": "0.10.0", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/src/github.ts b/web/src/github.ts index ba69bde..076c1c5 100755 --- a/web/src/github.ts +++ b/web/src/github.ts @@ -580,7 +580,7 @@ export async function getCombinedStatus( repo: string, ref: string, ): Promise { - const res = await ghFetch(token, `/repos/${repo}/commits/${encodeURIComponent(ref)}/status`); + const res = await ghFetch(token, `/repos/${repo}/commits/${encodeURIComponent(ref)}/status?per_page=100`); const data = (await res.json()) as { state: string; total_count: number; @@ -597,6 +597,36 @@ export async function getCombinedStatus( }; } +// ─── Check Runs ────────────────────────────────────────────── + +export interface CheckRunSummary { + total: number; + runs: { name: string; status: string; conclusion: string | null; app: string | null }[]; +} + +/** + * GitHub Actions (and other Checks API) results for a commit. The legacy + * combined-status endpoint above does not include them. + */ +export async function getCheckRuns( + token: string, + repo: string, + ref: string, +): Promise { + const res = await ghFetch(token, `/repos/${repo}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`); + const data = (await res.json()) as { + total_count: number; + check_runs: { name: string; status: string; conclusion: string | null; app?: { slug?: string } | null }[]; + }; + // GitHub's default filter=latest keeps only the most recent run per check + // name, so a passing re-run replaces a failed attempt. filter=all would make + // every re-run failure block forever. + return { + total: data.total_count, + runs: data.check_runs.map((r) => ({ name: r.name, status: r.status, conclusion: r.conclusion, app: r.app?.slug ?? null })), + }; +} + // ─── PR Stats ──────────────────────────────────────────────── export interface PullRequestStats { @@ -607,7 +637,14 @@ export interface PullRequestStats { deletions: number; changed_files: number; head: string; + /** Head commit SHA: what CI ran on, and what a merge must pin to. */ + headSha: string; + /** `owner/name` of the head branch's repo; null if the fork was deleted. */ + headRepo: string | null; base: string; + /** The base repo's default branch. */ + baseDefaultBranch: string; + labels: string[]; } export async function getPullRequestStats( @@ -623,8 +660,9 @@ export async function getPullRequestStats( additions: number; deletions: number; changed_files: number; - head: { ref: string }; - base: { ref: string }; + head: { ref: string; sha: string; repo: { full_name: string } | null }; + base: { ref: string; repo: { default_branch: string } }; + labels?: { name: string }[]; }; return { number: data.number, @@ -634,7 +672,11 @@ export async function getPullRequestStats( deletions: data.deletions, changed_files: data.changed_files, head: data.head.ref, + headSha: data.head.sha, + headRepo: data.head.repo?.full_name ?? null, base: data.base.ref, + baseDefaultBranch: data.base.repo.default_branch, + labels: (data.labels ?? []).map((l) => l.name), }; } @@ -650,6 +692,42 @@ export async function getPullRequestFiles( return data.map(f => f.filename); } +export interface PullRequestFile { + filename: string; + status: string; + previousFilename?: string; +} + +/** Changed files with status and, for renames, the old path. Reads up to 100. */ +export async function getPullRequestFileDetails( + token: string, + repo: string, + pullNumber: number, +): Promise { + const res = await ghFetch(token, `/repos/${repo}/pulls/${pullNumber}/files?per_page=100`); + const data = (await res.json()) as { filename: string; status: string; previous_filename?: string }[]; + return data.map((f) => ({ + filename: f.filename, + status: f.status, + ...(f.previous_filename ? { previousFilename: f.previous_filename } : {}), + })); +} + +/** Remove one label from an issue or PR; a label that isn't there is not an error. */ +export async function removeLabelFromIssue( + token: string, + repo: string, + issueNumber: number, + label: string, +): Promise { + try { + await ghFetch(token, `/repos/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`, { method: 'DELETE' }); + } catch (err) { + if (err instanceof GitHubApiError && err.status === 404) return; + throw err; + } +} + // ─── PR Merge ──────────────────────────────────────────────── export async function mergePullRequest( diff --git a/web/src/kernel/scheduled/index.ts b/web/src/kernel/scheduled/index.ts index 8ec47bf..e2a3eb0 100755 --- a/web/src/kernel/scheduled/index.ts +++ b/web/src/kernel/scheduled/index.ts @@ -18,7 +18,7 @@ import { runArgusNotifications } from './argus-notify.js'; import { runArgusHeartbeat } from './argus-heartbeat.js'; import { runCognitiveMetrics } from './cognitive-metrics.js'; import { runArgusAnalytics } from './argus-analytics.js'; -import { runPrAutomerge } from './pr-automerge.js'; +import { runPrReadyFlag } from './pr-ready.js'; import { runFeedWatcher } from './feed-watcher.js'; import { runCostReport } from './cost-report.js'; import { runBoardSync } from './board-sync.js'; @@ -134,7 +134,7 @@ async function runHeartbeatPhase(env: EdgeEnv): Promise { await runTask(env, 'argus-notify', 'heartbeat', runArgusNotifications); await runTask(env, 'argus-heartbeat', 'heartbeat', runArgusHeartbeat); await runTask(env, 'cognitive-metrics', 'heartbeat', runCognitiveMetrics); - await runTask(env, 'pr-automerge', 'heartbeat', runPrAutomerge); + await runTask(env, 'pr-ready', 'heartbeat', runPrReadyFlag); // Agent inbox -- process unread messages from peer agents await runTask(env, 'inbox', 'heartbeat', runInboxProcessor); diff --git a/web/src/kernel/scheduled/pr-automerge.ts b/web/src/kernel/scheduled/pr-automerge.ts deleted file mode 100644 index 1a11163..0000000 --- a/web/src/kernel/scheduled/pr-automerge.ts +++ /dev/null @@ -1,213 +0,0 @@ -// --- PR Auto-Merge --- -// Closes the self-improvement loop by auto-merging PRs from -// autonomous tasks that meet safety criteria. -// -// Gates: -// 1. Task category is auto_safe (docs, tests, research) -// 2. CI status is green (or no CI configured) -// 3. Diff is under LOC cap -// 4. No sensitive files touched -// 5. PR is open and mergeable -// -// Runs in Phase 1 (free -- GitHub API only, no LLM). - -import { type EdgeEnv } from '../dispatch.js'; -import { - getCombinedStatus, - getPullRequestStats, - getPullRequestFiles, - mergePullRequest, -} from '../../github.js'; - -import { AUTOMERGE_SAFE_CATEGORIES } from '../../schema-enums.js'; - -// --- Configuration --- - -/** Max total LOC changed (additions + deletions) */ -const MAX_LOC = 500; - -/** Files that block auto-merge if touched */ -const SENSITIVE_PATTERNS = [ - /\.env/i, - /secret/i, - /credential/i, - /wrangler\.toml$/, - /package\.json$/, - /package-lock\.json$/, - /pnpm-lock\.yaml$/, -]; - -/** Max PRs to process per run (rate limiting) */ -const MAX_PER_RUN = 3; - -/** Repos where auto-merge is disabled (production deploy targets) */ -const PROTECTED_REPOS = new Set([ - // Add your production repos here, e.g.: - // 'ExampleOrg/my-production-app', -]); - -// --- Types --- - -interface MergeCandidate { - taskId: string; - title: string; - repo: string; - category: string; - prUrl: string; - prNumber: number; - ghRepo: string; -} - -// --- Main --- - -export async function runPrAutomerge(env: EdgeEnv): Promise { - if (!env.githubToken) return; - - // Time guard: run every 2 hours (00, 02, 04, ..., 22 UTC) - const hour = new Date().getUTCHours(); - if (hour % 2 !== 0) return; - - // Find completed tasks with PRs in safe categories - const rows = await env.db.prepare(` - SELECT id, title, repo, category, pr_url - FROM cc_tasks - WHERE status = 'completed' - AND pr_url IS NOT NULL - AND category IN ('docs', 'tests', 'research') - AND pr_url NOT LIKE '%/merged%' - ORDER BY completed_at ASC - LIMIT ? - `).bind(MAX_PER_RUN * 2).all<{ - id: string; - title: string; - repo: string; - category: string; - pr_url: string; - }>(); - - if (rows.results.length === 0) return; - - // Parse PR URLs into merge candidates - const candidates: MergeCandidate[] = []; - for (const row of rows.results) { - const match = row.pr_url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); - if (!match) continue; - - const ghRepo = match[1]; - const prNumber = parseInt(match[2], 10); - - // Skip protected repos - if (PROTECTED_REPOS.has(ghRepo)) { - console.log(`[pr-automerge] Skipped ${ghRepo}#${prNumber} -- protected repo`); - continue; - } - - // Double-check category - if (!AUTOMERGE_SAFE_CATEGORIES.has(row.category as any)) continue; - - candidates.push({ - taskId: row.id, - title: row.title, - repo: row.repo, - category: row.category, - prUrl: row.pr_url, - prNumber, - ghRepo, - }); - } - - let merged = 0; - - for (const candidate of candidates.slice(0, MAX_PER_RUN)) { - try { - const result = await evaluateAndMerge(env, candidate); - if (result.merged) { - merged++; - // Mark in cc_tasks so we don't re-process - await env.db.prepare( - `UPDATE cc_tasks SET pr_url = ? WHERE id = ?` - ).bind(`${candidate.prUrl}/merged`, candidate.taskId).run(); - console.log(`[pr-automerge] Merged ${candidate.ghRepo}#${candidate.prNumber} -- "${candidate.title}"`); - } else { - console.log(`[pr-automerge] Skipped ${candidate.ghRepo}#${candidate.prNumber}: ${result.reason}`); - } - } catch (err) { - console.warn(`[pr-automerge] Error processing ${candidate.ghRepo}#${candidate.prNumber}:`, - err instanceof Error ? err.message : String(err)); - } - } - - if (merged > 0) { - console.log(`[pr-automerge] Merged ${merged} PR(s) this cycle`); - } -} - -// --- Evaluation --- - -interface EvalResult { - merged: boolean; - reason?: string; -} - -async function evaluateAndMerge( - env: EdgeEnv, - candidate: MergeCandidate, -): Promise { - const { githubToken } = env; - if (!githubToken) return { merged: false, reason: 'no token' }; - - // Gate 1: PR stats (open, mergeable, LOC) - const stats = await getPullRequestStats(githubToken, candidate.ghRepo, candidate.prNumber); - - if (stats.state !== 'open') { - return { merged: false, reason: `PR state: ${stats.state}` }; - } - - if (stats.mergeable === false) { - return { merged: false, reason: 'not mergeable (conflicts)' }; - } - - const totalLoc = stats.additions + stats.deletions; - if (totalLoc > MAX_LOC) { - return { merged: false, reason: `LOC ${totalLoc} > ${MAX_LOC} cap` }; - } - - // Gate 2: Sensitive files - const files = await getPullRequestFiles(githubToken, candidate.ghRepo, candidate.prNumber); - for (const file of files) { - for (const pattern of SENSITIVE_PATTERNS) { - if (pattern.test(file)) { - return { merged: false, reason: `sensitive file: ${file}` }; - } - } - } - - // Gate 3: CI status - const ciStatus = await getCombinedStatus(githubToken, candidate.ghRepo, stats.head); - - if (ciStatus.state === 'failure' || ciStatus.state === 'error') { - return { merged: false, reason: `CI ${ciStatus.state}` }; - } - - // CI pending is OK for repos without CI configured (total=0) - // But if there ARE statuses and they're pending, wait - if (ciStatus.state === 'pending' && ciStatus.total > 0) { - return { merged: false, reason: 'CI pending' }; - } - - // All gates passed -- merge with squash - const commitMsg = `${candidate.title} (#${candidate.prNumber})\n\nAuto-merged by AEGIS (category: ${candidate.category}, task: ${candidate.taskId.slice(0, 8)})`; - const mergeResult = await mergePullRequest( - githubToken, - candidate.ghRepo, - candidate.prNumber, - 'squash', - commitMsg, - ); - - if (!mergeResult.merged) { - return { merged: false, reason: `merge failed: ${mergeResult.message}` }; - } - - return { merged: true }; -} diff --git a/web/src/kernel/scheduled/pr-ready.ts b/web/src/kernel/scheduled/pr-ready.ts new file mode 100644 index 0000000..c9e0aa7 --- /dev/null +++ b/web/src/kernel/scheduled/pr-ready.ts @@ -0,0 +1,239 @@ +// --- PR Ready Flag --- +// Labels autonomous-task PRs that are ready for a human merge. It never +// merges: without branch protection and sandboxed CI, no file category is +// safe to merge by rule -- docs steer later agents, Markdown is served on +// some sites, and new tests are code that runs in CI next to deploy +// credentials. (This replaces PR auto-merge, removed in 0.10.0.) +// +// A PR is flagged when: +// 1. it belongs to a completed task and is still open +// 2. it comes from an `auto/` branch in the same repo and targets the +// repo's default branch +// 3. GitHub reports it mergeable (no conflicts) +// 4. it doesn't touch .github/ (a PR that edits its own workflows decides +// which checks run and pass on it) +// 5. CI on the head commit passed: every commit status is `success`, +// every check run completed and passing, at least one GitHub Actions +// check run succeeded, and every check the operator config requires +// for the repo (integrations.github.prReady.requiredChecks) is a +// successful GitHub Actions run +// +// The label means "CI reported green on the head commit at the last check": +// it can lag a new push by up to one run (2 h), covers only the newest +// tracked PRs, and CI is reported by code the PR itself can change -- so +// re-check CI when merging. A flagged PR that stops qualifying (say, a new +// commit fails CI), or can't be re-checked, loses the label. Closed or merged PRs are marked done on the task row so the +// heartbeat stops reading them. +// +// Runs in Phase 1 (free -- GitHub API only, no LLM). + +import type { EdgeEnv } from '../dispatch.js'; +import { + addLabelsToIssue, + removeLabelFromIssue, + getCombinedStatus, + getCheckRuns, + getPullRequestStats, + getPullRequestFileDetails, +} from '../../github.js'; +import { operatorConfig } from '../../operator/index.js'; + +// --- Configuration --- + +/** Label added to PRs that are ready for a human merge. */ +export const READY_LABEL = 'aegis:ready-to-merge'; + +/** + * Open task PRs evaluated per run, newest first. Every tracked PR is + * re-evaluated each run (so a label can be withdrawn), which takes a few + * GitHub calls apiece; old PRs that never close drop out of the window. + */ +const MAX_PER_RUN = 25; + +// --- Types --- + +interface Candidate { + taskId: string; + title: string; + prUrl: string; + prNumber: number; + ghRepo: string; +} + +type Outcome = + | { kind: 'flagged'; already: boolean } + | { kind: 'closed'; state: string } + | { kind: 'not_ready'; reason: string }; + +// --- Main --- + +export async function runPrReadyFlag(env: EdgeEnv): Promise { + if (!env.githubToken) return; + + // Time guard: run every 2 hours (00, 02, 04, ..., 22 UTC) + const hour = new Date().getUTCHours(); + if (hour % 2 !== 0) return; + + // Newest first, so new PRs are seen even when old ones are still open. + // '/merged' and '/closed' suffixes mark PRs already finished. + const rows = await env.db.prepare(` + SELECT id, title, pr_url + FROM cc_tasks + WHERE status = 'completed' + AND pr_url IS NOT NULL + AND pr_url NOT LIKE '%/merged' + AND pr_url NOT LIKE '%/closed' + ORDER BY completed_at DESC + LIMIT ? + `).bind(MAX_PER_RUN).all<{ id: string; title: string; pr_url: string }>(); + + for (const row of rows.results) { + const match = row.pr_url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)$/); + if (!match) { + // Not a PR URL this can judge; stop re-reading it. + await env.db.prepare(`UPDATE cc_tasks SET pr_url = ? WHERE id = ?`).bind(`${row.pr_url}/closed`, row.id).run(); + continue; + } + const candidate: Candidate = { + taskId: row.id, + title: row.title, + prUrl: row.pr_url, + ghRepo: match[1], + prNumber: parseInt(match[2], 10), + }; + + try { + const outcome = await evaluate(env.githubToken, candidate); + const ref = `${candidate.ghRepo}#${candidate.prNumber}`; + if (outcome.kind === 'closed') { + await env.db.prepare(`UPDATE cc_tasks SET pr_url = ? WHERE id = ?`) + .bind(`${candidate.prUrl}/closed`, candidate.taskId).run(); + console.log(`[pr-ready] ${ref} is ${outcome.state}; no longer tracked`); + } else if (outcome.kind === 'flagged') { + if (!outcome.already) console.log(`[pr-ready] Flagged ${ref} ready to merge -- "${candidate.title}"`); + } else { + console.log(`[pr-ready] Not ready ${ref}: ${outcome.reason}`); + } + } catch (err) { + console.warn(`[pr-ready] Error evaluating ${candidate.ghRepo}#${candidate.prNumber}:`, + err instanceof Error ? err.message : String(err)); + // Couldn't confirm the PR is still ready: withdraw any label (best effort) + // rather than leave a possibly stale one. + await removeLabelFromIssue(env.githubToken, candidate.ghRepo, candidate.prNumber, READY_LABEL).catch(() => {}); + } + } +} + +// --- Evaluation --- + +async function evaluate(token: string, candidate: Candidate): Promise { + const stats = await getPullRequestStats(token, candidate.ghRepo, candidate.prNumber); + + if (stats.state !== 'open') return { kind: 'closed', state: stats.state }; + + const labelled = stats.labels.includes(READY_LABEL); + const verdict = await judge(token, candidate, stats); + if (verdict) { + if (labelled) await removeLabelFromIssue(token, candidate.ghRepo, candidate.prNumber, READY_LABEL); + return { kind: 'not_ready', reason: labelled ? `${verdict} (label removed)` : verdict }; + } + if (!labelled) { + await addLabelsToIssue(token, candidate.ghRepo, candidate.prNumber, [READY_LABEL]); + // The label belongs to the PR, not the commit: if a push landed while CI + // was being checked, take it back off until the next run judges the new head. + const after = await getPullRequestStats(token, candidate.ghRepo, candidate.prNumber); + if (after.headSha !== stats.headSha) { + await removeLabelFromIssue(token, candidate.ghRepo, candidate.prNumber, READY_LABEL); + return { kind: 'not_ready', reason: `head moved to ${after.headSha.slice(0, 7)} while checking; label removed` }; + } + } + return { kind: 'flagged', already: labelled }; +} + +/** Why the PR isn't ready, or null when it is. */ +async function judge( + token: string, + candidate: Candidate, + stats: Awaited>, +): Promise { + const notReady = (reason: string): string => reason; + + // Provenance: the task's own branch in this repo, aimed at the default branch. + if (stats.headRepo !== candidate.ghRepo) { + return notReady(`head is from ${stats.headRepo ?? 'a deleted fork'}, not ${candidate.ghRepo}`); + } + if (!stats.head.startsWith('auto/')) { + return notReady(`head branch ${stats.head} is not an auto/ task branch`); + } + if (stats.base !== stats.baseDefaultBranch) { + return notReady(`targets ${stats.base}, not ${stats.baseDefaultBranch}`); + } + + if (stats.mergeable !== true) { + return notReady(stats.mergeable === false ? 'merge conflicts' : 'mergeability not computed yet'); + } + + const files = await getPullRequestFileDetails(token, candidate.ghRepo, candidate.prNumber); + if (stats.changed_files > files.length) { + return notReady(`too many changed files to verify (${stats.changed_files})`); + } + const workflowEdit = files.find((f) => [f.filename, f.previousFilename ?? ''].some((path) => path.toLowerCase().startsWith('.github/'))); + if (workflowEdit) { + return notReady(`touches ${workflowEdit.previousFilename?.toLowerCase().startsWith('.github/') ? workflowEdit.previousFilename : workflowEdit.filename}; a PR that edits .github/ decides its own checks`); + } + + const [status, checks] = await Promise.all([ + getCombinedStatus(token, candidate.ghRepo, stats.headSha), + getCheckRuns(token, candidate.ghRepo, stats.headSha), + ]); + const required = operatorConfig.integrations.github.prReady?.requiredChecks?.[candidate.ghRepo] ?? []; + const ci = ciVerdict(status, checks, required); + if (!ci.ok) return notReady(ci.reason); + return null; +} + +// --- CI verdict --- + +const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); + +/** + * Fail closed. GitHub Actions report through the Checks API, not commit + * statuses, and the combined status reads "pending" with zero statuses when + * a repo only uses Actions. Passing means: every commit status is `success`; + * every check run completed with `success`, `neutral` or `skipped`; at least + * one GitHub Actions check run concluded `success`; each required check is a + * GitHub Actions run concluding `success`; and everything fit in one page of + * 100 (more is refused rather than judged on a partial list). + */ +export function ciVerdict( + status: { total: number; statuses: { context: string; state: string }[] }, + checks: { total: number; runs: { name: string; status: string; conclusion: string | null; app?: string | null }[] }, + required: string[], +): { ok: true } | { ok: false; reason: string } { + if (checks.total > checks.runs.length) { + return { ok: false, reason: `too many check runs to verify (${checks.total})` }; + } + if (status.total > status.statuses.length) { + return { ok: false, reason: `too many commit statuses to verify (${status.total})` }; + } + const actionsPassed = (r: { status: string; conclusion: string | null; app?: string | null }) => + r.app === 'github-actions' && r.status === 'completed' && r.conclusion === 'success'; + if (!checks.runs.some(actionsPassed)) { + return { ok: false, reason: 'no successful GitHub Actions check run on head commit' }; + } + for (const name of required) { + if (!checks.runs.some((r) => r.name === name && actionsPassed(r))) { + return { ok: false, reason: `required check ${name} has not passed` }; + } + } + for (const s of status.statuses) { + if (s.state !== 'success') return { ok: false, reason: `status ${s.context}: ${s.state}` }; + } + for (const r of checks.runs) { + if (r.status !== 'completed') return { ok: false, reason: `check ${r.name}: ${r.status}` }; + if (!PASSING_CONCLUSIONS.has(r.conclusion ?? '')) { + return { ok: false, reason: `check ${r.name}: ${r.conclusion ?? 'no conclusion'}` }; + } + } + return { ok: true }; +} diff --git a/web/src/operator/config.example.ts b/web/src/operator/config.example.ts index 727b46c..4877cf2 100755 --- a/web/src/operator/config.example.ts +++ b/web/src/operator/config.example.ts @@ -41,7 +41,11 @@ const config: OperatorConfig = { fallbackUrl: 'https://your-bizops.example.com/mcp', toolPrefix: 'BizOps Copilot', }, - github: { enabled: true }, + github: { + enabled: true, + // PR ready flag: also require these GitHub Actions jobs before labelling a PR ready. + // prReady: { requiredChecks: { 'ExampleOrg/my-project': ['typecheck', 'test'] } }, + }, brave: { enabled: true }, email: { profiles: { diff --git a/web/src/operator/types.ts b/web/src/operator/types.ts index 316e9cf..c822309 100755 --- a/web/src/operator/types.ts +++ b/web/src/operator/types.ts @@ -28,7 +28,16 @@ export interface OperatorConfig { selfModel: SelfModel; integrations: { bizops: { enabled: boolean; fallbackUrl: string; toolPrefix: string }; - github: { enabled: boolean }; + github: { + enabled: boolean; + /** + * The PR ready flag labels green autonomous-task PRs for a human merge + * (nothing is merged automatically). Optionally require named checks + * per repo: keys are `owner/name`, values are GitHub Actions job + * names, each of which must have succeeded on the PR's head commit. + */ + prReady?: { requiredChecks?: Record }; + }; brave: { enabled: boolean }; email: { profiles: Record; diff --git a/web/src/schema-enums.ts b/web/src/schema-enums.ts index 634e099..7d2381a 100644 --- a/web/src/schema-enums.ts +++ b/web/src/schema-enums.ts @@ -84,9 +84,6 @@ export const AUTO_SAFE_CATEGORIES = new Set(['docs', 'tests', 'res /** Categories that require approval before execution. */ export const PROPOSED_CATEGORIES = new Set(['bugfix', 'feature']); -/** Subset safe for PR automerge. */ -export const AUTOMERGE_SAFE_CATEGORIES = new Set(['docs', 'tests', 'research']); - // ─── Narratives ───────────────────────────────────────────── export const MEMORY_GOAL_STATUSES = ['active', 'resolved', 'stalled', 'abandoned'] as const; diff --git a/web/src/version.ts b/web/src/version.ts index 6486056..f9532fa 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -1,3 +1,3 @@ // Semantic version - bump on each deploy // Used in /health endpoint and changelog tracking -export const VERSION = '0.9.4'; +export const VERSION = '0.10.0'; diff --git a/web/tests/pr-ready.test.ts b/web/tests/pr-ready.test.ts new file mode 100644 index 0000000..52f13a8 --- /dev/null +++ b/web/tests/pr-ready.test.ts @@ -0,0 +1,224 @@ +// PR ready flag: label green autonomous-task PRs for a human merge; never merge. +// Replaces PR auto-merge (0.10.0). The old gate read only the legacy +// combined-status API -- "pending" with zero statuses on an Actions-only repo +// -- and treated that as "no CI", so failed PRs could be merged. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ciVerdict, runPrReadyFlag, READY_LABEL } from '../src/kernel/scheduled/pr-ready.js'; +import { operatorConfig, setOperatorConfig } from '../src/operator/index.js'; + +const noStatuses = { total: 0, statuses: [] }; +const run = (name: string, status: string, conclusion: string | null, app: string | null = 'github-actions') => ({ name, status, conclusion, app }); +const green = run('typecheck', 'completed', 'success'); + +describe('ciVerdict', () => { + it('needs at least one successful GitHub Actions check run (the old hole: no CI passed)', () => { + expect(ciVerdict(noStatuses, { total: 0, runs: [] }, [])).toEqual({ ok: false, reason: 'no successful GitHub Actions check run on head commit' }); + expect(ciVerdict({ total: 1, statuses: [{ context: 'ci', state: 'success' }] }, { total: 0, runs: [] }, []).ok).toBe(false); + expect(ciVerdict(noStatuses, { total: 1, runs: [run('x', 'completed', 'success', 'other-app')] }, []).ok).toBe(false); + expect(ciVerdict(noStatuses, { total: 1, runs: [run('x', 'completed', 'skipped')] }, []).ok).toBe(false); + }); + + it('refuses a failed or unfinished check, and a non-success status', () => { + expect(ciVerdict(noStatuses, { total: 1, runs: [run('typecheck', 'completed', 'failure')] }, []).ok).toBe(false); + expect(ciVerdict(noStatuses, { total: 2, runs: [green, run('test', 'in_progress', null)] }, []).ok).toBe(false); + expect(ciVerdict({ total: 1, statuses: [{ context: 'ci', state: 'pending' }] }, { total: 1, runs: [green] }, []).ok).toBe(false); + }); + + it('requires each configured check as a successful GitHub Actions run', () => { + expect(ciVerdict(noStatuses, { total: 1, runs: [green] }, ['test'])).toEqual({ ok: false, reason: 'required check test has not passed' }); + expect(ciVerdict(noStatuses, { total: 2, runs: [green, run('test', 'completed', 'skipped')] }, ['test']).ok).toBe(false); + expect(ciVerdict({ total: 1, statuses: [{ context: 'test', state: 'success' }] }, { total: 1, runs: [green] }, ['test']).ok).toBe(false); + }); + + it('refuses to judge a partial list (pagination)', () => { + expect(ciVerdict(noStatuses, { total: 150, runs: [green] }, []).ok).toBe(false); + const thirty = Array.from({ length: 30 }, (_, i) => ({ context: `s${i}`, state: 'success' })); + expect(ciVerdict({ total: 31, statuses: thirty }, { total: 1, runs: [green] }, [])).toEqual({ ok: false, reason: 'too many commit statuses to verify (31)' }); + }); + + it('passes when everything passed (extra skipped/neutral runs allowed)', () => { + expect(ciVerdict({ total: 1, statuses: [{ context: 'ci', state: 'success' }] }, + { total: 3, runs: [green, run('b', 'completed', 'skipped'), run('c', 'completed', 'neutral')] }, [])).toEqual({ ok: true }); + }); +}); + +describe('runPrReadyFlag (mocked GitHub)', () => { + const HEAD_SHA = 'abc123def456'; + const original = operatorConfig; + let pr: Record; + let checkRuns: { total_count: number; check_runs: ReturnType[] }; + let calls: { url: string; method: string; body?: unknown }[]; + let logs: string[]; + let sqls: string[]; + let files: unknown[]; + let rows: Record[]; + let prReads: number; + let movedHead: string | null; + let checksFail: boolean; + + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + + function env() { + const updates: unknown[][] = []; + const db = { + prepare: (sql: string) => { + sqls.push(sql); + return { + bind: (...args: unknown[]) => ({ + all: async () => ({ results: rows }), + run: async () => { updates.push(args); return { success: true }; }, + }), + }; + }, + }; + return { env: { githubToken: 'test-token', db } as never, updates }; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-16T22:00:00Z')); + calls = []; logs = []; sqls = []; + prReads = 0; movedHead = null; checksFail = false; + files = [{ filename: 'docs/readme.md', status: 'modified' }]; + rows = [{ id: 'task-1234abcd', title: 'docs: fix typo', pr_url: 'https://github.com/ExampleOrg/demo/pull/7' }]; + pr = { + number: 7, state: 'open', mergeable: true, additions: 3, deletions: 1, changed_files: 1, + head: { ref: 'auto/task1234', sha: HEAD_SHA, repo: { full_name: 'ExampleOrg/demo' } }, + base: { ref: 'main', repo: { default_branch: 'main' } }, + labels: [], + }; + checkRuns = { total_count: 1, check_runs: [green] }; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { logs.push(a.join(' ')); }); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { logs.push('WARN ' + a.join(' ')); }); + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + calls.push({ url: u, method, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (u.endsWith('/pulls/7') && method === 'GET') { + prReads++; + return json(prReads > 1 && movedHead ? { ...pr, head: { ...(pr.head as object), sha: movedHead } } : pr); + } + if (u.includes('/check-runs') && checksFail) return new Response('forbidden', { status: 403 }); + if (u.includes(`/commits/${HEAD_SHA}/status?per_page=100`)) return json({ state: 'pending', total_count: 0, statuses: [] }); + if (u.includes(`/commits/${HEAD_SHA}/check-runs`)) { + return json({ ...checkRuns, check_runs: checkRuns.check_runs.map((r) => ({ ...r, app: r.app ? { slug: r.app } : null })) }); + } + if (u.includes('/pulls/7/files')) return json(files); + if (u.endsWith('/issues/7/labels') && method === 'POST') return json([{ name: READY_LABEL }]); + if (u.endsWith(`/issues/7/labels/${encodeURIComponent(READY_LABEL)}`) && method === 'DELETE') return json([]); + return new Response('not mocked: ' + u, { status: 404 }); + })); + }); + + afterEach(() => { + setOperatorConfig(original as never); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const labelled = () => calls.some((c) => c.url.endsWith('/issues/7/labels') && c.method === 'POST'); + const unlabelled = () => calls.some((c) => c.url.includes('/issues/7/labels/') && c.method === 'DELETE'); + const merged = () => calls.some((c) => c.url.includes('/merge')); + const notReady = (reason: string) => logs.some((l) => l.includes('Not ready ExampleOrg/demo#7') && l.includes(reason)); + + it('labels a green PR from the task branch, and never merges', async () => { + const { env: e, updates } = env(); + await runPrReadyFlag(e); + expect(calls.find((c) => c.url.endsWith('/issues/7/labels'))?.body).toEqual({ labels: [READY_LABEL] }); + expect(merged()).toBe(false); + expect(updates).toEqual([]); + }); + + it('reads newest tasks first and skips PRs already finished', async () => { + const { env: e } = env(); + await runPrReadyFlag(e); + expect(sqls[0]).toContain('ORDER BY completed_at DESC'); + expect(sqls[0]).toContain("NOT LIKE '%/closed'"); + }); + + it('marks a closed or merged PR done so it stops being re-read', async () => { + pr.state = 'closed'; + const { env: e, updates } = env(); + await runPrReadyFlag(e); + expect(updates).toEqual([['https://github.com/ExampleOrg/demo/pull/7/closed', 'task-1234abcd']]); + expect(labelled()).toBe(false); + }); + + it.each([ + ['a fork', () => { (pr.head as any).repo = { full_name: 'Outsider/demo' }; }, 'head is from Outsider/demo'], + ['a deleted fork', () => { (pr.head as any).repo = null; }, 'head is from a deleted fork'], + ['a non-task branch', () => { (pr.head as any).ref = 'feature/x'; }, 'is not an auto/ task branch'], + ['a non-default base', () => { (pr.base as any).ref = 'release'; }, 'targets release, not main'], + ['unknown mergeability', () => { pr.mergeable = null; }, 'mergeability not computed yet'], + ['conflicts', () => { pr.mergeable = false; }, 'merge conflicts'], + ['a failed check next to a green one', () => { checkRuns = { total_count: 2, check_runs: [green, run('test', 'completed', 'failure')] }; }, 'check test: failure'], + ['no CI', () => { checkRuns = { total_count: 0, check_runs: [] }; }, 'no successful GitHub Actions check run'], + ['a workflow edit', () => { files = [{ filename: '.github/workflows/ci.yml', status: 'modified' }]; }, 'touches .github/workflows/ci.yml'], + ['a workflow renamed away', () => { files = [{ filename: 'old-ci.yml', status: 'renamed', previous_filename: '.github/workflows/ci.yml' }]; }, 'touches .github/workflows/ci.yml'], + ['a workflow edit in a different case', () => { files = [{ filename: '.GitHub/workflows/ci.yml', status: 'added' }]; }, 'touches .GitHub/workflows/ci.yml'], + ['too many files to read', () => { pr.changed_files = 101; }, 'too many changed files to verify (101)'], + ])('does not flag %s', async (_name, setup, reason) => { + setup(); + const { env: e } = env(); + await runPrReadyFlag(e); + expect(labelled()).toBe(false); + expect(merged()).toBe(false); + expect(notReady(reason)).toBe(true); + }); + + it('withdraws the label when a flagged PR stops qualifying', async () => { + pr.labels = [{ name: READY_LABEL }]; + checkRuns = { total_count: 2, check_runs: [green, run('test', 'completed', 'failure')] }; + const { env: e } = env(); + await runPrReadyFlag(e); + expect(unlabelled()).toBe(true); + expect(labelled()).toBe(false); + expect(notReady('(label removed)')).toBe(true); + }); + + it('does not re-add the label to a PR that already has it', async () => { + pr.labels = [{ name: READY_LABEL }]; + const { env: e } = env(); + await runPrReadyFlag(e); + expect(labelled()).toBe(false); + expect(unlabelled()).toBe(false); + }); + + it('stops tracking a task whose pr_url is not a PR it can judge', async () => { + rows = [{ id: 'task-x', title: 't', pr_url: 'https://example.com/not-a-pr' }]; + const { env: e, updates } = env(); + await runPrReadyFlag(e); + expect(updates).toEqual([['https://example.com/not-a-pr/closed', 'task-x']]); + }); + + it('takes the label back off when the head moved while CI was being checked', async () => { + movedHead = 'fff999aaa000'; + const { env: e } = env(); + await runPrReadyFlag(e); + expect(labelled()).toBe(true); + expect(unlabelled()).toBe(true); + expect(notReady('head moved to fff999a while checking; label removed')).toBe(true); + }); + + it('withdraws the label when the PR can no longer be checked', async () => { + pr.labels = [{ name: READY_LABEL }]; + checksFail = true; + const { env: e } = env(); + await runPrReadyFlag(e); + expect(unlabelled()).toBe(true); + expect(logs.some((l) => l.startsWith('WARN') && l.includes('Error evaluating ExampleOrg/demo#7'))).toBe(true); + }); + + it('honours required checks from the operator config', async () => { + setOperatorConfig({ + ...original, + integrations: { ...original.integrations, github: { ...original.integrations.github, prReady: { requiredChecks: { 'ExampleOrg/demo': ['test'] } } } }, + } as never); + const { env: e } = env(); + await runPrReadyFlag(e); + expect(labelled()).toBe(false); + expect(notReady('required check test has not passed')).toBe(true); + }); +});