From c228d7088582f479d5c2253490ca665c3998f71a Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 12 May 2026 15:29:02 +0200 Subject: [PATCH 1/3] test(skill): port the skill-references test to Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ``mergify_cli/tests/queue/test_skill.py`` with a Rust integration test in ``crates/mergify-cli/tests/skill_references.rs``. The test validates two artifacts with no Python in the picture: - The ``skills/mergify-merge-queue/SKILL.md`` Markdown file (frontmatter shape, required sections). - The Rust binary's ``--list-native-commands`` output (every ``mergify queue `` reference in the skill must resolve to a native command). Keeping the test in pytest meant carrying Python plumbing for a language-agnostic concern. The Rust port: - spawns the binary via ``CARGO_BIN_EXE_mergify`` (the artifact ``cargo test`` just built), so the test always exercises the current code rather than whatever ``mergify`` happens to be on ``PATH``; - reads the skill file via ``CARGO_MANIFEST_DIR``-relative resolution, so it's robust to the cwd; - adds two dev-deps to ``mergify-cli`` (``regex`` for the frontmatter / reference patterns, ``serde_yaml_ng`` for frontmatter parsing — already used by ``mergify-ci``). The ``mergify_cli/tests/queue/`` directory had no other content, so it goes away entirely; the Python ``yaml`` dev-dep stays in place for now (still used elsewhere). 4 tests, same coverage as before: - ``skill_content_is_readable`` - ``skill_has_valid_frontmatter`` - ``skill_has_required_sections`` - ``skill_references_valid_commands`` Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I7eb5e3849dcb4219341be78173717ecd137f2d08 --- Cargo.lock | 2 + crates/mergify-cli/Cargo.toml | 4 + crates/mergify-cli/tests/skill_references.rs | 137 +++++++++++++++++++ mergify_cli/tests/queue/__init__.py | 0 mergify_cli/tests/queue/test_skill.py | 118 ---------------- 5 files changed, 143 insertions(+), 118 deletions(-) create mode 100644 crates/mergify-cli/tests/skill_references.rs delete mode 100644 mergify_cli/tests/queue/__init__.py delete mode 100644 mergify_cli/tests/queue/test_skill.py diff --git a/Cargo.lock b/Cargo.lock index 68fa00d7..f8eeff91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,6 +1083,8 @@ dependencies = [ "mergify-core", "mergify-py-shim", "mergify-queue", + "regex", + "serde_yaml_ng", "tokio", ] diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml index 7d28db2c..a3b26400 100644 --- a/crates/mergify-cli/Cargo.toml +++ b/crates/mergify-cli/Cargo.toml @@ -22,5 +22,9 @@ mergify-py-shim = { path = "../mergify-py-shim" } mergify-queue = { path = "../mergify-queue" } tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } +[dev-dependencies] +regex = "1" +serde_yaml_ng = "0.10" + [lints] workspace = true diff --git a/crates/mergify-cli/tests/skill_references.rs b/crates/mergify-cli/tests/skill_references.rs new file mode 100644 index 00000000..e0a8a2f7 --- /dev/null +++ b/crates/mergify-cli/tests/skill_references.rs @@ -0,0 +1,137 @@ +//! Cross-checks the `mergify-merge-queue` skill against the +//! freshly-built test binary. +//! +//! Replaces the pre-port Python `tests/queue/test_skill.py`: the +//! artifacts being validated (a Markdown skill file and the Rust +//! binary's `--list-native-commands` output) have no Python in +//! the picture, so the test lives next to the binary that emits +//! the truth. +//! +//! Each test fires the freshly-built binary via +//! `CARGO_BIN_EXE_mergify` — that's the same artifact `cargo test` +//! built moments earlier, so the test always exercises the +//! current code rather than whatever happens to be on `PATH`. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::process::Command; + +use regex::Regex; +use serde_yaml_ng::Value; + +const REQUIRED_SECTIONS: &[&str] = &[ + "## Commands", + "## Checking Queue Status", + "## Inspecting a PR", + "## Queue States", + "## Troubleshooting", +]; + +/// Resolve `skills/mergify-merge-queue/SKILL.md` from the +/// repository root. `CARGO_MANIFEST_DIR` points at this crate's +/// directory; two `..` hops up to the workspace root. +fn skill_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("skills") + .join("mergify-merge-queue") + .join("SKILL.md") +} + +fn skill_content() -> String { + let path = skill_path(); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +/// Ask the binary for its `(group, subcommand)` pairs and collect +/// the subcommands for `group`. Spawning the binary keeps the +/// test honest — a port that adds a native subcommand and its +/// `NATIVE_COMMANDS` entry shows up automatically, no parallel +/// list to drift. +fn native_commands_for_group(group: &str) -> BTreeSet { + let binary = env!("CARGO_BIN_EXE_mergify"); + let output = Command::new(binary) + .arg("--list-native-commands") + .output() + .unwrap_or_else(|e| panic!("spawn {binary} --list-native-commands: {e}")); + assert!( + output.status.success(), + "mergify --list-native-commands exited {:?}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + stdout + .lines() + .filter_map(|line| { + let (g, sub) = line.split_once(char::is_whitespace)?; + (g == group).then(|| sub.to_string()) + }) + .collect() +} + +#[test] +fn skill_content_is_readable() { + assert!(!skill_content().is_empty(), "SKILL.md must not be empty"); +} + +#[test] +fn skill_has_valid_frontmatter() { + let content = skill_content(); + // Extract YAML frontmatter between --- markers — the same + // shape Claude Code's skill loader expects. + let re = Regex::new(r"(?s)^---\n(.+?)\n---\n").expect("frontmatter regex compiles"); + let captures = re + .captures(&content) + .expect("Skill must have YAML frontmatter"); + let yaml = captures.get(1).unwrap().as_str(); + let parsed: Value = serde_yaml_ng::from_str(yaml).expect("frontmatter is valid YAML"); + let mapping = parsed + .as_mapping() + .expect("frontmatter must be a YAML mapping"); + let name = mapping + .get(Value::from("name")) + .and_then(Value::as_str) + .expect("frontmatter must have 'name'"); + assert_eq!(name, "mergify-merge-queue"); + assert!( + mapping.get(Value::from("description")).is_some(), + "frontmatter must have 'description'", + ); +} + +#[test] +fn skill_has_required_sections() { + let content = skill_content(); + for section in REQUIRED_SECTIONS { + assert!( + content.contains(section), + "Skill is missing required section: {section}", + ); + } +} + +#[test] +fn skill_references_valid_commands() { + let content = skill_content(); + let re = Regex::new(r"mergify queue ([\w-]+)").expect("reference regex compiles"); + // BTreeSet so iteration order — and therefore which assertion + // trips first — is deterministic. Same for `available` below: + // its `Debug` output ends up in the failure message and would + // otherwise reshuffle between runs. + let referenced: BTreeSet = re + .captures_iter(&content) + .map(|c| c[1].to_string()) + .collect(); + let available = native_commands_for_group("queue"); + + for cmd in &referenced { + assert!( + available.contains(cmd), + "Skill references 'mergify queue {cmd}' but it's not a Rust-native \ + command reported by `mergify --list-native-commands`. \ + Available: {available:?}", + ); + } +} diff --git a/mergify_cli/tests/queue/__init__.py b/mergify_cli/tests/queue/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/mergify_cli/tests/queue/test_skill.py b/mergify_cli/tests/queue/test_skill.py deleted file mode 100644 index f233b0db..00000000 --- a/mergify_cli/tests/queue/test_skill.py +++ /dev/null @@ -1,118 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import pathlib -import re -import shutil -import subprocess - -import yaml - - -SKILL_PATH = ( - pathlib.Path(__file__).parents[3] / "skills" / "mergify-merge-queue" / "SKILL.md" -) - - -def _get_skill_content() -> str: - return SKILL_PATH.read_text(encoding="utf-8") - - -def _native_commands_for_group(group: str) -> set[str]: - """Ask the installed `mergify` binary which ` ` pairs - it handles natively, then return the subcommands for `group`. - - Spawning the binary keeps this test honest: the source of truth - is the binary itself, so a port that adds a native subcommand - (and its `NATIVE_COMMANDS` entry) automatically becomes visible - here. No parallel hard-coded list to drift. - """ - # Fail (don't skip) when `mergify` isn't on PATH: the wheel must - # be installed for the test to be meaningful, and silently - # skipping would let a regression in the wheel's bin scripts - # slip through unnoticed. `uv run pytest` installs the wheel - # before invoking pytest, so on every supported entry point the - # binary is present. - binary = shutil.which("mergify") - assert binary is not None, ( - "`mergify` binary not on PATH. Install the wheel first " - "(`uv run pytest` does this automatically) — running this " - "test against an uninstalled checkout would give a false pass." - ) - out = subprocess.run( - [binary, "--list-native-commands"], - check=True, - capture_output=True, - text=True, - ).stdout - pairs = (line.split(maxsplit=1) for line in out.splitlines() if line.strip()) - return { - sub - for pair in pairs - if len(pair) == 2 and pair[0] == group - for sub in [pair[1]] - } - - -def test_skill_content_is_readable() -> None: - content = _get_skill_content() - assert len(content) > 0 - - -def test_skill_has_valid_frontmatter() -> None: - content = _get_skill_content() - # Extract YAML frontmatter between --- markers - match = re.match(r"^---\n(.+?)\n---\n", content, re.DOTALL) - assert match is not None, "Skill must have YAML frontmatter" - - frontmatter = yaml.safe_load(match.group(1)) - assert isinstance(frontmatter, dict), "Frontmatter must be a YAML mapping" - assert "name" in frontmatter, "Frontmatter must have 'name'" - assert "description" in frontmatter, "Frontmatter must have 'description'" - assert frontmatter["name"] == "mergify-merge-queue" - - -REQUIRED_SECTIONS = [ - "## Commands", - "## Checking Queue Status", - "## Inspecting a PR", - "## Queue States", - "## Troubleshooting", -] - - -def test_skill_has_required_sections() -> None: - content = _get_skill_content() - for section in REQUIRED_SECTIONS: - assert section in content, f"Skill is missing required section: {section}" - - -def test_skill_references_valid_commands() -> None: - """Every `mergify queue ` reference in the skill must resolve - to a Rust-native command reported by the binary. The whole - `queue` group has been ported, so the binary is the only source - of truth — no parallel click-command list to consult. - """ - content = _get_skill_content() - referenced = set(re.findall(r"mergify queue ([\w-]+)", content)) - available = _native_commands_for_group("queue") - - for cmd in referenced: - assert cmd in available, ( - f"Skill references 'mergify queue {cmd}' but it's not a " - f"Rust-native command reported by `mergify --list-native-commands`. " - f"Available: {sorted(available)}" - ) From 331118f7a7fdd4bb07e2b05e4006d7f6f4be0ffa Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 19 May 2026 09:59:38 +0200 Subject: [PATCH 2/3] test(freeze): add live smoke test for freeze list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the URL + auth + JSON-array shape of `freeze list --json` against the real Mergify API before the upcoming Python → Rust port. The Python `list_cmd` returns the inner `scheduled_freezes` array verbatim, so the test asserts that the `--json` output parses as a JSON array — same contract we want preserved across both ends of the port. Uses `live_admin_token` because scheduled-freeze endpoints sit under the queue-management family and the CI-scoped token is rejected with 403. Group-level options (`--token` / `--api-url` / `--repository`) come before the subcommand — Click requires it on the Python side, clap accepts both orders. Co-Authored-By: Claude Opus 4.7 Change-Id: I51b653702c10e184daff5f450f1edc4f3c581433 --- func-tests/test_live_smoke.py | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py index 081810d8..b7f8e5d3 100644 --- a/func-tests/test_live_smoke.py +++ b/func-tests/test_live_smoke.py @@ -196,6 +196,56 @@ def test_queue_show_not_in_queue( ) +def test_freeze_list( + live_admin_token: str, + cli: typing.Callable[..., typing.Any], +) -> None: + """`GET /v1/repos/{owner}/{repo}/scheduled_freeze`. + + Uses the admin-scoped token because scheduled-freeze endpoints + sit under the queue-management family; the CI-scoped token is + rejected with 403. + + ``--json`` mode is a passthrough of the inner + ``scheduled_freezes`` array (Python's ``list_freezes`` returns + ``data["scheduled_freezes"]``, the CLI prints that verbatim). + The smoke test only checks the call succeeds and parses as a + JSON array — the contract preserved across the Python → Rust + port is the URL, the auth, and the array shape of the + ``--json`` output. + """ + import json + + # Group-level options (``--token`` / ``--api-url`` / + # ``--repository``) come BEFORE the subcommand — Click requires + # it on the Python side (options live on ``@freeze``), Rust + # accepts both via clap's ``global = true``. + result = cli( + "freeze", + "--api-url", + API_URL, + "--token", + live_admin_token, + "--repository", + REPOSITORY, + "list", + "--json", + ) + assert result.returncode == 0, ( + f"freeze list failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + pytest.fail( + f"freeze list --json emitted non-JSON output\n" + f"error: {exc}\nstdout:\n{result.stdout}", + ) + assert isinstance(payload, list), ( + f"freeze list --json must emit a JSON array\nstdout:\n{result.stdout}" + ) + + def test_ci_git_refs_fallback( cli: typing.Callable[..., typing.Any], ) -> None: From e92fa132babc068525bab06da1f5bdc52b961a23 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 19 May 2026 10:07:18 +0200 Subject: [PATCH 3/3] feat(rust): port freeze list to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mergify freeze list` is now handled by the Rust binary: a single `GET /v1/repos//scheduled_freeze` with `--json` passthrough of the inner `scheduled_freezes` array or a human-readable table (ID / Reason / Start / End / Conditions / Status). The active-vs-scheduled flag is best-effort against UTC `now` — same approximation as Python's `_is_active`, with the same wrong-timezone caveat. New crate `mergify-freeze` mirrors the per-group layout used by `mergify-queue` and `mergify-ci`. Wired into the CLI via a new `freeze` clap group with global `--token` / `--api-url` / `--repository` options. Non-ported subcommands (`create` / `update` / `delete`) continue to fall through to the Python shim — they are not in `NATIVE_COMMANDS`, so `looks_native` rejects them and the fallback path runs unchanged. CRUD ports follow. Co-Authored-By: Claude Opus 4.7 Change-Id: I40ca436a13dde8b5d725ef2fc023d35f7b66340e --- Cargo.lock | 15 + crates/mergify-cli/Cargo.toml | 1 + crates/mergify-cli/src/main.rs | 70 ++++ crates/mergify-freeze/Cargo.toml | 25 ++ crates/mergify-freeze/src/lib.rs | 9 + crates/mergify-freeze/src/list.rs | 578 ++++++++++++++++++++++++++++++ 6 files changed, 698 insertions(+) create mode 100644 crates/mergify-freeze/Cargo.toml create mode 100644 crates/mergify-freeze/src/lib.rs create mode 100644 crates/mergify-freeze/src/list.rs diff --git a/Cargo.lock b/Cargo.lock index f8eeff91..76dc6061 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,7 @@ dependencies = [ "mergify-ci", "mergify-config", "mergify-core", + "mergify-freeze", "mergify-py-shim", "mergify-queue", "regex", @@ -1118,6 +1119,20 @@ dependencies = [ "wiremock", ] +[[package]] +name = "mergify-freeze" +version = "0.0.0" +dependencies = [ + "anstyle", + "chrono", + "mergify-core", + "mergify-tui", + "serde", + "serde_json", + "tokio", + "wiremock", +] + [[package]] name = "mergify-py-shim" version = "0.0.0" diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml index a3b26400..38d41a5d 100644 --- a/crates/mergify-cli/Cargo.toml +++ b/crates/mergify-cli/Cargo.toml @@ -18,6 +18,7 @@ clap = { version = "4.5", features = ["derive"] } mergify-ci = { path = "../mergify-ci" } mergify-config = { path = "../mergify-config" } mergify-core = { path = "../mergify-core" } +mergify-freeze = { path = "../mergify-freeze" } mergify-py-shim = { path = "../mergify-py-shim" } mergify-queue = { path = "../mergify-queue" } tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index a1776392..b3ae5ff6 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -26,6 +26,7 @@ use mergify_config::simulate::PullRequestRef; use mergify_config::simulate::SimulateOptions; use mergify_core::OutputMode; use mergify_core::StdioOutput; +use mergify_freeze::list::ListOptions as FreezeListOptions; use mergify_queue::pause::PauseOptions; use mergify_queue::show::ShowOptions; use mergify_queue::status::StatusOptions; @@ -91,6 +92,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("queue", "unpause"), ("queue", "status"), ("queue", "show"), + ("freeze", "list"), ]; /// Native commands the Rust binary handles without delegating to @@ -105,6 +107,7 @@ enum NativeCommand { QueueUnpause(QueueUnpauseOpts), QueueStatus(QueueStatusOpts), QueueShow(QueueShowOpts), + FreezeList(FreezeListOpts), } struct ConfigSimulateOpts { @@ -156,6 +159,13 @@ struct QueueShowOpts { output_json: bool, } +struct FreezeListOpts { + repository: Option, + token: Option, + api_url: Option, + output_json: bool, +} + /// Heuristic: does argv look like the user intended a native /// subcommand? /// @@ -334,6 +344,17 @@ fn detect_native(argv: &[String]) -> Option { verbose, output_json: json, })), + Subcommands::Freeze(FreezeArgs { + repository, + token, + api_url, + command: FreezeSubcommand::List(FreezeListCliArgs { json }), + }) => Some(NativeCommand::FreezeList(FreezeListOpts { + repository, + token, + api_url, + output_json: json, + })), } } @@ -440,6 +461,18 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await } + NativeCommand::FreezeList(opts) => { + mergify_freeze::list::run( + FreezeListOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + output_json: opts.output_json, + }, + &mut output, + ) + .await + } } }); @@ -469,6 +502,8 @@ enum Subcommands { Ci(CiArgs), /// Manage the Mergify merge queue. Queue(QueueArgs), + /// Manage scheduled freezes. + Freeze(FreezeArgs), } #[derive(clap::Args)] @@ -655,3 +690,38 @@ struct ShowCliArgs { #[arg(long, default_value_t = false)] json: bool, } + +#[derive(clap::Args)] +struct FreezeArgs { + /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and + /// then ``GITHUB_TOKEN`` env vars. + #[arg(long, short = 't', global = true)] + token: Option, + + /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, + /// then to the default. + #[arg(long = "api-url", short = 'u', global = true)] + api_url: Option, + + /// Repository full name (owner/repo). Falls back to + /// ``GITHUB_REPOSITORY`` env var. + #[arg(long, short = 'r', global = true)] + repository: Option, + + #[command(subcommand)] + command: FreezeSubcommand, +} + +#[derive(Subcommand)] +enum FreezeSubcommand { + /// List scheduled freezes for a repository. + List(FreezeListCliArgs), +} + +#[derive(clap::Args)] +struct FreezeListCliArgs { + /// Emit the raw `scheduled_freezes` array as a single JSON + /// document. + #[arg(long, default_value_t = false)] + json: bool, +} diff --git a/crates/mergify-freeze/Cargo.toml b/crates/mergify-freeze/Cargo.toml new file mode 100644 index 00000000..899fc1cd --- /dev/null +++ b/crates/mergify-freeze/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mergify-freeze" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Native implementation of `mergify freeze` subcommands." +publish = false + +[dependencies] +mergify-core = { path = "../mergify-core" } +mergify-tui = { path = "../mergify-tui" } +anstyle = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } +wiremock = "0.6" + +[lints] +workspace = true diff --git a/crates/mergify-freeze/src/lib.rs b/crates/mergify-freeze/src/lib.rs new file mode 100644 index 00000000..6e9bd8c7 --- /dev/null +++ b/crates/mergify-freeze/src/lib.rs @@ -0,0 +1,9 @@ +//! 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. + +pub mod list; diff --git a/crates/mergify-freeze/src/list.rs b/crates/mergify-freeze/src/list.rs new file mode 100644 index 00000000..3b0502b0 --- /dev/null +++ b/crates/mergify-freeze/src/list.rs @@ -0,0 +1,578 @@ +//! `mergify freeze list` — list scheduled freezes for a repository. +//! +//! `GET /v1/repos//scheduled_freeze`. Two output modes: +//! +//! - `--json`: pretty-prints the inner `scheduled_freezes` array +//! verbatim. Mirrors Python's `list_cmd`, which returns +//! `response.json()["scheduled_freezes"]` and feeds it to +//! `json.dumps`. The schema is Mergify's API contract, not this +//! CLI's, so unknown fields survive the round trip. +//! - Human (default): a table with columns ID / Reason / Start / +//! End / Conditions / Status. The status column is a best-effort +//! active-vs-scheduled flag: the API returns `start` as a naive +//! timestamp in the freeze's own timezone, but we compare against +//! UTC `now` because we don't have the server's local clock. Same +//! approximation Python's `_is_active` makes. + +use std::io::Write; + +use anstyle::AnsiColor; +use chrono::DateTime; +use chrono::NaiveDateTime; +use chrono::Utc; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use mergify_tui::Theme; +use serde::Deserialize; + +pub struct ListOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + 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)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + output.status(&format!("Fetching scheduled freezes for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!("/v1/repos/{repository}/scheduled_freeze"); + let raw: serde_json::Value = client.get(&path).await?; + + // Python's `list_freezes` returns `data["scheduled_freezes"]` + // and the CLI prints that inner array verbatim. Treat a missing + // key as an empty list so a future server quirk doesn't 500 + // the renderer. + let freezes = raw + .get("scheduled_freezes") + .cloned() + .unwrap_or_else(|| serde_json::Value::Array(Vec::new())); + + if opts.output_json { + emit_json(output, &freezes)?; + return Ok(()); + } + + 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(()) +} + +fn emit_json(output: &mut dyn Output, value: &serde_json::Value) -> std::io::Result<()> { + output.emit(value, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string_pretty(value) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) +} + +fn emit_human( + output: &mut dyn Output, + freezes: &[FreezeView], + now: DateTime, +) -> std::io::Result<()> { + let theme = Theme::detect(); + output.emit(&(), &mut |w: &mut dyn Write| { + if freezes.is_empty() { + writeln!(w, "No scheduled freezes found.")?; + return Ok(()); + } + render_table(w, &theme, freezes, now) + }) +} + +const HEADERS: [&str; 6] = ["ID", "Reason", "Start", "End", "Conditions", "Status"]; + +fn render_table( + w: &mut dyn Write, + theme: &Theme, + freezes: &[FreezeView], + now: DateTime, +) -> std::io::Result<()> { + writeln!( + w, + "{B}Scheduled Freezes{R}", + B = theme.bold, + R = theme.reset + )?; + writeln!(w)?; + + let rows: Vec<[String; 6]> = freezes.iter().map(|f| row_for(f, now)).collect(); + + let widths = column_widths(&rows); + + write_row(w, theme, &HEADERS.map(String::from), &widths, true)?; + write_separator(w, &widths)?; + for row in &rows { + write_row(w, theme, row, &widths, false)?; + } + Ok(()) +} + +fn row_for(freeze: &FreezeView, 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(""); + let start = format_datetime(freeze.start.as_deref(), timezone); + let end = format_datetime(freeze.end.as_deref(), timezone); + let conditions = format_conditions(&freeze.matching_conditions, &freeze.exclude_conditions); + let status = if is_active(freeze.start.as_deref(), now) { + "active".to_string() + } else { + "scheduled".to_string() + }; + [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`. +fn format_conditions(matching: &[String], exclude: &[String]) -> String { + let mut out = matching.join(", "); + if !exclude.is_empty() { + if !out.is_empty() { + out.push(' '); + } + out.push_str("(exclude: "); + out.push_str(&exclude.join(", ")); + out.push(')'); + } + out +} + +/// Best-effort active flag — see module docs for the timezone caveat +/// (same one the Python implementation acknowledges). +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 { + 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 { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + widths +} + +fn write_row( + w: &mut dyn Write, + theme: &Theme, + row: &[String; 6], + widths: &[usize; 6], + header: bool, +) -> std::io::Result<()> { + for (i, cell) in row.iter().enumerate() { + if i > 0 { + write!(w, " ")?; + } + let pad = widths[i].saturating_sub(cell.chars().count()); + if header { + write!( + w, + "{B}{cell}{R}{spaces}", + B = theme.bold, + R = theme.reset, + spaces = " ".repeat(pad), + )?; + } else if HEADERS[i] == "Status" { + let style = if cell == "active" { + if theme.enabled { + theme.fg(AnsiColor::Green) + } else { + anstyle::Style::new() + } + } else if theme.enabled { + theme.fg(AnsiColor::Yellow) + } else { + anstyle::Style::new() + }; + write!( + w, + "{S}{cell}{R}{spaces}", + S = style, + R = theme.reset, + spaces = " ".repeat(pad), + )?; + } else { + write!(w, "{cell}{spaces}", spaces = " ".repeat(pad))?; + } + } + writeln!(w) +} + +fn write_separator(w: &mut dyn Write, widths: &[usize; 6]) -> std::io::Result<()> { + for (i, width) in widths.iter().enumerate() { + if i > 0 { + write!(w, " ")?; + } + write!(w, "{}", "─".repeat(*width))?; + } + writeln!(w) +} + +#[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(mode: OutputMode) -> 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( + mode, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn stdout_string(cap: &Captured) -> String { + String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap() + } + + 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(()) + } + } + + fn freeze_sample() -> serde_json::Value { + json!({ + "id": "11111111-2222-3333-4444-555555555555", + "reason": "emergency-fix", + "start": "2026-01-01T10:00:00", + "end": "2026-01-01T12:00:00", + "timezone": "Europe/Paris", + "matching_conditions": ["base=main"], + "exclude_conditions": ["label=hotfix"], + }) + } + + async fn arrange(server: &MockServer, body: serde_json::Value) { + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/scheduled_freeze")) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(server) + .await; + } + + #[tokio::test] + async fn run_json_passthrough_emits_inner_array() { + // Python's `list_freezes` returns `data["scheduled_freezes"]` + // — the inner array, not the wrapping object. The Rust port + // must preserve that contract: `--json` mode prints exactly + // the array, including any unknown fields on the freeze + // objects. + let server = MockServer::start().await; + let mut freeze = freeze_sample(); + freeze["future_field"] = json!("preserved"); + let body = json!({"scheduled_freezes": [freeze.clone()]}); + arrange(&server, body).await; + + let mut cap = make_output(OutputMode::Json); + let api_url = server.uri(); + run( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + output_json: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed, json!([freeze])); + } + + #[tokio::test] + async fn run_json_passthrough_empty_array() { + // Server returns an empty list — JSON mode must still emit + // `[]` (not `null`, not the wrapping object). + let server = MockServer::start().await; + arrange(&server, json!({"scheduled_freezes": []})).await; + + let mut cap = make_output(OutputMode::Json); + let api_url = server.uri(); + run( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + output_json: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed, json!([])); + } + + #[tokio::test] + async fn run_human_renders_empty_message() { + let server = MockServer::start().await; + arrange(&server, json!({"scheduled_freezes": []})).await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!( + stdout.contains("No scheduled freezes found"), + "got: {stdout:?}" + ); + } + + #[tokio::test] + async fn run_human_renders_table_with_columns() { + let server = MockServer::start().await; + arrange(&server, json!({"scheduled_freezes": [freeze_sample()]})).await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Scheduled Freezes"), "got: {stdout}"); + assert!(stdout.contains("emergency-fix"), "got: {stdout}"); + assert!( + stdout.contains("2026-01-01T10:00:00 (Europe/Paris)"), + "got: {stdout}", + ); + assert!( + stdout.contains("2026-01-01T12:00:00 (Europe/Paris)"), + "got: {stdout}", + ); + // Matching + exclude conditions rendered together. + assert!(stdout.contains("base=main"), "got: {stdout}"); + assert!(stdout.contains("(exclude: label=hotfix)"), "got: {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_dash_for_open_ended_freeze() { + // `end == null` is a real API state — emergency freezes have + // no scheduled lift. The table should show `"-"` (Python's + // `_format_datetime(None, …) → "-"`), not the literal word + // "null" or a parse error. + let server = MockServer::start().await; + arrange( + &server, + json!({ + "scheduled_freezes": [{ + "id": "abc", + "reason": "emergency", + "start": "2026-01-01T10:00:00", + "end": null, + "timezone": "UTC", + "matching_conditions": [], + "exclude_conditions": [], + }], + }), + ) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + // The end column should render as a bare `-` (we don't pin + // the surrounding whitespace because the table's column + // widths depend on the row content). + assert!(stdout.contains(" - "), "got: {stdout}"); + } + + #[test] + fn is_active_past_start_is_active() { + let now = Utc::now(); + // 1h before now → active. + let start = (now - chrono::Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + assert!(is_active(Some(&start), now)); + } + + #[test] + fn is_active_future_start_is_scheduled() { + let now = Utc::now(); + let start = (now + chrono::Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + assert!(!is_active(Some(&start), now)); + } + + #[test] + fn is_active_missing_or_unparseable_is_scheduled() { + let now = Utc::now(); + // Missing start — degrade to "scheduled" rather than + // panicking. Matches Python's behavior: it would raise on + // `fromisoformat(None)`, but our serde decoder lets the + // field be absent, so the renderer falls through here. + assert!(!is_active(None, now)); + 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()]; + assert_eq!(format_conditions(&m, &[]), "base=main, label=ready"); + } + + #[test] + fn format_conditions_with_exclude() { + let m = vec!["base=main".to_string()]; + let e = vec!["label=hotfix".to_string()]; + assert_eq!( + format_conditions(&m, &e), + "base=main (exclude: label=hotfix)", + ); + } + + #[test] + fn format_conditions_exclude_only() { + // Edge case the Python format produces a leading space-free + // `(exclude: …)`. Mirror that. + let m: Vec = vec![]; + let e = vec!["label=hotfix".to_string()]; + assert_eq!(format_conditions(&m, &e), "(exclude: label=hotfix)"); + } +}