Allow milestones to be closed and reopened

This commit is contained in:
Georg Bauer
2026-08-01 14:58:49 +02:00
parent 3a69b70395
commit 047f0ab0dc
10 changed files with 64 additions and 23 deletions

View File

@@ -303,13 +303,16 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Open a milestone and verify every assigned issue and pull request is - [ ] Open a milestone and verify every assigned issue and pull request is
listed; tapping either opens its normal detail. listed; tapping either opens its normal detail.
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls, - [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
title and multiline description fields, and an optional inline date title and multiline description fields, a **Closed** switch that defaults
picker. Save is disabled for an empty or whitespace-only title. Create off, and an optional inline date picker. Save is disabled for an empty or
milestones both without and with a due date and verify each appears. 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 - [ ] 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 change its due date, then turn Due Date off and save to clear it. Turn
and relaunch after each save to verify persistence; Cancel leaves every **Closed** on and save; verify the list and detail show the closed state.
value unchanged. Exercise the form with the keyboard, Dynamic Type, and 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. VoiceOver labels.
- [ ] Repository issue lists and issue details show the assigned milestone with - [ ] Repository issue lists and issue details show the assigned milestone with
a flag icon instead of a text label. a flag icon instead of a text label.

View File

@@ -141,6 +141,7 @@ pub async fn save_milestone(
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
due_on: draft.due_date.map(api_date), due_on: draft.due_date.map(api_date),
state: if draft.closed { "closed" } else { "open" }.into(),
}, },
) )
.await .await

View File

@@ -138,6 +138,7 @@ pub struct MilestoneDraft {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub due_date: Option<i64>, pub due_date: Option<i64>,
pub closed: bool,
} }
pub fn open_status() -> String { pub fn open_status() -> String {

View File

@@ -527,12 +527,10 @@ impl GotchaCore {
owner: String, owner: String,
repository: String, repository: String,
id: Option<i64>, id: Option<i64>,
title: String, draft: MilestoneEditorPage,
description: String,
due_date: Option<i64>,
) -> Result<i64, GotchaError> { ) -> Result<i64, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?; let (owner, repository) = validate_repository(&owner, &repository)?;
let title = title.trim(); let title = draft.title.trim();
if title.is_empty() { if title.is_empty() {
return Err("Enter a milestone title.".into()); return Err("Enter a milestone title.".into());
} }
@@ -546,8 +544,9 @@ impl GotchaCore {
id, id,
MilestoneDraft { MilestoneDraft {
title: title.into(), title: title.into(),
description, description: draft.description,
due_date, due_date: draft.due_date,
closed: draft.closed,
}, },
) )
.await?; .await?;

View File

@@ -148,6 +148,7 @@ pub struct MilestoneEditorPage {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub due_date: Option<i64>, pub due_date: Option<i64>,
pub closed: bool,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -424,6 +425,10 @@ pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneE
.as_ref() .as_ref()
.and_then(|milestone| milestone.due_on.as_deref()) .and_then(|milestone| milestone.due_on.as_deref())
.and_then(parse_api_date), .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()), title: Some("Version 1".into()),
description: Some("Ship it".into()), description: Some("Ship it".into()),
due_on: Some("2024-02-29T12:34:56Z".into()), due_on: Some("2024-02-29T12:34:56Z".into()),
state: Some("closed".into()),
..Default::default() ..Default::default()
})); }));
assert_eq!(page.title, "Version 1"); assert_eq!(page.title, "Version 1");
assert_eq!(page.description, "Ship it"); assert_eq!(page.description, "Ship it");
assert_eq!(page.due_date, Some(1_709_164_800)); 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] #[test]

View File

@@ -130,6 +130,7 @@ pub struct MilestoneDraft {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub due_on: Option<String>, pub due_on: Option<String>,
pub state: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -164,6 +164,11 @@ impl Client {
"milestone title must not be empty".into(), "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 { let endpoint = match id {
Some(id) if id > 0 => format!( Some(id) if id > 0 => format!(
"repos/{}/{}/milestones/{id}", "repos/{}/{}/milestones/{id}",
@@ -194,6 +199,7 @@ impl Client {
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
due_on: draft.due_on, due_on: draft.due_on,
state: draft.state,
}); });
self.execute(request) self.execute(request)
.await? .await?
@@ -219,6 +225,7 @@ struct MilestoneRequest {
title: String, title: String,
description: String, description: String,
due_on: Option<String>, due_on: Option<String>,
state: String,
} }
#[cfg(test)] #[cfg(test)]
@@ -231,8 +238,10 @@ mod tests {
title: "Release".into(), title: "Release".into(),
description: String::new(), description: String::new(),
due_on: None, due_on: None,
state: "closed".into(),
}) })
.unwrap(); .unwrap();
assert!(value.get("due_on").unwrap().is_null()); assert!(value.get("due_on").unwrap().is_null());
assert_eq!(value["state"], "closed");
} }
} }

View File

@@ -657,7 +657,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws 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 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 return
try await uniffiRustCallAsync( try await uniffiRustCallAsync(
rustFutureFunc: { rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_save_milestone( 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, pollFunc: ffi_gotcha_core_rust_future_poll_i64,
@@ -2571,13 +2571,15 @@ public struct MilestoneEditorPage: Equatable, Hashable {
public var title: String public var title: String
public var description: String public var description: String
public var dueDate: Int64? public var dueDate: Int64?
public var closed: Bool
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // 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.title = title
self.description = description self.description = description
self.dueDate = dueDate self.dueDate = dueDate
self.closed = closed
} }
@@ -2598,7 +2600,8 @@ public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer {
try MilestoneEditorPage( try MilestoneEditorPage(
title: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf),
description: 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.title, into: &buf)
FfiConverterString.write(value.description, into: &buf) FfiConverterString.write(value.description, into: &buf)
FfiConverterOptionInt64.write(value.dueDate, 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) {
return InitializationResult.apiChecksumMismatch 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 return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {

View File

@@ -396,7 +396,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
#define 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -11,6 +11,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
private let stack = UIStackView() private let stack = UIStackView()
private let titleField = UITextField() private let titleField = UITextField()
private let descriptionView = UITextView() private let descriptionView = UITextView()
private let closedSwitch = UISwitch()
private let dueSwitch = UISwitch() private let dueSwitch = UISwitch()
private let duePicker = UIDatePicker() private let duePicker = UIDatePicker()
private let spinner = UIActivityIndicatorView(style: .medium) private let spinner = UIActivityIndicatorView(style: .medium)
@@ -88,6 +89,14 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
descriptionView.autocapitalizationType = .sentences descriptionView.autocapitalizationType = .sentences
descriptionView.accessibilityLabel = "Milestone description" 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() let dueLabel = UILabel()
dueLabel.text = "Due Date" dueLabel.text = "Due Date"
dueLabel.font = .preferredFont(forTextStyle: .body) dueLabel.font = .preferredFont(forTextStyle: .body)
@@ -103,6 +112,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
stack.addArrangedSubview(titleField) stack.addArrangedSubview(titleField)
stack.addArrangedSubview(descriptionLabel) stack.addArrangedSubview(descriptionLabel)
stack.addArrangedSubview(descriptionView) stack.addArrangedSubview(descriptionView)
stack.addArrangedSubview(closedRow)
stack.addArrangedSubview(dueRow) stack.addArrangedSubview(dueRow)
stack.addArrangedSubview(duePicker) stack.addArrangedSubview(duePicker)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -129,6 +139,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
titleField.text = page.title titleField.text = page.title
descriptionView.text = page.description descriptionView.text = page.description
closedSwitch.isOn = page.closed
if let timestamp = page.dueDate { if let timestamp = page.dueDate {
dueSwitch.isOn = true dueSwitch.isOn = true
duePicker.date = localDate(forUTCTimestamp: timestamp) duePicker.date = localDate(forUTCTimestamp: timestamp)
@@ -167,9 +178,12 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
owner: owner, owner: owner,
repository: repository, repository: repository,
id: id, id: id,
title: titleField.text ?? "", draft: MilestoneEditorPage(
description: descriptionView.text ?? "", title: titleField.text ?? "",
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil description: descriptionView.text ?? "",
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil,
closed: closedSwitch.isOn
)
) )
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
saved(id) saved(id)