Skip to content

Commit

Permalink
Initial implementation of github source, add subcommand
Browse files Browse the repository at this point in the history
  • Loading branch information
filiptibell committed Mar 23, 2024
1 parent 6af0c2e commit a11837a
Show file tree
Hide file tree
Showing 10 changed files with 637 additions and 15 deletions.
298 changes: 293 additions & 5 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ dialoguer = "0.11"
dirs = "5.0"
futures = "0.3"
itertools = "0.12"
octocrab = "0.36"
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls",
"http2",
] }
secrecy = "0.8"
semver = { version = "1.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand All @@ -34,6 +36,7 @@ toml = "0.8"
toml_edit = "0.22"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2.5"
zip = "0.6"

[target.'cfg(windows)'.dependencies]
Expand Down
1 change: 1 addition & 0 deletions lib/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod description;
pub mod sources;
pub mod storage;
pub mod system;
pub mod tool;
28 changes: 28 additions & 0 deletions lib/sources/artifact.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use octocrab::models::repos::Asset;
use url::Url;

use crate::tool::ToolSpec;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArtifactProvider {
GitHub,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub provider: ArtifactProvider,
pub tool_spec: ToolSpec,
pub source_url: Url,
pub download_url: Url,
}

impl Artifact {
pub(crate) fn from_github_release_asset(asset: &Asset, spec: &ToolSpec) -> Self {
Self {
provider: ArtifactProvider::GitHub,
tool_spec: spec.clone(),
source_url: asset.url.clone(),
download_url: asset.browser_download_url.clone(),
}
}
}
244 changes: 244 additions & 0 deletions lib/sources/github.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use std::{
backtrace::Backtrace,
io::{stdout, IsTerminal},
time::Duration,
};

use octocrab::{models::repos::Release, Error, Octocrab, OctocrabBuilder, Result};
use reqwest::header::ACCEPT;
use secrecy::{ExposeSecret, SecretString};
use semver::Version;
use tokio::time::sleep;
use tracing::{info, instrument, trace};

use crate::{
description::Description,
tool::{ToolId, ToolSpec},
};

use super::Artifact;

const BASE_URI: &str = "https://api.github.com";

const ERR_AUTH_UNRECOGNIZED: &str =
"Unrecognized access token format - must begin with `ghp_` or `gho_`.";
const ERR_AUTH_DEVICE_INTERACTIVE: &str =
"Device authentication flow may only be used in an interactive terminal.";

// NOTE: Users typically install somewhat recent tools, and fetching
// a smaller number of releases here lets us install tools much faster.
const RESULTS_PER_PAGE: u8 = 8;

pub struct GitHubSource {
client: Octocrab,
}

impl GitHubSource {
/**
Creates a new GitHub source instance.
This instance is unauthenticated and may be rate limited and/or unable to access
private resources. To authenticate using an access token, use `new_authenticated`.
*/
pub fn new() -> Result<Self> {
let client = crab_builder().build()?;
Ok(Self { client })
}

/**
Creates a new authorized GitHub source instance with a personal access token.
May be used with either personal access tokens or tokens generated using the GitHub device flow.
*/
pub fn new_authenticated(pat: impl AsRef<str>) -> Result<Self> {
let pat: String = pat.as_ref().trim().to_string();
// https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
if pat.starts_with("ghp_") {
Ok(Self {
client: crab_builder().personal_token(pat).build()?,
})
} else if pat.starts_with("gho_") {
Ok(Self {
client: crab_builder().user_access_token(pat).build()?,
})
} else {
Err(Error::Other {
source: ERR_AUTH_UNRECOGNIZED.into(),
backtrace: Backtrace::capture(),
})
}
}

/**
Authenticates with GitHub using the device flow.
Note that this will emit messages using `info` to guide the
user through the authentication process, and requires user interaction.
If the user does not interact, this will keep polling the GitHub API for a
maximum of 15 minutes (900 seconds) before timing out and returning an error.
Returns the access token if authentication is successful, but *does not* store it.
A new client instance must be created using `new_authenticated` to use it.
*/
pub async fn auth_with_device<C, I, S>(&self, client_id: C, scope: I) -> Result<String>
where
C: Into<SecretString>,
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
if !stdout().is_terminal() {
return Err(Error::Other {
source: ERR_AUTH_DEVICE_INTERACTIVE.into(),
backtrace: Backtrace::capture(),
});
}

let client_id = client_id.into();
let codes = self
.client
.authenticate_as_device(&client_id, scope)
.await?;

info!(
"Authentication is awaiting your approval.\
\nPlease visit the authentication page: {}\
\nAnd enter the verification code: {}",
codes.verification_uri, codes.user_code
);

let oauth = loop {
sleep(Duration::from_secs(codes.interval)).await;
let status = codes.poll_once(&self.client, &client_id).await?;
if status.is_left() {
break status.unwrap_left();
}
};

info!("Authentication successful!");
let token = oauth.access_token.expose_secret().clone();

Ok(token)
}

/**
Fetches a page of releases for a given tool.
*/
#[instrument(skip(self), fields(%tool_id), level = "trace")]
pub async fn get_releases(&self, tool_id: &ToolId, page: u32) -> Result<Vec<Release>> {
trace!("fetching releases for tool");

let repository = self.client.repos(tool_id.author(), tool_id.name());
let releases = repository
.releases()
.list()
.per_page(RESULTS_PER_PAGE)
.page(page)
.send()
.await?;

Ok(releases.items)
}

/**
Fetches a specific release for a given tool.
*/
#[instrument(skip(self), fields(%tool_spec), level = "trace")]
pub async fn find_release(&self, tool_spec: &ToolSpec) -> Result<Option<Release>> {
trace!("fetching release for tool");

let repository = self.client.repos(tool_spec.author(), tool_spec.name());
let releases = repository.releases();

let tag_with_prefix = format!("v{}", tool_spec.version());
let tag_without_prefix = tool_spec.version().to_string();
let (response_with_prefix, response_without_prefix) = tokio::join!(
releases.get_by_tag(&tag_with_prefix),
releases.get_by_tag(&tag_without_prefix),
);

if response_with_prefix.is_err() && response_without_prefix.is_err() {
#[allow(clippy::unnecessary_unwrap)]
return Err(response_with_prefix.unwrap_err());
}

let opt_with_prefix = response_with_prefix.ok();
let opt_without_prefix = response_without_prefix.ok();
Ok(opt_with_prefix.or(opt_without_prefix))
}

/**
Finds the latest version of a tool, optionally allowing prereleases.
If no releases are found, or no non-prerelease releases are found, this will return `None`.
*/
#[instrument(skip(self), fields(%tool_id), level = "trace")]
pub async fn find_latest_version(
&self,
tool_id: &ToolId,
allow_prereleases: bool,
) -> Result<Option<Version>> {
trace!("fetching latest version for tool");

let releases = self.get_releases(tool_id, 1).await?;
Ok(releases.into_iter().find_map(|release| {
if allow_prereleases || !release.prerelease {
let version = release.tag_name.trim_start_matches('v');
Version::parse(version).ok()
} else {
None
}
}))
}

/**
Finds compatible release artifacts for the given release and description.
The resulting list of artifacts will be sorted by preferred compatibility.
See [`Description::is_compatible_with`] and
[`Description::sort_by_preferred_compat`] for more information.
*/
pub fn find_compatible_artifacts(
&self,
tool_spec: &ToolSpec,
release: &Release,
description: &Description,
) -> Vec<Artifact> {
let mut compatible_artifacts = release
.assets
.iter()
.filter_map(|asset| {
if let Some(asset_desc) = Description::detect(asset.name.as_str()) {
if description.is_compatible_with(&asset_desc) {
let artifact = Artifact::from_github_release_asset(asset, tool_spec);
Some((asset_desc, artifact))
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>();

compatible_artifacts.sort_by(|(desc_a, _), (desc_b, _)| {
description.sort_by_preferred_compat(desc_a, desc_b)
});

compatible_artifacts
.into_iter()
.map(|(_, artifact)| artifact)
.collect()
}
}

// So generic, such wow

use octocrab::{DefaultOctocrabBuilderConfig, NoAuth, NoSvc, NotLayerReady};

fn crab_builder() -> OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady> {
OctocrabBuilder::new()
.base_uri(BASE_URI)
.unwrap()
.add_header(ACCEPT, String::from("application/json"))
}
5 changes: 5 additions & 0 deletions lib/sources/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod artifact;
mod github;

pub use self::artifact::{Artifact, ArtifactProvider};
pub use self::github::GitHubSource;
7 changes: 6 additions & 1 deletion lib/tool/id.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::{fmt, str::FromStr};

use semver::Version;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;

use super::util::is_invalid_identifier;
use super::{util::is_invalid_identifier, ToolSpec};

/**
Error type representing the possible errors that can occur when parsing a ToolId.
Expand Down Expand Up @@ -41,6 +42,10 @@ impl ToolId {
pub fn name(&self) -> &str {
&self.name
}

pub fn into_spec(self, version: Version) -> ToolSpec {
ToolSpec::from((self, version))
}
}

impl FromStr for ToolId {
Expand Down
28 changes: 25 additions & 3 deletions src/cli/add.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use anyhow::Result;
use anyhow::{Context, Result};
use clap::Parser;

use aftman::{storage::Home, tool::ToolAlias};
use aftman::{sources::GitHubSource, storage::Home, tool::ToolAlias};
use semver::Version;

use crate::util::ToolIdOrSpec;

Expand All @@ -23,7 +24,28 @@ pub struct AddSubcommand {

impl AddSubcommand {
pub async fn run(&self, home: &Home) -> Result<()> {
// TODO: Implement the add subcommand
let source = GitHubSource::new()?;

// If we only got an id without a specified version, we
// will fetch the latest no-prerelease release and use that
let spec = match self.tool_spec.clone() {
ToolIdOrSpec::Spec(spec) => spec,
ToolIdOrSpec::Id(id) => {
let version = source
.find_latest_version(&id, false)
.await?
.with_context(|| format!("No non-prerelease releases were found for {id}"))?;
id.into_spec(version)
}
};

// Fetch the release for the tool
let _release = source
.find_release(&spec)
.await?
.with_context(|| format!("No release was found for {spec}"))?;

// TODO: Add the tool spec to the desired manifest file, and install it

Ok(())
}
Expand Down
Loading

0 comments on commit a11837a

Please sign in to comment.