307 lines
9.0 KiB
Rust
307 lines
9.0 KiB
Rust
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<Page<models::Milestone>> {
|
|
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<Vec<models::Milestone>> {
|
|
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<i64> {
|
|
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<models::Milestone> {
|
|
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<MilestoneDetails> {
|
|
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<models::Milestone> {
|
|
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<models::Milestone> {
|
|
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<i64>,
|
|
draft: MilestoneDraft,
|
|
) -> Result<models::Milestone> {
|
|
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<models::Milestone> {
|
|
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<String>,
|
|
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);
|
|
}
|
|
}
|