summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/bot-client/Cargo.toml32
-rw-r--r--crates/bot-client/src/handler.rs112
-rw-r--r--crates/bot-client/src/lib.rs79
-rw-r--r--crates/bot-commands/Cargo.toml34
-rw-r--r--crates/bot-commands/src/about.rs44
-rw-r--r--crates/bot-commands/src/lib.rs17
-rw-r--r--crates/bot-commands/src/ping.rs21
-rw-r--r--crates/bot-commands/src/track.rs121
-rw-r--r--crates/bot-config/Cargo.toml23
-rw-r--r--crates/bot-config/src/lib.rs36
-rw-r--r--crates/bot-consts/Cargo.toml23
-rw-r--r--crates/bot-consts/src/lib.rs5
-rw-r--r--crates/bot-error/Cargo.toml23
-rw-r--r--crates/bot-error/src/lib.rs1
-rw-r--r--crates/bot-http/Cargo.toml26
-rw-r--r--crates/bot-http/src/github.rs35
-rw-r--r--crates/bot-http/src/lib.rs63
-rw-r--r--crates/bot-http/src/model.rs13
-rw-r--r--crates/bot-http/src/teawie.rs24
-rw-r--r--crates/bot-jobs/Cargo.toml29
-rw-r--r--crates/bot-jobs/src/lib.rs30
-rw-r--r--crates/bot-jobs/src/repo.rs77
-rw-r--r--crates/bot/Cargo.toml26
-rw-r--r--crates/bot/src/main.rs10
-rw-r--r--crates/git-tracker/Cargo.toml27
-rw-r--r--crates/git-tracker/src/lib.rs4
-rw-r--r--crates/git-tracker/src/tracker.rs109
27 files changed, 1044 insertions, 0 deletions
diff --git a/crates/bot-client/Cargo.toml b/crates/bot-client/Cargo.toml
new file mode 100644
index 0000000..a2ba2a0
--- /dev/null
+++ b/crates/bot-client/Cargo.toml
@@ -0,0 +1,32 @@
+[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-client/src/handler.rs b/crates/bot-client/src/handler.rs
new file mode 100644
index 0000000..2cd0082
--- /dev/null
+++ b/crates/bot-client/src/handler.rs
@@ -0,0 +1,112 @@
+use crate::{SharedConfig, SharedHttp};
+use bot_error::Error;
+
+use log::{debug, error, info, trace, warn};
+use serenity::all::CreateBotAuthParameters;
+use serenity::async_trait;
+use serenity::builder::{
+ CreateEmbed, CreateInteractionResponse, CreateInteractionResponseFollowup,
+ CreateInteractionResponseMessage,
+};
+use serenity::model::{
+ application::{Command, CommandInteraction, Interaction},
+ colour::Colour,
+ gateway::Ready,
+};
+use serenity::prelude::{Context, EventHandler};
+
+#[derive(Clone, Copy, Debug)]
+pub struct Handler;
+
+impl Handler {
+ async fn register_commands(&self, ctx: &Context) -> Result<(), Error> {
+ let commands = bot_commands::to_vec();
+ let commands_len = commands.len();
+ for command in commands {
+ Command::create_global_command(&ctx.http, command).await?;
+ }
+
+ debug!("Registered {} commands", commands_len);
+ Ok(())
+ }
+
+ /// Dispatch our commands from a [`CommandInteraction`]
+ async fn dispatch_command(ctx: &Context, command: &CommandInteraction) -> Result<(), Error> {
+ let command_name = command.data.name.as_str();
+
+ // grab our configuration & http client from the aether
+ let (http, config) = {
+ let read = ctx.data.read().await;
+ let http = read
+ .get::<SharedHttp>()
+ .ok_or("Couldn't get shared HTTP client! WHY??????")?
+ .clone();
+ let config = read
+ .get::<SharedConfig>()
+ .ok_or("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?,
+ _ => {
+ let message = CreateInteractionResponseMessage::new().content(format!(
+ "It doesn't look like you can use `{command_name}`. Sorry :("
+ ));
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+ }
+ };
+
+ Ok(())
+ }
+
+ async fn invite_link(ctx: &Context) {
+ if let Ok(invite_link) = CreateBotAuthParameters::new().auto_client_id(ctx).await {
+ let link = invite_link.build();
+ info!("You can install me as an app at {link}");
+ } else {
+ warn!("Couldn't figure out our own client ID! Something might be wrong");
+ }
+ }
+}
+
+#[async_trait]
+impl EventHandler for Handler {
+ /// Dispatch our commands and try to handle errors from them
+ async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
+ if let Interaction::Command(command) = interaction {
+ let command_name = &command.data.name;
+ trace!("Received command: {}", command_name);
+
+ if let Err(why) = Handler::dispatch_command(&ctx, &command).await {
+ error!(
+ "Ran into an error while dispatching command {}:\n{why:?}",
+ command_name
+ );
+
+ let embed = CreateEmbed::new()
+ .title("An error occurred")
+ .description("Sorry about that!")
+ .color(Colour::RED);
+ let response = CreateInteractionResponseFollowup::new().embed(embed);
+
+ if let Err(why) = command.create_followup(&ctx.http, response).await {
+ error!("Ran into an error while trying to recover from an error!\n{why:?}");
+ }
+ }
+ }
+ }
+
+ async fn ready(&self, ctx: Context, ready: Ready) {
+ info!("Connected as {}!", ready.user.name);
+ Handler::invite_link(&ctx).await;
+
+ if let Err(why) = self.register_commands(&ctx).await {
+ error!("Couldn't register commands!\n{why:?}");
+ };
+ }
+}
diff --git a/crates/bot-client/src/lib.rs b/crates/bot-client/src/lib.rs
new file mode 100644
index 0000000..851b853
--- /dev/null
+++ b/crates/bot-client/src/lib.rs
@@ -0,0 +1,79 @@
+use bot_config::Config;
+use bot_error::Error;
+use bot_http as http;
+
+use std::sync::Arc;
+
+use log::trace;
+use serenity::prelude::{Client, GatewayIntents, TypeMapKey};
+
+mod handler;
+
+use handler::Handler;
+
+/// Container for [`http::Client`]
+struct SharedHttp;
+
+impl TypeMapKey for SharedHttp {
+ type Value = Arc<http::Client>;
+}
+
+/// Container for [`Config`]
+struct SharedConfig;
+
+impl TypeMapKey for SharedConfig {
+ type Value = Arc<Config>;
+}
+
+/// Fetch our bot token
+fn token() -> Result<String, Error> {
+ let token = std::env::var("DISCORD_BOT_TOKEN")?;
+ Ok(token)
+}
+
+/// Create our client
+///
+/// # Errors
+///
+/// Will return [`Err`] if a [`Client`] cannot be created or configuration
+/// cannot be created from the environment.
+///
+/// # 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> {
+ let token = token().expect("Couldn't find token in environment! Is DISCORD_BOT_TOKEN set?");
+
+ let intents = GatewayIntents::default();
+ trace!("Creating client");
+ let client = Client::builder(token, intents)
+ .event_handler(Handler)
+ .await?;
+
+ // add state stuff
+ let http_client = <http::Client as http::ClientExt>::default();
+ let config = Config::from_env()?;
+
+ {
+ let mut data = client.data.write().await;
+
+ data.insert::<SharedHttp>(Arc::new(http_client));
+ data.insert::<SharedConfig>(Arc::new(config.clone()));
+ }
+
+ let shard_manager = client.shard_manager.clone();
+
+ // gracefully shutdown on ctrl+c
+ tokio::spawn(async move {
+ #[cfg(target_family = "unix")]
+ tokio::signal::ctrl_c()
+ .await
+ .expect("Couldn't register ctrl+c handler!");
+ shard_manager.shutdown_all().await;
+ });
+
+ // run our jobs
+ bot_jobs::dispatch(config)?;
+
+ Ok(client)
+}
diff --git a/crates/bot-commands/Cargo.toml b/crates/bot-commands/Cargo.toml
new file mode 100644
index 0000000..3594c70
--- /dev/null
+++ b/crates/bot-commands/Cargo.toml
@@ -0,0 +1,34 @@
+[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/about.rs b/crates/bot-commands/src/about.rs
new file mode 100644
index 0000000..2e5efae
--- /dev/null
+++ b/crates/bot-commands/src/about.rs
@@ -0,0 +1,44 @@
+use bot_error::Error;
+use bot_http::TeawieClientExt;
+
+use serenity::builder::{
+ CreateCommand, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse,
+ CreateInteractionResponseMessage,
+};
+use serenity::model::application::{CommandInteraction, InstallationContext};
+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> {
+ 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.")
+ .fields([
+ ("Version", VERSION, true),
+ ("Source code", &format!("[getchoo/nixpkgs-tracker-bot]({REPOSITORY})"), true),
+ ("Issues/Feature Requests", &format!("[getchoo/nixpkgs-tracker-bot/issues]({REPOSITORY}/issues)"), true)
+ ]);
+
+ if let Some(teawie_url) = http.random_teawie().await? {
+ let footer = CreateEmbedFooter::new("Images courtesy of @sympathytea");
+ embed = embed.image(teawie_url).footer(footer);
+ };
+
+ let message = CreateInteractionResponseMessage::new().embed(embed);
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+
+ Ok(())
+}
+
+pub fn register() -> CreateCommand {
+ CreateCommand::new("about")
+ .description("Learn more about me")
+ .add_integration_type(InstallationContext::User)
+}
diff --git a/crates/bot-commands/src/lib.rs b/crates/bot-commands/src/lib.rs
new file mode 100644
index 0000000..79fce17
--- /dev/null
+++ b/crates/bot-commands/src/lib.rs
@@ -0,0 +1,17 @@
+use serenity::builder::CreateCommand;
+
+pub mod about;
+pub mod ping;
+pub mod track;
+
+macro_rules! cmd {
+ ($module: ident) => {
+ $module::register()
+ };
+}
+
+/// Return a list of all our [`CreateCommand`]s
+#[must_use]
+pub fn to_vec() -> Vec<CreateCommand> {
+ vec![cmd!(about), cmd!(ping), cmd!(track)]
+}
diff --git a/crates/bot-commands/src/ping.rs b/crates/bot-commands/src/ping.rs
new file mode 100644
index 0000000..b18a0b6
--- /dev/null
+++ b/crates/bot-commands/src/ping.rs
@@ -0,0 +1,21 @@
+use bot_error::Error;
+
+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> {
+ let message = CreateInteractionResponseMessage::new().content("Pong!");
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+
+ Ok(())
+}
+
+pub fn register() -> CreateCommand {
+ CreateCommand::new("ping")
+ .description("Check if the bot is up")
+ .add_integration_type(InstallationContext::User)
+}
diff --git a/crates/bot-commands/src/track.rs b/crates/bot-commands/src/track.rs
new file mode 100644
index 0000000..1f22d0e
--- /dev/null
+++ b/crates/bot-commands/src/track.rs
@@ -0,0 +1,121 @@
+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
new file mode 100644
index 0000000..57b9a67
--- /dev/null
+++ b/crates/bot-config/Cargo.toml
@@ -0,0 +1,23 @@
+[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-config/src/lib.rs b/crates/bot-config/src/lib.rs
new file mode 100644
index 0000000..0691884
--- /dev/null
+++ b/crates/bot-config/src/lib.rs
@@ -0,0 +1,36 @@
+use std::env;
+
+/// The Discord client's configuration
+#[derive(Clone, Debug)]
+pub struct Config {
+ /// Path to clone a new or use an existing nixpkgs repository
+ pub nixpkgs_path: String,
+ // A comma separated list of nixpkgs branch to track commits for
+ pub nixpkgs_branches: Vec<String>,
+}
+
+impl Config {
+ /// Take in a comma separated list and split it into a [`Vec<String>`]
+ fn split_string_list(branches: &str) -> Vec<String> {
+ branches
+ .split(',')
+ .map(|branch| branch.trim().to_string())
+ .collect()
+ }
+
+ /// Create a new instance of [`Config`] based on variables from the environment
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if a variable is not found
+ pub fn from_env() -> Result<Self, env::VarError> {
+ let nixpkgs_path = env::var("BOT_NIXPKGS_PATH")?;
+ let nixpkgs_branches_raw = env::var("BOT_NIXPKGS_BRANCHES")?;
+ let nixpkgs_branches = Self::split_string_list(&nixpkgs_branches_raw);
+
+ Ok(Self {
+ nixpkgs_path,
+ nixpkgs_branches,
+ })
+ }
+}
diff --git a/crates/bot-consts/Cargo.toml b/crates/bot-consts/Cargo.toml
new file mode 100644
index 0000000..16d7726
--- /dev/null
+++ b/crates/bot-consts/Cargo.toml
@@ -0,0 +1,23 @@
+[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-consts/src/lib.rs b/crates/bot-consts/src/lib.rs
new file mode 100644
index 0000000..9396da0
--- /dev/null
+++ b/crates/bot-consts/src/lib.rs
@@ -0,0 +1,5 @@
+/// URL to the nixpkgs repository
+pub const NIXPKGS_URL: &str = "https://github.com/NixOS/nixpkgs";
+
+/// The Git remote for upstream nixpkgs in our local copy
+pub const NIXPKGS_REMOTE: &str = "origin";
diff --git a/crates/bot-error/Cargo.toml b/crates/bot-error/Cargo.toml
new file mode 100644
index 0000000..c6f6ed1
--- /dev/null
+++ b/crates/bot-error/Cargo.toml
@@ -0,0 +1,23 @@
+[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
new file mode 100644
index 0000000..f34e60e
--- /dev/null
+++ b/crates/bot-error/src/lib.rs
@@ -0,0 +1 @@
+pub type Error = Box<dyn std::error::Error + Send + Sync>;
diff --git a/crates/bot-http/Cargo.toml b/crates/bot-http/Cargo.toml
new file mode 100644
index 0000000..e451537
--- /dev/null
+++ b/crates/bot-http/Cargo.toml
@@ -0,0 +1,26 @@
+[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.4", default-features = false, features = ["charset", "http2", "rustls-tls", "json"] }
+serde = { version = "1.0.203", 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
new file mode 100644
index 0000000..7822eb8
--- /dev/null
+++ b/crates/bot-http/src/github.rs
@@ -0,0 +1,35 @@
+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
new file mode 100644
index 0000000..ab32cd4
--- /dev/null
+++ b/crates/bot-http/src/lib.rs
@@ -0,0 +1,63 @@
+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/model.rs b/crates/bot-http/src/model.rs
new file mode 100644
index 0000000..afd4717
--- /dev/null
+++ b/crates/bot-http/src/model.rs
@@ -0,0 +1,13 @@
+use serde::Deserialize;
+
+/// Bad version of `/repos/{owner}/{repo}/pulls/{pull_number}` for Github's api
+#[derive(Clone, Debug, Deserialize)]
+pub struct PullRequest {
+ pub merge_commit_sha: Option<String>,
+}
+
+/// `/random_teawie` for the teawieAPI
+#[derive(Clone, Debug, Deserialize)]
+pub struct RandomTeawie {
+ pub url: Option<String>,
+}
diff --git a/crates/bot-http/src/teawie.rs b/crates/bot-http/src/teawie.rs
new file mode 100644
index 0000000..ea4f53e
--- /dev/null
+++ b/crates/bot-http/src/teawie.rs
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 0000000..21b0248
--- /dev/null
+++ b/crates/bot-jobs/Cargo.toml
@@ -0,0 +1,29 @@
+[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
new file mode 100644
index 0000000..d65c929
--- /dev/null
+++ b/crates/bot-jobs/src/lib.rs
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 0000000..4d3e214
--- /dev/null
+++ b/crates/bot-jobs/src/repo.rs
@@ -0,0 +1,77 @@
+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
new file mode 100644
index 0000000..c56fc52
--- /dev/null
+++ b/crates/bot/Cargo.toml
@@ -0,0 +1,26 @@
+[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.3"
+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/src/main.rs b/crates/bot/src/main.rs
new file mode 100644
index 0000000..390e79b
--- /dev/null
+++ b/crates/bot/src/main.rs
@@ -0,0 +1,10 @@
+#[tokio::main]
+async fn main() -> Result<(), bot_error::Error> {
+ dotenvy::dotenv().ok();
+ env_logger::try_init()?;
+
+ let mut client = bot_client::get().await?;
+ client.start().await?;
+
+ Ok(())
+}
diff --git a/crates/git-tracker/Cargo.toml b/crates/git-tracker/Cargo.toml
new file mode 100644
index 0000000..60baa41
--- /dev/null
+++ b/crates/git-tracker/Cargo.toml
@@ -0,0 +1,27 @@
+[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"
+
+publish = false
+
+[dependencies]
+git2 = { workspace = true }
+log = { workspace = true }
+thiserror = "1.0.61"
+
+[lints.rust]
+async_fn_in_trait = "allow"
+unsafe_code = "forbid"
+
+[lints.clippy]
+complexity = "warn"
+correctness = "deny"
+pedantic = "warn"
+perf = "warn"
+style = "warn"
+suspicious = "deny"
diff --git a/crates/git-tracker/src/lib.rs b/crates/git-tracker/src/lib.rs
new file mode 100644
index 0000000..cb0907b
--- /dev/null
+++ b/crates/git-tracker/src/lib.rs
@@ -0,0 +1,4 @@
+//! A library that helps you track commits and branches in a Git repository
+
+mod tracker;
+pub use tracker::{Error, Tracker};
diff --git a/crates/git-tracker/src/tracker.rs b/crates/git-tracker/src/tracker.rs
new file mode 100644
index 0000000..e26f82d
--- /dev/null
+++ b/crates/git-tracker/src/tracker.rs
@@ -0,0 +1,109 @@
+use std::path::Path;
+
+use git2::{Branch, BranchType, Commit, ErrorCode, Oid, Reference, Repository};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("libgit2 error")]
+ Git(#[from] git2::Error),
+ #[error("Repository path not found at `{0}`")]
+ RepositoryPathNotFound(String),
+}
+
+/// Helper struct for tracking Git objects
+pub struct Tracker {
+ repository: Repository,
+}
+
+impl Tracker {
+ /// Create a new [`Tracker`] using the repository at [`path`]
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if the repository can not be opened
+ pub fn from_path(path: &str) -> Result<Self, Error> {
+ let repository_path = Path::new(path);
+ if repository_path.exists() {
+ let repository = Repository::open(repository_path)?;
+ Ok(Self { repository })
+ } else {
+ Err(Error::RepositoryPathNotFound(path.to_string()))
+ }
+ }
+
+ /// Finds a branch of name [`name`]
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if the branch cannot be found locally
+ pub fn branch_by_name(&self, name: &str) -> Result<Branch, Error> {
+ Ok(self.repository.find_branch(name, BranchType::Remote)?)
+ }
+
+ /// Finds a commit with a SHA match [`sha`]
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if [`sha`] cannot be converted an [`Oid`] or
+ /// a commit matching it cannot be found
+ pub fn commit_by_sha(&self, sha: &str) -> Result<Commit, Error> {
+ let oid = Oid::from_str(sha)?;
+ let commit = self.repository.find_commit(oid)?;
+
+ Ok(commit)
+ }
+
+ /// Check if [`Reference`] [`ref`] contains [`Commit`] [`commit`]
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if the reference cannot be resolved to a commit or the descendants
+ /// of the reference cannot be resolved
+ pub fn ref_contains_commit(
+ &self,
+ reference: &Reference,
+ commit: &Commit,
+ ) -> Result<bool, Error> {
+ let head = reference.peel_to_commit()?;
+
+ // NOTE: we have to check this as `Repository::graph_descendant_of()` (like the name says)
+ // only finds *descendants* of it's parent commit, and will not tell us if the parent commit
+ // *is* the child commit. i have no idea why i didn't think of this, but that's why this
+ // comment is here now
+ let is_head = head.id() == commit.id();
+
+ let has_commit = self
+ .repository
+ .graph_descendant_of(head.id(), commit.id())?;
+
+ Ok(has_commit || is_head)
+ }
+
+ /// Check if a [`Branch`] named [`branch_name`] has a commit with the SHA [`commit_sha`]
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if the commit SHA cannot be resolved to an object id, the branch name cannot
+ /// be resolved to a branch, or the descendants of the resolved branch cannot be resolved
+ pub fn branch_contains_sha(&self, branch_name: &str, commit_sha: &str) -> Result<bool, Error> {
+ let commit = match self.commit_by_sha(commit_sha) {
+ Ok(commit) => commit,
+ Err(why) => {
+ // NOTE: we assume commits not found are just not in the branch *yet*, not an error
+ // this is because github decides to report merge commit shas for unmerged PRs...yeah
+ if let Error::Git(git_error) = &why {
+ if git_error.code() == ErrorCode::NotFound {
+ return Ok(false);
+ }
+ }
+
+ return Err(why);
+ }
+ };
+
+ let branch = self.branch_by_name(branch_name)?;
+ let has_pr = self.ref_contains_commit(&branch.into_reference(), &commit)?;
+
+ Ok(has_pr)
+ }
+}