diff --git a/src/issues.rs b/src/issues.rs index 1f1d07d..0e49cbe 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -1,14 +1,24 @@ -use clap::Subcommand; -use eyre::eyre; +use clap::{Args, Subcommand}; +use eyre::{eyre, OptionExt}; use forgejo_api::structs::{ Comment, CreateIssueCommentOption, CreateIssueOption, EditIssueOption, IssueGetCommentsQuery, }; use forgejo_api::Forgejo; -use crate::repo::RepoInfo; +use crate::repo::{RepoInfo, RepoName}; + +#[derive(Args, Clone, Debug)] +pub struct IssueCommand { + #[clap(long, short = 'R')] + remote: Option, + #[clap(long, short)] + repo: Option, + #[clap(subcommand)] + command: IssueSubcommand, +} #[derive(Subcommand, Clone, Debug)] -pub enum IssueCommand { +pub enum IssueSubcommand { Create { title: String, #[clap(long)] @@ -86,11 +96,12 @@ pub enum ViewCommand { } impl IssueCommand { - pub async fn run(self, keys: &crate::KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { - use IssueCommand::*; - let repo = RepoInfo::get_current(remote_name)?; - let api = keys.get_api(&repo.host_url())?; - match self { + pub async fn run(self, keys: &crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { + use IssueSubcommand::*; + let repo = RepoInfo::get_current(host_name, self.repo.as_deref(), self.remote.as_deref())?; + let api = keys.get_api(repo.host_url())?; + let repo = repo.name().ok_or_eyre("couldn't get repo name, try specifying with --repo")?; + match self.command { Create { title, body } => create_issue(&repo, &api, title, body).await?, View { id, command } => match command.unwrap_or(ViewCommand::Body) { ViewCommand::Body => view_issue(&repo, &api, id).await?, @@ -122,7 +133,7 @@ impl IssueCommand { } async fn create_issue( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, title: String, body: Option, @@ -163,7 +174,7 @@ async fn create_issue( Ok(()) } -async fn view_issue(repo: &RepoInfo, api: &Forgejo, id: u64) -> eyre::Result<()> { +async fn view_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> { let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?; let title = issue .title @@ -186,7 +197,7 @@ async fn view_issue(repo: &RepoInfo, api: &Forgejo, id: u64) -> eyre::Result<()> Ok(()) } async fn view_issues( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, query_str: Option, labels: Option, @@ -240,7 +251,7 @@ async fn view_issues( Ok(()) } -async fn view_comment(repo: &RepoInfo, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> { +async fn view_comment(repo: &RepoName, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> { let query = IssueGetCommentsQuery { since: None, before: None, @@ -255,7 +266,7 @@ async fn view_comment(repo: &RepoInfo, api: &Forgejo, id: u64, idx: usize) -> ey Ok(()) } -async fn view_comments(repo: &RepoInfo, api: &Forgejo, id: u64) -> eyre::Result<()> { +async fn view_comments(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> { let query = IssueGetCommentsQuery { since: None, before: None, @@ -294,7 +305,7 @@ fn print_comment(comment: &Comment) -> eyre::Result<()> { Ok(()) } -async fn browse_issue(repo: &RepoInfo, api: &Forgejo, id: Option) -> eyre::Result<()> { +async fn browse_issue(repo: &RepoName, api: &Forgejo, id: Option) -> eyre::Result<()> { match id { Some(id) => { let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?; @@ -317,7 +328,7 @@ async fn browse_issue(repo: &RepoInfo, api: &Forgejo, id: Option) -> eyre:: } async fn add_comment( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, issue: u64, body: Option, @@ -344,7 +355,7 @@ async fn add_comment( } async fn edit_title( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, issue: u64, new_title: Option, @@ -390,7 +401,7 @@ async fn edit_title( } async fn edit_body( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, issue: u64, new_body: Option, @@ -430,7 +441,7 @@ async fn edit_body( } async fn edit_comment( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, issue: u64, idx: usize, @@ -478,7 +489,7 @@ async fn edit_comment( } async fn close_issue( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, issue: u64, message: Option>, diff --git a/src/main.rs b/src/main.rs index db4fe20..9e09ed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ use clap::{Parser, Subcommand}; -use eyre::eyre; +use eyre::{eyre, Context}; use tokio::io::AsyncWriteExt; -use url::Url; mod keys; use keys::*; @@ -13,8 +12,8 @@ mod repo; #[derive(Parser, Debug)] pub struct App { - #[clap(long, short = 'R')] - remote: Option, + #[clap(long, short = 'H')] + host: Option, #[clap(subcommand)] command: Command, } @@ -23,15 +22,13 @@ pub struct App { pub enum Command { #[clap(subcommand)] Repo(repo::RepoCommand), - #[clap(subcommand)] Issue(issues::IssueCommand), User { #[clap(long, short)] - host: Option, + remote: Option, }, #[clap(subcommand)] Auth(auth::AuthCommand), - #[clap(subcommand)] Release(release::ReleaseCommand), } @@ -40,21 +37,18 @@ async fn main() -> eyre::Result<()> { let args = App::parse(); let mut keys = KeyInfo::load().await?; - let remote_name = args.remote.as_deref(); + let host_name = args.host.as_deref(); + // let remote = repo::RepoInfo::get_current(host_name, remote_name)?; match args.command { - Command::Repo(subcommand) => subcommand.run(&keys, remote_name).await?, - Command::Issue(subcommand) => subcommand.run(&keys, remote_name).await?, - Command::User { host } => { - let host = host.map(|host| Url::parse(&host)).transpose()?; - let url = match host { - Some(url) => url, - None => repo::RepoInfo::get_current(remote_name)?.url().clone(), - }; + Command::Repo(subcommand) => subcommand.run(&keys, host_name).await?, + Command::Issue(subcommand) => subcommand.run(&keys, host_name).await?, + Command::User { remote } => { + let url = repo::RepoInfo::get_current(host_name, None, remote.as_deref()).wrap_err("could not find host, try specifying with --host")?.host_url().clone(); let name = keys.get_login(&url)?.username(); eprintln!("currently signed in to {name}@{url}"); } Command::Auth(subcommand) => subcommand.run(&mut keys).await?, - Command::Release(subcommand) => subcommand.run(&mut keys, remote_name).await?, + Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?, } keys.save().await?; diff --git a/src/release.rs b/src/release.rs index 12e4923..f6b1eb9 100644 --- a/src/release.rs +++ b/src/release.rs @@ -1,15 +1,25 @@ -use clap::Subcommand; -use eyre::{bail, eyre}; +use clap::{Subcommand, Args}; +use eyre::{bail, eyre, OptionExt}; use forgejo_api::{ structs::{RepoCreateReleaseAttachmentQuery, RepoListReleasesQuery}, Forgejo, }; use tokio::io::AsyncWriteExt; -use crate::{keys::KeyInfo, repo::RepoInfo}; +use crate::{keys::KeyInfo, repo::{RepoInfo, RepoName}}; + +#[derive(Args, Clone, Debug)] +pub struct ReleaseCommand { + #[clap(long, short = 'R')] + remote: Option, + #[clap(long, short)] + repo: Option, + #[clap(subcommand)] + command: ReleaseSubcommand, +} #[derive(Subcommand, Clone, Debug)] -pub enum ReleaseCommand { +pub enum ReleaseSubcommand { Create { name: String, #[clap(long, short = 'T')] @@ -103,10 +113,11 @@ pub enum AssetCommand { impl ReleaseCommand { pub async fn run(self, keys: &KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { - let repo = RepoInfo::get_current(remote_name)?; + let repo = RepoInfo::get_current(remote_name, self.repo.as_deref(), self.remote.as_deref())?; let api = keys.get_api(&repo.host_url())?; - match self { - Self::Create { + let repo = repo.name().ok_or_eyre("couldn't get repo name, try specifying with --repo")?; + match self.command { + ReleaseSubcommand::Create { name, create_tag, tag, @@ -121,7 +132,7 @@ impl ReleaseCommand { ) .await? } - Self::Edit { + ReleaseSubcommand::Edit { name, rename, tag, @@ -129,14 +140,14 @@ impl ReleaseCommand { draft, prerelease, } => edit_release(&repo, &api, name, rename, tag, body, draft, prerelease).await?, - Self::Delete { name, by_tag } => delete_release(&repo, &api, name, by_tag).await?, - Self::List { + ReleaseSubcommand::Delete { name, by_tag } => delete_release(&repo, &api, name, by_tag).await?, + ReleaseSubcommand::List { include_prerelease, include_draft, } => list_releases(&repo, &api, include_prerelease, include_draft).await?, - Self::View { name, by_tag } => view_release(&repo, &api, name, by_tag).await?, - Self::Browse { name } => browse_release(&repo, &api, name).await?, - Self::Asset(subcommand) => match subcommand { + ReleaseSubcommand::View { name, by_tag } => view_release(&repo, &api, name, by_tag).await?, + ReleaseSubcommand::Browse { name } => browse_release(&repo, &api, name).await?, + ReleaseSubcommand::Asset(subcommand) => match subcommand { AssetCommand::Create { release, path, @@ -157,7 +168,7 @@ impl ReleaseCommand { } async fn create_release( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, name: String, create_tag: Option>, @@ -241,7 +252,7 @@ async fn create_release( } async fn edit_release( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, name: String, rename: Option, @@ -280,7 +291,7 @@ async fn edit_release( } async fn list_releases( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, prerelease: bool, draft: bool, @@ -321,7 +332,7 @@ async fn list_releases( } async fn view_release( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, name: String, by_tag: bool, @@ -384,7 +395,7 @@ async fn view_release( Ok(()) } -async fn browse_release(repo: &RepoInfo, api: &Forgejo, name: Option) -> eyre::Result<()> { +async fn browse_release(repo: &RepoName, api: &Forgejo, name: Option) -> eyre::Result<()> { match name { Some(name) => { let release = find_release(repo, api, &name).await?; @@ -395,16 +406,20 @@ async fn browse_release(repo: &RepoInfo, api: &Forgejo, name: Option) -> open::that(html_url.as_str())?; } None => { - let mut url = repo.url().clone(); - url.path_segments_mut().unwrap().push("releases"); - open::that(url.as_str())?; + let repo_data = api.repo_get(repo.owner(), repo.name()).await?; + let mut html_url = repo_data + .html_url + .clone() + .ok_or_else(|| eyre::eyre!("repository does not have html_url"))?; + html_url.path_segments_mut().unwrap().push("releases"); + open::that(html_url.as_str())?; } } Ok(()) } async fn create_asset( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, release: String, file: std::path::PathBuf, @@ -441,7 +456,7 @@ async fn create_asset( } async fn delete_asset( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, release: String, asset: String, @@ -467,7 +482,7 @@ async fn delete_asset( } async fn download_asset( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, release: String, asset: String, @@ -526,7 +541,7 @@ async fn download_asset( } async fn find_release( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, name: &str, ) -> eyre::Result { @@ -548,7 +563,7 @@ async fn find_release( } async fn delete_release( - repo: &RepoInfo, + repo: &RepoName, api: &Forgejo, name: String, by_tag: bool, diff --git a/src/repo.rs b/src/repo.rs index c02cf9d..6c26125 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,32 +1,228 @@ use clap::Subcommand; -use eyre::eyre; +use eyre::{eyre, OptionExt}; use forgejo_api::structs::CreateRepoOption; use url::Url; pub struct RepoInfo { - owner: String, - name: String, url: Url, + name: Option, } impl RepoInfo { - pub fn get_current(remote: Option<&str>) -> eyre::Result { - let repo = git2::Repository::open(".")?; - let url = get_remote(&repo, remote)?; + pub fn get_current(host: Option<&str>, repo: Option<&str>, remote: Option<&str>) -> eyre::Result { + // l = domain/owner/name + // s = owner/name + // x = is present + // i = found locally by git + // + // | repo | host | remote | ans-host | ans-repo | + // |------|------|--------|----------|----------| + // | l | x | x | repo | repo | + // | l | x | i | repo | repo | + // | l | x | | repo | repo | + // | l | | x | repo | repo | + // | l | | i | repo | repo | + // | l | | | repo | repo | + // | s | x | x | host | repo | + // | s | x | i | host | repo | + // | s | x | | host | repo | + // | s | | x | remote | repo | + // | s | | i | remote | repo | + // | s | | | err | repo | + // | | x | x | remote | remote | + // | | x | i | host | ?remote | + // | | x | | host | none | + // | | | x | remote | remote | + // | | | i | remote | remote | + // | | | | err | remote | + // + // | repo | host | remote | ans-host | ans-repo | + // |------|------|--------|----------|----------| + // | l | x | x | repo | repo | + // | l | x | | repo | repo | + // | l | | x | repo | repo | + // | l | | | repo | repo | + // | s | x | x | host | repo | + // | s | x | | host | repo | + // | s | | x | remote | repo | + // | s | | | err | repo | + // | | x | x | remote | remote | + // | | x | | remote | remote | + // | | | x | remote | remote | + // | | | | err | remote | - let mut path = url.path_segments().ok_or_else(|| eyre!("bad path"))?; - let owner = path - .next() - .ok_or_else(|| eyre!("path does not have owner name"))? - .to_string(); - let name = path - .next() - .ok_or_else(|| eyre!("path does not have repo name"))?; - let name = name.strip_suffix(".git").unwrap_or(name).to_string(); + // let repo_name; + // + // let repo_url; + // let remote; + // let host; + // + // let url = if repo_url { repo_url } + // else if repo_name { host.or(remote) } + // else { remote.or_host() } + // + // let name = repo_name.or(remote) + + let mut repo_url: Option = None; + let mut repo_name: Option = None; - let repo_info = RepoInfo { owner, name, url }; - Ok(repo_info) + if let Some(repo) = repo { + let (head, name) = repo.rsplit_once("/").ok_or_eyre("repo name must contain owner and name")?; + let name = name.strip_suffix(".git").unwrap_or(name); + match head.rsplit_once("/") { + Some((url, owner)) => { + if let Ok(url) = Url::parse(url) { + repo_url = Some(url); + } else if let Ok(url) = Url::parse(&format!("https://{url}/")) { + repo_url = Some(url); + } + repo_name = Some(RepoName { + owner: owner.to_owned(), + name: name.to_owned(), + }); + }, + None => { + repo_name = Some(RepoName { + owner: head.to_owned(), + name: name.to_owned(), + }); + }, + } + } + + let repo_url = repo_url; + let repo_name = repo_name; + + let host_url = host.and_then(|host| Url::parse(host).ok().or_else(|| Url::parse(&format!("https://{host}/")).ok())); + + let (remote_url, remote_repo_name) = { + let mut out = (None, None); + if let Ok(local_repo) = git2::Repository::open(".") { + let tmp; + let mut name = remote; + + // if the user didn't specify a remote, try guessing other ways + let mut tmp2; + if name.is_none() { + let all_remotes = local_repo.remotes()?; + // if there's only one remote, use that + if all_remotes.len() == 1 { + if let Some(remote_name) = all_remotes.get(0) { + tmp2 = Some(remote_name.to_owned()); + name = tmp2.as_deref(); + } + // if there's a remote whose host url matches the one + // specified with `--host`, use that + // + // This is different than using `--host` itself, since this + // will include the repo name, which `--host` can't do. + } else if let Some(host_url) = &host_url { + for remote_name in all_remotes.iter() { + let Some(remote_name) = remote_name else { continue }; + let remote = local_repo.find_remote(remote_name)?; + + if let Some(url) = remote.url() { + let (url, _) = url_strip_repo_name(Url::parse(url)?)?; + if url.host_str() == host_url.host_str() && url.path() == host_url.path() { + tmp2 = Some(remote_name.to_owned()); + name = tmp2.as_deref(); + } + } + } + } + } + + // if there isn't an obvious answer, guess from the current + // branch's tracking remote + if name.is_none() { + let head = local_repo.head()?; + let branch_name = head.name().ok_or_else(|| eyre!("branch name not UTF-8"))?; + tmp = local_repo.branch_upstream_remote(branch_name).ok(); + name = tmp.as_ref().map(|remote| { + remote + .as_str() + .ok_or_else(|| eyre!("remote name not UTF-8")) + }).transpose()?; + } + + if let Some(name) = name { + if let Ok(remote) = local_repo.find_remote(name) { + let url_s = std::str::from_utf8(remote.url_bytes())?; + let url = Url::parse(url_s)?; + let (url, name) = url_strip_repo_name(url)?; + + out = (Some(url), Some(name)) + + } + } + } else { + eyre::ensure!(remote.is_none(), "remote specified but no git repo found"); + } + out + }; + + let (url, name) = if repo_url.is_some() { + (repo_url, repo_name) + } else if repo_name.is_some() { + (host_url.or(remote_url), repo_name) + } else { + if remote.is_some() { + (remote_url, remote_repo_name) + } else if host_url.is_none() || remote_url == host_url { + (remote_url, remote_repo_name) + } else { + (host_url, None) + } + }; + + let info = match (url, name) { + (Some(url), name) => RepoInfo { + url, + name, + }, + (None, Some(_)) => eyre::bail!("cannot find repo, no host specified"), + (None, None) => eyre::bail!("no repo info specified"), + }; + + Ok(info) } + + pub fn name(&self) -> Option<&RepoName> { + self.name.as_ref() + } + + pub fn host_url(&self) -> &Url { + &self.url + } +} + +fn url_strip_repo_name(mut url: Url) -> eyre::Result<(Url, RepoName)> { + let mut iter = url + .path_segments() + .ok_or_eyre("repo url cannot be a base")? + .rev(); + + let name = iter.next().ok_or_eyre("repo url too short")?; + let name = name.strip_suffix(".git").unwrap_or(name).to_owned(); + + let owner = iter.next().ok_or_eyre("repo url too short")?.to_owned(); + + // Remove the username and repo name from the url + url.path_segments_mut() + .map_err(|_| eyre!("repo url cannot be a base"))? + .pop() + .pop(); + + Ok((url, RepoName { owner, name })) +} + +#[derive(Debug)] +pub struct RepoName { + owner: String, + name: String, +} + +impl RepoName { pub fn owner(&self) -> &str { &self.owner } @@ -34,48 +230,11 @@ impl RepoInfo { pub fn name(&self) -> &str { &self.name } - - pub fn url(&self) -> &Url { - &self.url - } - - pub fn host_url(&self) -> Url { - let mut url = self.url.clone(); - url.path_segments_mut() - .expect("invalid url: cannot be a base") - .pop() - .pop(); - url - } -} - -fn get_remote(repo: &git2::Repository, name: Option<&str>) -> eyre::Result { - if let Some(name) = name { - if let Ok(url) = Url::parse(name) { - return Ok(url); - } - } - let remote_name; - let remote_name = match name { - Some(name) => name, - None => { - let head = repo.head()?; - let branch_name = head.name().ok_or_else(|| eyre!("branch name not UTF-8"))?; - remote_name = repo.branch_upstream_remote(branch_name)?; - remote_name - .as_str() - .ok_or_else(|| eyre!("remote name not UTF-8"))? - } - }; - let remote = repo.find_remote(remote_name)?; - let url = Url::parse(std::str::from_utf8(remote.url_bytes())?)?; - Ok(url) } #[derive(Subcommand, Clone, Debug)] pub enum RepoCommand { Create { - host: String, repo: String, // flags @@ -91,15 +250,22 @@ pub enum RepoCommand { #[clap(long, short)] push: bool, }, - Info, - Browse, + Info { + name: Option, + #[clap(long, short = 'R')] + remote: Option, + }, + Browse { + name: Option, + #[clap(long, short = 'R')] + remote: Option, + }, } impl RepoCommand { - pub async fn run(self, keys: &crate::KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { + pub async fn run(self, keys: &crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { match self { RepoCommand::Create { - host, repo, description, @@ -107,8 +273,8 @@ impl RepoCommand { set_upstream, push, } => { - let host = Url::parse(&host)?; - let api = keys.get_api(&host)?; + let host = RepoInfo::get_current(host_name, None, None)?; + let api = keys.get_api(host.host_url())?; let repo_spec = CreateRepoOption { auto_init: Some(false), default_branch: Some("main".into()), @@ -127,7 +293,7 @@ impl RepoCommand { .full_name .as_ref() .ok_or_else(|| eyre::eyre!("new_repo does not have full_name"))?; - eprintln!("created new repo at {}", host.join(&full_name)?); + eprintln!("created new repo at {}", host.host_url().join(&full_name)?); if set_upstream.is_some() || push { let repo = git2::Repository::open(".")?; @@ -158,22 +324,20 @@ impl RepoCommand { } } } - RepoCommand::Info => { - let repo = RepoInfo::get_current(remote_name)?; + RepoCommand::Info { name, remote } => { + let repo = RepoInfo::get_current(host_name, name.as_deref(), remote.as_deref())?; let api = keys.get_api(&repo.host_url())?; + let repo = repo.name().ok_or_eyre("couldn't get repo name, please specify")?; let repo = api.repo_get(repo.owner(), repo.name()).await?; + dbg!(repo); } - RepoCommand::Browse => { - let repo = RepoInfo::get_current(remote_name)?; + RepoCommand::Browse { name, remote } => { + let repo = RepoInfo::get_current(host_name, name.as_deref(), remote.as_deref())?; let mut url = repo.host_url().clone(); - let new_path = format!( - "{}/{}/{}", - url.path().strip_suffix("/").unwrap_or(url.path()), - repo.owner(), - repo.name(), - ); - url.set_path(&new_path); + let repo = repo.name().ok_or_eyre("couldn't get repo name, please specify")?; + url.path_segments_mut().map_err(|_| eyre!("url invalid"))?.extend([repo.owner(), repo.name()]); + open::that(url.as_str())?; } };