From 0cf62210fdd9c570f8e18fd3db00be245735fb0d Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Wed, 28 Oct 2026 10:12:23 +0100 Subject: [PATCH] Add si status set and view for commit statuses si status set reports pending, success, failure or error for a check on a commit, so CI can fill the checks tab. si status view shows the combined state and each check. Both take a SHA or any ref in the current clone, and view defaults to HEAD. The client crate gets create_status and combined_status, with the CommitStatus and CombinedStatus wire types. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/cli.rs | 32 ++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/status.rs | 68 +++++++++++++ crates/cli/src/git.rs | 51 ++++++++++ crates/cli/src/main.rs | 1 + crates/cli/src/ui/render.rs | 45 ++++++++- crates/sigit-si-api/src/lib.rs | 3 + crates/sigit-si-api/src/models.rs | 27 ++++++ crates/sigit-si-api/src/statuses.rs | 145 ++++++++++++++++++++++++++++ 9 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 crates/cli/src/commands/status.rs create mode 100644 crates/sigit-si-api/src/statuses.rs diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 2cd82ff..d0b215b 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -42,6 +42,10 @@ pub enum Command { #[command(subcommand)] Code(CodeCommand), + /// Report and read commit statuses, the checks CI shows on a commit. + #[command(subcommand)] + Status(StatusCommand), + /// Manage a repository's deploy webhooks. #[command(subcommand)] Hook(HookCommand), @@ -218,6 +222,34 @@ pub enum CodeCommand { }, } +#[derive(clap::Subcommand)] +pub enum StatusCommand { + /// Report a status on a commit. + Set { + /// A commit SHA, or a branch, tag or other ref in the current repo. + sha: String, + #[arg(long, value_parser = ["pending", "success", "failure", "error"])] + state: String, + /// Which check this is, such as ci/test. Defaults to "default". + #[arg(long)] + context: Option, + #[arg(long)] + description: Option, + /// Where to see the details, such as a CI run. + #[arg(long)] + url: Option, + #[arg(long)] + repo: Option, + }, + /// Show the combined status of a commit and each check behind it. + View { + /// A commit SHA or ref. Defaults to HEAD. + sha: Option, + #[arg(long)] + repo: Option, + }, +} + #[derive(clap::Subcommand)] pub enum HookCommand { /// List a repository's webhooks. diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 0b8f6b9..6302aa3 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -9,3 +9,4 @@ pub mod me; pub mod pr; pub mod repo; pub mod session; +pub mod status; diff --git a/crates/cli/src/commands/status.rs b/crates/cli/src/commands/status.rs new file mode 100644 index 0000000..cd0a45f --- /dev/null +++ b/crates/cli/src/commands/status.rs @@ -0,0 +1,68 @@ +use { + crate::{cli::StatusCommand, context::Ctx, git, ui}, + anyhow::{Context, Result}, + sigit_si_api::NewStatus, +}; + +pub async fn run(ctx: &Ctx, command: StatusCommand) -> Result<()> { + ctx.require_token()?; + + match command { + StatusCommand::Set { + sha, + state, + context, + description, + url, + repo, + } => set(ctx, sha, state, context, description, url, repo).await, + StatusCommand::View { sha, repo } => view(ctx, sha, repo).await, + } +} + +fn resolve(rev: &str) -> Result { + let cwd = std::env::current_dir().context("Could not read the current directory.")?; + git::resolve_sha(&cwd, rev) +} + +async fn set( + ctx: &Ctx, + rev: String, + state: String, + context: Option, + description: Option, + url: Option, + repo: Option, +) -> Result<()> { + let slug = ctx.resolve_repo(repo)?; + let sha = resolve(&rev)?; + + let status = NewStatus::new(state) + .context(context) + .description(description) + .target_url(url); + let created = ctx + .client + .create_status(&slug.owner, &slug.name, &sha, &status) + .await?; + + ui::emit(ctx.json, &created, |created| { + ui::success(format!( + "Reported {} for {} on {}.", + created.state, + ui::highlight(&created.context), + &created.sha[..created.sha.len().min(12)] + )); + }) +} + +async fn view(ctx: &Ctx, rev: Option, repo: Option) -> Result<()> { + let slug = ctx.resolve_repo(repo)?; + let sha = resolve(rev.as_deref().unwrap_or("HEAD"))?; + + let combined = ctx + .client + .combined_status(&slug.owner, &slug.name, &sha) + .await?; + ui::emit(ctx.json, &combined, ui::render::combined_status) +} diff --git a/crates/cli/src/git.rs b/crates/cli/src/git.rs index 75fa892..d93e3bd 100644 --- a/crates/cli/src/git.rs +++ b/crates/cli/src/git.rs @@ -86,6 +86,29 @@ pub fn current_branch(dir: &Path) -> Option { head.shorthand().map(str::to_string) } +/// Resolve a ref (or `HEAD`) in the repository around `dir` to a full commit +/// SHA. A full SHA that this checkout doesn't have is passed through, since +/// the commit may only exist on the server. +pub fn resolve_sha(dir: &Path, rev: &str) -> Result { + let found = GitRepository::discover(dir).ok().and_then(|repository| { + let object = repository.revparse_single(rev).ok()?; + Some(object.peel_to_commit().ok()?.id().to_string()) + }); + + match found { + Some(sha) => Ok(sha), + None if is_full_sha(rev) => Ok(rev.to_ascii_lowercase()), + None => Err(anyhow!( + "Could not resolve {rev:?} to a commit. Run this inside a clone of the repository, \ + or pass the full commit SHA." + )), + } +} + +fn is_full_sha(text: &str) -> bool { + text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) +} + /// `origin` first, then `sigit`, then the rest in their configured order. fn preferred_order(names: &[String]) -> Vec { let mut ordered = Vec::with_capacity(names.len()); @@ -220,6 +243,34 @@ mod tests { assert_eq!(slug.to_string(), "seto/demo"); } + #[test] + fn a_ref_resolves_to_its_commit() { + let dir = tempfile::tempdir().unwrap(); + let repository = GitRepository::init(dir.path()).unwrap(); + let signature = git2::Signature::now("t", "t@example.com").unwrap(); + let tree = repository + .find_tree(repository.treebuilder(None).unwrap().write().unwrap()) + .unwrap(); + let id = repository + .commit(Some("HEAD"), &signature, &signature, "init", &tree, &[]) + .unwrap(); + + assert_eq!(resolve_sha(dir.path(), "HEAD").unwrap(), id.to_string()); + } + + #[test] + fn an_unknown_full_sha_is_passed_through() { + let dir = tempfile::tempdir().unwrap(); + let sha = "A".repeat(40); + assert_eq!(resolve_sha(dir.path(), &sha).unwrap(), "a".repeat(40)); + } + + #[test] + fn an_unknown_ref_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_sha(dir.path(), "main").is_err()); + } + #[test] fn origin_is_tried_before_other_remotes() { let names = vec!["upstream".to_string(), "origin".to_string()]; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 45dd6d7..857064c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -46,6 +46,7 @@ async fn run() -> Result<()> { Command::Pr(command) => commands::pr::run(&ctx, command).await, Command::Issue(command) => commands::issue::run(&ctx, command).await, Command::Code(command) => commands::code::run(&ctx, command).await, + Command::Status(command) => commands::status::run(&ctx, command).await, Command::Hook(command) => commands::hook::run(&ctx, command).await, Command::Session(command) => commands::session::run(&ctx, command).await, Command::Billing(command) => commands::billing::run(&ctx, command).await, diff --git a/crates/cli/src/ui/render.rs b/crates/cli/src/ui/render.rs index 5b42c06..352db1e 100644 --- a/crates/cli/src/ui/render.rs +++ b/crates/cli/src/ui/render.rs @@ -3,9 +3,10 @@ use { super::{bold, dim, highlight, relative_time, state_badge}, + console::style, sigit_si_api::models::{ - Billing, CloudSession, CodeHit, Comment, Hook, Issue, IssueSummary, PullRequest, - PullRequestSummary, Repository, RepositorySummary, User, + Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue, IssueSummary, + PullRequest, PullRequestSummary, Repository, RepositorySummary, User, }, tabled::{builder::Builder, settings::Style}, }; @@ -360,6 +361,46 @@ pub fn session_detail(session: &CloudSession) { } } +// ── Commit statuses ───────────────────────────────────────────────────────── + +fn status_badge(state: &str) -> String { + match state { + "success" => style("success").green().to_string(), + "failure" | "error" => style(state).red().to_string(), + "pending" => style("pending").yellow().to_string(), + other => style(other).dim().to_string(), + } +} + +pub fn combined_status(combined: &CombinedStatus) { + println!(); + println!( + " {} {}", + highlight(&combined.sha[..combined.sha.len().min(12)]), + status_badge(&combined.state) + ); + println!(); + + let rows = combined + .statuses + .iter() + .map(|status| { + vec![ + status.context.clone(), + status_badge(&status.state), + truncate(status.description.as_deref().unwrap_or("—"), 48), + relative_time(status.updated_at), + ] + }) + .collect(); + + table( + &["Check", "State", "Description", "Updated"], + rows, + "No statuses have been reported on this commit.", + ); +} + // ── Billing ───────────────────────────────────────────────────────────────── pub fn billing(billing: &Billing) { diff --git a/crates/sigit-si-api/src/lib.rs b/crates/sigit-si-api/src/lib.rs index 78037b4..1286c59 100644 --- a/crates/sigit-si-api/src/lib.rs +++ b/crates/sigit-si-api/src/lib.rs @@ -53,6 +53,8 @@ pub mod pulls; pub mod repos; #[cfg(feature = "client")] pub mod sessions; +#[cfg(feature = "client")] +pub mod statuses; /// Request header a client uses to name itself. [`client::ClientBuilder::client_name`] /// sends it on every request. @@ -66,4 +68,5 @@ pub use { error::{ApiError, Result}, issues::State, repos::NewRepository, + statuses::NewStatus, }; diff --git a/crates/sigit-si-api/src/models.rs b/crates/sigit-si-api/src/models.rs index dfece29..e9befa8 100644 --- a/crates/sigit-si-api/src/models.rs +++ b/crates/sigit-si-api/src/models.rs @@ -139,6 +139,33 @@ pub struct GitCredential { pub expires_at: DateTime, } +// ── Commit statuses ───────────────────────────────────────────────────────── + +/// One reported status on a commit, from `POST …/statuses/:sha` and the +/// `statuses` list of the combined status. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CommitStatus { + pub id: i64, + pub sha: String, + /// `pending`, `success`, `failure` or `error`. + pub state: String, + pub context: String, + pub description: Option, + pub target_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// `GET …/commits/:sha/status` — every context on a commit and the state they +/// add up to. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CombinedStatus { + pub sha: String, + pub state: String, + pub total_count: u32, + pub statuses: Vec, +} + // ── MCP-backed resources ──────────────────────────────────────────────────── // // Issues, pull requests, and code search have no REST surface; they are served diff --git a/crates/sigit-si-api/src/statuses.rs b/crates/sigit-si-api/src/statuses.rs new file mode 100644 index 0000000..0a6607c --- /dev/null +++ b/crates/sigit-si-api/src/statuses.rs @@ -0,0 +1,145 @@ +//! Commit statuses — how CI reports pass or fail onto a commit. + +use { + crate::{ + client::Client, + error::Result, + models::{CombinedStatus, CommitStatus}, + }, + reqwest::{Method, RequestBuilder}, +}; + +/// Fields accepted by `POST /api/v1/repos/:owner/:repo/statuses/:sha`. +#[derive(Debug, Clone, serde::Serialize)] +pub struct NewStatus { + /// `pending`, `success`, `failure` or `error`. + pub state: String, + /// Which check this is. The server uses `default` when it is left out. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_url: Option, +} + +impl NewStatus { + pub fn new(state: impl Into) -> Self { + Self { + state: state.into(), + context: None, + description: None, + target_url: None, + } + } + + pub fn context(mut self, context: Option) -> Self { + self.context = context.filter(|c| !c.trim().is_empty()); + self + } + + pub fn description(mut self, description: Option) -> Self { + self.description = description.filter(|d| !d.trim().is_empty()); + self + } + + pub fn target_url(mut self, target_url: Option) -> Self { + self.target_url = target_url.filter(|u| !u.trim().is_empty()); + self + } +} + +impl Client { + /// `POST /api/v1/repos/:owner/:repo/statuses/:sha` — report a status on a + /// commit. Owner only. Reporting the same context again replaces it. + pub async fn create_status( + &self, + owner: &str, + repo: &str, + sha: &str, + status: &NewStatus, + ) -> Result { + self.send(self.create_status_request(owner, repo, sha, status)) + .await + } + + /// `GET /api/v1/repos/:owner/:repo/commits/:sha/status` — the combined + /// state of a commit and every context behind it. + pub async fn combined_status( + &self, + owner: &str, + repo: &str, + sha: &str, + ) -> Result { + self.get(&format!("repos/{owner}/{repo}/commits/{sha}/status")) + .await + } + + fn create_status_request( + &self, + owner: &str, + repo: &str, + sha: &str, + status: &NewStatus, + ) -> RequestBuilder { + self.request( + Method::POST, + &format!("repos/{owner}/{repo}/statuses/{sha}"), + ) + .json(status) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::Environment}; + + fn client() -> Client { + Client::builder(Environment::Production) + .base_url("https://sigit.si") + .token("tok") + .build() + .unwrap() + } + + #[test] + fn a_status_is_posted_to_the_commit() { + let status = NewStatus::new("success").context(Some("ci/test".into())); + let request = client() + .create_status_request("seto", "demo", "abc123", &status) + .build() + .unwrap(); + + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.url().as_str(), + "https://sigit.si/api/v1/repos/seto/demo/statuses/abc123" + ); + let body = request.body().and_then(|b| b.as_bytes()).unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["state"], "success"); + assert_eq!(body["context"], "ci/test"); + assert!(body.get("description").is_none()); + assert!(body.get("target_url").is_none()); + } + + #[test] + fn blank_optional_fields_are_left_out() { + let status = NewStatus::new("pending") + .context(Some(" ".into())) + .description(Some(String::new())) + .target_url(None); + let json = serde_json::to_value(&status).unwrap(); + assert_eq!(json, serde_json::json!({ "state": "pending" })); + } + + #[test] + fn a_combined_status_parses() { + let body = r#"{"sha":"abc","state":"success","total_count":1,"statuses":[ + {"id":1,"sha":"abc","state":"success","context":"ci","description":null, + "target_url":null,"created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-01T10:00:00Z"}]}"#; + let combined: CombinedStatus = serde_json::from_str(body).unwrap(); + assert_eq!(combined.total_count, 1); + assert_eq!(combined.statuses[0].context, "ci"); + } +}