Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,50 @@ fine when the command is being deprecated/dropped from the CLI — the
rule is "no two live copies of the same command", not "every Python
copy must be replaced".

### Port review checklist

Walk through this before opening a port PR. Most regressions caught so
far have been a violation of one of these.

**HTTP calls — match the Python original's response handling exactly.**

- If the Python original calls a method on the client and **ignores
the response** (e.g. `await client.post(...)` with no `.json()`),
use `post_no_response` / `delete_if_exists` / equivalent on the
Rust side. Do NOT write `let _: serde_json::Value = client.post(...)`
— `Value` still requires a valid JSON body, and a server that
returns an empty body fails at deserialization with `parse response
JSON: error decoding response body`. This regressed `ci scopes-send`
once (fixed in #1356); the cure is structural — pick the right HTTP
method up-front.
- If the Python original parses the response, keep the same shape on
the Rust side. Fields the Python type-hints as `T | None` (or that
the server has historically tolerated as missing/null) should be
`Option<T>` with `#[serde(default)]` so a missing/null field doesn't
abort deserialization.

**Test fixtures — mirror the real server, not what's convenient.**

- Wiremock body templates must match what the real endpoint actually
returns. If the real endpoint sends an empty body, use
`ResponseTemplate::new(200)` — never `set_body_json(json!({}))`. The
latter passes deserialization in tests and masks empty-body bugs
that fail in production.
- Add a regression test for the specific failure mode you're
preventing — exit code, error message, missing-field tolerance —
not just the happy path.

**Strings & UX parity with click.**

- Char-bounded validators ("must be ≤ N characters") use
`s.chars().count()`, not `s.len()`. The latter counts UTF-8 bytes
and rejects valid non-ASCII input.
- Confirmation prompts go to stderr, matching click's `confirm` /
`prompt`. Stdout stays pipeable.
- Resolve all required state (repository, token, API URL) before
prompting the user. A missing-token error surfacing after the user
types `y` is bad UX and worse for non-interactive invocations.

## Documentation

When adding or changing a CLI feature, always update the documentation:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 8 additions & 36 deletions crates/mergify-ci/src/scopes_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,24 @@
//! returns success — matches Python's "no PR, nothing to send"
//! behavior.
//!
//! Auth + API URL resolution follows the same fallback order as
//! ``config simulate``: explicit flag → ``MERGIFY_TOKEN`` /
//! ``MERGIFY_API_URL`` env var → default (or error).
//! Auth + API URL resolution goes through `mergify_core::auth`,
//! which adds a `gh auth token` fallback (matches Python's
//! `utils.get_default_token`) and a `git config remote.origin.url`
//! fallback for the repository slug (matches
//! `utils.get_default_repository`).

use std::env;
use std::path::Path;

use mergify_core::ApiFlavor;
use mergify_core::CliError;
use mergify_core::HttpClient;
use mergify_core::Output;
use mergify_core::auth;
use serde::Deserialize;
use serde::Serialize;
use url::Url;

use crate::detector;

const DEFAULT_API_URL: &str = "https://api.mergify.com";

pub struct ScopesSendOptions<'a> {
pub repository: Option<&'a str>,
pub pull_request: Option<u64>,
Expand All @@ -56,8 +55,8 @@ pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result
};

let repository = resolve_repository(opts.repository)?;
let token = resolve_token(opts.token)?;
let api_url = resolve_api_url(opts.api_url)?;
let token = auth::resolve_token(opts.token)?;
let api_url = auth::resolve_api_url(opts.api_url)?;

// Whenever the deprecated `--file` flag is supplied, surface
// the deprecation warning — even when `--scopes-json` is also
Expand Down Expand Up @@ -111,33 +110,6 @@ fn resolve_pull_request(explicit: Option<u64>) -> Result<Option<u64>, CliError>
detector::get_github_pull_request_number()
}

fn resolve_token(explicit: Option<&str>) -> Result<String, CliError> {
if let Some(value) = explicit.filter(|s| !s.is_empty()) {
return Ok(value.to_string());
}
for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] {
if let Ok(value) = env::var(env_name) {
if !value.is_empty() {
return Ok(value);
}
}
}
Err(CliError::Configuration(
"please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \
or pass --token explicitly"
.to_string(),
))
}

fn resolve_api_url(explicit: Option<&str>) -> Result<Url, CliError> {
let raw = explicit
.map(str::to_string)
.or_else(|| env::var("MERGIFY_API_URL").ok())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_API_URL.to_string());
Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}")))
}

#[derive(Deserialize)]
struct DetectedScopesFile {
scopes: Vec<String>,
Expand Down
124 changes: 6 additions & 118 deletions crates/mergify-config/src/simulate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,23 @@
//! 5. Prints the simulator's title + summary.
//!
//! Token / api-url / config-file all follow the same resolution
//! order as the Python CLI: explicit flag, then env var, then
//! default. Missing token falls back from ``MERGIFY_TOKEN`` to
//! ``GITHUB_TOKEN``. The ``gh auth token`` subprocess fallback from
//! Python isn't ported yet — if neither env var is set the command
//! errors out.
//! order as the Python CLI (`mergify_core::auth`): explicit flag,
//! then env var, then `gh auth token` for the bearer, then the
//! default API URL.

use std::env;
use std::io::Write;
use std::path::Path;

use mergify_core::ApiFlavor;
use mergify_core::CliError;
use mergify_core::HttpClient;
use mergify_core::Output;
use mergify_core::auth;
use serde::Deserialize;
use serde::Serialize;
use url::Url;

use crate::paths::resolve_config_path;

const DEFAULT_API_URL: &str = "https://api.mergify.com";

/// Deserialized shape of the `(owner/repo, number)` pair parsed from
/// a pull-request URL.
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -75,39 +70,6 @@ pub fn parse_pr_url(url: &str) -> Result<PullRequestRef, String> {
})
}

/// Resolve the Mergify API bearer token.
///
/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then
/// `GITHUB_TOKEN`. Errors out when none of those are set.
fn resolve_token(explicit: Option<&str>) -> Result<String, CliError> {
if let Some(token) = explicit.filter(|t| !t.is_empty()) {
return Ok(token.to_string());
}
for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] {
if let Ok(value) = env::var(env_name) {
if !value.is_empty() {
return Ok(value);
}
}
}
Err(CliError::Configuration(
"please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \
or pass --token explicitly"
.to_string(),
))
}

/// Resolve the Mergify API base URL. Falls back to `MERGIFY_API_URL`
/// env var, then to the default `https://api.mergify.com`.
fn resolve_api_url(explicit: Option<&str>) -> Result<Url, CliError> {
let raw = explicit
.map(str::to_string)
.or_else(|| env::var("MERGIFY_API_URL").ok())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_API_URL.to_string());
Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}")))
}

#[derive(Serialize)]
struct SimulatorRequest<'a> {
mergify_yml: &'a str,
Expand All @@ -133,8 +95,8 @@ pub async fn run(opts: SimulateOptions<'_>, output: &mut dyn Output) -> Result<(
CliError::Configuration(format!("cannot read {}: {e}", config_path.display()))
})?;

let token = resolve_token(opts.token)?;
let api_url = resolve_api_url(opts.api_url)?;
let token = auth::resolve_token(opts.token)?;
let api_url = auth::resolve_api_url(opts.api_url)?;

output.status(&format!("Simulating against {api_url}…"))?;

Expand Down Expand Up @@ -218,80 +180,6 @@ mod tests {
assert!(parse_pr_url("https://github.com//repo/pull/42").is_err());
}

#[test]
fn resolve_token_prefers_explicit_over_env() {
temp_env::with_vars(
[
("MERGIFY_TOKEN", Some("from-env")),
("GITHUB_TOKEN", Some("github-env")),
],
|| {
assert_eq!(resolve_token(Some("from-cli")).unwrap(), "from-cli");
},
);
}

#[test]
fn resolve_token_falls_back_to_mergify_env() {
temp_env::with_vars(
[
("MERGIFY_TOKEN", Some("mergify-env")),
("GITHUB_TOKEN", Some("github-env")),
],
|| {
assert_eq!(resolve_token(None).unwrap(), "mergify-env");
},
);
}

#[test]
fn resolve_token_falls_back_to_github_env() {
temp_env::with_vars(
[
("MERGIFY_TOKEN", None::<&str>),
("GITHUB_TOKEN", Some("github-env")),
],
|| {
assert_eq!(resolve_token(None).unwrap(), "github-env");
},
);
}

#[test]
fn resolve_token_errors_when_no_source_available() {
temp_env::with_vars(
[
("MERGIFY_TOKEN", None::<&str>),
("GITHUB_TOKEN", None::<&str>),
],
|| {
let err = resolve_token(None).unwrap_err();
assert!(matches!(err, CliError::Configuration(_)));
assert!(err.to_string().contains("MERGIFY_TOKEN"));
},
);
}

#[test]
fn resolve_api_url_uses_default_when_unset() {
temp_env::with_vars([("MERGIFY_API_URL", None::<&str>)], || {
assert_eq!(
resolve_api_url(None).unwrap().as_str(),
"https://api.mergify.com/",
);
});
}

#[test]
fn resolve_api_url_prefers_explicit() {
temp_env::with_vars([("MERGIFY_API_URL", Some("https://from.env"))], || {
assert_eq!(
resolve_api_url(Some("https://from.cli")).unwrap().as_str(),
"https://from.cli/",
);
});
}

#[tokio::test]
async fn run_posts_config_and_prints_simulator_result() {
let server = MockServer::start().await;
Expand Down
1 change: 1 addition & 0 deletions crates/mergify-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ tokio = { version = "1", default-features = false, features = ["macros", "rt", "
url = "2"

[dev-dependencies]
temp-env = "0.3"
tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "time"] }
wiremock = "0.6"

Expand Down
Loading
Loading