ref:c62e1fbf1db44177c5915d3882f7b851d8fc854a

feat: add .anvil.yml CI pipeline and apply rustfmt

Pipeline steps: check → test, clippy, fmt → build (release) All steps use Cargo.lock-keyed caching for target/ directory. Build step produces anvil binary as artifact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: c62e1fbf1db44177c5915d3882f7b851d8fc854a
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-11 02:13
Parents: 83913e0
12 files changed +131 -102
Type
.anvil.yml +44 −0
@@ -1,0 +1,44 @@
image: rust:1.86-bookworm
steps:
- name: check
run: cargo check --all-targets 2>&1
cache:
key: Cargo.lock
paths:
- target/
- /usr/local/cargo/registry/
- name: test
run: cargo test 2>&1
depends_on: [check]
cache:
key: Cargo.lock
paths:
- target/
- name: clippy
run: |
rustup component add clippy
cargo clippy --all-targets -- -D warnings 2>&1
depends_on: [check]
cache:
key: Cargo.lock
paths:
- target/
- name: fmt
run: |
rustup component add rustfmt
cargo fmt -- --check 2>&1
- name: build
run: cargo build --release 2>&1
depends_on: [test, clippy, fmt]
cache:
key: Cargo.lock
paths:
- target/
artifacts:
- name: anvil-binary
path: target/release/anvil
src/client.rs +1 −6
@@ -60,11 +60,6 @@
path: &str,
query: &[(&str, &str)],
) -> Result<T, ApiError> {
let resp = self
.http
.get(self.url(path))
.query(query)
.send()
.await?;
let resp = self.http.get(self.url(path)).query(query).send().await?;
self.handle_response(resp).await
}
src/commands/agent.rs +15 −15
@@ -87,9 +87,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/agents"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/agents")).await?;
let agents: Vec<serde_json::Value> = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone()).unwrap_or_default()
@@ -136,7 +134,10 @@
output::header(&format!(
"Agent: {}",
agent
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(agent_name)
agent.get("name").and_then(|v| v.as_str()).unwrap_or(agent_name)
));
if let Some(desc) = agent.get("description").and_then(|v| v.as_str()) {
@@ -207,11 +208,7 @@
.chars()
.take(8)
.collect(),
output::colorize_status(
s.get("status")
output::colorize_status(s.get("status").and_then(|v| v.as_str()).unwrap_or("?")),
.and_then(|v| v.as_str())
.unwrap_or("?"),
),
s.get("inserted_at")
.and_then(|v| v.as_str())
.map(output::format_time)
@@ -249,10 +246,7 @@
output::header("Messages");
for msg in messages {
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("?");
let content = msg
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("");
let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
println!(" [{role}] {content}");
}
}
@@ -268,7 +262,10 @@
.post_empty(&format!("/{org}/{name}/agents/sessions/{id}/approve"))
.await?;
output::success(&format!(
"Approved action for session {}",
&id[..8.min(id.len())]
output::success(&format!("Approved action for session {}", &id[..8.min(id.len())]));
));
Ok(())
}
@@ -281,7 +278,10 @@
.post_empty(&format!("/{org}/{name}/agents/sessions/{id}/reject"))
.await?;
output::success(&format!(
"Rejected action for session {}",
output::success(&format!("Rejected action for session {}", &id[..8.min(id.len())]));
&id[..8.min(id.len())]
));
Ok(())
}
src/commands/auth.rs +11 −12
@@ -33,24 +33,23 @@
}
}
async fn login(url: Option<String>, token: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
async fn login(
url: Option<String>,
token: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = match url {
Some(u) => u,
None => {
dialoguer::Input::<String>::new()
.with_prompt("Anvil server URL")
None => dialoguer::Input::<String>::new()
.with_prompt("Anvil server URL")
.default("https://anvil.fangorn.io".into())
.interact_text()?,
.default("https://anvil.fangorn.io".into())
.interact_text()?
}
};
let token = match token {
Some(t) => t,
None => dialoguer::Password::new()
.with_prompt("Personal access token")
.interact()?,
None => {
dialoguer::Password::new()
.with_prompt("Personal access token")
.interact()?
}
};
// Validate by hitting the health endpoint
src/commands/branch.rs +2 −7
@@ -35,9 +35,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/branches"))
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/branches")).await?;
.await?;
let branches: Vec<Branch> = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone()).unwrap_or_default()
@@ -57,9 +55,6 @@
.chars()
.take(8)
.collect::<String>();
vec![b.name.clone().unwrap_or_default(), sha]
vec![
b.name.clone().unwrap_or_default(),
sha,
]
})
.collect();
src/commands/ci.rs +25 −10
@@ -63,8 +63,14 @@
pub async fn run(args: CiArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
CiCommand::List {
repo,
status,
limit,
} => list(repo.as_deref(), status.as_deref(), limit).await,
CiCommand::List { repo, status, limit } => list(repo.as_deref(), status.as_deref(), limit).await,
CiCommand::View { id } => view(&id).await,
CiCommand::Run { repo, sha, branch } => trigger(repo.as_deref(), sha.as_deref(), branch.as_deref()).await,
CiCommand::Run { repo, sha, branch } => {
trigger(repo.as_deref(), sha.as_deref(), branch.as_deref()).await
}
}
}
@@ -125,7 +131,10 @@
})
.collect();
output::print_table(
&["ID", "STATUS", "BRANCH", "SHA", "DURATION", "STARTED"],
&rows,
);
output::print_table(&["ID", "STATUS", "BRANCH", "SHA", "DURATION", "STARTED"], &rows);
Ok(())
}
@@ -141,10 +150,7 @@
serde_json::from_value(resp)?
};
output::header(&format!(
"CI Run {}",
output::header(&format!("CI Run {}", run.id.as_deref().unwrap_or(id)));
run.id.as_deref().unwrap_or(id)
));
output::detail(
"Status",
@@ -205,7 +211,10 @@
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
};
body.insert(
"branch".into(),
serde_json::Value::String(branch_name.clone()),
body.insert("branch".into(), serde_json::Value::String(branch_name.clone()));
);
// Get SHA from arg or current HEAD
let commit_sha = match sha {
@@ -217,7 +226,10 @@
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
};
body.insert(
"commit_sha".into(),
body.insert("commit_sha".into(), serde_json::Value::String(commit_sha.clone()));
serde_json::Value::String(commit_sha.clone()),
);
let resp: serde_json::Value = client
.post(
@@ -232,6 +244,9 @@
.and_then(|v| v.as_str())
.unwrap_or("?");
output::success(&format!("Triggered CI run {}", &run_id[..8.min(run_id.len())]));
output::success(&format!(
"Triggered CI run {}",
&run_id[..8.min(run_id.len())]
));
output::detail("Branch", &branch_name);
output::detail("Commit", &commit_sha[..8.min(commit_sha.len())]);
src/commands/deploy.rs +8 −17
@@ -85,9 +85,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/deployments"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/deployments")).await?;
let deployments: Vec<serde_json::Value> = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone()).unwrap_or_default()
@@ -105,11 +103,7 @@
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
output::colorize_status(
d.get("status")
output::colorize_status(d.get("status").and_then(|v| v.as_str()).unwrap_or("?")),
.and_then(|v| v.as_str())
.unwrap_or("?"),
),
d.get("ref")
.and_then(|v| v.as_str())
.unwrap_or("")
@@ -171,9 +165,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/environments"))
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/environments")).await?;
.await?;
let envs: Vec<serde_json::Value> = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone()).unwrap_or_default()
@@ -186,11 +178,10 @@
let rows: Vec<Vec<String>> = envs
.iter()
.map(|e| {
vec![
e.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
]
vec![e
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string()]
})
.collect();
src/commands/issue.rs +1 −4
@@ -95,9 +95,6 @@
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/issues"),
&[
("state", state),
("per_page", &limit.to_string()),
],
&[("state", state), ("per_page", &limit.to_string())],
)
.await?;
src/commands/pr.rs +2 −7
@@ -100,9 +100,6 @@
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/pulls"),
&[
("state", state),
("per_page", &limit.to_string()),
],
&[("state", state), ("per_page", &limit.to_string())],
)
.await?;
@@ -144,9 +141,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/pulls/{number}"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/pulls/{number}")).await?;
let pr: PullRequest = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone())?
src/commands/release.rs +13 −7
@@ -83,7 +83,17 @@
body,
prerelease,
draft,
} => create(repo.as_deref(), &tag, title.as_deref(), &body, prerelease, draft).await,
} => {
create(
repo.as_deref(),
&tag,
title.as_deref(),
&body,
prerelease,
draft,
)
.await
}
ReleaseCommand::Delete { tag, repo } => delete(repo.as_deref(), &tag).await,
}
}
@@ -92,9 +102,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/releases")).await?;
let releases: Vec<Release> = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone()).unwrap_or_default()
@@ -137,9 +145,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/releases/{tag}")).await?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases/{tag}"))
.await?;
let rel: Release = if let Some(data) = resp.get("data") {
serde_json::from_value(data.clone())?
src/commands/runner.rs +4 −16
@@ -63,10 +63,7 @@
}
}
async fn list(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
async fn list(org: Option<&str>, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -99,11 +96,7 @@
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
output::colorize_status(
r.get("status")
.and_then(|v| v.as_str())
.unwrap_or("?"),
),
output::colorize_status(r.get("status").and_then(|v| v.as_str()).unwrap_or("?")),
r.get("labels")
.and_then(|v| v.as_str())
.unwrap_or("")
@@ -193,10 +186,7 @@
Ok(())
}
async fn token(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
async fn token(org: Option<&str>, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -205,9 +195,7 @@
_ => return Err("specify --org or --repo".into()),
};
let resp: serde_json::Value = client.post(&path, &serde_json::json!({})).await?;
let resp: serde_json::Value = client
.post(&path, &serde_json::json!({}))
.await?;
let token = resp
.pointer("/data/token")
src/config.rs +5 −1
@@ -116,7 +116,11 @@
.ok()
.and_then(|u| {
let p = u.path().trim_start_matches('/').to_string();
if p.is_empty() { None } else { Some(p) }
if p.is_empty() {
None
} else {
Some(p)
}
})
.ok_or_else(|| {
ConfigError::Io(std::io::Error::new(