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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `flare_git` `pr_wait` action — bounded server-side poll loop for PR checks (default 60s, capped 120s per call), replacing manual `gh pr checks` polling loops (#118)

## [1.6.0](https://github.com/getappz/agentflare/compare/v1.5.0...v1.6.0) - 2026-07-21

### Added
Expand Down
14 changes: 12 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,22 @@ cargo test

### Quality bar (required)

Run the local gate before pushing — it mirrors CI (`.github/workflows/ci.yml`) exactly:

```bash
mise run verify
```

Equivalent without mise:

```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic
cargo test --workspace
```

CI also runs `cargo-deny` (dependency licensing/advisories) and a target-dir guard; those aren't part of the local gate since they need network access or are CI-environment-specific.

## Repo structure

```text
Expand Down
4 changes: 4 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ run = "aube run deploy --filter agentflare-web"
[tasks.tail]
description = "Tail live Cloudflare Worker logs for agentflare.dev"
run = "aube run tail --filter agentflare-web"

[tasks.verify]
description = "Local gate mirroring CI (fmt, clippy -D warnings, full workspace test suite) — run before pushing"
run = "cargo fmt --check && cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic && cargo test --workspace"
75 changes: 75 additions & 0 deletions src/github/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,37 @@ pub fn pr_status_json(
serde_json::to_string(&value).unwrap_or_default()
}

/// Summarizes a `CheckRun` snapshot for `pr_wait`. `status` is `"completed"`
/// only once GitHub has a final verdict; `conclusion` is only set then, so
/// pending checks and their eventual pass/fail state are distinguished by
/// checking `status` first, `conclusion` second — mirrors `pr_status_json`'s
/// same distinction but as a standalone, non-network-dependent function so
/// `pr_wait`'s poll-loop-termination logic is testable without a live token.
pub fn checks_wait_summary(checks: &[CheckRun], elapsed_secs: u64) -> serde_json::Value {
let pending: Vec<&str> = checks
.iter()
.filter(|c| c.status != "completed")
.map(|c| c.name.as_str())
.collect();
let failed: Vec<&str> = checks
.iter()
.filter(|c| {
c.conclusion
.as_deref()
.is_some_and(|concl| !matches!(concl, "success" | "neutral" | "skipped"))
})
.map(|c| c.name.as_str())
.collect();
serde_json::json!({
"pending": !pending.is_empty(),
"pending_checks": pending,
"failed_checks": failed,
"checks_ok": checks.iter().filter(|c| c.conclusion.as_deref() == Some("success")).count(),
"total_checks": checks.len(),
"elapsed_secs": elapsed_secs,
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -186,4 +217,48 @@ mod tests {
assert!(!out.contains("created_at"));
assert!(!out.contains("html_url"));
}

fn check(name: &str, status: &str, conclusion: Option<&str>) -> CheckRun {
serde_json::from_value(serde_json::json!({
"name": name, "status": status, "conclusion": conclusion,
}))
.unwrap()
}

#[test]
fn checks_wait_summary_pending_when_any_check_not_completed() {
let checks = [
check("build", "completed", Some("success")),
check("clippy", "in_progress", None),
];
let out = checks_wait_summary(&checks, 12);
assert_eq!(out["pending"], true);
assert_eq!(out["pending_checks"], serde_json::json!(["clippy"]));
assert_eq!(out["checks_ok"], 1);
assert_eq!(out["total_checks"], 2);
assert_eq!(out["elapsed_secs"], 12);
}

#[test]
fn checks_wait_summary_not_pending_once_all_completed() {
let checks = [
check("build", "completed", Some("success")),
check("fmt", "completed", Some("success")),
];
let out = checks_wait_summary(&checks, 5);
assert_eq!(out["pending"], false);
assert_eq!(out["pending_checks"], serde_json::json!([] as [&str; 0]));
}

#[test]
fn checks_wait_summary_neutral_and_skipped_are_not_failures() {
let checks = [
check("build", "completed", Some("success")),
check("optional", "completed", Some("neutral")),
check("docs", "completed", Some("skipped")),
check("clippy", "completed", Some("failure")),
];
let out = checks_wait_summary(&checks, 30);
assert_eq!(out["failed_checks"], serde_json::json!(["clippy"]));
}
}
35 changes: 35 additions & 0 deletions src/mcp_server/flare_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,41 @@ impl AgentflareMcp {
&comments,
)
}
"pr_wait" => {
// Bounded server-side poll loop: collapses the "for n in ...;
// do gh pr checks; sleep; done" pattern (fragile against the
// lean-ctx shell allowlist, noisy in the transcript) into one
// call. Capped well under typical MCP client/tool timeouts —
// if still pending when the cap hits, the caller just calls
// pr_wait again instead of the whole thing blocking for the
// length of a CI run.
const MAX_WAIT_SECS: u64 = 120;
const MIN_POLL_INTERVAL_SECS: u64 = 3;
let n = req
.number
.ok_or_else(|| ErrorData::invalid_params("number is required", None))?;
let wait_secs = req.wait_secs.unwrap_or(60).min(MAX_WAIT_SECS);
// Clamp to wait_secs too: an unbounded interval would let a
// single sleep() overshoot the documented wait cap.
let poll_interval_secs = req.poll_interval_secs.unwrap_or(10).clamp(
MIN_POLL_INTERVAL_SECS,
wait_secs.max(MIN_POLL_INTERVAL_SECS),
);
let pr = pulls::get(&client, &repo, n).map_err(to_mcp_error)?;
let sha = pr.head.as_ref().map(|h| h.sha.as_str()).unwrap_or_default();
let start = std::time::Instant::now();
let mut checks =
actions::list_check_runs(&client, &repo, sha).map_err(to_mcp_error)?;
while checks.iter().any(|c| c.status != "completed")
&& start.elapsed().as_secs() < wait_secs
{
std::thread::sleep(std::time::Duration::from_secs(poll_interval_secs));
checks = actions::list_check_runs(&client, &repo, sha).map_err(to_mcp_error)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
let mut summary = mcp::checks_wait_summary(&checks, start.elapsed().as_secs());
summary["n"] = serde_json::json!(n);
summary.to_string()
}
"pr_merge" => {
let n = req
.number
Expand Down
12 changes: 10 additions & 2 deletions src/mcp_server/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,14 +457,14 @@ pub(crate) struct FlareDocsRequest {
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub(crate) struct GitHubRequest {
#[schemars(
description = "Action: pr_create|pr_list|pr_get|pr_status|pr_merge|pr_comment|pr_request_review|issue_create|issue_list|issue_get|issue_comment|issue_close|issue_label|release_list|release_get|release_latest|release_create|run_list|run_get|run_rerun|workflow_dispatch"
description = "Action: pr_create|pr_list|pr_get|pr_status|pr_wait|pr_merge|pr_comment|pr_request_review|issue_create|issue_list|issue_get|issue_comment|issue_close|issue_label|release_list|release_get|release_latest|release_create|run_list|run_get|run_rerun|workflow_dispatch"
)]
pub(crate) action: String,
#[schemars(description = "owner/repo (default: resolved from the current repo's origin)")]
#[serde(default)]
pub(crate) repo: Option<String>,
#[schemars(
description = "PR number (pr_get, pr_status, pr_merge, pr_comment, pr_request_review)"
description = "PR number (pr_get, pr_status, pr_wait, pr_merge, pr_comment, pr_request_review)"
)]
#[serde(default)]
pub(crate) number: Option<u64>,
Expand Down Expand Up @@ -532,6 +532,14 @@ pub(crate) struct GitHubRequest {
)]
#[serde(default)]
pub(crate) since: Option<String>,
#[schemars(
description = "pr_wait: max seconds to block polling checks before returning (default 60, capped at 120) — if still pending, call pr_wait again"
)]
#[serde(default)]
pub(crate) wait_secs: Option<u64>,
#[schemars(description = "pr_wait: seconds between check polls (default 10, min 3)")]
#[serde(default)]
pub(crate) poll_interval_secs: Option<u64>,
}

/// All local artifact backends (flared, another session, or our own
Expand Down
Loading