diff --git a/Cargo.lock b/Cargo.lock index 76dc6061..509915a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,6 +1077,7 @@ dependencies = [ name = "mergify-cli" version = "0.0.0" dependencies = [ + "chrono", "clap", "mergify-ci", "mergify-config", @@ -1125,6 +1126,7 @@ version = "0.0.0" dependencies = [ "anstyle", "chrono", + "iana-time-zone", "mergify-core", "mergify-tui", "serde", diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml index 38d41a5d..139bcc95 100644 --- a/crates/mergify-cli/Cargo.toml +++ b/crates/mergify-cli/Cargo.toml @@ -14,6 +14,7 @@ name = "mergify" path = "src/main.rs" [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = { version = "4.5", features = ["derive"] } mergify-ci = { path = "../mergify-ci" } mergify-config = { path = "../mergify-config" } diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 6158a2aa..8826a1ee 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -33,7 +33,11 @@ use mergify_config::simulate::PullRequestRef; use mergify_config::simulate::SimulateOptions; use mergify_core::OutputMode; use mergify_core::StdioOutput; +use mergify_freeze::common::parse_naive_datetime; +use mergify_freeze::create::CreateOptions as FreezeCreateOptions; +use mergify_freeze::delete::DeleteOptions as FreezeDeleteOptions; use mergify_freeze::list::ListOptions as FreezeListOptions; +use mergify_freeze::update::UpdateOptions as FreezeUpdateOptions; use mergify_queue::pause::PauseOptions; use mergify_queue::show::ShowOptions; use mergify_queue::status::StatusOptions; @@ -130,6 +134,9 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("queue", "status"), ("queue", "show"), ("freeze", "list"), + ("freeze", "create"), + ("freeze", "update"), + ("freeze", "delete"), ]; /// Native commands the Rust binary handles without delegating to @@ -145,6 +152,9 @@ enum NativeCommand { QueueStatus(QueueStatusOpts), QueueShow(QueueShowOpts), FreezeList(FreezeListOpts), + FreezeCreate(FreezeCreateOpts), + FreezeUpdate(FreezeUpdateOpts), + FreezeDelete(FreezeDeleteOpts), } struct ConfigSimulateOpts { @@ -203,6 +213,39 @@ struct FreezeListOpts { output_json: bool, } +struct FreezeCreateOpts { + repository: Option, + token: Option, + api_url: Option, + reason: String, + timezone: Option, + start: Option, + end: Option, + matching_conditions: Vec, + exclude_conditions: Vec, +} + +struct FreezeUpdateOpts { + repository: Option, + token: Option, + api_url: Option, + freeze_id: String, + reason: Option, + timezone: Option, + start: Option, + end: Option, + matching_conditions: Option>, + exclude_conditions: Option>, +} + +struct FreezeDeleteOpts { + repository: Option, + token: Option, + api_url: Option, + freeze_id: String, + delete_reason: Option, +} + /// Heuristic: does argv look like the user intended a native /// subcommand? /// @@ -409,6 +452,86 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { api_url, output_json: json, })), + Subcommands::Freeze(FreezeArgs { + repository, + token, + api_url, + command: + FreezeSubcommand::Create(FreezeCreateCliArgs { + reason, + timezone, + condition, + start, + end, + exclude, + }), + }) => Dispatch::Native(NativeCommand::FreezeCreate(FreezeCreateOpts { + repository, + token, + api_url, + reason, + timezone, + start, + end, + matching_conditions: condition, + exclude_conditions: exclude, + })), + Subcommands::Freeze(FreezeArgs { + repository, + token, + api_url, + command: + FreezeSubcommand::Update(FreezeUpdateCliArgs { + freeze_id, + reason, + timezone, + condition, + start, + end, + exclude, + }), + }) => Dispatch::Native(NativeCommand::FreezeUpdate(FreezeUpdateOpts { + repository, + token, + api_url, + freeze_id, + reason, + timezone, + start, + end, + // Python's "include the list when the flag was passed + // at least once" maps to `Some(vec)` only when the user + // actually supplied a value. clap collects multiple + // `-c`/`-e` into a `Vec`, so an empty vec is + // indistinguishable from "flag never given" at this + // boundary — treat empty as `None` for parity. + matching_conditions: if condition.is_empty() { + None + } else { + Some(condition) + }, + exclude_conditions: if exclude.is_empty() { + None + } else { + Some(exclude) + }, + })), + Subcommands::Freeze(FreezeArgs { + repository, + token, + api_url, + command: + FreezeSubcommand::Delete(FreezeDeleteCliArgs { + freeze_id, + delete_reason, + }), + }) => Dispatch::Native(NativeCommand::FreezeDelete(FreezeDeleteOpts { + repository, + token, + api_url, + freeze_id, + delete_reason, + })), } } @@ -527,6 +650,54 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await } + NativeCommand::FreezeCreate(opts) => { + mergify_freeze::create::run( + FreezeCreateOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + reason: &opts.reason, + timezone: opts.timezone.as_deref(), + start: opts.start, + end: opts.end, + matching_conditions: &opts.matching_conditions, + exclude_conditions: &opts.exclude_conditions, + }, + &mut output, + ) + .await + } + NativeCommand::FreezeUpdate(opts) => { + mergify_freeze::update::run( + FreezeUpdateOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + freeze_id: &opts.freeze_id, + reason: opts.reason.as_deref(), + timezone: opts.timezone.as_deref(), + start: opts.start, + end: opts.end, + matching_conditions: opts.matching_conditions.as_deref(), + exclude_conditions: opts.exclude_conditions.as_deref(), + }, + &mut output, + ) + .await + } + NativeCommand::FreezeDelete(opts) => { + mergify_freeze::delete::run( + FreezeDeleteOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + freeze_id: &opts.freeze_id, + delete_reason: opts.delete_reason.as_deref(), + }, + &mut output, + ) + .await + } } }); @@ -796,6 +967,12 @@ struct FreezeArgs { enum FreezeSubcommand { /// List scheduled freezes for a repository. List(FreezeListCliArgs), + /// Create a new scheduled freeze. + Create(FreezeCreateCliArgs), + /// Update an existing scheduled freeze. + Update(FreezeUpdateCliArgs), + /// Delete a scheduled freeze. + Delete(FreezeDeleteCliArgs), } #[derive(clap::Args)] @@ -805,3 +982,83 @@ struct FreezeListCliArgs { #[arg(long, default_value_t = false)] json: bool, } + +#[derive(clap::Args)] +struct FreezeCreateCliArgs { + /// Reason for the freeze. + #[arg(long, required = true)] + reason: String, + + /// IANA timezone name (e.g. ``Europe/Paris``, ``US/Eastern``). + /// Defaults to the system timezone when omitted. + #[arg(long)] + timezone: Option, + + /// Matching condition (repeatable, e.g. `-c base=main`). + #[arg(long = "condition", short = 'c')] + condition: Vec, + + /// Start time in ISO 8601 format (default: now). + #[arg(long, value_parser = parse_naive_datetime_arg)] + start: Option, + + /// End time in ISO 8601 format (default: no end / emergency freeze). + #[arg(long, value_parser = parse_naive_datetime_arg)] + end: Option, + + /// Exclude condition (repeatable, e.g. `-e label=hotfix`). + #[arg(long = "exclude", short = 'e')] + exclude: Vec, +} + +#[derive(clap::Args)] +struct FreezeUpdateCliArgs { + /// Freeze ID (UUID). + #[arg(value_name = "FREEZE_ID")] + freeze_id: String, + + /// Reason for the freeze. + #[arg(long)] + reason: Option, + + /// IANA timezone name. + #[arg(long)] + timezone: Option, + + /// Matching condition (repeatable). Passing the flag — even + /// with no value — replaces the existing list. + #[arg(long = "condition", short = 'c')] + condition: Vec, + + /// Start time in ISO 8601 format. + #[arg(long, value_parser = parse_naive_datetime_arg)] + start: Option, + + /// End time in ISO 8601 format. + #[arg(long, value_parser = parse_naive_datetime_arg)] + end: Option, + + /// Exclude condition (repeatable). + #[arg(long = "exclude", short = 'e')] + exclude: Vec, +} + +#[derive(clap::Args)] +struct FreezeDeleteCliArgs { + /// Freeze ID (UUID). + #[arg(value_name = "FREEZE_ID")] + freeze_id: String, + + /// Reason for deleting the freeze (required if the freeze is + /// currently active). + #[arg(long = "reason")] + delete_reason: Option, +} + +/// clap `value_parser` shim for `--start` / `--end`. Delegates to +/// [`parse_naive_datetime`] and converts the typed `CliError` into a +/// stringified parser error so clap can render it as a normal +/// argument error. +fn parse_naive_datetime_arg(value: &str) -> Result { + parse_naive_datetime(value).map_err(|e| e.to_string()) +} diff --git a/crates/mergify-core/src/http.rs b/crates/mergify-core/src/http.rs index b17e3526..b5984baf 100644 --- a/crates/mergify-core/src/http.rs +++ b/crates/mergify-core/src/http.rs @@ -187,6 +187,22 @@ impl Client { self.decode_json(resp).await } + /// PATCH `body` as JSON to `path` and deserialize the JSON + /// response as `T`. Mirrors [`Self::put`] but for endpoints that + /// use the more permissive PATCH semantics (partial update) — + /// `freeze update` is the first caller. + pub async fn patch( + &self, + path: &str, + body: &B, + ) -> Result { + let url = self.join(path)?; + let resp = self + .execute_request(self.inner.patch(url).json(body)) + .await?; + self.decode_json(resp).await + } + /// DELETE `path`, returning whether the resource existed. /// /// Returns `Ok(DeleteOutcome::Deleted)` on 2xx responses and @@ -527,6 +543,22 @@ mod tests { assert_eq!(got, Foo { bar: 14 }); } + #[tokio::test] + async fn patch_sends_json_body_and_returns_deserialized_response() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/freeze/abc")) + .and(body_json(Foo { bar: 1 })) + .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 2 })) + .expect(1) + .mount(&server) + .await; + + let client = fast_client(&server, ApiFlavor::Mergify); + let got: Foo = client.patch("/freeze/abc", &Foo { bar: 1 }).await.unwrap(); + assert_eq!(got, Foo { bar: 2 }); + } + struct Flaky { attempts: Arc, fail_first: u32, diff --git a/crates/mergify-freeze/Cargo.toml b/crates/mergify-freeze/Cargo.toml index 899fc1cd..53dd6ed7 100644 --- a/crates/mergify-freeze/Cargo.toml +++ b/crates/mergify-freeze/Cargo.toml @@ -14,6 +14,7 @@ mergify-core = { path = "../mergify-core" } mergify-tui = { path = "../mergify-tui" } anstyle = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } +iana-time-zone = "0.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/mergify-freeze/src/common.rs b/crates/mergify-freeze/src/common.rs new file mode 100644 index 00000000..c23d741e --- /dev/null +++ b/crates/mergify-freeze/src/common.rs @@ -0,0 +1,242 @@ +//! Shared helpers across the freeze subcommands. +//! +//! Three responsibilities: +//! +//! - [`ScheduledFreeze`] — the wire format shared by every endpoint. +//! - [`print_freeze`] — the per-freeze human block that +//! `create` and `update` emit on success. Format matches Python's +//! `_print_freeze` so the smoke tests parse the same way against +//! either implementation. +//! - [`parse_naive_datetime`] — accept the ISO-8601 datetime +//! strings users give to `--start` / `--end`, rejecting anything +//! Python's `datetime.fromisoformat` would reject. +//! - [`detect_local_timezone`] — wrapper around `iana-time-zone` +//! that produces the user-facing error Python raises when +//! `tzlocal.get_localzone_name()` returns nothing. + +use std::io::Write; + +use chrono::NaiveDateTime; +use mergify_core::CliError; +use serde::Deserialize; + +/// Mergify's `/scheduled_freeze` resource — used as the response +/// shape on every freeze endpoint. Every field is optional so the +/// CLI can render whatever the server actually returned without +/// aborting on a missing key. +#[derive(Deserialize, Debug, Clone)] +pub struct ScheduledFreeze { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub start: Option, + #[serde(default)] + pub end: Option, + #[serde(default)] + pub timezone: Option, + #[serde(default)] + pub matching_conditions: Vec, + #[serde(default)] + pub exclude_conditions: Vec, +} + +/// `--start` / `--end` payload encoder: the Python CLI feeds an +/// `isoformat()` string to the API, which is exactly the input +/// string we parsed (no offset). [`Self::iso`] reproduces that +/// shape — seconds precision, no offset — so the round-trip +/// matches what the Mergify server has historically accepted. +#[derive(Debug, Clone, Copy)] +pub struct NaiveDateTimeWire<'a>(pub &'a NaiveDateTime); + +impl NaiveDateTimeWire<'_> { + #[must_use] + pub fn iso(self) -> String { + // `%Y-%m-%dT%H:%M:%S` matches Python's `datetime.isoformat()` + // for a naive datetime with no microseconds. + self.0.format("%Y-%m-%dT%H:%M:%S").to_string() + } +} + +/// Parse a user-supplied `--start` / `--end` value as a naive +/// datetime. Accepts the same handful of ISO-8601 shapes +/// `datetime.fromisoformat` accepts: with seconds, with optional +/// fractional seconds, and (best-effort) with a `Z` / offset suffix. +/// Returns a [`CliError::Configuration`] on parse failure so the +/// binary exits with the right code (8) and an obvious message. +pub fn parse_naive_datetime(value: &str) -> Result { + // The order matters: try the most specific patterns first so a + // string with fractional seconds isn't lossily matched by the + // seconds-only parser. + for fmt in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M:%S", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(value, fmt) { + return Ok(dt); + } + } + // RFC-3339 / Z-terminated strings: parse with offset and drop it. + // The Mergify API treats `start` / `end` as naive in the freeze's + // own timezone, so an offset on the input is misleading — but + // accepting the shape and ignoring the offset matches Python's + // `datetime.fromisoformat` (which also strips the offset into a + // naive value before we serialize it back out). + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) { + return Ok(dt.naive_local()); + } + Err(CliError::Configuration(format!( + "invalid datetime format: {value:?}. \ + Use ISO 8601 format (e.g. 2024-06-19T08:00:00)" + ))) +} + +/// Detect the system's IANA timezone (e.g. `Europe/Paris`). Used +/// as the default for `--timezone` on `freeze create` when the +/// user doesn't pass one — matches Python's +/// `tzlocal.get_localzone_name()` call. Returns a +/// [`CliError::Configuration`] when detection fails so the user +/// gets a clear "pass `--timezone` explicitly" message instead of +/// a panic. +pub fn detect_local_timezone() -> Result { + iana_time_zone::get_timezone().map_err(|_| { + CliError::Configuration( + "Could not detect system timezone. Please specify --timezone explicitly.".to_string(), + ) + }) +} + +/// Emit the per-freeze human block. Format mirrors Python's +/// `_print_freeze`: each label padded to the same column, missing +/// values rendered as `-`. Writes to the caller's +/// [`std::io::Write`] sink so `create` and `update` can stitch the +/// "Freeze … successfully:" banner and the body into a single +/// [`Output::emit`] call. +pub fn write_freeze(w: &mut dyn Write, freeze: &ScheduledFreeze) -> std::io::Result<()> { + let timezone = freeze.timezone.as_deref().unwrap_or(""); + writeln!(w, " ID: {}", freeze.id.as_deref().unwrap_or("-"))?; + writeln!( + w, + " Reason: {}", + freeze.reason.as_deref().unwrap_or("-"), + )?; + writeln!( + w, + " Start: {}", + format_datetime(freeze.start.as_deref(), timezone), + )?; + writeln!( + w, + " End: {}", + format_datetime(freeze.end.as_deref(), timezone), + )?; + let conditions = freeze.matching_conditions.join(", "); + writeln!(w, " Conditions: {conditions}")?; + if !freeze.exclude_conditions.is_empty() { + let excludes = freeze.exclude_conditions.join(", "); + writeln!(w, " Exclude: {excludes}")?; + } + Ok(()) +} + +/// Render an ISO timestamp + IANA timezone tuple the way Python +/// formats them in the `_print_freeze` output: `" ()"` +/// when both are present, `"-"` when the value is missing. +#[must_use] +pub fn format_datetime(value: Option<&str>, timezone: &str) -> String { + match value.filter(|s| !s.is_empty()) { + None => "-".to_string(), + Some(v) => format!("{v} ({timezone})"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_naive_datetime_basic_iso() { + let dt = parse_naive_datetime("2026-05-19T10:30:00").unwrap(); + assert_eq!( + dt.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-05-19 10:30:00" + ); + } + + #[test] + fn parse_naive_datetime_with_fractional_seconds() { + let dt = parse_naive_datetime("2026-05-19T10:30:00.123456").unwrap(); + assert_eq!( + dt.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-05-19 10:30:00" + ); + } + + #[test] + fn parse_naive_datetime_space_separator() { + // Python's `fromisoformat` accepts a space between date and + // time as a relaxed alternative to `T`. Mirror that. + let dt = parse_naive_datetime("2026-05-19 10:30:00").unwrap(); + assert_eq!( + dt.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-05-19 10:30:00" + ); + } + + #[test] + fn parse_naive_datetime_rfc3339_drops_offset() { + // The Mergify API treats `start` as naive in the freeze's + // own timezone — accepting an offset is for Python parity, + // but the offset is dropped so the round-trip keeps the + // wall-clock value the user typed. + let dt = parse_naive_datetime("2026-05-19T10:30:00Z").unwrap(); + assert_eq!( + dt.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-05-19 10:30:00" + ); + } + + #[test] + fn parse_naive_datetime_rejects_garbage() { + let err = parse_naive_datetime("not a date").unwrap_err(); + assert!(matches!(err, CliError::Configuration(_))); + assert!(err.to_string().contains("invalid datetime format")); + } + + #[test] + fn naive_wire_round_trip_drops_microseconds() { + // Match Python's `isoformat()` on a microsecond-bearing dt: + // we still emit seconds-only because the API expects that + // shape. + let dt = parse_naive_datetime("2026-05-19T10:30:00.123456").unwrap(); + assert_eq!(NaiveDateTimeWire(&dt).iso(), "2026-05-19T10:30:00"); + } + + #[test] + fn format_datetime_missing_renders_dash() { + assert_eq!(format_datetime(None, "UTC"), "-"); + assert_eq!(format_datetime(Some(""), "UTC"), "-"); + } + + #[test] + fn format_datetime_appends_timezone() { + assert_eq!( + format_datetime(Some("2026-01-01T10:00:00"), "Europe/Paris"), + "2026-01-01T10:00:00 (Europe/Paris)", + ); + } + + #[test] + fn detect_local_timezone_returns_a_value() { + // We can't assert a specific timezone (varies by environment), + // but we can assert that detection doesn't fail outright in + // a normal dev / CI environment. If this fires in a sandbox + // that masks `TZ`, the `iana-time-zone` crate falls back to + // `/etc/localtime` on Unix — both routes work in CI. + let tz = detect_local_timezone().expect("local timezone detectable"); + assert!(!tz.is_empty()); + } +} diff --git a/crates/mergify-freeze/src/create.rs b/crates/mergify-freeze/src/create.rs new file mode 100644 index 00000000..bf29a653 --- /dev/null +++ b/crates/mergify-freeze/src/create.rs @@ -0,0 +1,280 @@ +//! `mergify freeze create` — schedule a new freeze. +//! +//! `POST /v1/repos//scheduled_freeze`. Payload mirrors +//! Python's `CreateScheduledFreezePayload` exactly: `reason`, +//! `start`, `end`, `timezone` always present (with `null` for an +//! open-ended emergency freeze), `matching_conditions` and +//! `exclude_conditions` only included when non-empty. On success +//! the server echoes the freeze body, which we render through the +//! shared [`print_freeze`](crate::common::print_freeze) helper. + +use std::io::Write; + +use chrono::NaiveDateTime; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use serde::Serialize; + +use crate::common::NaiveDateTimeWire; +use crate::common::ScheduledFreeze; +use crate::common::detect_local_timezone; +use crate::common::write_freeze; + +pub struct CreateOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub reason: &'a str, + /// IANA timezone. When `None`, defaults to the system timezone + /// (errors out if undetectable). + pub timezone: Option<&'a str>, + pub start: Option, + pub end: Option, + pub matching_conditions: &'a [String], + pub exclude_conditions: &'a [String], +} + +#[derive(Serialize)] +struct CreatePayload<'a> { + reason: &'a str, + // `Option` rather than `&str` because the API expects + // `null` for missing values (matches Python's + // `start.isoformat() if start is not None else None`), and + // serde flattens `Option::None` to `null` cleanly. Keep these + // owned to avoid juggling lifetimes on the formatted string. + start: Option, + end: Option, + timezone: String, + #[serde(skip_serializing_if = "Option::is_none")] + matching_conditions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + exclude_conditions: Option>, +} + +/// Run the `freeze create` command. +pub async fn run(opts: CreateOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + let timezone = match opts.timezone { + Some(tz) => tz.to_string(), + None => detect_local_timezone()?, + }; + + let payload = CreatePayload { + reason: opts.reason, + start: opts.start.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), + end: opts.end.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), + timezone, + // Python passes the list when the user supplied any + // matching conditions, and `None` (omitting the key) when + // they didn't. Mirror that — the API may interpret a + // present-but-empty list differently from an absent key. + matching_conditions: if opts.matching_conditions.is_empty() { + None + } else { + Some(opts.matching_conditions.to_vec()) + }, + exclude_conditions: if opts.exclude_conditions.is_empty() { + None + } else { + Some(opts.exclude_conditions.to_vec()) + }, + }; + + output.status(&format!("Creating scheduled freeze for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!("/v1/repos/{repository}/scheduled_freeze"); + let freeze: ScheduledFreeze = client.post(&path, &payload).await?; + + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Freeze created successfully:")?; + write_freeze(w, &freeze) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_partial_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + use crate::common::parse_naive_datetime; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn run_posts_payload_with_optional_conditions_when_provided() { + let server = MockServer::start().await; + let start = parse_naive_datetime("2099-01-01T00:00:00").unwrap(); + let end = parse_naive_datetime("2099-01-02T00:00:00").unwrap(); + + Mock::given(method("POST")) + .and(path("/v1/repos/owner/repo/scheduled_freeze")) + .and(header("Authorization", "Bearer t")) + // Use `body_partial_json` so unrelated fields don't make + // the matcher brittle — but include the conditions key + // explicitly to assert it's serialized (the Python + // `if matching_conditions is not None:` branch). + .and(body_partial_json(json!({ + "reason": "emergency-fix", + "start": "2099-01-01T00:00:00", + "end": "2099-01-02T00:00:00", + "timezone": "UTC", + "matching_conditions": ["base=main"], + "exclude_conditions": ["label=hotfix"], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "11111111-2222-3333-4444-555555555555", + "reason": "emergency-fix", + "start": "2099-01-01T00:00:00", + "end": "2099-01-02T00:00:00", + "timezone": "UTC", + "matching_conditions": ["base=main"], + "exclude_conditions": ["label=hotfix"], + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + let matching = ["base=main".to_string()]; + let exclude = ["label=hotfix".to_string()]; + run( + CreateOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + reason: "emergency-fix", + timezone: Some("UTC"), + start: Some(start), + end: Some(end), + matching_conditions: &matching, + exclude_conditions: &exclude, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!( + stdout.contains("Freeze created successfully"), + "got: {stdout}" + ); + assert!( + stdout.contains("11111111-2222-3333-4444-555555555555"), + "got: {stdout}" + ); + assert!(stdout.contains("emergency-fix"), "got: {stdout}"); + } + + #[tokio::test] + async fn run_omits_conditions_keys_when_empty() { + // Python's API client omits `matching_conditions` / + // `exclude_conditions` when the user passes no `-c` / `-e` + // flags. The Mergify API may treat absent-vs-empty + // differently, so the Rust port must keep the same wire + // shape. + let server = MockServer::start().await; + + // Match the request body, capture the raw bytes via a fixed + // response, then read them back through `received_requests`. + Mock::given(method("POST")) + .and(path("/v1/repos/owner/repo/scheduled_freeze")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "abc", + "reason": "no-conditions", + "start": null, + "end": null, + "timezone": "UTC", + "matching_conditions": [], + "exclude_conditions": [], + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + let empty: [String; 0] = []; + run( + CreateOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + reason: "no-conditions", + timezone: Some("UTC"), + start: None, + end: None, + matching_conditions: &empty, + exclude_conditions: &empty, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let map = body.as_object().unwrap(); + assert!( + !map.contains_key("matching_conditions"), + "matching_conditions key must be omitted when empty, got body: {body}" + ); + assert!( + !map.contains_key("exclude_conditions"), + "exclude_conditions key must be omitted when empty, got body: {body}" + ); + // `start` / `end` are always present (with `null` for + // open-ended freezes) — match Python's + // `CreateScheduledFreezePayload` total fields. + assert!(map.contains_key("start")); + assert!(map.contains_key("end")); + assert!(map["start"].is_null()); + assert!(map["end"].is_null()); + } +} diff --git a/crates/mergify-freeze/src/delete.rs b/crates/mergify-freeze/src/delete.rs new file mode 100644 index 00000000..08519b1d --- /dev/null +++ b/crates/mergify-freeze/src/delete.rs @@ -0,0 +1,187 @@ +//! `mergify freeze delete` — remove a scheduled freeze. +//! +//! `POST /v1/repos//scheduled_freeze//delete`. The +//! endpoint is `POST … /delete` (not a `DELETE` verb) because +//! deleting an *active* freeze requires an audit reason — the +//! request body carries `{"delete_reason": ""}`. We mirror +//! Python's payload shape: include `delete_reason` only when the +//! user provided one, otherwise send an empty `{}` (no key). + +use std::io::Write; + +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use serde::Serialize; + +pub struct DeleteOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub freeze_id: &'a str, + /// Required by the API when the target freeze is active; the + /// CLI doesn't validate ahead of time and lets the server + /// reject a missing reason for an active freeze with its own + /// 4xx + message. + pub delete_reason: Option<&'a str>, +} + +#[derive(Serialize, Default)] +struct DeletePayload<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + delete_reason: Option<&'a str>, +} + +/// Run the `freeze delete` command. +pub async fn run(opts: DeleteOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + output.status(&format!( + "Deleting scheduled freeze {id} on {repository}…", + id = opts.freeze_id, + ))?; + + let payload = DeletePayload { + delete_reason: opts.delete_reason, + }; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!( + "/v1/repos/{repository}/scheduled_freeze/{id}/delete", + id = opts.freeze_id, + ); + client.post_no_response(&path, &payload).await?; + + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Freeze deleted successfully.") + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn run_posts_empty_body_when_no_reason_provided() { + let server = MockServer::start().await; + let freeze_id = "abc-123"; + Mock::given(method("POST")) + .and(path(format!( + "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}/delete" + ))) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + DeleteOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + freeze_id, + delete_reason: None, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let map = body.as_object().unwrap(); + // Active vs inactive: an empty body is the right shape when + // the freeze isn't active. The server decides whether to + // require the key. + assert!(map.is_empty(), "expected `{{}}` body, got {body}"); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!( + stdout.contains("Freeze deleted successfully"), + "got: {stdout}" + ); + } + + #[tokio::test] + async fn run_includes_delete_reason_when_provided() { + let server = MockServer::start().await; + let freeze_id = "abc-123"; + Mock::given(method("POST")) + .and(path(format!( + "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}/delete" + ))) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + DeleteOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + freeze_id, + delete_reason: Some("audit-trail"), + }, + &mut cap.output, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!( + body.get("delete_reason").and_then(|v| v.as_str()), + Some("audit-trail"), + "got body: {body}" + ); + } +} diff --git a/crates/mergify-freeze/src/lib.rs b/crates/mergify-freeze/src/lib.rs index 6e9bd8c7..ffc0466a 100644 --- a/crates/mergify-freeze/src/lib.rs +++ b/crates/mergify-freeze/src/lib.rs @@ -1,9 +1,13 @@ //! Native Rust implementation of the `mergify freeze` subcommands. //! -//! `freeze list` is the first port — a read-only `GET` on -//! `/v1/repos//scheduled_freeze` with either a JSON -//! passthrough of the inner `scheduled_freezes` array or a -//! human-readable table. `create` / `update` / `delete` follow -//! the same module-per-subcommand layout once they land. +//! Each freeze subcommand owns a module. `list` is a read-only GET; +//! `create`/`update`/`delete` mutate the `/v1/repos//scheduled_freeze` +//! resource and share a small block of helpers in [`common`] — +//! "print one freeze", naive-datetime parsing, system-timezone +//! detection. +pub mod common; +pub mod create; +pub mod delete; pub mod list; +pub mod update; diff --git a/crates/mergify-freeze/src/list.rs b/crates/mergify-freeze/src/list.rs index 3b0502b0..e205a5e2 100644 --- a/crates/mergify-freeze/src/list.rs +++ b/crates/mergify-freeze/src/list.rs @@ -18,7 +18,6 @@ use std::io::Write; use anstyle::AnsiColor; use chrono::DateTime; -use chrono::NaiveDateTime; use chrono::Utc; use mergify_core::ApiFlavor; use mergify_core::CliError; @@ -26,7 +25,10 @@ use mergify_core::HttpClient; use mergify_core::Output; use mergify_core::auth; use mergify_tui::Theme; -use serde::Deserialize; + +use crate::common::ScheduledFreeze; +use crate::common::format_datetime; +use crate::common::parse_naive_datetime; pub struct ListOptions<'a> { pub repository: Option<&'a str>, @@ -35,24 +37,6 @@ pub struct ListOptions<'a> { pub output_json: bool, } -#[derive(Deserialize)] -struct FreezeView { - #[serde(default)] - id: Option, - #[serde(default)] - reason: Option, - #[serde(default)] - start: Option, - #[serde(default)] - end: Option, - #[serde(default)] - timezone: Option, - #[serde(default)] - matching_conditions: Vec, - #[serde(default)] - exclude_conditions: Vec, -} - /// Run the `freeze list` command. pub async fn run(opts: ListOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { let repository = auth::resolve_repository(opts.repository)?; @@ -79,7 +63,7 @@ pub async fn run(opts: ListOptions<'_>, output: &mut dyn Output) -> Result<(), C return Ok(()); } - let views: Vec = serde_json::from_value(freezes) + let views: Vec = serde_json::from_value(freezes) .map_err(|e| CliError::Generic(format!("decode scheduled freezes response: {e}")))?; emit_human(output, &views, Utc::now())?; Ok(()) @@ -95,7 +79,7 @@ fn emit_json(output: &mut dyn Output, value: &serde_json::Value) -> std::io::Res fn emit_human( output: &mut dyn Output, - freezes: &[FreezeView], + freezes: &[ScheduledFreeze], now: DateTime, ) -> std::io::Result<()> { let theme = Theme::detect(); @@ -113,7 +97,7 @@ const HEADERS: [&str; 6] = ["ID", "Reason", "Start", "End", "Conditions", "Statu fn render_table( w: &mut dyn Write, theme: &Theme, - freezes: &[FreezeView], + freezes: &[ScheduledFreeze], now: DateTime, ) -> std::io::Result<()> { writeln!( @@ -136,7 +120,7 @@ fn render_table( Ok(()) } -fn row_for(freeze: &FreezeView, now: DateTime) -> [String; 6] { +fn row_for(freeze: &ScheduledFreeze, now: DateTime) -> [String; 6] { let id = freeze.id.clone().unwrap_or_default(); let reason = freeze.reason.clone().unwrap_or_default(); let timezone = freeze.timezone.as_deref().unwrap_or(""); @@ -151,17 +135,6 @@ fn row_for(freeze: &FreezeView, now: DateTime) -> [String; 6] { [id, reason, start, end, conditions, status] } -/// Format `value` as `" ()"`. Mirrors Python's -/// `_format_datetime`: returns `"-"` for missing/empty values so the -/// table reads cleanly when the API omits an end time (open-ended -/// emergency freeze). -fn format_datetime(value: Option<&str>, timezone: &str) -> String { - match value.filter(|s| !s.is_empty()) { - None => "-".to_string(), - Some(v) => format!("{v} ({timezone})"), - } -} - /// Build the Conditions cell: matching conditions joined with `, `, /// followed by `(exclude: …)` when any exclude conditions are set. /// Same formatting as Python's `_print_freeze_table`. @@ -179,36 +152,20 @@ fn format_conditions(matching: &[String], exclude: &[String]) -> String { } /// Best-effort active flag — see module docs for the timezone caveat -/// (same one the Python implementation acknowledges). +/// (same one the Python implementation acknowledges). Delegates the +/// ISO-8601 parse to [`parse_naive_datetime`] so the CLI-input parser +/// and the API-response parser stay locked. A malformed `start` falls +/// through to "scheduled" instead of aborting the render. fn is_active(start: Option<&str>, now: DateTime) -> bool { let Some(start) = start else { return false; }; - let Some(naive_start) = parse_iso_naive(start) else { + let Ok(naive_start) = parse_naive_datetime(start) else { return false; }; naive_start <= now.naive_utc() } -/// Parse an ISO-8601 datetime, ignoring any timezone offset so the -/// comparison stays naive (matches Python's -/// `datetime.fromisoformat(start)` on a naive-or-aware string — -/// we treat both as the same wall-clock instant). Returns `None` on -/// parse failure rather than panicking, so a malformed `start` falls -/// through to "scheduled" instead of aborting the render. -fn parse_iso_naive(value: &str) -> Option { - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") { - return Some(dt); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") { - return Some(dt); - } - if let Ok(dt) = DateTime::parse_from_rfc3339(value) { - return Some(dt.naive_utc()); - } - None -} - fn column_widths(rows: &[[String; 6]]) -> [usize; 6] { let mut widths = HEADERS.map(str::len); for row in rows { @@ -537,20 +494,6 @@ mod tests { assert!(!is_active(Some("not a date"), now)); } - #[test] - fn format_datetime_missing_renders_dash() { - assert_eq!(format_datetime(None, "UTC"), "-"); - assert_eq!(format_datetime(Some(""), "UTC"), "-"); - } - - #[test] - fn format_datetime_appends_timezone() { - assert_eq!( - format_datetime(Some("2026-01-01T10:00:00"), "Europe/Paris"), - "2026-01-01T10:00:00 (Europe/Paris)", - ); - } - #[test] fn format_conditions_matching_only() { let m = vec!["base=main".to_string(), "label=ready".to_string()]; diff --git a/crates/mergify-freeze/src/update.rs b/crates/mergify-freeze/src/update.rs new file mode 100644 index 00000000..24878212 --- /dev/null +++ b/crates/mergify-freeze/src/update.rs @@ -0,0 +1,262 @@ +//! `mergify freeze update` — modify an existing scheduled freeze. +//! +//! `PATCH /v1/repos//scheduled_freeze/`. The payload +//! includes only the fields the user actually changed (Python's +//! `UpdateScheduledFreezePayload` is a `TypedDict` whose entries +//! are conditionally inserted) — we replicate that with +//! `skip_serializing_if = "Option::is_none"` so unspecified fields +//! stay absent on the wire rather than being sent as `null`. + +use std::io::Write; + +use chrono::NaiveDateTime; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use serde::Serialize; + +use crate::common::NaiveDateTimeWire; +use crate::common::ScheduledFreeze; +use crate::common::write_freeze; + +pub struct UpdateOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub freeze_id: &'a str, + pub reason: Option<&'a str>, + pub timezone: Option<&'a str>, + pub start: Option, + pub end: Option, + /// `Some(empty slice)` is treated the same as `None` — matches + /// the Python behavior where `matching_conditions` is only + /// included when the user passed `-c` at least once. + pub matching_conditions: Option<&'a [String]>, + pub exclude_conditions: Option<&'a [String]>, +} + +#[derive(Serialize, Default)] +struct UpdatePayload<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + timezone: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + matching_conditions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + exclude_conditions: Option>, +} + +/// Run the `freeze update` command. +pub async fn run(opts: UpdateOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + let payload = UpdatePayload { + reason: opts.reason, + timezone: opts.timezone, + start: opts.start.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), + end: opts.end.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), + matching_conditions: opts.matching_conditions.map(<[String]>::to_vec), + exclude_conditions: opts.exclude_conditions.map(<[String]>::to_vec), + }; + + output.status(&format!( + "Updating scheduled freeze {id} on {repository}…", + id = opts.freeze_id, + ))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!( + "/v1/repos/{repository}/scheduled_freeze/{id}", + id = opts.freeze_id, + ); + let freeze: ScheduledFreeze = client.patch(&path, &payload).await?; + + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Freeze updated successfully:")?; + write_freeze(w, &freeze) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn run_patches_only_user_supplied_fields() { + // The whole point of PATCH semantics: only the fields the + // user changed go on the wire. Mirrors Python's "if + // is not None: payload[] = ..." chain in + // `update_freeze`. + let server = MockServer::start().await; + let freeze_id = "11111111-2222-3333-4444-555555555555"; + Mock::given(method("PATCH")) + .and(path(format!( + "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}" + ))) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": freeze_id, + "reason": "new-reason", + "start": "2099-01-01T00:00:00", + "end": null, + "timezone": "UTC", + "matching_conditions": [], + "exclude_conditions": [], + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + UpdateOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + freeze_id, + reason: Some("new-reason"), + timezone: None, + start: None, + end: None, + matching_conditions: None, + exclude_conditions: None, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let map = body.as_object().unwrap(); + assert_eq!( + map.get("reason").and_then(|v| v.as_str()), + Some("new-reason") + ); + // Only `reason` was set — every other field must be absent + // from the request body so the server's PATCH semantics + // leave them untouched. + for absent in [ + "timezone", + "start", + "end", + "matching_conditions", + "exclude_conditions", + ] { + assert!( + !map.contains_key(absent), + "{absent} must be omitted, got body: {body}" + ); + } + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!( + stdout.contains("Freeze updated successfully"), + "got: {stdout}" + ); + assert!(stdout.contains("new-reason"), "got: {stdout}"); + } + + #[tokio::test] + async fn run_sends_empty_conditions_list_when_provided() { + // `Some(&[])` (explicit "clear the conditions list") is + // wire-different from `None` (don't touch). The Mergify API + // distinguishes them, and Python sends the empty list when + // the user passes the flag. Mirror that. + let server = MockServer::start().await; + let freeze_id = "abc"; + Mock::given(method("PATCH")) + .and(path(format!( + "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": freeze_id, + "reason": "r", + "start": null, + "end": null, + "timezone": "UTC", + "matching_conditions": [], + "exclude_conditions": [], + }))) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + let empty: [String; 0] = []; + run( + UpdateOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + freeze_id, + reason: None, + timezone: None, + start: None, + end: None, + matching_conditions: Some(&empty), + exclude_conditions: None, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let map = body.as_object().unwrap(); + assert!(map.contains_key("matching_conditions")); + assert_eq!(map["matching_conditions"], json!([])); + assert!(!map.contains_key("exclude_conditions")); + } +}