Skip to content

Commit e84a26e

Browse files
jdclaude
andcommitted
feat(rust): port queue pause and unpause to native Rust (Phase 1.5)
Two queue commands in one PR — both are idempotent one-shot API calls that share the same auth + repository resolution. Pause exercises the new PUT method; unpause exercises the new DELETE-with-status-check method. Together they add 5 commands to ``native`` status (3 config + scopes-send + pause + unpause = 6 of 30 commands native). PUTs ``{"reason": "..."}`` to ``/v1/repos/<repo>/merge-queue/pause``, prints a confirmation line with the reason and timestamp. Safety rails match Python: - ``--yes-i-am-sure`` skips confirmation outright. - Interactive (TTY): prompts "Proceed? [y/N]". Anything other than ``y``/``yes`` aborts as a generic error. - Non-interactive without the flag: refuses with INVALID_STATE (exit 7), matching Python's ``raise SystemExit(ExitCode.INVALID_STATE)``. ``--reason`` has a 255-char cap enforced by clap's ``value_parser`` — bad input exits 2. DELETEs the same path. On 404 the API is telling us the queue wasn't paused, so the command prints "Queue is not currently paused" and exits MERGIFY_API_ERROR (matches Python). On 2xx it prints "Queue resumed." and exits 0. Two new methods on ``mergify_core::HttpClient``: - ``put<B, T>(path, body) -> Result<T, CliError>`` — mirror of ``post``, different verb. - ``delete_if_exists(path) -> Result<DeleteOutcome, CliError>`` — returns ``Deleted`` on 2xx, ``NotFound`` on 404, errors on any other non-success status. Lets commands like ``unpause`` give a friendlier 404 message without parsing error strings. The ``resolve_token`` / ``resolve_api_url`` / ``resolve_repository`` trio is duplicated into ``mergify_queue::auth``. That's now three copies in the tree (config simulate, ci scopes-send, queue). A follow-up PR factors them into ``mergify_core::auth`` as soon as a fourth command needs them. 5 new unit tests in the queue crate: - ``parse_reason`` accepts short strings and rejects > 255 chars - ``run`` pauses and prints the API-returned reason + timestamp - ``run`` prints "Queue resumed" on 2xx - ``run`` errors with MERGIFY_API_ERROR on 404 carrying the "not currently paused" message End-to-end smoke tested three paths: ``queue pause --reason X -r owner/repo`` → exit 8 (missing token), ``queue unpause -r owner/repo`` → exit 8 (missing token), ``echo n | queue pause --reason X`` → exit 7 (non-TTY, no --sure). ``PORT_STATUS.toml`` flips both ``queue pause`` and ``queue unpause`` to ``native``. Binary: 8.4 MB → 8.5 MB. 56 Rust tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Change-Id: Idba6fa38caf403fd5f4184cda462b5f7c1eb3ebf
1 parent 1c79bf4 commit e84a26e

15 files changed

Lines changed: 699 additions & 279 deletions

File tree

Cargo.lock

Lines changed: 14 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: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ status = "shimmed"
6363
path = ["freeze", "update"]
6464
status = "shimmed"
6565

66-
[[command]]
67-
path = ["queue", "pause"]
68-
status = "shimmed"
69-
7066
[[command]]
7167
path = ["queue", "show"]
7268
status = "shimmed"
@@ -75,10 +71,6 @@ status = "shimmed"
7571
path = ["queue", "status"]
7672
status = "shimmed"
7773

78-
[[command]]
79-
path = ["queue", "unpause"]
80-
status = "shimmed"
81-
8274
[[command]]
8375
path = ["stack", "checkout"]
8476
status = "shimmed"

crates/mergify-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mergify-ci = { path = "../mergify-ci" }
1919
mergify-config = { path = "../mergify-config" }
2020
mergify-core = { path = "../mergify-core" }
2121
mergify-py-shim = { path = "../mergify-py-shim" }
22+
mergify-queue = { path = "../mergify-queue" }
2223
tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] }
2324

2425
[lints]

crates/mergify-cli/src/main.rs

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use mergify_config::simulate::PullRequestRef;
2424
use mergify_config::simulate::SimulateOptions;
2525
use mergify_core::OutputMode;
2626
use mergify_core::StdioOutput;
27+
use mergify_queue::pause::PauseOptions;
28+
use mergify_queue::unpause::UnpauseOptions;
2729

2830
fn main() -> ExitCode {
2931
let argv: Vec<String> = env::args().skip(1).collect();
@@ -47,6 +49,8 @@ enum NativeCommand {
4749
ConfigValidate { config_file: Option<PathBuf> },
4850
ConfigSimulate(ConfigSimulateOpts),
4951
CiScopesSend(CiScopesSendOpts),
52+
QueuePause(QueuePauseOpts),
53+
QueueUnpause(QueueUnpauseOpts),
5054
}
5155

5256
struct ConfigSimulateOpts {
@@ -67,36 +71,50 @@ struct CiScopesSendOpts {
6771
file_deprecated: Option<PathBuf>,
6872
}
6973

70-
/// Try to recognize the invocation as a native command.
71-
///
72-
/// Returns ``None`` when the argv doesn't look like a native
73-
/// command — callers fall back to the Python shim, which produces
74-
/// the same error messages as before the port started. When the
75-
/// argv obviously targets a native command (contains ``config``
76-
/// and ``validate``/``simulate``) but clap can't parse it — e.g.
77-
/// the user gave a bad flag or an invalid URL — this function
78-
/// prints clap's formatted error to stderr and exits the process
79-
/// with clap's exit code (2), matching the Python CLI's behavior
80-
/// for argument errors.
74+
struct QueuePauseOpts {
75+
repository: Option<String>,
76+
token: Option<String>,
77+
api_url: Option<String>,
78+
reason: String,
79+
yes_i_am_sure: bool,
80+
}
81+
82+
struct QueueUnpauseOpts {
83+
repository: Option<String>,
84+
token: Option<String>,
85+
api_url: Option<String>,
86+
}
87+
8188
/// Heuristic: does argv look like the user intended a native
82-
/// subcommand (`config validate`, `config simulate`, `ci
83-
/// scopes-send`)?
89+
/// subcommand?
8490
///
8591
/// Used as a fallback when clap rejects the input — if the user
86-
/// clearly meant a native command, surface clap's error rather than
87-
/// silently dispatching to the Python shim. We look for two
92+
/// clearly meant a native command, surface clap's error rather
93+
/// than silently dispatching to the Python shim. We look for two
8894
/// *consecutive* tokens forming a `(group, subcommand)` pair so a
8995
/// flag value like `--repository config` doesn't accidentally
9096
/// classify the invocation as native.
9197
fn looks_native(argv: &[String]) -> bool {
9298
argv.windows(2).any(|pair| {
9399
matches!(
94100
(pair[0].as_str(), pair[1].as_str()),
95-
("config", "validate" | "simulate") | ("ci", "scopes-send"),
101+
("config", "validate" | "simulate")
102+
| ("ci", "scopes-send")
103+
| ("queue", "pause" | "unpause"),
96104
)
97105
})
98106
}
99107

108+
/// Try to recognize the invocation as a native command.
109+
///
110+
/// Returns ``None`` when the argv doesn't look like a native
111+
/// command — callers fall back to the Python shim, which produces
112+
/// the same error messages as before the port started. When the
113+
/// argv obviously targets a native command (per [`looks_native`])
114+
/// but clap can't parse it — e.g. the user gave a bad flag or an
115+
/// invalid URL — this function prints clap's formatted error to
116+
/// stderr and exits the process with clap's exit code (2),
117+
/// matching the Python CLI's behavior for argument errors.
100118
fn detect_native(argv: &[String]) -> Option<NativeCommand> {
101119
let looks_native = looks_native(argv);
102120

@@ -154,6 +172,32 @@ fn detect_native(argv: &[String]) -> Option<NativeCommand> {
154172
scopes_file,
155173
file_deprecated,
156174
})),
175+
Subcommands::Queue(QueueArgs {
176+
repository,
177+
token,
178+
api_url,
179+
command:
180+
QueueSubcommand::Pause(PauseCliArgs {
181+
reason,
182+
yes_i_am_sure,
183+
}),
184+
}) => Some(NativeCommand::QueuePause(QueuePauseOpts {
185+
repository,
186+
token,
187+
api_url,
188+
reason,
189+
yes_i_am_sure,
190+
})),
191+
Subcommands::Queue(QueueArgs {
192+
repository,
193+
token,
194+
api_url,
195+
command: QueueSubcommand::Unpause,
196+
}) => Some(NativeCommand::QueueUnpause(QueueUnpauseOpts {
197+
repository,
198+
token,
199+
api_url,
200+
})),
157201
}
158202
}
159203

@@ -204,6 +248,30 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
204248
)
205249
.await
206250
}
251+
NativeCommand::QueuePause(opts) => {
252+
mergify_queue::pause::run(
253+
PauseOptions {
254+
repository: opts.repository.as_deref(),
255+
token: opts.token.as_deref(),
256+
api_url: opts.api_url.as_deref(),
257+
reason: &opts.reason,
258+
yes_i_am_sure: opts.yes_i_am_sure,
259+
},
260+
&mut output,
261+
)
262+
.await
263+
}
264+
NativeCommand::QueueUnpause(opts) => {
265+
mergify_queue::unpause::run(
266+
UnpauseOptions {
267+
repository: opts.repository.as_deref(),
268+
token: opts.token.as_deref(),
269+
api_url: opts.api_url.as_deref(),
270+
},
271+
&mut output,
272+
)
273+
.await
274+
}
207275
}
208276
});
209277

@@ -231,6 +299,8 @@ enum Subcommands {
231299
Config(ConfigArgs),
232300
/// Mergify CI-related commands.
233301
Ci(CiArgs),
302+
/// Manage the Mergify merge queue.
303+
Queue(QueueArgs),
234304
}
235305

236306
#[derive(clap::Args)]
@@ -326,3 +396,44 @@ struct ScopesSendCliArgs {
326396
#[arg(long = "file", short = 'f', hide = true)]
327397
file_deprecated: Option<PathBuf>,
328398
}
399+
400+
#[derive(clap::Args)]
401+
struct QueueArgs {
402+
/// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and
403+
/// then ``GITHUB_TOKEN`` env vars.
404+
#[arg(long, short = 't', global = true)]
405+
token: Option<String>,
406+
407+
/// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var,
408+
/// then to the default.
409+
#[arg(long = "api-url", short = 'u', global = true)]
410+
api_url: Option<String>,
411+
412+
/// Repository full name (owner/repo). Falls back to
413+
/// ``GITHUB_REPOSITORY`` env var.
414+
#[arg(long, short = 'r', global = true)]
415+
repository: Option<String>,
416+
417+
#[command(subcommand)]
418+
command: QueueSubcommand,
419+
}
420+
421+
#[derive(Subcommand)]
422+
enum QueueSubcommand {
423+
/// Pause the merge queue for the repository.
424+
Pause(PauseCliArgs),
425+
/// Unpause the merge queue for the repository.
426+
Unpause,
427+
}
428+
429+
#[derive(clap::Args)]
430+
struct PauseCliArgs {
431+
/// Reason for pausing the queue (max 255 characters).
432+
#[arg(long, value_parser = mergify_queue::pause::parse_reason)]
433+
reason: String,
434+
435+
/// Skip the confirmation prompt. Required in non-interactive
436+
/// sessions.
437+
#[arg(long = "yes-i-am-sure", default_value_t = false)]
438+
yes_i_am_sure: bool,
439+
}

crates/mergify-core/src/http.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ pub enum ApiFlavor {
4040
Mergify,
4141
}
4242

43+
/// Outcome of [`Client::delete_if_exists`].
44+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
45+
pub enum DeleteOutcome {
46+
/// 2xx: the resource was deleted.
47+
Deleted,
48+
/// 404: the resource didn't exist (or was already gone).
49+
NotFound,
50+
}
51+
4352
/// Retry policy for transient failures. Only 5xx responses and
4453
/// connect/timeout errors are retried; 4xx responses are never
4554
/// retried — those are caller errors and retrying would hide bugs.
@@ -128,6 +137,29 @@ impl Client {
128137
self.execute(self.inner.post(url).json(body)).await
129138
}
130139

140+
/// PUT `body` as JSON to `path` and deserialize the JSON
141+
/// response as `T`.
142+
pub async fn put<B: Serialize + ?Sized, T: DeserializeOwned>(
143+
&self,
144+
path: &str,
145+
body: &B,
146+
) -> Result<T, CliError> {
147+
let url = self.join(path)?;
148+
self.execute(self.inner.put(url).json(body)).await
149+
}
150+
151+
/// DELETE `path`, returning whether the resource existed.
152+
///
153+
/// Returns `Ok(DeleteOutcome::Deleted)` on 2xx responses and
154+
/// `Ok(DeleteOutcome::NotFound)` on 404 — useful for idempotent
155+
/// "turn this thing off if it's on" operations where 404 means
156+
/// "nothing to do". 4xx-other and 5xx map to the normal API
157+
/// errors.
158+
pub async fn delete_if_exists(&self, path: &str) -> Result<DeleteOutcome, CliError> {
159+
let url = self.join(path)?;
160+
self.execute_status(self.inner.delete(url)).await
161+
}
162+
131163
fn join(&self, path: &str) -> Result<Url, CliError> {
132164
// `Url::join` accepts absolute URLs and protocol-relative
133165
// paths (`//host/...`), which would let a caller-supplied
@@ -143,6 +175,58 @@ impl Client {
143175
.map_err(|e| self.api_error(format!("invalid path {path:?}: {e}")))
144176
}
145177

178+
/// Execute a request that cares only about the HTTP status.
179+
///
180+
/// Used by [`Self::delete_if_exists`] — the response body (if
181+
/// any) is discarded.
182+
async fn execute_status(
183+
&self,
184+
builder: reqwest::RequestBuilder,
185+
) -> Result<DeleteOutcome, CliError> {
186+
let mut backoff = self.retry.initial_backoff;
187+
let mut last_message = String::from("HTTP request failed without response");
188+
189+
for attempt in 0..self.retry.max_attempts {
190+
let Some(cloned) = builder.try_clone() else {
191+
return Err(self.api_error(
192+
"request body is not cloneable (streaming?) — cannot retry".into(),
193+
));
194+
};
195+
let req = match &self.token {
196+
Some(token) => cloned.bearer_auth(token),
197+
None => cloned,
198+
};
199+
200+
match req.send().await {
201+
Ok(resp) => {
202+
let status = resp.status();
203+
if status.is_success() {
204+
return Ok(DeleteOutcome::Deleted);
205+
}
206+
if status == StatusCode::NOT_FOUND {
207+
return Ok(DeleteOutcome::NotFound);
208+
}
209+
last_message = error_message(status, resp).await;
210+
if status.is_server_error() && attempt + 1 < self.retry.max_attempts {
211+
tokio::time::sleep(backoff).await;
212+
backoff *= 2;
213+
continue;
214+
}
215+
return Err(self.api_error(last_message));
216+
}
217+
Err(e) if is_transient(&e) && attempt + 1 < self.retry.max_attempts => {
218+
last_message = format!("network error: {e}");
219+
tokio::time::sleep(backoff).await;
220+
backoff *= 2;
221+
}
222+
Err(e) => {
223+
return Err(self.api_error(format!("request failed: {e}")));
224+
}
225+
}
226+
}
227+
Err(self.api_error(last_message))
228+
}
229+
146230
async fn execute<T: DeserializeOwned>(
147231
&self,
148232
builder: reqwest::RequestBuilder,

crates/mergify-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub mod output;
2222

2323
pub use error::CliError;
2424
pub use exit_code::ExitCode;
25-
pub use http::{ApiFlavor, Client as HttpClient, RetryPolicy};
25+
pub use http::{ApiFlavor, Client as HttpClient, DeleteOutcome, RetryPolicy};
2626
pub use output::{Output, OutputMode, StdioOutput};
2727

2828
/// Compile-time version string taken from the crate package metadata

crates/mergify-queue/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "mergify-queue"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
authors.workspace = true
9+
description = "Native implementation of `mergify queue` subcommands."
10+
publish = false
11+
12+
[dependencies]
13+
mergify-core = { path = "../mergify-core" }
14+
serde = { version = "1.0", features = ["derive"] }
15+
url = "2"
16+
17+
[dev-dependencies]
18+
serde_json = "1.0"
19+
temp-env = { version = "0.3", features = ["async_closure"] }
20+
tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] }
21+
wiremock = "0.6"
22+
23+
[lints]
24+
workspace = true

0 commit comments

Comments
 (0)