diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1751446..197be54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - rust_version: [stable, "1.41.0"] + rust_version: [stable, "1.42.0"] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f346c3..66aa65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Foreman Changelog ## Unreleased Changes +- Support tools hosted on GitLab ([#31](https://github.com/Roblox/foreman/pull/31)) + - Updated config format to support both GitHub and GitLab tools + - Added `foreman gitlab-auth` command for authenticating with GitLab. - Logging improvements ([#30](https://github.com/Roblox/foreman/pull/30)) - Add commandline option to increase logging level (`-v`, `-vv`, etc) - Add an INFO-level log explaining when a release version tag name doesn't match expected convention. - Default logging to INFO level. Fixes ([#27]https://github.com/Roblox/foreman/issues/27). - ## 1.0.2 (2020-05-20) - Fixed Foreman not propagating error codes from underlying tools. ([#20](https://github.com/Roblox/foreman/pull/20)) diff --git a/Cargo.lock b/Cargo.lock index 3103fb8..742f8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,7 @@ dependencies = [ "structopt", "toml", "toml_edit", + "urlencoding", "zip", ] @@ -1369,6 +1370,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" + [[package]] name = "vcpkg" version = "0.2.8" diff --git a/Cargo.toml b/Cargo.toml index d2236cf..feb1080 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,5 @@ serde_json = "1.0.47" structopt = "0.3.20" toml = "0.5.6" toml_edit = "0.1.5" +urlencoding = "2.1.0" zip = "0.5" diff --git a/README.md b/README.md index 2fd0190..a5dc34d 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,33 @@ You can download pre-built Foreman releases for Windows, macOS, and Linux from t You can use the official [setup-foreman](https://github.com/rojo-rbx/setup-foreman) action to install Foreman as part of your GitHub Actions workflow. ### From Source -If you have [Rust](https://www.rust-lang.org/) 1.41.0 or newer installed, you can also compile Foreman by installing it from [crates.io](https://crates.io): +If you have [Rust](https://www.rust-lang.org/) 1.42.0 or newer installed, you can also compile Foreman by installing it from [crates.io](https://crates.io): ```bash cargo install foreman ``` ## Usage -Foreman downloads tools from GitHub and references them by their `user/repo` name, like `Roblox/foreman`. +Foreman downloads tools from GitHub or GitLab and references them by their `user/repo` name, like `Roblox/foreman`. On first run (try `foreman list`), Foreman will create a `.foreman` directory in your user folder (usually `~/.foreman` on Unix systems, `%USERPROFILE%/.foreman` on Windows). It's recommended that you **add `~/.foreman/bin` to your `PATH`** to make the tools that Foreman installs for you accessible on your system. +### Configuration File + +Foreman uses [TOML](https://toml.io/en/) for its configuration file. It simply takes a single `tools` entry and an enumeration of the tools you need, which looks like this: + +```toml +[tools] +rojo = { github = "rojo-rbx/rojo", version = "7.0.0" } +darklua = { gitlab = "seaofvoices/darklua", version = "0.7.0" } +``` + +As you may already have noticed, the tool name is located at the left side of `=` and the right side contains the information necessary to download it. For GitHub tools, use `github = "user/repo-name"` and for GitLab, use `gitlab = "user/repo-name"`. + +Previously, foreman was only able to download tools from GitHub and the format used to be `source = "rojo-rbx/rojo"`. For backward compatibility, foreman still supports this format. + ### System Tools To start using Foreman to manage your system's default tools, create the file `~/.foreman/foreman.toml`. @@ -39,7 +53,7 @@ A Foreman config that lists Rojo could look like: ```toml [tools] -rojo = { source = "rojo-rbx/rojo", version = "0.5.0" } +rojo = { github = "rojo-rbx/rojo", version = "7.0.0" } ``` Run `foreman install` from any directory to have Foreman pick up and install the tools listed in your system's Foreman config. @@ -53,7 +67,7 @@ A Foreman config that lists Remodel might look like this: ```toml [tools] -remodel = { source = "rojo-rbx/remodel", version = "0.6.1" } +remodel = { github = "rojo-rbx/remodel", version = "0.9.1" } ``` Run `foreman install` to tell Foreman to install any new binaries from this config file. @@ -65,6 +79,8 @@ To install tools from a private GitHub repository, Foreman supports authenticati Use `foreman github-auth` to pass an authentication token to Foreman, or open `~/.foreman/auth.toml` and follow the contained instructions. +Similarly, for projects hosted on a GitLab repository, use `foreman gitlab-auth` to pass an authentication token to Foreman, or open `~/.foreman/auth.toml`. + ## Troubleshooting Foreman is a work in progress tool and has some known issues. Check out [the issue tracker](https://github.com/Roblox/foreman/issues) for known bugs. diff --git a/resources/default-auth.toml b/resources/default-auth.toml index ea5a3e7..801f332 100644 --- a/resources/default-auth.toml +++ b/resources/default-auth.toml @@ -4,6 +4,14 @@ # `github` key. This is useful if you hit GitHub API rate limits or if you need # to access private tools. +# github = "YOUR_TOKEN_HERE" + +# For authenticating with GitLab.com, put a personal access token here under the +# `gitlab` key. This is useful if you hit GitLab API rate limits or if you need +# to access private tools. + +# gitlab = "YOUR_TOKEN_HERE" + # You can also run `foreman github-auth` to update this file, optionally passing # the token as the first argument. diff --git a/src/auth_store.rs b/src/auth_store.rs index 8775812..d5ee9b7 100644 --- a/src/auth_store.rs +++ b/src/auth_store.rs @@ -11,6 +11,7 @@ pub static DEFAULT_AUTH_CONFIG: &str = include_str!("../resources/default-auth.t #[derive(Debug, Default, Serialize, Deserialize)] pub struct AuthStore { pub github: Option, + pub gitlab: Option, } impl AuthStore { @@ -21,9 +22,16 @@ impl AuthStore { Ok(contents) => { let store: AuthStore = toml::from_slice(&contents).unwrap(); + let mut found_credentials = false; if store.github.is_some() { log::debug!("Found GitHub credentials"); - } else { + found_credentials = true; + } + if store.gitlab.is_some() { + log::debug!("Found GitLab credentials"); + found_credentials = true; + } + if !found_credentials { log::debug!("Found no credentials"); } @@ -40,6 +48,14 @@ impl AuthStore { } pub fn set_github_token(token: &str) -> io::Result<()> { + Self::set_token("github", token) + } + + pub fn set_gitlab_token(token: &str) -> io::Result<()> { + Self::set_token("gitlab", token) + } + + fn set_token(key: &str, token: &str) -> io::Result<()> { let contents = match fs::read_to_string(paths::auth_store()) { Ok(contents) => contents, Err(err) => { @@ -52,7 +68,7 @@ impl AuthStore { }; let mut store: Document = contents.parse().unwrap(); - store["github"] = value(token); + store[key] = value(token); let serialized = store.to_string(); fs::write(paths::auth_store(), serialized) diff --git a/src/ci_string.rs b/src/ci_string.rs index b11adfa..a821cbd 100644 --- a/src/ci_string.rs +++ b/src/ci_string.rs @@ -13,6 +13,12 @@ use serde::{Deserialize, Serialize}; #[serde(transparent)] pub struct CiString(pub String); +impl From<&str> for CiString { + fn from(string: &str) -> Self { + Self(string.to_owned()) + } +} + impl fmt::Display for CiString { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str(&self.0) diff --git a/src/config.rs b/src/config.rs index 50fc49b..6f9eb40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,19 +1,73 @@ -use std::{collections::HashMap, env, io}; +use std::{collections::HashMap, env, fmt, io}; use semver::VersionReq; use serde::{Deserialize, Serialize}; -use crate::{fs, paths}; +use crate::{ci_string::CiString, fs, paths, tool_provider::Provider}; #[derive(Debug, Serialize, Deserialize)] pub struct ConfigFile { pub tools: HashMap, } -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolSpec { - pub source: String, - pub version: VersionReq, +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolSpec { + Github { + // alias to `source` for backward compatibilty + #[serde(alias = "source")] + github: String, + version: VersionReq, + }, + Gitlab { + gitlab: String, + version: VersionReq, + }, +} + +impl ToolSpec { + pub fn cache_key(&self) -> CiString { + match self { + ToolSpec::Github { github, .. } => CiString(github.clone()), + ToolSpec::Gitlab { gitlab, .. } => CiString(format!("gitlab@{}", gitlab)), + } + } + + pub fn source(&self) -> &str { + match self { + ToolSpec::Github { github: source, .. } | ToolSpec::Gitlab { gitlab: source, .. } => { + source + } + } + } + + pub fn version(&self) -> &VersionReq { + match self { + ToolSpec::Github { version, .. } | ToolSpec::Gitlab { version, .. } => version, + } + } + + pub fn provider(&self) -> Provider { + match self { + ToolSpec::Github { .. } => Provider::Github, + ToolSpec::Gitlab { .. } => Provider::Gitlab, + } + } +} + +impl fmt::Display for ToolSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}.com/{}@{}", + match self { + ToolSpec::Github { .. } => "github", + ToolSpec::Gitlab { .. } => "gitlab", + }, + self.source(), + self.version(), + ) + } } impl ConfigFile { @@ -75,3 +129,67 @@ impl ConfigFile { Ok(config) } } + +#[cfg(test)] +mod test { + use super::*; + + fn new_github>(github: S, version: VersionReq) -> ToolSpec { + ToolSpec::Github { + github: github.into(), + version, + } + } + + fn new_gitlab>(github: S, version: VersionReq) -> ToolSpec { + ToolSpec::Gitlab { + gitlab: github.into(), + version, + } + } + + fn version(string: &str) -> VersionReq { + VersionReq::parse(string).unwrap() + } + + mod deserialization { + use super::*; + + #[test] + fn github_from_source_field() { + let github: ToolSpec = + toml::from_str(&[r#"source = "user/repo""#, r#"version = "0.1.0""#].join("\n")) + .unwrap(); + assert_eq!(github, new_github("user/repo", version("0.1.0"))); + } + + #[test] + fn github_from_github_field() { + let github: ToolSpec = + toml::from_str(&[r#"github = "user/repo""#, r#"version = "0.1.0""#].join("\n")) + .unwrap(); + assert_eq!(github, new_github("user/repo", version("0.1.0"))); + } + + #[test] + fn gitlab_from_gitlab_field() { + let gitlab: ToolSpec = + toml::from_str(&[r#"gitlab = "user/repo""#, r#"version = "0.1.0""#].join("\n")) + .unwrap(); + assert_eq!(gitlab, new_gitlab("user/repo", version("0.1.0"))); + } + } + + #[test] + fn tool_cache_entry_is_backward_compatible() { + let github = new_github("user/repo", version("7.0.0")); + assert_eq!(github.cache_key(), "user/repo".into()); + } + + #[test] + fn tool_cache_entry_is_different_for_github_and_gitlab_identical_projects() { + let github = new_github("user/repo", version("7.0.0")); + let gitlab = new_gitlab("user/repo", version("7.0.0")); + assert_ne!(github.cache_key(), gitlab.cache_key()); + } +} diff --git a/src/github.rs b/src/github.rs deleted file mode 100644 index ac44a11..0000000 --- a/src/github.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Slice of GitHub's API that Foreman consumes. - -use std::io::Write; - -use reqwest::{ - blocking::Client, - header::{ACCEPT, AUTHORIZATION, USER_AGENT}, -}; -use serde::{Deserialize, Serialize}; - -use crate::auth_store::AuthStore; - -pub fn get_releases(repo: &str) -> reqwest::Result> { - log::debug!("Downloading releases for {}", repo); - - let client = Client::new(); - - let url = format!("https://api.github.com/repos/{}/releases", repo); - let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); - - let auth_store = AuthStore::load().unwrap(); - if let Some(token) = &auth_store.github { - builder = builder.header(AUTHORIZATION, format!("token {}", token)); - } - - let response_body = builder.send()?.text()?; - - let releases: Vec = match serde_json::from_str(&response_body) { - Ok(releases) => releases, - Err(err) => { - log::error!("Unexpected GitHub API response: {}", response_body); - panic!("{}", err); - } - }; - - Ok(releases) -} - -pub fn download_asset(url: &str, mut output: W) -> reqwest::Result<()> { - log::debug!("Downloading release asset {}", url); - - let client = Client::new(); - - let mut builder = client - .get(url) - .header(USER_AGENT, "Roblox/foreman") - // Setting `Accept` is required to make the GitHub API return the actual - // release asset instead of JSON metadata about the release. - .header(ACCEPT, "application/octet-stream"); - - let auth_store = AuthStore::load().unwrap(); - if let Some(token) = &auth_store.github { - builder = builder.header(AUTHORIZATION, format!("token {}", token)); - } - - let mut response = builder.send()?; - response.copy_to(&mut output)?; - - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Release { - pub tag_name: String, - pub prerelease: bool, - pub assets: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ReleaseAsset { - pub url: String, - pub name: String, -} diff --git a/src/main.rs b/src/main.rs index 2d13dc7..9e1c7e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ mod auth_store; mod ci_string; mod config; mod fs; -mod github; mod paths; mod tool_cache; +mod tool_provider; use std::{env, error::Error, io, process}; @@ -64,13 +64,13 @@ fn main() -> Result<(), Box> { let config = ConfigFile::aggregate()?; if let Some(tool_spec) = config.tools.get(&invocation.name) { - log::debug!("Found tool spec {}@{}", tool_spec.source, tool_spec.version); + log::debug!("Found tool spec {}", tool_spec); - let maybe_version = - ToolCache::download_if_necessary(&tool_spec.source, &tool_spec.version); + let mut tool_cache = ToolCache::load()?; + let maybe_version = tool_cache.download_if_necessary(tool_spec); if let Some(version) = maybe_version { - let exit_code = ToolCache::run(&tool_spec.source, &version, invocation.args); + let exit_code = ToolCache::run(tool_spec, &version, invocation.args); if exit_code != 0 { process::exit(exit_code); @@ -117,6 +117,13 @@ enum Subcommand { /// This token can also be configured by editing ~/.foreman/auth.toml. #[structopt(name = "github-auth")] GitHubAuth(GitHubAuthCommand), + + /// Set the GitLab Personal Access Token that Foreman should use with the + /// GitLab API. + /// + /// This token can also be configured by editing ~/.foreman/auth.toml. + #[structopt(name = "gitlab-auth")] + GitLabAuth(GitLabAuthCommand), } #[derive(Debug, StructOpt)] @@ -127,6 +134,14 @@ struct GitHubAuthCommand { token: Option, } +#[derive(Debug, StructOpt)] +struct GitLabAuthCommand { + /// GitLab personal access token that Foreman should use. + /// + /// If not specified, Foreman will prompt for it. + token: Option, +} + fn actual_main() -> io::Result<()> { let options = Options::from_args(); @@ -136,15 +151,17 @@ fn actual_main() -> io::Result<()> { log::trace!("Installing from gathered config: {:#?}", config); + let mut cache = ToolCache::load()?; + for (tool_alias, tool_spec) in &config.tools { - ToolCache::download_if_necessary(&tool_spec.source, &tool_spec.version); + cache.download_if_necessary(tool_spec); add_self_alias(tool_alias); } } Subcommand::List => { println!("Installed tools:"); - let cache = ToolCache::load().unwrap(); + let cache = ToolCache::load()?; for (tool_source, tool) in &cache.tools { println!(" {}", tool_source); @@ -155,30 +172,54 @@ fn actual_main() -> io::Result<()> { } } Subcommand::GitHubAuth(subcommand) => { - let token = match subcommand.token { - Some(token) => token, - None => { - println!("Foreman authenticates to GitHub using Personal Access Tokens."); - println!("https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line"); - println!(); - - loop { - let token = rpassword::read_password_from_tty(Some("GitHub Token: "))?; - - if token.is_empty() { - println!("Token must be non-empty."); - } else { - break token; - } - } - } - }; + let token = prompt_auth_token( + subcommand.token, + "GitHub", + "https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line", + )?; AuthStore::set_github_token(&token)?; println!("GitHub auth saved successfully."); } + Subcommand::GitLabAuth(subcommand) => { + let token = prompt_auth_token( + subcommand.token, + "GitLab", + "https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html", + )?; + + AuthStore::set_gitlab_token(&token)?; + + println!("GitLab auth saved successfully."); + } } Ok(()) } + +fn prompt_auth_token(token: Option, provider: &str, help: &str) -> io::Result { + match token { + Some(token) => Ok(token), + None => { + println!("{} auth saved successfully.", provider); + println!( + "Foreman authenticates to {} using Personal Access Tokens.", + provider + ); + println!("{}", help); + println!(); + + loop { + let token = + rpassword::read_password_from_tty(Some(&format!("{} Token: ", provider)))?; + + if token.is_empty() { + println!("Token must be non-empty."); + } else { + break Ok(token); + } + } + } + } +} diff --git a/src/tool_cache.rs b/src/tool_cache.rs index 1765b07..05601c6 100644 --- a/src/tool_cache.rs +++ b/src/tool_cache.rs @@ -6,15 +6,17 @@ use std::{ process, }; -use semver::{Version, VersionReq}; +use semver::Version; use serde::{Deserialize, Serialize}; use zip::ZipArchive; use crate::{ artifact_choosing::platform_keywords, ci_string::CiString, + config::ToolSpec, fs::{self, File}, - github, paths, + paths, + tool_provider::ToolProvider, }; fn index_file() -> PathBuf { @@ -27,15 +29,17 @@ fn index_file() -> PathBuf { #[derive(Debug, Default, Serialize, Deserialize)] pub struct ToolCache { pub tools: HashMap, + #[serde(skip)] + providers: ToolProvider, } impl ToolCache { #[must_use] - pub fn run(source: &str, version: &Version, args: Vec) -> i32 { - log::debug!("Running tool {}@{}", source, version); + pub fn run(tool: &ToolSpec, version: &Version, args: Vec) -> i32 { + log::debug!("Running tool {}", tool); let mut tool_path = paths::tools_dir(); - let exe_name = tool_identifier_to_exe_name(source, version); + let exe_name = tool_identifier_to_exe_name(tool, version); tool_path.push(exe_name); let status = process::Command::new(tool_path) @@ -46,30 +50,29 @@ impl ToolCache { status.code().unwrap_or(1) } - pub fn download_if_necessary(source: &str, version_req: &VersionReq) -> Option { - let cache = Self::load().unwrap(); - - if let Some(tool) = cache.tools.get(&CiString(source.to_owned())) { + pub fn download_if_necessary(&mut self, tool: &ToolSpec) -> Option { + if let Some(tool_entry) = self.tools.get(&tool.cache_key()) { log::debug!("Tool has some versions installed"); - let matching_version = tool + let matching_version = tool_entry .versions .iter() .rev() - .find(|version| version_req.matches(version)); + .find(|version| tool.version().matches(version)); if let Some(version) = matching_version { return Some(version.clone()); } } - Self::download(source, version_req) + self.download(tool) } - pub fn download(source: &str, version_req: &VersionReq) -> Option { - log::info!("Downloading {}@{}", source, version_req); + pub fn download(&mut self, tool: &ToolSpec) -> Option { + log::info!("Downloading {}", tool); - let releases = github::get_releases(source).unwrap(); + let provider = self.providers.get(&tool.provider()); + let releases = provider.get_releases(tool.source()).unwrap(); // Filter down our set of releases to those that are valid versions and // have release assets for our current platform. @@ -104,6 +107,7 @@ impl ToolCache { // descending version numbers. semver_releases.sort_by(|a, b| b.0.cmp(&a.0)); + let version_req = tool.version(); let matching_release = semver_releases .into_iter() .find(|(version, _asset_index, _release)| version_req.matches(version)); @@ -112,15 +116,14 @@ impl ToolCache { log::trace!("Picked version {}", version); let url = &release.assets[asset_index].url; - let mut buffer = Vec::new(); - github::download_asset(url, &mut buffer).unwrap(); + let buffer = provider.download_asset(url).unwrap(); log::trace!("Extracting downloaded artifact"); let mut archive = ZipArchive::new(Cursor::new(&buffer)).unwrap(); let mut file = archive.by_index(0).unwrap(); let mut tool_path = paths::tools_dir(); - let exe_name = tool_identifier_to_exe_name(source, &version); + let exe_name = tool_identifier_to_exe_name(tool, &version); tool_path.push(exe_name); let mut output = BufWriter::new(File::create(&tool_path).unwrap()); @@ -135,16 +138,15 @@ impl ToolCache { } log::trace!("Updating tool cache"); - let mut cache = Self::load().unwrap(); - let tool = cache.tools.entry(CiString(source.to_owned())).or_default(); - tool.versions.insert(version.clone()); - cache.save().unwrap(); + let tool_entry = self.tools.entry(tool.cache_key()).or_default(); + tool_entry.versions.insert(version.clone()); + self.save().unwrap(); Some(version) } else { log::error!( "No compatible version of {} was found for version requirement {}", - source, + tool.source(), version_req ); @@ -176,8 +178,8 @@ pub struct ToolEntry { pub versions: BTreeSet, } -fn tool_identifier_to_exe_name(source: &str, version: &Version) -> String { - let mut name = format!("{}-{}{}", source, version, EXE_SUFFIX); +fn tool_identifier_to_exe_name(tool: &ToolSpec, version: &Version) -> String { + let mut name = format!("{}-{}{}", tool.cache_key().0, version, EXE_SUFFIX); name = name.replace('/', "__"); name.replace('\\', "__") } diff --git a/src/tool_provider/github.rs b/src/tool_provider/github.rs new file mode 100644 index 0000000..e4d12f7 --- /dev/null +++ b/src/tool_provider/github.rs @@ -0,0 +1,98 @@ +//! Slice of GitHub's API that Foreman consumes. + +use reqwest::{ + blocking::Client, + header::{ACCEPT, AUTHORIZATION, USER_AGENT}, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth_store::AuthStore; + +use super::{Release, ReleaseAsset, ToolProviderImpl}; + +#[derive(Debug, Default)] +pub struct GithubProvider {} + +impl ToolProviderImpl for GithubProvider { + fn get_releases(&self, repo: &str) -> reqwest::Result> { + log::debug!("Downloading github releases for {}", repo); + + let client = Client::new(); + + let url = format!("https://api.github.com/repos/{}/releases", repo); + let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); + + let auth_store = AuthStore::load().unwrap(); + if let Some(token) = &auth_store.github { + builder = builder.header(AUTHORIZATION, format!("token {}", token)); + } + + let response_body = builder.send()?.text()?; + + let releases: Vec = match serde_json::from_str(&response_body) { + Ok(releases) => releases, + Err(err) => { + log::error!("Unexpected GitHub API response: {}", response_body); + panic!("{}", err); + } + }; + + Ok(releases.into_iter().map(Into::into).collect()) + } + + fn download_asset(&self, url: &str) -> reqwest::Result> { + log::debug!("Downloading release asset {}", url); + + let client = Client::new(); + + let mut builder = client + .get(url) + .header(USER_AGENT, "Roblox/foreman") + // Setting `Accept` is required to make the GitHub API return the actual + // release asset instead of JSON metadata about the release. + .header(ACCEPT, "application/octet-stream"); + + let auth_store = AuthStore::load().unwrap(); + if let Some(token) = &auth_store.github { + builder = builder.header(AUTHORIZATION, format!("token {}", token)); + } + + let mut response = builder.send()?; + + let mut output = Vec::new(); + response.copy_to(&mut output)?; + Ok(output) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct GithubRelease { + pub tag_name: String, + pub prerelease: bool, + pub assets: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GithubAsset { + pub url: String, + pub name: String, +} + +impl From for Release { + fn from(release: GithubRelease) -> Self { + Release { + tag_name: release.tag_name, + prerelease: release.prerelease, + assets: release.assets.into_iter().map(Into::into).collect(), + } + } +} + +impl From for ReleaseAsset { + fn from(asset: GithubAsset) -> Self { + ReleaseAsset { + url: asset.url, + name: asset.name, + } + } +} diff --git a/src/tool_provider/gitlab.rs b/src/tool_provider/gitlab.rs new file mode 100644 index 0000000..6ef636a --- /dev/null +++ b/src/tool_provider/gitlab.rs @@ -0,0 +1,106 @@ +//! Slice of Gitlab's API that Foreman consumes. + +use reqwest::{ + blocking::Client, + header::{ACCEPT, USER_AGENT}, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth_store::AuthStore; + +use super::{Release, ReleaseAsset, ToolProviderImpl}; + +#[derive(Debug, Default)] +pub struct GitlabProvider {} + +impl ToolProviderImpl for GitlabProvider { + fn get_releases(&self, repo: &str) -> reqwest::Result> { + log::debug!("Downloading gitlab releases for {}", repo); + + let client = Client::new(); + + let url = format!( + "https://gitlab.com/api/v4/projects/{}/releases", + urlencoding::encode(repo) + ); + let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); + + let auth_store = AuthStore::load().unwrap(); + if let Some(token) = &auth_store.gitlab { + builder = builder.header("PRIVATE-TOKEN", token); + } + let response_body = builder.send()?.text()?; + + let releases: Vec = match serde_json::from_str(&response_body) { + Ok(releases) => releases, + Err(err) => { + log::error!("Unexpected GitLab API response: {}", response_body); + panic!("{}", err); + } + }; + + Ok(releases.into_iter().map(Into::into).collect()) + } + + fn download_asset(&self, url: &str) -> reqwest::Result> { + log::debug!("Downloading release asset {}", url); + + let client = Client::new(); + + let mut builder = client + .get(url) + .header(USER_AGENT, "Roblox/foreman") + // Setting `Accept` is required to make the GitLab API return the actual + // release asset instead of JSON metadata about the release. + .header(ACCEPT, "application/octet-stream"); + + let auth_store = AuthStore::load().unwrap(); + if let Some(token) = &auth_store.gitlab { + builder = builder.header("PRIVATE-TOKEN", token); + } + + let mut response = builder.send()?; + + let mut output = Vec::new(); + response.copy_to(&mut output)?; + Ok(output) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitlabRelease { + pub name: String, + pub tag_name: String, + pub upcoming_release: bool, + pub assets: ReleaseAssets, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ReleaseAssets { + links: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitlabAsset { + pub url: String, + pub name: String, +} + +impl From for Release { + fn from(release: GitlabRelease) -> Self { + Release { + tag_name: release.tag_name, + prerelease: release.upcoming_release, + assets: release.assets.links.into_iter().map(Into::into).collect(), + } + } +} + +impl From for ReleaseAsset { + fn from(asset: GitlabAsset) -> Self { + ReleaseAsset { + url: asset.url, + name: asset.name, + } + } +} diff --git a/src/tool_provider/mod.rs b/src/tool_provider/mod.rs new file mode 100644 index 0000000..5da9ced --- /dev/null +++ b/src/tool_provider/mod.rs @@ -0,0 +1,73 @@ +mod github; +mod gitlab; + +use std::{collections::HashMap, fmt}; + +use github::GithubProvider; +use gitlab::GitlabProvider; + +pub trait ToolProviderImpl: fmt::Debug { + fn get_releases(&self, repo: &str) -> reqwest::Result>; + + fn download_asset(&self, url: &str) -> reqwest::Result>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Provider { + Github, + Gitlab, +} + +impl fmt::Display for Provider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Provider::Github => "GitHub", + Provider::Gitlab => "GitLab", + } + ) + } +} + +#[derive(Debug)] +pub struct ToolProvider { + providers: HashMap>, +} + +impl Default for ToolProvider { + fn default() -> Self { + let mut providers: HashMap> = HashMap::default(); + providers.insert(Provider::Github, Box::new(GithubProvider::default())); + providers.insert(Provider::Gitlab, Box::new(GitlabProvider::default())); + Self { providers } + } +} + +impl ToolProvider { + pub fn get(&self, provider: &Provider) -> &dyn ToolProviderImpl { + self.providers + .get(provider) + .unwrap_or_else(|| { + panic!( + "unable to find tool provider implementation for {}", + provider + ) + }) + .as_ref() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Release { + pub tag_name: String, + pub prerelease: bool, + pub assets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseAsset { + pub url: String, + pub name: String, +}