a73x

src/lease.rs

Ref:   Size: 7.6 KiB   History

//! Client-side work leases: claim an issue, keep the claim alive, give it up.
//!
//! Leases live on the server, not in the repository — they are the one
//! collaboration primitive git cannot express, needing an atomic decision
//! point and a TTL. So unlike `issue open` or `issue comment`, these commands
//! do not write refs and never sync: each is one `collab-lease` exec verb
//! over SSH, authenticated by the same key that clones the repository.

use std::process::Stdio;

use git2::Repository;

use crate::error::Error;
use crate::remote_ssh::{run_remote_expecting, ssh_remote, SshRemote};

/// The exit code the server uses for "held by someone else" / "not yours to
/// renew". Distinct from 1 so a racing agent can branch on it.
const CONFLICT_CODE: i32 = 4;

/// Reject anything that is not a plain hex id or prefix before it reaches a
/// single-quoted remote command string. The server validates too; this turns
/// a typo into a local error rather than a round trip, and keeps a quote from
/// ever reaching the quoting we cannot escape.
fn validate_id(id: &str) -> Result<(), Error> {
    if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err(Error::Cmd(format!(
            "'{}' is not an issue id — expected hex characters",
            id
        )));
    }
    Ok(())
}

fn run(
    repo: &Repository,
    remote: &SshRemote,
    remote_cmd: &str,
) -> Result<(serde_json::Value, bool), Error> {
    let output = run_remote_expecting(repo, remote, remote_cmd, Stdio::null(), &[CONFLICT_CODE])?;
    let text = String::from_utf8_lossy(&output.stdout);
    let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|e| {
        Error::Cmd(format!(
            "could not read the server's reply ({e}): {}",
            text.trim()
        ))
    })?;
    let conflict = output.status.code() == Some(CONFLICT_CODE);
    Ok((value, conflict))
}

/// Print the server's reply verbatim under `--json`, so a caller reads exactly
/// what the server said rather than a re-rendering of it.
fn emit(value: &serde_json::Value, json: bool, prose: impl FnOnce() -> String) {
    if json {
        println!("{}", value);
    } else {
        println!("{}", prose());
    }
}

/// The id to show a human: abbreviated against this repository's issues, as
/// every other command does. The server always answers with the full id —
/// that is what `--json` passes through, and what a script should hold — but
/// prose that printed 40 characters here would be the only place in the CLI
/// that did.
fn short(repo: &Repository, value: &serde_json::Value, fallback: &str) -> String {
    let full = value["issue"].as_str().unwrap_or(fallback);
    crate::abbrev::for_issues(repo).of(full).to_string()
}

fn expiry_note(value: &serde_json::Value) -> String {
    match value["expires_at"].as_str() {
        Some(at) => format!(" (expires {})", at),
        None => String::new(),
    }
}

/// A conflict, rendered for a human and carried as the exit-4 error.
fn conflict(value: &serde_json::Value, id: &str, json: bool) -> Error {
    if json {
        println!("{}", value);
    }
    let holder = value["holder"].as_str().unwrap_or("someone else");
    Error::LeaseConflict(format!(
        "issue {} is claimed by {}{}",
        id,
        holder,
        expiry_note(value)
    ))
}

pub fn claim(
    repo: &Repository,
    remote_name: &str,
    id: &str,
    ttl_secs: Option<u64>,
    json: bool,
) -> Result<(), Error> {
    validate_id(id)?;
    let remote = ssh_remote(repo, remote_name)?;
    let ttl = ttl_secs
        .map(|t| format!(" --ttl {}", t))
        .unwrap_or_default();
    let cmd = format!("collab-lease acquire '{}' '{}'{}", remote.path, id, ttl);
    let (value, is_conflict) = run(repo, &remote, &cmd)?;
    if is_conflict {
        return Err(conflict(&value, id, json));
    }
    emit(&value, json, || {
        format!(
            "Claimed issue {}{}",
            short(repo, &value, id),
            expiry_note(&value)
        )
    });
    Ok(())
}

pub fn renew(
    repo: &Repository,
    remote_name: &str,
    id: &str,
    ttl_secs: u64,
    json: bool,
) -> Result<(), Error> {
    validate_id(id)?;
    let remote = ssh_remote(repo, remote_name)?;
    let cmd = format!(
        "collab-lease renew '{}' '{}' --ttl {}",
        remote.path, id, ttl_secs
    );
    let (value, is_conflict) = run(repo, &remote, &cmd)?;
    if is_conflict {
        // Not a rival's claim necessarily — an expired lease reads the same
        // way, and the fix is the same: claim it again.
        if json {
            println!("{}", value);
        }
        return Err(Error::LeaseConflict(match value["holder"].as_str() {
            Some(holder) => format!("issue {} is claimed by {}", id, holder),
            None => format!("you do not hold a lease on issue {} — claim it first", id),
        }));
    }
    emit(&value, json, || {
        format!("Renewed the claim on {}{}", id, expiry_note(&value))
    });
    Ok(())
}

pub fn unclaim(repo: &Repository, remote_name: &str, id: &str, json: bool) -> Result<(), Error> {
    validate_id(id)?;
    let remote = ssh_remote(repo, remote_name)?;
    let cmd = format!("collab-lease release '{}' '{}'", remote.path, id);
    let (value, is_conflict) = run(repo, &remote, &cmd)?;
    if is_conflict {
        return Err(conflict(&value, id, json));
    }
    emit(&value, json, || {
        // "released" and "not-held" are both exit 0, and saying "released"
        // for either is what hid a bug where a claim made by prefix could
        // not be released by that prefix at all (2026-09-06).
        if value["status"] == "not-held" {
            format!("No claim on {} to release", short(repo, &value, id))
        } else {
            format!("Released the claim on {}", short(repo, &value, id))
        }
    });
    Ok(())
}

pub fn claims(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> {
    let remote = ssh_remote(repo, remote_name)?;
    let cmd = format!("collab-lease list '{}'", remote.path);
    let (value, _) = run(repo, &remote, &cmd)?;
    if json {
        println!("{}", value);
        return Ok(());
    }
    let rows = value["leases"].as_array().cloned().unwrap_or_default();
    if rows.is_empty() {
        println!("No claims.");
        return Ok(());
    }
    let abbrev = crate::abbrev::for_issues(repo);
    for row in &rows {
        println!(
            "{}  {}{}",
            abbrev.of(row["issue"].as_str().unwrap_or("?")),
            row["holder"].as_str().unwrap_or("?"),
            match row["expires_at"].as_str() {
                Some(at) => format!("  expires {}", at),
                None => "  assigned".to_string(),
            }
        );
    }
    Ok(())
}

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

    #[test]
    fn validate_id_accepts_hex_and_prefixes() {
        assert!(validate_id("a1b2c3d4").is_ok());
        assert!(validate_id("a1b2").is_ok());
        assert!(validate_id("ABCDEF01").is_ok());
    }

    #[test]
    fn validate_id_rejects_anything_that_could_break_quoting() {
        // A quote would escape the single-quoted remote argument.
        assert!(validate_id("a1'; rm -rf /").is_err());
        assert!(validate_id("a1 b2").is_err());
        assert!(validate_id("--ttl").is_err());
        assert!(validate_id("").is_err());
        assert!(validate_id("zzzz").is_err());
    }

    #[test]
    fn expiry_note_reads_open_ended_leases_as_no_note() {
        assert_eq!(expiry_note(&serde_json::json!({})), "");
        assert_eq!(expiry_note(&serde_json::json!({ "expires_at": null })), "");
        assert_eq!(
            expiry_note(&serde_json::json!({ "expires_at": "2026-09-05T12:00:00Z" })),
            " (expires 2026-09-05T12:00:00Z)"
        );
    }
}