diff --git a/src/github.rs b/src/github.rs index 9c3b5ab..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,10 +93,13 @@ 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)] -pub struct GitHub { +pub struct GitHubArgs { /// GitHub token for API access #[arg(long, env = "GITHUB_TOKEN")] pub token: Option, @@ -105,7 +108,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 +121,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 async fn login(mut self) -> Result { // Try to get token from GitHub CLI if self.token.is_none() { self.token = Command::new("gh") @@ -142,9 +143,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).await; } println!("No GitHub token found. Please authenticate with GitHub."); @@ -168,31 +169,73 @@ impl GitHub { anyhow::bail!("GitHub authentication required. Please follow the instructions above."); } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Milestone { + title: String, + number: u64, +} - fn client(&self) -> Client { +#[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; + + async 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()?; + 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> { - 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,21 +243,36 @@ 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::>(); 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 client = self.client(); + 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.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..3cc1452 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")] @@ -47,13 +47,12 @@ struct Args { } #[tokio::main] -#[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().await?); // Bind to our server port let listener = TcpListener::bind(&args.bind).await?; @@ -61,8 +60,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 +70,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