diff options
Diffstat (limited to 'crates')
35 files changed, 510 insertions, 625 deletions
diff --git a/crates/bot-client/Cargo.toml b/crates/bot-client/Cargo.toml deleted file mode 100644 index a2ba2a0..0000000 --- a/crates/bot-client/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "bot-client" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Discord client for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] -bot-commands = { workspace = true } -bot-config = { workspace = true } -bot-consts = { workspace = true } -bot-error = { workspace = true } -bot-http = { workspace = true } -bot-jobs = { workspace = true } -log = { workspace = true } -serenity = { workspace = true } -tokio = { workspace = true } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-commands/Cargo.toml b/crates/bot-commands/Cargo.toml deleted file mode 100644 index 3594c70..0000000 --- a/crates/bot-commands/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "bot-commands" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Discord application commands for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] -bot-config = { workspace = true } -bot-consts = { workspace = true } -bot-error = { workspace = true } -bot-http = { workspace = true } -git-tracker = { workspace = true } -log = { workspace = true } -serenity = { workspace = true } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" -# NOTE: THIS ISN'T IN OTHER CRATES BUT IS HERE -# this is because we don't really care about error docs here -# and it could mess with poise's comment system in the future :p -missing-errors-doc = "allow" diff --git a/crates/bot-commands/src/track.rs b/crates/bot-commands/src/track.rs deleted file mode 100644 index 1f22d0e..0000000 --- a/crates/bot-commands/src/track.rs +++ /dev/null @@ -1,121 +0,0 @@ -use bot_config::Config; -use bot_consts::{NIXPKGS_REMOTE, NIXPKGS_URL}; -use bot_error::Error; -use bot_http::{self as http, GithubClientExt}; -use git_tracker::Tracker; - -use log::trace; -use serenity::all::CreateEmbed; -use serenity::builder::{CreateCommand, CreateCommandOption, CreateInteractionResponseFollowup}; -use serenity::model::application::{ - CommandInteraction, CommandOptionType, InstallationContext, ResolvedOption, ResolvedValue, -}; -use serenity::prelude::Context; - -const REPO_OWNER: &str = "NixOS"; -const REPO_NAME: &str = "nixpkgs"; - -/// Collect the status of the commit SHA [`commit_sha`] in each of the nixpkgs -/// branches in [`branches`], using the repository at path [`repository_path`] -/// -/// # Errors -/// -/// Will return [`Err`] if we can't start tracking a repository at the given path, -/// or if we can't determine if the branch has given commit -fn collect_statuses_in<'a>( - repository_path: &str, - commit_sha: &str, - branches: impl IntoIterator<Item = &'a String>, -) -> Result<Vec<String>, Error> { - // start tracking nixpkgs - let tracker = Tracker::from_path(repository_path)?; - - // check to see what branches it's in - let mut status_results = vec![]; - for branch_name in branches { - trace!("Checking for commit in {branch_name}"); - let full_branch_name = format!("{NIXPKGS_REMOTE}/{branch_name}"); - let has_pr = tracker.branch_contains_sha(&full_branch_name, commit_sha)?; - - if has_pr { - status_results.push(format!("`{branch_name}` ✅")); - } - } - - Ok(status_results) -} - -pub async fn respond( - ctx: &Context, - http: &http::Client, - config: &Config, - command: &CommandInteraction, -) -> Result<(), Error> { - // this will probably take a while - command.defer(&ctx).await?; - - let options = command.data.options(); - let Some(ResolvedOption { - value: ResolvedValue::Integer(pr), - .. - }) = options.first() - else { - let resp = CreateInteractionResponseFollowup::new() - .content("Please provide a valid pull request!"); - command.create_followup(&ctx, resp).await?; - - return Ok(()); - }; - - let Ok(pr_id) = u64::try_from(*pr) else { - let resp = - CreateInteractionResponseFollowup::new().content("PR numbers aren't negative..."); - command.create_followup(&ctx, resp).await?; - - return Ok(()); - }; - - // find out what commit our PR was merged in - let Some(commit_sha) = http.merge_commit_for(REPO_OWNER, REPO_NAME, pr_id).await? else { - let response = CreateInteractionResponseFollowup::new() - .content("It seems this pull request is very old. I can't track it"); - command.create_followup(&ctx, response).await?; - - return Ok(()); - }; - - let status_results = collect_statuses_in( - &config.nixpkgs_path, - &commit_sha, - config.nixpkgs_branches.iter(), - )?; - - // if we don't find the commit in any branches from above, we can pretty safely assume - // it's an unmerged PR - let embed_description: String = if status_results.is_empty() { - "It doesn't look like this PR has been merged yet! (or maybe I just haven't updated)" - .to_string() - } else { - status_results.join("\n") - }; - - let embed = CreateEmbed::new() - .title(format!("Nixpkgs PR #{} Status", *pr)) - .url(format!("{NIXPKGS_URL}/pull/{pr}")) - .description(embed_description); - - let resp = CreateInteractionResponseFollowup::new().embed(embed); - command.create_followup(&ctx, resp).await?; - - Ok(()) -} - -pub fn register() -> CreateCommand { - CreateCommand::new("track") - .description("Track a nixpkgs PR") - .add_integration_type(InstallationContext::User) - .add_option( - CreateCommandOption::new(CommandOptionType::Integer, "pull_request", "PR to track") - .required(true), - ) -} diff --git a/crates/bot-config/Cargo.toml b/crates/bot-config/Cargo.toml deleted file mode 100644 index 57b9a67..0000000 --- a/crates/bot-config/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "bot-config" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Configuration for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-consts/Cargo.toml b/crates/bot-consts/Cargo.toml deleted file mode 100644 index 16d7726..0000000 --- a/crates/bot-consts/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "bot-consts" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Constants for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-error/Cargo.toml b/crates/bot-error/Cargo.toml deleted file mode 100644 index c6f6ed1..0000000 --- a/crates/bot-error/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "bot-error" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Shared Err variant used for (most of) nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-error/src/lib.rs b/crates/bot-error/src/lib.rs deleted file mode 100644 index f34e60e..0000000 --- a/crates/bot-error/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub type Error = Box<dyn std::error::Error + Send + Sync>; diff --git a/crates/bot-http/Cargo.toml b/crates/bot-http/Cargo.toml deleted file mode 100644 index 0888fda..0000000 --- a/crates/bot-http/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "bot-http" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "HTTP client for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] -log = { workspace = true } -reqwest = { version = "0.12.5", default-features = false, features = ["charset", "http2", "rustls-tls", "json"] } -serde = { version = "1.0.207", features = ["derive"] } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-http/src/github.rs b/crates/bot-http/src/github.rs deleted file mode 100644 index 7822eb8..0000000 --- a/crates/bot-http/src/github.rs +++ /dev/null @@ -1,35 +0,0 @@ -use super::{ClientExt as _, Error}; -use crate::model::PullRequest; - -use std::future::Future; - -const GITHUB_API: &str = "https://api.github.com"; - -pub trait ClientExt { - /// Get the commit that merged [`pr`] in [`repo_owner`]/[`repo_name`] - /// - /// # Errors - /// - /// Will return [`Err`] if the merge commit cannot be found - fn merge_commit_for( - &self, - repo_owner: &str, - repo_name: &str, - pr: u64, - ) -> impl Future<Output = Result<Option<String>, Error>> + Send; -} - -impl ClientExt for super::Client { - async fn merge_commit_for( - &self, - repo_owner: &str, - repo_name: &str, - pr: u64, - ) -> Result<Option<String>, Error> { - let url = format!("{GITHUB_API}/repos/{repo_owner}/{repo_name}/pulls/{pr}"); - let resp: PullRequest = self.get_json(&url).await?; - let merge_commit = resp.merge_commit_sha; - - Ok(merge_commit) - } -} diff --git a/crates/bot-http/src/lib.rs b/crates/bot-http/src/lib.rs deleted file mode 100644 index ab32cd4..0000000 --- a/crates/bot-http/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::future::Future; - -use log::trace; -use serde::de::DeserializeOwned; - -mod github; -mod model; -mod teawie; - -pub use github::ClientExt as GithubClientExt; -pub use teawie::ClientExt as TeawieClientExt; - -pub type Client = reqwest::Client; -pub type Response = reqwest::Response; -pub type Error = reqwest::Error; - -/// Fun trait for functions we use with [Client] -pub trait ClientExt { - fn default() -> Self; - fn get_request(&self, url: &str) -> impl Future<Output = Result<Response, Error>> + Send; - fn get_json<T: DeserializeOwned>( - &self, - url: &str, - ) -> impl Future<Output = Result<T, Error>> + Send; -} - -impl ClientExt for Client { - /// Create the default [`Client`] - fn default() -> Self { - reqwest::Client::builder() - .user_agent(format!( - "nixpkgs-tracker-bot/{}", - option_env!("CARGO_PKG_VERSION").unwrap_or_else(|| "development") - )) - .build() - .unwrap() - } - - /// Perform a GET request to [`url`] - /// - /// # Errors - /// - /// Will return [`Err`] if the request fails - async fn get_request(&self, url: &str) -> Result<Response, Error> { - trace!("Making GET request to {url}"); - - let resp = self.get(url).send().await?; - resp.error_for_status_ref()?; - - Ok(resp) - } - - /// Perform a GET request to [`url`] and decode the json response - /// - /// # Errors - /// - /// Will return [`Err`] if the request fails or cannot be deserialized - async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, Error> { - let resp = self.get_request(url).await?; - let json = resp.json().await?; - Ok(json) - } -} diff --git a/crates/bot-http/src/teawie.rs b/crates/bot-http/src/teawie.rs deleted file mode 100644 index ea4f53e..0000000 --- a/crates/bot-http/src/teawie.rs +++ /dev/null @@ -1,24 +0,0 @@ -use super::{ClientExt as _, Error}; -use crate::model::RandomTeawie; - -use std::future::Future; - -const TEAWIE_API: &str = "https://api.getchoo.com"; - -pub trait ClientExt { - /// Get a random teawie - /// - /// # Errors - /// - /// Will return [`Err`] if the request fails or the response cannot be deserialized - fn random_teawie(&self) -> impl Future<Output = Result<Option<String>, Error>> + Send; -} - -impl ClientExt for super::Client { - async fn random_teawie(&self) -> Result<Option<String>, Error> { - let url = format!("{TEAWIE_API}/random_teawie"); - let resp: RandomTeawie = self.get_json(&url).await?; - - Ok(resp.url) - } -} diff --git a/crates/bot-jobs/Cargo.toml b/crates/bot-jobs/Cargo.toml deleted file mode 100644 index 21b0248..0000000 --- a/crates/bot-jobs/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "bot-jobs" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "Background jobs for nixpkgs-tracker-bot" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -publish = false - -[dependencies] -bot-config = { workspace = true } -bot-consts = { workspace = true } -bot-error = { workspace = true } -git2 = { workspace = true, features = ["https"] } -log = { workspace = true } -tokio = { workspace = true } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/bot-jobs/src/lib.rs b/crates/bot-jobs/src/lib.rs deleted file mode 100644 index d65c929..0000000 --- a/crates/bot-jobs/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -use bot_config::Config; -use bot_error::Error; - -use std::time::Duration; - -use log::error; - -mod repo; - -/// Run our jobs an initial time, then loop them on a separate thread -/// -/// # Errors -/// -/// Will return [`Err`] if any jobs fail -pub fn dispatch(config: Config) -> Result<(), Error> { - repo::fetch_or_update_repository(&config.nixpkgs_path, &config.nixpkgs_branches)?; - - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(repo::TTL_SECS)).await; - if let Err(why) = - repo::fetch_or_update_repository(&config.nixpkgs_path, &config.nixpkgs_branches) - { - error!("Failed to fetch or update repository!\n{why:?}"); - }; - } - }); - - Ok(()) -} diff --git a/crates/bot-jobs/src/repo.rs b/crates/bot-jobs/src/repo.rs deleted file mode 100644 index 4d3e214..0000000 --- a/crates/bot-jobs/src/repo.rs +++ /dev/null @@ -1,77 +0,0 @@ -use bot_consts::{NIXPKGS_REMOTE, NIXPKGS_URL}; -use bot_error::Error; - -use std::{io::Write, path::Path}; - -use git2::{AutotagOption, FetchOptions, RemoteCallbacks, Repository}; -use log::{debug, info, trace, warn}; - -pub const TTL_SECS: u64 = 60 * 5; // 5 minutes - -// much of this is shamelessly lifted from -// https://github.com/rust-lang/git2-rs/blob/9a5c9706ff578c936be644dd1e8fe155bdc4d129/examples/pull.rs - -/// basic set of options for fetching from remotes -fn fetch_options<'a>() -> FetchOptions<'a> { - let mut remote_callbacks = RemoteCallbacks::new(); - remote_callbacks.transfer_progress(|progress| { - if progress.received_objects() == progress.total_objects() { - trace!( - "Resolving deltas {}/{}\r", - progress.indexed_deltas(), - progress.total_deltas() - ); - } else { - trace!( - "Received {}/{} objects ({}) in {} bytes\r", - progress.received_objects(), - progress.total_objects(), - progress.indexed_objects(), - progress.received_bytes() - ); - } - std::io::stdout().flush().ok(); - true - }); - - let mut fetch_opts = FetchOptions::new(); - fetch_opts.remote_callbacks(remote_callbacks); - - fetch_opts -} - -/// update the given branches in the [`repository`] using the nixpkgs remote -fn update_branches_in(repository: &Repository, branches: &[String]) -> Result<(), Error> { - let mut remote = repository.find_remote(NIXPKGS_REMOTE)?; - // download all the refs - remote.download(branches, Some(&mut fetch_options()))?; - remote.disconnect()?; - // and (hopefully) update what they refer to for later - remote.update_tips(None, true, AutotagOption::Auto, None)?; - - Ok(()) -} - -pub fn fetch_or_update_repository(path: &str, branches: &[String]) -> Result<(), Error> { - // Open our repository or clone it if it doesn't exist - let path = Path::new(path); - let repository = if path.exists() { - Repository::open(path)? - } else { - warn!( - "Couldn't find repository at {}! Cloning a fresh one from {NIXPKGS_URL}", - path.display() - ); - Repository::clone(NIXPKGS_URL, path)?; - info!("Finished cloning to {}", path.display()); - - // bail early as we already have a fresh copy - return Ok(()); - }; - - debug!("Updating repository at {}", path.display()); - update_branches_in(&repository, branches)?; - debug!("Finished updating!"); - - Ok(()) -} diff --git a/crates/bot/Cargo.toml b/crates/bot/Cargo.toml deleted file mode 100644 index 69feace..0000000 --- a/crates/bot/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "nixpkgs-tracker-bot" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "A small Discord app that helps you track where nixpkgs PRs have reached" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" - -[dependencies] -bot-error = { workspace = true } -bot-client = { workspace = true } -dotenvy = "0.15.7" -env_logger = "0.11.5" -tokio = { workspace = true } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" diff --git a/crates/discord-bot/Cargo.toml b/crates/discord-bot/Cargo.toml new file mode 100644 index 0000000..dd35306 --- /dev/null +++ b/crates/discord-bot/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "discord-bot" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Small Discord app that helps you track where nixpkgs PRs have reached" +repository.workspace = true +license.workspace = true + +publish = false + +[[bin]] +name = "nixpkgs-tracker-bot" +path = "src/main.rs" + +[dependencies] +dotenvy = "0.15.7" +env_logger = "0.11.5" +eyre = "0.6.12" +git-tracker.workspace = true +log.workspace = true +nixpkgs-tracker-http.workspace = true +serenity = { version = "0.12.2", features = ["unstable_discord_api"] } +tokio = { version = "1.39.2", features = [ + "macros", + "rt-multi-thread", + "signal" +] } + +[lints] +workspace = true diff --git a/crates/bot-commands/src/about.rs b/crates/discord-bot/src/commands/about.rs index 2e5efae..e663faf 100644 --- a/crates/bot-commands/src/about.rs +++ b/crates/discord-bot/src/commands/about.rs @@ -1,6 +1,9 @@ -use bot_error::Error; -use bot_http::TeawieClientExt; +use std::sync::Arc; +use crate::http::TeawieClientExt; + +use eyre::Result; +use log::warn; use serenity::builder::{ CreateCommand, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage, @@ -11,11 +14,10 @@ use serenity::prelude::Context; const VERSION: &str = env!("CARGO_PKG_VERSION"); const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); -pub async fn respond( - ctx: &Context, - http: &bot_http::Client, - command: &CommandInteraction, -) -> Result<(), Error> { +pub async fn respond<T>(ctx: &Context, http: &Arc<T>, command: &CommandInteraction) -> Result<()> +where + T: TeawieClientExt, +{ let mut embed = CreateEmbed::new() .title("About nixpkgs-tracker-bot") .description("I help track what branches PRs to nixpkgs have reached. If you've used [Nixpkgs Pull Request Tracker](https://nixpk.gs/pr-tracker.html), you probably know what this is about.") @@ -25,9 +27,13 @@ pub async fn respond( ("Issues/Feature Requests", &format!("[getchoo/nixpkgs-tracker-bot/issues]({REPOSITORY}/issues)"), true) ]); - if let Some(teawie_url) = http.random_teawie().await? { + let random_teawie = http.random_teawie().await?; + + if let Some(teawie_url) = random_teawie.url { let footer = CreateEmbedFooter::new("Images courtesy of @sympathytea"); embed = embed.image(teawie_url).footer(footer); + } else if let Some(error) = random_teawie.error { + warn!("Error from TeawieAPI: {error:#?}"); }; let message = CreateInteractionResponseMessage::new().embed(embed); diff --git a/crates/bot-commands/src/lib.rs b/crates/discord-bot/src/commands/mod.rs index 79fce17..79fce17 100644 --- a/crates/bot-commands/src/lib.rs +++ b/crates/discord-bot/src/commands/mod.rs diff --git a/crates/bot-commands/src/ping.rs b/crates/discord-bot/src/commands/ping.rs index b18a0b6..30150dc 100644 --- a/crates/bot-commands/src/ping.rs +++ b/crates/discord-bot/src/commands/ping.rs @@ -1,12 +1,11 @@ -use bot_error::Error; - +use eyre::Result; use serenity::builder::{ CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, }; use serenity::model::application::{CommandInteraction, InstallationContext}; use serenity::prelude::Context; -pub async fn respond(ctx: &Context, command: &CommandInteraction) -> Result<(), Error> { +pub async fn respond(ctx: &Context, command: &CommandInteraction) -> Result<()> { let message = CreateInteractionResponseMessage::new().content("Pong!"); let response = CreateInteractionResponse::Message(message); command.create_response(&ctx, response).await?; diff --git a/crates/discord-bot/src/commands/track.rs b/crates/discord-bot/src/commands/track.rs new file mode 100644 index 0000000..f071ebf --- /dev/null +++ b/crates/discord-bot/src/commands/track.rs @@ -0,0 +1,133 @@ +use crate::{config::Config, consts::NIXPKGS_REMOTE, http::GitHubClientExt}; + +use std::sync::Arc; + +use eyre::Result; +use log::debug; +use serenity::builder::{ + CreateCommand, CreateCommandOption, CreateEmbed, CreateInteractionResponseFollowup, +}; +use serenity::model::{ + application::{ + CommandInteraction, CommandOptionType, InstallationContext, ResolvedOption, ResolvedValue, + }, + Timestamp, +}; +use serenity::prelude::Context; + +const REPO_OWNER: &str = "NixOS"; +const REPO_NAME: &str = "nixpkgs"; + +pub async fn respond<T>( + ctx: &Context, + http: &Arc<T>, + config: &Config, + command: &CommandInteraction, +) -> Result<()> +where + T: GitHubClientExt, +{ + // this will probably take a while + command.defer(&ctx).await?; + + let options = command.data.options(); + let Some(ResolvedOption { + value: ResolvedValue::Integer(pr), + .. + }) = options.first() + else { + let resp = CreateInteractionResponseFollowup::new() + .content("PR numbers aren't negative or that big..."); + command.create_followup(&ctx, resp).await?; + + return Ok(()); + }; + + let Ok(id) = u64::try_from(*pr) else { + let resp = CreateInteractionResponseFollowup::new() + .content("PR numbers aren't negative or that big..."); + command.create_followup(&ctx, resp).await?; + + return Ok(()); + }; + + // find out what commit our PR was merged in + let pull_request = http.pull_request(REPO_OWNER, REPO_NAME, id).await?; + if !pull_request.merged { + let response = CreateInteractionResponseFollowup::new() + .content("It looks like that PR isn't merged yet! Try again when it is 😄"); + command.create_followup(&ctx, response).await?; + + return Ok(()); + } + + // seems older PRs may not have this + let Some(commit_sha) = pull_request.merge_commit_sha else { + let response = CreateInteractionResponseFollowup::new() + .content("It seems this pull request is very old. I can't track it"); + command.create_followup(&ctx, response).await?; + + return Ok(()); + }; + + let status_results = git_tracker::collect_statuses_in( + &config.nixpkgs_path, + &commit_sha, + &config.nixpkgs_branches, + )?; + + // find branches containing our PR and trim the remote ref prefix + let found_branches: Vec<String> = status_results + .iter() + .filter(|&(_, has_pr)| *has_pr) + .map(|(branch_name, _)| { + // remove the ref prefix that we add in our Config struct + let start_pos = format!("{NIXPKGS_REMOTE}/").len(); + branch_name[start_pos..].to_string() + }) + .collect(); + + // if we didn't find any, bail + if found_branches.is_empty() { + let response = CreateInteractionResponseFollowup::new() + .content("This PR has been merged...but I can't seem to find it anywhere. I might not be tracking it's base branch"); + command.create_followup(&ctx, response).await?; + + return Ok(()); + } + + let mut embed = CreateEmbed::new() + .title(format!("Nixpkgs PR #{} Status", pull_request.number)) + .url(&pull_request.html_url) + .description(&pull_request.title) + .fields( + found_branches + .iter() + .map(|branch_name| (branch_name, "✅", true)), + ); + + if let Some(merged_at) = pull_request.merged_at { + if let Ok(timestamp) = Timestamp::parse(&merged_at) { + embed = embed.timestamp(timestamp); + } else { + debug!("Couldn't parse timestamp from GitHub! Ignoring."); + } + } else { + debug!("Couldn't find `merged_at` information for a supposedly merged PR! Ignoring."); + } + + let resp = CreateInteractionResponseFollowup::new().embed(embed); + command.create_followup(&ctx, resp).await?; + + Ok(()) +} + +pub fn register() -> CreateCommand { + CreateCommand::new("track") + .description("Track a nixpkgs PR") + .add_integration_type(InstallationContext::User) + .add_option( + CreateCommandOption::new(CommandOptionType::Integer, "pull_request", "PR to track") + .required(true), + ) +} diff --git a/crates/bot-config/src/lib.rs b/crates/discord-bot/src/config.rs index 0691884..5076eb9 100644 --- a/crates/bot-config/src/lib.rs +++ b/crates/discord-bot/src/config.rs @@ -1,3 +1,5 @@ +use crate::consts::NIXPKGS_REMOTE; + use std::env; /// The Discord client's configuration @@ -14,7 +16,7 @@ impl Config { fn split_string_list(branches: &str) -> Vec<String> { branches .split(',') - .map(|branch| branch.trim().to_string()) + .map(|branch| format!("{NIXPKGS_REMOTE}/{}", branch.trim())) .collect() } diff --git a/crates/bot-consts/src/lib.rs b/crates/discord-bot/src/consts.rs index 9396da0..9396da0 100644 --- a/crates/bot-consts/src/lib.rs +++ b/crates/discord-bot/src/consts.rs diff --git a/crates/bot-client/src/handler.rs b/crates/discord-bot/src/handler.rs index 2cd0082..4abb99b 100644 --- a/crates/bot-client/src/handler.rs +++ b/crates/discord-bot/src/handler.rs @@ -1,6 +1,6 @@ -use crate::{SharedConfig, SharedHttp}; -use bot_error::Error; +use crate::{commands, SharedConfig, SharedHttp}; +use eyre::{OptionExt, Result}; use log::{debug, error, info, trace, warn}; use serenity::all::CreateBotAuthParameters; use serenity::async_trait; @@ -19,8 +19,8 @@ use serenity::prelude::{Context, EventHandler}; pub struct Handler; impl Handler { - async fn register_commands(&self, ctx: &Context) -> Result<(), Error> { - let commands = bot_commands::to_vec(); + async fn register_commands(&self, ctx: &Context) -> Result<()> { + let commands = commands::to_vec(); let commands_len = commands.len(); for command in commands { Command::create_global_command(&ctx.http, command).await?; @@ -31,7 +31,7 @@ impl Handler { } /// Dispatch our commands from a [`CommandInteraction`] - async fn dispatch_command(ctx: &Context, command: &CommandInteraction) -> Result<(), Error> { + async fn dispatch_command(ctx: &Context, command: &CommandInteraction) -> Result<()> { let command_name = command.data.name.as_str(); // grab our configuration & http client from the aether @@ -39,19 +39,19 @@ impl Handler { let read = ctx.data.read().await; let http = read .get::<SharedHttp>() - .ok_or("Couldn't get shared HTTP client! WHY??????")? + .ok_or_eyre("Couldn't get shared HTTP client! WHY??????")? .clone(); let config = read .get::<SharedConfig>() - .ok_or("Couldn't get shared bot configuration!")? + .ok_or_eyre("Couldn't get shared bot configuration!")? .clone(); (http, config) }; match command_name { - "about" => bot_commands::about::respond(ctx, &http, command).await?, - "ping" => bot_commands::ping::respond(ctx, command).await?, - "track" => bot_commands::track::respond(ctx, &http, &config, command).await?, + "about" => commands::about::respond(ctx, &http, command).await?, + "ping" => commands::ping::respond(ctx, command).await?, + "track" => commands::track::respond(ctx, &http, &config, command).await?, _ => { let message = CreateInteractionResponseMessage::new().content(format!( "It doesn't look like you can use `{command_name}`. Sorry :(" diff --git a/crates/discord-bot/src/jobs.rs b/crates/discord-bot/src/jobs.rs new file mode 100644 index 0000000..40d34cc --- /dev/null +++ b/crates/discord-bot/src/jobs.rs @@ -0,0 +1,36 @@ +use crate::{config::Config, consts::NIXPKGS_REMOTE, consts::NIXPKGS_URL}; + +use std::{path::Path, time::Duration}; + +use eyre::Result; +use git_tracker::ManagedRepository; +use log::error; + +const TTL_SECS: u64 = 60 * 5; // 5 minutes + +/// Run our jobs an initial time, then loop them on a separate thread +/// +/// # Errors +/// +/// Will return [`Err`] if any jobs fail +pub fn dispatch(config: Config) -> Result<()> { + let managed_repository = ManagedRepository { + path: Path::new(&config.nixpkgs_path).to_path_buf(), + tracked_branches: config.nixpkgs_branches, + upstream_remote_url: NIXPKGS_URL.to_string(), + upstream_remote_name: NIXPKGS_REMOTE.to_string(), + }; + + managed_repository.fetch_or_update()?; + + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(TTL_SECS)).await; + if let Err(why) = managed_repository.fetch_or_update() { + error!("Could not fetch or update repository!\n{why:?}"); + }; + } + }); + + Ok(()) +} diff --git a/crates/bot-client/src/lib.rs b/crates/discord-bot/src/lib.rs index 851b853..7ac2e98 100644 --- a/crates/bot-client/src/lib.rs +++ b/crates/discord-bot/src/lib.rs @@ -1,15 +1,18 @@ -use bot_config::Config; -use bot_error::Error; -use bot_http as http; - use std::sync::Arc; +use eyre::Result; use log::trace; use serenity::prelude::{Client, GatewayIntents, TypeMapKey}; +mod commands; +mod config; +mod consts; mod handler; +mod jobs; +use config::Config; use handler::Handler; +use nixpkgs_tracker_http as http; /// Container for [`http::Client`] struct SharedHttp; @@ -26,7 +29,7 @@ impl TypeMapKey for SharedConfig { } /// Fetch our bot token -fn token() -> Result<String, Error> { +fn token() -> Result<String> { let token = std::env::var("DISCORD_BOT_TOKEN")?; Ok(token) } @@ -41,7 +44,7 @@ fn token() -> Result<String, Error> { /// # Panics /// /// Will [`panic!`] if the bot token isn't found or the ctrl+c handler can't be made -pub async fn get() -> Result<Client, Error> { +pub async fn client() -> Result<Client> { let token = token().expect("Couldn't find token in environment! Is DISCORD_BOT_TOKEN set?"); let intents = GatewayIntents::default(); @@ -51,7 +54,7 @@ pub async fn get() -> Result<Client, Error> { .await?; // add state stuff - let http_client = <http::Client as http::ClientExt>::default(); + let http_client = <http::Client as http::Ext>::default(); let config = Config::from_env()?; { @@ -73,7 +76,7 @@ pub async fn get() -> Result<Client, Error> { }); // run our jobs - bot_jobs::dispatch(config)?; + jobs::dispatch(config)?; Ok(client) } diff --git a/crates/bot/src/main.rs b/crates/discord-bot/src/main.rs index 390e79b..acbf9fd 100644 --- a/crates/bot/src/main.rs +++ b/crates/discord-bot/src/main.rs @@ -1,9 +1,9 @@ #[tokio::main] -async fn main() -> Result<(), bot_error::Error> { +async fn main() -> eyre::Result<()> { dotenvy::dotenv().ok(); env_logger::try_init()?; - let mut client = bot_client::get().await?; + let mut client = discord_bot::client().await?; client.start().await?; Ok(()) diff --git a/crates/git-tracker/Cargo.toml b/crates/git-tracker/Cargo.toml index 09584cb..4805502 100644 --- a/crates/git-tracker/Cargo.toml +++ b/crates/git-tracker/Cargo.toml @@ -1,27 +1,17 @@ [package] name = "git-tracker" -version = "0.2.0" -edition = "2021" - -authors = ["seth <getchoo at tuta dot io>"] -description = "A library that helps you track commits and branches in a Git repository" -repository = "https://github.com/getchoo/nixpkgs-tracker-bot" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "Library that helps you track commits and branches in a Git repository" +repository.workspace = true publish = false [dependencies] -git2 = { workspace = true } -log = { workspace = true } +git2 = { version = "0.19.0", default-features = false, features = ["https"] } +log.workspace = true thiserror = "1.0.63" -[lints.rust] -async_fn_in_trait = "allow" -unsafe_code = "forbid" - -[lints.clippy] -complexity = "warn" -correctness = "deny" -pedantic = "warn" -perf = "warn" -style = "warn" -suspicious = "deny" +[lints] +workspace = true diff --git a/crates/git-tracker/src/lib.rs b/crates/git-tracker/src/lib.rs index cb0907b..0bf17dc 100644 --- a/crates/git-tracker/src/lib.rs +++ b/crates/git-tracker/src/lib.rs @@ -1,4 +1,35 @@ //! A library that helps you track commits and branches in a Git repository +use log::trace; +mod managed_repository; mod tracker; -pub use tracker::{Error, Tracker}; +pub use managed_repository::ManagedRepository; +pub use tracker::Tracker; + +/// Collect the status of the commit SHA [`commit_sha`] in each of the nixpkgs +/// branches in [`branches`], using the repository at path [`repository_path`] +/// +/// NOTE: `branches` should contain the full ref (i.e., `origin/main`) +/// +/// # Errors +/// +/// Will return [`Err`] if we can't start tracking a repository at the given path, +/// or if we can't determine if the branch has given commit +pub fn collect_statuses_in( + repository_path: &str, + commit_sha: &str, + branches: &Vec<String>, +) -> Result<Vec<(String, bool)>, tracker::Error> { + // start tracking nixpkgs + let tracker = Tracker::from_path(repository_path)?; + + // check to see what branches it's in + let mut status_results = Vec::new(); + for branch_name in branches { + trace!("Checking for commit in {branch_name}"); + let has_pr = tracker.branch_contains_sha(branch_name, commit_sha)?; + status_results.push((branch_name.to_string(), has_pr)); + } + + Ok(status_results) +} diff --git a/crates/git-tracker/src/managed_repository.rs b/crates/git-tracker/src/managed_repository.rs new file mode 100644 index 0000000..0a41bd0 --- /dev/null +++ b/crates/git-tracker/src/managed_repository.rs @@ -0,0 +1,95 @@ +use git2::{AutotagOption, FetchOptions, RemoteCallbacks, RemoteUpdateFlags, Repository}; +use log::{debug, info, trace, warn}; +use std::{io::Write, path::PathBuf}; + +// much of this is shamelessly lifted from +// https://github.com/rust-lang/git2-rs/blob/9a5c9706ff578c936be644dd1e8fe155bdc4d129/examples/pull.rs + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("libgit2 error")] + Git(#[from] git2::Error), +} + +pub struct ManagedRepository { + pub path: PathBuf, + pub tracked_branches: Vec<String>, + pub upstream_remote_url: String, + pub upstream_remote_name: String, +} + +impl ManagedRepository { + /// basic set of options for fetching from remotes + fn fetch_options<'a>() -> FetchOptions<'a> { + let mut remote_callbacks = RemoteCallbacks::new(); + remote_callbacks.transfer_progress(|progress| { + if progress.received_objects() == progress.total_objects() { + trace!( + "Resolving deltas {}/{}\r", + progress.indexed_deltas(), + progress.total_deltas() + ); + } else { + trace!( + "Received {}/{} objects ({}) in {} bytes\r", + progress.received_objects(), + progress.total_objects(), + progress.indexed_objects(), + progress.received_bytes() + ); + } + std::io::stdout().flush().ok(); + true + }); + + let mut fetch_opts = FetchOptions::new(); + fetch_opts.remote_callbacks(remote_callbacks); + + fetch_opts + } + + /// Update the given branches in the [`repository`] using the nixpkgs remote + fn update_branches_in(&self, repository: &Repository) -> Result<(), Error> { + let mut remote = repository.find_remote(&self.upstream_remote_name)?; + // download all the refs + remote.download(&self.tracked_branches, Some(&mut Self::fetch_options()))?; + remote.disconnect()?; + // and (hopefully) update what they refer to for later + remote.update_tips( + None, + RemoteUpdateFlags::UPDATE_FETCHHEAD, + AutotagOption::Auto, + None, + )?; + + Ok(()) + } + + /// Fetch the repository or update it if it exists + /// + /// # Errors + /// Will return [`Err`] if the repository cannot be opened, cloned, or updated + pub fn fetch_or_update(&self) -> Result<(), Error> { + // Open our repository or clone it if it doesn't exist + let repository = if self.path.exists() { + Repository::open(self.path.as_path())? + } else { + warn!( + "Couldn't find repository at {}! Cloning a fresh one from {}", + self.path.display(), + self.upstream_remote_url + ); + Repository::clone(&self.upstream_remote_url, self.path.as_path())?; + info!("Finished cloning to {}", self.path.display()); + + // bail early as we already have a fresh copy + return Ok(()); + }; + + debug!("Updating repository at {}", self.path.display()); + self.update_branches_in(&repository)?; + debug!("Finished updating!"); + + Ok(()) + } +} diff --git a/crates/git-tracker/src/tracker.rs b/crates/git-tracker/src/tracker.rs index e26f82d..e6a3f54 100644 --- a/crates/git-tracker/src/tracker.rs +++ b/crates/git-tracker/src/tracker.rs @@ -2,6 +2,11 @@ use std::path::Path; use git2::{Branch, BranchType, Commit, ErrorCode, Oid, Reference, Repository}; +/// Helper struct for tracking Git objects +pub struct Tracker { + repository: Repository, +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("libgit2 error")] @@ -10,11 +15,6 @@ pub enum Error { RepositoryPathNotFound(String), } -/// Helper struct for tracking Git objects -pub struct Tracker { - repository: Repository, -} - impl Tracker { /// Create a new [`Tracker`] using the repository at [`path`] /// @@ -76,7 +76,7 @@ impl Tracker { .repository .graph_descendant_of(head.id(), commit.id())?; - Ok(has_commit || is_head) + Ok(is_head || has_commit) } /// Check if a [`Branch`] named [`branch_name`] has a commit with the SHA [`commit_sha`] diff --git a/crates/nixpkgs-tracker-http/Cargo.toml b/crates/nixpkgs-tracker-http/Cargo.toml new file mode 100644 index 0000000..45b897d --- /dev/null +++ b/crates/nixpkgs-tracker-http/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nixpkgs-tracker-http" +version.workspace = true +authors.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true + +publish = false + +[dependencies] +log.workspace = true +reqwest = { version = "0.12.5", default-features = false, features = [ + "charset", + "http2", + "rustls-tls", + "json" +] } +serde = { version = "1.0.204", features = ["derive"] } + +[lints] +workspace = true diff --git a/crates/nixpkgs-tracker-http/src/github.rs b/crates/nixpkgs-tracker-http/src/github.rs new file mode 100644 index 0000000..12ee832 --- /dev/null +++ b/crates/nixpkgs-tracker-http/src/github.rs @@ -0,0 +1,40 @@ +use super::{Error, PullRequest}; + +use std::future::Future; + +use log::trace; + +const GITHUB_API: &str = "https://api.github.com"; + +pub trait Ext { + /// GET `/repos/{repo_owner}/{repo_name}/pulls/{id}` + /// + /// # Errors + /// + /// Will return [`Err`] if the merge commit cannot be found + fn pull_request( + &self, + repo_owner: &str, + repo_name: &str, + id: u64, + ) -> impl Future<Output = Result<PullRequest, Error>> + Send; +} + +impl Ext for super::Client { + async fn pull_request( + &self, + repo_owner: &str, + repo_name: &str, + id: u64, + ) -> Result<PullRequest, Error> { + let url = format!("{GITHUB_API}/repos/{repo_owner}/{repo_name}/pulls/{id}"); + + let request = self.get(&url).build()?; + trace!("Making GET request to `{}`", request.url()); + let response = self.execute(request).await?; + response.error_for_status_ref()?; + let pull_request: PullRequest = response.json().await?; + + Ok(pull_request) + } +} diff --git a/crates/nixpkgs-tracker-http/src/lib.rs b/crates/nixpkgs-tracker-http/src/lib.rs new file mode 100644 index 0000000..873ebb8 --- /dev/null +++ b/crates/nixpkgs-tracker-http/src/lib.rs @@ -0,0 +1,28 @@ +mod github; +mod model; +mod teawie; + +pub use github::Ext as GitHubClientExt; +pub use model::*; +pub use teawie::Ext as TeawieClientExt; + +pub type Client = reqwest::Client; +pub type Error = reqwest::Error; + +/// Fun trait for functions we use with [Client] +pub trait Ext { + fn default() -> Self; +} + +impl Ext for Client { + /// Create the default [`Client`] + fn default() -> Self { + reqwest::Client::builder() + .user_agent(format!( + "nixpkgs-tracker-bot/{}", + option_env!("CARGO_PKG_VERSION").unwrap_or_else(|| "development") + )) + .build() + .unwrap() + } +} diff --git a/crates/bot-http/src/model.rs b/crates/nixpkgs-tracker-http/src/model.rs index afd4717..9439538 100644 --- a/crates/bot-http/src/model.rs +++ b/crates/nixpkgs-tracker-http/src/model.rs @@ -3,6 +3,11 @@ use serde::Deserialize; /// Bad version of `/repos/{owner}/{repo}/pulls/{pull_number}` for Github's api #[derive(Clone, Debug, Deserialize)] pub struct PullRequest { + pub html_url: String, + pub number: u64, + pub title: String, + pub merged: bool, + pub merged_at: Option<String>, pub merge_commit_sha: Option<String>, } @@ -10,4 +15,5 @@ pub struct PullRequest { #[derive(Clone, Debug, Deserialize)] pub struct RandomTeawie { pub url: Option<String>, + pub error: Option<String>, } diff --git a/crates/nixpkgs-tracker-http/src/teawie.rs b/crates/nixpkgs-tracker-http/src/teawie.rs new file mode 100644 index 0000000..97af63c --- /dev/null +++ b/crates/nixpkgs-tracker-http/src/teawie.rs @@ -0,0 +1,30 @@ +use crate::{Error, RandomTeawie}; + +use std::future::Future; + +use log::trace; + +const TEAWIE_API: &str = "https://api.getchoo.com"; + +pub trait Ext { + /// Get a random teawie + /// + /// # Errors + /// + /// Will return [`Err`] if the request fails or the response cannot be deserialized + fn random_teawie(&self) -> impl Future<Output = Result<RandomTeawie, Error>> + Send; +} + +impl Ext for super::Client { + async fn random_teawie(&self) -> Result<RandomTeawie, Error> { + let url = format!("{TEAWIE_API}/random_teawie"); + + let request = self.get(&url).build()?; + trace!("Making GET request to {}", request.url()); + let response = self.execute(request).await?; + response.error_for_status_ref()?; + let random_teawie: RandomTeawie = response.json().await?; + + Ok(random_teawie) + } +} |
