From f9f86ad4a5189fc2beafa49512d687c9a518a807 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:23:05 +0700 Subject: [PATCH 1/2] feat: implement DEV-86 - Enable automatic conflict sync/resolution across all open PRs in the repo via configuration --- docs/code/worker.md | 18 ++ packages/code/CHANGELOG.md | 4 + packages/code/src/index.ts | 19 +- .../code/src/lib/review-polling-acquirer.ts | 215 +++++++++++++- .../src/lib/workspace/workspace-worker.ts | 11 +- .../code/tests/automation-acquirer.test.ts | 6 +- .../tests/review-polling-acquirer.test.ts | 277 +++++++++++++++++- 7 files changed, 543 insertions(+), 7 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index 05f522a..3e20f8a 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -176,6 +176,24 @@ When a watched PR falls behind its base branch, the worker catches the branch up This applies only to the agent's own PRs (the same watch list as review polling). Each base/head SHA pair is a durable event in `.devintern-code/queue.db`; new commits on the PR branch open a fresh event, so an exhausted attempt is retried after the next push. Failures retry up to `WEBHOOK_MAX_RETRIES` (default 3), including across worker restarts. Before acting, the worker requires the PR head SHA to remain unchanged for `WORKER_BASE_SYNC_QUIET_SECONDS` (default 30) and then re-fetches both SHAs. Recent or concurrent pushes defer the run without consuming an attempt; if a run defers several times in a row, the event is given up until the head or base moves again. GitHub's PR API can report an outdated `base.sha` for a while, so the resolver always merges the actual fetched tip of the base branch rather than trusting that field. Each resolve run is bounded by `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800; `0` disables) — a hung resolver subprocess is killed and counted as a failed attempt, and runs left `in_progress` by a crashed or killed worker are marked failed at the next startup. The same merge logic is available manually for any PR via `devintern resolve-conflicts `. +### Syncing every open PR (`WORKER_BASE_SYNC_ALL_PRS`) + +By default only the agent's own PRs are synced, as described above. Set `WORKER_BASE_SYNC_ALL_PRS=true` in `.devintern-code/.env` to extend automatic base sync to **every open pull request** in the repo(s) the worker manages — useful when human teammates' long-lived branches also fall behind their base. + +Behavior with the flag enabled: + +- Discovery polls each watched repo's open-PR list every tick using conditional (ETag-cached) requests, so idle ticks stay rate-limit-free. The first sweep after startup fetches PR details once per open PR; afterwards only changed PRs cost requests. The list covers up to the first 100 open PRs per repo — while a repo sits at that page-size cap, the worker logs a warning, since PRs beyond it are invisible to discovery. +- Closed and merged PRs drop out of the open list on their own; draft PRs are never auto-synced (a draft signals work in progress). +- Fork PRs are skipped: devintern cannot — and should not — push merges to forks, so conflicts there remain the author's to resolve. +- Review feedback handling stays an own-PR feature: other people's PRs are only ever caught up with their base, never addressed or replied to beyond the sync outcome. +- Everything else works exactly as for the agent's own PRs: quiet period, durable per-SHA events, retry limits, no force-pushes, and branch protection rules apply equally. + +**Trust boundaries:** unlike the agent's own PRs, these branches are authored by anyone who can open a PR, so their content is untrusted input. What contains it: all resolution work happens inside a dedicated branch-scoped worktree under `/tmp/`, never in your checkout; fork PRs and drafts never enter the pipeline; merge pushes are lease-verified and never forced; and each resolver subprocess is killed at `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800). What does *not* contain it: genuine conflict resolution hands the conflicted tree to the coding agent, which may run project tooling (typecheck/tests) inside that worktree — the same pipeline the agent's own PRs use. On repositories that accept PRs from arbitrary third parties, run the agent sandboxed (`AGENT_SANDBOX`, see [Sandboxing the Agent](./configuration.md#sandboxing-the-agent)) and let branch protection decide what actually lands. There is no per-author allowlist: the flag is all-or-nothing per watched repo. + +The sweep also needs at least one repo target. Single-repo workers derive it from the checkout's detected GitHub repo (workspace mode uses `workspace.toml`); if detection fails and the agent has no open PR yet, discovery has nothing to run on and the worker logs a "nothing to sweep" warning until a target exists. + +Sync activity on non-devintern PRs is fully auditable: the worker logs each foreign PR it starts/stops watching (`👀 … watching owner/repo#N (open PR) for base sync`) and marks its sync lines with `(external PR)`; the resolver posts a comment on the PR describing what was merged; and every attempt is recorded as a `conflict_resolution` run in the [dashboard](./dashboard.md). + ## Mention the bot on any PR The worker also reacts to mentions on pull requests it did not create. When a teammate writes a comment like `@devintern address the review feedback` on any PR in the repository, the worker picks it up on the next poll and handles it through the same pipeline. Detection is a repository-wide sweep of new comments (two requests per interval, regardless of how many PRs are open), so mentions work without any webhook setup. diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index c9943a0..525dd6a 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Opt-in base sync for every open PR (`WORKER_BASE_SYNC_ALL_PRS`)**: setting it to `true` extends the worker's automatic merge-conflict/base-behind sync from just the agent's own PRs to every open, non-draft PR in the managed repo(s) — useful when teammates' long-lived branches fall behind their base. Discovery uses ETag-cached open-PR list polling (rate-limit friendly, first 100 open PRs per repo), fork PRs are skipped, review feedback stays an own-PR feature, and every foreign sync is logged and recorded as a `conflict_resolution` run. Default remains off: only devintern's own PRs are synced + ## [2.5.0] - 2026-08-26 Recurring scheduled automations, Supporter licenses covering multi-repo workspace mode, anonymous CLI analytics, and pickup/sync/JSON robustness fixes. diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 2a77141..fd4d35e 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -644,6 +644,9 @@ if (process.argv[2] === "init") { console.log( " WORKER_BASE_SYNC_QUIET_SECONDS Stable-head window before PR base sync (default: 30)", ); + console.log(" WORKER_BASE_SYNC_ALL_PRS"); + console.log(" Set to true to base-sync every open PR in the repo,"); + console.log(" not just the agent's own (default: disabled)"); console.log( " WEBHOOK_MAX_RETRIES Retry limit for queued and PR base-sync work (default: 3)", ); @@ -765,8 +768,12 @@ if (process.argv[2] === "init") { // (webhooks already deliver reviews there — polling too would double-run) // and without GitHub credentials. if (!listen && (process.env.GITHUB_TOKEN || process.env.GITHUB_APP_ID)) { - const { ReviewPollingAcquirer, runAddressReviewViaCli, runResolveConflictsViaCli } = - await import("./lib/review-polling-acquirer"); + const { + ReviewPollingAcquirer, + runAddressReviewViaCli, + runResolveConflictsViaCli, + OPEN_PR_LIST_PAGE_SIZE, + } = await import("./lib/review-polling-acquirer"); const { GitHubReviewsClient } = await import("./lib/github-reviews"); const { RunStore } = await import("./lib/run-recorder"); const gh = new GitHubReviewsClient({ preferAppAuth: true }); @@ -822,6 +829,13 @@ if (process.argv[2] === "init") { ); return result.data ?? []; }, + listOpenPullRequests: (repo, etag) => + gh.conditionalGet( + `/repos/${repo}/pulls?state=open&per_page=${OPEN_PR_LIST_PAGE_SIZE}`, + ownerOf(repo), + nameOf(repo), + etag, + ), }, addressPr: (repo, n) => runAddressReviewViaCli(repo, n), resolveConflicts: (repo, n, expected) => @@ -832,6 +846,7 @@ if (process.argv[2] === "init") { quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), runStore, allowedRepos: repoSlug ? [repoSlug] : undefined, + syncAllOpenPrs: process.env.WORKER_BASE_SYNC_ALL_PRS === "true", verbose, }), ); diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index ff0c18a..35ead58 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -53,10 +53,19 @@ export interface PolledPr { state: string; /** GitHub's computed merge state; `"dirty"` means merge conflicts. */ mergeable_state?: string; + /** Work-in-progress PR (excluded from all-PR base sync). */ + draft?: boolean; head?: { sha: string; ref?: string; repo?: { full_name: string } | null }; base?: { sha: string; ref?: string }; } +/** Open-PR list entry used to discover PRs beyond the agent's own. */ +export interface PolledPrListItem { + number: number; + /** Draft PRs are excluded from all-PR base sync. */ + draft?: boolean; +} + export interface AutomaticResolveResult { outcome: "clean" | "resolved" | "skipped" | "failed" | "deferred"; message: string; @@ -75,6 +84,14 @@ export interface ReviewPollingGitHub { prNumber: number, sinceIso: string, ): Promise; + /** + * List the repo's open PRs (discovery for `syncAllOpenPrs`). Omit when the + * feature is disabled. + */ + listOpenPullRequests?( + repo: string, + etag?: string, + ): Promise>; } export interface ReviewPollingAcquirerOptions { @@ -107,6 +124,15 @@ export interface ReviewPollingAcquirerOptions { */ allowedRepos?: string[]; verbose?: boolean; + /** + * Extend automatic base sync to every open, non-draft PR in the watched + * repos — not just the agent's own PRs (`WORKER_BASE_SYNC_ALL_PRS=true`). + * Requires `github.listOpenPullRequests`; default off. + * + * Foreign PR branches are untrusted input; see `sweepRepoOpenPrs` for + * what that contains (and does not). + */ + syncAllOpenPrs?: boolean; } /** Dedupe source for review/comment ids. */ @@ -125,6 +151,13 @@ const MAX_CONSECUTIVE_DEFERS = 3; /** Default wall-clock budget for one resolve-conflicts subprocess. */ export const DEFAULT_RESOLVE_TIMEOUT_MS = 30 * 60 * 1000; +/** + * Page size the built-in GitHub clients request when listing a repo's open + * PRs. A list reaching this size means GitHub truncated it: open PRs past + * the first page are invisible to base-sync discovery. + */ +export const OPEN_PR_LIST_PAGE_SIZE = 100; + /** Resolver subprocess budget; `WORKER_RESOLVE_TIMEOUT_SECONDS` overrides (0 disables). */ function resolveTimeoutMs(overrideMs?: number): number { if (overrideMs !== undefined) return overrideMs; @@ -359,9 +392,28 @@ export class ReviewPollingAcquirer implements Acquirer { private prCache = new Map(); /** Consecutive `deferred` resolver outcomes per base-sync event. */ private deferCounts = new Map(); + /** Effective all-open-PR sync switch (disabled when discovery is unavailable). */ + private readonly syncAllActive: boolean; + /** Last open-PR list seen per repo (lets a 304 reuse the previous set). */ + private foreignListItems = new Map(); + /** Repos whose at-cap open-PR list warning is active (reset when below the cap). */ + private capWarnedRepos = new Set(); + /** Whether the "nothing to sweep" warning is active (reset when targets exist). */ + private emptySweepWarned = false; + /** Foreign PRs currently watched for base sync (audit logging). */ + private foreignWatched = new Set(); + private foreignPrCache = new Map(); constructor(options: ReviewPollingAcquirerOptions) { this.options = options; + this.syncAllActive = Boolean(options.syncAllOpenPrs); + if (this.syncAllActive && !options.github.listOpenPullRequests) { + console.warn( + `⚠️ [${this.name}] WORKER_BASE_SYNC_ALL_PRS is enabled but the GitHub client cannot ` + + `list open PRs; syncing only the agent's own PRs`, + ); + this.syncAllActive = false; + } } /** Start polling: immediate first tick, then on the configured interval. */ @@ -380,6 +432,18 @@ export class ReviewPollingAcquirer implements Acquirer { `🔎 Polling reviews on agent PRs every ${this.options.intervalSeconds}s ` + `(watching ${this.options.workerState.listOpenAgentPrs().length} open PR(s))`, ); + if (this.syncAllActive) { + console.log( + `🌐 [${this.name}] base sync extended to every open PR in the watched repo(s) ` + + `(WORKER_BASE_SYNC_ALL_PRS)`, + ); + console.warn( + `⚠️ [${this.name}] foreign PRs are authored by anyone who can open one, so their ` + + `content is treated as untrusted: forks and drafts are skipped and each resolver ` + + `run is bounded by WORKER_RESOLVE_TIMEOUT_SECONDS, but conflict resolution may ` + + `invoke the coding agent — consider AGENT_SANDBOX`, + ); + } await this.tick(); this.timer = setInterval(() => void this.tick(), this.options.intervalSeconds * 1000); } @@ -420,6 +484,36 @@ export class ReviewPollingAcquirer implements Acquirer { ); } } + + if (this.syncAllActive) { + const repos = + allowedRepos && allowedRepos.length > 0 + ? allowedRepos + : [...new Set(watchedPrs.map((pr) => pr.repo))]; + if (repos.length === 0) { + // No managed repo configured and nothing registered in the + // transient own-PR watch list: discovery would silently never run. + if (!this.emptySweepWarned) { + this.emptySweepWarned = true; + console.warn( + `⚠️ [${this.name}] WORKER_BASE_SYNC_ALL_PRS is enabled but there is nothing to ` + + `sweep: no managed repo is configured and the agent has no open PR to derive ` + + `a repo from`, + ); + } + } else { + this.emptySweepWarned = false; + for (const repo of repos) { + try { + await this.sweepRepoOpenPrs(repo, watchedKeys); + } catch (error) { + console.warn( + `⚠️ [${this.name}] listing open PRs on ${repo} failed: ${(error as Error).message}`, + ); + } + } + } + } } finally { this.busy = false; } @@ -521,7 +615,121 @@ export class ReviewPollingAcquirer implements Acquirer { ); } - private async maybeSyncBase(repo: string, prNumber: number, pr: PolledPr): Promise { + /** + * Discover open PRs beyond the agent's own (`WORKER_BASE_SYNC_ALL_PRS`) + * and run base-sync-only polling on each eligible one. Review feedback + * stays an own-PR feature: foreign PRs are never addressed, only caught + * up with their base. + * + * Trust model: unlike the agent's own PRs, foreign branches are authored + * by anyone who can open a PR, so this path processes untrusted input. + * Contained by construction: work happens inside a dedicated branch-scoped + * review worktree (`prepareReviewWorktree`), never the worker checkout; + * fork heads are terminally skipped and drafts never enter; pushes are + * lease-verified and never forced; and each resolver subprocess is killed + * at `WORKER_RESOLVE_TIMEOUT_SECONDS`. Not contained: a genuinely + * conflicting merge hands the conflicted tree to the coding agent, which + * may run project tooling (typecheck/tests) inside that worktree — the + * same pipeline the agent's own PRs get. Operators enabling this flag on + * repos with untrusted contributors should sandbox the agent + * (`AGENT_SANDBOX`) and gate merges via branch protection. + */ + private async sweepRepoOpenPrs(repo: string, ownKeys: Set): Promise { + const { github, workerState } = this.options; + const listOpenPullRequests = github.listOpenPullRequests; + if (!listOpenPullRequests) return; + + // Hydrate once per process even when an ETag survived a restart + // (mirrors pollPr), then reuse conditional requests on later ticks. + const listSource = `github:open-prs:${repo}`; + const listCursor = workerState.getCursor(listSource); + const result = await listOpenPullRequests( + repo, + this.foreignListItems.has(repo) ? listCursor?.etag : undefined, + ); + if (!result.notModified && result.data) { + this.foreignListItems.set(repo, result.data); + if (result.etag) workerState.setCursor(listSource, "state", result.etag); + // The built-in clients fetch a single per_page=OPEN_PR_LIST_PAGE_SIZE + // page: a full page means GitHub truncated the list, so PRs beyond it + // are silently invisible to discovery. Surface that once (until the + // list dips below the cap again) instead of failing silently. + if (result.data.length >= OPEN_PR_LIST_PAGE_SIZE && !this.capWarnedRepos.has(repo)) { + this.capWarnedRepos.add(repo); + console.warn( + `⚠️ [${this.name}] ${repo}'s open-PR list hit its page-size cap (${result.data.length} ` + + `PRs); open PRs past the first ${OPEN_PR_LIST_PAGE_SIZE} are not discovered for base sync`, + ); + } else if (result.data.length < OPEN_PR_LIST_PAGE_SIZE) { + this.capWarnedRepos.delete(repo); + } + } + const items = this.foreignListItems.get(repo) ?? []; + + const currentKeys = new Set(); + for (const item of items) { + const key = `${repo.toLowerCase()}#${item.number}`; + currentKeys.add(key); + // The agent's own PRs go through the full review pipeline above. + if (ownKeys.has(key)) continue; + // Drafts signal work in progress; auto-merging into them surprises authors. + if (item.draft === true) continue; + if (!this.foreignWatched.has(key)) { + this.foreignWatched.add(key); + console.log(`👀 [${this.name}] watching ${repo}#${item.number} (open PR) for base sync`); + } + try { + await this.pollForeignPr(repo, item.number); + } catch (error) { + console.warn( + `⚠️ [${this.name}] polling ${repo}#${item.number} failed: ${(error as Error).message}`, + ); + } + } + + for (const key of [...this.foreignWatched]) { + if (!key.startsWith(`${repo.toLowerCase()}#`) || currentKeys.has(key)) continue; + this.foreignWatched.delete(key); + this.foreignPrCache.delete(key); + console.log(`👋 [${this.name}] ${key} left the open-PR list; no longer watching`); + } + } + + /** Base-sync-only polling for a PR the agent did not create. */ + private async pollForeignPr(repo: string, prNumber: number): Promise { + const { github, resolveConflicts, workerState } = this.options; + if (!resolveConflicts) return; + + const prSource = `github:base-sync-pr:${repo}#${prNumber}`; + const prKey = this.prKey(repo, prNumber); + const prResult = await github.fetchPr( + repo, + prNumber, + this.foreignPrCache.has(prKey) ? workerState.getCursor(prSource)?.etag : undefined, + ); + if (prResult.notModified) { + const cachedPr = this.foreignPrCache.get(prKey); + if (cachedPr) await this.maybeSyncBase(repo, prNumber, cachedPr, { external: true }); + return; + } + if (prResult.etag) workerState.setCursor(prSource, "state", prResult.etag); + const pr = prResult.data; + if (!pr || pr.state !== "open") { + // Closed/merged PRs drop out on the next open-PR list refresh. + this.foreignPrCache.delete(prKey); + return; + } + if (pr.draft === true) return; + this.foreignPrCache.set(prKey, pr); + await this.maybeSyncBase(repo, prNumber, pr, { external: true }); + } + + private async maybeSyncBase( + repo: string, + prNumber: number, + pr: PolledPr, + opts: { external?: boolean } = {}, + ): Promise { const { github, queue, resolveConflicts, runStore } = this.options; if (!resolveConflicts) return; if (!pr.head?.sha || !pr.base?.sha || !pr.head.ref) return; @@ -613,7 +821,10 @@ export class ReviewPollingAcquirer implements Acquirer { console.warn(`⚠️ Run recording (base sync begin) failed: ${(error as Error).message}`); } - console.log(`\n🔀 [${this.name}] syncing ${repo}#${prNumber} with its advanced base`); + console.log( + `\n🔀 [${this.name}] syncing ${repo}#${prNumber}` + + `${opts.external ? " (external PR)" : ""} with its advanced base`, + ); let result: AutomaticResolveResult; try { result = await resolveConflicts(repo, prNumber, { diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 0011f9b..6cfa3d7 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -466,7 +466,8 @@ async function buildFleetEventAcquirers(options: { // Tier 1: the agent's own PRs (central agent_prs registry is repo-keyed, // so one acquirer covers the whole fleet). - const { ReviewPollingAcquirer } = await import("../review-polling-acquirer"); + const { ReviewPollingAcquirer, OPEN_PR_LIST_PAGE_SIZE } = + await import("../review-polling-acquirer"); const { RunStore } = await import("../run-recorder"); const runStore = new RunStore(state.dbPath); const reapedRuns = runStore.reapOrphanedRuns(); @@ -500,12 +501,20 @@ async function buildFleetEventAcquirers(options: { ); return result.data ?? []; }, + listOpenPullRequests: (repo, etag) => + gh.conditionalGet( + `/repos/${repo}/pulls?state=open&per_page=${OPEN_PR_LIST_PAGE_SIZE}`, + ownerOf(repo), + nameOf(repo), + etag, + ), }, addressPr, resolveConflicts, quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), runStore, allowedRepos: slugs, + syncAllOpenPrs: process.env.WORKER_BASE_SYNC_ALL_PRS === "true", verbose, }), ); diff --git a/packages/code/tests/automation-acquirer.test.ts b/packages/code/tests/automation-acquirer.test.ts index b8e098c..6615f88 100644 --- a/packages/code/tests/automation-acquirer.test.ts +++ b/packages/code/tests/automation-acquirer.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -128,6 +128,10 @@ describe("AutomationAcquirer", () => { test("falls back to the run cwd's config dir when no parent config exists", () => { const worktree = join(tmpdir(), `acquirer-fallback-${Date.now()}-${Math.random()}`); mkdirSync(worktree, { recursive: true }); + // Disposable worktrees carry a `.git` file; it also pins config-dir + // traversal to this directory so ambient ancestors (/tmp, /) cannot + // satisfy the lookup on a shared machine. + writeFileSync(join(worktree, ".git"), "gitdir: /tmp/fake-worktree.git\n"); const filePath = writeAutomationTaskFile( { diff --git a/packages/code/tests/review-polling-acquirer.test.ts b/packages/code/tests/review-polling-acquirer.test.ts index 0297dc3..c1cca25 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -4,6 +4,7 @@ import { join } from "path"; import { tmpdir } from "os"; import { + OPEN_PR_LIST_PAGE_SIZE, ReviewPollingAcquirer, runResolveConflictsViaCli, } from "../src/lib/review-polling-acquirer"; @@ -39,10 +40,12 @@ describe("ReviewPollingAcquirer", () => { interface FakeGitHubState { prState: string; mergeableState?: string; + draft?: boolean; headSha?: string; baseSha?: string; baseIncluded?: boolean | null; prEtagHit?: boolean; + listEtagHit?: boolean; reviews: PolledReview[]; reviewsEtagHit?: boolean; comments: PolledComment[]; @@ -62,10 +65,14 @@ describe("ReviewPollingAcquirer", () => { quietPeriodSeconds?: number; now?: () => number; allowedRepos?: string[]; + syncAllOpenPrs?: boolean; + openPrs?: Array<{ number: number; draft?: boolean }>; } = {}, ) { const addressed: string[] = []; const resolved: string[] = []; + const listCalls: Array = []; + const openPrs = options.openPrs; const acquirer = new ReviewPollingAcquirer({ intervalSeconds: 60, workerState, @@ -80,6 +87,7 @@ describe("ReviewPollingAcquirer", () => { data: { state: gh.prState, mergeable_state: gh.mergeableState, + draft: gh.draft, head: gh.headSha ? { sha: gh.headSha, ref: "agent/task", repo: { full_name: "acme/widgets" } } : undefined, @@ -100,6 +108,20 @@ describe("ReviewPollingAcquirer", () => { gh.seenSince = sinceIso; return gh.comments; }, + ...(openPrs + ? { + async listOpenPullRequests( + _repo: string, + etag?: string, + ): Promise>> { + listCalls.push(etag); + if (gh.listEtagHit) { + return { data: null, etag, notModified: true }; + } + return { data: openPrs, etag: 'W/"list-1"', notModified: false }; + }, + } + : {}), }, addressPr: async (repo, n) => { addressed.push(`${repo}#${n}`); @@ -113,12 +135,21 @@ describe("ReviewPollingAcquirer", () => { runStore: options.runStore, now: options.now, allowedRepos: options.allowedRepos, + syncAllOpenPrs: options.syncAllOpenPrs, }); - return { acquirer, addressed, resolved }; + return { acquirer, addressed, resolved, listCalls }; } const human = { login: "reviewer", type: "User" }; const bot = { login: "devintern[bot]", type: "Bot" }; + const dirtyState = (): FakeGitHubState => ({ + prState: "open", + mergeableState: "dirty", + headSha: "head1", + baseSha: "base1", + reviews: [], + comments: [], + }); test("a new human changes_requested review triggers one address run", async () => { workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); @@ -704,6 +735,250 @@ describe("ReviewPollingAcquirer", () => { await acquirer.tick(); expect(addressed).toEqual(["acme/widgets#42"]); }); + + test("foreign open PRs are not synced by default (WORKER_BASE_SYNC_ALL_PRS off)", async () => { + const gh = dirtyState(); + const made = makeAcquirer(gh, { + openPrs: [{ number: 7 }], + allowedRepos: ["acme/widgets"], + }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + // Discovery never runs when the feature is off. + expect(made.listCalls).toEqual([]); + }); + + test("enabled: foreign open PRs are synced but their feedback is never addressed", async () => { + const gh = dirtyState(); + gh.reviews = [{ id: 1, state: "changes_requested", user: human }]; + const made = makeAcquirer(gh, { + syncAllOpenPrs: true, + openPrs: [{ number: 7 }], + allowedRepos: ["acme/widgets"], + }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#7"]); + expect(made.addressed).toEqual([]); + // First discovery hydrates unconditionally. + expect(made.listCalls).toEqual([undefined]); + + // Next tick reuses the list ETag and dedupes by base/head SHA. + await made.acquirer.tick(); + expect(made.listCalls).toEqual([undefined, 'W/"list-1"']); + expect(made.resolved).toHaveLength(1); + + // A moved base opens a fresh event for the foreign PR too. + gh.baseSha = "base2"; + await made.acquirer.tick(); + expect(made.resolved).toHaveLength(2); + }); + + test("enabled: a 304 open-PR list reuses the previously hydrated item set", async () => { + const gh = dirtyState(); + const made = makeAcquirer(gh, { + syncAllOpenPrs: true, + openPrs: [{ number: 12 }], + allowedRepos: ["acme/widgets"], + }); + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#12"]); + + // Unchanged list: the conditional request 304s (rate-limit-free), and + // the cached item set keeps driving per-PR base-sync polling. + gh.listEtagHit = true; + await made.acquirer.tick(); + expect(made.listCalls).toEqual([undefined, 'W/"list-1"']); + expect(made.resolved).toHaveLength(1); + + // Still 304-ing, a moved base on the cached foreign PR resolves again. + gh.baseSha = "base2"; + await made.acquirer.tick(); + expect(made.listCalls).toEqual([undefined, 'W/"list-1"', 'W/"list-1"']); + expect(made.resolved).toEqual(["acme/widgets#12", "acme/widgets#12"]); + }); + + test("enabled: restart hydration refetches the open-PR list despite a stored ETag", async () => { + // A previous process persisted the list ETag; the fresh acquirer's + // in-memory item cache is empty, so the first sweep must fetch + // unconditionally instead of trusting the stale ETag. + workerState.setCursor("github:open-prs:acme/widgets", "state", 'W/"list-stale"'); + const made = makeAcquirer(dirtyState(), { + syncAllOpenPrs: true, + openPrs: [{ number: 13 }], + allowedRepos: ["acme/widgets"], + }); + + await made.acquirer.tick(); + expect(made.listCalls).toEqual([undefined]); + expect(made.resolved).toEqual(["acme/widgets#13"]); + + // Later ticks reuse the refreshed ETag. + await made.acquirer.tick(); + expect(made.listCalls).toEqual([undefined, 'W/"list-1"']); + }); + + test("enabled: the agent's own PRs are polled once even when listed as open", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const made = makeAcquirer(dirtyState(), { + syncAllOpenPrs: true, + openPrs: [{ number: 42 }, { number: 43 }], + allowedRepos: ["acme/widgets"], + }); + + await made.acquirer.tick(); + expect([...made.resolved].sort()).toEqual(["acme/widgets#42", "acme/widgets#43"]); + }); + + test("enabled: draft PRs are excluded from sync at discovery and per-PR polling", async () => { + const gh = dirtyState(); + const made = makeAcquirer(gh, { + syncAllOpenPrs: true, + openPrs: [{ number: 8, draft: true }, { number: 9 }], + allowedRepos: ["acme/widgets"], + }); + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#9"]); + + // A PR that looks ready in the list but is a draft per-PR is skipped too. + const resolved: string[] = []; + const acquirer = new ReviewPollingAcquirer({ + intervalSeconds: 60, + quietPeriodSeconds: 0, + workerState, + queue, + github: { + async fetchPr() { + return { + data: { + state: "open", + mergeable_state: "dirty", + draft: true, + head: { sha: "head1", ref: "feature", repo: { full_name: "acme/widgets" } }, + base: { sha: "base1", ref: "main" }, + }, + notModified: false, + }; + }, + async fetchReviews() { + return { data: [], notModified: false }; + }, + async fetchReviewCommentsSince() { + return []; + }, + async listOpenPullRequests() { + return { data: [{ number: 10 }], notModified: false }; + }, + }, + addressPr: async () => true, + resolveConflicts: async (repo, n) => { + resolved.push(`${repo}#${n}`); + return { outcome: "clean", message: "merged" }; + }, + syncAllOpenPrs: true, + }); + await acquirer.tick(); + expect(resolved).toEqual([]); + }); + + test("enabled: a closed foreign PR stops being polled and leaves the watch list", async () => { + const gh = dirtyState(); + const openPrs = [{ number: 11 }]; + const made = makeAcquirer(gh, { + syncAllOpenPrs: true, + openPrs, + allowedRepos: ["acme/widgets"], + }); + await made.acquirer.tick(); + expect(made.resolved).toHaveLength(1); + + // Merged while a stale open list still shows it: per-PR polling stops at + // the closed state — no new sync attempt even though the base moved on. + gh.baseSha = "base2"; + gh.prState = "closed"; + await made.acquirer.tick(); + expect(made.resolved).toHaveLength(1); + expect(queue.getBaseSyncEvent("base-sync:acme/widgets#11:base2:head1")).toBeNull(); + + // Gone from the refreshed open list: nothing further happens. + openPrs.length = 0; + await made.acquirer.tick(); + expect(made.resolved).toHaveLength(1); + }); + + test("enabled: a foreign fork PR is terminally skipped without invoking the resolver", async () => { + const resolved: string[] = []; + const acquirer = new ReviewPollingAcquirer({ + intervalSeconds: 60, + quietPeriodSeconds: 0, + workerState, + queue, + github: { + async fetchPr() { + return { + data: { + state: "open", + mergeable_state: "dirty", + head: { sha: "head1", ref: "feature", repo: { full_name: "contrib/widgets" } }, + base: { sha: "base1", ref: "main" }, + }, + notModified: false, + }; + }, + async fetchReviews() { + return { data: [], notModified: false }; + }, + async fetchReviewCommentsSince() { + return []; + }, + async listOpenPullRequests() { + return { data: [{ number: 77 }], notModified: false }; + }, + }, + addressPr: async () => true, + resolveConflicts: async (repo, n) => { + resolved.push(`${repo}#${n}`); + return { outcome: "clean", message: "merged" }; + }, + syncAllOpenPrs: true, + allowedRepos: ["acme/widgets"], + }); + await acquirer.tick(); + expect(resolved).toEqual([]); + expect(queue.hasProcessed("github:base-sync", "base-sync:acme/widgets#77:base1:head1")).toBe( + true, + ); + }); + + test("enabled without list support degrades to own-PR syncing only", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const made = makeAcquirer(dirtyState(), { syncAllOpenPrs: true }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#42"]); + }); + + test("foreign sync attempts are recorded as conflict_resolution runs", async () => { + const gh = dirtyState(); + const runStore = new RunStore(dbPath); + const made = makeAcquirer(gh, { + syncAllOpenPrs: true, + openPrs: [{ number: 9 }], + allowedRepos: ["acme/widgets"], + runStore, + }); + + await made.acquirer.tick(); + const [run] = runStore.listRuns(); + expect(run).toMatchObject({ + origin: "conflict_resolution", + repo: "acme/widgets", + prNumber: 9, + status: "succeeded", + }); + runStore.close(); + }); }); describe("runResolveConflictsViaCli", () => { From 7d7f198d410e3c8bdb25053825daf7cbced41a3a Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Wed, 26 Aug 2026 14:51:29 +0700 Subject: [PATCH 2/2] feat(code): gate team-PR base sync by author association and require AGENT_SANDBOX - Only sync foreign PRs authored by the repo's own team (author_association OWNER/MEMBER/COLLABORATOR/MAINTAIN), checked at discovery and re-checked against the fresh per-PR payload before every run - Rename WORKER_BASE_SYNC_ALL_PRS to WORKER_BASE_SYNC_TEAM_PRS to reflect scope - Fleet mode: per-repo sync_team_prs opt-in in workspace.toml instead of one global env var; single-repo mode keeps the env var for its own checkout - Refuse to start when AGENT_SANDBOX is unset/none: team-PR conflict resolution hands teammate-authored code to the coding agent, which must be sandboxed - Tests: author-gate discovery/per-PR cases, sandbox guard, TOML validation; docs, CHANGELOG, worker --help, and workspace init scaffold updated --- docs/code/worker.md | 17 +- packages/code/CHANGELOG.md | 2 +- packages/code/src/index.ts | 24 ++- .../code/src/lib/review-polling-acquirer.ts | 167 ++++++++++++------ packages/code/src/lib/workspace/config.ts | 24 +++ packages/code/src/lib/workspace/init.ts | 1 + .../src/lib/workspace/workspace-worker.ts | 29 ++- .../tests/review-polling-acquirer.test.ts | 132 +++++++++++--- packages/code/tests/workspace-config.test.ts | 17 ++ 9 files changed, 315 insertions(+), 98 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index 3e20f8a..ed29089 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -176,23 +176,24 @@ When a watched PR falls behind its base branch, the worker catches the branch up This applies only to the agent's own PRs (the same watch list as review polling). Each base/head SHA pair is a durable event in `.devintern-code/queue.db`; new commits on the PR branch open a fresh event, so an exhausted attempt is retried after the next push. Failures retry up to `WEBHOOK_MAX_RETRIES` (default 3), including across worker restarts. Before acting, the worker requires the PR head SHA to remain unchanged for `WORKER_BASE_SYNC_QUIET_SECONDS` (default 30) and then re-fetches both SHAs. Recent or concurrent pushes defer the run without consuming an attempt; if a run defers several times in a row, the event is given up until the head or base moves again. GitHub's PR API can report an outdated `base.sha` for a while, so the resolver always merges the actual fetched tip of the base branch rather than trusting that field. Each resolve run is bounded by `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800; `0` disables) — a hung resolver subprocess is killed and counted as a failed attempt, and runs left `in_progress` by a crashed or killed worker are marked failed at the next startup. The same merge logic is available manually for any PR via `devintern resolve-conflicts `. -### Syncing every open PR (`WORKER_BASE_SYNC_ALL_PRS`) +### Syncing teammates' open PRs (`WORKER_BASE_SYNC_TEAM_PRS` / `sync_team_prs`) -By default only the agent's own PRs are synced, as described above. Set `WORKER_BASE_SYNC_ALL_PRS=true` in `.devintern-code/.env` to extend automatic base sync to **every open pull request** in the repo(s) the worker manages — useful when human teammates' long-lived branches also fall behind their base. +By default only the agent's own PRs are synced, as described above. To also keep human teammates' long-lived branches caught up with their base, enable team-PR sync: + +- Single-repo mode: set `WORKER_BASE_SYNC_TEAM_PRS=true` in `.devintern-code/.env`. +- Workspace (fleet) mode: set `sync_team_prs = true` on the relevant `[[repos]]` entry in `workspace.toml`. Behavior with the flag enabled: -- Discovery polls each watched repo's open-PR list every tick using conditional (ETag-cached) requests, so idle ticks stay rate-limit-free. The first sweep after startup fetches PR details once per open PR; afterwards only changed PRs cost requests. The list covers up to the first 100 open PRs per repo — while a repo sits at that page-size cap, the worker logs a warning, since PRs beyond it are invisible to discovery. +- Only PRs authored by the repository's own team are synced: GitHub's `author_association` must be `OWNER`, `MEMBER`, `COLLABORATOR`, or `MAINTAIN`. PRs from outside contributors — and everything from forks — is never auto-synced; those branches remain their authors' to manage. The gate is checked both at discovery and again against the fresh per-PR payload before any run. +- Discovery polls each enabled repo's open-PR list every tick using conditional (ETag-cached) requests, so idle ticks stay rate-limit-free. The first sweep after startup fetches PR details once per open PR; afterwards only changed PRs cost requests. The list covers up to the first 100 open PRs per repo — while a repo sits at that page-size cap, the worker logs a warning, since PRs beyond it are invisible to discovery. - Closed and merged PRs drop out of the open list on their own; draft PRs are never auto-synced (a draft signals work in progress). -- Fork PRs are skipped: devintern cannot — and should not — push merges to forks, so conflicts there remain the author's to resolve. - Review feedback handling stays an own-PR feature: other people's PRs are only ever caught up with their base, never addressed or replied to beyond the sync outcome. - Everything else works exactly as for the agent's own PRs: quiet period, durable per-SHA events, retry limits, no force-pushes, and branch protection rules apply equally. -**Trust boundaries:** unlike the agent's own PRs, these branches are authored by anyone who can open a PR, so their content is untrusted input. What contains it: all resolution work happens inside a dedicated branch-scoped worktree under `/tmp/`, never in your checkout; fork PRs and drafts never enter the pipeline; merge pushes are lease-verified and never forced; and each resolver subprocess is killed at `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800). What does *not* contain it: genuine conflict resolution hands the conflicted tree to the coding agent, which may run project tooling (typecheck/tests) inside that worktree — the same pipeline the agent's own PRs use. On repositories that accept PRs from arbitrary third parties, run the agent sandboxed (`AGENT_SANDBOX`, see [Sandboxing the Agent](./configuration.md#sandboxing-the-agent)) and let branch protection decide what actually lands. There is no per-author allowlist: the flag is all-or-nothing per watched repo. - -The sweep also needs at least one repo target. Single-repo workers derive it from the checkout's detected GitHub repo (workspace mode uses `workspace.toml`); if detection fails and the agent has no open PR yet, discovery has nothing to run on and the worker logs a "nothing to sweep" warning until a target exists. +**Trust boundaries:** unlike the agent's own PRs, these branches are authored by other people, so their content is untrusted input. Enabling the flag therefore **requires a sandboxed agent**: the worker refuses to start when `AGENT_SANDBOX` is unset or `none` (see [Sandboxing the Agent](./configuration.md#sandboxing-the-agent)). What contains the rest: all resolution work happens inside a dedicated branch-scoped worktree under `/tmp/`, never in your checkout; fork PRs and drafts never enter the pipeline; merge pushes are lease-verified and never forced; and each resolver subprocess is killed at `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800). What does *not* contain it: genuine conflict resolution hands a teammate's conflicted tree to the sandboxed coding agent — let branch protection decide what actually lands. -Sync activity on non-devintern PRs is fully auditable: the worker logs each foreign PR it starts/stops watching (`👀 … watching owner/repo#N (open PR) for base sync`) and marks its sync lines with `(external PR)`; the resolver posts a comment on the PR describing what was merged; and every attempt is recorded as a `conflict_resolution` run in the [dashboard](./dashboard.md). +Sync activity on non-devintern PRs is fully auditable: the worker logs each foreign PR it starts/stops watching (`👀 … watching owner/repo#N (open PR) for base sync`) plus any PR skipped for a non-team author (`🚧 …`), and marks its sync lines with `(external PR)`; the resolver posts a comment on the PR describing what was merged; and every attempt is recorded as a `conflict_resolution` run in the [dashboard](./dashboard.md). ## Mention the bot on any PR diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 525dd6a..358b7b0 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Opt-in base sync for every open PR (`WORKER_BASE_SYNC_ALL_PRS`)**: setting it to `true` extends the worker's automatic merge-conflict/base-behind sync from just the agent's own PRs to every open, non-draft PR in the managed repo(s) — useful when teammates' long-lived branches fall behind their base. Discovery uses ETag-cached open-PR list polling (rate-limit friendly, first 100 open PRs per repo), fork PRs are skipped, review feedback stays an own-PR feature, and every foreign sync is logged and recorded as a `conflict_resolution` run. Default remains off: only devintern's own PRs are synced +- **Opt-in base sync for teammates' open PRs (`WORKER_BASE_SYNC_TEAM_PRS`, fleet: `sync_team_prs` per repo)**: setting it extends the worker's automatic merge-conflict/base-behind sync from just the agent's own PRs to open, non-draft PRs authored by the repository's own team (`author_association` OWNER/MEMBER/COLLABORATOR/MAINTAIN) — useful when teammates' long-lived branches fall behind their base. Enabling it requires `AGENT_SANDBOX`: the worker refuses to start otherwise. Outside contributors and fork PRs are never auto-synced; discovery uses ETag-cached open-PR list polling (rate-limit friendly, first 100 open PRs per repo); review feedback stays an own-PR feature; every foreign sync is logged and recorded as a `conflict_resolution` run. Default remains off ## [2.5.0] - 2026-08-26 diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index fd4d35e..db02200 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -644,9 +644,11 @@ if (process.argv[2] === "init") { console.log( " WORKER_BASE_SYNC_QUIET_SECONDS Stable-head window before PR base sync (default: 30)", ); - console.log(" WORKER_BASE_SYNC_ALL_PRS"); - console.log(" Set to true to base-sync every open PR in the repo,"); - console.log(" not just the agent's own (default: disabled)"); + console.log(" WORKER_BASE_SYNC_TEAM_PRS"); + console.log( + " Set to true to base-sync teammates' open PRs (requires", + ); + console.log(" AGENT_SANDBOX; default: disabled)"); console.log( " WEBHOOK_MAX_RETRIES Retry limit for queued and PR base-sync work (default: 3)", ); @@ -773,6 +775,7 @@ if (process.argv[2] === "init") { runAddressReviewViaCli, runResolveConflictsViaCli, OPEN_PR_LIST_PAGE_SIZE, + assertAgentSandboxForTeamPrSync, } = await import("./lib/review-polling-acquirer"); const { GitHubReviewsClient } = await import("./lib/github-reviews"); const { RunStore } = await import("./lib/run-recorder"); @@ -800,6 +803,19 @@ if (process.argv[2] === "init") { .detectRepository() .then((r) => (r.platform === "github" ? r.repository : ""))); + // All-PR base sync hands foreign-authored code to the agent; it must + // not start unsandboxed. Refuse the run rather than silently degrading. + const syncTeamPrsRepos = + process.env.WORKER_BASE_SYNC_TEAM_PRS === "true" && repoSlug ? [repoSlug] : []; + if (process.env.WORKER_BASE_SYNC_TEAM_PRS === "true") { + try { + assertAgentSandboxForTeamPrSync(); + } catch (error) { + console.error(`❌ ${(error as Error).message}`); + process.exit(1); + } + } + acquirers.push( new ReviewPollingAcquirer({ intervalSeconds, @@ -846,7 +862,7 @@ if (process.argv[2] === "init") { quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), runStore, allowedRepos: repoSlug ? [repoSlug] : undefined, - syncAllOpenPrs: process.env.WORKER_BASE_SYNC_ALL_PRS === "true", + syncTeamPrsRepos, verbose, }), ); diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index 35ead58..0f6384d 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -53,8 +53,14 @@ export interface PolledPr { state: string; /** GitHub's computed merge state; `"dirty"` means merge conflicts. */ mergeable_state?: string; - /** Work-in-progress PR (excluded from all-PR base sync). */ + /** Work-in-progress PR (excluded from team-PR base sync). */ draft?: boolean; + /** + * GitHub's author/repo relationship (`OWNER`, `MEMBER`, `COLLABORATOR`, + * `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, `NONE`, …). All-PR base sync + * only touches PRs authored by the repository's own team. + */ + author_association?: string; head?: { sha: string; ref?: string; repo?: { full_name: string } | null }; base?: { sha: string; ref?: string }; } @@ -62,8 +68,10 @@ export interface PolledPr { /** Open-PR list entry used to discover PRs beyond the agent's own. */ export interface PolledPrListItem { number: number; - /** Draft PRs are excluded from all-PR base sync. */ + /** Draft PRs are excluded from team-PR base sync. */ draft?: boolean; + /** See {@link PolledPr.author_association}. */ + author_association?: string; } export interface AutomaticResolveResult { @@ -85,7 +93,7 @@ export interface ReviewPollingGitHub { sinceIso: string, ): Promise; /** - * List the repo's open PRs (discovery for `syncAllOpenPrs`). Omit when the + * List the repo's open PRs (discovery for team-PR base sync). Omit when the * feature is disabled. */ listOpenPullRequests?( @@ -125,14 +133,16 @@ export interface ReviewPollingAcquirerOptions { allowedRepos?: string[]; verbose?: boolean; /** - * Extend automatic base sync to every open, non-draft PR in the watched - * repos — not just the agent's own PRs (`WORKER_BASE_SYNC_ALL_PRS=true`). - * Requires `github.listOpenPullRequests`; default off. + * GitHub slugs (`owner/repo`) where automatic base sync extends beyond the + * agent's own PRs to the repo's open, non-draft PRs authored by team + * members (fleet: `sync_team_prs = true` per `[[repos]]`; single-repo: + * `WORKER_BASE_SYNC_TEAM_PRS=true`). Empty/omitted keeps the default + * own-PR-only behavior. Requires `github.listOpenPullRequests`. * * Foreign PR branches are untrusted input; see `sweepRepoOpenPrs` for * what that contains (and does not). */ - syncAllOpenPrs?: boolean; + syncTeamPrsRepos?: string[]; } /** Dedupe source for review/comment ids. */ @@ -158,6 +168,45 @@ export const DEFAULT_RESOLVE_TIMEOUT_MS = 30 * 60 * 1000; */ export const OPEN_PR_LIST_PAGE_SIZE = 100; +/** + * `author_association` values that count as "part of the repository's + * team" for team-PR base sync. Anything else (`CONTRIBUTOR`, + * `FIRST_TIME_CONTRIBUTOR`, `NONE`, …) is treated as an outside author: + * their PRs are never auto-synced, because same-repo PR branches from + * non-collaborators are rare and a rogue/compromised writer is exactly + * the threat this gate narrows. + */ +export const TEAM_AUTHOR_ASSOCIATIONS: ReadonlySet = new Set([ + "OWNER", + "MEMBER", + "COLLABORATOR", + "MAINTAIN", +]); + +/** True when `author_association` marks the author as part of the repo team. */ +export function isTeamAuthor(association?: string): boolean { + return association !== undefined && TEAM_AUTHOR_ASSOCIATIONS.has(association.toUpperCase()); +} + +/** + * Guard for enabling team-PR base sync: conflict resolution on foreign + * branches hands untrusted code to the coding agent, which may run project + * tooling inside the review worktree. Without an OS sandbox that executes + * with the worker's full credentials, so the flag refuses to start. + * + * @throws When `AGENT_SANDBOX` is unset or explicitly `none`. + */ +export function assertAgentSandboxForTeamPrSync(): void { + const sandbox = process.env.AGENT_SANDBOX?.trim(); + if (sandbox && sandbox.toLowerCase() !== "none") return; + throw new Error( + "WORKER_BASE_SYNC_TEAM_PRS / sync_team_prs requires a sandboxed agent " + + "(AGENT_SANDBOX), because base-syncing foreign PRs can hand attacker- or " + + `teammate-authored code to the coding agent. Set AGENT_SANDBOX (see \`devintern sandbox\`), ` + + "or disable team-PR base sync.", + ); +} + /** Resolver subprocess budget; `WORKER_RESOLVE_TIMEOUT_SECONDS` overrides (0 disables). */ function resolveTimeoutMs(overrideMs?: number): number { if (overrideMs !== undefined) return overrideMs; @@ -394,25 +443,28 @@ export class ReviewPollingAcquirer implements Acquirer { private deferCounts = new Map(); /** Effective all-open-PR sync switch (disabled when discovery is unavailable). */ private readonly syncAllActive: boolean; + /** Slugs where all-open-PR base sync is enabled. */ + private readonly syncTeamPrsRepos: readonly string[]; /** Last open-PR list seen per repo (lets a 304 reuse the previous set). */ private foreignListItems = new Map(); /** Repos whose at-cap open-PR list warning is active (reset when below the cap). */ private capWarnedRepos = new Set(); - /** Whether the "nothing to sweep" warning is active (reset when targets exist). */ - private emptySweepWarned = false; /** Foreign PRs currently watched for base sync (audit logging). */ private foreignWatched = new Set(); + /** Foreign PRs already announced as author-ineligible (log once, not per tick). */ + private foreignSkippedAuthors = new Set(); private foreignPrCache = new Map(); constructor(options: ReviewPollingAcquirerOptions) { this.options = options; - this.syncAllActive = Boolean(options.syncAllOpenPrs); - if (this.syncAllActive && !options.github.listOpenPullRequests) { + this.syncTeamPrsRepos = options.syncTeamPrsRepos ?? []; + this.syncAllActive = + this.syncTeamPrsRepos.length > 0 && Boolean(options.github.listOpenPullRequests); + if (this.syncTeamPrsRepos.length > 0 && !this.syncAllActive) { console.warn( - `⚠️ [${this.name}] WORKER_BASE_SYNC_ALL_PRS is enabled but the GitHub client cannot ` + + `⚠️ [${this.name}] team-PR base sync is enabled but the GitHub client cannot ` + `list open PRs; syncing only the agent's own PRs`, ); - this.syncAllActive = false; } } @@ -434,14 +486,14 @@ export class ReviewPollingAcquirer implements Acquirer { ); if (this.syncAllActive) { console.log( - `🌐 [${this.name}] base sync extended to every open PR in the watched repo(s) ` + - `(WORKER_BASE_SYNC_ALL_PRS)`, + `🌐 [${this.name}] base sync extended to team-authored open PRs in: ` + + `${this.syncTeamPrsRepos.join(", ")}`, ); console.warn( - `⚠️ [${this.name}] foreign PRs are authored by anyone who can open one, so their ` + - `content is treated as untrusted: forks and drafts are skipped and each resolver ` + - `run is bounded by WORKER_RESOLVE_TIMEOUT_SECONDS, but conflict resolution may ` + - `invoke the coding agent — consider AGENT_SANDBOX`, + `⚠️ [${this.name}] foreign PRs are authored by teammates, so their content is ` + + `still treated as untrusted: forks, drafts, and non-team authors are skipped and ` + + `each resolver run is bounded by WORKER_RESOLVE_TIMEOUT_SECONDS, but conflict ` + + `resolution may invoke the coding agent (running under AGENT_SANDBOX)`, ); } await this.tick(); @@ -486,32 +538,16 @@ export class ReviewPollingAcquirer implements Acquirer { } if (this.syncAllActive) { - const repos = - allowedRepos && allowedRepos.length > 0 - ? allowedRepos - : [...new Set(watchedPrs.map((pr) => pr.repo))]; - if (repos.length === 0) { - // No managed repo configured and nothing registered in the - // transient own-PR watch list: discovery would silently never run. - if (!this.emptySweepWarned) { - this.emptySweepWarned = true; + // Sweep only the repos explicitly opted in (fleet `sync_team_prs` / + // single-repo env), never every managed repo by accident. + for (const repo of this.syncTeamPrsRepos) { + try { + await this.sweepRepoOpenPrs(repo, watchedKeys); + } catch (error) { console.warn( - `⚠️ [${this.name}] WORKER_BASE_SYNC_ALL_PRS is enabled but there is nothing to ` + - `sweep: no managed repo is configured and the agent has no open PR to derive ` + - `a repo from`, + `⚠️ [${this.name}] listing open PRs on ${repo} failed: ${(error as Error).message}`, ); } - } else { - this.emptySweepWarned = false; - for (const repo of repos) { - try { - await this.sweepRepoOpenPrs(repo, watchedKeys); - } catch (error) { - console.warn( - `⚠️ [${this.name}] listing open PRs on ${repo} failed: ${(error as Error).message}`, - ); - } - } } } } finally { @@ -616,23 +652,24 @@ export class ReviewPollingAcquirer implements Acquirer { } /** - * Discover open PRs beyond the agent's own (`WORKER_BASE_SYNC_ALL_PRS`) - * and run base-sync-only polling on each eligible one. Review feedback - * stays an own-PR feature: foreign PRs are never addressed, only caught - * up with their base. + * Discover open PRs beyond the agent's own in repos opted into team-PR + * base sync, and run base-sync-only polling on each eligible one. Review + * feedback stays an own-PR feature: foreign PRs are never addressed, only + * caught up with their base. * * Trust model: unlike the agent's own PRs, foreign branches are authored - * by anyone who can open a PR, so this path processes untrusted input. - * Contained by construction: work happens inside a dedicated branch-scoped - * review worktree (`prepareReviewWorktree`), never the worker checkout; - * fork heads are terminally skipped and drafts never enter; pushes are - * lease-verified and never forced; and each resolver subprocess is killed - * at `WORKER_RESOLVE_TIMEOUT_SECONDS`. Not contained: a genuinely - * conflicting merge hands the conflicted tree to the coding agent, which - * may run project tooling (typecheck/tests) inside that worktree — the - * same pipeline the agent's own PRs get. Operators enabling this flag on - * repos with untrusted contributors should sandbox the agent - * (`AGENT_SANDBOX`) and gate merges via branch protection. + * by other people, so this path processes untrusted input. Eligibility is + * narrowed to the repository's own team (`author_association` OWNER, + * MEMBER, COLLABORATOR, or MAINTAIN) — outside contributors and fork + * authors are never auto-synced. Contained by construction: work happens + * inside a dedicated branch-scoped review worktree (`prepareReviewWorktree`), + * never the worker checkout; fork heads are terminally skipped and drafts + * never enter; pushes are lease-verified and never forced; each resolver + * subprocess is killed at `WORKER_RESOLVE_TIMEOUT_SECONDS`; and enabling + * the feature requires AGENT_SANDBOX (see + * {@link assertAgentSandboxForTeamPrSync}). Still not contained: a + * genuinely conflicting merge hands a teammate's code to the sandboxed + * coding agent — branch protection decides what actually lands. */ private async sweepRepoOpenPrs(repo: string, ownKeys: Set): Promise { const { github, workerState } = this.options; @@ -674,6 +711,18 @@ export class ReviewPollingAcquirer implements Acquirer { if (ownKeys.has(key)) continue; // Drafts signal work in progress; auto-merging into them surprises authors. if (item.draft === true) continue; + // Team gate: only PRs authored by the repository's own team are + // auto-synced (outside contributors' branches stay theirs to manage). + if (!isTeamAuthor(item.author_association)) { + if (!this.foreignSkippedAuthors.has(key)) { + this.foreignSkippedAuthors.add(key); + console.log( + `🚧 [${this.name}] skipping ${repo}#${item.number} for base sync: author is not a ` + + `team member/collaborator (${item.author_association ?? "unknown"})`, + ); + } + continue; + } if (!this.foreignWatched.has(key)) { this.foreignWatched.add(key); console.log(`👀 [${this.name}] watching ${repo}#${item.number} (open PR) for base sync`); @@ -691,6 +740,7 @@ export class ReviewPollingAcquirer implements Acquirer { if (!key.startsWith(`${repo.toLowerCase()}#`) || currentKeys.has(key)) continue; this.foreignWatched.delete(key); this.foreignPrCache.delete(key); + this.foreignSkippedAuthors.delete(key); console.log(`👋 [${this.name}] ${key} left the open-PR list; no longer watching`); } } @@ -720,6 +770,9 @@ export class ReviewPollingAcquirer implements Acquirer { return; } if (pr.draft === true) return; + // Re-check the team gate on the fresh per-PR payload: the open-PR list + // entry can be stale, and eligibility must hold at sync time. + if (!isTeamAuthor(pr.author_association)) return; this.foreignPrCache.set(prKey, pr); await this.maybeSyncBase(repo, prNumber, pr, { external: true }); } diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index e3e3736..73961e4 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -33,6 +33,12 @@ export interface RepoConfig { defaultBranch?: string; /** Optional env file path, relative to the workspace directory. */ envFile?: string; + /** + * Extend automatic base sync to this repo's open, non-draft PRs authored + * by team members (`WORKER_BASE_SYNC_TEAM_PRS` equivalent). Requires a + * sandboxed agent; off by default. + */ + syncTeamPrs?: boolean; /** Inline env overrides (highest precedence). */ env: Record; } @@ -127,6 +133,23 @@ function readStringList( return (value as string[]).map((item) => item.trim()); } +function readBoolean( + table: Record, + key: string, + label: string, + errors: string[], +): boolean | undefined { + const value = table[key]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "boolean") { + errors.push(`${label}.${key} must be a boolean.`); + return undefined; + } + return value; +} + function readEnvTable( table: Record, label: string, @@ -226,6 +249,7 @@ export function parseWorkspaceConfig( remote, defaultBranch: readString(table, "default_branch", label, errors) ?? defaults.defaultBranch, envFile: readString(table, "env_file", label, errors), + syncTeamPrs: readBoolean(table, "sync_team_prs", label, errors), env: readEnvTable(table, label, errors), }); } diff --git a/packages/code/src/lib/workspace/init.ts b/packages/code/src/lib/workspace/init.ts index 6df771a..3c44e5a 100644 --- a/packages/code/src/lib/workspace/init.ts +++ b/packages/code/src/lib/workspace/init.ts @@ -41,6 +41,7 @@ default_branch = "main" # [[repos]] # name = "backend" # remote = "git@github.com:acme/backend.git" +# sync_team_prs = true # also base-sync teammates' open PRs (needs AGENT_SANDBOX) # # [[routing.rules]] # repo = "backend" diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 6cfa3d7..9a945b5 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -18,7 +18,7 @@ import type { WebhookQueue } from "../webhook-queue"; import type { WorkerState } from "../worker-state"; import { findRepo, loadWorkspaceConfig } from "./config"; import type { RepoConfig, WorkspaceConfig } from "./config"; -import { buildRepoEnv, parseEnvFile } from "./env"; +import { buildRepoEnv, gitHubSlugFromRemote, parseEnvFile } from "./env"; import { resolveWorkspaceDir, workspaceConfigPath, @@ -466,9 +466,32 @@ async function buildFleetEventAcquirers(options: { // Tier 1: the agent's own PRs (central agent_prs registry is repo-keyed, // so one acquirer covers the whole fleet). - const { ReviewPollingAcquirer, OPEN_PR_LIST_PAGE_SIZE } = + const { ReviewPollingAcquirer, OPEN_PR_LIST_PAGE_SIZE, assertAgentSandboxForTeamPrSync } = await import("../review-polling-acquirer"); const { RunStore } = await import("../run-recorder"); + + // Per-repo opt-in (`sync_team_prs = true` in workspace.toml), resolved to + // GitHub slugs the same way fleetGitHubSlugs does. + const syncTeamPrsRepos = [ + ...new Set( + config.repos + .filter((repo) => repo.syncTeamPrs === true) + .map((repo) => repo.env.GITHUB_REPO ?? gitHubSlugFromRemote(repo.remote)) + .filter((slug): slug is string => Boolean(slug)), + ), + ]; + if (syncTeamPrsRepos.length > 0) { + // Base-syncing foreign PRs hands teammate-authored code to the agent; + // refuse an unsandboxed fleet worker instead of silently degrading. + try { + assertAgentSandboxForTeamPrSync(); + } catch (error) { + throw new Error( + `${(error as Error).message}\n (repos with sync_team_prs enabled: ${syncTeamPrsRepos.join(", ")})`, + ); + } + } + const runStore = new RunStore(state.dbPath); const reapedRuns = runStore.reapOrphanedRuns(); if (reapedRuns > 0) { @@ -514,7 +537,7 @@ async function buildFleetEventAcquirers(options: { quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), runStore, allowedRepos: slugs, - syncAllOpenPrs: process.env.WORKER_BASE_SYNC_ALL_PRS === "true", + syncTeamPrsRepos, verbose, }), ); diff --git a/packages/code/tests/review-polling-acquirer.test.ts b/packages/code/tests/review-polling-acquirer.test.ts index c1cca25..3f64d8c 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -4,6 +4,8 @@ import { join } from "path"; import { tmpdir } from "os"; import { + assertAgentSandboxForTeamPrSync, + isTeamAuthor, OPEN_PR_LIST_PAGE_SIZE, ReviewPollingAcquirer, runResolveConflictsViaCli, @@ -41,6 +43,7 @@ describe("ReviewPollingAcquirer", () => { prState: string; mergeableState?: string; draft?: boolean; + authorAssociation?: string; headSha?: string; baseSha?: string; baseIncluded?: boolean | null; @@ -65,8 +68,8 @@ describe("ReviewPollingAcquirer", () => { quietPeriodSeconds?: number; now?: () => number; allowedRepos?: string[]; - syncAllOpenPrs?: boolean; - openPrs?: Array<{ number: number; draft?: boolean }>; + syncTeamPrsRepos?: string[]; + openPrs?: Array<{ number: number; draft?: boolean; author_association?: string }>; } = {}, ) { const addressed: string[] = []; @@ -88,6 +91,7 @@ describe("ReviewPollingAcquirer", () => { state: gh.prState, mergeable_state: gh.mergeableState, draft: gh.draft, + author_association: gh.authorAssociation, head: gh.headSha ? { sha: gh.headSha, ref: "agent/task", repo: { full_name: "acme/widgets" } } : undefined, @@ -113,7 +117,11 @@ describe("ReviewPollingAcquirer", () => { async listOpenPullRequests( _repo: string, etag?: string, - ): Promise>> { + ): Promise< + ConditionalResult< + Array<{ number: number; draft?: boolean; author_association?: string }> + > + > { listCalls.push(etag); if (gh.listEtagHit) { return { data: null, etag, notModified: true }; @@ -135,7 +143,7 @@ describe("ReviewPollingAcquirer", () => { runStore: options.runStore, now: options.now, allowedRepos: options.allowedRepos, - syncAllOpenPrs: options.syncAllOpenPrs, + syncTeamPrsRepos: options.syncTeamPrsRepos, }); return { acquirer, addressed, resolved, listCalls }; } @@ -145,6 +153,7 @@ describe("ReviewPollingAcquirer", () => { const dirtyState = (): FakeGitHubState => ({ prState: "open", mergeableState: "dirty", + authorAssociation: "MEMBER", headSha: "head1", baseSha: "base1", reviews: [], @@ -736,10 +745,10 @@ describe("ReviewPollingAcquirer", () => { expect(addressed).toEqual(["acme/widgets#42"]); }); - test("foreign open PRs are not synced by default (WORKER_BASE_SYNC_ALL_PRS off)", async () => { + test("foreign open PRs are not synced by default (WORKER_BASE_SYNC_TEAM_PRS off)", async () => { const gh = dirtyState(); const made = makeAcquirer(gh, { - openPrs: [{ number: 7 }], + openPrs: [{ number: 7, author_association: "MEMBER" }], allowedRepos: ["acme/widgets"], }); @@ -753,8 +762,8 @@ describe("ReviewPollingAcquirer", () => { const gh = dirtyState(); gh.reviews = [{ id: 1, state: "changes_requested", user: human }]; const made = makeAcquirer(gh, { - syncAllOpenPrs: true, - openPrs: [{ number: 7 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [{ number: 7, author_association: "MEMBER" }], allowedRepos: ["acme/widgets"], }); @@ -778,8 +787,8 @@ describe("ReviewPollingAcquirer", () => { test("enabled: a 304 open-PR list reuses the previously hydrated item set", async () => { const gh = dirtyState(); const made = makeAcquirer(gh, { - syncAllOpenPrs: true, - openPrs: [{ number: 12 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [{ number: 12, author_association: "MEMBER" }], allowedRepos: ["acme/widgets"], }); await made.acquirer.tick(); @@ -805,8 +814,8 @@ describe("ReviewPollingAcquirer", () => { // unconditionally instead of trusting the stale ETag. workerState.setCursor("github:open-prs:acme/widgets", "state", 'W/"list-stale"'); const made = makeAcquirer(dirtyState(), { - syncAllOpenPrs: true, - openPrs: [{ number: 13 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [{ number: 13, author_association: "MEMBER" }], allowedRepos: ["acme/widgets"], }); @@ -822,8 +831,11 @@ describe("ReviewPollingAcquirer", () => { test("enabled: the agent's own PRs are polled once even when listed as open", async () => { workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); const made = makeAcquirer(dirtyState(), { - syncAllOpenPrs: true, - openPrs: [{ number: 42 }, { number: 43 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [ + { number: 42, author_association: "MEMBER" }, + { number: 43, author_association: "MEMBER" }, + ], allowedRepos: ["acme/widgets"], }); @@ -834,8 +846,11 @@ describe("ReviewPollingAcquirer", () => { test("enabled: draft PRs are excluded from sync at discovery and per-PR polling", async () => { const gh = dirtyState(); const made = makeAcquirer(gh, { - syncAllOpenPrs: true, - openPrs: [{ number: 8, draft: true }, { number: 9 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [ + { number: 8, draft: true, author_association: "MEMBER" }, + { number: 9, author_association: "MEMBER" }, + ], allowedRepos: ["acme/widgets"], }); await made.acquirer.tick(); @@ -868,7 +883,7 @@ describe("ReviewPollingAcquirer", () => { return []; }, async listOpenPullRequests() { - return { data: [{ number: 10 }], notModified: false }; + return { data: [{ number: 10, author_association: "MEMBER" }], notModified: false }; }, }, addressPr: async () => true, @@ -876,7 +891,7 @@ describe("ReviewPollingAcquirer", () => { resolved.push(`${repo}#${n}`); return { outcome: "clean", message: "merged" }; }, - syncAllOpenPrs: true, + syncTeamPrsRepos: ["acme/widgets"], }); await acquirer.tick(); expect(resolved).toEqual([]); @@ -884,9 +899,9 @@ describe("ReviewPollingAcquirer", () => { test("enabled: a closed foreign PR stops being polled and leaves the watch list", async () => { const gh = dirtyState(); - const openPrs = [{ number: 11 }]; + const openPrs = [{ number: 11, author_association: "MEMBER" }]; const made = makeAcquirer(gh, { - syncAllOpenPrs: true, + syncTeamPrsRepos: ["acme/widgets"], openPrs, allowedRepos: ["acme/widgets"], }); @@ -920,6 +935,7 @@ describe("ReviewPollingAcquirer", () => { data: { state: "open", mergeable_state: "dirty", + author_association: "MEMBER", head: { sha: "head1", ref: "feature", repo: { full_name: "contrib/widgets" } }, base: { sha: "base1", ref: "main" }, }, @@ -933,7 +949,7 @@ describe("ReviewPollingAcquirer", () => { return []; }, async listOpenPullRequests() { - return { data: [{ number: 77 }], notModified: false }; + return { data: [{ number: 77, author_association: "MEMBER" }], notModified: false }; }, }, addressPr: async () => true, @@ -941,7 +957,7 @@ describe("ReviewPollingAcquirer", () => { resolved.push(`${repo}#${n}`); return { outcome: "clean", message: "merged" }; }, - syncAllOpenPrs: true, + syncTeamPrsRepos: ["acme/widgets"], allowedRepos: ["acme/widgets"], }); await acquirer.tick(); @@ -953,7 +969,7 @@ describe("ReviewPollingAcquirer", () => { test("enabled without list support degrades to own-PR syncing only", async () => { workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); - const made = makeAcquirer(dirtyState(), { syncAllOpenPrs: true }); + const made = makeAcquirer(dirtyState(), { syncTeamPrsRepos: ["acme/widgets"] }); await made.acquirer.tick(); expect(made.resolved).toEqual(["acme/widgets#42"]); @@ -963,8 +979,8 @@ describe("ReviewPollingAcquirer", () => { const gh = dirtyState(); const runStore = new RunStore(dbPath); const made = makeAcquirer(gh, { - syncAllOpenPrs: true, - openPrs: [{ number: 9 }], + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [{ number: 9, author_association: "MEMBER" }], allowedRepos: ["acme/widgets"], runStore, }); @@ -979,6 +995,72 @@ describe("ReviewPollingAcquirer", () => { }); runStore.close(); }); + + test("enabled: PRs from outside authors are never discovered for sync", async () => { + const gh = dirtyState(); + const made = makeAcquirer(gh, { + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [ + { number: 20, author_association: "NONE" }, + { number: 21, author_association: "FIRST_TIME_CONTRIBUTOR" }, + { number: 22, author_association: "CONTRIBUTOR" }, + { number: 23, author_association: undefined }, + { number: 24, author_association: "COLLABORATOR" }, + { number: 25, author_association: "MAINTAIN" }, + { number: 26, author_association: "OWNER" }, + ], + allowedRepos: ["acme/widgets"], + }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#24", "acme/widgets#25", "acme/widgets#26"]); + }); + + test("enabled: the fresh per-PR author wins over a stale team-authored list entry", async () => { + const gh = dirtyState(); + const made = makeAcquirer(gh, { + syncTeamPrsRepos: ["acme/widgets"], + openPrs: [{ number: 30, author_association: "MEMBER" }], + allowedRepos: ["acme/widgets"], + }); + + // Eligible at first sight. + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#30"]); + + // Author association downgraded (e.g. collaborator access revoked): + // the per-PR payload gates even when the cached list entry says MEMBER, + // and a moved base does not open a new sync event. + gh.authorAssociation = "NONE"; + gh.baseSha = "base2"; + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#30"]); + }); + + test("team-PR base sync refuses to start without AGENT_SANDBOX", () => { + const prev = process.env.AGENT_SANDBOX; + try { + delete process.env.AGENT_SANDBOX; + expect(() => assertAgentSandboxForTeamPrSync()).toThrow(/AGENT_SANDBOX/); + process.env.AGENT_SANDBOX = "none"; + expect(() => assertAgentSandboxForTeamPrSync()).toThrow(/AGENT_SANDBOX/); + process.env.AGENT_SANDBOX = "docker"; + expect(() => assertAgentSandboxForTeamPrSync()).not.toThrow(); + } finally { + if (prev === undefined) delete process.env.AGENT_SANDBOX; + else process.env.AGENT_SANDBOX = prev; + } + }); + + test("isTeamAuthor accepts GitHub's team associations case-insensitively", () => { + expect(isTeamAuthor("MEMBER")).toBe(true); + expect(isTeamAuthor("collaborator")).toBe(true); + expect(isTeamAuthor("Maintain")).toBe(true); + expect(isTeamAuthor("OWNER")).toBe(true); + expect(isTeamAuthor("CONTRIBUTOR")).toBe(false); + expect(isTeamAuthor("NONE")).toBe(false); + expect(isTeamAuthor(undefined)).toBe(false); + }); }); describe("runResolveConflictsViaCli", () => { diff --git a/packages/code/tests/workspace-config.test.ts b/packages/code/tests/workspace-config.test.ts index 0b1dca8..1b920c8 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -35,6 +35,7 @@ name = "backend" remote = "git@github.com:acme/backend.git" default_branch = "develop" env_file = "env/backend.env" +sync_team_prs = true [repos.env] GITHUB_REPO = "acme/backend" @@ -65,11 +66,13 @@ describe("parseWorkspaceConfig", () => { expect(backend?.remote).toBe("git@github.com:acme/backend.git"); expect(backend?.defaultBranch).toBe("develop"); expect(backend?.envFile).toBe("env/backend.env"); + expect(backend?.syncTeamPrs).toBe(true); expect(backend?.env).toEqual({ GITHUB_REPO: "acme/backend" }); // frontend has no default_branch of its own: inherits [defaults]. const frontend = findRepo(config, "frontend"); expect(frontend?.defaultBranch).toBe("main"); + expect(frontend?.syncTeamPrs).toBeUndefined(); expect(frontend?.env).toEqual({}); expect(config.routing).toHaveLength(2); @@ -108,6 +111,20 @@ tracker = "fossil" ).toThrow(/does not support polling/); }); + test("rejects a non-boolean repos.sync_team_prs", () => { + expect(() => + parseWorkspaceConfig(` +[defaults] +tracker = "jira" + +[[repos]] +name = "backend" +remote = "git@github.com:acme/backend.git" +sync_team_prs = "yes" +`), + ).toThrow(/\[\[repos\]\]\[0\]\.sync_team_prs must be a boolean/); + }); + test("rejects duplicate repo names", () => { expect(() => parseWorkspaceConfig(`