ref:98c330db77d3ea0c53c6ec2c487a5fb9e6ffe366

feat(ci): publish cross-compiled runner binaries as release assets (#1)

## Summary - Adds `publish-runner` CI step that builds the CLI for linux_arm64 (native on Pi) and linux_amd64 (cross-compiled), then uploads both as release assets under a `latest` tag - Fixes the broken `/runner/download` endpoint — currently returns `{"platforms":[]}` because the old Burrito/Elixir build was silently failing ## Prerequisites (one-time on Pi runner) ```bash rustup target add x86_64-unknown-linux-gnu sudo apt-get install -y gcc-x86-64-linux-gnu ``` ## Test plan - [ ] Install cross-compilation toolchain on Pi runner - [ ] Merge and verify CI `publish-runner` step succeeds - [ ] Verify `anvil release assets latest fangorn/anvil-cli` shows both binaries - [ ] Server-side consumption of these assets is a separate task Closes #1
SHA: 98c330db77d3ea0c53c6ec2c487a5fb9e6ffe366
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-10 04:25
Parents: 9b0ea17
10 files changed +330 -160
Type
.anvil.yml +33 −1
@@ -18,8 +18,9 @@
- target/
- name: coverage
timeout_seconds: 1800
run: |
cargo install cargo-tarpaulin --version 0.32.7 2>&1
cargo install cargo-tarpaulin 2>&1
cargo tarpaulin --out lcov --output-dir coverage/ 2>&1
depends_on: [check]
cache:
@@ -55,3 +56,34 @@
artifacts:
- name: anvil-binary
path: target/release/anvil
- name: build-runner
timeout_seconds: 1800
run: |
set -e
apt-get update && apt-get install -y gcc-x86-64-linux-gnu 2>&1
# Build arm64 natively (CI runner is aarch64)
echo "==> Building arm64 binary (native)..."
cargo build --release 2>&1
# Cross-compile amd64
echo "==> Cross-compiling amd64 binary..."
rustup target add x86_64-unknown-linux-gnu 2>&1
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
cargo build --release --target x86_64-unknown-linux-gnu 2>&1
# Stage for artifact upload
mkdir -p runner-dist
cp target/release/anvil runner-dist/anvil_runner_linux_arm64
cp target/x86_64-unknown-linux-gnu/release/anvil runner-dist/anvil_runner_linux_amd64
depends_on: [test, clippy, fmt]
cache:
key: Cargo.lock
paths:
- target/
artifacts:
- name: anvil_runner_linux_arm64
path: runner-dist/anvil_runner_linux_arm64
- name: anvil_runner_linux_amd64
path: runner-dist/anvil_runner_linux_amd64
src/client.rs +13 −14
@@ -188,10 +188,12 @@
.to_string_lossy()
.to_string();
let file_bytes = tokio::fs::read(file_path).await.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to read file: {e}"),
})?;
let file_bytes = tokio::fs::read(file_path)
.await
.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to read file: {e}"),
})?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(file_name)
@@ -205,12 +207,7 @@
// Build request without default Content-Type header (multipart sets its own)
let url = self.url(path);
let resp = self.http.post(&url).multipart(form).send().await?;
let resp = self
.http
.post(&url)
.multipart(form)
.send()
.await?;
let status = resp.status();
if status.is_success() {
@@ -241,9 +238,11 @@
}
let bytes = resp.bytes().await?;
tokio::fs::write(output, &bytes)
.await
.map_err(|e| ApiError::Api {
status: 0,
tokio::fs::write(output, &bytes).await.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to write file: {e}"),
})?;
message: format!("Failed to write file: {e}"),
})?;
Ok(())
}
src/commands/board.rs +1 −4
@@ -64,10 +64,7 @@
if let Some(issue_list) = col.get("issues").and_then(|v| v.as_array()) {
for issue in issue_list {
let num = issue
.get("number")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let num = issue.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
let title = issue.get("title").and_then(|v| v.as_str()).unwrap_or("?");
output::detail(&format!("#{}", num), title);
}
src/commands/ci.rs +28 −48
@@ -149,8 +149,6 @@
env,
repo,
} => set_secret(repo.as_deref(), &name, &value, env.as_deref()).await,
CiCommand::DeleteSecret { name, repo } => {
delete_secret(repo.as_deref(), &name).await
CiCommand::DeleteSecret { name, repo } => delete_secret(repo.as_deref(), &name).await,
}
}
}
@@ -228,9 +226,7 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/ci/runs/{id}"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/runs/{id}")).await?;
let run: CiRun = if let Some(obj) = resp.get("ci_run") {
serde_json::from_value(obj.clone())?
@@ -310,9 +306,7 @@
let (org, name) = config::resolve_repo(repo)?;
// Fetch job metadata
let resp: serde_json::Value = client
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/jobs/{id}")).await?;
.get(&format!("/{org}/{name}/ci/jobs/{id}"))
.await?;
let job: CiJob = if let Some(obj) = resp.get("job") {
serde_json::from_value(obj.clone())?
@@ -342,7 +336,9 @@
output::detail("Duration", &format!("{:.1}s", d));
}
if let Some(ref runner) = job.runner {
let name = runner
.get("name")
.and_then(|v| v.as_str())
let name = runner.get("name").and_then(|v| v.as_str())
.or_else(|| runner.as_str())
.unwrap_or("?");
output::detail("Runner", name);
@@ -368,13 +364,10 @@
Ok(())
}
async fn stream_job_logs(
client: &Client,
path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
async fn stream_job_logs(client: &Client, path: &str) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.get_sse_stream(path).await?;
let stream = resp.bytes_stream().map(|r| {
r.map_err(std::io::Error::other)
});
let stream = resp
.bytes_stream()
.map(|r| r.map_err(std::io::Error::other));
let stream_reader = tokio_util::io::StreamReader::new(stream);
let mut reader = tokio::io::BufReader::new(stream_reader);
@@ -411,10 +404,7 @@
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let status = parsed["status"].as_str().unwrap_or("?");
let exit_code = parsed["exit_code"].as_i64().unwrap_or(-1);
eprintln!(
eprintln!("\n--- Job {} (exit code: {}) ---", status, exit_code);
"\n--- Job {} (exit code: {}) ---",
status, exit_code
);
}
break;
}
@@ -428,9 +418,6 @@
Ok(())
}
async fn cancel(
async fn cancel(id: &str, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
id: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -446,10 +433,7 @@
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("cancelled");
let short_id = resp
.get("short_id")
.and_then(|v| v.as_str())
let short_id = resp.get("short_id").and_then(|v| v.as_str()).unwrap_or(id);
.unwrap_or(id);
output::success(&format!("Cancelled CI run {short_id} (status: {status})"));
Ok(())
@@ -480,20 +464,19 @@
serde_json::Value::String(branch_name.clone()),
);
// Get SHA from arg or current HEAD
let commit_sha = match sha {
Some(s) => s.to_string(),
None => {
let output = std::process::Command::new("git")
// Only send commit_sha if explicitly provided via --sha.
.args(["rev-parse", "HEAD"])
.output()?;
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
};
body.insert(
"commit_sha".into(),
serde_json::Value::String(commit_sha.clone()),
);
// Otherwise let the server resolve it from the branch, ensuring
// .anvil.yml is read from the correct branch on the server.
let display_sha;
if let Some(s) = sha {
display_sha = s.to_string();
body.insert(
"commit_sha".into(),
serde_json::Value::String(display_sha.clone()),
);
} else {
display_sha = "(server-resolved)".to_string();
}
let resp: serde_json::Value = client
.post(
@@ -511,12 +494,9 @@
.and_then(|v| v.as_str())
.unwrap_or("?");
output::success(&format!("Triggered CI run {}", run_id));
output::success(&format!(
"Triggered CI run {}",
run_id
));
output::detail("Branch", &branch_name);
output::detail("Commit", &commit_sha[..8.min(commit_sha.len())]);
output::detail("Commit", &display_sha[..8.min(display_sha.len())]);
Ok(())
}
src/commands/issue.rs +115 −31
@@ -164,11 +164,7 @@
match args.command {
IssueCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
IssueCommand::View { number, repo } => view(repo.as_deref(), number).await,
IssueCommand::Create {
repo,
title,
body,
} => create(repo.as_deref(), &title, &body).await,
IssueCommand::Create { repo, title, body } => create(repo.as_deref(), &title, &body).await,
IssueCommand::Close { number, repo } => close_issue(repo.as_deref(), number).await,
IssueCommand::Reopen { number, repo } => reopen_issue(repo.as_deref(), number).await,
IssueCommand::Edit {
@@ -177,11 +173,34 @@
title,
body,
} => edit_issue(repo.as_deref(), number, title.as_deref(), body.as_deref()).await,
IssueCommand::Comment { number, body, repo } => {
add_comment(repo.as_deref(), number, &body).await
IssueCommand::Comment { number, body, repo } => add_comment(repo.as_deref(), number, &body).await,
}
IssueCommand::Comments { number, repo } => list_comments(repo.as_deref(), number).await,
IssueCommand::Milestones { repo } => list_milestones(repo.as_deref()).await,
IssueCommand::CreateMilestone {
title,
description,
due_date,
repo,
} => {
create_milestone(
repo.as_deref(),
&title,
IssueCommand::CreateMilestone { title, description, due_date, repo } => create_milestone(repo.as_deref(), &title, description.as_deref(), due_date.as_deref()).await,
IssueCommand::Assign { number, user_id, repo } => assign(repo.as_deref(), number, &user_id).await,
IssueCommand::Unassign { number, user_id, repo } => unassign(repo.as_deref(), number, &user_id).await,
description.as_deref(),
due_date.as_deref(),
)
.await
}
IssueCommand::Assign {
number,
user_id,
repo,
} => assign(repo.as_deref(), number, &user_id).await,
IssueCommand::Unassign {
number,
user_id,
repo,
} => unassign(repo.as_deref(), number, &user_id).await,
}
}
@@ -389,11 +408,18 @@
Ok(())
}
async fn add_comment(
repo: Option<&str>,
number: u32,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
async fn add_comment(repo: Option<&str>, number: u32, body: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/issues/{number}/comments"), &serde_json::json!({"body": body}))
.post(
&format!("/{org}/{name}/issues/{number}/comments"),
&serde_json::json!({"body": body}),
)
.await?;
output::success(&format!("Added comment to issue #{number}"));
Ok(())
@@ -419,8 +445,15 @@
}
for comment in &comments {
let author = comment
.pointer("/author/email")
.and_then(|v| v.as_str())
let author = comment.pointer("/author/email").and_then(|v| v.as_str()).unwrap_or("unknown");
let time = comment.get("inserted_at").and_then(|v| v.as_str()).map(output::format_time).unwrap_or_default();
.unwrap_or("unknown");
let time = comment
.get("inserted_at")
.and_then(|v| v.as_str())
.map(output::format_time)
.unwrap_or_default();
let body = comment.get("body").and_then(|v| v.as_str()).unwrap_or("");
println!("\n {} — {}", author, time);
println!(" {body}");
@@ -432,46 +465,97 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/milestones")).await?;
let milestones: Vec<serde_json::Value> = resp
let milestones: Vec<serde_json::Value> = resp.get("milestones").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
.get("milestones")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Milestones ({org}/{name})"));
let rows: Vec<Vec<String>> = milestones.iter().map(|m| {
vec![
m.get("title").and_then(|v| v.as_str()).unwrap_or("?").to_string(),
m.get("state").and_then(|v| v.as_str()).map(output::colorize_status).unwrap_or_default(),
format!("{}/{}", m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0), m.get("open_issues").and_then(|v| v.as_u64()).unwrap_or(0) + m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0)),
format!("{}%", m.get("progress_percent").and_then(|v| v.as_f64()).unwrap_or(0.0) as u32),
m.get("due_date").and_then(|v| v.as_str()).unwrap_or("none").to_string(),
]
}).collect();
let rows: Vec<Vec<String>> = milestones
.iter()
.map(|m| {
vec![
m.get("title")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
m.get("state")
.and_then(|v| v.as_str())
.map(output::colorize_status)
.unwrap_or_default(),
format!(
"{}/{}",
m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0),
m.get("open_issues").and_then(|v| v.as_u64()).unwrap_or(0)
+ m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0)
),
format!(
"{}%",
m.get("progress_percent")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as u32
),
m.get("due_date")
.and_then(|v| v.as_str())
.unwrap_or("none")
.to_string(),
]
})
.collect();
output::print_table(&["TITLE", "STATE", "PROGRESS", "%", "DUE"], &rows);
Ok(())
}
async fn create_milestone(
repo: Option<&str>,
title: &str,
description: Option<&str>,
due_date: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
async fn create_milestone(repo: Option<&str>, title: &str, description: Option<&str>, due_date: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"title": title});
if let Some(d) = description {
payload["description"] = serde_json::json!(d);
}
if let Some(dd) = due_date {
if let Some(d) = description { payload["description"] = serde_json::json!(d); }
if let Some(dd) = due_date { payload["due_date"] = serde_json::json!(dd); }
let _resp: serde_json::Value = client.post(&format!("/{org}/{name}/milestones"), &payload).await?;
payload["due_date"] = serde_json::json!(dd);
}
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/milestones"), &payload)
.await?;
output::success(&format!("Created milestone '{title}'"));
Ok(())
}
async fn assign(
async fn assign(repo: Option<&str>, number: u32, user_id: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
user_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{number}/assignees"),
&serde_json::json!({"user_id": user_id}),
)
.post(&format!("/{org}/{name}/issues/{number}/assignees"), &serde_json::json!({"user_id": user_id}))
.await?;
output::success(&format!("Assigned user to issue #{number}"));
Ok(())
}
async fn unassign(repo: Option<&str>, number: u32, user_id: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn unassign(
repo: Option<&str>,
number: u32,
user_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
client.delete_empty(&format!("/{org}/{name}/issues/{number}/assignees/{user_id}")).await?;
"/{org}/{name}/issues/{number}/assignees/{user_id}"
))
.await?;
output::success(&format!("Unassigned user from issue #{number}"));
Ok(())
}
src/commands/label.rs +54 −17
@@ -68,7 +68,12 @@
pub async fn run(args: LabelArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
LabelCommand::List { repo } => list(repo.as_deref()).await,
LabelCommand::Create {
name,
color,
description,
repo,
LabelCommand::Create { name, color, description, repo } => create(repo.as_deref(), &name, &color, description.as_deref()).await,
} => create(repo.as_deref(), &name, &color, description.as_deref()).await,
LabelCommand::Add { issue, name, repo } => add(repo.as_deref(), issue, &name).await,
LabelCommand::Remove { issue, name, repo } => remove(repo.as_deref(), issue, &name).await,
}
@@ -78,45 +83,77 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/labels")).await?;
let labels: Vec<Label> = resp.get("labels").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let labels: Vec<Label> = resp
.get("labels")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Labels ({org}/{name})"));
let rows: Vec<Vec<String>> = labels.iter().map(|l| {
vec![
l.name.clone().unwrap_or_default(),
l.color.clone().unwrap_or_default(),
l.description.clone().unwrap_or_default(),
]
}).collect();
let rows: Vec<Vec<String>> = labels
.iter()
.map(|l| {
vec![
l.name.clone().unwrap_or_default(),
l.color.clone().unwrap_or_default(),
l.description.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["NAME", "COLOR", "DESCRIPTION"], &rows);
Ok(())
}
async fn create(
repo: Option<&str>,
label_name: &str,
async fn create(repo: Option<&str>, label_name: &str, color: &str, description: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
color: &str,
description: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"name": label_name, "color": color});
if let Some(desc) = description {
payload["description"] = serde_json::json!(desc);
}
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/labels"), &payload)
.await?;
let _resp: serde_json::Value = client.post(&format!("/{org}/{name}/labels"), &payload).await?;
output::success(&format!("Created label '{label_name}'"));
Ok(())
}
async fn add(
async fn add(repo: Option<&str>, issue_number: u32, label_name: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
issue_number: u32,
label_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{issue_number}/labels"),
&serde_json::json!({"name": label_name}),
)
.post(&format!("/{org}/{name}/issues/{issue_number}/labels"), &serde_json::json!({"name": label_name}))
.await?;
output::success(&format!(
"Added label '{label_name}' to issue #{issue_number}"
));
output::success(&format!("Added label '{label_name}' to issue #{issue_number}"));
Ok(())
}
async fn remove(
repo: Option<&str>,
issue_number: u32,
label_name: &str,
async fn remove(repo: Option<&str>, issue_number: u32, label_name: &str) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
client.delete_empty(&format!("/{org}/{name}/issues/{issue_number}/labels/{label_name}")).await?;
output::success(&format!("Removed label '{label_name}' from issue #{issue_number}"));
"/{org}/{name}/issues/{issue_number}/labels/{label_name}"
))
.await?;
output::success(&format!(
"Removed label '{label_name}' from issue #{issue_number}"
));
Ok(())
}
src/commands/pr.rs +67 −16
@@ -160,8 +160,19 @@
repo,
strategy,
} => merge(repo.as_deref(), number, &strategy).await,
PrCommand::Review {
number,
action,
body,
repo,
} => submit_review(repo.as_deref(), number, &action, &body).await,
PrCommand::Review { number, action, body, repo } => submit_review(repo.as_deref(), number, &action, &body).await,
PrCommand::Reviews { number, repo } => list_reviews(repo.as_deref(), number).await,
PrCommand::CommentCode {
number,
file,
line,
body,
repo,
} => add_pr_comment(repo.as_deref(), number, &file, line, &body).await,
PrCommand::CommentCode { number, file, line, body, repo } => add_pr_comment(repo.as_deref(), number, &file, line, &body).await,
}
}
@@ -376,11 +387,19 @@
Ok(())
}
async fn submit_review(repo: Option<&str>, number: u32, action: &str, body: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn submit_review(
repo: Option<&str>,
number: u32,
action: &str,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
.post(&format!("/{org}/{name}/pulls/{number}/reviews"), &serde_json::json!({"state": action, "body": body}))
&format!("/{org}/{name}/pulls/{number}/reviews"),
&serde_json::json!({"state": action, "body": body}),
)
.await?;
let action_display = match action {
"approve" | "approved" => "Approved",
@@ -394,26 +413,58 @@
async fn list_reviews(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/pulls/{number}/reviews"))
.await?;
let reviews: Vec<serde_json::Value> = resp
.get("reviews")
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/pulls/{number}/reviews")).await?;
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let reviews: Vec<serde_json::Value> = resp.get("reviews").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
output::header(&format!("Reviews on PR #{number}"));
let rows: Vec<Vec<String>> = reviews
.iter()
.map(|r| {
vec![
r.get("state")
.and_then(|v| v.as_str())
let rows: Vec<Vec<String>> = reviews.iter().map(|r| {
.map(output::colorize_status)
.unwrap_or_default(),
r.pointer("/author/email")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
r.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string(),
r.get("inserted_at")
.and_then(|v| v.as_str())
.map(output::format_time)
.unwrap_or_default(),
]
})
.collect();
vec![
r.get("state").and_then(|v| v.as_str()).map(output::colorize_status).unwrap_or_default(),
r.pointer("/author/email").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
r.get("body").and_then(|v| v.as_str()).unwrap_or("").lines().next().unwrap_or("").to_string(),
r.get("inserted_at").and_then(|v| v.as_str()).map(output::format_time).unwrap_or_default(),
]
}).collect();
output::print_table(&["STATE", "AUTHOR", "BODY", "DATE"], &rows);
Ok(())
}
async fn add_pr_comment(
async fn add_pr_comment(repo: Option<&str>, number: u32, file: &str, line: u32, body: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
file: &str,
line: u32,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/pulls/{number}/comments"), &serde_json::json!({"file_path": file, "line_number": line, "body": body}))
.post(
&format!("/{org}/{name}/pulls/{number}/comments"),
&serde_json::json!({"file_path": file, "line_number": line, "body": body}),
)
.await?;
output::success(&format!("Added comment on {file}:{line} in PR #{number}"));
Ok(())
src/commands/release.rs +6 −8
@@ -618,9 +618,7 @@
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
"/{org}/{name}/releases/{tag}/assets/{asset_id}"
))
.delete_empty(&format!("/{org}/{name}/releases/{tag}/assets/{asset_id}"))
.await?;
output::success(&format!("Deleted asset {asset_id} from release {tag}"));
@@ -656,7 +654,10 @@
} else {
// Maybe the whole response is the changelog, or has commits
if let Some(commits) = resp.get("commits").and_then(|c| c.as_array()) {
output::header(&format!("Changelog: {base}..{head} ({} commits)", commits.len()));
output::header(&format!(
"Changelog: {base}..{head} ({} commits)",
commits.len()
));
for commit in commits {
let sha = commit
.get("sha")
@@ -664,10 +665,7 @@
.and_then(|s| s.as_str())
.unwrap_or("???????");
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
let message = commit
let message = commit.get("message").and_then(|m| m.as_str()).unwrap_or("");
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("");
// Just show first line of the message
let first_line = message.lines().next().unwrap_or("");
println!(" {short_sha} {first_line}");
src/commands/requirement.rs +12 −17
@@ -259,10 +259,7 @@
output::detail("Priority", req.priority.as_deref().unwrap_or("-"));
output::detail("Status", req.status.as_deref().unwrap_or("-"));
output::detail("Version", req.version.as_deref().unwrap_or("-"));
output::detail(
"Parent",
req.parent_id.as_deref().unwrap_or("(top-level)"),
);
output::detail("Parent", req.parent_id.as_deref().unwrap_or("(top-level)"));
let children = req.children.as_ref().map(|c| c.len()).unwrap_or(0);
output::detail("Children", &children.to_string());
let tests = req.test_links.as_ref().map(|t| t.len()).unwrap_or(0);
@@ -378,15 +375,9 @@
.as_str()
.unwrap_or("unknown")
.to_string();
let tests = entry["tests"]
.as_array()
let tests = entry["tests"].as_array().map(|t| t.len()).unwrap_or(0);
.map(|t| t.len())
.unwrap_or(0);
vec![
req["requirement_id"]
req["requirement_id"].as_str().unwrap_or("-").to_string(),
.as_str()
.unwrap_or("-")
.to_string(),
req["title"].as_str().unwrap_or("-").to_string(),
coverage,
tests.to_string(),
@@ -494,9 +485,7 @@
if clear {
output::warn("Clearing existing requirements...");
// List and delete all existing requirements (children first, then parents)
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements"))
.await?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/requirements")).await?;
let existing: Vec<serde_json::Value> = resp
.get("requirements")
.and_then(|v| v.as_array())
@@ -538,7 +527,10 @@
}
Err(e) => {
let err_msg = format!("{e}");
if err_msg.contains("422") || err_msg.contains("unique") || err_msg.contains("already") {
if err_msg.contains("422")
|| err_msg.contains("unique")
|| err_msg.contains("already")
{
// Already exists — fetch it to get the UUID
let existing: RequirementDetail = client
.get(&format!("/{org}/{name}/requirements/{}", domain.id))
@@ -574,7 +566,10 @@
}
Err(e) => {
let err_msg = format!("{e}");
if err_msg.contains("422")
|| err_msg.contains("unique")
if err_msg.contains("422") || err_msg.contains("unique") || err_msg.contains("already") {
|| err_msg.contains("already")
{
skipped += 1;
eprint!("s");
} else {
src/runner/artifacts.rs +1 −4
@@ -149,10 +149,7 @@
let resp = client
.post(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header())?,
)
.header(AUTHORIZATION, HeaderValue::from_str(&config.auth_header())?)
.multipart(form)
.send()
.await?;