Skip to content

Commit 1e13659

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). ## ``queue pause`` 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. ## ``queue unpause`` 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. ## HttpClient additions 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. ## Shared auth helpers 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. ## Tests 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 33da35f commit 1e13659

11 files changed

Lines changed: 681 additions & 4 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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ status = "shimmed"
7777

7878
[[command]]
7979
path = ["queue", "pause"]
80-
status = "shimmed"
80+
status = "native"
8181

8282
[[command]]
8383
path = ["queue", "show"]
@@ -89,7 +89,7 @@ status = "shimmed"
8989

9090
[[command]]
9191
path = ["queue", "unpause"]
92-
status = "shimmed"
92+
status = "native"
9393

9494
[[command]]
9595
path = ["stack", "checkout"]

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: 114 additions & 1 deletion
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,6 +71,20 @@ struct CiScopesSendOpts {
6771
file_deprecated: Option<PathBuf>,
6872
}
6973

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+
7088
/// Try to recognize the invocation as a native command.
7189
///
7290
/// Returns ``None`` when the argv doesn't look like a native
@@ -84,7 +102,9 @@ fn detect_native(argv: &[String]) -> Option<NativeCommand> {
84102
let has_config_sub = argv.iter().any(|a| a == "validate" || a == "simulate");
85103
let has_ci = argv.iter().any(|a| a == "ci");
86104
let has_ci_sub = argv.iter().any(|a| a == "scopes-send");
87-
(has_config && has_config_sub) || (has_ci && has_ci_sub)
105+
let has_queue = argv.iter().any(|a| a == "queue");
106+
let has_queue_sub = argv.iter().any(|a| a == "pause" || a == "unpause");
107+
(has_config && has_config_sub) || (has_ci && has_ci_sub) || (has_queue && has_queue_sub)
88108
};
89109

90110
let parsed = match CliRoot::try_parse_from(
@@ -141,6 +161,32 @@ fn detect_native(argv: &[String]) -> Option<NativeCommand> {
141161
scopes_file,
142162
file_deprecated,
143163
})),
164+
Subcommands::Queue(QueueArgs {
165+
repository,
166+
token,
167+
api_url,
168+
command:
169+
QueueSubcommand::Pause(PauseCliArgs {
170+
reason,
171+
yes_i_am_sure,
172+
}),
173+
}) => Some(NativeCommand::QueuePause(QueuePauseOpts {
174+
repository,
175+
token,
176+
api_url,
177+
reason,
178+
yes_i_am_sure,
179+
})),
180+
Subcommands::Queue(QueueArgs {
181+
repository,
182+
token,
183+
api_url,
184+
command: QueueSubcommand::Unpause,
185+
}) => Some(NativeCommand::QueueUnpause(QueueUnpauseOpts {
186+
repository,
187+
token,
188+
api_url,
189+
})),
144190
}
145191
}
146192

@@ -191,6 +237,30 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
191237
)
192238
.await
193239
}
240+
NativeCommand::QueuePause(opts) => {
241+
mergify_queue::pause::run(
242+
PauseOptions {
243+
repository: opts.repository.as_deref(),
244+
token: opts.token.as_deref(),
245+
api_url: opts.api_url.as_deref(),
246+
reason: &opts.reason,
247+
yes_i_am_sure: opts.yes_i_am_sure,
248+
},
249+
&mut output,
250+
)
251+
.await
252+
}
253+
NativeCommand::QueueUnpause(opts) => {
254+
mergify_queue::unpause::run(
255+
UnpauseOptions {
256+
repository: opts.repository.as_deref(),
257+
token: opts.token.as_deref(),
258+
api_url: opts.api_url.as_deref(),
259+
},
260+
&mut output,
261+
)
262+
.await
263+
}
194264
}
195265
});
196266

@@ -218,6 +288,8 @@ enum Subcommands {
218288
Config(ConfigArgs),
219289
/// Mergify CI-related commands.
220290
Ci(CiArgs),
291+
/// Manage the Mergify merge queue.
292+
Queue(QueueArgs),
221293
}
222294

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

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)