Merge pull request 'add basic issue commands' (#7) from issues into main

Reviewed-on: https://codeberg.org/Cyborus/forgejo-cli/pulls/7
This commit is contained in:
Cyborus 2023-12-12 21:54:12 +00:00
commit ae382110a4
5 changed files with 682 additions and 303 deletions

581
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ auth-git2 = "0.5.3"
clap = { version = "4.3.11", features = ["derive"] }
directories = "5.0.1"
eyre = "0.6.8"
forgejo-api = { git = "https://codeberg.org/Cyborus/forgejo-api.git" }
forgejo-api = { git = "https://codeberg.org/Cyborus/forgejo-api.git", rev = "cdb15605f6" }
futures = "0.3.28"
git2 = "0.17.2"
open = "5.0.0"
@ -19,4 +19,5 @@ serde_json = "1.0.100"
soft_assert = "0.1.1"
tokio = { version = "1.29.1", features = ["full"] }
url = "2.4.0"
uuid = { version = "1.5.0", features = ["v4"] }

335
src/issues.rs Normal file
View file

@ -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(())
}

View file

@ -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))
}

View file

@ -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(".")?;