Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ duplicate comments.
| Maintainer command protocol | Implemented | Typed `@familiar <verb>` grammar; casual mentions ignored; write-access gate; self-comments never re-trigger. |
| Marker-backed status comments | Implemented | One edited-in-place status surface per issue/PR; no duplicate bot 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). |
| 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. |
Expand Down
4 changes: 4 additions & 0 deletions config/example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ trigger_labels = ["coven:fix", "coven:review"] # Labels that trigger this famil
# id = 123456 # GitHub App installation id
# account = "your-org"
# familiars = ["cody"] # allow-list; empty = all familiars
# [installations.limits] # tier limits (issue #15); omit = unlimited
# max_concurrent = 2 # tasks running at once for this installation
# max_tasks_per_day = 50 # accepted tasks per rolling 24h; overflow
# # deliveries are recorded ignored:quota_exceeded
# [installations.triggers] # installation-wide lane switches (default on)
# assignment = true
# labels = true
Expand Down
47 changes: 47 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,23 @@ pub struct InstallationConfig {
/// Installation-wide trigger switches; repos may override.
#[serde(default)]
pub triggers: TriggerPolicy,
/// Usage limits for this installation (issue #15). Absent = unlimited.
#[serde(default)]
pub limits: InstallationLimits,
/// Per-repo overrides keyed "owner/name".
#[serde(default)]
pub repos: std::collections::HashMap<String, RepoRoutingOverride>,
}

/// Tier limits for one installation (issue #15). `None` = unlimited.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
pub struct InstallationLimits {
/// Max tasks running at once for this installation.
pub max_concurrent: Option<u32>,
/// Max tasks accepted per rolling 24 hours.
pub max_tasks_per_day: Option<u32>,
}

/// Which trigger lanes may create work. All on by default.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
pub struct TriggerPolicy {
Expand Down Expand Up @@ -433,6 +445,25 @@ impl Config {
RoutingScope { familiars, triggers }
}

/// Usage limits for one installation (issue #15). Unlimited when the
/// installation is not configured.
pub fn limits_for(&self, installation_id: u64) -> InstallationLimits {
self.installations
.iter()
.find(|i| i.id == installation_id)
.map(|i| i.limits)
.unwrap_or_default()
}

/// `installation id → max_concurrent` for every configured cap — the
/// worker's claim filter (issue #15).
pub fn concurrency_caps(&self) -> std::collections::HashMap<u64, u32> {
self.installations
.iter()
.filter_map(|i| i.limits.max_concurrent.map(|cap| (i.id, cap)))
.collect()
}

/// Run semantic validation over a parsed config and return every diagnostic
/// found. An empty `Error`-severity set means the config is ready to serve.
///
Expand Down Expand Up @@ -673,6 +704,17 @@ impl Config {
));
}
}
if installation.limits.max_concurrent == Some(0)
|| installation.limits.max_tasks_per_day == Some(0)
{
out.push(Diagnostic::error(
"installations[].limits",
format!(
"installation {} has a limit of 0 — no task would ever run; omit the limit for unlimited or set it to 1+.",
installation.id
),
));
}
}

// ── Review policy ───────────────────────────────────────────────
Expand Down Expand Up @@ -855,6 +897,9 @@ fn next_step_for(field: &str, _message: &str) -> &'static str {
"installations[].familiars" => {
"Reference only ids that appear in a [[familiars]] block, or leave the list empty to allow all."
}
"installations[].limits" => {
"Remove the zero limit (unlimited) or set max_concurrent / max_tasks_per_day to 1 or more."
}
_ => "Update this config field, then rerun coven-github doctor.",
}
}
Expand Down Expand Up @@ -1326,13 +1371,15 @@ mod tests {
account: None,
familiars: vec!["ghost".to_string()],
triggers: TriggerPolicy::default(),
limits: InstallationLimits::default(),
repos: std::collections::HashMap::new(),
},
InstallationConfig {
id: 7,
account: None,
familiars: vec![],
triggers: TriggerPolicy::default(),
limits: InstallationLimits::default(),
repos: std::collections::HashMap::new(),
},
];
Expand Down
3 changes: 2 additions & 1 deletion crates/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;

use coven_github_config::Config;
use coven_github_webhook::routes::{
handle_webhook, list_memory, list_tasks, revoke_memory, AppState,
handle_webhook, list_memory, list_tasks, revoke_memory, usage, AppState,
};
use coven_github_worker as worker;

Expand Down Expand Up @@ -123,6 +123,7 @@ async fn main() -> Result<()> {
.route("/api/github/tasks", get(list_tasks))
.route("/api/github/memory", get(list_memory))
.route("/api/github/memory/revoke", post(revoke_memory))
.route("/api/github/usage", get(usage))
.with_state(state)
.layer(TraceLayer::new_for_http());

Expand Down
Loading
Loading