Merge pull request 'repo fork command' (#83) from fork into main

Reviewed-on: https://codeberg.org/Cyborus/forgejo-cli/pulls/83
This commit is contained in:
Cyborus 2024-07-08 23:56:38 +00:00
commit 88d5356a44
4 changed files with 164 additions and 51 deletions

View file

@ -7,7 +7,7 @@ use forgejo_api::structs::{
}; };
use forgejo_api::Forgejo; use forgejo_api::Forgejo;
use crate::repo::{RepoInfo, RepoName}; use crate::repo::{RepoArg, RepoInfo, RepoName};
#[derive(Args, Clone, Debug)] #[derive(Args, Clone, Debug)]
pub struct IssueCommand { pub struct IssueCommand {
@ -24,7 +24,7 @@ pub enum IssueSubcommand {
#[clap(long)] #[clap(long)]
body: Option<String>, body: Option<String>,
#[clap(long, short)] #[clap(long, short)]
repo: Option<String>, repo: Option<RepoArg>,
}, },
Edit { Edit {
issue: IssueId, issue: IssueId,
@ -42,7 +42,7 @@ pub enum IssueSubcommand {
}, },
Search { Search {
#[clap(long, short)] #[clap(long, short)]
repo: Option<String>, repo: Option<RepoArg>,
query: Option<String>, query: Option<String>,
#[clap(long, short)] #[clap(long, short)]
labels: Option<String>, labels: Option<String>,
@ -65,16 +65,16 @@ pub enum IssueSubcommand {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct IssueId { pub struct IssueId {
pub repo: Option<String>, pub repo: Option<RepoArg>,
pub number: u64, pub number: u64,
} }
impl FromStr for IssueId { impl FromStr for IssueId {
type Err = std::num::ParseIntError; type Err = IssueIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let (repo, number) = match s.rsplit_once("#") { let (repo, number) = match s.rsplit_once("#") {
Some((repo, number)) => (Some(repo.to_owned()), number), Some((repo, number)) => (Some(repo.parse::<RepoArg>()?), number),
None => (None, s), None => (None, s),
}; };
Ok(Self { Ok(Self {
@ -84,6 +84,35 @@ impl FromStr for IssueId {
} }
} }
#[derive(Debug, Clone)]
pub enum IssueIdError {
Repo(crate::repo::RepoArgError),
Number(std::num::ParseIntError),
}
impl std::fmt::Display for IssueIdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IssueIdError::Repo(e) => e.fmt(f),
IssueIdError::Number(e) => e.fmt(f),
}
}
}
impl From<crate::repo::RepoArgError> for IssueIdError {
fn from(value: crate::repo::RepoArgError) -> Self {
Self::Repo(value)
}
}
impl From<std::num::ParseIntError> for IssueIdError {
fn from(value: std::num::ParseIntError) -> Self {
Self::Number(value)
}
}
impl std::error::Error for IssueIdError {}
#[derive(clap::ValueEnum, Clone, Copy, Debug)] #[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum State { pub enum State {
Open, Open,
@ -163,15 +192,15 @@ impl IssueCommand {
Ok(()) Ok(())
} }
fn repo(&self) -> Option<&str> { fn repo(&self) -> Option<&RepoArg> {
use IssueSubcommand::*; use IssueSubcommand::*;
match &self.command { match &self.command {
Create { repo, .. } | Search { repo, .. } => repo.as_deref(), Create { repo, .. } | Search { repo, .. } => repo.as_ref(),
View { id: issue, .. } View { id: issue, .. }
| Edit { issue, .. } | Edit { issue, .. }
| Close { issue, .. } | Close { issue, .. }
| Comment { issue, .. } | Comment { issue, .. }
| Browse { id: issue, .. } => issue.repo.as_deref(), | Browse { id: issue, .. } => issue.repo.as_ref(),
} }
} }

View file

@ -12,7 +12,7 @@ use forgejo_api::{
use crate::{ use crate::{
issues::IssueId, issues::IssueId,
repo::{RepoInfo, RepoName}, repo::{RepoArg, RepoInfo, RepoName},
SpecialRender, SpecialRender,
}; };
@ -40,7 +40,7 @@ pub enum PrSubcommand {
state: Option<crate::issues::State>, state: Option<crate::issues::State>,
/// The repo to search in /// The repo to search in
#[clap(long, short)] #[clap(long, short)]
repo: Option<String>, repo: Option<RepoArg>,
}, },
/// Create a new pull request /// Create a new pull request
Create { Create {
@ -59,7 +59,7 @@ pub enum PrSubcommand {
body: Option<String>, body: Option<String>,
/// The repo to create this issue on /// The repo to create this issue on
#[clap(long, short)] #[clap(long, short)]
repo: Option<String>, repo: Option<RepoArg>,
}, },
/// View the contents of a pull request /// View the contents of a pull request
View { View {
@ -345,17 +345,17 @@ impl PrCommand {
Ok(()) Ok(())
} }
fn repo(&self) -> Option<&str> { fn repo(&self) -> Option<&RepoArg> {
use PrSubcommand::*; use PrSubcommand::*;
match &self.command { match &self.command {
Search { repo, .. } | Create { repo, .. } => repo.as_deref(), Search { repo, .. } | Create { repo, .. } => repo.as_ref(),
Checkout { .. } => None, Checkout { .. } => None,
View { id: pr, .. } View { id: pr, .. }
| Comment { pr, .. } | Comment { pr, .. }
| Edit { pr, .. } | Edit { pr, .. }
| Close { pr, .. } | Close { pr, .. }
| Merge { pr, .. } | Merge { pr, .. }
| Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_deref()), | Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_ref()),
} }
} }

View file

@ -8,7 +8,7 @@ use tokio::io::AsyncWriteExt;
use crate::{ use crate::{
keys::KeyInfo, keys::KeyInfo,
repo::{RepoInfo, RepoName}, repo::{RepoArg, RepoInfo, RepoName},
SpecialRender, SpecialRender,
}; };
@ -17,7 +17,7 @@ pub struct ReleaseCommand {
#[clap(long, short = 'R')] #[clap(long, short = 'R')]
remote: Option<String>, remote: Option<String>,
#[clap(long, short)] #[clap(long, short)]
repo: Option<String>, repo: Option<RepoArg>,
#[clap(subcommand)] #[clap(subcommand)]
command: ReleaseSubcommand, command: ReleaseSubcommand,
} }
@ -117,8 +117,7 @@ pub enum AssetCommand {
impl ReleaseCommand { impl ReleaseCommand {
pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> {
let repo = let repo = RepoInfo::get_current(remote_name, self.repo.as_ref(), self.remote.as_deref())?;
RepoInfo::get_current(remote_name, self.repo.as_deref(), self.remote.as_deref())?;
let api = keys.get_api(&repo.host_url()).await?; let api = keys.get_api(&repo.host_url()).await?;
let repo = repo let repo = repo
.name() .name()

View file

@ -1,4 +1,4 @@
use std::{io::Write, path::PathBuf}; use std::{io::Write, path::PathBuf, str::FromStr};
use clap::Subcommand; use clap::Subcommand;
use eyre::{eyre, OptionExt}; use eyre::{eyre, OptionExt};
@ -15,7 +15,7 @@ pub struct RepoInfo {
impl RepoInfo { impl RepoInfo {
pub fn get_current( pub fn get_current(
host: Option<&str>, host: Option<&str>,
repo: Option<&str>, repo: Option<&RepoArg>,
remote: Option<&str>, remote: Option<&str>,
) -> eyre::Result<Self> { ) -> eyre::Result<Self> {
// l = domain/owner/name // l = domain/owner/name
@ -48,30 +48,18 @@ impl RepoInfo {
let mut repo_name: Option<RepoName> = None; let mut repo_name: Option<RepoName> = None;
if let Some(repo) = repo { if let Some(repo) = repo {
let (head, name) = repo if let Some(host) = &repo.host {
.rsplit_once("/") if let Ok(url) = Url::parse(host) {
.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); repo_url = Some(url);
} else if let Ok(url) = Url::parse(&format!("https://{url}/")) { } else if let Ok(url) = Url::parse(&format!("https://{host}/")) {
repo_url = Some(url); repo_url = Some(url);
} }
}
repo_name = Some(RepoName { repo_name = Some(RepoName {
owner: owner.to_owned(), owner: repo.owner.clone(),
name: name.to_owned(), name: repo.name.clone(),
}); });
} }
None => {
repo_name = Some(RepoName {
owner: head.to_owned(),
name: name.to_owned(),
});
}
}
}
let repo_url = repo_url; let repo_url = repo_url;
let repo_name = repo_name; let repo_name = repo_name;
@ -241,10 +229,61 @@ impl RepoName {
} }
} }
#[derive(Debug, Clone)]
pub struct RepoArg {
host: Option<String>,
owner: String,
name: String,
}
impl std::fmt::Display for RepoArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.host {
Some(host) => write!(f, "{host}/{}/{}", self.owner, self.name),
None => write!(f, "{}/{}", self.owner, self.name),
}
}
}
impl FromStr for RepoArg {
type Err = RepoArgError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (head, name) = s.rsplit_once("/").ok_or(RepoArgError::NoOwner)?;
let name = name.strip_suffix(".git").unwrap_or(name);
let (host, owner) = match head.rsplit_once("/") {
Some((host, owner)) => (Some(host), owner),
None => (None, head),
};
Ok(Self {
host: host.map(|s| s.to_owned()),
owner: owner.to_owned(),
name: name.to_owned(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoArgError {
NoOwner,
}
impl std::error::Error for RepoArgError {}
impl std::fmt::Display for RepoArgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RepoArgError::NoOwner => {
write!(f, "repo name should be in the format [HOST/]OWNER/NAME")
}
}
}
}
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
pub enum RepoCommand { pub enum RepoCommand {
Create { Create {
repo: String, repo: RepoArg,
// flags // flags
#[clap(long, short)] #[clap(long, short)]
@ -259,23 +298,30 @@ pub enum RepoCommand {
#[clap(long, short)] #[clap(long, short)]
push: bool, push: bool,
}, },
View { Fork {
repo: RepoArg,
#[clap(long)]
name: Option<String>, name: Option<String>,
#[clap(long, short = 'R')] #[clap(long, short = 'R')]
remote: Option<String>, remote: Option<String>,
}, },
View {
name: Option<RepoArg>,
#[clap(long, short = 'R')]
remote: Option<String>,
},
Clone { Clone {
repo: String, repo: RepoArg,
path: Option<PathBuf>, path: Option<PathBuf>,
}, },
Star { Star {
repo: String, repo: RepoArg,
}, },
Unstar { Unstar {
repo: String, repo: RepoArg,
}, },
Browse { Browse {
name: Option<String>, name: Option<RepoArg>,
#[clap(long, short = 'R')] #[clap(long, short = 'R')]
remote: Option<String>, remote: Option<String>,
}, },
@ -309,7 +355,7 @@ impl RepoCommand {
gitignores: None, gitignores: None,
issue_labels: None, issue_labels: None,
license: None, license: None,
name: repo.clone(), name: format!("{}/{}", repo.owner, repo.name),
object_format_name: None, object_format_name: None,
private: Some(private), private: Some(private),
readme: Some(String::new()), readme: Some(String::new()),
@ -355,8 +401,47 @@ impl RepoCommand {
} }
} }
} }
RepoCommand::Fork { repo, name, remote } => {
match (repo.host.as_deref(), host_name) {
(Some(a), Some(b)) => {
fn strip(s: &str) -> &str {
let no_scheme = s
.strip_prefix("https://")
.or_else(|| s.strip_prefix("http://"))
.unwrap_or(s);
let no_trailing_slash =
no_scheme.strip_suffix("/").unwrap_or(no_scheme);
no_trailing_slash
}
if strip(a) != strip(b) {
eyre::bail!("conflicting hosts {a} and {b}. please only specify one");
}
}
_ => (),
}
let repo_info = RepoInfo::get_current(host_name, Some(&repo), remote.as_deref())?;
let api = keys.get_api(&repo_info.host_url()).await?;
let repo = repo_info
.name()
.ok_or_eyre("couldn't get repo name, please specify")?;
let opt = forgejo_api::structs::CreateForkOption {
name,
organization: None,
};
let new_fork = api.create_fork(repo.owner(), repo.name(), opt).await?;
let fork_full_name = new_fork
.full_name
.as_deref()
.ok_or_eyre("fork does not have name")?;
println!(
"Forked {}/{} into {}",
repo.owner(),
repo.name(),
fork_full_name
);
}
RepoCommand::View { name, remote } => { RepoCommand::View { name, remote } => {
let repo = RepoInfo::get_current(host_name, name.as_deref(), remote.as_deref())?; let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
let api = keys.get_api(&repo.host_url()).await?; let api = keys.get_api(&repo.host_url()).await?;
let repo = repo let repo = repo
.name() .name()
@ -573,7 +658,7 @@ impl RepoCommand {
println!("Removed star from {}/{}", name.owner(), name.name()); println!("Removed star from {}/{}", name.owner(), name.name());
} }
RepoCommand::Browse { name, remote } => { RepoCommand::Browse { name, remote } => {
let repo = RepoInfo::get_current(host_name, name.as_deref(), remote.as_deref())?; let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
let mut url = repo.host_url().clone(); let mut url = repo.host_url().clone();
let repo = repo let repo = repo
.name() .name()