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
32 changes: 32 additions & 0 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<String>,
#[arg(long)]
description: Option<String>,
/// Where to see the details, such as a CI run.
#[arg(long)]
url: Option<String>,
#[arg(long)]
repo: Option<String>,
},
/// Show the combined status of a commit and each check behind it.
View {
/// A commit SHA or ref. Defaults to HEAD.
sha: Option<String>,
#[arg(long)]
repo: Option<String>,
},
}

#[derive(clap::Subcommand)]
pub enum HookCommand {
/// List a repository's webhooks.
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ pub mod me;
pub mod pr;
pub mod repo;
pub mod session;
pub mod status;
68 changes: 68 additions & 0 deletions crates/cli/src/commands/status.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String>,
description: Option<String>,
url: Option<String>,
repo: Option<String>,
) -> 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<String>, repo: Option<String>) -> 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)
}
51 changes: 51 additions & 0 deletions crates/cli/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ pub fn current_branch(dir: &Path) -> Option<String> {
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<String> {
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<String> {
let mut ordered = Vec::with_capacity(names.len());
Expand Down Expand Up @@ -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", "[email protected]").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()];
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 43 additions & 2 deletions crates/cli/src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions crates/sigit-si-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -66,4 +68,5 @@ pub use {
error::{ApiError, Result},
issues::State,
repos::NewRepository,
statuses::NewStatus,
};
27 changes: 27 additions & 0 deletions crates/sigit-si-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,33 @@ pub struct GitCredential {
pub expires_at: DateTime<Utc>,
}

// ── 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<String>,
pub target_url: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

/// `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<CommitStatus>,
}

// ── MCP-backed resources ────────────────────────────────────────────────────
//
// Issues, pull requests, and code search have no REST surface; they are served
Expand Down
Loading
Loading