summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorCyborus <cyborus@cyborus.xyz>2023-12-11 05:59:04 +0100
committerCyborus <cyborus@cyborus.xyz>2023-12-12 22:53:13 +0100
commitd8c04f662a9f416eadbbf8b6fe58c641cd4bc436 (patch)
tree1492b4ef8e3be387b05a9dad815dff31bb77db50 /src
parentMerge pull request 'fix repo creation pushing' (#6) from push-fix into main (diff)
downloadforgejo-cli-d8c04f662a9f416eadbbf8b6fe58c641cd4bc436.tar.xz
forgejo-cli-d8c04f662a9f416eadbbf8b6fe58c641cd4bc436.zip
add basic issue commands
Diffstat (limited to 'src')
-rw-r--r--src/issues.rs335
-rw-r--r--src/main.rs58
-rw-r--r--src/repo.rs8
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(".")?;