use crate::{ Client, Error, Result, domain::{ CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page, RepositoryId, }, models, positive, }; use gitea_openapi::apis; impl Client { pub async fn issues( &self, repository: &RepositoryId, query: &IssueQuery, ) -> Result> { if !matches!(query.state.as_str(), "open" | "closed" | "all") { return Err(Error::InvalidInput( "issue state must be open, closed, or all".into(), )); } if !matches!(query.kind.as_str(), "issues" | "pulls" | "all") { return Err(Error::InvalidInput( "issue kind must be issues, pulls, or all".into(), )); } if query.page < 1 || query.limit < 1 { return Err(Error::InvalidInput( "issue page and limit must be positive".into(), )); } apis::issue_api::issue_list_issues( &self.configuration(), &repository.owner, &repository.repository, Some(&query.state), query.labels.as_deref(), query.keyword.as_deref(), Some(&query.kind), query.milestones.as_deref(), query.from.clone(), query.until.clone(), query.author.as_deref(), query.assignee.as_deref(), query.mentions.as_deref(), Some(query.page), Some(query.limit), ) .await .map(|items| Page::from_items(items, query.limit)) .map_err(Error::generated) } pub async fn issue(&self, repository: &RepositoryId, number: i64) -> Result { positive(number, "issue number")?; apis::issue_api::issue_get_issue( &self.configuration(), &repository.owner, &repository.repository, number, ) .await .map_err(Error::generated) } pub async fn issue_details( &self, repository: &RepositoryId, number: i64, ) -> Result { let (issue, comments, viewer) = tokio::join!( self.issue(repository, number), self.issue_comments(repository, number), self.current_user(), ); Ok(IssueDetails { issue: issue?, comments: comments?, viewer_id: viewer?.id, has_more: false, }) } pub async fn issue_editor( &self, repository: &RepositoryId, number: Option, ) -> Result { let issue = async { match number { Some(number) => self.issue(repository, number).await.map(Some), None => Ok(None), } }; let (issue, labels, milestones) = tokio::try_join!(issue, self.labels(repository), self.milestones(repository),)?; Ok(IssueEditorData { issue, labels, milestones, }) } pub async fn labels(&self, repository: &RepositoryId) -> Result> { let configuration = self.configuration(); let mut labels = Vec::new(); for page in 1.. { let batch = apis::issue_api::issue_list_labels( &configuration, &repository.owner, &repository.repository, Some(page), Some(100), ) .await .map_err(Error::generated)?; let done = batch.len() < 100; labels.extend(batch); if done { break; } } Ok(labels) } pub async fn resolve_label_ids( &self, repository: &RepositoryId, names: &[String], ) -> Result> { if names.is_empty() { return Ok(Vec::new()); } let labels = self.labels(repository).await?; names .iter() .map(|name| { labels .iter() .find(|label| label.name.as_deref() == Some(name)) .and_then(|label| label.id) .ok_or_else(|| Error::InvalidInput(format!("unknown label {name:?}"))) }) .collect() } pub async fn create_issue( &self, repository: &RepositoryId, mut input: CreateIssue, ) -> Result { if input.option.title.trim().is_empty() { return Err(Error::InvalidInput("issue title must not be empty".into())); } if input.option.milestone.is_some() && input.milestone_name.is_some() { return Err(Error::InvalidInput( "use either milestone or milestone_name, not both".into(), )); } if let Some(name) = input.milestone_name.as_deref() { input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?); } if !input.label_names.is_empty() { input.option.labels.get_or_insert_default().extend( self.resolve_label_ids(repository, &input.label_names) .await?, ); } apis::issue_api::issue_create_issue( &self.configuration(), &repository.owner, &repository.repository, Some(input.option), ) .await .map_err(Error::generated) } pub async fn create_issue_draft( &self, repository: &RepositoryId, draft: IssueDraft, ) -> Result { self.create_issue( repository, CreateIssue { option: create_issue_option(draft), label_names: Vec::new(), milestone_name: None, }, ) .await } pub async fn edit_issues( &self, repository: &RepositoryId, numbers: &[i64], mut input: EditIssue, ) -> Result> { if numbers.is_empty() { return Err(Error::InvalidInput( "at least one issue number is required".into(), )); } if input.option.milestone.is_some() && input.milestone_name.is_some() { return Err(Error::InvalidInput( "use either milestone or milestone_name, not both".into(), )); } if input.option.assignees.is_some() && !input.add_assignees.is_empty() { return Err(Error::InvalidInput( "use either assignees or add_assignees, not both".into(), )); } if input.replace_labels.is_some() && (!input.add_labels.is_empty() || !input.remove_labels.is_empty()) { return Err(Error::InvalidInput( "use either replace_labels or add_labels/remove_labels, not both".into(), )); } if let Some(name) = input.milestone_name.as_deref() { input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?); } let remove = self .resolve_label_ids(repository, &input.remove_labels) .await?; let add = self .resolve_label_ids(repository, &input.add_labels) .await?; let mut issues = Vec::with_capacity(numbers.len()); for &number in numbers { positive(number, "issue number")?; issues.push( self.apply_issue_edit(repository, number, &input, &remove, &add) .await?, ); } Ok(issues) } async fn apply_issue_edit( &self, repository: &RepositoryId, number: i64, input: &EditIssue, remove: &[i64], add: &[i64], ) -> Result { let configuration = self.configuration(); let mut edit = input.option.clone(); if !input.add_assignees.is_empty() { let mut assignees = self .issue(repository, number) .await? .assignees .as_deref() .unwrap_or_default() .iter() .filter_map(|user| user.login.clone()) .collect::>(); for assignee in &input.add_assignees { if !assignees.contains(assignee) { assignees.push(assignee.clone()); } } edit.assignees = Some(assignees); } if edit != models::EditIssueOption::default() { apis::issue_api::issue_edit_issue( &configuration, &repository.owner, &repository.repository, number, Some(edit), ) .await .map_err(Error::generated)?; } if let Some(labels) = &input.replace_labels { apis::issue_api::issue_replace_labels( &configuration, &repository.owner, &repository.repository, number, Some(label_option(labels)), ) .await .map_err(Error::generated)?; } else { for &id in remove { apis::issue_api::issue_remove_label( &configuration, &repository.owner, &repository.repository, number, id, ) .await .map_err(Error::generated)?; } if !add.is_empty() { apis::issue_api::issue_add_label( &configuration, &repository.owner, &repository.repository, number, Some(label_option(add)), ) .await .map_err(Error::generated)?; } } self.issue(repository, number).await } pub async fn edit_issue_draft( &self, repository: &RepositoryId, number: i64, draft: IssueDraft, ) -> Result { self.edit_issues(repository, &[number], edit_issue_draft(draft)) .await .map(|mut issues| issues.remove(0)) } pub async fn set_issue_state( &self, repository: &RepositoryId, numbers: &[i64], state: &str, ) -> Result> { if !matches!(state, "open" | "closed") { return Err(Error::InvalidInput( "issue state must be open or closed".into(), )); } self.edit_issues( repository, numbers, EditIssue { option: models::EditIssueOption { state: Some(state.into()), ..Default::default() }, replace_labels: None, add_labels: Vec::new(), remove_labels: Vec::new(), add_assignees: Vec::new(), milestone_name: None, }, ) .await } pub async fn delete_issue(&self, repository: &RepositoryId, number: i64) -> Result<()> { positive(number, "issue number")?; apis::issue_api::issue_delete( &self.configuration(), &repository.owner, &repository.repository, number, ) .await .map_err(Error::generated) } pub async fn issue_comments( &self, repository: &RepositoryId, number: i64, ) -> Result> { positive(number, "issue number")?; apis::issue_api::issue_get_comments( &self.configuration(), &repository.owner, &repository.repository, number, None, None, ) .await .map_err(Error::generated) } pub async fn create_issue_comment( &self, repository: &RepositoryId, number: i64, body: String, ) -> Result { positive(number, "issue number")?; if body.trim().is_empty() { return Err(Error::InvalidInput("comment must not be empty".into())); } apis::issue_api::issue_create_comment( &self.configuration(), &repository.owner, &repository.repository, number, Some(models::CreateIssueCommentOption::new(body)), ) .await .map_err(Error::generated) } pub async fn save_issue_comment( &self, repository: &RepositoryId, number: i64, comment_id: Option, body: String, ) -> Result { let Some(id) = comment_id else { return self.create_issue_comment(repository, number, body).await; }; positive(id, "comment id")?; if body.trim().is_empty() { return Err(Error::InvalidInput("comment must not be empty".into())); } let configuration = self.configuration(); let (comment, viewer) = tokio::join!( apis::issue_api::issue_get_comment( &configuration, &repository.owner, &repository.repository, id, ), self.current_user(), ); let comment = comment.map_err(Error::generated)?; let viewer = viewer?; if !comment_can_edit(&comment, viewer.id) || !comment_belongs_to_issue(&comment, number) { return Err(Error::Forbidden( "You can only edit your own comments.".into(), )); } apis::issue_api::issue_edit_comment( &configuration, &repository.owner, &repository.repository, id, Some(models::EditIssueCommentOption::new(body)), ) .await .map_err(Error::generated) } } pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option) -> bool { matches!( ( comment.id, comment.user.as_ref().and_then(|user| user.id), viewer_id, ), (Some(_), Some(author), Some(viewer)) if author == viewer ) } mod helpers; use helpers::*; #[cfg(test)] mod tests;