use serde::Serialize; use crate::{ Client, Error, Method, Result, domain::{DEFAULT_PAGE_SIZE, IssueQuery, MilestoneDetails, MilestoneDraft, Page, RepositoryId}, models, }; use gitea_openapi::apis; impl Client { pub async fn milestones_page( &self, repository: &RepositoryId, page: i32, limit: i32, ) -> Result> { if page < 1 || limit < 1 { return Err(Error::InvalidInput( "milestone page and limit must be positive".into(), )); } apis::issue_api::issue_get_milestones_list( &self.configuration(), &repository.owner, &repository.repository, Some("all"), None, Some(page), Some(limit), ) .await .map(|items| Page::from_items(items, limit)) .map_err(Error::generated) } pub async fn milestones(&self, repository: &RepositoryId) -> Result> { let mut milestones = Vec::new(); for page in 1.. { let batch = self.milestones_page(repository, page, 100).await?; milestones.extend(batch.items); if !batch.has_more { break; } } Ok(milestones) } pub async fn resolve_milestone_id(&self, repository: &RepositoryId, name: &str) -> Result { self.milestones(repository) .await? .into_iter() .find(|milestone| milestone.title.as_deref() == Some(name)) .and_then(|milestone| milestone.id) .ok_or_else(|| Error::InvalidInput(format!("unknown milestone {name:?}"))) } pub async fn milestone(&self, repository: &RepositoryId, id: i64) -> Result { if id < 1 { return Err(Error::InvalidInput( "milestone id must be a positive integer".into(), )); } apis::issue_api::issue_get_milestone( &self.configuration(), &repository.owner, &repository.repository, &id.to_string(), ) .await .map_err(Error::generated) } pub async fn milestone_details( &self, repository: &RepositoryId, id: i64, page: i32, ) -> Result { let milestone = self.milestone(repository, id).await?; let base = IssueQuery { state: "all".into(), milestones: milestone.title.clone(), page, limit: DEFAULT_PAGE_SIZE, ..Default::default() }; let pulls_query = IssueQuery { kind: "pulls".into(), ..base.clone() }; let issues = self.issues(repository, &base); let pulls = self.issues(repository, &pulls_query); let (issues, pulls) = tokio::try_join!(issues, pulls)?; let has_more = issues.has_more || pulls.has_more; let mut pulls = pulls.items; for pull in &mut pulls { pull.repository.get_or_insert_with(|| { Box::new(models::RepositoryMeta { name: Some(repository.repository.clone()), owner: Some(repository.owner.clone()), ..Default::default() }) }); } Ok(MilestoneDetails { milestone, has_more, issues: issues.items, pulls, }) } pub async fn create_milestone( &self, repository: &RepositoryId, option: models::CreateMilestoneOption, ) -> Result { if option .title .as_deref() .unwrap_or_default() .trim() .is_empty() { return Err(Error::InvalidInput( "milestone title must not be empty".into(), )); } apis::issue_api::issue_create_milestone( &self.configuration(), &repository.owner, &repository.repository, Some(option), ) .await .map_err(Error::generated) } pub async fn edit_milestone( &self, repository: &RepositoryId, id: &str, option: models::EditMilestoneOption, ) -> Result { apis::issue_api::issue_edit_milestone( &self.configuration(), &repository.owner, &repository.repository, id, Some(option), ) .await .map_err(Error::generated) } pub async fn save_milestone( &self, repository: &RepositoryId, id: Option, draft: MilestoneDraft, ) -> Result { if draft.title.trim().is_empty() { return Err(Error::InvalidInput( "milestone title must not be empty".into(), )); } if !matches!(draft.state.as_str(), "open" | "closed") { return Err(Error::InvalidInput( "milestone state must be open or closed".into(), )); } let endpoint = match id { Some(id) if id > 0 => format!( "repos/{}/{}/milestones/{id}", apis::urlencode(&repository.owner), apis::urlencode(&repository.repository) ), Some(_) => { return Err(Error::InvalidInput( "milestone id must be a positive integer".into(), )); } None => format!( "repos/{}/{}/milestones", apis::urlencode(&repository.owner), apis::urlencode(&repository.repository) ), }; let request = self .request( if id.is_some() { Method::PATCH } else { Method::POST }, &endpoint, )? .json(&MilestoneRequest { title: draft.title, description: draft.description, due_on: draft.due_on, state: draft.state, }); self.execute(request) .await? .json() .await .map_err(Into::into) } pub async fn set_milestone_closed( &self, repository: &RepositoryId, id: i64, closed: bool, ) -> Result { if id < 1 { return Err(Error::InvalidInput( "milestone id must be a positive integer".into(), )); } self.edit_milestone( repository, &id.to_string(), models::EditMilestoneOption { state: Some(if closed { "closed" } else { "open" }.into()), ..Default::default() }, ) .await } pub async fn delete_milestone(&self, repository: &RepositoryId, id: &str) -> Result<()> { let id = id .parse() .map_err(|_| Error::InvalidInput("milestone id must be a positive integer".into()))?; let milestone = self.milestone(repository, id).await?; let title = milestone .title .ok_or_else(|| Error::Generated("The milestone has no title.".into()))?; if !self .issues(repository, &assigned_items_query(title)) .await? .items .is_empty() { return Err(Error::InvalidInput( "Remove all assigned issues and pull requests before deleting this milestone." .into(), )); } apis::issue_api::issue_delete_milestone( &self.configuration(), &repository.owner, &repository.repository, &id.to_string(), ) .await .map_err(Error::generated) } } fn assigned_items_query(title: String) -> IssueQuery { IssueQuery { state: "all".into(), kind: "all".into(), milestones: Some(title), limit: 1, ..Default::default() } } #[derive(Serialize)] struct MilestoneRequest { title: String, description: String, due_on: Option, state: String, } #[cfg(test)] mod tests { use super::*; #[test] fn milestone_request_preserves_due_date_clear() { let value = serde_json::to_value(MilestoneRequest { title: "Release".into(), description: String::new(), due_on: None, state: "closed".into(), }) .unwrap(); assert!(value.get("due_on").unwrap().is_null()); assert_eq!(value["state"], "closed"); } #[test] fn assigned_item_check_includes_issues_and_pull_requests() { let query = assigned_items_query("Release".into()); assert_eq!(query.state, "all"); assert_eq!(query.kind, "all"); assert_eq!(query.milestones.as_deref(), Some("Release")); assert_eq!(query.limit, 1); } }