From 047f0ab0dc51dc52ad421952d38c7bfee3a5d7a1 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 1 Aug 2026 14:58:49 +0200 Subject: [PATCH] Allow milestones to be closed and reopened --- TESTING.md | 15 ++++++++------ crates/app/src/api.rs | 1 + crates/app/src/domain.rs | 1 + crates/app/src/lib.rs | 11 +++++----- crates/app/src/presentation.rs | 11 +++++++++- crates/gitea/src/domain.rs | 1 + crates/gitea/src/milestones.rs | 9 +++++++++ ios/Generated/gotcha_core.swift | 16 +++++++++------ ios/Generated/gotcha_coreFFI.h | 2 +- .../MilestoneEditorViewController.swift | 20 ++++++++++++++++--- 10 files changed, 64 insertions(+), 23 deletions(-) diff --git a/TESTING.md b/TESTING.md index bae01ae..e8d964b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -303,13 +303,16 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Open a milestone and verify every assigned issue and pull request is listed; tapping either opens its normal detail. - [ ] Tap the add button. **New Milestone** has native Cancel and Save controls, - title and multiline description fields, and an optional inline date - picker. Save is disabled for an empty or whitespace-only title. Create - milestones both without and with a due date and verify each appears. + title and multiline description fields, a **Closed** switch that defaults + off, and an optional inline date picker. Save is disabled for an empty or + whitespace-only title. Create milestones both without and with a due date + and verify each appears open. - [ ] Tap the pencil button on a milestone. Change its title and description, add and - change its due date, then turn Due Date off and save to clear it. Refresh - and relaunch after each save to verify persistence; Cancel leaves every - value unchanged. Exercise the form with the keyboard, Dynamic Type, and + change its due date, then turn Due Date off and save to clear it. Turn + **Closed** on and save; verify the list and detail show the closed state. + Edit it again, turn **Closed** off, and verify it reopens. Refresh and + relaunch after each save to verify persistence; Cancel leaves every value + unchanged. Exercise the form with the keyboard, Dynamic Type, and VoiceOver labels. - [ ] Repository issue lists and issue details show the assigned milestone with a flag icon instead of a text label. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 3307cda..a0cbe94 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -141,6 +141,7 @@ pub async fn save_milestone( title: draft.title, description: draft.description, due_on: draft.due_date.map(api_date), + state: if draft.closed { "closed" } else { "open" }.into(), }, ) .await diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 996bcf2..003d7f8 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -138,6 +138,7 @@ pub struct MilestoneDraft { pub title: String, pub description: String, pub due_date: Option, + pub closed: bool, } pub fn open_status() -> String { diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 9c96fad..138f178 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -527,12 +527,10 @@ impl GotchaCore { owner: String, repository: String, id: Option, - title: String, - description: String, - due_date: Option, + draft: MilestoneEditorPage, ) -> Result { let (owner, repository) = validate_repository(&owner, &repository)?; - let title = title.trim(); + let title = draft.title.trim(); if title.is_empty() { return Err("Enter a milestone title.".into()); } @@ -546,8 +544,9 @@ impl GotchaCore { id, MilestoneDraft { title: title.into(), - description, - due_date, + description: draft.description, + due_date: draft.due_date, + closed: draft.closed, }, ) .await?; diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 11afa02..d702054 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -148,6 +148,7 @@ pub struct MilestoneEditorPage { pub title: String, pub description: String, pub due_date: Option, + pub closed: bool, } #[derive(Clone, uniffi::Record)] @@ -424,6 +425,10 @@ pub fn milestone_editor_page(milestone: Option) -> MilestoneE .as_ref() .and_then(|milestone| milestone.due_on.as_deref()) .and_then(parse_api_date), + closed: milestone + .as_ref() + .and_then(|milestone| milestone.state.as_deref()) + == Some("closed"), } } @@ -1628,13 +1633,17 @@ mod tests { title: Some("Version 1".into()), description: Some("Ship it".into()), due_on: Some("2024-02-29T12:34:56Z".into()), + state: Some("closed".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!(page.closed); - assert_eq!(milestone_editor_page(None).due_date, None); + let new = milestone_editor_page(None); + assert_eq!(new.due_date, None); + assert!(!new.closed); } #[test] diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index 1b86c9d..dc07d3a 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -130,6 +130,7 @@ pub struct MilestoneDraft { pub title: String, pub description: String, pub due_on: Option, + pub state: String, } #[derive(Clone, Debug)] diff --git a/crates/gitea/src/milestones.rs b/crates/gitea/src/milestones.rs index 4926b83..672e859 100644 --- a/crates/gitea/src/milestones.rs +++ b/crates/gitea/src/milestones.rs @@ -164,6 +164,11 @@ impl Client { "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}", @@ -194,6 +199,7 @@ impl Client { title: draft.title, description: draft.description, due_on: draft.due_on, + state: draft.state, }); self.execute(request) .await? @@ -219,6 +225,7 @@ struct MilestoneRequest { title: String, description: String, due_on: Option, + state: String, } #[cfg(test)] @@ -231,8 +238,10 @@ mod tests { 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"); } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index e7691de..271f307 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -657,7 +657,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws - func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?) async throws -> Int64 + func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage) async throws -> Int64 func selectServer(index: UInt32) throws @@ -1133,12 +1133,12 @@ open func saveIssueComment(owner: String, repository: String, number: Int64, com ) } -open func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?)async throws -> Int64 { +open func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage)async throws -> Int64 { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_save_milestone( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterString.lower(title),FfiConverterString.lower(description),FfiConverterOptionInt64.lower(dueDate) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterTypeMilestoneEditorPage_lower(draft) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_i64, @@ -2571,13 +2571,15 @@ public struct MilestoneEditorPage: Equatable, Hashable { public var title: String public var description: String public var dueDate: Int64? + public var closed: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, description: String, dueDate: Int64?) { + public init(title: String, description: String, dueDate: Int64?, closed: Bool) { self.title = title self.description = description self.dueDate = dueDate + self.closed = closed } @@ -2598,7 +2600,8 @@ public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer { try MilestoneEditorPage( title: FfiConverterString.read(from: &buf), description: FfiConverterString.read(from: &buf), - dueDate: FfiConverterOptionInt64.read(from: &buf) + dueDate: FfiConverterOptionInt64.read(from: &buf), + closed: FfiConverterBool.read(from: &buf) ) } @@ -2606,6 +2609,7 @@ public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer { FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.description, into: &buf) FfiConverterOptionInt64.write(value.dueDate, into: &buf) + FfiConverterBool.write(value.closed, into: &buf) } } @@ -4354,7 +4358,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 33075) { + if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 9828) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 72bfa28..b115fbb 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -396,7 +396,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer title, RustBuffer description, RustBuffer due_date +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER diff --git a/ios/Sources/MilestoneEditorViewController.swift b/ios/Sources/MilestoneEditorViewController.swift index efda398..692c650 100644 --- a/ios/Sources/MilestoneEditorViewController.swift +++ b/ios/Sources/MilestoneEditorViewController.swift @@ -11,6 +11,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate private let stack = UIStackView() private let titleField = UITextField() private let descriptionView = UITextView() + private let closedSwitch = UISwitch() private let dueSwitch = UISwitch() private let duePicker = UIDatePicker() private let spinner = UIActivityIndicatorView(style: .medium) @@ -88,6 +89,14 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate descriptionView.autocapitalizationType = .sentences descriptionView.accessibilityLabel = "Milestone description" + let closedLabel = UILabel() + closedLabel.text = "Closed" + closedLabel.font = .preferredFont(forTextStyle: .body) + closedLabel.adjustsFontForContentSizeCategory = true + let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch]) + closedRow.alignment = .center + closedSwitch.accessibilityLabel = "Closed milestone" + let dueLabel = UILabel() dueLabel.text = "Due Date" dueLabel.font = .preferredFont(forTextStyle: .body) @@ -103,6 +112,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate stack.addArrangedSubview(titleField) stack.addArrangedSubview(descriptionLabel) stack.addArrangedSubview(descriptionView) + stack.addArrangedSubview(closedRow) stack.addArrangedSubview(dueRow) stack.addArrangedSubview(duePicker) NSLayoutConstraint.activate([ @@ -129,6 +139,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate guard !Task.isCancelled else { return } titleField.text = page.title descriptionView.text = page.description + closedSwitch.isOn = page.closed if let timestamp = page.dueDate { dueSwitch.isOn = true duePicker.date = localDate(forUTCTimestamp: timestamp) @@ -167,9 +178,12 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate owner: owner, repository: repository, id: id, - title: titleField.text ?? "", - description: descriptionView.text ?? "", - dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil + draft: MilestoneEditorPage( + title: titleField.text ?? "", + description: descriptionView.text ?? "", + dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil, + closed: closedSwitch.isOn + ) ) guard !Task.isCancelled else { return } saved(id)