diff --git a/src/config/loader_js.rs b/src/config/loader_js.rs index e4c9b6f5..12a7fca8 100644 --- a/src/config/loader_js.rs +++ b/src/config/loader_js.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use std::path::Path; use crate::error_code::{self, ErrorCodeExt}; +use crate::uri::percent_encode_path; use super::Config; @@ -30,19 +31,6 @@ pub(crate) fn path_to_file_url(path: &Path) -> Result { } } -pub(crate) fn percent_encode_path(path: &str) -> String { - let mut out = String::with_capacity(path.len()); - for byte in path.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => { - out.push(byte as char); - } - _ => out.push_str(&format!("%{byte:02X}")), - } - } - out -} - const LOADER_SCRIPT: &str = r#" const SHELL_SAFE_JS_ESCAPES = { '\\': '\\\\\\\\', "'": "\\'", '"': '\\"', '`': '\\`', '$': '\\$' }; diff --git a/src/config/mod.rs b/src/config/mod.rs index 74815d1d..249a469d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -48,8 +48,6 @@ pub use workspace::{ use format::{CONFIG_FORMATS, DotfileFormat, Json5Format, JsonFormat, TomlFormat}; #[cfg(feature = "cli")] -pub(crate) use loader_js::percent_encode_path; -#[cfg(feature = "cli")] use loader_js::{JS_CONFIG_FILENAME, TS_CONFIG_FILENAME, load_js_ts_config}; #[derive(Debug, Deserialize, Serialize, Default)] diff --git a/src/forge/bitbucket.rs b/src/forge/bitbucket.rs index b0225688..477f5e6a 100644 --- a/src/forge/bitbucket.rs +++ b/src/forge/bitbucket.rs @@ -30,7 +30,7 @@ impl Forge for BitbucketForge { "{}/repositories/{}/refs/tags/{}", self.api_base, self.slug, - crate::config::percent_encode_path(tag) + crate::uri::percent_encode_path(tag) ); let response: serde_json::Value = self .agent diff --git a/src/forge/gitea.rs b/src/forge/gitea.rs index 4c43bd2f..489bcf16 100644 --- a/src/forge/gitea.rs +++ b/src/forge/gitea.rs @@ -1,12 +1,9 @@ use anyhow::{Context, Result}; +use super::rest::RestClient; use super::{Forge, MergeRequestResult, ReleaseResult}; use crate::error_code::{self, ErrorCodeExt}; -const PER_PAGE: u32 = 50; - -const MAX_PAGES: u32 = 100; - pub struct GiteaForge { pub token: String, pub slug: String, @@ -15,31 +12,8 @@ pub struct GiteaForge { } impl GiteaForge { - fn paginated_json_array(&self, base_url: &str, what: &str) -> Result> { - let mut all = Vec::new(); - for page in 1..=MAX_PAGES { - let url = format!("{base_url}?limit={PER_PAGE}&page={page}"); - let body: serde_json::Value = self - .agent - .get(&url) - .header("Authorization", &format!("token {}", self.token)) - .header("User-Agent", "ferrflow") - .call() - .with_context(|| format!("Failed to list {what}"))? - .body_mut() - .read_json() - .with_context(|| format!("Failed to parse {what} response"))?; - let page_items = match body.as_array() { - Some(arr) if !arr.is_empty() => arr.clone(), - _ => return Ok(all), - }; - let len = page_items.len(); - all.extend(page_items); - if (len as u32) < PER_PAGE { - return Ok(all); - } - } - Ok(all) + fn rest(&self) -> RestClient<'_> { + RestClient::gitea(&self.agent, &self.api_base, &self.slug, &self.token) } } @@ -51,64 +25,23 @@ impl Forge for GiteaForge { prerelease: bool, draft: bool, ) -> Result { - let url = format!("{}/repos/{}/releases", self.api_base, self.slug); - - let payload = serde_json::json!({ - "tag_name": tag, - "name": tag, - "body": body, - "draft": draft, - "prerelease": prerelease, - }); - let response: serde_json::Value = self - .agent - .post(&url) - .header("Authorization", &format!("token {}", self.token)) - .header("User-Agent", "ferrflow") - .send_json(payload) + self.rest() + .create_release(tag, body, prerelease, draft) .with_context(|| format!("Failed to create Gitea release for {tag}")) - .error_code(error_code::GITEA_CREATE_RELEASE)? - .body_mut() - .read_json() - .unwrap_or(serde_json::Value::Null); - - Ok(ReleaseResult { - id: response["id"].as_u64(), - url: response["html_url"].as_str().map(str::to_string), - }) + .error_code(error_code::GITEA_CREATE_RELEASE) } fn find_draft_release(&self, tag: &str) -> Result> { - let base_url = format!("{}/repos/{}/releases", self.api_base, self.slug); - let releases = self - .paginated_json_array(&base_url, "Gitea releases") - .error_code(error_code::GITEA_LIST_RELEASES)?; - for release in releases { - if release["draft"].as_bool() == Some(true) - && release["tag_name"].as_str() == Some(tag) - && let Some(id) = release["id"].as_u64() - { - return Ok(Some(id)); - } - } - Ok(None) + self.rest() + .find_draft_release(tag, "Gitea releases") + .error_code(error_code::GITEA_LIST_RELEASES) } fn publish_release(&self, release_id: u64) -> Result<()> { - let url = format!( - "{}/repos/{}/releases/{release_id}", - self.api_base, self.slug - ); - - self.agent - .patch(&url) - .header("Authorization", &format!("token {}", self.token)) - .header("User-Agent", "ferrflow") - .send_json(serde_json::json!({ "draft": false })) + self.rest() + .publish_release(release_id) .with_context(|| format!("Failed to publish Gitea release {release_id}")) - .error_code(error_code::GITEA_PUBLISH_RELEASE)?; - - Ok(()) + .error_code(error_code::GITEA_PUBLISH_RELEASE) } fn create_merge_request( @@ -139,48 +72,19 @@ impl Forge for GiteaForge { } fn find_comment(&self, pr_id: u64, marker: &str) -> Result> { - let base_url = format!( - "{}/repos/{}/issues/{}/comments", - self.api_base, self.slug, pr_id - ); - let comments = self.paginated_json_array(&base_url, "issue comments")?; - for comment in comments { - if let Some(body) = comment["body"].as_str() - && body.contains(marker) - && let Some(id) = comment["id"].as_u64() - { - return Ok(Some(id)); - } - } - Ok(None) + self.rest().find_comment(pr_id, marker, "issue comments") } fn create_comment(&self, pr_id: u64, body: &str) -> Result<()> { - let url = format!( - "{}/repos/{}/issues/{}/comments", - self.api_base, self.slug, pr_id - ); - self.agent - .post(&url) - .header("Authorization", &format!("token {}", self.token)) - .header("User-Agent", "ferrflow") - .send_json(serde_json::json!({ "body": body })) - .with_context(|| "Failed to create issue comment")?; - Ok(()) + self.rest() + .create_comment(pr_id, body) + .context("Failed to create issue comment") } fn update_comment(&self, _pr_id: u64, comment_id: u64, body: &str) -> Result<()> { - let url = format!( - "{}/repos/{}/issues/comments/{}", - self.api_base, self.slug, comment_id - ); - self.agent - .patch(&url) - .header("Authorization", &format!("token {}", self.token)) - .header("User-Agent", "ferrflow") - .send_json(serde_json::json!({ "body": body })) - .with_context(|| "Failed to update issue comment")?; - Ok(()) + self.rest() + .update_comment(comment_id, body) + .context("Failed to update issue comment") } fn find_open_pr(&self, _head: &str, _base: &str) -> Result> { diff --git a/src/forge/github.rs b/src/forge/github.rs index 6c6c34af..5f86a547 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -1,12 +1,9 @@ use anyhow::{Context, Result}; +use super::rest::RestClient; use super::{AuthoredCommit, Forge, MergeRequestResult, ReleaseResult}; use crate::error_code::{self, ErrorCodeExt}; -const PER_PAGE: u32 = 100; - -const MAX_PAGES: u32 = 100; - pub struct GitHubForge { pub token: String, pub slug: String, @@ -15,33 +12,8 @@ pub struct GitHubForge { } impl GitHubForge { - fn paginated_json_array(&self, base_url: &str, what: &str) -> Result> { - let mut all = Vec::new(); - for page in 1..=MAX_PAGES { - let url = format!("{base_url}?per_page={PER_PAGE}&page={page}"); - let body: serde_json::Value = self - .agent - .get(&url) - .header("Authorization", &format!("Bearer {}", self.token)) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "ferrflow") - .call() - .with_context(|| format!("Failed to list {what}"))? - .body_mut() - .read_json() - .with_context(|| format!("Failed to parse {what} response"))?; - let page_items = match body.as_array() { - Some(arr) if !arr.is_empty() => arr.clone(), - _ => return Ok(all), - }; - let len = page_items.len(); - all.extend(page_items); - if (len as u32) < PER_PAGE { - return Ok(all); - } - } - Ok(all) + fn rest(&self) -> RestClient<'_> { + RestClient::github(&self.agent, &self.api_base, &self.slug, &self.token) } } @@ -158,7 +130,7 @@ impl Forge for GitHubForge { "{}/repos/{}/git/refs/heads/{}", self.api_base, self.slug, - crate::config::percent_encode_path(branch) + crate::uri::percent_encode_path(branch) ); let patched = self .agent @@ -195,33 +167,10 @@ impl Forge for GitHubForge { prerelease: bool, draft: bool, ) -> Result { - let url = format!("{}/repos/{}/releases", self.api_base, self.slug); - - let payload = serde_json::json!({ - "tag_name": tag, - "name": tag, - "body": body, - "draft": draft, - "prerelease": prerelease, - }); - let response: serde_json::Value = self - .agent - .post(&url) - .header("Authorization", &format!("Bearer {}", self.token)) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "ferrflow") - .send_json(payload) + self.rest() + .create_release(tag, body, prerelease, draft) .with_context(|| format!("Failed to create GitHub release for {tag}")) - .error_code(error_code::GITHUB_CREATE_RELEASE)? - .body_mut() - .read_json() - .unwrap_or(serde_json::Value::Null); - - Ok(ReleaseResult { - id: response["id"].as_u64(), - url: response["html_url"].as_str().map(str::to_string), - }) + .error_code(error_code::GITHUB_CREATE_RELEASE) } fn delete_release(&self, id: u64) -> Result<()> { @@ -237,42 +186,16 @@ impl Forge for GitHubForge { } fn find_draft_release(&self, tag: &str) -> Result> { - let base_url = format!("{}/repos/{}/releases", self.api_base, self.slug); - let releases = self - .paginated_json_array(&base_url, "GitHub releases") - .error_code(error_code::GITHUB_LIST_RELEASES)?; - for release in releases { - if release["draft"].as_bool() == Some(true) - && release["tag_name"].as_str() == Some(tag) - && let Some(id) = release["id"].as_u64() - { - return Ok(Some(id)); - } - } - Ok(None) + self.rest() + .find_draft_release(tag, "GitHub releases") + .error_code(error_code::GITHUB_LIST_RELEASES) } fn publish_release(&self, release_id: u64) -> Result<()> { - let url = format!( - "{}/repos/{}/releases/{release_id}", - self.api_base, self.slug - ); - - let payload = serde_json::json!({ - "draft": false, - }); - - self.agent - .patch(&url) - .header("Authorization", &format!("Bearer {}", self.token)) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "ferrflow") - .send_json(payload) + self.rest() + .publish_release(release_id) .with_context(|| format!("Failed to publish GitHub release {release_id}")) - .error_code(error_code::GITHUB_PUBLISH_RELEASE)?; - - Ok(()) + .error_code(error_code::GITHUB_PUBLISH_RELEASE) } fn create_merge_request( @@ -372,50 +295,19 @@ impl Forge for GitHubForge { } fn find_comment(&self, pr_id: u64, marker: &str) -> Result> { - let base_url = format!( - "{}/repos/{}/issues/{}/comments", - self.api_base, self.slug, pr_id - ); - let comments = self.paginated_json_array(&base_url, "PR comments")?; - for comment in comments { - if let Some(body) = comment["body"].as_str() - && body.contains(marker) - && let Some(id) = comment["id"].as_u64() - { - return Ok(Some(id)); - } - } - Ok(None) + self.rest().find_comment(pr_id, marker, "PR comments") } fn create_comment(&self, pr_id: u64, body: &str) -> Result<()> { - let url = format!( - "{}/repos/{}/issues/{}/comments", - self.api_base, self.slug, pr_id - ); - self.agent - .post(&url) - .header("Authorization", &format!("Bearer {}", self.token)) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "ferrflow") - .send_json(serde_json::json!({ "body": body })) - .with_context(|| "Failed to create PR comment")?; - Ok(()) + self.rest() + .create_comment(pr_id, body) + .context("Failed to create PR comment") } fn update_comment(&self, _pr_id: u64, comment_id: u64, body: &str) -> Result<()> { - let url = format!( - "{}/repos/{}/issues/comments/{}", - self.api_base, self.slug, comment_id - ); - self.agent - .patch(&url) - .header("Authorization", &format!("Bearer {}", self.token)) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "ferrflow") - .send_json(serde_json::json!({ "body": body })) - .with_context(|| "Failed to update PR comment")?; - Ok(()) + self.rest() + .update_comment(comment_id, body) + .context("Failed to update PR comment") } fn find_open_pr(&self, head: &str, base: &str) -> Result> { diff --git a/src/forge/gitlab.rs b/src/forge/gitlab.rs index 068f76be..56bd9f30 100644 --- a/src/forge/gitlab.rs +++ b/src/forge/gitlab.rs @@ -1,12 +1,10 @@ use anyhow::{Context, Result}; use colored::Colorize; +use super::rest::RestClient; use super::{Forge, MergeRequestResult, ReleaseResult}; use crate::error_code::{self, ErrorCodeExt}; -const PER_PAGE: u32 = 100; -const MAX_PAGES: u32 = 100; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitLabToken { Private, @@ -54,30 +52,13 @@ impl GitLabForge { } fn paginated_json_array(&self, base_url: &str, what: &str) -> Result> { - let mut all = Vec::new(); - for page in 1..=MAX_PAGES { - let url = format!("{base_url}?per_page={PER_PAGE}&page={page}"); - let body: serde_json::Value = self - .agent - .get(&url) - .header(self.token_kind.header(), &self.token) - .header("User-Agent", "ferrflow") - .call() - .with_context(|| format!("Failed to list {what}"))? - .body_mut() - .read_json() - .with_context(|| format!("Failed to parse {what} response"))?; - let page_items = match body.as_array() { - Some(arr) if !arr.is_empty() => arr.clone(), - _ => return Ok(all), - }; - let len = page_items.len(); - all.extend(page_items); - if (len as u32) < PER_PAGE { - return Ok(all); - } - } - Ok(all) + RestClient::gitlab( + &self.agent, + format!("{}/projects/{}", self.api_base, self.encoded_project_id()), + self.token_kind.header(), + &self.token, + ) + .paginated_json_array(base_url, what) } } diff --git a/src/forge/mod.rs b/src/forge/mod.rs index 3029b72e..8334e60c 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -2,6 +2,7 @@ pub mod bitbucket; pub mod gitea; pub mod github; pub mod gitlab; +mod rest; #[cfg(test)] pub(crate) mod test_server; diff --git a/src/forge/rest.rs b/src/forge/rest.rs new file mode 100644 index 00000000..3b660ca9 --- /dev/null +++ b/src/forge/rest.rs @@ -0,0 +1,174 @@ +use anyhow::{Context, Result}; +use ureq::RequestBuilder; +use ureq::typestate::{WithBody, WithoutBody}; + +use super::ReleaseResult; + +const MAX_PAGES: u32 = 100; + +pub(super) struct RestClient<'a> { + agent: &'a ureq::Agent, + repo_url: String, + auth: (&'static str, String), + extra_headers: &'static [(&'static str, &'static str)], + page_size_param: &'static str, + page_size: u32, +} + +impl<'a> RestClient<'a> { + pub fn github(agent: &'a ureq::Agent, api_base: &str, slug: &str, token: &str) -> Self { + Self { + agent, + repo_url: format!("{api_base}/repos/{slug}"), + auth: ("Authorization", format!("Bearer {token}")), + extra_headers: &[ + ("Accept", "application/vnd.github+json"), + ("X-GitHub-Api-Version", "2022-11-28"), + ], + page_size_param: "per_page", + page_size: 100, + } + } + + pub fn gitea(agent: &'a ureq::Agent, api_base: &str, slug: &str, token: &str) -> Self { + Self { + agent, + repo_url: format!("{api_base}/repos/{slug}"), + auth: ("Authorization", format!("token {token}")), + extra_headers: &[], + page_size_param: "limit", + page_size: 50, + } + } + + pub fn gitlab( + agent: &'a ureq::Agent, + project_url: String, + token_header: &'static str, + token: &str, + ) -> Self { + Self { + agent, + repo_url: project_url, + auth: (token_header, token.to_string()), + extra_headers: &[], + page_size_param: "per_page", + page_size: 100, + } + } + + fn headers(&self, mut request: RequestBuilder) -> RequestBuilder { + request = request + .header(self.auth.0, &self.auth.1) + .header("User-Agent", "ferrflow"); + for (name, value) in self.extra_headers { + request = request.header(*name, *value); + } + request + } + + fn get(&self, url: &str) -> RequestBuilder { + self.headers(self.agent.get(url)) + } + + fn post(&self, url: &str) -> RequestBuilder { + self.headers(self.agent.post(url)) + } + + fn patch(&self, url: &str) -> RequestBuilder { + self.headers(self.agent.patch(url)) + } + + pub fn paginated_json_array( + &self, + base_url: &str, + what: &str, + ) -> Result> { + let mut all = Vec::new(); + for page in 1..=MAX_PAGES { + let body: serde_json::Value = self + .get(base_url) + .query(self.page_size_param, self.page_size.to_string()) + .query("page", page.to_string()) + .call() + .with_context(|| format!("Failed to list {what}"))? + .body_mut() + .read_json() + .with_context(|| format!("Failed to parse {what} response"))?; + let page_items = match body.as_array() { + Some(arr) if !arr.is_empty() => arr.clone(), + _ => return Ok(all), + }; + let len = page_items.len(); + all.extend(page_items); + if (len as u32) < self.page_size { + return Ok(all); + } + } + Ok(all) + } + + pub fn create_release( + &self, + tag: &str, + body: &str, + prerelease: bool, + draft: bool, + ) -> Result { + let response: serde_json::Value = self + .post(&format!("{}/releases", self.repo_url)) + .send_json(serde_json::json!({ + "tag_name": tag, + "name": tag, + "body": body, + "draft": draft, + "prerelease": prerelease, + }))? + .body_mut() + .read_json() + .unwrap_or(serde_json::Value::Null); + Ok(ReleaseResult { + id: response["id"].as_u64(), + url: response["html_url"].as_str().map(str::to_string), + }) + } + + pub fn find_draft_release(&self, tag: &str, what: &str) -> Result> { + let releases = self.paginated_json_array(&format!("{}/releases", self.repo_url), what)?; + Ok(releases.iter().find_map(|release| { + (release["draft"].as_bool() == Some(true) && release["tag_name"].as_str() == Some(tag)) + .then(|| release["id"].as_u64()) + .flatten() + })) + } + + pub fn publish_release(&self, release_id: u64) -> Result<()> { + self.patch(&format!("{}/releases/{release_id}", self.repo_url)) + .send_json(serde_json::json!({ "draft": false }))?; + Ok(()) + } + + pub fn find_comment(&self, pr_id: u64, marker: &str, what: &str) -> Result> { + let comments = + self.paginated_json_array(&format!("{}/issues/{pr_id}/comments", self.repo_url), what)?; + Ok(comments.iter().find_map(|comment| { + comment["body"] + .as_str() + .is_some_and(|body| body.contains(marker)) + .then(|| comment["id"].as_u64()) + .flatten() + })) + } + + pub fn create_comment(&self, pr_id: u64, body: &str) -> Result<()> { + self.post(&format!("{}/issues/{pr_id}/comments", self.repo_url)) + .send_json(serde_json::json!({ "body": body }))?; + Ok(()) + } + + pub fn update_comment(&self, comment_id: u64, body: &str) -> Result<()> { + self.patch(&format!("{}/issues/comments/{comment_id}", self.repo_url)) + .send_json(serde_json::json!({ "body": body }))?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1bf126d7..32575101 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod error_code; pub mod formats; pub mod prerelease; pub mod schema; +pub mod uri; pub mod validate; pub mod versioning; diff --git a/src/main.rs b/src/main.rs index 08ac00af..7ebeaf37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod schema; mod shadow; mod status; mod timing; +mod uri; mod validate; mod version_diff; mod versioning; diff --git a/src/uri.rs b/src/uri.rs new file mode 100644 index 00000000..404573d4 --- /dev/null +++ b/src/uri.rs @@ -0,0 +1,12 @@ +pub fn percent_encode_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for byte in path.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +}