Files
Gotcha/crates/gitea/src/issues.rs
2026-08-03 17:54:07 +02:00

468 lines
14 KiB
Rust

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<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
)
}
mod helpers;
use helpers::*;
#[cfg(test)]
mod tests;