mirror of
https://codeberg.org/Cyborus/forgejo-cli.git
synced 2024-11-10 12:09:33 +01:00
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:
commit
ae382110a4
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"] }
|
clap = { version = "4.3.11", features = ["derive"] }
|
||||||
directories = "5.0.1"
|
directories = "5.0.1"
|
||||||
eyre = "0.6.8"
|
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"
|
futures = "0.3.28"
|
||||||
git2 = "0.17.2"
|
git2 = "0.17.2"
|
||||||
open = "5.0.0"
|
open = "5.0.0"
|
||||||
|
@ -19,4 +19,5 @@ serde_json = "1.0.100"
|
||||||
soft_assert = "0.1.1"
|
soft_assert = "0.1.1"
|
||||||
tokio = { version = "1.29.1", features = ["full"] }
|
tokio = { version = "1.29.1", features = ["full"] }
|
||||||
url = "2.4.0"
|
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 clap::{Parser, Subcommand};
|
||||||
use eyre::{bail, eyre};
|
use eyre::eyre;
|
||||||
use forgejo_api::{CreateRepoOption, Forgejo};
|
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -10,6 +7,7 @@ mod keys;
|
||||||
use keys::*;
|
use keys::*;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod issues;
|
||||||
mod repo;
|
mod repo;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
|
@ -22,6 +20,8 @@ pub struct App {
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
Repo(repo::RepoCommand),
|
Repo(repo::RepoCommand),
|
||||||
|
#[clap(subcommand)]
|
||||||
|
Issue(issues::IssueCommand),
|
||||||
User {
|
User {
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
|
@ -36,7 +36,8 @@ async fn main() -> eyre::Result<()> {
|
||||||
let mut keys = KeyInfo::load().await?;
|
let mut keys = KeyInfo::load().await?;
|
||||||
|
|
||||||
match args.command {
|
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 } => {
|
Command::User { host } => {
|
||||||
let host = host.map(|host| Url::parse(&host)).transpose()?;
|
let host = host.map(|host| Url::parse(&host)).transpose()?;
|
||||||
let url = match host {
|
let url = match host {
|
||||||
|
@ -46,7 +47,7 @@ async fn main() -> eyre::Result<()> {
|
||||||
let name = keys.get_login(&url)?.username();
|
let name = keys.get_login(&url)?.username();
|
||||||
eprintln!("currently signed in to {name}@{url}");
|
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?;
|
keys.save().await?;
|
||||||
|
@ -63,3 +64,48 @@ async fn readline(msg: &str) -> eyre::Result<String> {
|
||||||
})
|
})
|
||||||
.await?
|
.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,
|
push,
|
||||||
} => {
|
} => {
|
||||||
let host = Url::parse(&host)?;
|
let host = Url::parse(&host)?;
|
||||||
let login = keys.get_login(&host)?;
|
let api = keys.get_api(&host)?;
|
||||||
let api = login.api_for(&host)?;
|
|
||||||
let repo_spec = CreateRepoOption {
|
let repo_spec = CreateRepoOption {
|
||||||
auto_init: false,
|
auto_init: false,
|
||||||
default_branch: "main".into(),
|
default_branch: "main".into(),
|
||||||
|
@ -113,10 +112,7 @@ impl RepoCommand {
|
||||||
trust_model: forgejo_api::TrustModel::Default,
|
trust_model: forgejo_api::TrustModel::Default,
|
||||||
};
|
};
|
||||||
let new_repo = api.create_repo(repo_spec).await?;
|
let new_repo = api.create_repo(repo_spec).await?;
|
||||||
eprintln!(
|
eprintln!("created new repo at {}", host.join(&new_repo.full_name)?);
|
||||||
"created new repo at {}",
|
|
||||||
host.join(&format!("{}/{}", login.username(), repo))?
|
|
||||||
);
|
|
||||||
|
|
||||||
if set_upstream.is_some() || push {
|
if set_upstream.is_some() || push {
|
||||||
let repo = git2::Repository::open(".")?;
|
let repo = git2::Repository::open(".")?;
|
||||||
|
|
Loading…
Reference in a new issue