Centralize Gitea domain workflows
This commit is contained in:
565
crates/gitea/src/issues.rs
Normal file
565
crates/gitea/src/issues.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use crate::{
|
||||
Client, Error, Result,
|
||||
domain::{
|
||||
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
|
||||
RepositoryId,
|
||||
},
|
||||
models,
|
||||
};
|
||||
use gitea_openapi::apis;
|
||||
|
||||
impl Client {
|
||||
pub async fn issues(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
query: &IssueQuery,
|
||||
) -> Result<Page<models::Issue>> {
|
||||
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<models::Issue> {
|
||||
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<IssueDetails> {
|
||||
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<i64>,
|
||||
) -> Result<IssueEditorData> {
|
||||
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<Vec<models::Label>> {
|
||||
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<Vec<i64>> {
|
||||
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<models::Issue> {
|
||||
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<models::Issue> {
|
||||
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<Vec<models::Issue>> {
|
||||
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<models::Issue> {
|
||||
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::<Vec<_>>();
|
||||
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<models::Issue> {
|
||||
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<Vec<models::Issue>> {
|
||||
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<Vec<models::Comment>> {
|
||||
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<models::Comment> {
|
||||
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<i64>,
|
||||
body: String,
|
||||
) -> Result<models::Comment> {
|
||||
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<i64>) -> bool {
|
||||
matches!(
|
||||
(
|
||||
comment.id,
|
||||
comment.user.as_ref().and_then(|user| user.id),
|
||||
viewer_id,
|
||||
),
|
||||
(Some(_), Some(author), Some(viewer)) if author == viewer
|
||||
)
|
||||
}
|
||||
|
||||
fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
|
||||
models::CreateIssueOption {
|
||||
body: Some(draft.body),
|
||||
due_date: draft.due_date,
|
||||
labels: Some(draft.label_ids),
|
||||
milestone: draft.milestone_id,
|
||||
..models::CreateIssueOption::new(draft.title)
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_issue_draft(draft: IssueDraft) -> EditIssue {
|
||||
EditIssue {
|
||||
option: models::EditIssueOption {
|
||||
body: Some(draft.body),
|
||||
due_date: draft.due_date.clone(),
|
||||
milestone: Some(draft.milestone_id.unwrap_or_default()),
|
||||
title: Some(draft.title),
|
||||
unset_due_date: draft.due_date.is_none().then_some(true),
|
||||
..models::EditIssueOption::new()
|
||||
},
|
||||
replace_labels: Some(draft.label_ids),
|
||||
add_labels: Vec::new(),
|
||||
remove_labels: Vec::new(),
|
||||
add_assignees: Vec::new(),
|
||||
milestone_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
|
||||
models::IssueLabelsOption {
|
||||
labels: Some(
|
||||
labels
|
||||
.iter()
|
||||
.copied()
|
||||
.map(serde_json::Value::from)
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool {
|
||||
comment
|
||||
.issue_url
|
||||
.as_deref()
|
||||
.map(|url| url.trim_end_matches('/'))
|
||||
.and_then(|url| url.rsplit('/').next())
|
||||
.and_then(|index| index.parse().ok())
|
||||
== Some(number)
|
||||
}
|
||||
|
||||
fn positive(value: i64, name: &str) -> Result<()> {
|
||||
if value < 1 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"{name} must be a positive integer"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scopes_comments_and_builds_label_payloads() {
|
||||
let comment = models::Comment {
|
||||
issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(comment_belongs_to_issue(&comment, 7));
|
||||
assert!(!comment_belongs_to_issue(&comment, 8));
|
||||
assert!(!comment_can_edit(&comment, Some(1)));
|
||||
let owned = models::Comment {
|
||||
id: Some(3),
|
||||
user: Some(Box::new(models::User {
|
||||
id: Some(1),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(comment_can_edit(&owned, Some(1)));
|
||||
assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_issue_drafts_without_losing_clear_operations() {
|
||||
let draft = IssueDraft {
|
||||
title: "Title".into(),
|
||||
body: "Body".into(),
|
||||
label_ids: vec![2, 4],
|
||||
milestone_id: None,
|
||||
due_date: None,
|
||||
};
|
||||
let create = create_issue_option(draft.clone());
|
||||
assert_eq!(create.title, "Title");
|
||||
assert_eq!(create.body.as_deref(), Some("Body"));
|
||||
assert_eq!(create.labels, Some(vec![2, 4]));
|
||||
let edit = edit_issue_draft(draft);
|
||||
assert_eq!(edit.option.milestone, Some(0));
|
||||
assert_eq!(edit.option.unset_due_date, Some(true));
|
||||
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user