summaryrefslogtreecommitdiffstats
path: root/src/issues.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/issues.rs')
-rw-r--r--src/issues.rs674
1 files changed, 674 insertions, 0 deletions
diff --git a/src/issues.rs b/src/issues.rs
new file mode 100644
index 0000000..a65e3d4
--- /dev/null
+++ b/src/issues.rs
@@ -0,0 +1,674 @@
+use std::str::FromStr;
+
+use clap::{Args, Subcommand};
+use eyre::{eyre, OptionExt};
+use forgejo_api::structs::{
+ Comment, CreateIssueCommentOption, CreateIssueOption, EditIssueOption, IssueGetCommentsQuery,
+};
+use forgejo_api::Forgejo;
+
+use crate::repo::{RepoArg, RepoInfo, RepoName};
+
+#[derive(Args, Clone, Debug)]
+pub struct IssueCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: IssueSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum IssueSubcommand {
+ /// Create a new issue on a repo
+ Create {
+ title: String,
+ #[clap(long)]
+ body: Option<String>,
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ },
+ /// Edit an issue
+ Edit {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ #[clap(subcommand)]
+ command: EditCommand,
+ },
+ /// Add a comment on an issue
+ Comment {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ body: Option<String>,
+ },
+ /// Close an issue
+ Close {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ /// A comment to leave on the issue before closing it
+ #[clap(long, short)]
+ with_msg: Option<Option<String>>,
+ },
+ /// Search for an issue in a repo
+ Search {
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ query: Option<String>,
+ #[clap(long, short)]
+ labels: Option<String>,
+ #[clap(long, short)]
+ creator: Option<String>,
+ #[clap(long, short)]
+ assignee: Option<String>,
+ #[clap(long, short)]
+ state: Option<State>,
+ },
+ /// View an issue's info
+ View {
+ #[clap(id = "[REPO#]ID")]
+ id: IssueId,
+ #[clap(subcommand)]
+ command: Option<ViewCommand>,
+ },
+ /// Open an issue in your browser
+ Browse {
+ #[clap(id = "[REPO#]ID")]
+ id: IssueId,
+ },
+}
+
+#[derive(Clone, Debug)]
+pub struct IssueId {
+ pub repo: Option<RepoArg>,
+ pub number: u64,
+}
+
+impl FromStr for IssueId {
+ type Err = IssueIdError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (repo, number) = match s.rsplit_once("#") {
+ Some((repo, number)) => (Some(repo.parse::<RepoArg>()?), number),
+ None => (None, s),
+ };
+ Ok(Self {
+ repo,
+ number: number.parse()?,
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum IssueIdError {
+ Repo(crate::repo::RepoArgError),
+ Number(std::num::ParseIntError),
+}
+
+impl std::fmt::Display for IssueIdError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ IssueIdError::Repo(e) => e.fmt(f),
+ IssueIdError::Number(e) => e.fmt(f),
+ }
+ }
+}
+
+impl From<crate::repo::RepoArgError> for IssueIdError {
+ fn from(value: crate::repo::RepoArgError) -> Self {
+ Self::Repo(value)
+ }
+}
+
+impl From<std::num::ParseIntError> for IssueIdError {
+ fn from(value: std::num::ParseIntError) -> Self {
+ Self::Number(value)
+ }
+}
+
+impl std::error::Error for IssueIdError {}
+
+#[derive(clap::ValueEnum, Clone, Copy, Debug)]
+pub enum State {
+ Open,
+ Closed,
+}
+
+impl From<State> for forgejo_api::structs::IssueListIssuesQueryState {
+ fn from(value: State) -> Self {
+ match value {
+ State::Open => forgejo_api::structs::IssueListIssuesQueryState::Open,
+ State::Closed => forgejo_api::structs::IssueListIssuesQueryState::Closed,
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Edit an issue's title
+ Title { new_title: Option<String> },
+ /// Edit an issue's text content
+ Body { new_body: Option<String> },
+ /// Edit a comment on an issue
+ Comment {
+ idx: usize,
+ new_body: Option<String>,
+ },
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum ViewCommand {
+ /// View an issue's title and body. The default
+ Body,
+ /// View a specific
+ Comment { idx: usize },
+ /// List every comment
+ Comments,
+}
+
+impl IssueCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ use IssueSubcommand::*;
+ let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
+ match self.command {
+ Create {
+ repo: _,
+ 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.number).await?,
+ ViewCommand::Comment { idx } => view_comment(&repo, &api, id.number, idx).await?,
+ ViewCommand::Comments => view_comments(&repo, &api, id.number).await?,
+ },
+ Search {
+ repo: _,
+ query,
+ labels,
+ creator,
+ assignee,
+ state,
+ } => view_issues(&repo, &api, query, labels, creator, assignee, state).await?,
+ Edit { issue, command } => match command {
+ EditCommand::Title { new_title } => {
+ edit_title(&repo, &api, issue.number, new_title).await?
+ }
+ EditCommand::Body { new_body } => {
+ edit_body(&repo, &api, issue.number, new_body).await?
+ }
+ EditCommand::Comment { idx, new_body } => {
+ edit_comment(&repo, &api, issue.number, idx, new_body).await?
+ }
+ },
+ Close { issue, with_msg } => close_issue(&repo, &api, issue.number, with_msg).await?,
+ Browse { id } => browse_issue(&repo, &api, id.number).await?,
+ Comment { issue, body } => add_comment(&repo, &api, issue.number, body).await?,
+ }
+ Ok(())
+ }
+
+ fn repo(&self) -> Option<&RepoArg> {
+ use IssueSubcommand::*;
+ match &self.command {
+ Create { repo, .. } | Search { repo, .. } => repo.as_ref(),
+ View { id: issue, .. }
+ | Edit { issue, .. }
+ | Close { issue, .. }
+ | Comment { issue, .. }
+ | Browse { id: issue, .. } => issue.repo.as_ref(),
+ }
+ }
+
+ fn no_repo_error(&self) -> eyre::Error {
+ use IssueSubcommand::*;
+ match &self.command {
+ Create { .. } | Search { .. } => {
+ eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
+ }
+ View { id: issue, .. }
+ | Edit { issue, .. }
+ | Close { issue, .. }
+ | Comment { issue, .. }
+ | Browse { id: issue, .. } => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
+ issue.number
+ ),
+ }
+ }
+}
+
+async fn create_issue(
+ repo: &RepoName,
+ 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
+ .issue_create_issue(
+ repo.owner(),
+ repo.name(),
+ CreateIssueOption {
+ body: Some(body),
+ title,
+ assignee: None,
+ assignees: None,
+ closed: None,
+ due_date: None,
+ labels: None,
+ milestone: None,
+ r#ref: None,
+ },
+ )
+ .await?;
+ let number = issue
+ .number
+ .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ eprintln!("created issue #{}: {}", number, title);
+ Ok(())
+}
+
+pub async fn view_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let crate::SpecialRender {
+ dash,
+
+ bright_red,
+ bright_green,
+ yellow,
+ dark_grey,
+ white,
+ reset,
+ ..
+ } = crate::special_render();
+
+ let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
+
+ // if it's a pull request, display it as one instead
+ if issue.pull_request.is_some() {
+ crate::prs::view_pr(repo, api, Some(id)).await?;
+ return Ok(());
+ }
+
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ let user = issue
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ let state = issue
+ .state
+ .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
+ let comments = issue.comments.unwrap_or_default();
+
+ println!("{yellow}{title} {dark_grey}#{id}{reset}");
+ print!("By {white}{username}{reset} {dash} ");
+
+ use forgejo_api::structs::StateType;
+ match state {
+ StateType::Open => println!("{bright_green}Open{reset}"),
+ StateType::Closed => println!("{bright_red}Closed{reset}"),
+ };
+
+ if let Some(body) = &issue.body {
+ if !body.is_empty() {
+ println!();
+ println!("{}", crate::markdown(body));
+ }
+ }
+ println!();
+
+ if comments == 1 {
+ println!("1 comment");
+ } else {
+ println!("{comments} comments");
+ }
+ Ok(())
+}
+async fn view_issues(
+ repo: &RepoName,
+ api: &Forgejo,
+ query_str: Option<String>,
+ labels: Option<String>,
+ creator: Option<String>,
+ assignee: Option<String>,
+ state: Option<State>,
+) -> eyre::Result<()> {
+ let labels = labels
+ .map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
+ .unwrap_or_default();
+ let query = forgejo_api::structs::IssueListIssuesQuery {
+ q: query_str,
+ labels: Some(labels.join(",")),
+ created_by: creator,
+ assigned_by: assignee,
+ state: state.map(|s| s.into()),
+ r#type: None,
+ milestones: None,
+ since: None,
+ before: None,
+ mentioned_by: None,
+ page: None,
+ limit: None,
+ };
+ let issues = api
+ .issue_list_issues(repo.owner(), repo.name(), query)
+ .await?;
+ if issues.len() == 1 {
+ println!("1 issue");
+ } else {
+ println!("{} issues", issues.len());
+ }
+ for issue in issues {
+ let number = issue
+ .number
+ .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ let user = issue
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ println!("#{}: {} (by {})", number, title, username);
+ }
+ Ok(())
+}
+
+pub async fn view_comment(repo: &RepoName, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> {
+ let query = IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ };
+ let comments = api
+ .issue_get_comments(repo.owner(), repo.name(), id, query)
+ .await?;
+ let comment = comments
+ .get(idx)
+ .ok_or_else(|| eyre!("comment {idx} doesn't exist"))?;
+ print_comment(&comment)?;
+ Ok(())
+}
+
+pub async fn view_comments(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let query = IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ };
+ let comments = api
+ .issue_get_comments(repo.owner(), repo.name(), id, query)
+ .await?;
+ for comment in comments {
+ print_comment(&comment)?;
+ }
+ Ok(())
+}
+
+fn print_comment(comment: &Comment) -> eyre::Result<()> {
+ let body = comment
+ .body
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have body"))?;
+ let user = comment
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have user"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ println!("{} said:", username);
+ println!("{}", crate::markdown(&body));
+ let assets = comment
+ .assets
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have assets"))?;
+ if !assets.is_empty() {
+ println!("({} attachments)", assets.len());
+ }
+ Ok(())
+}
+
+pub async fn browse_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
+ let html_url = issue
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have html_url"))?;
+ open::that(html_url.as_str())?;
+ Ok(())
+}
+
+pub async fn add_comment(
+ repo: &RepoName,
+ 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.issue_create_comment(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::CreateIssueCommentOption {
+ body,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_title(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ new_title: Option<String>,
+) -> eyre::Result<()> {
+ let new_title = match new_title {
+ Some(s) => s,
+ None => {
+ let issue_info = api
+ .issue_get_issue(repo.owner(), repo.name(), issue)
+ .await?;
+ let mut title = issue_info
+ .title
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ crate::editor(&mut title, Some("md")).await?;
+ title
+ }
+ };
+ let new_title = new_title.trim();
+ if new_title.is_empty() {
+ eyre::bail!("title cannot be empty");
+ }
+ if new_title.contains('\n') {
+ eyre::bail!("title cannot contain newlines");
+ }
+ api.issue_edit_issue(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::EditIssueOption {
+ title: Some(new_title.to_owned()),
+ assignee: None,
+ assignees: None,
+ body: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ state: None,
+ unset_due_date: None,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_body(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ new_body: Option<String>,
+) -> eyre::Result<()> {
+ let new_body = match new_body {
+ Some(s) => s,
+ None => {
+ let issue_info = api
+ .issue_get_issue(repo.owner(), repo.name(), issue)
+ .await?;
+ let mut body = issue_info
+ .body
+ .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ api.issue_edit_issue(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::EditIssueOption {
+ body: Some(new_body),
+ assignee: None,
+ assignees: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ state: None,
+ title: None,
+ unset_due_date: None,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_comment(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ idx: usize,
+ new_body: Option<String>,
+) -> eyre::Result<()> {
+ let comments = api
+ .issue_get_comments(
+ repo.owner(),
+ repo.name(),
+ issue,
+ IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ },
+ )
+ .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()
+ .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ let id = comment
+ .id
+ .ok_or_else(|| eyre::eyre!("comment does not have id"))? as u64;
+ api.issue_edit_comment(
+ repo.owner(),
+ repo.name(),
+ id,
+ forgejo_api::structs::EditIssueCommentOption {
+ body: new_body,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn close_issue(
+ repo: &RepoName,
+ 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,
+ updated_at: None,
+ };
+ api.issue_create_comment(repo.owner(), repo.name(), issue, opt)
+ .await?;
+ }
+
+ let edit = EditIssueOption {
+ state: Some("closed".into()),
+ assignee: None,
+ assignees: None,
+ body: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ title: None,
+ unset_due_date: None,
+ updated_at: None,
+ };
+ let issue_data = api
+ .issue_edit_issue(repo.owner(), repo.name(), issue, edit)
+ .await?;
+
+ let issue_title = issue_data
+ .title
+ .as_deref()
+ .ok_or_eyre("issue does not have title")?;
+
+ println!("Closed issue {issue}: \"{issue_title}\"");
+
+ Ok(())
+}