mirror of
https://codeberg.org/Cyborus/forgejo-cli.git
synced 2024-11-10 12:09:33 +01:00
add basic issue commands
This commit is contained in:
parent
be7136bc13
commit
d8c04f662a
5 changed files with 682 additions and 303 deletions
581
Cargo.lock
generated
581
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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
335
src/issues.rs
Normal 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(())
|
||||
|
||||
}
|
58
src/main.rs
58
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))
|
||||
}
|
||||
|
|
|
@ -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(".")?;
|
||||
|
|
Loading…
Reference in a new issue