From a0167e888b2e4d2d4e1452ca730070b8de652c19 Mon Sep 17 00:00:00 2001 From: Nathaniel McCallum Date: Wed, 15 Oct 2025 13:23:35 -0400 Subject: [PATCH 1/2] chore: split GitHub args from GitHub state This allows us to hold state across GitHub requests without exposing that state as a command line option. --- src/github.rs | 53 ++++++++++++++++++++++++++++----------------------- src/main.rs | 12 +++++------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/github.rs b/src/github.rs index 9c3b5ab..28de249 100644 --- a/src/github.rs +++ b/src/github.rs @@ -96,7 +96,7 @@ pub struct Report { } #[derive(Debug, Clone, clap::Args)] -pub struct GitHub { +pub struct GitHubArgs { /// GitHub token for API access #[arg(long, env = "GITHUB_TOKEN")] pub token: Option, @@ -105,7 +105,7 @@ pub struct GitHub { #[arg(short = 'o', long)] pub owner: String, - /// GitHub repository name + /// GitHub repository name #[arg(short = 'r', long)] pub repo: String, @@ -118,11 +118,9 @@ pub struct GitHub { pub filter: Vec, } -impl GitHub { - const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); - +impl GitHubArgs { /// Authenticate with GitHub by guiding user to create a Personal Access Token - pub fn login(&mut self) -> Result<()> { + pub fn login(mut self) -> Result { // Try to get token from GitHub CLI if self.token.is_none() { self.token = Command::new("gh") @@ -142,9 +140,9 @@ impl GitHub { }); } - // If we already have a token, nothing to do + // If we already have a token, create the client. if self.token.is_some() { - return Ok(()); + return GitHub::new(self); } println!("No GitHub token found. Please authenticate with GitHub."); @@ -168,31 +166,38 @@ impl GitHub { anyhow::bail!("GitHub authentication required. Please follow the instructions above."); } +} + +#[derive(Debug)] +pub struct GitHub { + args: GitHubArgs, + client: Client, +} + +impl GitHub { + const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); - fn client(&self) -> Client { + fn new(args: GitHubArgs) -> Result { let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("Accept", "application/vnd.github+json".parse().unwrap()); - headers.insert("User-Agent", Self::USER_AGENT.parse().unwrap()); + headers.insert("Accept", "application/vnd.github+json".parse()?); + headers.insert("User-Agent", Self::USER_AGENT.parse()?); - if let Some(token) = &self.token { + if let Some(token) = &args.token { let auth_value = format!("Bearer {token}"); - headers.insert("Authorization", auth_value.parse().unwrap()); + headers.insert("Authorization", auth_value.parse()?); } - Client::builder() - .default_headers(headers) - .build() - .expect("Failed to create HTTP client") + let client = Client::builder().default_headers(headers).build()?; + Ok(Self { args, client }) } pub async fn assets(&self) -> Result> { - let client = self.client(); let url = format!( "https://api.github.com/repos/{}/{}/releases/tags/{}", - self.owner, self.repo, self.tag + self.args.owner, self.args.repo, self.args.tag ); - let response = client.get(&url).send().await?; + let response = self.client.get(&url).send().await?; let release: Release = response.json().await?; let assets = release @@ -200,7 +205,8 @@ impl GitHub { .into_iter() .filter_map(Asset::known) .filter(|asset| { - self.filter.is_empty() || self.filter.iter().any(|f| asset.name.contains(f)) + self.args.filter.is_empty() + || self.args.filter.iter().any(|f| asset.name.contains(f)) }) .collect::>(); @@ -208,13 +214,12 @@ impl GitHub { } pub async fn report(&self, report: Report) -> Result<()> { - let client = self.client(); let url = format!( "https://api.github.com/repos/{}/{}/issues", - self.owner, self.repo + self.args.owner, self.args.repo ); - client.post(&url).json(&report).send().await?; + self.client.post(&url).json(&report).send().await?; Ok(()) } } diff --git a/src/main.rs b/src/main.rs index ab59df7..2d6a187 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ mod tui; use std::sync::Arc; use crate::avahi::AvahiService; -use crate::github::GitHub; +use crate::github::GitHubArgs; use crate::http::Server; use crate::tui::{Status, Throbbing}; @@ -35,7 +35,7 @@ use tokio::sync::Mutex; #[command(about = std::env!("CARGO_PKG_DESCRIPTION"))] struct Args { #[command(flatten)] - github: GitHub, + github: GitHubArgs, /// Address and port to bind to #[arg(short = 'b', long, default_value = "0.0.0.0:8080")] @@ -50,10 +50,10 @@ struct Args { #[allow(clippy::too_many_lines)] async fn main() -> Result<()> { // Parse arguments - let mut args = Args::parse(); + let args = Args::parse(); // Ensure we're authenticated with GitHub - args.github.login()?; + let github = Arc::new(args.github.login()?); // Bind to our server port let listener = TcpListener::bind(&args.bind).await?; @@ -61,8 +61,7 @@ async fn main() -> Result<()> { let path = Arc::new(args.path); // Load the github assets - let assets = args - .github + let assets = github .assets() .throbbing("Loading GitHub assets...") .await?; @@ -72,7 +71,6 @@ async fn main() -> Result<()> { status.lock().await.render()?; // Create the HTTP server - let github = Arc::new(args.github); let server = Server::new(listener, status.clone(), github, path.clone())?; // Create TXT records From 6f7af75b20394f946a59c78d5a551c74d5920a41 Mon Sep 17 00:00:00 2001 From: Nathaniel McCallum Date: Wed, 15 Oct 2025 15:29:08 -0400 Subject: [PATCH 2/2] feat: add support for reporting milestones Workloaads likely know only the name of the milestone. On the other hand, the GitHub API knows only about the number of the milestone. Therefore, we load all milestones during launch so that we can automatically resolve the milestone number from the name. --- src/github.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 3 +-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/github.rs b/src/github.rs index 28de249..88f21ea 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::process::Command; use anyhow::Result; @@ -82,7 +82,7 @@ struct Release { } #[derive(Debug, Serialize, Deserialize)] -pub struct Report { +pub struct Report { title: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,6 +93,9 @@ pub struct Report { #[serde(skip_serializing_if = "Option::is_none")] assignees: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + milestone: Option, } #[derive(Debug, Clone, clap::Args)] @@ -120,7 +123,7 @@ pub struct GitHubArgs { impl GitHubArgs { /// Authenticate with GitHub by guiding user to create a Personal Access Token - pub fn login(mut self) -> Result { + pub async fn login(mut self) -> Result { // Try to get token from GitHub CLI if self.token.is_none() { self.token = Command::new("gh") @@ -142,7 +145,7 @@ impl GitHubArgs { // If we already have a token, create the client. if self.token.is_some() { - return GitHub::new(self); + return GitHub::new(self).await; } println!("No GitHub token found. Please authenticate with GitHub."); @@ -168,16 +171,24 @@ impl GitHubArgs { } } +#[derive(Debug, Serialize, Deserialize)] +struct Milestone { + title: String, + number: u64, +} + #[derive(Debug)] pub struct GitHub { args: GitHubArgs, client: Client, + milestones: HashMap, } impl GitHub { const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + const PER_PAGE: u32 = 100; - fn new(args: GitHubArgs) -> Result { + async fn new(args: GitHubArgs) -> Result { let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Accept", "application/vnd.github+json".parse()?); headers.insert("User-Agent", Self::USER_AGENT.parse()?); @@ -188,7 +199,34 @@ impl GitHub { } let client = Client::builder().default_headers(headers).build()?; - Ok(Self { args, client }) + let mut milestones = HashMap::new(); + + // Load all milestones... + for n in 1.. { + let url = format!( + "https://api.github.com/repos/{}/{}/milestones?state=all&per_page={}&page={}", + args.owner, + args.repo, + Self::PER_PAGE, + n + ); + + let response = client.get(&url).send().await?; + let page: Vec = response.json().await?; + if page.is_empty() { + break; + } + + for milestone in page { + milestones.insert(milestone.title, milestone.number); + } + } + + Ok(Self { + args, + client, + milestones, + }) } pub async fn assets(&self) -> Result> { @@ -213,7 +251,22 @@ impl GitHub { Ok(assets) } + fn milestone(&self, title: &str) -> Result { + self.milestones + .get(title) + .copied() + .ok_or_else(|| anyhow::anyhow!("Milestone '{}' not found", title)) + } + pub async fn report(&self, report: Report) -> Result<()> { + let report = Report { + title: report.title, + body: report.body, + labels: report.labels, + assignees: report.assignees, + milestone: report.milestone.map(|t| self.milestone(&t)).transpose()?, + }; + let url = format!( "https://api.github.com/repos/{}/{}/issues", self.args.owner, self.args.repo diff --git a/src/main.rs b/src/main.rs index 2d6a187..3cc1452 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,13 +47,12 @@ struct Args { } #[tokio::main] -#[allow(clippy::too_many_lines)] async fn main() -> Result<()> { // Parse arguments let args = Args::parse(); // Ensure we're authenticated with GitHub - let github = Arc::new(args.github.login()?); + let github = Arc::new(args.github.login().await?); // Bind to our server port let listener = TcpListener::bind(&args.bind).await?;