Files
ironclaw/tests/e2e_live_code_review.rs
firat.sertgoz 27d53f5153 docs(skills): code-review v2 + GitHub endpoint fixes + minor text updates (#2528)
* feat(skills): paranoid-architect code-review skill v2

Rewrite the code-review skill from a 6-bullet checklist into a
paranoid-architect workflow that handles both local diffs and GitHub
PRs end-to-end:

- Two input shapes: local `git diff` or `owner/repo N` /
  `github.com/.../pull/N` URLs.
- Step 1 wraps GitHub fetches in `async def` + `FINAL(await ...)` to
  avoid the closure-capture quirk that kept tripping LLMs (see the
  paired codeact preamble update); reads metadata, diff, and files
  via three sequential awaits instead of `asyncio.gather`.
- Step 2 reads each changed file in full (raw media type, no base64
  module needed) so reviews account for surrounding context.
- Step 3 runs the change through six lenses: correctness, edge cases,
  security (with a real adversarial checklist), test coverage, docs,
  architecture.
- Step 4 renders findings as a severity table and asks which to post.
- Step 5 posts line-level comments via the PR comments endpoint with
  the captured head SHA, falling back to issue comments for
  multi-file findings.

Bumps `requires.skills` to include `github` so the activation pulls
in the GitHub API recipes via the chain-loader.

Adds a live e2e test (`e2e_live_code_review.rs`) plus a recorded
trace fixture (PR #2483) so the workflow is replayable without
hitting GitHub.

* docs(github): clarify search endpoints, response envelope, @me queries

LLMs kept inventing a `search_issues` action and looping over
`/repos/{owner}/{repo}/pulls` for "my PRs" queries. Clarify the
GitHub tool surface in three places:

- `tools-src/github/src/lib.rs` and `registry/tools/github.json`:
  enumerate the three real search actions and call out that
  `search_issues_pull_requests` covers both. Add the canonical
  `is:pr author:@me sort:updated-desc` recipe for cross-repo "my PRs".

- `skills/github/SKILL.md`: add an "Authenticated User & Cross-Repo
  Queries" section with copy-paste recipes for `@me`, the search
  endpoints with proper URL encoding, and the response-envelope
  contract (`body` is parsed JSON for application/json, raw `str` for
  diff endpoints — never call `json.loads()` on it, never write
  `.get("body", body)` as a fallback).

* fix: resolve CI failures — clippy useless_conversion + missing test harness methods

- Remove `.into_iter()` on `details` in catalog.rs (clippy::useless_conversion)
- Add `with_skills_dir` to `LiveTestHarnessBuilder` for e2e_live_code_review test
- Add `active_skill_names` to `TestRig` extracting from SkillActivated status events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): address zmanian + gemini review — URL encoding, multi-line comments, description trimming (#2528)

- URL-encode file paths in GitHub API content URLs
- Add start_line/start_side to multi-line comment example
- Add 'locally' keyword override for mode detection
- Trim overly long schema descriptions
- Remove duplicated /search/issues note from Common Mistakes
- Fetch PR title from trace fixture instead of hard-coding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): propagate skills_dir into TestRig config (#2528)

LiveTestHarnessBuilder::with_skills_dir() stored a PathBuf but only
used it as an is_some() flag — the actual SkillRegistry always pointed
at an empty temp directory. Now the stored path flows through
TestRigBuilder::with_skills_dir() into config.skills.local_dir and
the SkillRegistry constructor.

Also generalizes the hardcoded nearai/ironclaw repo name in the
github skill's response-handling example to {owner}/{repo}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:11:59 +09:00

350 lines
15 KiB
Rust

//! Live/replay test for `/code-review owner/repo N`.
//!
//! Drives the `code-review` skill against a real (or replayed) pull
//! request on `nearai/ironclaw` and verifies:
//!
//! 1. The `code-review` skill actually activated from the `/code-review`
//! slash mention.
//! 2. The agent fetched the *correct* PR via the GitHub API (the URL
//! contains `/repos/nearai/ironclaw/pulls/2483`, not some other PR).
//! 3. The response text references the PR the user asked about, so a
//! silent-substitution regression (agent reviews the wrong PR but
//! confidently answers) is caught.
//!
//! # Running
//!
//! **Replay mode** (default, deterministic, needs committed trace fixture):
//! ```bash
//! cargo test --features libsql --test e2e_live_code_review -- --ignored
//! ```
//!
//! **Live mode** (real LLM + real GitHub API, records/updates fixture):
//! ```bash
//! IRONCLAW_LIVE_TEST=1 cargo test --features libsql \
//! --test e2e_live_code_review -- --ignored --test-threads=1 --nocapture
//! ```
//!
//! Live mode requires a `github_token` secret in the developer's
//! `~/.ironclaw/ironclaw.db` (read scope is enough; the PR is public).
//! Replay mode does not need any credentials — the trace fixture carries
//! the LLM side and the harness stubs HTTP interactions recorded in the
//! fixture.
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod code_review_test {
use std::path::PathBuf;
use std::time::Duration;
use crate::support::live_harness::{LiveTestHarness, LiveTestHarnessBuilder};
use ironclaw::channels::StatusUpdate;
const TEST_NAME: &str = "code_review_pr_2483";
const REPO_OWNER: &str = "nearai";
const REPO_NAME: &str = "ironclaw";
const PR_NUMBER: u64 = 2483;
fn repo_skills_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("skills")
}
fn trace_fixture_path(test_name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("llm_traces")
.join("live")
.join(format!("{test_name}.json"))
}
/// Extract the PR title from the trace fixture's HTTP exchanges.
///
/// Finds the first exchange whose URL contains `pulls/{PR_NUMBER}` and
/// whose response body parses as JSON with a `"title"` field (i.e. the
/// PR metadata request, not the diff). This keeps the expected title in
/// sync with the recorded fixture so drift is caught by the replay
/// machinery rather than a stale hard-coded constant.
fn pr_title_from_fixture(test_name: &str) -> Option<String> {
let path = trace_fixture_path(test_name);
let data = std::fs::read_to_string(&path).ok()?;
let trace: serde_json::Value = serde_json::from_str(&data).ok()?;
let expected_url_fragment = format!("pulls/{PR_NUMBER}");
for exchange in trace.get("http_exchanges")?.as_array()? {
let url = exchange
.pointer("/request/url")
.and_then(|v| v.as_str())
.unwrap_or("");
if !url.contains(&expected_url_fragment) {
continue;
}
let body_str = exchange
.pointer("/response/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Ok(body) = serde_json::from_str::<serde_json::Value>(body_str)
&& let Some(title) = body.get("title").and_then(|v| v.as_str())
{
return Some(title.to_string());
}
}
None
}
/// Mirror of the pattern in `e2e_github_dev_workflow`: skip in replay
/// mode unless the fixture is already committed. In live mode we
/// always run (and the fixture gets recorded).
fn should_run_test(test_name: &str) -> bool {
if trace_fixture_path(test_name).exists()
|| std::env::var("IRONCLAW_LIVE_TEST")
.ok()
.filter(|v| !v.is_empty() && v != "0")
.is_some()
{
true
} else {
eprintln!(
"[{}] replay fixture missing at {}; skipping until recorded in live mode",
test_name,
trace_fixture_path(test_name).display()
);
false
}
}
async fn build_harness(test_name: &str) -> LiveTestHarness {
LiveTestHarnessBuilder::new(test_name)
.with_engine_v2(true)
.with_auto_approve_tools(true)
// Fetching the PR, parsing metadata, then fetching the diff
// is at most a handful of tool calls, but the LLM may branch
// on large diffs — give it enough headroom to finish.
.with_max_tool_iterations(30)
.with_skills_dir(repo_skills_dir())
// The agent hits api.github.com. In live mode we need the
// real token so the request is authenticated (avoids the
// 60/hr unauthenticated rate limit). In replay mode the
// token is unused — the trace fixture carries the response.
.with_secrets(["github_token"])
.build()
.await
}
/// Dump activity for a failed run so CI logs show what the agent
/// actually did.
fn dump_activity(harness: &LiveTestHarness, label: &str) {
eprintln!("───── [{label}] activity dump ─────");
eprintln!("active skills: {:?}", harness.rig().active_skill_names());
for event in harness.rig().captured_status_events() {
match event {
StatusUpdate::SkillActivated { skill_names, .. } => {
eprintln!(" ◆ skills activated: {}", skill_names.join(", "));
}
StatusUpdate::ToolStarted { name, detail, .. } => {
eprintln!("{name} {}", detail.unwrap_or_default());
}
StatusUpdate::ToolCompleted {
name,
success,
error,
..
} => {
if success {
eprintln!("{name}");
} else {
eprintln!("{name}: {}", error.unwrap_or_default());
}
}
StatusUpdate::ToolResult { name, preview, .. } => {
let short: String = preview.chars().take(200).collect();
eprintln!(" {name}{short}");
}
_ => {}
}
}
eprintln!("───── end activity ─────");
}
/// End-to-end: `/code-review nearai/ironclaw 2483` must (a) activate
/// the `code-review` skill, (b) hit
/// `api.github.com/repos/nearai/ironclaw/pulls/2483`, and (c)
/// produce a response naming the PR it reviewed.
#[tokio::test]
#[ignore] // Live tier: requires LLM API keys or a recorded trace fixture
async fn code_review_real_pr() {
if !should_run_test(TEST_NAME) {
return;
}
let harness = build_harness(TEST_NAME).await;
let rig = harness.rig();
let user_input = format!("/code-review {REPO_OWNER}/{REPO_NAME} {PR_NUMBER}");
rig.send_message(&user_input).await;
// Reviewing a real PR with diff-fetching + reasoning can take a
// while in live mode — wait up to 5 minutes.
let responses = rig.wait_for_responses(1, Duration::from_secs(300)).await;
let response_text: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
let joined = response_text.join("\n");
// ── Activity-level diagnostics before assertions ──────────────
dump_activity(&harness, "code_review_real_pr");
// Assertion 1: the code-review skill activated.
//
// Without this the test could pass on a generic "here's what I'd
// do" reply that never touched the skill body. `/code-review` is
// an explicit slash mention — selector::extract_skill_mentions
// should force-select it regardless of score.
let active = rig.active_skill_names();
assert!(
active.iter().any(|s| s == "code-review"),
"Expected `code-review` skill to activate from the `/code-review` \
mention. Active skills: {active:?}"
);
// Assertion 2: the agent actually called the `http` tool.
//
// The skill body tells the agent to reach api.github.com. If it
// falls back to shell/git or hallucinates a review from training
// data, this catches it. `tool_calls_started` decorates the name
// with a short summary (e.g. `"http(https://.../pulls/2483)"`),
// so we match on a prefix rather than bare equality.
let tools = rig.tool_calls_started();
assert!(
tools.iter().any(|t| t == "http" || t.starts_with("http(")),
"Expected the `http` tool to be invoked for the GitHub PR fetch. \
Tools used: {tools:?}"
);
// Assertion 3: the request targeted *this* PR, not a different one.
//
// We inspect both ToolStarted.detail (which the http tool populates
// with the URL summary) and ToolResult.preview (which echoes the
// PR metadata). The ToolStarted.name also embeds the URL as
// `http(<url>)` in this harness, so we check that too. Any of
// the three surfaces containing `pulls/2483` proves the request
// went to the correct endpoint. Checking only the response text
// is not enough — the LLM could repeat the number from the prompt
// without ever fetching the right PR.
let expected_path = format!("pulls/{PR_NUMBER}");
let pr_endpoint_hit = rig
.captured_status_events()
.iter()
.any(|event| match event {
StatusUpdate::ToolStarted { name, detail, .. } => {
let name_hit = name.contains(&expected_path);
let detail_hit = detail
.as_deref()
.map(|d| d.contains(&expected_path))
.unwrap_or(false);
(name.starts_with("http") || name == "http") && (name_hit || detail_hit)
}
StatusUpdate::ToolResult {
name: _, preview, ..
} => preview.contains(&expected_path),
_ => false,
});
assert!(
pr_endpoint_hit,
"Expected at least one http call or result referencing `{expected_path}`. \
The agent invoked http() but did not appear to target PR #{PR_NUMBER}. \
Full response preview: {}",
joined.chars().take(400).collect::<String>()
);
// Assertion 4: the response names the PR it reviewed.
//
// Catches the "silent substitution" regression — agent fetches
// the right PR but writes about a different one, or answers
// generically without naming the PR at all.
let lower = joined.to_lowercase();
let names_pr =
lower.contains(&format!("#{PR_NUMBER}")) || lower.contains(&PR_NUMBER.to_string());
assert!(
names_pr,
"Response should reference PR #{PR_NUMBER}; got: {}",
joined.chars().take(400).collect::<String>()
);
let names_repo = lower.contains("nearai/ironclaw") || lower.contains("ironclaw");
assert!(
names_repo,
"Response should name the repo that was reviewed; got: {}",
joined.chars().take(400).collect::<String>()
);
// Assertion 5: the review must reference the *real* PR content,
// not a blank shell.
//
// The earlier fixture captured a green-ticket "looks good"
// reply where every PR field was "unknown" because the LLM's
// generated code mishandled the `http` envelope shape. Guard
// against that class of silent-empty review by requiring:
// (a) the exact PR title from GitHub (so the agent actually
// extracted `body["title"]` instead of falling back to
// `"(unknown title)"`), and
// (b) at least one concrete `path:line` or fenced-code
// reference — a review with zero specifics is not a
// review.
//
// Extract the expected PR title from the trace fixture so that
// re-recording the fixture automatically updates the expectation.
// Falls back to a hard-coded value if the fixture is missing or
// doesn't contain the PR metadata exchange (e.g. live mode before
// the fixture is committed).
let pr_title = pr_title_from_fixture(TEST_NAME).unwrap_or_else(|| {
"feat(engine): add code execution failure categorization instrumentation".to_string()
});
assert!(
joined.contains(&pr_title),
"Response should include the real PR title \"{pr_title}\"\
absence usually means the agent never parsed the JSON body. \
Got: {}",
joined.chars().take(600).collect::<String>()
);
// At least one concrete file reference. The pattern is loose
// on purpose: any of these signals a grounded review:
// - `path/to/file.rs` inside backticks
// - `path/to/file.rs:42` line reference
// - a fenced code block with diff content
let has_concrete_reference = joined.contains("```")
|| joined.contains(".rs:")
|| joined.contains(".py:")
|| joined.contains(".ts:")
|| joined.contains(".md:")
|| joined.contains("crates/")
|| joined.contains("src/");
assert!(
has_concrete_reference,
"Response should cite at least one concrete file or code reference. \
A review without specifics is not a review. Got: {}",
joined.chars().take(600).collect::<String>()
);
// Assertion 6: the sidebar / header must not say every field
// is "unknown". This is the exact failure mode the earlier
// fixture recorded, and it is the strongest indicator that
// the `http` envelope handling is broken again.
let unknown_markers = [
"(unknown title)",
"state: `unknown`",
"base ← head: `unknown`",
"files changed (reported): `0`",
];
for marker in unknown_markers {
assert!(
!lower.contains(&marker.to_lowercase()),
"Response contains the blank-shell marker {marker:?} — the \
agent's `http` response handling produced an empty PR \
snapshot. Got: {}",
joined.chars().take(600).collect::<String>()
);
}
harness.finish(&user_input, &response_text).await;
}
}