diff options
author | Cyborus <cyborus@cyborus.xyz> | 2023-12-11 05:59:04 +0100 |
---|---|---|
committer | Cyborus <cyborus@cyborus.xyz> | 2023-12-12 22:53:13 +0100 |
commit | d8c04f662a9f416eadbbf8b6fe58c641cd4bc436 (patch) | |
tree | 1492b4ef8e3be387b05a9dad815dff31bb77db50 /src | |
parent | Merge pull request 'fix repo creation pushing' (#6) from push-fix into main (diff) | |
download | forgejo-cli-d8c04f662a9f416eadbbf8b6fe58c641cd4bc436.tar.xz forgejo-cli-d8c04f662a9f416eadbbf8b6fe58c641cd4bc436.zip |
add basic issue commands
Diffstat (limited to 'src')
-rw-r--r-- | src/issues.rs | 335 | ||||
-rw-r--r-- | src/main.rs | 58 | ||||
-rw-r--r-- | src/repo.rs | 8 |
3 files changed, 389 insertions, 12 deletions
diff --git a/src/issues.rs b/src/issues.rs new file mode 100644 index 0000000..2970a26 --- /dev/null +++ b/src/issues.rs @@ -0,0 +1,335 @@ +use clap::Subcommand; +use eyre::eyre; +use forgejo_api::{Forgejo, CreateIssueCommentOption, EditIssueOption, IssueCommentQuery, Comment}; + +use crate::repo::RepoInfo; + +#[derive(Subcommand, Clone, Debug)] +pub enum IssueCommand { + Create { + title: String, + #[clap(long)] + body: Option<String>, + }, + Edit { + issue: u64, + #[clap(subcommand)] + command: EditCommand, + }, + Comment { + issue: u64, + body: Option<String>, + }, + Close { + issue: u64, + #[clap(long, short)] + with_msg: Option<Option<String>>, + }, + View { + id: u64, + #[clap(subcommand)] + command: Option<ViewCommand>, + }, + Browse { + id: Option<u64>, + }, +} + +#[derive(Subcommand, Clone, Debug)] +pub enum EditCommand { + Title { + new_title: Option<String>, + }, + Body { + new_body: Option<String>, + }, + Comment { + idx: usize, + new_body: Option<String>, + }, +} + +#[derive(Subcommand, Clone, Debug)] +pub enum ViewCommand { + Body, + Comment { + idx: usize, + }, + Comments, +} + +impl IssueCommand { + pub async fn run(self, keys: &crate::KeyInfo) -> eyre::Result<()> { + use IssueCommand::*; + let repo = RepoInfo::get_current()?; + let api = keys.get_api(&repo.host_url())?; + match self { + 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?, + ViewCommand::Comment { idx } => view_comment(&repo, &api, id, idx).await?, + ViewCommand::Comments => view_comments(&repo, &api, id).await? + }, + Edit { issue, command } => match command { + EditCommand::Title { new_title } => { + edit_title(&repo, &api, issue, new_title).await? + } + EditCommand::Body { new_body } => edit_body(&repo, &api, issue, new_body).await?, + EditCommand::Comment { idx, new_body } => { + edit_comment(&repo, &api, issue, idx, new_body).await? + } + }, + Close { issue, with_msg } => close_issue(&repo, &api, issue, with_msg).await?, + Browse { id } => browse_issue(&repo, &api, id).await?, + Comment { issue, body } => add_comment(&repo, &api, issue, body).await?, + } + Ok(()) + } +} + +async fn create_issue( + repo: &RepoInfo, + api: &Forgejo, + title: String, + body: Option<String>, +) -> eyre::Result<()> { + let body = match body { + Some(body) => body, + None => { + let mut body = String::new(); + crate::editor(&mut body, Some("md")).await?; + body + } + }; + let issue = api + .create_issue( + repo.owner(), + repo.name(), + forgejo_api::CreateIssueOption { + body: Some(body), + title, + ..Default::default() + }, + ) + .await?; + eprintln!("created issue #{}: {}", issue.number, issue.title); + Ok(()) +} + +async fn view_issue(repo: &RepoInfo, api: &Forgejo, id: u64) -> eyre::Result<()> { + let issue = api + .get_issue(repo.owner(), repo.name(), id) + .await? + .ok_or_else(|| eyre!("issue {id} does not exist"))?; + println!("#{}: {}", id, issue.title); + println!("By {}", issue.user.login); + if !issue.body.is_empty() { + println!(); + println!("{}", issue.body); + } + Ok(()) +} + +async fn view_comment(repo: &RepoInfo, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> { + let comments = api + .get_issue_comments(repo.owner(), repo.name(), id, IssueCommentQuery::default()) + .await?; + let comment = comments.get(idx).ok_or_else(|| eyre!("comment {idx} doesn't exist"))?; + print_comment(&comment); + Ok(()) +} + +async fn view_comments(repo: &RepoInfo, api: &Forgejo, id: u64) -> eyre::Result<()> { + let comments = api + .get_issue_comments(repo.owner(), repo.name(), id, IssueCommentQuery::default()) + .await?; + for comment in comments { + print_comment(&comment); + } + Ok(()) +} + +fn print_comment(comment: &Comment) { + println!("{} said:", comment.user.login); + println!("{}", comment.body); + if !comment.assets.is_empty() { + println!("({} attachments)", comment.assets.len()); + } +} + +async fn browse_issue(repo: &RepoInfo, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> { + match id { + Some(id) => { + let issue = api + .get_issue(repo.owner(), repo.name(), id) + .await? + .ok_or_else(|| eyre!("issue {id} does not exist"))?; + open::that(issue.html_url.as_str())?; + } + None => { + let repo = api + .get_repo(repo.owner(), repo.name()) + .await? + .ok_or_else(|| eyre!("repo {}/{} does not exist", repo.owner(), repo.name()))?; + open::that(format!("{}/issues", repo.html_url))?; + } + } + Ok(()) +} + +async fn add_comment( + repo: &RepoInfo, + api: &Forgejo, + issue: u64, + body: Option<String>, +) -> eyre::Result<()> { + let body = match body { + Some(body) => body, + None => { + let mut body = String::new(); + crate::editor(&mut body, Some("md")).await?; + body + } + }; + api.create_comment( + repo.owner(), + repo.name(), + issue, + forgejo_api::CreateIssueCommentOption { + body, + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +async fn edit_title( + repo: &RepoInfo, + api: &Forgejo, + issue: u64, + new_title: Option<String>, +) -> eyre::Result<()> { + let new_title = match new_title { + Some(s) => s, + None => { + let mut issue_info = api + .get_issue(repo.owner(), repo.name(), issue) + .await? + .ok_or_else(|| eyre!("issue {issue} does not exist"))?; + crate::editor(&mut issue_info.title, Some("md")).await?; + issue_info.title + } + }; + if new_title.is_empty() { + eyre::bail!("title cannot be empty"); + } + if new_title.contains('\n') { + eyre::bail!("title cannot contain newlines"); + } + api.edit_issue( + repo.owner(), + repo.name(), + issue, + forgejo_api::EditIssueOption { + title: Some(new_title.trim().to_owned()), + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +async fn edit_body( + repo: &RepoInfo, + api: &Forgejo, + issue: u64, + new_body: Option<String>, +) -> eyre::Result<()> { + let new_body = match new_body { + Some(s) => s, + None => { + let mut issue_info = api + .get_issue(repo.owner(), repo.name(), issue) + .await? + .ok_or_else(|| eyre!("issue {issue} does not exist"))?; + crate::editor(&mut issue_info.body, Some("md")).await?; + issue_info.body + } + }; + api.edit_issue( + repo.owner(), + repo.name(), + issue, + forgejo_api::EditIssueOption { + body: Some(new_body), + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +async fn edit_comment( + repo: &RepoInfo, + api: &Forgejo, + issue: u64, + idx: usize, + new_body: Option<String>, +) -> eyre::Result<()> { + let comments = api + .get_issue_comments( + repo.owner(), + repo.name(), + issue, + forgejo_api::IssueCommentQuery::default(), + ) + .await?; + let comment = comments + .get(idx) + .ok_or_else(|| eyre!("comment not found"))?; + let new_body = match new_body { + Some(s) => s, + None => { + let mut body = comment.body.clone(); + crate::editor(&mut body, Some("md")).await?; + body + } + }; + api.edit_comment( + repo.owner(), + repo.name(), + comment.id, + forgejo_api::EditIssueCommentOption { + body: new_body, + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +async fn close_issue(repo: &RepoInfo, api: &Forgejo, issue: u64, message: Option<Option<String>>) -> eyre::Result<()> { + if let Some(message) = message { + let body = match message { + Some(m) => m, + None => { + let mut s = String::new(); + crate::editor(&mut s, Some("md")).await?; + s + } + }; + + let opt = CreateIssueCommentOption { body }; + api.create_comment(repo.owner(), repo.name(), issue, opt).await?; + } + + let edit = EditIssueOption { + state: Some(forgejo_api::State::Closed), + ..Default::default() + }; + api.edit_issue(repo.owner(), repo.name(), issue, edit).await?; + + Ok(()) + +} diff --git a/src/main.rs b/src/main.rs index cb8e8e9..856f958 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,5 @@ -use std::{collections::BTreeMap, io::ErrorKind}; - use clap::{Parser, Subcommand}; -use eyre::{bail, eyre}; -use forgejo_api::{CreateRepoOption, Forgejo}; +use eyre::eyre; use tokio::io::AsyncWriteExt; use url::Url; @@ -10,6 +7,7 @@ mod keys; use keys::*; mod auth; +mod issues; mod repo; #[derive(Parser, Debug)] @@ -22,6 +20,8 @@ pub struct App { pub enum Command { #[clap(subcommand)] Repo(repo::RepoCommand), + #[clap(subcommand)] + Issue(issues::IssueCommand), User { #[clap(long, short)] host: Option<String>, @@ -36,7 +36,8 @@ async fn main() -> eyre::Result<()> { let mut keys = KeyInfo::load().await?; match args.command { - Command::Repo(repo_subcommand) => repo_subcommand.run(&keys).await?, + Command::Repo(subcommand) => subcommand.run(&keys).await?, + Command::Issue(subcommand) => subcommand.run(&keys).await?, Command::User { host } => { let host = host.map(|host| Url::parse(&host)).transpose()?; let url = match host { @@ -46,7 +47,7 @@ async fn main() -> eyre::Result<()> { let name = keys.get_login(&url)?.username(); eprintln!("currently signed in to {name}@{url}"); } - Command::Auth(auth_subcommand) => auth_subcommand.run(&mut keys).await?, + Command::Auth(subcommand) => subcommand.run(&mut keys).await?, } keys.save().await?; @@ -63,3 +64,48 @@ async fn readline(msg: &str) -> eyre::Result<String> { }) .await? } + +async fn editor(contents: &mut String, ext: Option<&str>) -> eyre::Result<()> { + let editor = std::env::var_os("EDITOR").ok_or_else(|| eyre!("unable to locate editor"))?; + + let (mut file, path) = tempfile(ext).await?; + file.write_all(contents.as_bytes()).await?; + drop(file); + + // Closure acting as a try/catch block so that the temp file is deleted even + // on errors + let res = (|| async { + eprint!("waiting on editor\r"); + let status = tokio::process::Command::new(editor) + .arg(&path) + .status() + .await?; + if !status.success() { + eyre::bail!("editor exited unsuccessfully"); + } + + *contents = tokio::fs::read_to_string(&path).await?; + eprint!(" \r"); + + Ok(()) + })().await; + + tokio::fs::remove_file(path).await?; + res?; + Ok(()) +} + +async fn tempfile(ext: Option<&str>) -> tokio::io::Result<(tokio::fs::File, std::path::PathBuf)> { + let filename = uuid::Uuid::new_v4(); + let mut path = std::env::temp_dir().join(filename.to_string()); + if let Some(ext) = ext { + path.set_extension(ext); + } + let file = tokio::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&path) + .await?; + Ok((file, path)) +} diff --git a/src/repo.rs b/src/repo.rs index 8ca89e0..a98e1f5 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -97,8 +97,7 @@ impl RepoCommand { push, } => { let host = Url::parse(&host)?; - let login = keys.get_login(&host)?; - let api = login.api_for(&host)?; + let api = keys.get_api(&host)?; let repo_spec = CreateRepoOption { auto_init: false, default_branch: "main".into(), @@ -113,10 +112,7 @@ impl RepoCommand { trust_model: forgejo_api::TrustModel::Default, }; let new_repo = api.create_repo(repo_spec).await?; - eprintln!( - "created new repo at {}", - host.join(&format!("{}/{}", login.username(), repo))? - ); + eprintln!("created new repo at {}", host.join(&new_repo.full_name)?); if set_upstream.is_some() || push { let repo = git2::Repository::open(".")?; |