Skip to content

Commit b35651e

Browse files
jdclaude
andauthored
feat(rust): port config simulate to native Rust (Phase 1.3b) (#1298)
Completes the config pilot. Second native command, flipped in PORT_STATUS.toml from ``shimmed`` to ``native``. ## What ports natively ``mergify config simulate PULL_REQUEST_URL [-f PATH] [-t TOKEN] [-u URL]``: 1. Parses the PR URL into ``(owner/repo, number)``. Invalid URLs are rejected by clap's ``value_parser``, which exits 2 (matching Python's ``click.BadParameter``). 2. Resolves the config file (same three-location search as ``config validate`` — shared helper in ``paths.rs``). 3. Reads the YAML as a raw string (no parsing — the simulator API accepts the text verbatim). 4. Resolves the bearer token: explicit ``--token`` → ``MERGIFY_TOKEN`` → ``GITHUB_TOKEN`` → error. Matches Python's precedence; the ``gh auth token`` subprocess fallback isn't ported yet. 5. Resolves the API URL: explicit ``--api-url`` → ``MERGIFY_API_URL`` → ``https://api.mergify.com`` default. 6. POSTs ``{"mergify_yml": <content>}`` to ``/v1/repos/<repo>/pulls/<number>/simulator`` via the 1.2b HTTP client (auth + retry + typed errors). 7. Prints the returned title + summary to stdout. Rich Markdown rendering of ``summary`` is deliberately deferred — we print raw Markdown today. Human output drift is allowed by the compat contract; machine-readable paths aren't affected because ``config simulate`` has no ``--json`` flag in Python either. ## Dispatch ``mergify-cli/src/main.rs`` now carries two clap subcommands (``validate`` + ``simulate``). The dispatch logic got a light reshape: on clap parse failure we distinguish "obvious native intent" (argv contains ``config`` + one of ``validate``/``simulate``) from "unrecognized command, fall through". Native-intent parse errors surface clap's formatted error and exit 2; anything else falls through to the Python shim so unported commands keep working. ## Refactor The config-path resolver moved from ``validate.rs`` into a shared ``paths.rs`` module so both commands share a single source of truth for the ``[".mergify.yml", ".mergify/config.yml", ".github/mergify.yml"]`` search list. ## Tests 24 tests in ``mergify-config`` (up from 11 in Phase 1.3): - 3 for the shared path resolver (moved out of ``validate.rs``) - 6 for PR URL parsing (happy path + 5 rejection cases) - 4 for token resolution (explicit, env fallback, error) - 2 for API URL resolution - 1 end-to-end wiremock test: POST + JSON body + auth header + response rendering Covered by the port-inventory guard: ``PORT_STATUS.toml`` flips ``config simulate`` from ``shimmed`` to ``native``. Binary size: 8.0 MB → 8.3 MB (small bump from simulate.rs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 882bafe commit b35651e

8 files changed

Lines changed: 600 additions & 125 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

PORT_STATUS.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ status = "shimmed"
5353

5454
[[command]]
5555
path = ["config", "simulate"]
56-
status = "shimmed"
56+
status = "native"
5757

5858
[[command]]
5959
path = ["config", "validate"]

crates/mergify-cli/src/main.rs

Lines changed: 123 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
//! `mergify` binary entry point.
22
//!
3-
//! Dispatch logic:
4-
//! - If the invocation is `mergify config validate [--config-file
5-
//! PATH]`, run it natively via `mergify_config::validate`.
6-
//! - Anything else is handed to `mergify_py_shim::run`, which
7-
//! extracts the embedded Python source on first use and invokes
8-
//! `python3 -m mergify_cli`.
3+
//! Dispatch logic: every invocation is speculatively parsed with
4+
//! clap, which knows about the native commands
5+
//! ([`ConfigSubcommand::Validate`], [`ConfigSubcommand::Simulate`]).
6+
//! If clap succeeds with a known native variant the binary runs
7+
//! that code path natively. Any parse failure — including
8+
//! subcommands clap doesn't know about (``stack push``, ``ci
9+
//! junit-process``, …) — falls through to [`mergify_py_shim::run`],
10+
//! which hands the original argv to ``python3 -m mergify_cli``.
911
//!
10-
//! As each command ports (Phase 1.4+), native dispatch grows and
11-
//! the shim fallback shrinks. Phase 6 deletes the shim entirely.
12+
//! As each command ports (Phase 1.4+), new variants land on the
13+
//! clap enum and the shim fallback shrinks. Phase 6 deletes the
14+
//! shim entirely.
1215
1316
use std::env;
1417
use std::path::PathBuf;
1518
use std::process::ExitCode;
1619

1720
use clap::Parser;
1821
use clap::Subcommand;
22+
use mergify_config::simulate::PullRequestRef;
23+
use mergify_config::simulate::SimulateOptions;
1924
use mergify_core::OutputMode;
2025
use mergify_core::StdioOutput;
2126

2227
fn main() -> ExitCode {
2328
let argv: Vec<String> = env::args().skip(1).collect();
2429

25-
if let Some(NativeCommand::ConfigValidate(opts)) = detect_native(&argv) {
26-
return run_native_config_validate(&opts);
30+
if let Some(cmd) = detect_native(&argv) {
31+
return run_native(cmd);
2732
}
2833

2934
match mergify_py_shim::run(&argv) {
@@ -35,7 +40,74 @@ fn main() -> ExitCode {
3540
}
3641
}
3742

38-
fn run_native_config_validate(opts: &ConfigValidateOpts) -> ExitCode {
43+
/// Native commands the Rust binary handles without delegating to
44+
/// the Python shim.
45+
enum NativeCommand {
46+
ConfigValidate { config_file: Option<PathBuf> },
47+
ConfigSimulate(ConfigSimulateOpts),
48+
}
49+
50+
struct ConfigSimulateOpts {
51+
config_file: Option<PathBuf>,
52+
pull_request: PullRequestRef,
53+
token: Option<String>,
54+
api_url: Option<String>,
55+
}
56+
57+
/// Try to recognize the invocation as a native command.
58+
///
59+
/// Returns ``None`` when the argv doesn't look like a native
60+
/// command — callers fall back to the Python shim, which produces
61+
/// the same error messages as before the port started. When the
62+
/// argv obviously targets a native command (contains ``config``
63+
/// and ``validate``/``simulate``) but clap can't parse it — e.g.
64+
/// the user gave a bad flag or an invalid URL — this function
65+
/// prints clap's formatted error to stderr and exits the process
66+
/// with clap's exit code (2), matching the Python CLI's behavior
67+
/// for argument errors.
68+
fn detect_native(argv: &[String]) -> Option<NativeCommand> {
69+
let looks_native = {
70+
let has_config = argv.iter().any(|a| a == "config");
71+
let has_native_sub = argv.iter().any(|a| a == "validate" || a == "simulate");
72+
has_config && has_native_sub
73+
};
74+
75+
let parsed = match CliRoot::try_parse_from(
76+
std::iter::once("mergify".to_string()).chain(argv.iter().cloned()),
77+
) {
78+
Ok(parsed) => parsed,
79+
Err(err) if looks_native => {
80+
// Native intent + clap rejection = surface clap's error
81+
// and exit. ``err.exit()`` prints to stderr and calls
82+
// ``process::exit``; does not return.
83+
err.exit()
84+
}
85+
Err(_) => return None,
86+
};
87+
88+
match parsed.command {
89+
Subcommands::Config(ConfigArgs {
90+
config_file,
91+
command: ConfigSubcommand::Validate(_),
92+
}) => Some(NativeCommand::ConfigValidate { config_file }),
93+
Subcommands::Config(ConfigArgs {
94+
config_file,
95+
command:
96+
ConfigSubcommand::Simulate(SimulateCliArgs {
97+
pull_request,
98+
token,
99+
api_url,
100+
}),
101+
}) => Some(NativeCommand::ConfigSimulate(ConfigSimulateOpts {
102+
config_file,
103+
pull_request,
104+
token,
105+
api_url,
106+
})),
107+
}
108+
}
109+
110+
fn run_native(cmd: NativeCommand) -> ExitCode {
39111
let rt = match tokio::runtime::Builder::new_current_thread()
40112
.enable_all()
41113
.build()
@@ -48,68 +120,37 @@ fn run_native_config_validate(opts: &ConfigValidateOpts) -> ExitCode {
48120
};
49121

50122
let mut output = StdioOutput::new(OutputMode::Human);
51-
let result = rt.block_on(mergify_config::validate::run(
52-
opts.config_file.as_deref(),
53-
&mut output,
54-
));
123+
124+
let result = rt.block_on(async {
125+
match cmd {
126+
NativeCommand::ConfigValidate { config_file } => {
127+
mergify_config::validate::run(config_file.as_deref(), &mut output).await
128+
}
129+
NativeCommand::ConfigSimulate(opts) => {
130+
mergify_config::simulate::run(
131+
SimulateOptions {
132+
pull_request: &opts.pull_request,
133+
config_file: opts.config_file.as_deref(),
134+
token: opts.token.as_deref(),
135+
api_url: opts.api_url.as_deref(),
136+
},
137+
&mut output,
138+
)
139+
.await
140+
}
141+
}
142+
});
55143

56144
match result {
57145
Ok(()) => ExitCode::from(mergify_core::ExitCode::Success.as_u8()),
58146
Err(err) => {
59147
let code = err.exit_code();
60-
// Commands that emit their own details through `Output`
61-
// (e.g. `config validate`'s per-error list) still get a
62-
// top-level "mergify: <message>" line appended, matching
63-
// the Python CLI's behavior.
64148
eprintln!("mergify: {err}");
65149
ExitCode::from(code.as_u8())
66150
}
67151
}
68152
}
69153

70-
/// Recognised native commands, paired with their pre-parsed options.
71-
enum NativeCommand {
72-
ConfigValidate(ConfigValidateOpts),
73-
}
74-
75-
#[derive(Debug, Default)]
76-
struct ConfigValidateOpts {
77-
config_file: Option<PathBuf>,
78-
}
79-
80-
/// Try to recognise the invocation as a native command. Returns
81-
/// `None` when the argv doesn't match any native command or when
82-
/// clap rejects it — in both cases the caller falls back to the
83-
/// Python shim (which produces the same error messages as before
84-
/// the port started).
85-
fn detect_native(argv: &[String]) -> Option<NativeCommand> {
86-
// Quick cheap check: argv must contain "config" followed by
87-
// "validate" to possibly be a native match. This avoids running
88-
// clap on every command.
89-
let has_config_validate = argv
90-
.iter()
91-
.position(|a| a == "config")
92-
.is_some_and(|i| argv.get(i + 1).is_some_and(|a| a == "validate"));
93-
if !has_config_validate {
94-
return None;
95-
}
96-
97-
match CliRoot::try_parse_from(
98-
std::iter::once("mergify".to_string()).chain(argv.iter().cloned()),
99-
) {
100-
Ok(CliRoot {
101-
command:
102-
Subcommands::Config(ConfigArgs {
103-
config_file,
104-
command: ConfigSubcommand::Validate(_),
105-
}),
106-
}) => Some(NativeCommand::ConfigValidate(ConfigValidateOpts {
107-
config_file,
108-
})),
109-
_ => None,
110-
}
111-
}
112-
113154
#[derive(Parser)]
114155
#[command(name = "mergify", disable_help_subcommand = true)]
115156
#[command(disable_version_flag = true, disable_help_flag = true)]
@@ -139,7 +180,27 @@ struct ConfigArgs {
139180
enum ConfigSubcommand {
140181
/// Validate the Mergify configuration file against the schema.
141182
Validate(ValidateArgs),
183+
/// Simulate Mergify actions on a pull request using the local
184+
/// configuration.
185+
Simulate(SimulateCliArgs),
142186
}
143187

144188
#[derive(clap::Args)]
145189
struct ValidateArgs {}
190+
191+
#[derive(clap::Args)]
192+
struct SimulateCliArgs {
193+
/// Pull request URL (e.g. <https://github.com/owner/repo/pull/123>).
194+
#[arg(value_name = "PULL_REQUEST_URL", value_parser = mergify_config::simulate::parse_pr_url)]
195+
pull_request: PullRequestRef,
196+
197+
/// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and
198+
/// then ``GITHUB_TOKEN`` env vars.
199+
#[arg(long, short = 't')]
200+
token: Option<String>,
201+
202+
/// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var,
203+
/// then to the default.
204+
#[arg(long = "api-url", short = 'u')]
205+
api_url: Option<String>,
206+
}

crates/mergify-config/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ url = "2"
1919

2020
[dev-dependencies]
2121
tempfile = "3.14"
22+
temp-env = "0.3"
2223
tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] }
2324
wiremock = "0.6"
2425

crates/mergify-config/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Native Rust implementation of the `mergify config` subcommands.
22
//!
3-
//! Phase 1.3 ports `config validate`. `config simulate` stays in the
4-
//! Python shim until Phase 1.3b.
3+
//! Phase 1.3 ports `config validate`. Phase 1.3b adds `config
4+
//! simulate`. Both share the config-file resolver in [`paths`].
55
6+
pub mod paths;
7+
pub mod simulate;
68
pub mod validate;

crates/mergify-config/src/paths.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Shared helpers for resolving the Mergify configuration file path.
2+
//!
3+
//! Both `config validate` and `config simulate` accept a
4+
//! ``--config-file`` flag and otherwise auto-detect the file from a
5+
//! small list of conventional locations. The resolver here is the
6+
//! single source of truth for that behavior.
7+
8+
use std::path::Path;
9+
use std::path::PathBuf;
10+
11+
use mergify_core::CliError;
12+
13+
/// Filename patterns the CLI searches for a Mergify configuration,
14+
/// in priority order. Mirrors ``MERGIFY_CONFIG_PATHS`` in
15+
/// ``mergify_cli/ci/detector.py``.
16+
pub const DEFAULT_CONFIG_PATHS: [&str; 3] =
17+
[".mergify.yml", ".mergify/config.yml", ".github/mergify.yml"];
18+
19+
/// Resolve the path of the Mergify configuration file relative to
20+
/// the current working directory.
21+
///
22+
/// When ``explicit`` is ``Some``, that path must be a real file —
23+
/// otherwise the user specified a bad path and we fail loudly with
24+
/// [`CliError::Configuration`]. When ``explicit`` is ``None`` the
25+
/// resolver walks [`DEFAULT_CONFIG_PATHS`] in order and returns the
26+
/// first match.
27+
pub fn resolve_config_path(explicit: Option<&Path>) -> Result<PathBuf, CliError> {
28+
resolve_config_path_in(explicit, Path::new("."))
29+
}
30+
31+
/// Same as [`resolve_config_path`] but searches relative to
32+
/// ``base`` instead of the current working directory.
33+
///
34+
/// Tests use this directly to avoid `std::env::set_current_dir`,
35+
/// which races with parallel cargo test workers in the same
36+
/// process.
37+
///
38+
/// # Errors
39+
///
40+
/// Returns [`CliError::Configuration`] when neither an explicit
41+
/// path nor any default candidate exists.
42+
pub fn resolve_config_path_in(explicit: Option<&Path>, base: &Path) -> Result<PathBuf, CliError> {
43+
if let Some(path) = explicit {
44+
if path.is_file() {
45+
return Ok(path.to_path_buf());
46+
}
47+
return Err(CliError::Configuration(format!(
48+
"Configuration file not found: {}",
49+
path.display(),
50+
)));
51+
}
52+
for candidate in DEFAULT_CONFIG_PATHS {
53+
let path = base.join(candidate);
54+
if path.is_file() {
55+
return Ok(path);
56+
}
57+
}
58+
Err(CliError::Configuration(format!(
59+
"Mergify configuration file not found. Looked in: {}",
60+
DEFAULT_CONFIG_PATHS.join(", "),
61+
)))
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use std::fs;
67+
68+
use super::*;
69+
70+
#[test]
71+
fn finds_dotmergify_yml() {
72+
let tmp = tempfile::tempdir().unwrap();
73+
fs::write(tmp.path().join(".mergify.yml"), "").unwrap();
74+
let got = resolve_config_path_in(None, tmp.path()).unwrap();
75+
assert_eq!(got, tmp.path().join(".mergify.yml"));
76+
}
77+
78+
#[test]
79+
fn errors_when_no_file_and_no_explicit() {
80+
let tmp = tempfile::tempdir().unwrap();
81+
let err = resolve_config_path_in(None, tmp.path()).unwrap_err();
82+
assert!(matches!(err, CliError::Configuration(_)));
83+
assert!(err.to_string().contains("not found"));
84+
}
85+
86+
#[test]
87+
fn errors_on_explicit_missing_file() {
88+
let err = resolve_config_path_in(Some(Path::new("/nonexistent/path.yml")), Path::new("."))
89+
.unwrap_err();
90+
assert!(matches!(err, CliError::Configuration(_)));
91+
}
92+
}

0 commit comments

Comments
 (0)