Add milestone creation and editing

This commit is contained in:
Georg Bauer
2026-07-31 15:30:45 +02:00
parent da0a3aecb4
commit c4e64b5c0c
13 changed files with 591 additions and 21 deletions

View File

@@ -3,14 +3,15 @@ use std::{
collections::{BTreeSet, BinaryHeap, HashMap},
};
use gotcha_gitea::{Client, apis, models};
use gotcha_gitea::{Client, Method, apis, models};
use serde::Serialize;
use tokio::task::JoinSet;
use crate::{
diff,
domain::{
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails,
PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date,
MilestoneDraft, PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date,
},
presentation::compact_date,
};
@@ -229,6 +230,80 @@ pub async fn load_milestone(
})
}
pub async fn load_milestone_editor(
server: &Server,
owner: &str,
repository: &str,
id: i64,
) -> Result<models::Milestone, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
apis::issue_api::issue_get_milestone(
&client.configuration(),
owner,
repository,
&id.to_string(),
)
.await
.map_err(|error| error.to_string())
}
#[derive(Serialize)]
struct MilestoneRequest {
title: String,
description: String,
due_on: Option<String>,
}
fn milestone_request(draft: MilestoneDraft) -> MilestoneRequest {
MilestoneRequest {
title: draft.title,
description: draft.description,
due_on: draft.due_date.map(api_date),
}
}
pub async fn save_milestone(
server: &Server,
owner: &str,
repository: &str,
id: Option<i64>,
draft: MilestoneDraft,
) -> Result<models::Milestone, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let endpoint = match id {
Some(id) => format!(
"repos/{}/{}/milestones/{id}",
apis::urlencode(owner),
apis::urlencode(repository)
),
None => format!(
"repos/{}/{}/milestones",
apis::urlencode(owner),
apis::urlencode(repository)
),
};
let request = client
.request(
if id.is_some() {
Method::PATCH
} else {
Method::POST
},
&endpoint,
)
.map_err(|error| error.to_string())?
.json(&milestone_request(draft));
client
.execute(request)
.await
.map_err(|error| error.to_string())?
.json()
.await
.map_err(|error| error.to_string())
}
pub async fn load_pulls(
server: &Server,
status: &str,
@@ -1115,4 +1190,25 @@ mod tests {
assert!(!comment_belongs_to_issue(&comment, 5));
assert!(!comment_belongs_to_issue(&models::Comment::default(), 4));
}
#[test]
fn milestone_requests_can_set_and_clear_due_dates() {
let dated = serde_json::to_value(milestone_request(MilestoneDraft {
title: "Version 1".into(),
description: "Description".into(),
due_date: Some(1_709_164_800),
}))
.unwrap();
assert_eq!(dated["title"], "Version 1");
assert_eq!(dated["description"], "Description");
assert_eq!(dated["due_on"], "2024-02-29T00:00:00Z");
let cleared = serde_json::to_value(milestone_request(MilestoneDraft {
title: "Version 1".into(),
description: String::new(),
due_date: None,
}))
.unwrap();
assert!(cleared["due_on"].is_null());
}
}

View File

@@ -179,6 +179,12 @@ pub struct MilestoneDetails {
pub has_more: bool,
}
pub struct MilestoneDraft {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
}
pub struct PullDetails {
pub pull: models::PullRequest,
pub comments: Vec<models::Comment>,

View File

@@ -458,6 +458,57 @@ impl GotchaCore {
))
}
pub async fn milestone_editor(
&self,
owner: String,
repository: String,
id: Option<i64>,
) -> Result<MilestoneEditorPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if id.is_some_and(|id| id <= 0) {
return Err("Invalid milestone selection.".into());
}
let milestone = match id {
Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?),
None => None,
};
Ok(milestone_editor_page(milestone))
}
pub async fn save_milestone(
&self,
owner: String,
repository: String,
id: Option<i64>,
title: String,
description: String,
due_date: Option<i64>,
) -> Result<i64, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
let title = title.trim();
if title.is_empty() {
return Err("Enter a milestone title.".into());
}
if id.is_some_and(|id| id <= 0) {
return Err("Invalid milestone selection.".into());
}
let milestone = save_milestone(
&self.server()?,
owner,
repository,
id,
MilestoneDraft {
title: title.into(),
description,
due_date,
},
)
.await?;
milestone
.id
.ok_or_else(|| "The saved milestone has no ID.".into())
}
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
let server = self.server()?;
let filter = self

View File

@@ -139,6 +139,13 @@ pub struct MilestoneListPage {
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct MilestoneEditorPage {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
}
#[derive(Clone, uniffi::Record)]
pub struct PullFilterOptions {
pub milestones: Vec<String>,
@@ -364,6 +371,23 @@ pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
}
}
pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneEditorPage {
MilestoneEditorPage {
title: milestone
.as_ref()
.and_then(|milestone| milestone.title.clone())
.unwrap_or_default(),
description: milestone
.as_ref()
.and_then(|milestone| milestone.description.clone())
.unwrap_or_default(),
due_date: milestone
.as_ref()
.and_then(|milestone| milestone.due_on.as_deref())
.and_then(parse_api_date),
}
}
pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions {
let mut milestones = milestones.to_vec();
if !filter.milestone.is_empty() {
@@ -1425,6 +1449,21 @@ mod tests {
assert_eq!(page.pulls[0].number, 7);
}
#[test]
fn milestone_editor_preserves_description_and_optional_due_date() {
let page = milestone_editor_page(Some(models::Milestone {
title: Some("Version 1".into()),
description: Some("Ship it".into()),
due_on: Some("2024-02-29T12:34:56Z".into()),
..Default::default()
}));
assert_eq!(page.title, "Version 1");
assert_eq!(page.description, "Ship it");
assert_eq!(page.due_date, Some(1_709_164_800));
assert_eq!(milestone_editor_page(None).due_date, None);
}
#[test]
fn classifies_repository_files_in_rust() {
let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());