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
29 changes: 29 additions & 0 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ pub enum Command {
#[command(subcommand)]
Run(RunCommand),

/// Publish a repository's default branch as a static site.
#[command(subcommand)]
Site(SiteCommand),

/// Report and read commit statuses, the checks CI shows on a commit.
#[command(subcommand)]
Status(StatusCommand),
Expand Down Expand Up @@ -248,6 +252,31 @@ pub enum RunCommand {
Handoff { id: i64 },
}

#[derive(clap::Subcommand)]
pub enum SiteCommand {
/// Show a repository's site: its address and whether it is live.
View {
#[arg(long)]
repo: Option<String>,
},
/// Publish the default branch, or publish it again.
Publish {
/// Wait until the site is live or the publish has failed.
#[arg(long)]
wait: bool,
#[arg(long)]
repo: Option<String>,
},
/// Take the site offline.
Unpublish {
#[arg(long)]
repo: Option<String>,
/// Skip the confirmation prompt.
#[arg(short, long)]
yes: bool,
},
}

#[derive(clap::Subcommand)]
pub enum StatusCommand {
/// Report a status on a commit.
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 @@ -10,4 +10,5 @@ pub mod pr;
pub mod repo;
pub mod run;
pub mod session;
pub mod site;
pub mod status;
115 changes: 115 additions & 0 deletions crates/cli/src/commands/site.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use {
crate::{
cli::SiteCommand,
context::Ctx,
ui::{self, prompt},
},
anyhow::{anyhow, Result},
sigit_si_api::models::Site,
std::time::Duration,
};

/// How often `publish --wait` asks, and how many times: about 90 seconds.
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const POLL_ATTEMPTS: u32 = 45;

pub async fn run(ctx: &Ctx, command: SiteCommand) -> Result<()> {
ctx.require_token()?;

match command {
SiteCommand::View { repo } => view(ctx, repo).await,
SiteCommand::Publish { wait, repo } => publish(ctx, wait, repo).await,
SiteCommand::Unpublish { repo, yes } => unpublish(ctx, repo, yes).await,
}
}

async fn view(ctx: &Ctx, repo: Option<String>) -> Result<()> {
let slug = ctx.resolve_repo(repo)?;
let site = ctx.client.site(&slug.owner, &slug.name).await?;
ui::emit(ctx.json, &site, ui::render::site)
}

async fn publish(ctx: &Ctx, wait: bool, repo: Option<String>) -> Result<()> {
let slug = ctx.resolve_repo(repo)?;
let mut site = ctx.client.publish_site(&slug.owner, &slug.name).await?;

if wait {
ui::note("Publishing…");
for _ in 0..POLL_ATTEMPTS {
if is_settled(&site) {
break;
}
tokio::time::sleep(POLL_INTERVAL).await;
site = ctx.client.site(&slug.owner, &slug.name).await?;
}
if !is_settled(&site) {
return Err(anyhow!(
"The site is still publishing. Check on it with `si site view`."
));
}
}

let failed = site.state == "failed";
ui::emit(ctx.json, &site, |site| {
if failed {
ui::render::site(site);
} else if site.state == "live" {
ui::success(format!("Published {}.", ui::highlight(&site.url)));
} else {
ui::success(format!("Publishing {}.", ui::highlight(&site.url)));
ui::note("Check on it with `si site view`.");
}
})?;

if failed {
return Err(anyhow!(
"Publishing failed: {}",
site.error.as_deref().unwrap_or("no reason given.")
));
}
Ok(())
}

async fn unpublish(ctx: &Ctx, repo: Option<String>, yes: bool) -> Result<()> {
let slug = ctx.resolve_repo(repo)?;
if !yes && !prompt::confirm(&format!("Take the site for {slug} offline?"), false)? {
ui::note("Cancelled.");
return Ok(());
}

ctx.client.unpublish_site(&slug.owner, &slug.name).await?;
ui::success(format!("Taking the site for {slug} offline."));
Ok(())
}

fn is_settled(site: &Site) -> bool {
matches!(site.state.as_str(), "live" | "failed")
}

#[cfg(test)]
mod tests {
use super::*;

fn site(state: &str) -> Site {
serde_json::from_value(serde_json::json!({
"url": "https://demo.example",
"state": state,
"error": null,
"last_published_at": null,
"files": 0,
"bytes": 0,
}))
.unwrap()
}

#[test]
fn live_and_failed_end_the_wait() {
assert!(is_settled(&site("live")));
assert!(is_settled(&site("failed")));
}

#[test]
fn publishing_keeps_the_wait_going() {
assert!(!is_settled(&site("publishing")));
}
}
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async fn run() -> Result<()> {
Command::Issue(command) => commands::issue::run(&ctx, command).await,
Command::Code(command) => commands::code::run(&ctx, command).await,
Command::Run(command) => commands::run::run(&ctx, command).await,
Command::Site(command) => commands::site::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,
Expand Down
27 changes: 26 additions & 1 deletion crates/cli/src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use {
console::style,
sigit_si_api::models::{
AgentRun, Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue,
IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, User,
IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, Site, User,
},
tabled::{builder::Builder, settings::Style},
};
Expand Down Expand Up @@ -468,6 +468,31 @@ pub fn agent_run_detail(run: &AgentRun) {
}
}

// ── Sites ───────────────────────────────────────────────────────────────────

pub fn site(site: &Site) {
let state = match site.state.as_str() {
"live" => style("live").green().to_string(),
"failed" => style("failed").red().to_string(),
"publishing" => style("publishing").yellow().to_string(),
other => style(other).dim().to_string(),
};
println!();
println!(" {} {}", highlight(&site.url), state);
field("Error", site.error.as_deref());
field(
"Published",
site.last_published_at.map(relative_time).as_deref(),
);
if site.files > 0 {
field(
"Size",
Some(&format!("{} files, {} bytes", site.files, site.bytes)),
);
}
println!();
}

// ── Billing ─────────────────────────────────────────────────────────────────

pub fn billing(billing: &Billing) {
Expand Down
2 changes: 2 additions & 0 deletions crates/sigit-si-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ pub mod repos;
#[cfg(feature = "client")]
pub mod sessions;
#[cfg(feature = "client")]
pub mod sites;
#[cfg(feature = "client")]
pub mod statuses;

/// Request header a client uses to name itself. [`client::ClientBuilder::client_name`]
Expand Down
16 changes: 16 additions & 0 deletions crates/sigit-si-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ pub struct AgentRun {
pub transcript_tail: Option<String>,
}

// ── Sites ───────────────────────────────────────────────────────────────────

/// A repository's published static site.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Site {
pub url: String,
/// `publishing`, `live` or `failed`.
pub state: String,
pub error: Option<String>,
pub last_published_at: Option<DateTime<Utc>>,
#[serde(default)]
pub files: i64,
#[serde(default)]
pub bytes: i64,
}

// ── MCP-backed resources ────────────────────────────────────────────────────
//
// Issues, pull requests, and code search have no REST surface; they are served
Expand Down
75 changes: 75 additions & 0 deletions crates/sigit-si-api/src/sites.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! A repository's default branch, published as a static site.

use {
crate::{client::Client, error::Result, models::Site},
reqwest::{Method, RequestBuilder},
};

impl Client {
/// `GET /api/v1/repos/:owner/:repo/site` — `404` when the repository has no
/// site.
pub async fn site(&self, owner: &str, repo: &str) -> Result<Site> {
self.get(&site_path(owner, repo)).await
}

/// `POST /api/v1/repos/:owner/:repo/site` — publishes the site, or
/// publishes it again. Owner only. Publishing runs in the background, so
/// the answer has state `publishing`; poll [`Client::site`] for `live` or
/// `failed`. Publishing too often answers `429`.
pub async fn publish_site(&self, owner: &str, repo: &str) -> Result<Site> {
self.send(self.publish_site_request(owner, repo)).await
}

/// `DELETE /api/v1/repos/:owner/:repo/site` — takes the site offline. The
/// server does it in the background.
pub async fn unpublish_site(&self, owner: &str, repo: &str) -> Result<()> {
self.delete(&site_path(owner, repo)).await
}

fn publish_site_request(&self, owner: &str, repo: &str) -> RequestBuilder {
self.request(Method::POST, &site_path(owner, repo))
.json(&serde_json::json!({}))
}
}

fn site_path(owner: &str, repo: &str) -> String {
format!("repos/{owner}/{repo}/site")
}

#[cfg(test)]
mod tests {
use {super::*, crate::Environment};

#[test]
fn publishing_posts_to_the_site_path() {
let client = Client::builder(Environment::Production)
.base_url("https://sigit.si")
.token("tok")
.build()
.unwrap();
let request = client.publish_site_request("seto", "demo").build().unwrap();

assert_eq!(request.method(), Method::POST);
assert_eq!(
request.url().as_str(),
"https://sigit.si/api/v1/repos/seto/demo/site"
);
}

#[test]
fn a_site_parses() {
let body = r#"{"url":"https://demo.example","state":"live","error":null,
"last_published_at":"2026-10-01T10:00:00Z","files":4,"bytes":2048}"#;
let site: Site = serde_json::from_str(body).unwrap();
assert_eq!(site.state, "live");
assert_eq!(site.files, 4);
}

#[test]
fn a_site_still_publishing_has_no_timestamp_yet() {
let body = r#"{"url":"https://demo.example","state":"publishing","error":null,
"last_published_at":null,"files":0,"bytes":0}"#;
let site: Site = serde_json::from_str(body).unwrap();
assert!(site.last_published_at.is_none());
}
}
Loading