diff --git a/crates/mergify-core/src/command_context.rs b/crates/mergify-core/src/command_context.rs new file mode 100644 index 00000000..6bcc7a4d --- /dev/null +++ b/crates/mergify-core/src/command_context.rs @@ -0,0 +1,67 @@ +//! Per-command "resolved context" + Mergify HTTP client builder. +//! +//! Every Mergify-API command starts the same way: resolve the +//! repository slug, the bearer token, and the API URL via the +//! standard fallback chain (flag → env → `gh auth token` / git +//! remote / default), then build a typed [`HttpClient`] from them. +//! [`CommandContext`] bundles those three pieces with a +//! `mergify_client()` builder so the prelude shrinks from a four-line +//! ritual to two: +//! +//! ```ignore +//! let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; +//! let client = ctx.mergify_client()?; +//! ``` +//! +//! Specialized commands that don't fit the shape (`config validate` +//! needs no repository; `ci scopes-send` resolves the repo from CI +//! env; `config simulate` derives it from a PR URL) keep wiring up +//! the lower-level [`auth::resolve_*`] / [`HttpClient::new`] calls +//! by hand. +//! +//! [`auth::resolve_*`]: crate::auth + +use url::Url; + +use crate::auth; +use crate::error::CliError; +use crate::http::ApiFlavor; +use crate::http::Client as HttpClient; + +/// Resolved repository / token / API URL for a Mergify-API command. +pub struct CommandContext { + pub repository: String, + pub token: String, + pub api_url: Url, +} + +impl CommandContext { + /// Resolve all three pieces of context using the standard + /// fallback chain. Used by the `queue` and `freeze` command + /// families — every member of those groups needs the same + /// shape. + /// + /// # Errors + /// + /// Surfaces the first resolution failure as + /// [`CliError::Configuration`]. + pub fn resolve( + repository: Option<&str>, + token: Option<&str>, + api_url: Option<&str>, + ) -> Result { + Ok(Self { + repository: auth::resolve_repository(repository)?, + token: auth::resolve_token(token)?, + api_url: auth::resolve_api_url(api_url)?, + }) + } + + /// Build a Mergify-flavored [`HttpClient`] from this context. + /// Clones the API URL so the [`CommandContext`] stays usable + /// afterwards — callers typically still need `self.repository` + /// to format URL paths. + pub fn mergify_client(&self) -> Result { + HttpClient::new(self.api_url.clone(), &self.token, ApiFlavor::Mergify) + } +} diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs index 8b731465..d362f1d9 100644 --- a/crates/mergify-core/src/lib.rs +++ b/crates/mergify-core/src/lib.rs @@ -16,11 +16,13 @@ //! in subsequent sub-phases. pub mod auth; +pub mod command_context; pub mod error; pub mod exit_code; pub mod http; pub mod output; +pub use command_context::CommandContext; pub use error::CliError; pub use exit_code::ExitCode; pub use http::{ApiFlavor, Client as HttpClient, DeleteOutcome, RetryPolicy}; diff --git a/crates/mergify-freeze/src/create.rs b/crates/mergify-freeze/src/create.rs index 8553d021..597870ef 100644 --- a/crates/mergify-freeze/src/create.rs +++ b/crates/mergify-freeze/src/create.rs @@ -11,11 +11,9 @@ use std::io::Write; use chrono::NaiveDateTime; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use serde::Serialize; use crate::common::NaiveDateTimeWire; @@ -56,9 +54,7 @@ struct CreatePayload<'a> { /// Run the `freeze create` command. pub async fn run(opts: CreateOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; let timezone = match opts.timezone { Some(tz) => tz.to_string(), None => detect_local_timezone()?, @@ -85,10 +81,13 @@ pub async fn run(opts: CreateOptions<'_>, output: &mut dyn Output) -> Result<(), }, }; - output.status(&format!("Creating scheduled freeze for {repository}…"))?; + output.status(&format!( + "Creating scheduled freeze for {repo}…", + repo = ctx.repository, + ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = format!("/v1/repos/{repository}/scheduled_freeze"); + let client = ctx.mergify_client()?; + let path = format!("/v1/repos/{}/scheduled_freeze", ctx.repository); let freeze: ScheduledFreeze = client.post(&path, &payload).await?; output.emit(&(), &mut |w: &mut dyn Write| { diff --git a/crates/mergify-freeze/src/delete.rs b/crates/mergify-freeze/src/delete.rs index c8d621ea..fa2339ec 100644 --- a/crates/mergify-freeze/src/delete.rs +++ b/crates/mergify-freeze/src/delete.rs @@ -9,11 +9,9 @@ use std::io::Write; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use serde::Serialize; pub struct DeleteOptions<'a> { @@ -36,22 +34,22 @@ struct DeletePayload<'a> { /// Run the `freeze delete` command. pub async fn run(opts: DeleteOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; output.status(&format!( - "Deleting scheduled freeze {id} on {repository}…", + "Deleting scheduled freeze {id} on {repo}…", id = opts.freeze_id, + repo = ctx.repository, ))?; let payload = DeletePayload { delete_reason: opts.delete_reason, }; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let client = ctx.mergify_client()?; let path = format!( - "/v1/repos/{repository}/scheduled_freeze/{id}/delete", + "/v1/repos/{repo}/scheduled_freeze/{id}/delete", + repo = ctx.repository, id = opts.freeze_id, ); client.post_no_response(&path, &payload).await?; diff --git a/crates/mergify-freeze/src/list.rs b/crates/mergify-freeze/src/list.rs index 80f619a0..c06d32d0 100644 --- a/crates/mergify-freeze/src/list.rs +++ b/crates/mergify-freeze/src/list.rs @@ -19,11 +19,9 @@ use std::io::Write; use anstyle::AnsiColor; use chrono::DateTime; use chrono::Utc; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use mergify_tui::Theme; use crate::common::ScheduledFreeze; @@ -39,14 +37,15 @@ pub struct ListOptions<'a> { /// 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - output.status(&format!("Fetching scheduled freezes for {repository}…"))?; + output.status(&format!( + "Fetching scheduled freezes for {repo}…", + repo = ctx.repository, + ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = format!("/v1/repos/{repository}/scheduled_freeze"); + let client = ctx.mergify_client()?; + let path = format!("/v1/repos/{}/scheduled_freeze", ctx.repository); let raw: serde_json::Value = client.get(&path).await?; // Python's `list_freezes` returns `data["scheduled_freezes"]` diff --git a/crates/mergify-freeze/src/update.rs b/crates/mergify-freeze/src/update.rs index 5d4bd7aa..3f19c42f 100644 --- a/crates/mergify-freeze/src/update.rs +++ b/crates/mergify-freeze/src/update.rs @@ -10,11 +10,9 @@ use std::io::Write; use chrono::NaiveDateTime; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use serde::Serialize; use crate::common::NaiveDateTimeWire; @@ -55,9 +53,7 @@ struct UpdatePayload<'a> { /// Run the `freeze update` command. pub async fn run(opts: UpdateOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; let payload = UpdatePayload { reason: opts.reason, @@ -69,13 +65,15 @@ pub async fn run(opts: UpdateOptions<'_>, output: &mut dyn Output) -> Result<(), }; output.status(&format!( - "Updating scheduled freeze {id} on {repository}…", + "Updating scheduled freeze {id} on {repo}…", id = opts.freeze_id, + repo = ctx.repository, ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let client = ctx.mergify_client()?; let path = format!( - "/v1/repos/{repository}/scheduled_freeze/{id}", + "/v1/repos/{repo}/scheduled_freeze/{id}", + repo = ctx.repository, id = opts.freeze_id, ); let freeze: ScheduledFreeze = client.patch(&path, &payload).await?; diff --git a/crates/mergify-queue/src/pause.rs b/crates/mergify-queue/src/pause.rs index ca81e36c..09156215 100644 --- a/crates/mergify-queue/src/pause.rs +++ b/crates/mergify-queue/src/pause.rs @@ -16,11 +16,9 @@ use std::io::IsTerminal; use std::io::Write; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use serde::Deserialize; use serde::Serialize; @@ -74,20 +72,21 @@ pub async fn run(opts: PauseOptions<'_>, output: &mut dyn Output) -> Result<(), // Resolve auth/repo first so the prompt names the *actual* repo // (including the `GITHUB_REPOSITORY` fallback) and so a missing // repo or token fails loudly *before* we ask for confirmation. - let repository = auth::resolve_repository(opts.repository)?; - let token = auth::resolve_token(opts.token)?; - let api_url = auth::resolve_api_url(opts.api_url)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; confirm( opts.yes_i_am_sure, std::io::stdin().is_terminal(), - &repository, + &ctx.repository, )?; - output.status(&format!("Pausing merge queue for {repository}…"))?; + output.status(&format!( + "Pausing merge queue for {repo}…", + repo = ctx.repository, + ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = format!("/v1/repos/{repository}/merge-queue/pause"); + let client = ctx.mergify_client()?; + let path = format!("/v1/repos/{}/merge-queue/pause", ctx.repository); let resp: PauseResponse = client .put( &path, diff --git a/crates/mergify-queue/src/show.rs b/crates/mergify-queue/src/show.rs index ec9f6e4f..50cdf262 100644 --- a/crates/mergify-queue/src/show.rs +++ b/crates/mergify-queue/src/show.rs @@ -30,11 +30,9 @@ use anstyle::AnsiColor; use anstyle::Style; use chrono::DateTime; use chrono::Utc; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use mergify_tui::Theme; use mergify_tui::relative_time; use mergify_tui::tree; @@ -105,13 +103,12 @@ const fn default_match_true() -> bool { /// Run the `queue show` command. pub async fn run(opts: ShowOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let client = ctx.mergify_client()?; let path = format!( - "/v1/repos/{repository}/merge-queue/pull/{pr_number}", + "/v1/repos/{repo}/merge-queue/pull/{pr_number}", + repo = ctx.repository, pr_number = opts.pr_number, ); diff --git a/crates/mergify-queue/src/status.rs b/crates/mergify-queue/src/status.rs index e1d8dc8a..8807b62b 100644 --- a/crates/mergify-queue/src/status.rs +++ b/crates/mergify-queue/src/status.rs @@ -33,11 +33,9 @@ use anstyle::Style; use chrono::DateTime; use chrono::Utc; use indexmap::IndexMap; -use mergify_core::ApiFlavor; use mergify_core::CliError; -use mergify_core::HttpClient; +use mergify_core::CommandContext; use mergify_core::Output; -use mergify_core::auth; use mergify_tui::Theme; use mergify_tui::relative_time; use mergify_tui::tree; @@ -125,14 +123,15 @@ struct Author { /// Run the `queue status` command. pub async fn run(opts: StatusOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - output.status(&format!("Fetching merge queue status for {repository}…"))?; + output.status(&format!( + "Fetching merge queue status for {repo}…", + repo = ctx.repository, + ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = build_path(&repository, opts.branch); + let client = ctx.mergify_client()?; + let path = build_path(&ctx.repository, opts.branch); let raw: serde_json::Value = client.get(&path).await?; @@ -141,7 +140,7 @@ pub async fn run(opts: StatusOptions<'_>, output: &mut dyn Output) -> Result<(), } else { let view: StatusView = serde_json::from_value(raw) .map_err(|e| CliError::Generic(format!("decode merge queue status response: {e}")))?; - emit_human(output, &repository, &view)?; + emit_human(output, &ctx.repository, &view)?; } Ok(()) } diff --git a/crates/mergify-queue/src/unpause.rs b/crates/mergify-queue/src/unpause.rs index a3f03151..14c990e6 100644 --- a/crates/mergify-queue/src/unpause.rs +++ b/crates/mergify-queue/src/unpause.rs @@ -7,12 +7,10 @@ use std::io::Write; -use mergify_core::ApiFlavor; use mergify_core::CliError; +use mergify_core::CommandContext; use mergify_core::DeleteOutcome; -use mergify_core::HttpClient; use mergify_core::Output; -use mergify_core::auth; pub struct UnpauseOptions<'a> { pub repository: Option<&'a str>, @@ -22,14 +20,15 @@ pub struct UnpauseOptions<'a> { /// Run the `queue unpause` command. pub async fn run(opts: UnpauseOptions<'_>, 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)?; + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - output.status(&format!("Unpausing merge queue for {repository}…"))?; + output.status(&format!( + "Unpausing merge queue for {repo}…", + repo = ctx.repository, + ))?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = format!("/v1/repos/{repository}/merge-queue/pause"); + let client = ctx.mergify_client()?; + let path = format!("/v1/repos/{}/merge-queue/pause", ctx.repository); match client.delete_if_exists(&path).await? { DeleteOutcome::Deleted => {