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, #[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, #[clap(long, short, id = "[HOST/]OWNER/REPO")] repo: Option, }, /// 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, }, /// 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>, }, /// Search for an issue in a repo Search { #[clap(long, short, id = "[HOST/]OWNER/REPO")] repo: Option, query: Option, #[clap(long, short)] labels: Option, #[clap(long, short)] creator: Option, #[clap(long, short)] assignee: Option, #[clap(long, short)] state: Option, }, /// View an issue's info View { #[clap(id = "[REPO#]ID")] id: IssueId, #[clap(subcommand)] command: Option, }, /// Open an issue in your browser Browse { #[clap(id = "[REPO#]ID")] id: IssueId, }, } #[derive(Clone, Debug)] pub struct IssueId { pub repo: Option, pub number: u64, } impl FromStr for IssueId { type Err = IssueIdError; fn from_str(s: &str) -> Result { let (repo, number) = match s.rsplit_once("#") { Some((repo, number)) => (Some(repo.parse::()?), 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 for IssueIdError { fn from(value: crate::repo::RepoArgError) -> Self { Self::Repo(value) } } impl From 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 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 }, /// Edit an issue's text content Body { new_body: Option }, /// Edit a comment on an issue Comment { idx: usize, new_body: Option, }, } #[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, ) -> 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, labels: Option, creator: Option, assignee: Option, state: Option, ) -> eyre::Result<()> { let labels = labels .map(|s| s.split(',').map(|s| s.to_string()).collect::>()) .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, ) -> 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, ) -> 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, ) -> 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, ) -> 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>, ) -> 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(()) }