diff --git a/Cargo.lock b/Cargo.lock index ce49d89..49fc478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,10 +346,18 @@ name = "coven-github-config" version = "0.1.0" dependencies = [ "anyhow", + "coven-github-gardener", "serde", "toml", ] +[[package]] +name = "coven-github-gardener" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "coven-github-store" version = "0.1.0" @@ -392,6 +400,7 @@ dependencies = [ "anyhow", "coven-github-api", "coven-github-config", + "coven-github-gardener", "coven-github-store", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c54e9d2..9c1fa1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/config", "crates/github", + "crates/gardener", "crates/store", "crates/webhook", "crates/worker", diff --git a/README.md b/README.md index 2e06efb..da3278f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ flowchart LR - Routes configured triggers to a familiar by bot username or label. - Runs `coven-code --headless` with a tokenless session brief. - Posts Check Run state, direct Cave session links, and draft PRs when the run produces commits. +- Runs the Branch Gardener on configured schedules or `garden` commands to prune dead branches and surface PRless work. See [Architecture Diagrams](docs/architecture.md), [Design](DESIGN.md), [Hosted OpenCoven](HOSTED.md), [Familiar Contract](FAMILIAR-CONTRACT.md), [Roadmap](ROADMAP.md), and [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md) for the operational plan. @@ -107,6 +108,8 @@ Implemented lanes: | Maintainer command in a comment (`@cody `) | See the command table below | | PR opened / synchronize / reopened / ready_for_review | Automatic hosted review when the `[review]` policy enables the lane (drafts skipped by default; newer pushes supersede queued reviews of the same PR) | | Review label applied to a PR | Explicit per-PR review opt-in — works even with the automatic lane off, including drafts | +| Branch Gardener schedule | Enqueues adapter-side branch hygiene for configured installation/repository policies | +| Maintainer `garden` command | Runs Branch Gardener on demand for the current repository | Planned lanes: @@ -137,6 +140,7 @@ comments never re-trigger it. | `deepen` | Clarification | Re-review with a wider lens (supporting files, tests) | | `retry` | Re-run the fix lane | Re-run the review | | `cancel` | Clarification (PR reviews only) | Cancel queued reviews for the PR (in-flight work finishes) | +| `garden` | Run Branch Gardener for the repository | Run Branch Gardener for the repository | | `remember` / `forget` | Acknowledged; persistence lands with the memory governance contract (#6) | Same | | `status` | Current task state for this thread | Same | @@ -161,6 +165,7 @@ duplicate comments. | Installation-scoped routing | Implemented | `[[installations]]` policy: per-installation familiar allow-lists, per-repo trigger-lane switches, fail-closed for unlisted installations; absent config keeps open self-hosted routing. | | Usage metering + tier limits | Implemented | Per-installation `max_concurrent` (enforced at claim) and `max_tasks_per_day` (enforced at intake, recorded `ignored:quota_exceeded`); tenant-scoped `GET /api/github/usage` rollup by installation/repo/familiar with attempt runtime. | | Reference demo of the operating loop | Implemented | Offline, self-verifying replay of the full loop with a real adapter binary — see [docs/demo.md](docs/demo.md). | +| Branch Gardener | Implemented | Scheduled and `garden` command runs classify branches, dry-run or prune dead/merged refs by policy, open draft PRs for PRless branches, and report through Cave-visible task state/status surfaces. | | PR lifecycle review trigger | Implemented | Policy-gated auto-review on opened/synchronize/reopened/ready_for_review plus label opt-in; familiar-authored PRs are never auto-reviewed. | | Push / commit review trigger | Partial | Events parsed and typed with fixtures; execution lane needs headless contract v3. | | GitHub App installation tokens | Implemented | Mints installation access tokens from the App private key. | diff --git a/ROADMAP.md b/ROADMAP.md index a91e7d4..06454e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ Goal: support real hosted installations without losing task state or leaking ten - Task audit log and terminal states. (terminal states #2; audit controls #12) - Marker-backed GitHub status comments that are edited in place per task. (#13) - Maintainer command router for status, stop, retry, explain, and approve. (#13) -- **Branch Gardener** — scheduled branch hygiene skill: classify branches, delete dead ones, open draft PRs for PRless work, report to Cave. See [`docs/branch-gardener.md`](docs/branch-gardener.md). (#14) +- **Branch Gardener** — scheduled branch hygiene skill: classify branches, delete dead ones, open draft PRs for PRless work, report to Cave. See [`docs/branch-gardener.md`](docs/branch-gardener.md). (#14) **Shipped.** ## Milestone 3: GitHub Correctness @@ -146,8 +146,7 @@ shipped (see the [backlog DAG](docs/backlog-dag.md) for the full ledger). What remains is sequenced by the hosted beta gates and go-to-market, not code dependencies: -1. **#14 Branch Gardener** — the last scheduled-hygiene skill (in progress). -2. **#16 (GTM)** — landing page + beta waitlist (pricing #17 has shipped — +1. **#16 (GTM)** — landing page + beta waitlist (pricing #17 has shipped — see [docs/pricing.md](docs/pricing.md)). -3. Harden toward the hosted beta gates in [HOSTED.md](HOSTED.md) and stand up +2. Harden toward the hosted beta gates in [HOSTED.md](HOSTED.md) and stand up the hosted control plane on top of the now-durable, tenant-scoped adapter. diff --git a/config/example.toml b/config/example.toml index fa66427..a555cc3 100644 --- a/config/example.toml +++ b/config/example.toml @@ -122,3 +122,17 @@ trigger_labels = ["coven:fix", "coven:review"] # Labels that trigger this famil # retention_days = 365 # forwarded to the runtime; also expires the adapter's memory audit # [memory.repos."acme/billing"] # enabled = true # per-repo opt-in + +# ── Branch Gardener (issue #14) ────────────────────────────────────────────── +# Scheduled branch hygiene is off by default. Enable it per repository to scan +# branches, open draft PRs for PRless work, and optionally prune dead/merged refs. +# Scheduled runs require [[installations]] so the worker knows the installation id. +# [gardener] +# enabled = false +# autonomy = "propose" # propose (dry-run deletes) or prune-dead +# schedule = "0 4 * * *" # UTC daily; accepted form: M H * * * +# exclude = ["release/*", "hotfix/*"] +# draft_pr_label = "coven:garden" +# [gardener.repos."your-org/your-repo"] +# enabled = true +# autonomy = "propose" diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 2e889e3..378dd5d 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,3 +8,4 @@ license.workspace = true anyhow.workspace = true serde.workspace = true toml.workspace = true +coven-github-gardener = { path = "../gardener" } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2847201..ee3f16f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,5 +1,6 @@ //! Configuration types for coven-github installations. +use coven_github_gardener::{parse_schedule, Autonomy}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -19,6 +20,9 @@ pub struct Config { /// Hosted memory governance policy (issue #6). Absent section = memory off. #[serde(default)] pub memory: MemoryConfig, + /// Scheduled branch hygiene policy (issue #14). Absent section = gardener off. + #[serde(default)] + pub gardener: GardenerConfig, /// Task API authentication (issue #3). Absent section = open mode, which /// is only safe for local development. #[serde(default)] @@ -366,6 +370,112 @@ impl ReviewConfig { } } +/// Scheduled branch hygiene policy (issue #14). Lanes default to off. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GardenerConfig { + /// Master opt-in. Repos may opt in or out independently. + #[serde(default)] + pub enabled: bool, + /// Autonomy tier: `propose` or `prune-dead`. + #[serde(default = "default_gardener_autonomy")] + pub autonomy: String, + /// Restricted cron schedule, accepted form `M H * * *`. + #[serde(default = "default_gardener_schedule")] + pub schedule: String, + /// Branch-name exclude patterns: literal, bare `*`, or trailing-`*` prefix. + #[serde(default)] + pub exclude: Vec, + /// Optional label for draft PRs surfaced by the gardener. + pub draft_pr_label: Option, + /// Per-repo overrides keyed "owner/name". + #[serde(default)] + pub repos: std::collections::HashMap, +} + +impl Default for GardenerConfig { + fn default() -> Self { + Self { + enabled: false, + autonomy: default_gardener_autonomy(), + schedule: default_gardener_schedule(), + exclude: Vec::new(), + draft_pr_label: None, + repos: std::collections::HashMap::new(), + } + } +} + +/// Per-repo override of the global [`GardenerConfig`]; unset fields inherit. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct RepoGardenerOverride { + pub enabled: Option, + pub autonomy: Option, + pub schedule: Option, + pub exclude: Option>, + pub draft_pr_label: Option, +} + +/// Effective gardener policy for one repository after override layering. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedGardenerPolicy { + pub enabled: bool, + pub autonomy: Autonomy, + pub schedule: String, + pub exclude: Vec, + pub draft_pr_label: Option, +} + +impl GardenerConfig { + fn overrides(&self, repo: &str) -> Option<&RepoGardenerOverride> { + self.repos.get(repo) + } + + pub fn enabled_for(&self, repo: &str) -> bool { + self.overrides(repo) + .and_then(|o| o.enabled) + .unwrap_or(self.enabled) + } + + pub fn autonomy_for(&self, repo: &str) -> String { + self.overrides(repo) + .and_then(|o| o.autonomy.clone()) + .unwrap_or_else(|| self.autonomy.clone()) + } + + pub fn schedule_for(&self, repo: &str) -> String { + self.overrides(repo) + .and_then(|o| o.schedule.clone()) + .unwrap_or_else(|| self.schedule.clone()) + } + + pub fn exclude_for(&self, repo: &str) -> Vec { + self.overrides(repo) + .and_then(|o| o.exclude.clone()) + .unwrap_or_else(|| self.exclude.clone()) + } + + pub fn draft_pr_label_for(&self, repo: &str) -> Option { + self.overrides(repo) + .and_then(|o| o.draft_pr_label.clone()) + .or_else(|| self.draft_pr_label.clone()) + } +} + +fn default_gardener_autonomy() -> String { + "propose".to_string() +} + +fn default_gardener_schedule() -> String { + "0 4 * * *".to_string() +} + +fn gardener_autonomy(value: &str) -> Autonomy { + match value { + "prune-dead" => Autonomy::PruneDead, + _ => Autonomy::Propose, + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { /// Bind address, e.g. "0.0.0.0:3000" @@ -526,8 +636,7 @@ impl Config { triggers: TriggerPolicy::default(), }; } - let Some(installation) = self.installations.iter().find(|i| i.id == installation_id) - else { + let Some(installation) = self.installations.iter().find(|i| i.id == installation_id) else { return RoutingScope::closed(); }; let familiars: Vec<&FamiliarConfig> = if installation.familiars.is_empty() { @@ -558,7 +667,10 @@ impl Config { reviews: o.reviews.unwrap_or(triggers.reviews), }; } - RoutingScope { familiars, triggers } + RoutingScope { + familiars, + triggers, + } } /// The routing policy view for one installation, for the Cave dashboard @@ -634,6 +746,18 @@ impl Config { .collect() } + /// Resolves the effective gardener policy for `repo` (issue #14). + pub fn gardener_policy(&self, repo: &str) -> ResolvedGardenerPolicy { + let autonomy = self.gardener.autonomy_for(repo); + ResolvedGardenerPolicy { + enabled: self.gardener.enabled_for(repo), + autonomy: gardener_autonomy(&autonomy), + schedule: self.gardener.schedule_for(repo), + exclude: self.gardener.exclude_for(repo), + draft_pr_label: self.gardener.draft_pr_label_for(repo), + } + } + /// Run semantic validation over a parsed config and return every diagnostic /// found. An empty `Error`-severity set means the config is ready to serve. /// @@ -932,7 +1056,10 @@ impl Config { )), }; if self.review.pull_request { - check_reviewer("the pull_request review lane", self.review.familiar.as_deref()); + check_reviewer( + "the pull_request review lane", + self.review.familiar.as_deref(), + ); } for (repo, o) in &self.review.repos { if o.pull_request == Some(true) { @@ -951,7 +1078,10 @@ impl Config { if !severities.contains(&value.to_ascii_lowercase().as_str()) { out.push(Diagnostic::error( "review.min_severity", - format!("{scope} has unknown min_severity '{value}' — use one of: {}.", severities.join(", ")), + format!( + "{scope} has unknown min_severity '{value}' — use one of: {}.", + severities.join(", ") + ), )); } } @@ -959,7 +1089,10 @@ impl Config { if !publish_modes.contains(&value.to_ascii_lowercase().as_str()) { out.push(Diagnostic::error( "review.publish", - format!("{scope} has unknown publish mode '{value}' — use one of: {}.", publish_modes.join(", ")), + format!( + "{scope} has unknown publish mode '{value}' — use one of: {}.", + publish_modes.join(", ") + ), )); } } @@ -977,14 +1110,75 @@ impl Config { ); } + // ── Branch gardener (issue #14) ────────────────────────────────── + validate_gardener_autonomy( + &mut out, + "gardener.autonomy", + "the [gardener] section", + &self.gardener.autonomy, + ); + validate_gardener_schedule( + &mut out, + "gardener.schedule", + "the [gardener] section", + &self.gardener.schedule, + ); + let gardener_on = self.gardener.enabled + || self + .gardener + .repos + .values() + .any(|o| o.enabled == Some(true)); + if gardener_on { + validate_gardener_excludes( + &mut out, + "gardener.exclude[]", + "the [gardener] section", + &self.gardener.exclude, + ); + } + for (repo, o) in &self.gardener.repos { + if let Some(autonomy) = &o.autonomy { + validate_gardener_autonomy( + &mut out, + &format!("gardener.repos.\"{repo}\".autonomy"), + &format!("the gardener override for '{repo}'"), + autonomy, + ); + } + if let Some(schedule) = &o.schedule { + validate_gardener_schedule( + &mut out, + &format!("gardener.repos.\"{repo}\".schedule"), + &format!("the gardener override for '{repo}'"), + schedule, + ); + } + if o.enabled.unwrap_or(self.gardener.enabled) { + if let Some(exclude) = &o.exclude { + validate_gardener_excludes( + &mut out, + &format!("gardener.repos.\"{repo}\".exclude[]"), + &format!("the gardener override for '{repo}'"), + exclude, + ); + } + } + } + // ── Memory governance (issue #6) ──────────────────────────────── // Memory is off by default; when an operator enables it anywhere, // warn if writes are not approval-gated — that is the posture that // lets untrusted content shape future reviews. - let memory_on = self.memory.enabled || self.memory.repos.values().any(|o| o.enabled == Some(true)); + let memory_on = + self.memory.enabled || self.memory.repos.values().any(|o| o.enabled == Some(true)); if memory_on { let gated = self.memory.approval_required - && self.memory.repos.values().all(|o| o.approval_required != Some(false)); + && self + .memory + .repos + .values() + .all(|o| o.approval_required != Some(false)); if !gated { out.push(Diagnostic::warning( "memory.approval_required", @@ -1025,6 +1219,18 @@ impl Diagnostic { message, } } + fn error_with_next_step( + field: impl Into, + message: impl Into, + next_step: impl Into, + ) -> Self { + Self { + severity: Severity::Error, + field: field.into(), + message: message.into(), + next_step: next_step.into(), + } + } fn warning(field: &str, message: impl Into) -> Self { let message = message.into(); Self { @@ -1034,11 +1240,81 @@ impl Diagnostic { message, } } + fn warning_with_next_step( + field: impl Into, + message: impl Into, + next_step: impl Into, + ) -> Self { + Self { + severity: Severity::Warning, + field: field.into(), + message: message.into(), + next_step: next_step.into(), + } + } pub fn is_error(&self) -> bool { self.severity == Severity::Error } } +fn validate_gardener_autonomy(out: &mut Vec, field: &str, scope: &str, value: &str) { + if matches!(value, "propose" | "prune-dead") { + return; + } + + let next_step = "Set gardener autonomy to one of the supported tiers: propose or prune-dead."; + if value == "full" { + out.push(Diagnostic::error_with_next_step( + field, + format!( + "{scope} requests autonomy 'full', but the full/approval tier is not yet implemented." + ), + next_step, + )); + } else { + out.push(Diagnostic::error_with_next_step( + field, + format!("{scope} has unknown autonomy '{value}' — use one of: propose, prune-dead."), + next_step, + )); + } +} + +fn validate_gardener_schedule(out: &mut Vec, field: &str, scope: &str, value: &str) { + if let Err(err) = parse_schedule(value) { + out.push(Diagnostic::error_with_next_step( + field, + format!("{scope} has unsupported schedule '{value}': {err}."), + "Use the accepted schedule form 'M H * * *', for example '0 4 * * *'.", + )); + } +} + +fn validate_gardener_excludes( + out: &mut Vec, + field: &str, + scope: &str, + patterns: &[String], +) { + for pattern in patterns { + if has_non_trailing_star(pattern) { + out.push(Diagnostic::warning_with_next_step( + field, + format!( + "{scope} has exclude pattern '{pattern}' with '*' before the final character; the gardener will only match it literally." + ), + "Use a literal branch name, a trailing-* prefix such as 'release/*', or bare '*' to match every branch.", + )); + } + } +} + +fn has_non_trailing_star(pattern: &str) -> bool { + pattern + .char_indices() + .any(|(idx, ch)| ch == '*' && idx + ch.len_utf8() != pattern.len()) +} + const PLACEHOLDER_SECRETS: &[&str] = &[ "CHANGE_ME", "CHANGEME", @@ -1209,6 +1485,7 @@ mod tests { review: ReviewConfig::default(), storage: StorageConfig::default(), memory: MemoryConfig::default(), + gardener: GardenerConfig::default(), api: ApiConfig::default(), installations: vec![], } @@ -1384,10 +1661,314 @@ mod tests { approval_required: None, }, ); - assert!(!memory.enabled_for("acme/quiet"), "override disables the repo"); + assert!( + !memory.enabled_for("acme/quiet"), + "override disables the repo" + ); assert!(memory.enabled_for("acme/loud")); } + #[test] + fn gardener_policy_defaults_to_disabled_propose_daily_schedule() { + let policy = GardenerConfig::default(); + assert!(!policy.enabled_for("OpenCoven/any")); + assert_eq!(policy.autonomy_for("OpenCoven/any"), "propose"); + assert_eq!(policy.schedule_for("OpenCoven/any"), "0 4 * * *"); + + let resolved = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: PathBuf::from("key.pem"), + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: PathBuf::from("coven-code"), + workspace_root: PathBuf::from("."), + timeout_secs: 600, + max_retries: 2, + backend: WorkerBackendKind::Host, + container: ContainerConfig::default(), + allow_host_backend: true, + }, + vec![good_familiar()], + ) + .gardener_policy("OpenCoven/any"); + assert!(!resolved.enabled); + assert_eq!(resolved.autonomy, coven_github_gardener::Autonomy::Propose); + assert_eq!(resolved.schedule, "0 4 * * *"); + assert!(resolved.exclude.is_empty()); + assert_eq!(resolved.draft_pr_label, None); + } + + #[test] + fn gardener_policy_resolves_repo_overrides_and_repo_opt_in() { + let mut gardener = GardenerConfig { + enabled: true, + autonomy: "propose".to_string(), + schedule: "0 4 * * *".to_string(), + exclude: vec!["release/*".to_string()], + draft_pr_label: Some("coven:garden".to_string()), + repos: std::collections::HashMap::new(), + }; + gardener.repos.insert( + "OpenCoven/coven-github".to_string(), + RepoGardenerOverride { + enabled: Some(true), + autonomy: Some("prune-dead".to_string()), + schedule: Some("30 6 * * *".to_string()), + exclude: Some(vec!["keep/*".to_string()]), + draft_pr_label: Some("coven:garden-local".to_string()), + }, + ); + gardener.repos.insert( + "OpenCoven/quiet".to_string(), + RepoGardenerOverride { + enabled: Some(false), + autonomy: None, + schedule: None, + exclude: None, + draft_pr_label: None, + }, + ); + + assert!(gardener.enabled_for("OpenCoven/other")); + assert!(gardener.enabled_for("OpenCoven/coven-github")); + assert!(!gardener.enabled_for("OpenCoven/quiet")); + assert_eq!( + gardener.autonomy_for("OpenCoven/coven-github"), + "prune-dead" + ); + assert_eq!( + gardener.schedule_for("OpenCoven/coven-github"), + "30 6 * * *" + ); + assert_eq!( + gardener.exclude_for("OpenCoven/coven-github"), + vec!["keep/*".to_string()] + ); + assert_eq!( + gardener + .draft_pr_label_for("OpenCoven/coven-github") + .as_deref(), + Some("coven:garden-local") + ); + + let mut repo_only = GardenerConfig::default(); + repo_only.repos.insert( + "OpenCoven/coven-github".to_string(), + RepoGardenerOverride { + enabled: Some(true), + autonomy: Some("propose".to_string()), + schedule: Some("15 3 * * *".to_string()), + exclude: None, + draft_pr_label: None, + }, + ); + assert!(repo_only.enabled_for("OpenCoven/coven-github")); + assert!(!repo_only.enabled_for("OpenCoven/other")); + } + + #[test] + fn gardener_policy_maps_supported_autonomy_strings_to_enums() { + let mut cfg = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: PathBuf::from("key.pem"), + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: PathBuf::from("coven-code"), + workspace_root: PathBuf::from("."), + timeout_secs: 600, + max_retries: 2, + backend: WorkerBackendKind::Host, + container: ContainerConfig::default(), + allow_host_backend: true, + }, + vec![good_familiar()], + ); + cfg.gardener.autonomy = "propose".to_string(); + assert_eq!( + cfg.gardener_policy("OpenCoven/any").autonomy, + coven_github_gardener::Autonomy::Propose + ); + cfg.gardener.autonomy = "prune-dead".to_string(); + assert_eq!( + cfg.gardener_policy("OpenCoven/any").autonomy, + coven_github_gardener::Autonomy::PruneDead + ); + } + + #[test] + fn doctor_validates_gardener_policy_values() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let bin = write_bin(&dir); + let mut cfg = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + backend: WorkerBackendKind::Host, + container: ContainerConfig::default(), + allow_host_backend: true, + }, + vec![good_familiar()], + ); + cfg.gardener.enabled = true; + cfg.gardener.autonomy = "full".to_string(); + cfg.gardener.schedule = "*/5 4 * * *".to_string(); + cfg.gardener.exclude = vec!["release/*/hotfix".to_string()]; + cfg.gardener.repos.insert( + "o/r".to_string(), + RepoGardenerOverride { + enabled: Some(true), + autonomy: Some("launch".to_string()), + schedule: Some("0 4 * * 1".to_string()), + exclude: Some(vec!["feat/*/wip".to_string()]), + draft_pr_label: None, + }, + ); + + let diags = cfg.check(); + let full = diags + .iter() + .find(|d| d.field == "gardener.autonomy") + .expect("full autonomy should be diagnosed"); + assert_eq!(full.severity, Severity::Error); + assert!(full + .message + .contains("full/approval tier is not yet implemented")); + assert!(full.next_step.contains("propose")); + assert!(full.next_step.contains("prune-dead")); + assert!(errors(&diags).contains(&"gardener.schedule")); + assert!( + diags + .iter() + .any(|d| d.field == "gardener.exclude[]" && d.severity == Severity::Warning), + "mid-pattern wildcard should warn: {diags:?}" + ); + assert!( + errors(&diags).contains(&"gardener.repos.\"o/r\".autonomy"), + "repo autonomy should be diagnosed: {diags:?}" + ); + assert!( + errors(&diags).contains(&"gardener.repos.\"o/r\".schedule"), + "repo schedule should be diagnosed: {diags:?}" + ); + assert!( + diags.iter().any(|d| { + d.field == "gardener.repos.\"o/r\".exclude[]" && d.severity == Severity::Warning + }), + "repo mid-pattern wildcard should warn: {diags:?}" + ); + } + + #[test] + fn doctor_accepts_valid_gardener_policy() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let bin = write_bin(&dir); + let mut cfg = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + backend: WorkerBackendKind::Host, + container: ContainerConfig::default(), + allow_host_backend: true, + }, + vec![good_familiar()], + ); + cfg.gardener.enabled = true; + cfg.gardener.autonomy = "prune-dead".to_string(); + cfg.gardener.schedule = "45 2 * * *".to_string(); + cfg.gardener.exclude = vec!["release/*".to_string(), "*".to_string(), "main".to_string()]; + + let gardener_diags: Vec<_> = cfg + .check() + .into_iter() + .filter(|d| d.field.starts_with("gardener.")) + .collect(); + assert!(gardener_diags.is_empty(), "diags: {gardener_diags:?}"); + } + + #[test] + fn parses_gardener_toml_with_repo_overrides() { + let raw = r#" + [server] + bind = "127.0.0.1:3000" + + [github] + app_id = 123 + private_key_path = "key.pem" + webhook_secret = "a-long-random-webhook-secret" + + [worker] + concurrency = 4 + coven_code_bin = "coven-code" + workspace_root = "." + + [[familiars]] + id = "cody" + display_name = "Cody" + bot_username = "coven-cody[bot]" + trigger_labels = ["coven:fix"] + + [gardener] + enabled = true + autonomy = "propose" + schedule = "0 4 * * *" + exclude = ["release/*"] + draft_pr_label = "coven:garden" + + [gardener.repos."o/r"] + enabled = true + autonomy = "prune-dead" + schedule = "15 5 * * *" + exclude = ["keep/*"] + draft_pr_label = "coven:garden-local" + "#; + + let cfg: Config = toml::from_str(raw).expect("config should parse"); + let policy = cfg.gardener_policy("o/r"); + assert!(policy.enabled); + assert_eq!(policy.autonomy, coven_github_gardener::Autonomy::PruneDead); + assert_eq!(policy.schedule, "15 5 * * *"); + assert_eq!(policy.exclude, vec!["keep/*".to_string()]); + assert_eq!(policy.draft_pr_label.as_deref(), Some("coven:garden-local")); + + let encoded = toml::to_string(&cfg).expect("config should serialize"); + let round_tripped: Config = + toml::from_str(&encoded).expect("serialized config should parse"); + let policy = round_tripped.gardener_policy("o/r"); + assert!(policy.enabled); + assert_eq!(policy.autonomy, coven_github_gardener::Autonomy::PruneDead); + assert_eq!(policy.schedule, "15 5 * * *"); + assert_eq!(policy.exclude, vec!["keep/*".to_string()]); + assert_eq!(policy.draft_pr_label.as_deref(), Some("coven:garden-local")); + } + #[test] fn doctor_warns_when_memory_enabled_without_approval() { let dir = tmpdir(); diff --git a/crates/gardener/Cargo.toml b/crates/gardener/Cargo.toml new file mode 100644 index 0000000..e545895 --- /dev/null +++ b/crates/gardener/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "coven-github-gardener" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true diff --git a/crates/gardener/src/lib.rs b/crates/gardener/src/lib.rs new file mode 100644 index 0000000..e770a79 --- /dev/null +++ b/crates/gardener/src/lib.rs @@ -0,0 +1,12 @@ +//! Pure branch hygiene planning for coven-github. + +pub mod report; +pub mod scan; +pub mod schedule; + +pub use report::{ExecutionCounts, RunReport}; +pub use scan::{ + classify, matches_exclude, plan, Autonomy, BranchClass, BranchFacts, GardenPlan, + GardenerPolicy, PruneAction, PruneReason, SkipCode, SkipReason, SurfaceAction, +}; +pub use schedule::{parse_schedule, Schedule, ScheduleError}; diff --git a/crates/gardener/src/report.rs b/crates/gardener/src/report.rs new file mode 100644 index 0000000..a69cedf --- /dev/null +++ b/crates/gardener/src/report.rs @@ -0,0 +1,303 @@ +//! Markdown reporting for branch gardener plans. + +use serde::{Deserialize, Serialize}; +use std::fmt::Write; + +use crate::scan::{ + Autonomy, GardenPlan, PruneAction, PruneReason, SkipCode, SkipReason, SurfaceAction, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ExecutionCounts { + pub pruned: usize, + pub prune_skipped_moved: usize, + pub surfaced: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunReport { + plan: GardenPlan, + executed: ExecutionCounts, +} + +impl RunReport { + pub fn from_plan(plan: &GardenPlan, executed: &ExecutionCounts) -> Self { + Self { + plan: plan.clone(), + executed: *executed, + } + } + + pub fn summary_line(&self) -> String { + format!( + "gardener: pruned {}, surfaced {}, active {}, skipped {}, would-prune {}", + self.executed.pruned, + self.executed.surfaced, + self.plan.active.len(), + self.plan.skipped.len(), + self.plan.would_prune.len() + ) + } + + pub fn comment_body(&self, repo: &str, autonomy: Autonomy) -> String { + let mut body = String::new(); + writeln!(body, "🌿 Branch Gardener for `{repo}`").expect("write markdown heading"); + writeln!(body).expect("write markdown spacer"); + writeln!(body, "Autonomy: `{}`", autonomy.as_str()).expect("write autonomy"); + writeln!(body).expect("write markdown spacer"); + writeln!(body, "| count | value |").expect("write table header"); + writeln!(body, "| --- | ---: |").expect("write table divider"); + writeln!(body, "| pruned | {} |", self.executed.pruned).expect("write pruned count"); + writeln!( + body, + "| prune-skipped-moved | {} |", + self.executed.prune_skipped_moved + ) + .expect("write skipped moved count"); + writeln!(body, "| surfaced | {} |", self.executed.surfaced).expect("write surfaced count"); + writeln!(body, "| active | {} |", self.plan.active.len()).expect("write active count"); + writeln!(body, "| skipped | {} |", self.plan.skipped.len()).expect("write skipped count"); + writeln!(body, "| would-prune | {} |", self.plan.would_prune.len()) + .expect("write would prune count"); + + write_prune_section(&mut body, "Pruned", &self.plan.prune); + write_prune_section(&mut body, "Would prune", &self.plan.would_prune); + write_surface_section(&mut body, &self.plan.surface); + write_skip_section(&mut body, &self.plan.skipped); + + body + } +} + +impl Autonomy { + fn as_str(self) -> &'static str { + match self { + Autonomy::Propose => "propose", + Autonomy::PruneDead => "prune-dead", + } + } +} + +fn write_prune_section(body: &mut String, title: &str, actions: &[PruneAction]) { + writeln!(body).expect("write markdown spacer"); + writeln!(body, "### {title}").expect("write prune heading"); + if actions.is_empty() { + writeln!(body, "- _(none)_").expect("write empty prune list"); + } else { + for action in actions { + writeln!(body, "{}", format_prune_action(action)).expect("write prune action"); + } + } +} + +fn write_surface_section(body: &mut String, actions: &[SurfaceAction]) { + writeln!(body).expect("write markdown spacer"); + writeln!(body, "### Surfaced").expect("write surface heading"); + if actions.is_empty() { + writeln!(body, "- _(none)_").expect("write empty surface list"); + } else { + for action in actions { + writeln!(body, "{}", format_surface_action(action)).expect("write surface action"); + } + } +} + +fn write_skip_section(body: &mut String, skips: &[SkipReason]) { + writeln!(body).expect("write markdown spacer"); + writeln!(body, "### Skipped").expect("write skipped heading"); + if skips.is_empty() { + writeln!(body, "- _(none)_").expect("write empty skipped list"); + } else { + for skip in skips { + writeln!(body, "{}", format_skip(skip)).expect("write skipped branch"); + } + } +} + +fn format_prune_reason(reason: &PruneReason) -> String { + match reason { + PruneReason::Dead => "dead".to_string(), + PruneReason::Merged { pr } => format!("merged PR #{pr}"), + } +} + +fn format_skip_reason(reason: SkipCode) -> &'static str { + match reason { + SkipCode::Excluded => "excluded", + SkipCode::BotOnlyPrless => "bot-only-prless", + SkipCode::Withheld => "withheld", + } +} + +fn short_sha(sha: &str) -> &str { + sha.get(..7).unwrap_or(sha) +} + +fn code_span(value: &str) -> String { + let longest_run = longest_backtick_run(value); + if longest_run == 0 { + format!("`{value}`") + } else { + let fence = "`".repeat(longest_run + 1); + format!("{fence} {value} {fence}") + } +} + +fn longest_backtick_run(value: &str) -> usize { + let mut longest = 0; + let mut current = 0; + for ch in value.chars() { + if ch == '`' { + current += 1; + longest = longest.max(current); + } else { + current = 0; + } + } + longest +} + +fn format_prune_action(action: &PruneAction) -> String { + format!( + "- {} @ `{}` — {}", + code_span(&action.branch), + short_sha(&action.sha), + format_prune_reason(&action.reason) + ) +} + +fn format_surface_action(action: &SurfaceAction) -> String { + let pr = action + .pr_number + .map(|number| format!("draft PR #{number}")) + .unwrap_or_else(|| "draft PR planned".to_string()); + let label = action + .draft_pr_label + .as_ref() + .map(|label| format!(", label `{label}`")) + .unwrap_or_default(); + + format!( + "- {} @ `{}` — {}{}", + code_span(&action.branch), + short_sha(&action.sha), + pr, + label + ) +} + +fn format_skip(skip: &SkipReason) -> String { + format!( + "- {} — {}", + code_span(&skip.branch), + format_skip_reason(skip.reason) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::{GardenPlan, PruneAction, PruneReason, SkipCode, SkipReason, SurfaceAction}; + + fn sample_plan() -> GardenPlan { + GardenPlan { + prune: vec![PruneAction { + branch: "dead".to_string(), + sha: "abc123".to_string(), + reason: PruneReason::Dead, + }], + would_prune: vec![PruneAction { + branch: "merged".to_string(), + sha: "def456".to_string(), + reason: PruneReason::Merged { pr: 42 }, + }], + surface: vec![SurfaceAction { + branch: "feature".to_string(), + sha: "fed987".to_string(), + draft_pr_label: Some("gardener".to_string()), + pr_number: Some(77), + }], + active: vec!["active".to_string(), "reviewed".to_string()], + skipped: vec![SkipReason { + branch: "main".to_string(), + reason: SkipCode::Excluded, + }], + } + } + + #[test] + fn summary_line_uses_executed_counts_and_plan_counts() { + let report = RunReport::from_plan( + &sample_plan(), + &ExecutionCounts { + pruned: 3, + prune_skipped_moved: 1, + surfaced: 2, + }, + ); + + assert_eq!( + report.summary_line(), + "gardener: pruned 3, surfaced 2, active 2, skipped 1, would-prune 1" + ); + } + + #[test] + fn comment_body_contains_counts_and_branch_sections() { + let report = RunReport::from_plan( + &sample_plan(), + &ExecutionCounts { + pruned: 1, + prune_skipped_moved: 1, + surfaced: 1, + }, + ); + + let body = report.comment_body("OpenCoven/coven-github", Autonomy::PruneDead); + + assert!(body.contains("🌿 Branch Gardener for `OpenCoven/coven-github`")); + assert!(body.contains("| pruned | 1 |")); + assert!(body.contains("| prune-skipped-moved | 1 |")); + assert!(body.contains("| would-prune | 1 |")); + assert!(body.contains("`dead`")); + assert!(body.contains("`merged`")); + assert!(body.contains("PR #42")); + assert!(body.contains("`feature`")); + assert!(body.contains("PR #77")); + assert!(body.contains("`main`")); + assert!(body.contains("excluded")); + } + + #[test] + fn comment_body_uses_safe_code_spans_for_branch_names_with_backticks() { + let plan = GardenPlan { + prune: vec![PruneAction { + branch: "pwn`@org/team".to_string(), + sha: "abc123".to_string(), + reason: PruneReason::Dead, + }], + would_prune: Vec::new(), + surface: vec![SurfaceAction { + branch: "two``ticks".to_string(), + sha: "def456".to_string(), + draft_pr_label: None, + pr_number: None, + }], + active: Vec::new(), + skipped: vec![SkipReason { + branch: "skip`@all".to_string(), + reason: SkipCode::Excluded, + }], + }; + let report = RunReport::from_plan(&plan, &ExecutionCounts::default()); + + let body = report.comment_body("OpenCoven/coven-github", Autonomy::Propose); + + assert!(body.contains("`` pwn`@org/team ``")); + assert!(body.contains("``` two``ticks ```")); + assert!(body.contains("`` skip`@all ``")); + assert!(!body.contains("- `pwn`@org/team`")); + assert!(!body.contains("- `two``ticks`")); + assert!(!body.contains("- `skip`@all`")); + } +} diff --git a/crates/gardener/src/scan.rs b/crates/gardener/src/scan.rs new file mode 100644 index 0000000..c9f026d --- /dev/null +++ b/crates/gardener/src/scan.rs @@ -0,0 +1,390 @@ +//! Branch classification and action planning. +//! +//! Exclude patterns are intentionally small: literal equality, a bare `*` that +//! matches everything, or a trailing-`*` prefix glob such as `release/*`. Other +//! `*` characters are treated literally. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchFacts { + pub name: String, + pub sha: String, + pub protected: bool, + pub ahead: u64, + pub behind: u64, + pub ahead_author_logins: Vec, + pub authors_truncated: bool, + pub open_pr: Option, + pub merged_pr: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BranchClass { + Excluded, + Merged, + Dead, + Active, + Prless, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Autonomy { + Propose, + PruneDead, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GardenerPolicy { + pub autonomy: Autonomy, + pub default_branch: String, + pub exclude: Vec, + pub draft_pr_label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct GardenPlan { + pub prune: Vec, + pub would_prune: Vec, + pub surface: Vec, + pub active: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PruneAction { + pub branch: String, + pub sha: String, + pub reason: PruneReason, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PruneReason { + Dead, + Merged { pr: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceAction { + pub branch: String, + pub sha: String, + pub draft_pr_label: Option, + pub pr_number: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkipReason { + pub branch: String, + pub reason: SkipCode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SkipCode { + Excluded, + BotOnlyPrless, + Withheld, +} + +pub fn classify(facts: &BranchFacts, policy: &GardenerPolicy) -> BranchClass { + if facts.name == policy.default_branch + || facts.protected + || matches_exclude(&facts.name, &policy.exclude) + { + return BranchClass::Excluded; + } + + if facts.open_pr.is_some() { + return BranchClass::Active; + } + + if facts.merged_pr.is_some() && facts.ahead == 0 { + return BranchClass::Merged; + } + + if facts.ahead == 0 { + return BranchClass::Dead; + } + + BranchClass::Prless +} + +pub fn matches_exclude(name: &str, patterns: &[String]) -> bool { + patterns + .iter() + .any(|pattern| matches_exclude_pattern(name, pattern)) +} + +fn matches_exclude_pattern(name: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + + if let Some(prefix) = pattern.strip_suffix('*') { + if !prefix.contains('*') { + return name.starts_with(prefix); + } + } + + name == pattern +} + +pub fn plan(facts: &[BranchFacts], policy: &GardenerPolicy) -> GardenPlan { + let mut plan = GardenPlan::default(); + + for branch in facts { + match classify(branch, policy) { + BranchClass::Excluded => plan.skipped.push(skip(branch, SkipCode::Excluded)), + class @ (BranchClass::Merged | BranchClass::Dead) => { + let action = prune_action(branch, class); + match policy.autonomy { + Autonomy::Propose => plan.would_prune.push(action), + Autonomy::PruneDead => plan.prune.push(action), + } + } + BranchClass::Active => plan.active.push(branch.name.clone()), + BranchClass::Prless => { + if is_proven_bot_only(branch) { + plan.skipped.push(skip(branch, SkipCode::BotOnlyPrless)); + } else { + plan.surface.push(SurfaceAction { + branch: branch.name.clone(), + sha: branch.sha.clone(), + draft_pr_label: policy.draft_pr_label.clone(), + pr_number: None, + }); + } + } + } + } + + plan +} + +fn prune_action(facts: &BranchFacts, class: BranchClass) -> PruneAction { + debug_assert!(matches!(class, BranchClass::Dead | BranchClass::Merged)); + debug_assert_eq!(facts.ahead, 0); + + let reason = match class { + BranchClass::Merged => PruneReason::Merged { + pr: facts + .merged_pr + .expect("merged class requires a merged pull request number"), + }, + BranchClass::Dead => PruneReason::Dead, + BranchClass::Excluded | BranchClass::Active | BranchClass::Prless => { + unreachable!("non-prune class passed to prune_action") + } + }; + + PruneAction { + branch: facts.name.clone(), + sha: facts.sha.clone(), + reason, + } +} + +fn skip(facts: &BranchFacts, reason: SkipCode) -> SkipReason { + SkipReason { + branch: facts.name.clone(), + reason, + } +} + +fn is_proven_bot_only(facts: &BranchFacts) -> bool { + !facts.authors_truncated + && !facts.ahead_author_logins.is_empty() + && facts + .ahead_author_logins + .iter() + .all(|login| login.ends_with("[bot]")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy(autonomy: Autonomy) -> GardenerPolicy { + GardenerPolicy { + autonomy, + default_branch: "main".to_string(), + exclude: vec!["skip/*".to_string()], + draft_pr_label: Some("gardener".to_string()), + } + } + + fn facts(name: &str) -> BranchFacts { + BranchFacts { + name: name.to_string(), + sha: format!("sha-{name}"), + protected: false, + ahead: 1, + behind: 0, + ahead_author_logins: vec!["human".to_string()], + authors_truncated: false, + open_pr: None, + merged_pr: None, + } + } + + #[test] + fn classifies_every_branch_class_with_first_match_precedence() { + let policy = policy(Autonomy::Propose); + + let mut excluded = facts("skip/release"); + excluded.ahead = 0; + excluded.merged_pr = Some(10); + assert_eq!(classify(&excluded, &policy), BranchClass::Excluded); + + let mut protected = facts("protected"); + protected.protected = true; + protected.ahead = 0; + assert_eq!(classify(&protected, &policy), BranchClass::Excluded); + + let mut default_branch = facts("main"); + default_branch.ahead = 42; + assert_eq!(classify(&default_branch, &policy), BranchClass::Excluded); + + let mut merged = facts("merged"); + merged.ahead = 0; + merged.merged_pr = Some(11); + assert_eq!(classify(&merged, &policy), BranchClass::Merged); + + let mut dead = facts("dead"); + dead.ahead = 0; + assert_eq!(classify(&dead, &policy), BranchClass::Dead); + + let mut active = facts("active"); + active.open_pr = Some(12); + assert_eq!(classify(&active, &policy), BranchClass::Active); + + let prless = facts("prless"); + assert_eq!(classify(&prless, &policy), BranchClass::Prless); + } + + #[test] + fn open_pull_request_keeps_zero_ahead_branches_active() { + let policy = policy(Autonomy::Propose); + + let mut open_pr_not_merged = facts("release-targeted"); + open_pr_not_merged.ahead = 0; + open_pr_not_merged.open_pr = Some(21); + assert_eq!(classify(&open_pr_not_merged, &policy), BranchClass::Active); + + let mut open_pr_with_merged_history = facts("reopened-release-targeted"); + open_pr_with_merged_history.ahead = 0; + open_pr_with_merged_history.open_pr = Some(22); + open_pr_with_merged_history.merged_pr = Some(20); + assert_eq!( + classify(&open_pr_with_merged_history, &policy), + BranchClass::Active + ); + } + + #[test] + fn exclude_matcher_supports_literals_bare_star_and_trailing_star_only() { + assert!(matches_exclude("release", &["release".to_string()])); + assert!(matches_exclude("release/1.2", &["release/*".to_string()])); + assert!(!matches_exclude("release", &["release/*".to_string()])); + assert!(matches_exclude("anything", &["*".to_string()])); + assert!(matches_exclude("a*b", &["a*b".to_string()])); + assert!(!matches_exclude("axb", &["a*b".to_string()])); + } + + #[test] + fn bot_only_prless_branches_are_skipped_only_when_proven_bot_only() { + let policy = policy(Autonomy::Propose); + let mut all_bot = facts("bot-only"); + all_bot.ahead_author_logins = + vec!["dependabot[bot]".to_string(), "renovate[bot]".to_string()]; + + let mut mixed = facts("mixed"); + mixed.ahead_author_logins = vec!["dependabot[bot]".to_string(), "alice".to_string()]; + + let mut truncated = facts("truncated"); + truncated.ahead_author_logins = vec!["dependabot[bot]".to_string()]; + truncated.authors_truncated = true; + + let mut empty = facts("empty"); + empty.ahead_author_logins.clear(); + + let plan = plan(&[all_bot, mixed, truncated, empty], &policy); + + assert_eq!( + plan.skipped, + vec![SkipReason { + branch: "bot-only".to_string(), + reason: SkipCode::BotOnlyPrless, + }] + ); + let surfaced: Vec<_> = plan + .surface + .iter() + .map(|action| action.branch.as_str()) + .collect(); + assert_eq!(surfaced, vec!["mixed", "truncated", "empty"]); + } + + #[test] + fn autonomy_controls_dead_and_merged_prune_buckets() { + let mut dead = facts("dead"); + dead.ahead = 0; + let mut merged = facts("merged"); + merged.ahead = 0; + merged.merged_pr = Some(7); + + let propose = plan(&[dead.clone(), merged.clone()], &policy(Autonomy::Propose)); + assert!(propose.prune.is_empty()); + assert_eq!(propose.would_prune.len(), 2); + + let prune_dead = plan(&[dead, merged], &policy(Autonomy::PruneDead)); + assert_eq!(prune_dead.prune.len(), 2); + assert!(prune_dead.would_prune.is_empty()); + } + + #[test] + fn active_and_prless_branches_never_land_in_prune_buckets() { + let mut active = facts("active"); + active.open_pr = Some(9); + let prless = facts("prless"); + + let prune_dead = plan( + &[active.clone(), prless.clone()], + &policy(Autonomy::PruneDead), + ); + assert!(prune_dead.prune.is_empty()); + assert!(prune_dead.would_prune.is_empty()); + + let propose = plan(&[active, prless], &policy(Autonomy::Propose)); + assert!(propose.prune.is_empty()); + assert!(propose.would_prune.is_empty()); + } + + #[test] + fn plan_records_active_excluded_and_surface_actions() { + let mut active = facts("active"); + active.open_pr = Some(5); + let mut excluded = facts("skip/nope"); + excluded.ahead = 0; + let prless = facts("feature"); + + let plan = plan(&[active, excluded, prless], &policy(Autonomy::Propose)); + + assert_eq!(plan.active, vec!["active".to_string()]); + assert_eq!( + plan.skipped, + vec![SkipReason { + branch: "skip/nope".to_string(), + reason: SkipCode::Excluded, + }] + ); + assert_eq!( + plan.surface, + vec![SurfaceAction { + branch: "feature".to_string(), + sha: "sha-feature".to_string(), + draft_pr_label: Some("gardener".to_string()), + pr_number: None, + }] + ); + } +} diff --git a/crates/gardener/src/schedule.rs b/crates/gardener/src/schedule.rs new file mode 100644 index 0000000..c74beb4 --- /dev/null +++ b/crates/gardener/src/schedule.rs @@ -0,0 +1,131 @@ +//! Restricted cron schedule parsing for branch gardener runs. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +const ACCEPTED_FORM: &str = + "accepted form: ' * * *' (for example '0 4 * * *')"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Schedule { + pub minute: u8, + pub hour: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScheduleError { + message: String, +} + +impl ScheduleError { + pub fn message(&self) -> &str { + &self.message + } + + fn accepted_form() -> Self { + Self { + message: ACCEPTED_FORM.to_string(), + } + } +} + +impl fmt::Display for ScheduleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ScheduleError {} + +pub fn parse_schedule(input: &str) -> Result { + let fields: Vec<_> = input.split_whitespace().collect(); + if fields.len() != 5 || fields[2] != "*" || fields[3] != "*" || fields[4] != "*" { + return Err(ScheduleError::accepted_form()); + } + + let minute = parse_field(fields[0], 59)?; + let hour = parse_field(fields[1], 23)?; + + Ok(Schedule { minute, hour }) +} + +fn parse_field(field: &str, max: u8) -> Result { + let value = field + .parse::() + .map_err(|_| ScheduleError::accepted_form())?; + if value <= max { + Ok(value) + } else { + Err(ScheduleError::accepted_form()) + } +} + +impl Schedule { + pub fn slot_id(&self, unix_minutes: i64) -> Option { + let minute_of_day = unix_minutes.rem_euclid(1440); + let scheduled_minute = i64::from(self.hour) * 60 + i64::from(self.minute); + if minute_of_day == scheduled_minute { + let unix_day = unix_minutes.div_euclid(1440); + Some(format!("{unix_day}-{:02}{:02}", self.hour, self.minute)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_accepted_five_field_schedule() { + assert_eq!( + parse_schedule("0 4 * * *").unwrap(), + Schedule { minute: 0, hour: 4 } + ); + } + + #[test] + fn rejects_unsupported_schedule_forms_with_human_message() { + for input in [ + "0 4 * * * extra", + "60 4 * * *", + "0 24 * * *", + "*/5 4 * * *", + "0 4 * * 1", + ] { + let error = parse_schedule(input).unwrap_err(); + assert!( + error.to_string().contains("accepted form"), + "{input}: {error}" + ); + assert!(error.message().contains("0-59")); + } + } + + #[test] + fn slot_id_fires_only_on_scheduled_minute() { + let schedule = Schedule { minute: 0, hour: 4 }; + let day_two_four_am = 2 * 1440 + 4 * 60; + + assert_eq!( + schedule.slot_id(day_two_four_am), + Some("2-0400".to_string()) + ); + assert_eq!(schedule.slot_id(day_two_four_am - 1), None); + assert_eq!(schedule.slot_id(day_two_four_am + 1), None); + } + + #[test] + fn slot_ids_are_stable_within_minute_and_differ_across_days() { + let schedule = Schedule { + minute: 30, + hour: 23, + }; + let day_one = 1440 + 23 * 60 + 30; + let day_two = 2 * 1440 + 23 * 60 + 30; + + assert_eq!(schedule.slot_id(day_one), schedule.slot_id(day_one)); + assert_ne!(schedule.slot_id(day_one), schedule.slot_id(day_two)); + } +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 826cbc1..241fb57 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -27,6 +27,18 @@ fn api_url(base_url: &str, path: &str) -> String { format!("{}{}", base_url.trim_end_matches('/'), path) } +fn encode_ref_component(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + fn client() -> anyhow::Result { reqwest::Client::builder() .user_agent("coven-github/0.1") @@ -231,6 +243,12 @@ pub enum TaskKind { /// or a maintainer command (`command:review`, `command:deepen`, …). reason: String, }, + /// Branch Gardener run over this repository. Command-triggered runs report + /// status back to the invoking issue/PR number; scheduled runs have no + /// single GitHub conversation surface. + GardenRun { + report_issue: Option, + }, /// Adapter-only reply on an issue/PR conversation (issue #13): command /// acknowledgements, clarifications, status answers, permission declines. /// Executed without spawning coven-code. diff --git a/crates/github/src/pr.rs b/crates/github/src/pr.rs index 12b8eb5..ce0d529 100644 --- a/crates/github/src/pr.rs +++ b/crates/github/src/pr.rs @@ -10,6 +10,38 @@ struct PullRequestResponse { number: u64, } +/// Pull request summary for PRs found by head branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeadPullRequest { + /// Pull request number. + pub number: u64, + /// Pull request state (`open` or `closed`). + pub state: String, + /// Whether GitHub reports the PR as merged. + pub merged: bool, + /// Whether the PR is a draft. + pub draft: bool, +} + +#[derive(Debug, Deserialize)] +struct HeadPullRequestResponse { + number: u64, + state: String, + merged_at: Option, + draft: bool, +} + +impl From for HeadPullRequest { + fn from(value: HeadPullRequestResponse) -> Self { + Self { + number: value.number, + state: value.state, + merged: value.merged_at.is_some(), + draft: value.draft, + } + } +} + /// Opens a draft pull request. Returns the PR number. #[allow(clippy::too_many_arguments)] pub async fn open_pull_request( @@ -74,6 +106,42 @@ pub async fn open_pull_request_with_base_url( Ok(body.number) } +/// Lists pull requests whose head is `{owner}:{branch}`. +pub async fn list_pulls_by_head( + installation_token: &str, + repo_owner: &str, + repo_name: &str, + branch: &str, +) -> Result> { + list_pulls_by_head_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + repo_owner, + repo_name, + branch, + ) + .await +} + +pub async fn list_pulls_by_head_with_base_url( + api_base_url: &str, + installation_token: &str, + repo_owner: &str, + repo_name: &str, + branch: &str, +) -> Result> { + let client = client()?; + let response = send_json( + &client, + api_base_url, + installation_token, + list_pulls_by_head_request(repo_owner, repo_name, branch), + ) + .await?; + let body: Vec = response.json().await?; + Ok(body.into_iter().map(HeadPullRequest::from).collect()) +} + /// Posts a comment on an issue or PR. pub async fn post_comment( installation_token: &str, @@ -113,6 +181,44 @@ pub async fn post_comment_with_base_url( Ok(()) } +/// Adds labels to an issue or PR. +pub async fn add_labels_to_issue( + installation_token: &str, + repo_owner: &str, + repo_name: &str, + issue_number: u64, + labels: &[String], +) -> Result<()> { + add_labels_to_issue_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + repo_owner, + repo_name, + issue_number, + labels, + ) + .await +} + +pub async fn add_labels_to_issue_with_base_url( + api_base_url: &str, + installation_token: &str, + repo_owner: &str, + repo_name: &str, + issue_number: u64, + labels: &[String], +) -> Result<()> { + let client = client()?; + send_json( + &client, + api_base_url, + installation_token, + add_labels_to_issue_request(repo_owner, repo_name, issue_number, labels), + ) + .await?; + Ok(()) +} + fn pull_request_request( repo_owner: &str, repo_name: &str, @@ -135,6 +241,17 @@ fn pull_request_request( } } +fn list_pulls_by_head_request(repo_owner: &str, repo_name: &str, branch: &str) -> GitHubRequest { + let branch = crate::encode_ref_component(branch); + GitHubRequest { + method: "GET", + path: format!( + "/repos/{repo_owner}/{repo_name}/pulls?state=all&head={repo_owner}:{branch}&per_page=100" + ), + body: serde_json::Value::Null, + } +} + fn issue_comment_request( repo_owner: &str, repo_name: &str, @@ -148,6 +265,19 @@ fn issue_comment_request( } } +fn add_labels_to_issue_request( + repo_owner: &str, + repo_name: &str, + issue_number: u64, + labels: &[String], +) -> GitHubRequest { + GitHubRequest { + method: "POST", + path: format!("/repos/{repo_owner}/{repo_name}/issues/{issue_number}/labels"), + body: serde_json::json!({ "labels": labels }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -187,6 +317,60 @@ mod tests { assert_eq!(request.path, "/repos/octo/repo/issues/7/comments"); assert_eq!(request.body, json!({ "body": "On it" })); } + + #[test] + fn list_pulls_by_head_request_targets_head_query() { + let request = list_pulls_by_head_request("octo", "repo", "coven/fix-7"); + + assert_eq!(request.method, "GET"); + assert_eq!( + request.path, + "/repos/octo/repo/pulls?state=all&head=octo:coven/fix-7&per_page=100" + ); + assert!(request.body.is_null()); + } + + #[test] + fn list_pulls_by_head_request_percent_encodes_query_delimiters() { + let request = list_pulls_by_head_request("octo", "repo", "feature&fix#1"); + + assert_eq!( + request.path, + "/repos/octo/repo/pulls?state=all&head=octo:feature%26fix%231&per_page=100" + ); + } + + #[test] + fn add_labels_to_issue_request_posts_label_names() { + let labels = vec!["branch-gardener".to_string(), "automated".to_string()]; + let request = add_labels_to_issue_request("octo", "repo", 7, &labels); + + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/repos/octo/repo/issues/7/labels"); + assert_eq!( + request.body, + json!({ "labels": ["branch-gardener", "automated"] }) + ); + } + + #[test] + fn head_pull_request_response_derives_merged_from_merged_at() { + let body: Vec = serde_json::from_value(json!([ + { "number": 7, "state": "closed", "merged_at": "2026-07-07T00:00:00Z", "draft": false }, + { "number": 8, "state": "open", "merged_at": null, "draft": true } + ])) + .unwrap(); + + let pulls: Vec<_> = body.into_iter().map(HeadPullRequest::from).collect(); + assert_eq!(pulls[0].number, 7); + assert_eq!(pulls[0].state, "closed"); + assert!(pulls[0].merged); + assert!(!pulls[0].draft); + assert_eq!(pulls[1].number, 8); + assert_eq!(pulls[1].state, "open"); + assert!(!pulls[1].merged); + assert!(pulls[1].draft); + } } /// One issue/PR conversation comment, trimmed to what marker lookup needs. diff --git a/crates/github/src/repo.rs b/crates/github/src/repo.rs index 9269d2a..99249e2 100644 --- a/crates/github/src/repo.rs +++ b/crates/github/src/repo.rs @@ -8,6 +8,8 @@ use serde::Deserialize; use crate::{client, send_json, GitHubRequest, DEFAULT_API_BASE_URL}; +const BRANCH_PAGE_SIZE: usize = 100; + /// Repository metadata we care about for routing and publication. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct RepoMetadata { @@ -27,6 +29,38 @@ pub struct PullRequestRefs { pub head_is_fork: bool, } +/// Repository branch metadata returned by GitHub's branch listing endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GitHubBranch { + /// Branch name. + pub name: String, + /// Commit at the branch tip. + pub commit: GitHubBranchCommit, + /// Whether GitHub reports the branch as protected. + pub protected: bool, +} + +/// Commit reference nested in a listed branch. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GitHubBranchCommit { + /// Commit SHA at the branch tip. + pub sha: String, +} + +/// Ahead/behind counts and ahead-commit author logins from a compare response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompareAheadBehind { + /// Number of commits `head` is ahead of `base`. + pub ahead_by: u64, + /// Number of commits `head` is behind `base`. + pub behind_by: u64, + /// GitHub logins for non-null authors on ahead commits. + pub author_logins: Vec, + /// True when GitHub returned fewer commit records than `ahead_by`; callers + /// must treat a truncated author list as NOT proven bot-only. + pub truncated: bool, +} + #[derive(Debug, Deserialize)] struct BranchResponse { commit: CommitRef, @@ -43,6 +77,41 @@ struct PullRequestMetaResponse { base: PrRef, } +#[derive(Debug, Deserialize)] +struct CompareResponse { + ahead_by: u64, + behind_by: u64, + #[serde(default)] + commits: Vec, +} + +impl CompareResponse { + fn into_ahead_behind(self) -> CompareAheadBehind { + let truncated = self.ahead_by as usize > self.commits.len(); + CompareAheadBehind { + ahead_by: self.ahead_by, + behind_by: self.behind_by, + author_logins: self + .commits + .into_iter() + .filter_map(|commit| commit.author.map(|author| author.login)) + .collect(), + truncated, + } + } +} + +#[derive(Debug, Deserialize)] +struct CompareCommit { + #[serde(default)] + author: Option, +} + +#[derive(Debug, Deserialize)] +struct CompareAuthor { + login: String, +} + impl PullRequestMetaResponse { /// A PR is a fork PR when its head repository differs from its base. A /// missing head repo means the fork was deleted — treated as a fork @@ -128,6 +197,87 @@ pub async fn get_branch_sha_with_base_url( Ok(body.commit.sha) } +/// Lists repository branches, fetching pages of 100 until GitHub returns a +/// short page. +pub async fn list_branches( + installation_token: &str, + owner: &str, + name: &str, +) -> Result> { + list_branches_with_base_url(DEFAULT_API_BASE_URL, installation_token, owner, name).await +} + +pub async fn list_branches_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, +) -> Result> { + let client = client()?; + let mut branches = Vec::new(); + let mut page = 1; + + loop { + let response = send_json( + &client, + api_base_url, + installation_token, + list_branches_request(owner, name, page), + ) + .await?; + let mut page_branches: Vec = response.json().await?; + let page_len = page_branches.len(); + branches.append(&mut page_branches); + + match next_branch_page(page, page_len) { + Some(next_page) => page = next_page, + None => break, + } + } + + Ok(branches) +} + +/// Compares two refs and returns ahead/behind counts plus ahead-commit author +/// logins. +pub async fn compare_ahead_behind( + installation_token: &str, + owner: &str, + name: &str, + base: &str, + head: &str, +) -> Result { + compare_ahead_behind_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + owner, + name, + base, + head, + ) + .await +} + +pub async fn compare_ahead_behind_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, + base: &str, + head: &str, +) -> Result { + let client = client()?; + let response = send_json( + &client, + api_base_url, + installation_token, + compare_request(owner, name, base, head), + ) + .await?; + let body: CompareResponse = response.json().await?; + Ok(body.into_ahead_behind()) +} + /// Fetches the head/base refs and SHAs for a pull request. pub async fn get_pull_request_refs( installation_token: &str, @@ -193,6 +343,41 @@ pub async fn get_pull_request_files_with_base_url( Ok(body.into_iter().map(|f| f.filename).collect()) } +/// Deletes a branch ref. +pub async fn delete_ref( + installation_token: &str, + owner: &str, + name: &str, + branch: &str, +) -> Result<()> { + delete_ref_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + owner, + name, + branch, + ) + .await +} + +pub async fn delete_ref_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, + branch: &str, +) -> Result<()> { + let client = client()?; + send_json( + &client, + api_base_url, + installation_token, + delete_ref_request(owner, name, branch), + ) + .await?; + Ok(()) +} + #[derive(Debug, serde::Deserialize)] struct PullRequestFile { filename: String, @@ -222,6 +407,33 @@ fn get_branch_request(owner: &str, name: &str, branch: &str) -> GitHubRequest { } } +fn list_branches_request(owner: &str, name: &str, page: u32) -> GitHubRequest { + let page_query = if page <= 1 { + String::new() + } else { + format!("&page={page}") + }; + GitHubRequest { + method: "GET", + path: format!("/repos/{owner}/{name}/branches?per_page=100{page_query}"), + body: serde_json::Value::Null, + } +} + +fn next_branch_page(current_page: u32, page_len: usize) -> Option { + (page_len == BRANCH_PAGE_SIZE).then_some(current_page + 1) +} + +fn compare_request(owner: &str, name: &str, base: &str, head: &str) -> GitHubRequest { + let base = crate::encode_ref_component(base); + let head = crate::encode_ref_component(head); + GitHubRequest { + method: "GET", + path: format!("/repos/{owner}/{name}/compare/{base}...{head}"), + body: serde_json::Value::Null, + } +} + fn get_pull_request_request(owner: &str, name: &str, pr_number: u64) -> GitHubRequest { GitHubRequest { method: "GET", @@ -230,6 +442,15 @@ fn get_pull_request_request(owner: &str, name: &str, pr_number: u64) -> GitHubRe } } +fn delete_ref_request(owner: &str, name: &str, branch: &str) -> GitHubRequest { + let branch = crate::encode_ref_component(branch); + GitHubRequest { + method: "DELETE", + path: format!("/repos/{owner}/{name}/git/refs/heads/{branch}"), + body: serde_json::Value::Null, + } +} + #[cfg(test)] mod tests { use super::*; @@ -263,6 +484,60 @@ mod tests { assert_eq!(request.path, "/repos/octo/repo/pulls/7/files?per_page=100"); } + #[test] + fn list_branches_request_targets_first_branches_page() { + let request = list_branches_request("octo", "repo", 1); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/repos/octo/repo/branches?per_page=100"); + assert!(request.body.is_null()); + } + + #[test] + fn list_branches_request_targets_later_branch_pages() { + let request = list_branches_request("octo", "repo", 2); + assert_eq!(request.method, "GET"); + assert_eq!( + request.path, + "/repos/octo/repo/branches?per_page=100&page=2" + ); + assert!(request.body.is_null()); + } + + #[test] + fn list_branches_pagination_continues_until_short_page() { + assert_eq!(next_branch_page(1, 100), Some(2)); + assert_eq!(next_branch_page(2, 99), None); + assert_eq!(next_branch_page(3, 0), None); + } + + #[test] + fn compare_request_targets_compare_endpoint() { + let request = compare_request("octo", "repo", "main", "coven/fix-7"); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/repos/octo/repo/compare/main...coven/fix-7"); + assert!(request.body.is_null()); + } + + #[test] + fn compare_request_percent_encodes_fragment_markers_in_refs() { + let request = compare_request("octo", "repo", "main", "feature#1"); + assert_eq!(request.path, "/repos/octo/repo/compare/main...feature%231"); + } + + #[test] + fn delete_ref_request_deletes_branch_ref() { + let request = delete_ref_request("octo", "repo", "coven/fix-7"); + assert_eq!(request.method, "DELETE"); + assert_eq!(request.path, "/repos/octo/repo/git/refs/heads/coven/fix-7"); + assert!(request.body.is_null()); + } + + #[test] + fn delete_ref_request_percent_encodes_fragment_markers_in_branch_names() { + let request = delete_ref_request("octo", "repo", "feature#1"); + assert_eq!(request.path, "/repos/octo/repo/git/refs/heads/feature%231"); + } + #[test] fn pull_request_file_extracts_filename() { let files: Vec = serde_json::from_value(json!([ @@ -289,6 +564,68 @@ mod tests { assert_eq!(body.commit.sha, "abc123"); } + #[test] + fn branch_list_response_deserializes_name_sha_and_protection() { + let body: Vec = serde_json::from_value(json!([ + { "name": "main", "commit": { "sha": "abc123" }, "protected": true }, + { "name": "feature", "commit": { "sha": "def456" }, "protected": false } + ])) + .unwrap(); + + assert_eq!(body[0].name, "main"); + assert_eq!(body[0].commit.sha, "abc123"); + assert!(body[0].protected); + assert_eq!(body[1].name, "feature"); + assert_eq!(body[1].commit.sha, "def456"); + assert!(!body[1].protected); + } + + #[test] + fn compare_response_extracts_counts_and_skips_null_authors() { + let body: CompareResponse = serde_json::from_value(json!({ + "ahead_by": 2, + "behind_by": 1, + "commits": [ + { "sha": "one", "author": { "login": "coven[bot]" } }, + { "sha": "two", "author": null }, + { "sha": "three", "author": { "login": "BunsDev" } } + ] + })) + .unwrap(); + + let compare = body.into_ahead_behind(); + assert_eq!(compare.ahead_by, 2); + assert_eq!(compare.behind_by, 1); + assert_eq!(compare.author_logins, vec!["coven[bot]", "BunsDev"]); + } + + #[test] + fn compare_response_marks_author_logins_truncated_when_ahead_exceeds_returned_commits() { + let truncated: CompareResponse = serde_json::from_value(json!({ + "ahead_by": 300, + "behind_by": 0, + "commits": [ + { "sha": "one", "author": { "login": "coven[bot]" } }, + { "sha": "two", "author": { "login": "coven[bot]" } } + ] + })) + .unwrap(); + + assert!(truncated.into_ahead_behind().truncated); + + let complete: CompareResponse = serde_json::from_value(json!({ + "ahead_by": 2, + "behind_by": 0, + "commits": [ + { "sha": "one", "author": { "login": "coven[bot]" } }, + { "sha": "two", "author": { "login": "coven[bot]" } } + ] + })) + .unwrap(); + + assert!(!complete.into_ahead_behind().truncated); + } + #[test] fn pull_request_meta_extracts_head_and_base_refs() { let body: PullRequestMetaResponse = serde_json::from_value(json!({ diff --git a/crates/github/src/tasks.rs b/crates/github/src/tasks.rs index 85db30c..21dac0d 100644 --- a/crates/github/src/tasks.rs +++ b/crates/github/src/tasks.rs @@ -27,7 +27,7 @@ pub enum TaskListStatus { pub struct TaskListItem { pub id: String, pub repo: String, - pub issue_number: u64, + pub issue_number: Option, pub issue_title: String, pub branch: Option, pub pr_number: Option, @@ -41,30 +41,34 @@ pub struct TaskListItem { } /// The issue/PR conversation a task surfaces on, with a human-readable title. -pub fn surface_of(kind: &TaskKind) -> (u64, String) { +pub fn surface_of(kind: &TaskKind) -> (Option, String) { match kind { TaskKind::FixIssue { issue_number, issue_title, .. - } => (*issue_number, issue_title.clone()), + } => (Some(*issue_number), issue_title.clone()), TaskKind::RespondToMention { issue_number, .. } => ( - *issue_number, + Some(*issue_number), format!("Respond to issue #{issue_number} mention"), ), TaskKind::AddressReviewComment { pr_number, .. } => { - (*pr_number, format!("Address review on PR #{pr_number}")) + (Some(*pr_number), format!("Address review on PR #{pr_number}")) } TaskKind::ReviewPullRequest { pr_number, pr_title, .. - } => (*pr_number, format!("Review PR #{pr_number}: {pr_title}")), + } => (Some(*pr_number), format!("Review PR #{pr_number}: {pr_title}")), + TaskKind::GardenRun { report_issue } => ( + *report_issue, + "Branch gardener run".to_string(), + ), TaskKind::CommandReply { issue_number, .. } => { - (*issue_number, format!("Reply on #{issue_number}")) + (Some(*issue_number), format!("Reply on #{issue_number}")) } TaskKind::CancelReviews { pr_number } => { - (*pr_number, format!("Cancel queued reviews on PR #{pr_number}")) + (Some(*pr_number), format!("Cancel queued reviews on PR #{pr_number}")) } } } @@ -75,14 +79,14 @@ mod tests { #[test] fn surface_of_names_every_task_kind() { - let cases: Vec<(TaskKind, u64, &str)> = vec![ + let cases: Vec<(TaskKind, Option, &str)> = vec![ ( TaskKind::FixIssue { issue_number: 42, issue_title: "Fix auth".to_string(), issue_body: "b".to_string(), }, - 42, + Some(42), "Fix auth", ), ( @@ -91,7 +95,7 @@ mod tests { pr_title: "t".to_string(), reason: "opened".to_string(), }, - 88, + Some(88), "Review PR #88: t", ), ( @@ -100,7 +104,7 @@ mod tests { comment_body: "c".to_string(), diff_hunk: None, }, - 7, + Some(7), "Address review on PR #7", ), ( @@ -108,9 +112,16 @@ mod tests { issue_number: 3, body: "b".to_string(), }, - 3, + Some(3), "Reply on #3", ), + ( + TaskKind::GardenRun { + report_issue: Some(9), + }, + Some(9), + "Branch gardener run", + ), ]; for (kind, number, title) in cases { let (n, t) = surface_of(&kind); @@ -118,4 +129,11 @@ mod tests { assert_eq!(t, title); } } + + #[test] + fn scheduled_garden_run_has_no_issue_surface() { + let (number, title) = surface_of(&TaskKind::GardenRun { report_issue: None }); + assert_eq!(number, None); + assert_eq!(title, "Branch gardener run"); + } } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index edfd433..d164aa3 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -111,6 +111,18 @@ async fn main() -> Result<()> { worker::run(worker_config, worker_store, worker_notify).await; }); + // Branch Gardener scheduler (issue #14): enqueue one adapter-side + // garden task per configured installation/repo schedule. The durable + // store delivery id makes each schedule slot idempotent across + // process restarts. + let gardener_config = config.clone(); + let gardener_store = store.clone(); + let gardener_notify = notify.clone(); + tokio::spawn(async move { + worker::gardener_schedule::run(gardener_config, gardener_store, gardener_notify) + .await; + }); + // Memory retention sweep (issue #6): when a retention horizon is // configured, periodically expire audit rows older than it. The // first tick fires immediately, so a stale audit is trimmed at boot. diff --git a/crates/webhook/src/commands.rs b/crates/webhook/src/commands.rs index 99bd025..6556639 100644 --- a/crates/webhook/src/commands.rs +++ b/crates/webhook/src/commands.rs @@ -19,6 +19,8 @@ pub enum Command { Retry, /// Cancel queued work for this surface. Cancel, + /// Run the Branch Gardener for this repository. + Garden, /// Memory governance intents — parsed and acknowledged; persistence lands /// with the hosted memory contract (issue #6). Remember { note: String }, @@ -61,6 +63,7 @@ pub fn parse_mention(body: &str, bot_username: &str) -> MentionKind { "deepen" => Command::Deepen, "retry" => Command::Retry, "cancel" => Command::Cancel, + "garden" => Command::Garden, "remember" => Command::Remember { note: args }, "forget" => Command::Forget { note: args }, "status" => Command::Status, @@ -82,7 +85,7 @@ pub fn parse_mention(body: &str, bot_username: &str) -> MentionKind { /// The commands a clarification reply should list. pub const COMMAND_LIST: &str = - "`review`, `fix`, `deepen`, `retry`, `cancel`, `remember`, `forget`, `status`"; + "`review`, `fix`, `deepen`, `retry`, `cancel`, `garden`, `remember`, `forget`, `status`"; /// If `text` starts with the mention as a whole token, returns the remainder. fn strip_command_mention<'a>(text: &'a str, needle: &str) -> Option<&'a str> { @@ -150,6 +153,8 @@ mod tests { ("@coven-cody deepen", Command::Deepen), ("@coven-cody retry", Command::Retry), ("@coven-cody cancel", Command::Cancel), + ("@coven-cody garden", Command::Garden), + ("@coven-cody garden now", Command::Garden), ( "@coven-cody remember we ship Fridays", Command::Remember { @@ -184,6 +189,11 @@ mod tests { ); } + #[test] + fn clarification_command_list_mentions_garden() { + assert!(COMMAND_LIST.contains("`garden`")); + } + #[test] fn casual_mentions_carry_no_command() { // Mid-sentence mention. diff --git a/crates/webhook/src/routes.rs b/crates/webhook/src/routes.rs index 94c4e77..bfc5235 100644 --- a/crates/webhook/src/routes.rs +++ b/crates/webhook/src/routes.rs @@ -942,6 +942,13 @@ fn parse_command<'a>( body: &str, author: &str, ) -> Option<(&'a coven_github_config::FamiliarConfig, Command)> { + if slash_garden_command(body) { + return scope + .familiars() + .find(|f| author != f.bot_username) + .map(|f| (f, Command::Garden)); + } + scope.familiars().find_map(|f| { if author == f.bot_username { return None; @@ -953,6 +960,11 @@ fn parse_command<'a>( }) } +fn slash_garden_command(body: &str) -> bool { + let mut words = body.split_whitespace(); + matches!(words.next(), Some("/coven")) && matches!(words.next(), Some("garden")) +} + /// The issue/PR conversation a command arrived on. struct CommandSurface<'a> { installation_id: u64, @@ -1045,6 +1057,12 @@ async fn command_task( commander, ), Command::Cancel => reply("`cancel` currently applies to queued pull-request reviews only.".to_string()), + Command::Garden => make( + TaskKind::GardenRun { + report_issue: Some(s.number), + }, + commander, + ), // Memory acknowledgements are gated too: only maintainers should hear // how the familiar handles memory intents. Command::Remember { .. } | Command::Forget { .. } => make( @@ -1067,7 +1085,7 @@ async fn command_task( }); let mut lines: Vec = items .iter() - .filter(|t| t.repo == repo && t.issue_number == s.number) + .filter(|t| t.repo == repo && t.issue_number == Some(s.number)) .map(|t| format!("- {} — {}", t.issue_title, status_label(&t.status))) .collect(); let body = if lines.is_empty() { @@ -1091,6 +1109,7 @@ fn verb(command: &Command) -> &'static str { Command::Deepen => "deepen", Command::Retry => "retry", Command::Cancel => "cancel", + Command::Garden => "garden", Command::Remember { .. } => "remember", Command::Forget { .. } => "forget", Command::Status => "status", @@ -1154,6 +1173,7 @@ mod tests { review, storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], }), @@ -1347,6 +1367,88 @@ mod tests { } } + #[tokio::test] + async fn garden_command_on_issue_routes_to_garden_run_with_report_surface() { + let state = app_state(); + let task = event_to_task( + &state, + GitHubEvent::IssueComment(IssueCommentEvent { + installation_id: 123, + repo_owner: "OpenCoven".to_string(), + repo_name: "coven-code".to_string(), + issue_number: 42, + issue_title: "Fix auth".to_string(), + issue_body: "Body".to_string(), + comment_body: "@coven-cody garden".to_string(), + commenter_login: "octocat".to_string(), + on_pull_request: false, + }), + ) + .await + .expect("a garden command on an issue should create a task"); + + assert_eq!(task.commander.as_deref(), Some("octocat")); + match task.kind { + TaskKind::GardenRun { report_issue } => assert_eq!(report_issue, Some(42)), + other => panic!("expected GardenRun for a garden command, got {other:?}"), + } + } + + #[tokio::test] + async fn slash_coven_garden_routes_to_garden_run_with_first_available_familiar() { + let state = app_state(); + let task = event_to_task( + &state, + GitHubEvent::IssueComment(IssueCommentEvent { + installation_id: 123, + repo_owner: "OpenCoven".to_string(), + repo_name: "coven-code".to_string(), + issue_number: 42, + issue_title: "Fix auth".to_string(), + issue_body: "Body".to_string(), + comment_body: "/coven garden".to_string(), + commenter_login: "octocat".to_string(), + on_pull_request: false, + }), + ) + .await + .expect("slash garden command should create a task"); + + assert_eq!(task.familiar_id, "cody"); + assert_eq!(task.commander.as_deref(), Some("octocat")); + match task.kind { + TaskKind::GardenRun { report_issue } => assert_eq!(report_issue, Some(42)), + other => panic!("expected GardenRun for slash garden command, got {other:?}"), + } + } + + #[tokio::test] + async fn garden_command_on_pr_conversation_comment_reports_on_pr_number() { + let state = app_state(); + let task = event_to_task( + &state, + GitHubEvent::IssueComment(IssueCommentEvent { + installation_id: 123, + repo_owner: "OpenCoven".to_string(), + repo_name: "coven-code".to_string(), + issue_number: 73, + issue_title: "Add spell compiler cache".to_string(), + issue_body: "".to_string(), + comment_body: "@coven-cody garden".to_string(), + commenter_login: "octocat".to_string(), + on_pull_request: true, + }), + ) + .await + .expect("a garden command on a PR conversation should create a task"); + + assert_eq!(task.commander.as_deref(), Some("octocat")); + match task.kind { + TaskKind::GardenRun { report_issue } => assert_eq!(report_issue, Some(73)), + other => panic!("expected GardenRun for a PR conversation, got {other:?}"), + } + } + #[tokio::test] async fn casual_mention_creates_no_task() { let state = app_state(); @@ -1654,6 +1756,20 @@ mod command_routing_tests { } } + #[tokio::test] + async fn garden_command_on_pr_review_comment_routes_to_garden_run_with_pr_surface() { + let state = app_state(); + let task = event_to_task(&state, pr_comment("@coven-cody garden")) + .await + .expect("garden command should create a task"); + + assert_eq!(task.commander.as_deref(), Some("octocat")); + match task.kind { + TaskKind::GardenRun { report_issue } => assert_eq!(report_issue, Some(88)), + other => panic!("expected GardenRun, got {other:?}"), + } + } + #[tokio::test] async fn deepen_command_carries_its_verb_in_the_reason() { let state = app_state(); diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index a27bcc9..5ec06a2 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -13,6 +13,7 @@ tracing.workspace = true uuid.workspace = true coven-github-api = { path = "../github" } coven-github-config = { path = "../config" } +coven-github-gardener = { path = "../gardener" } coven-github-store = { path = "../store" } [dev-dependencies] diff --git a/crates/worker/src/brief.rs b/crates/worker/src/brief.rs index b69e29f..92f3611 100644 --- a/crates/worker/src/brief.rs +++ b/crates/worker/src/brief.rs @@ -124,6 +124,7 @@ pub fn build( // paths. TaskKind::CommandReply { .. } => "issue_mention", TaskKind::CancelReviews { .. } => "issue_mention", + TaskKind::GardenRun { .. } => panic!("GardenRun is adapter-side and must not be briefed"), }; let task_brief = match &task.kind { @@ -160,6 +161,7 @@ pub fn build( issue_number: *pr_number, comment_body: format!("Cancel queued reviews for PR #{pr_number}."), }, + TaskKind::GardenRun { .. } => panic!("GardenRun is adapter-side and must not be briefed"), TaskKind::ReviewPullRequest { pr_number, pr_title, diff --git a/crates/worker/src/gardener_exec.rs b/crates/worker/src/gardener_exec.rs new file mode 100644 index 0000000..ff08531 --- /dev/null +++ b/crates/worker/src/gardener_exec.rs @@ -0,0 +1,471 @@ +//! Branch Gardener execution path for adapter-only garden run tasks. + +use anyhow::Result; +use tracing::{info, warn}; + +use coven_github_api::{installation::TokenRole, pr, repo, Task}; +use coven_github_config::{Config, FamiliarConfig}; +use coven_github_gardener::{ + classify as classify_branch, matches_exclude, plan as plan_garden, Autonomy, BranchClass, + BranchFacts, ExecutionCounts, GardenPlan, GardenerPolicy, RunReport, SkipCode, SkipReason, +}; +use coven_github_store::{Store, Terminal, TerminalState}; + +use crate::{redact, status_comment, Minter}; + +async fn finish_garden( + store: &Store, + task: &Task, + state: TerminalState, + summary: impl Into, + detail: Option, +) -> Result<()> { + store + .finish( + &task.id, + Terminal { + state, + summary: Some(summary.into()), + detail, + ..Terminal::default() + }, + ) + .await +} + +async fn upsert_garden_comment( + api_base_url: &str, + orchestration: &str, + familiar: &FamiliarConfig, + task: &Task, + issue_number: u64, + body: &str, +) -> Result<()> { + let marker = status_comment::marker( + &familiar.id, + &task.repo_owner, + &task.repo_name, + issue_number, + ); + status_comment::upsert( + api_base_url, + orchestration, + &task.repo_owner, + &task.repo_name, + issue_number, + &marker, + body, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn finish_failed_garden_run( + api_base_url: &str, + orchestration: &str, + store: &Store, + task: &Task, + familiar: &FamiliarConfig, + report_issue: Option, + summary: &str, + error: &anyhow::Error, +) -> Result<()> { + warn!(task_id = %task.id, "branch gardener run failed: {error:#}"); + finish_garden( + store, + task, + TerminalState::Failed, + summary, + Some(redact::redact(&format!("{error:#}"), &[orchestration])), + ) + .await?; + if let Some(number) = report_issue { + let body = redact::redact( + &format!("Status: failed\n\nBranch Gardener run failed: {summary}."), + &[orchestration], + ); + if let Err(comment_error) = + upsert_garden_comment(api_base_url, orchestration, familiar, task, number, &body).await + { + warn!(task_id = %task.id, "failed to upsert gardener failure status: {comment_error:#}"); + } + } + Ok(()) +} + +async fn scan_garden_branches( + api_base_url: &str, + orchestration: &str, + task: &Task, + resolved: &coven_github_config::ResolvedGardenerPolicy, +) -> Result<(String, Vec)> { + let metadata = repo::get_repo_with_base_url( + api_base_url, + orchestration, + &task.repo_owner, + &task.repo_name, + ) + .await?; + let default_branch = metadata.default_branch; + let branches = repo::list_branches_with_base_url( + api_base_url, + orchestration, + &task.repo_owner, + &task.repo_name, + ) + .await?; + + let mut facts = Vec::with_capacity(branches.len()); + for branch in branches { + let excluded = branch.name == default_branch + || branch.protected + || matches_exclude(&branch.name, &resolved.exclude); + if excluded { + facts.push(BranchFacts { + name: branch.name, + sha: branch.commit.sha, + protected: branch.protected, + ahead: 0, + behind: 0, + ahead_author_logins: Vec::new(), + authors_truncated: false, + open_pr: None, + merged_pr: None, + }); + continue; + } + + let compare = repo::compare_ahead_behind_with_base_url( + api_base_url, + orchestration, + &task.repo_owner, + &task.repo_name, + &default_branch, + &branch.name, + ) + .await?; + let pulls = pr::list_pulls_by_head_with_base_url( + api_base_url, + orchestration, + &task.repo_owner, + &task.repo_name, + &branch.name, + ) + .await?; + facts.push(BranchFacts { + name: branch.name, + sha: branch.commit.sha, + protected: branch.protected, + ahead: compare.ahead_by, + behind: compare.behind_by, + ahead_author_logins: compare.author_logins, + authors_truncated: compare.truncated, + open_pr: pulls + .iter() + .find(|pull| pull.state == "open") + .map(|pull| pull.number), + merged_pr: pulls + .iter() + .find(|pull| pull.merged) + .map(|pull| pull.number), + }); + } + + Ok((default_branch, facts)) +} + +fn trace_garden_plan(facts: &[BranchFacts], policy: &GardenerPolicy) { + for fact in facts { + let class = classify_branch(fact, policy); + let action = match class { + BranchClass::Excluded => "skip", + BranchClass::Merged | BranchClass::Dead => match policy.autonomy { + Autonomy::Propose => "would_prune", + Autonomy::PruneDead => "prune", + }, + BranchClass::Active => "active", + BranchClass::Prless + if fact + .ahead_author_logins + .iter() + .all(|login| login.ends_with("[bot]")) + && !fact.ahead_author_logins.is_empty() + && !fact.authors_truncated => + { + "skip" + } + BranchClass::Prless => "surface", + }; + info!( + branch = %fact.name, + class = ?class, + action, + "branch gardener planned action" + ); + } +} + +fn surface_body(branch: &str, ahead: u64) -> String { + format!( + "Opened by the Branch Gardener to surface `{branch}` for maintainer review.\n\n\ + This branch is {ahead} commit(s) ahead of the default branch." + ) +} + +async fn execute_garden_plan( + api_base_url: &str, + minter: &Minter, + task: &Task, + default_branch: &str, + facts: &[BranchFacts], + plan: &mut GardenPlan, +) -> Result { + let mut counts = ExecutionCounts::default(); + + if !plan.prune.is_empty() { + let agent_git = minter.mint(TokenRole::AgentGit).await?; + for action in &plan.prune { + info!( + branch = %action.branch, + class = ?classify_branch( + facts + .iter() + .find(|fact| fact.name == action.branch) + .expect("planned prune action has source facts"), + &GardenerPolicy { + autonomy: Autonomy::PruneDead, + default_branch: default_branch.to_string(), + exclude: Vec::new(), + draft_pr_label: None, + }, + ), + action = "prune", + "branch gardener pruning branch" + ); + let current_sha = match repo::get_branch_sha_with_base_url( + api_base_url, + &agent_git, + &task.repo_owner, + &task.repo_name, + &action.branch, + ) + .await + { + Ok(sha) => sha, + Err(error) => { + counts.prune_skipped_moved += 1; + warn!( + task_id = %task.id, + branch = %action.branch, + "skipping branch prune because SHA re-check failed: {error:#}" + ); + continue; + } + }; + + if current_sha != action.sha { + counts.prune_skipped_moved += 1; + warn!( + task_id = %task.id, + branch = %action.branch, + scanned_sha = %action.sha, + current_sha = %current_sha, + "skipping branch prune because branch moved" + ); + continue; + } + + match repo::delete_ref_with_base_url( + api_base_url, + &agent_git, + &task.repo_owner, + &task.repo_name, + &action.branch, + ) + .await + { + Ok(()) => counts.pruned += 1, + Err(error) => { + counts.prune_skipped_moved += 1; + warn!( + task_id = %task.id, + branch = %action.branch, + "skipping branch prune after delete failed: {error:#}" + ); + } + } + } + } + + if !plan.surface.is_empty() { + let publication = minter.mint(TokenRole::Publication).await?; + let planned_surface = std::mem::take(&mut plan.surface); + for mut action in planned_surface { + info!( + branch = %action.branch, + class = ?BranchClass::Prless, + action = "surface", + "branch gardener surfacing branch" + ); + let ahead = facts + .iter() + .find(|fact| fact.name == action.branch) + .map(|fact| fact.ahead) + .unwrap_or_default(); + match pr::open_pull_request_with_base_url( + api_base_url, + &publication, + &task.repo_owner, + &task.repo_name, + &action.branch, + default_branch, + &format!("Surface branch {}", action.branch), + &surface_body(&action.branch, ahead), + true, + ) + .await + { + Ok(number) => { + action.pr_number = Some(number); + counts.surfaced += 1; + if let Some(label) = &action.draft_pr_label { + if let Err(error) = pr::add_labels_to_issue_with_base_url( + api_base_url, + &publication, + &task.repo_owner, + &task.repo_name, + number, + std::slice::from_ref(label), + ) + .await + { + warn!( + task_id = %task.id, + branch = %action.branch, + pr_number = number, + "failed to add branch gardener draft label: {error:#}" + ); + } + } + plan.surface.push(action); + } + Err(error) => { + plan.skipped.push(SkipReason { + branch: action.branch.clone(), + reason: SkipCode::Withheld, + }); + warn!( + task_id = %task.id, + branch = %action.branch, + "failed to surface branch as draft PR: {error:#}" + ); + } + } + } + } + + Ok(counts) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn execute_garden_run( + config: &Config, + api_base_url: &str, + orchestration: &str, + minter: &Minter, + store: &Store, + task: &Task, + familiar: &FamiliarConfig, + report_issue: Option, +) -> Result<()> { + let repo_full = format!("{}/{}", task.repo_owner, task.repo_name); + let resolved = config.gardener_policy(&repo_full); + if !resolved.enabled { + if let Some(number) = report_issue { + let body = "Status: done\n\nBranch Gardener is not enabled for this repository. \ + Enable the `gardener` policy in coven-github config for this repo before \ + running it."; + if let Err(error) = + upsert_garden_comment(api_base_url, orchestration, familiar, task, number, body) + .await + { + warn!(task_id = %task.id, "failed to upsert disabled gardener status: {error:#}"); + } + } + finish_garden( + store, + task, + TerminalState::Completed, + "gardener disabled", + None, + ) + .await?; + return Ok(()); + } + + let (default_branch, facts) = + match scan_garden_branches(api_base_url, orchestration, task, &resolved).await { + Ok(scan) => scan, + Err(error) => { + return finish_failed_garden_run( + api_base_url, + orchestration, + store, + task, + familiar, + report_issue, + "gardener scan failed", + &error, + ) + .await; + } + }; + let policy = GardenerPolicy { + autonomy: resolved.autonomy, + default_branch: default_branch.clone(), + exclude: resolved.exclude, + draft_pr_label: resolved.draft_pr_label, + }; + trace_garden_plan(&facts, &policy); + let mut plan = plan_garden(&facts, &policy); + + let counts = match execute_garden_plan( + api_base_url, + minter, + task, + &default_branch, + &facts, + &mut plan, + ) + .await + { + Ok(counts) => counts, + Err(error) => { + return finish_failed_garden_run( + api_base_url, + orchestration, + store, + task, + familiar, + report_issue, + "gardener execution failed", + &error, + ) + .await; + } + }; + + let report = RunReport::from_plan(&plan, &counts); + let summary = report.summary_line(); + if let Some(number) = report_issue { + let body = report.comment_body(&repo_full, policy.autonomy); + if let Err(error) = + upsert_garden_comment(api_base_url, orchestration, familiar, task, number, &body).await + { + warn!(task_id = %task.id, "failed to upsert gardener status: {error:#}"); + } + } + + finish_garden(store, task, TerminalState::Completed, summary, None).await?; + Ok(()) +} diff --git a/crates/worker/src/gardener_schedule.rs b/crates/worker/src/gardener_schedule.rs new file mode 100644 index 0000000..ae13c7e --- /dev/null +++ b/crates/worker/src/gardener_schedule.rs @@ -0,0 +1,314 @@ +//! Scheduled Branch Gardener task enqueueing. + +use anyhow::Result; +use coven_github_api::{Task, TaskKind}; +use coven_github_config::{Config, InstallationConfig}; +use coven_github_gardener::parse_schedule; +use coven_github_store::{Delivery, Recorded, Routing, Store}; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub async fn run(config: Arc, store: Store, notify: Arc) { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + ticker.tick().await; + if let Err(error) = + enqueue_due_gardener_runs(&config, &store, ¬ify, current_unix_minutes()).await + { + tracing::error!("scheduled branch gardener enqueue failed: {error:#}"); + } + } +} + +fn current_unix_minutes() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_div(60) as i64 +} + +async fn enqueue_due_gardener_runs( + config: &Config, + store: &Store, + notify: &tokio::sync::Notify, + unix_minutes: i64, +) -> Result { + let mut enqueued = 0; + for installation in &config.installations { + let Some(familiar_id) = scheduled_familiar_id(config, installation) else { + continue; + }; + for repo in scheduled_repo_candidates(config, installation) { + if !repo_matches_installation_account(&repo, installation) { + continue; + } + let policy = config.gardener_policy(&repo); + if !policy.enabled { + continue; + } + let schedule = match parse_schedule(&policy.schedule) { + Ok(schedule) => schedule, + Err(error) => { + tracing::warn!( + installation_id = installation.id, + repo, + "skipping branch gardener schedule with invalid cron: {error}" + ); + continue; + } + }; + let Some(slot_id) = schedule.slot_id(unix_minutes) else { + continue; + }; + let Some((repo_owner, repo_name)) = repo.split_once('/') else { + tracing::warn!( + installation_id = installation.id, + repo, + "skipping branch gardener schedule with invalid repo key" + ); + continue; + }; + let task = Task { + id: format!( + "garden-{}-{}-{}", + installation.id, + sanitize_id(&repo), + sanitize_id(&slot_id) + ), + installation_id: installation.id, + repo_owner: repo_owner.to_string(), + repo_name: repo_name.to_string(), + kind: TaskKind::GardenRun { report_issue: None }, + familiar_id: familiar_id.clone(), + commander: None, + }; + let recorded = store + .record_delivery( + Delivery { + delivery_id: format!( + "schedule:garden:{}:{repo}:{slot_id}", + installation.id + ), + event: "schedule".to_string(), + action: Some("gardener".to_string()), + installation_id: Some(installation.id), + repo: Some(repo), + payload_hash: format!("garden:{unix_minutes}"), + }, + Routing::Task(&task), + ) + .await?; + if recorded == Recorded::New { + enqueued += 1; + notify.notify_one(); + } + } + } + Ok(enqueued) +} + +fn scheduled_repo_candidates( + config: &Config, + installation: &InstallationConfig, +) -> BTreeSet { + config + .gardener + .repos + .keys() + .chain(installation.repos.keys()) + .cloned() + .collect() +} + +fn scheduled_familiar_id(config: &Config, installation: &InstallationConfig) -> Option { + if installation.familiars.is_empty() { + return config.familiars.first().map(|familiar| familiar.id.clone()); + } + installation + .familiars + .iter() + .find(|id| config.familiars.iter().any(|familiar| familiar.id == **id)) + .cloned() +} + +fn repo_matches_installation_account(repo: &str, installation: &InstallationConfig) -> bool { + let Some(account) = &installation.account else { + return true; + }; + repo.split_once('/') + .map(|(owner, _)| owner == account) + .unwrap_or(false) +} + +fn sanitize_id(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::enqueue_due_gardener_runs; + use coven_github_api::TaskKind; + use coven_github_config::{ + ApiConfig, Config, ContainerConfig, FamiliarConfig, GardenerConfig, GitHubAppConfig, + InstallationConfig, InstallationLimits, MemoryConfig, RepoGardenerOverride, ReviewConfig, + ServerConfig, StorageConfig, TriggerPolicy, WorkerBackendKind, WorkerConfig, + }; + use coven_github_store::Store; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + fn config() -> Arc { + let mut repos = HashMap::new(); + repos.insert( + "OpenCoven/demo".to_string(), + RepoGardenerOverride { + enabled: Some(true), + autonomy: Some("propose".to_string()), + schedule: Some("15 5 * * *".to_string()), + exclude: None, + draft_pr_label: None, + }, + ); + repos.insert( + "OtherOrg/skip".to_string(), + RepoGardenerOverride { + enabled: Some(true), + autonomy: Some("propose".to_string()), + schedule: Some("15 5 * * *".to_string()), + exclude: None, + draft_pr_label: None, + }, + ); + + Arc::new(Config { + server: ServerConfig { + bind: "127.0.0.1:0".to_string(), + cave_base_url: None, + }, + github: GitHubAppConfig { + app_id: 1, + private_key_path: PathBuf::from("unused.pem"), + webhook_secret: "a-long-random-webhook-secret".to_string(), + api_base_url: None, + }, + worker: WorkerConfig { + concurrency: 1, + coven_code_bin: PathBuf::from("coven-code"), + workspace_root: PathBuf::from("."), + timeout_secs: 600, + max_retries: 2, + backend: WorkerBackendKind::Host, + container: ContainerConfig::default(), + allow_host_backend: true, + }, + familiars: vec![ + FamiliarConfig { + id: "cody".to_string(), + display_name: "Cody".to_string(), + bot_username: "coven-cody[bot]".to_string(), + model: None, + skills: Vec::new(), + trigger_labels: Vec::new(), + }, + FamiliarConfig { + id: "nova".to_string(), + display_name: "Nova".to_string(), + bot_username: "coven-nova[bot]".to_string(), + model: None, + skills: Vec::new(), + trigger_labels: Vec::new(), + }, + ], + review: ReviewConfig::default(), + storage: StorageConfig::default(), + memory: MemoryConfig::default(), + gardener: GardenerConfig { + enabled: false, + autonomy: "propose".to_string(), + schedule: "0 4 * * *".to_string(), + exclude: Vec::new(), + draft_pr_label: None, + repos, + }, + api: ApiConfig::default(), + installations: vec![InstallationConfig { + id: 42, + account: Some("OpenCoven".to_string()), + familiars: vec!["nova".to_string()], + triggers: TriggerPolicy::default(), + limits: InstallationLimits::default(), + repos: HashMap::new(), + }], + }) + } + + #[tokio::test] + async fn scheduled_tick_enqueues_one_garden_run_per_due_repo_and_slot() { + let store = Store::open_in_memory().expect("store"); + let notify = Arc::new(tokio::sync::Notify::new()); + let minute = 5 * 60 + 15; + + let enqueued = enqueue_due_gardener_runs(&config(), &store, ¬ify, minute) + .await + .expect("enqueue scheduled runs"); + + assert_eq!(enqueued, 1); + let task = store + .claim_next(&HashMap::new()) + .await + .expect("claim") + .expect("scheduled task"); + assert_eq!(task.installation_id, 42); + assert_eq!(task.repo_owner, "OpenCoven"); + assert_eq!(task.repo_name, "demo"); + assert_eq!(task.familiar_id, "nova"); + assert_eq!(task.commander, None); + assert!(matches!( + task.kind, + TaskKind::GardenRun { report_issue: None } + )); + } + + #[tokio::test] + async fn scheduled_tick_is_idempotent_for_the_same_slot() { + let store = Store::open_in_memory().expect("store"); + let notify = Arc::new(tokio::sync::Notify::new()); + let minute = 2 * 1440 + 5 * 60 + 15; + + let first = enqueue_due_gardener_runs(&config(), &store, ¬ify, minute) + .await + .expect("first enqueue"); + let second = enqueue_due_gardener_runs(&config(), &store, ¬ify, minute) + .await + .expect("second enqueue"); + + assert_eq!(first, 1); + assert_eq!(second, 0); + assert_eq!(store.task_states().await.expect("states").len(), 1); + } + + #[tokio::test] + async fn scheduled_tick_skips_repos_before_their_schedule() { + let store = Store::open_in_memory().expect("store"); + let notify = Arc::new(tokio::sync::Notify::new()); + + let enqueued = enqueue_due_gardener_runs(&config(), &store, ¬ify, 5 * 60 + 14) + .await + .expect("not due"); + + assert_eq!(enqueued, 0); + assert!(store.task_states().await.expect("states").is_empty()); + } +} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 5aad079..2b31772 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -14,6 +14,8 @@ use coven_github_store::{Store, Terminal, TerminalState}; pub mod backend; pub mod brief; +pub(crate) mod gardener_exec; +pub mod gardener_schedule; pub mod findings; pub mod memory; pub mod redact; @@ -99,6 +101,7 @@ enum Prepared { Declined { orchestration: String, }, + AdapterCompleted, } /// Task execution past minter construction; tests inject `Minter::Fixed`. @@ -175,12 +178,8 @@ async fn execute_task_with_minter( familiar.bot_username.trim_end_matches("[bot]") ) }; - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - *pr_number, - ); + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, *pr_number); status_comment::upsert( api_base_url, &orchestration, @@ -232,6 +231,21 @@ async fn execute_task_with_minter( } } + if let TaskKind::GardenRun { report_issue } = &task.kind { + gardener_exec::execute_garden_run( + config, + api_base_url, + &orchestration, + minter, + &store, + &task, + familiar, + *report_issue, + ) + .await?; + return Ok(Prepared::AdapterCompleted); + } + // Resolve target refs and base branch from live GitHub state. Check Runs // must attach to an immutable commit SHA, and PRs must target the repo's // actual base branch rather than a hardcoded "main". @@ -271,12 +285,8 @@ async fn execute_task_with_minter( // Below-write commander: decline on the status surface, do no work. info!(task_id = %task.id, "declining command from a commander without write access"); if let Some(number) = surface_number(&task.kind) { - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - number, - ); + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, number); let body = decline_body(&task); if let Err(e) = status_comment::upsert( api_base_url, @@ -304,6 +314,7 @@ async fn execute_task_with_minter( .await?; return Ok(()); } + Ok(Prepared::AdapterCompleted) => return Ok(()), Err(e) => { error!(task_id = %task.id, "pre-flight failed before check run: {e:#}"); store @@ -389,12 +400,8 @@ async fn execute_task_with_minter( .await .ok(); if let Some(number) = surface_number(&task.kind) { - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - number, - ); + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, number); let body = format!( "Status: superseded\n\nThe PR head moved from `{}` to `{}` while the \ review ran, so these findings no longer describe the current diff. \ @@ -436,10 +443,7 @@ async fn execute_task_with_minter( } Ok(published) => { let disp = disposition(&published.result); - store - .finish(&task.id, terminal_of(&published)) - .await - .ok(); + store.finish(&task.id, terminal_of(&published)).await.ok(); // Findings pass the deterministic publication gates before any // surface sees them (issue #11): scope, severity policy, dedupe. // The digest always lands on the Check Run; policy can add the @@ -496,20 +500,15 @@ async fn execute_task_with_minter( } // Terminal state on the marker-backed status surface (issue #13). if let Some(number) = surface_number(&task.kind) { - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - number, + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, number); + let mut body = final_status_body( + config, + &task.id, + &published.result, + published.opened_pr, + &published.cited_memory, ); - let mut body = - final_status_body( - config, - &task.id, - &published.result, - published.opened_pr, - &published.cited_memory, - ); if let Some(report) = &advisory { body = format!("{body}\n\n{report}"); } @@ -556,12 +555,8 @@ async fn execute_task_with_minter( .await .ok(); if let Some(number) = surface_number(&task.kind) { - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - number, - ); + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, number); let body = redact::redact( &format!("Status: failed\n\nTask failed: {e}"), &[&orchestration], @@ -692,8 +687,8 @@ fn memory_activity_rows( .map(|r| format!("rejected:{}", r.reason)) .unwrap_or_else(|| "accepted".to_string()) }; - let row = |op: &str, target: &str, scope: &str, outcome: String| { - coven_github_store::MemoryActivity { + let row = + |op: &str, target: &str, scope: &str, outcome: String| coven_github_store::MemoryActivity { at: String::new(), installation_id, repo: repo.to_string(), @@ -702,8 +697,7 @@ fn memory_activity_rows( target: target.to_string(), scope: scope.to_string(), outcome, - } - }; + }; let mut rows = Vec::new(); for entry in &used.read { rows.push(row( @@ -897,8 +891,13 @@ async fn run_and_publish( } // Record every reported read/write with the adapter's verdict so a // customer can inspect what memory a familiar used on their repo (#6). - let activity = - memory_activity_rows(task.installation_id, &repo_full, &task.id, used, &rejections); + let activity = memory_activity_rows( + task.installation_id, + &repo_full, + &task.id, + used, + &rejections, + ); if let Err(e) = store.record_memory_activity(activity).await { warn!(task_id = %task.id, "failed to record memory activity: {e:#}"); } @@ -1046,12 +1045,8 @@ async fn open_draft_pr( // the user's view. warn!(task_id = %task.id, "failed to open PR: {e:#}"); if let Some(number) = surface_number(&task.kind) { - let marker = status_comment::marker( - &familiar.id, - &task.repo_owner, - &task.repo_name, - number, - ); + let marker = + status_comment::marker(&familiar.id, &task.repo_owner, &task.repo_name, number); let msg = redact::redact( &format!( "Status: failed\n\nI pushed `{branch}` but could not open the PR automatically: {e}. Open the branch manually or check the App's pull-request permission." @@ -1365,19 +1360,15 @@ fn final_status_body( "Status: needs input\n\n{}\n\nReply on this thread to continue. Session: {session}", result.summary ), - SessionStatus::Failure => format!( - "Status: failed\n\n{}\n\nSession: {session}", - result.summary - ), + SessionStatus::Failure => { + format!("Status: failed\n\n{}\n\nSession: {session}", result.summary) + } SessionStatus::Success | SessionStatus::Partial => match opened_pr { Some(pr_number) => format!( "Status: done\n\n{}\n\nPR #{pr_number} opened. Session: {session}", result.summary ), - None => format!( - "Status: done\n\n{}\n\nSession: {session}", - result.summary - ), + None => format!("Status: done\n\n{}\n\nSession: {session}", result.summary), }, }; // Disclose which memory entries influenced this review (issue #6). @@ -1419,7 +1410,10 @@ async fn commander_below_write( commander, ) .await?; - Ok(!matches!(permission.as_str(), "admin" | "maintain" | "write")) + Ok(!matches!( + permission.as_str(), + "admin" | "maintain" | "write" + )) } /// Status-surface body for a below-write commander. @@ -1448,6 +1442,7 @@ fn task_title(kind: &TaskKind) -> String { pr_title, .. } => format!("Review PR #{pr_number}: {pr_title}"), + TaskKind::GardenRun { .. } => "Run branch gardener".to_string(), TaskKind::CommandReply { issue_number, .. } => format!("Reply on #{issue_number}"), TaskKind::CancelReviews { pr_number } => { format!("Cancel queued reviews on PR #{pr_number}") @@ -1496,7 +1491,8 @@ async fn resolve_targets(api_base_url: &str, token: &str, task: &Task) -> Result TaskKind::FixIssue { .. } | TaskKind::RespondToMention { .. } | TaskKind::CommandReply { .. } - | TaskKind::CancelReviews { .. } => { + | TaskKind::CancelReviews { .. } + | TaskKind::GardenRun { .. } => { let head_sha = repo::get_branch_sha_with_base_url( api_base_url, token, @@ -1525,6 +1521,7 @@ fn surface_number(kind: &TaskKind) -> Option { TaskKind::AddressReviewComment { pr_number, .. } | TaskKind::ReviewPullRequest { pr_number, .. } | TaskKind::CancelReviews { pr_number } => Some(*pr_number), + TaskKind::GardenRun { report_issue } => *report_issue, } } @@ -1915,6 +1912,7 @@ mod disposition_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], } @@ -2044,6 +2042,7 @@ mod process_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], } @@ -2275,8 +2274,7 @@ cat > "$5" < = requests .iter() .filter(|r| { - r.method.as_str() == "PATCH" - && r.url.path() == "/repos/OpenCoven/demo/check-runs/7" + r.method.as_str() == "PATCH" && r.url.path() == "/repos/OpenCoven/demo/check-runs/7" }) .map(|r| String::from_utf8_lossy(&r.body).to_string()) .collect(); @@ -2624,7 +2623,9 @@ exit 0 .map(|r| String::from_utf8_lossy(&r.body).to_string()) .collect(); assert!( - comment_posts.iter().any(|b| b.contains("Status: superseded")), + comment_posts + .iter() + .any(|b| b.contains("Status: superseded")), "status surface must say superseded: {comment_posts:?}" ); assert!( @@ -2643,16 +2644,19 @@ mod command_and_marker_tests { use coven_github_config::{FamiliarConfig, GitHubAppConfig, ServerConfig, WorkerConfig}; use std::collections::HashMap; use std::path::PathBuf; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; const ORCHESTRATION: &str = "ghs_orchestration0000000000000000000000"; + const AGENT_GIT: &str = "ghs_agentgit000000000000000000000000000"; + const PUBLICATION: &str = "ghs_publication0000000000000000000000"; fn fixed_minter() -> Minter { - Minter::Fixed(HashMap::from([( - TokenRole::Orchestration, - ORCHESTRATION.to_string(), - )])) + Minter::Fixed(HashMap::from([ + (TokenRole::Orchestration, ORCHESTRATION.to_string()), + (TokenRole::AgentGit, AGENT_GIT.to_string()), + (TokenRole::Publication, PUBLICATION.to_string()), + ])) } fn config(api_base_url: String) -> Config { @@ -2689,11 +2693,30 @@ mod command_and_marker_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], } } + fn config_with_gardener( + api_base_url: String, + enabled: bool, + autonomy: &str, + draft_pr_label: Option<&str>, + ) -> Config { + let mut config = config(api_base_url); + config.gardener = coven_github_config::GardenerConfig { + enabled, + autonomy: autonomy.to_string(), + schedule: "0 4 * * *".to_string(), + exclude: vec!["keep/*".to_string()], + draft_pr_label: draft_pr_label.map(str::to_string), + repos: HashMap::new(), + }; + config + } + fn task(kind: TaskKind, commander: Option<&str>) -> Task { Task { id: "task-cmd".to_string(), @@ -2799,10 +2822,11 @@ mod command_and_marker_tests { async fn below_write_commander_is_declined_before_any_work() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/repos/OpenCoven/demo/collaborators/drive-by/permission")) + .and(path( + "/repos/OpenCoven/demo/collaborators/drive-by/permission", + )) .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"permission": "read"})), + ResponseTemplate::new(200).set_body_json(serde_json::json!({"permission": "read"})), ) .mount(&server) .await; @@ -2855,15 +2879,616 @@ mod command_and_marker_tests { .find(|r| r.method.as_str() == "POST") .expect("decline should land on the status surface"); let body = String::from_utf8_lossy(&posted.body); - assert!( - body.contains("Status: declined"), - "decline body: {body}" - ); + assert!(body.contains("Status: declined"), "decline body: {body}"); // The durable record stays honest: terminal, with the decline noted. let states = store.task_states().await.expect("states"); assert_eq!(states.len(), 1); assert_eq!(states[0].1, "completed"); } + + fn repo_metadata_mock(default_branch: &str) -> Mock { + Mock::given(method("GET")) + .and(path("/repos/OpenCoven/demo")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "default_branch": default_branch })), + ) + } + + fn branch(name: &str, sha: &str, protected: bool) -> serde_json::Value { + serde_json::json!({ "name": name, "commit": { "sha": sha }, "protected": protected }) + } + + fn branches_mock(branches: Vec) -> Mock { + Mock::given(method("GET")) + .and(path("/repos/OpenCoven/demo/branches")) + .and(query_param("per_page", "100")) + .respond_with(ResponseTemplate::new(200).set_body_json(branches)) + } + + fn compare_mock(branch: &str, ahead: u64, behind: u64, authors: &[&str]) -> Mock { + let commits: Vec<_> = authors + .iter() + .map(|login| serde_json::json!({ "author": { "login": login } })) + .collect(); + Mock::given(method("GET")) + .and(path(format!( + "/repos/OpenCoven/demo/compare/main...{branch}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ahead_by": ahead, + "behind_by": behind, + "commits": commits, + }))) + } + + fn pulls_by_head_mock(branch: &str, pulls: Vec) -> Mock { + Mock::given(method("GET")) + .and(path("/repos/OpenCoven/demo/pulls")) + .and(query_param("state", "all")) + .and(query_param("head", format!("OpenCoven:{branch}"))) + .and(query_param("per_page", "100")) + .respond_with(ResponseTemplate::new(200).set_body_json(pulls)) + } + + fn pull(number: u64, state: &str, merged: bool) -> serde_json::Value { + serde_json::json!({ + "number": number, + "state": state, + "merged_at": if merged { serde_json::Value::String("2026-07-07T00:00:00Z".to_string()) } else { serde_json::Value::Null }, + "draft": false, + }) + } + + fn branch_sha_mock(branch: &str, sha: &str) -> Mock { + Mock::given(method("GET")) + .and(path(format!("/repos/OpenCoven/demo/branches/{branch}"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "commit": { "sha": sha } })), + ) + } + + async fn mount_report_comment_mocks(server: &MockServer, issue: u64) { + Mock::given(method("GET")) + .and(path(format!( + "/repos/OpenCoven/demo/issues/{issue}/comments" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!( + "/repos/OpenCoven/demo/issues/{issue}/comments" + ))) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1}))) + .mount(server) + .await; + } + + #[tokio::test] + async fn drive_by_garden_run_is_declined_before_stub() { + let server = MockServer::start().await; + permission_mock("drive-by", "read").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("drive-by"), + ) + .with_id("garden-task"); + seed(&store, "dl-garden-declined", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "prune-dead", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("declined garden run is not an error"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.url.path().contains("check-runs")), + "no Check Run may be created for a declined garden command" + ); + assert!( + !requests + .iter() + .any(|r| r.url.path() == "/repos/OpenCoven/demo"), + "the garden scan must not run below the gate" + ); + let posted = requests + .iter() + .find(|r| r.method.as_str() == "POST") + .expect("decline should land on the status surface"); + let body = String::from_utf8_lossy(&posted.body); + assert!(body.contains("Status: declined"), "decline body: {body}"); + assert!( + !body.contains("Branch Gardener"), + "the garden runner must not run below the gate: {body}" + ); + + let states: std::collections::HashMap = + store.task_states().await.unwrap().into_iter().collect(); + assert_eq!(states["garden-task"], "completed"); + } + + #[tokio::test] + async fn maintainer_garden_run_posts_stub_without_check_run_or_session() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-task"); + seed(&store, "dl-garden", &garden).await; + + execute_task_with_minter( + &config(server.uri()), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("disabled gardener should complete cleanly"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.url.path().contains("check-runs")), + "garden is adapter-only — no Check Run" + ); + assert!( + !requests + .iter() + .any(|r| r.url.path() == "/repos/OpenCoven/demo"), + "disabled gardener must not scan the repo" + ); + let posted = requests + .iter() + .find(|r| r.method.as_str() == "POST") + .expect("disabled status should land on the report surface"); + let body = String::from_utf8_lossy(&posted.body); + assert!(body.contains("not enabled"), "disabled status body: {body}"); + assert!( + body.contains("gardener"), + "body should point at gardener config: {body}" + ); + + let states: std::collections::HashMap = + store.task_states().await.unwrap().into_iter().collect(); + assert_eq!(states["garden-task"], "completed"); + } + + #[tokio::test] + async fn propose_garden_run_does_not_delete_dead_branches_and_surfaces_prless() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![ + branch("main", "sha-main", false), + branch("dead", "sha-dead", false), + branch("prless", "sha-prless", false), + ]) + .mount(&server) + .await; + compare_mock("dead", 0, 0, &[]).mount(&server).await; + pulls_by_head_mock("dead", vec![]).mount(&server).await; + compare_mock("prless", 2, 0, &["alice", "bob"]) + .mount(&server) + .await; + pulls_by_head_mock("prless", vec![]).mount(&server).await; + Mock::given(method("POST")) + .and(path("/repos/OpenCoven/demo/pulls")) + .respond_with( + ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 17})), + ) + .mount(&server) + .await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-propose"); + seed(&store, "dl-garden-propose", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "propose", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("propose garden run should complete"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.method.as_str() == "DELETE"), + "propose tier must not issue delete requests: {requests:?}" + ); + let pr_post = requests + .iter() + .find(|r| r.method.as_str() == "POST" && r.url.path() == "/repos/OpenCoven/demo/pulls") + .expect("prless branch should be surfaced as a draft PR"); + let pr_body: serde_json::Value = serde_json::from_slice(&pr_post.body).expect("PR JSON"); + assert_eq!(pr_body["draft"], true); + assert_eq!(pr_body["base"], "main"); + assert_eq!(pr_body["head"], "prless"); + let comment = requests + .iter() + .find(|r| { + r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/issues/42/comments" + }) + .expect("report comment should be posted"); + let body = String::from_utf8_lossy(&comment.body); + assert!(body.contains("Would prune"), "body: {body}"); + assert!(body.contains("`dead`"), "body: {body}"); + assert!(body.contains("draft PR #17"), "body: {body}"); + } + + #[tokio::test] + async fn prune_dead_garden_run_deletes_dead_and_merged_but_not_active_or_prless() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![ + branch("main", "sha-main", false), + branch("dead", "sha-dead", false), + branch("merged", "sha-merged", false), + branch("active", "sha-active", false), + branch("prless", "sha-prless", false), + ]) + .mount(&server) + .await; + compare_mock("dead", 0, 0, &[]).mount(&server).await; + pulls_by_head_mock("dead", vec![]).mount(&server).await; + compare_mock("merged", 0, 0, &[]).mount(&server).await; + pulls_by_head_mock("merged", vec![pull(7, "closed", true)]) + .mount(&server) + .await; + compare_mock("active", 0, 1, &[]).mount(&server).await; + pulls_by_head_mock("active", vec![pull(8, "open", false)]) + .mount(&server) + .await; + compare_mock("prless", 1, 0, &["alice"]) + .mount(&server) + .await; + pulls_by_head_mock("prless", vec![]).mount(&server).await; + branch_sha_mock("dead", "sha-dead").mount(&server).await; + branch_sha_mock("merged", "sha-merged").mount(&server).await; + Mock::given(method("DELETE")) + .and(path("/repos/OpenCoven/demo/git/refs/heads/dead")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/OpenCoven/demo/git/refs/heads/merged")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/OpenCoven/demo/pulls")) + .respond_with( + ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 18})), + ) + .mount(&server) + .await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-prune"); + seed(&store, "dl-garden-prune", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "prune-dead", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("prune garden run should complete"); + + let requests = server.received_requests().await.expect("requests recorded"); + let deleted: Vec<_> = requests + .iter() + .filter(|r| r.method.as_str() == "DELETE") + .map(|r| r.url.path().to_string()) + .collect(); + assert_eq!( + deleted, + vec![ + "/repos/OpenCoven/demo/git/refs/heads/dead".to_string(), + "/repos/OpenCoven/demo/git/refs/heads/merged".to_string(), + ] + ); + } + + #[tokio::test] + async fn moved_sha_guard_skips_delete_and_counts_prune_skipped_moved() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![ + branch("main", "sha-main", false), + branch("dead", "sha-old", false), + ]) + .mount(&server) + .await; + compare_mock("dead", 0, 0, &[]).mount(&server).await; + pulls_by_head_mock("dead", vec![]).mount(&server).await; + branch_sha_mock("dead", "sha-new").mount(&server).await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-moved"); + seed(&store, "dl-garden-moved", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "prune-dead", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("moved SHA should not abort run"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.method.as_str() == "DELETE"), + "moved branch must not be deleted" + ); + let comment = requests + .iter() + .find(|r| { + r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/issues/42/comments" + }) + .expect("report comment should be posted"); + let body = String::from_utf8_lossy(&comment.body); + assert!(body.contains("| prune-skipped-moved | 1 |"), "body: {body}"); + } + + #[tokio::test] + async fn prless_branch_opens_draft_pr_and_applies_configured_label() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![ + branch("main", "sha-main", false), + branch("prless", "sha-prless", false), + ]) + .mount(&server) + .await; + compare_mock("prless", 3, 0, &["alice", "bob", "carol"]) + .mount(&server) + .await; + pulls_by_head_mock("prless", vec![]).mount(&server).await; + Mock::given(method("POST")) + .and(path("/repos/OpenCoven/demo/pulls")) + .respond_with( + ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 17})), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/OpenCoven/demo/issues/17/labels")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&server) + .await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-surface"); + seed(&store, "dl-garden-surface", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "propose", Some("branch-gardener")), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("surface garden run should complete"); + + let requests = server.received_requests().await.expect("requests recorded"); + let pr_post = requests + .iter() + .find(|r| r.method.as_str() == "POST" && r.url.path() == "/repos/OpenCoven/demo/pulls") + .expect("draft PR should be opened"); + let pr_body: serde_json::Value = serde_json::from_slice(&pr_post.body).expect("PR JSON"); + assert_eq!(pr_body["draft"], true); + assert_eq!(pr_body["base"], "main"); + assert_eq!(pr_body["title"], "Surface branch prless"); + assert!( + pr_body["body"] + .as_str() + .unwrap() + .contains("3 commit(s) ahead"), + "PR body: {pr_body}" + ); + let labels = requests + .iter() + .find(|r| { + r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/issues/17/labels" + }) + .expect("configured label should be applied to new PR"); + let label_body: serde_json::Value = + serde_json::from_slice(&labels.body).expect("labels JSON"); + assert_eq!(label_body["labels"], serde_json::json!(["branch-gardener"])); + } + + #[tokio::test] + async fn bot_only_prless_branch_is_skipped_without_opening_pr() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![ + branch("main", "sha-main", false), + branch("bot-only", "sha-bot", false), + ]) + .mount(&server) + .await; + compare_mock("bot-only", 2, 0, &["dependabot[bot]", "renovate[bot]"]) + .mount(&server) + .await; + pulls_by_head_mock("bot-only", vec![]).mount(&server).await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-bot-only"); + seed(&store, "dl-garden-bot-only", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "propose", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("bot-only run should complete"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests + .iter() + .any(|r| r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/pulls"), + "bot-only prless branch must not be surfaced" + ); + let comment = requests + .iter() + .find(|r| { + r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/issues/42/comments" + }) + .expect("report comment should be posted"); + let body = String::from_utf8_lossy(&comment.body); + assert!(body.contains("bot-only-prless"), "body: {body}"); + } + + #[tokio::test] + async fn scheduled_garden_run_posts_no_comment_and_finishes_with_real_summary() { + let server = MockServer::start().await; + repo_metadata_mock("main").mount(&server).await; + branches_mock(vec![branch("main", "sha-main", false)]) + .mount(&server) + .await; + + let store = Store::open_in_memory().expect("store"); + let garden = + task(TaskKind::GardenRun { report_issue: None }, None).with_id("garden-scheduled"); + seed(&store, "dl-garden-scheduled", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "propose", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("scheduled garden run should complete"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.url.path().contains("/issues/")), + "scheduled gardener runs must not upsert status comments" + ); + let states: std::collections::HashMap = + store.task_states().await.unwrap().into_iter().collect(); + assert_eq!(states["garden-scheduled"], "completed"); + } + + #[tokio::test] + async fn scan_failure_marks_task_failed_and_posts_failure_comment_without_deletes() { + let server = MockServer::start().await; + permission_mock("octocat", "write").mount(&server).await; + mount_report_comment_mocks(&server, 42).await; + Mock::given(method("GET")) + .and(path("/repos/OpenCoven/demo")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let store = Store::open_in_memory().expect("store"); + let garden = task( + TaskKind::GardenRun { + report_issue: Some(42), + }, + Some("octocat"), + ) + .with_id("garden-scan-failure"); + seed(&store, "dl-garden-scan-failure", &garden).await; + + execute_task_with_minter( + &config_with_gardener(server.uri(), true, "prune-dead", None), + store.clone(), + garden, + &fixed_minter(), + ) + .await + .expect("scan failure should be recorded, not propagated"); + + let states: std::collections::HashMap = + store.task_states().await.unwrap().into_iter().collect(); + assert_eq!(states["garden-scan-failure"], "failed"); + let requests = server.received_requests().await.expect("requests recorded"); + assert!( + !requests.iter().any(|r| r.method.as_str() == "DELETE"), + "scan failure must not delete branches" + ); + let comment = requests + .iter() + .find(|r| { + r.method.as_str() == "POST" + && r.url.path() == "/repos/OpenCoven/demo/issues/42/comments" + }) + .expect("failure comment should be posted"); + let body = String::from_utf8_lossy(&comment.body); + assert!(body.contains("Status: failed"), "body: {body}"); + assert!(body.contains("scan failed"), "body: {body}"); + } + async fn seed(store: &Store, delivery_id: &str, task: &Task) { store .record_delivery( @@ -2933,9 +3558,14 @@ mod command_and_marker_tests { .with_id("cancel-task"); seed(&store, "dl-c", &cancel).await; - execute_task_with_minter(&config(server.uri()), store.clone(), cancel, &fixed_minter()) - .await - .expect("declined cancel is not an error"); + execute_task_with_minter( + &config(server.uri()), + store.clone(), + cancel, + &fixed_minter(), + ) + .await + .expect("declined cancel is not an error"); let states: std::collections::HashMap = store.task_states().await.unwrap().into_iter().collect(); @@ -2966,9 +3596,14 @@ mod command_and_marker_tests { task(TaskKind::CancelReviews { pr_number: 88 }, Some("octocat")).with_id("cancel-task"); seed(&store, "dl-c", &cancel).await; - execute_task_with_minter(&config(server.uri()), store.clone(), cancel, &fixed_minter()) - .await - .expect("cancel should succeed"); + execute_task_with_minter( + &config(server.uri()), + store.clone(), + cancel, + &fixed_minter(), + ) + .await + .expect("cancel should succeed"); let states: std::collections::HashMap = store.task_states().await.unwrap().into_iter().collect(); @@ -3117,7 +3752,9 @@ mod publication_gate_tests { .mount(&server) .await; - let script = format!("#!/usr/bin/env bash\ncat > \"$5\" <<'RESULT'\n{RESULT_JSON}\nRESULT\nexit 0\n"); + let script = format!( + "#!/usr/bin/env bash\ncat > \"$5\" <<'RESULT'\n{RESULT_JSON}\nRESULT\nexit 0\n" + ); let root = std::env::temp_dir().join(format!("coven-github-gates-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&root).expect("test dir"); @@ -3157,6 +3794,7 @@ mod publication_gate_tests { review: policy, storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], }; @@ -3194,7 +3832,10 @@ mod publication_gate_tests { requests } - fn policy(min_severity: Option<&str>, publish: Option<&str>) -> coven_github_config::ReviewConfig { + fn policy( + min_severity: Option<&str>, + publish: Option<&str>, + ) -> coven_github_config::ReviewConfig { coven_github_config::ReviewConfig { familiar: Some("cody".to_string()), pull_request: true, @@ -3212,13 +3853,15 @@ mod publication_gate_tests { let terminal = requests .iter() .filter(|r| { - r.method.as_str() == "PATCH" - && r.url.path() == "/repos/OpenCoven/demo/check-runs/7" + r.method.as_str() == "PATCH" && r.url.path() == "/repos/OpenCoven/demo/check-runs/7" }) .map(|r| String::from_utf8_lossy(&r.body).to_string()) .next_back() .expect("terminal check patch"); - assert!(terminal.contains("Off-by-one"), "digest published: {terminal}"); + assert!( + terminal.contains("Off-by-one"), + "digest published: {terminal}" + ); assert!( !terminal.contains("Speculative"), "out-of-scope finding must be withheld: {terminal}" @@ -3252,7 +3895,10 @@ mod publication_gate_tests { .expect("PR review must be submitted"); let body = String::from_utf8_lossy(&review_post.body); assert!(body.contains("REQUEST_CHANGES"), "verdict: {body}"); - assert!(body.contains("Off-by-one"), "digest in verdict body: {body}"); + assert!( + body.contains("Off-by-one"), + "digest in verdict body: {body}" + ); // The verdict is write-authority work: publication token, never // orchestration (issue #4 boundary). let auth = review_post @@ -3361,6 +4007,7 @@ exit 0 review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], } @@ -3517,6 +4164,7 @@ mod cleanup_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + gardener: coven_github_config::GardenerConfig::default(), api: coven_github_config::ApiConfig::default(), installations: vec![], }; diff --git a/docs/backlog-dag.md b/docs/backlog-dag.md index 11434e9..ebc19d1 100644 --- a/docs/backlog-dag.md +++ b/docs/backlog-dag.md @@ -6,8 +6,8 @@ shipped. Native GitHub "blocked by" relationships encode the same edges; this doc is the readable projection and the status ledger. > **Status: the engineering spine and all of Waves 0–4 are shipped.** What -> remains is one in-progress hygiene skill (#14) and the GTM landing page (#16); -> pricing (#17) has landed. See [Current state](#current-state). +> remains is the GTM landing page (#16); pricing (#17) has landed. See +> [Current state](#current-state). ## Critical path (the spine) — ✅ complete @@ -34,7 +34,7 @@ Legend: ✅ shipped · 🔧 in progress · 🔲 open (no code dependency). - ✅ #8 Check Run head SHA / target ref - ✅ #9 Repo default branch for brief + PR base - ✅ #11 Structured review output + publication gates -- 🔧 #14 Branch Gardener scheduled skill *(in progress)* +- ✅ #14 Branch Gardener scheduled skill **Wave 2** - ✅ #10 PR/push/commit review triggers *(was blocked by #4)* @@ -54,7 +54,7 @@ Legend: ✅ shipped · 🔧 in progress · 🔲 open (no code dependency). | Issue | Blocked by | Status | |---|---|---| -| #3, #4, #5, #8, #9, #11, #14 | #2 | #14 in progress; rest ✅ | +| #3, #4, #5, #8, #9, #11, #14 | #2 | ✅ | | #10 | #4 | ✅ | | #6, #7, #15 | #3 | ✅ | | #12 | #5 | ✅ | @@ -84,7 +84,7 @@ flowchart LR I15["#15 usage metering"]:::done I12["#12 audit/retention"]:::done I18["#18 Cave dashboard"]:::done - I14["#14 Branch Gardener"]:::wip + I14["#14 Branch Gardener"]:::done I16["#16 landing/waitlist"]:::gtm I17["#17 pricing"]:::done @@ -106,11 +106,11 @@ The moat → hosted-V1 engineering backlog is effectively complete: redaction (#12), Cave oversight dashboard (#18): shipped. - **GitHub correctness** — head-SHA/target-ref (#8), default-branch resolution (#9), structured review output + publication gates (#11): shipped. +- **Hosted hygiene** — Branch Gardener scheduled branch cleanup (#14): shipped. - **Hosted worker fleet** — container isolation + resource limits (#5): shipped. **Remaining:** -- 🔧 **#14 Branch Gardener** — scheduled branch-hygiene skill; in progress. - 🔲 **#16 (GTM)** — landing page + beta waitlist; no code dependency, sequenced by go-to-market rather than the DAG. Pricing (#17) has shipped. diff --git a/docs/branch-gardener.md b/docs/branch-gardener.md index 0d313ec..2d20397 100644 --- a/docs/branch-gardener.md +++ b/docs/branch-gardener.md @@ -105,9 +105,9 @@ Branch gardener runs are triggered in two ways: installation using the configured schedule. The worker calls `gardener::run()` with the installation's GitHub token and config. -**On-demand (secondary)** — a maintainer posts `/coven garden` in any issue or PR -comment. The webhook router recognises this as a `gardener` command and enqueues a -one-shot run. +**On-demand (secondary)** — a maintainer posts `/coven garden` (or +`@familiar garden`) in any issue or PR comment. The webhook router recognises +this as a `gardener` command and enqueues a one-shot run. ```mermaid flowchart LR diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 29fbca3..387714a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -96,6 +96,7 @@ Edit `config/local.toml`: - Set `worker.coven_code_bin` to your `coven-code` binary path - Configure `[[familiars]]` with your bot username and model - Set `server.cave_base_url` only if you need to override the hosted Cave default; task comments and Check Runs link to `/sessions/` under this base URL. +- Optional: enable `[gardener.repos."owner/name"]` for scheduled Branch Gardener runs. Scheduled runs require a matching `[[installations]]` id. Important config fields: @@ -111,6 +112,29 @@ Important config fields: | `worker.timeout_secs` | Wall-clock limit for each familiar run. | | `familiars[].bot_username` | GitHub App bot username that assignment and mentions match. | | `familiars[].trigger_labels` | Labels such as `coven:fix` that create familiar tasks. | +| `gardener.repos."owner/name".enabled` | Opts a repository into scheduled Branch Gardener runs. | +| `gardener.schedule` | UTC schedule for Branch Gardener, accepted form `M H * * *`. | + +### Branch Gardener setup + +Branch Gardener is conservative by default: `autonomy = "propose"` opens draft +PRs for PRless branches and reports dead/merged branches without deleting them. +Switch to `prune-dead` only after the reports match your workflow. + +```toml +[gardener] +enabled = false +autonomy = "propose" +schedule = "0 4 * * *" +exclude = ["release/*", "hotfix/*"] +draft_pr_label = "coven:garden" + +[gardener.repos."your-org/your-repo"] +enabled = true +``` + +Maintainers can also run the same path on demand with `@your-bot garden` in an +issue or PR conversation. ### Validate the config