Commit 6a01f20b03
Unsigned
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -156,6 +156,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 156 | OrgView(client: client, org: name) | 156 | OrgView(client: client, org: name) |
| 157 | case .team(let org, let team): | 157 | case .team(let org, let team): |
| 158 | TeamView(client: client, org: org, team: team) | 158 | TeamView(client: client, org: org, team: team) |
| 159 | case .labels(let org): | ||
| 160 | LabelListView(client: client, owner: .org(org)) | ||
| 161 | case .milestones(let org): | ||
| 162 | MilestoneListView(client: client, owner: .org(org)) | ||
| 159 | } | 163 | } |
| 160 | } | 164 | } |
| 161 | 165 | ||
gitbay/Issues/LabelListViewModel.swift +34 −9
| @@ -1,9 +1,30 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `label list <repo>`, plus the two commands that change the set: | 4 | /// Whose label or milestone set a screen manages. A repository's is |
| 5 | /// `label set` creates one or recolours it, `label remove` takes it off | 5 | /// `label <verb> <repo>`; an org's is `org label <verb> <org>`, and its |
| 6 | /// the repository and off every issue that carried it. | 6 | /// rows are seen by every repository under the org (v1.19.0). |
| 7 | nonisolated enum LabelOwner: Hashable, Sendable { | ||
| 8 | case repo(String) | ||
| 9 | case org(String) | ||
| 10 | |||
| 11 | /// The command prefix for `noun` (`label` or `milestone`) and `verb`. | ||
| 12 | func argv(_ noun: String, _ verb: String) -> [String] { | ||
| 13 | switch self { | ||
| 14 | case .repo(let path): [noun, verb, path] | ||
| 15 | case .org(let name): ["org", noun, verb, name] | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | var isOrg: Bool { | ||
| 20 | if case .org = self { return true } | ||
| 21 | return false | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | /// `label list`, plus the two commands that change the set: `label set` | ||
| 26 | /// creates one or recolours it, `label remove` takes it off the owner and | ||
| 27 | /// off every issue that carried it. | ||
| 7 | @Observable | 28 | @Observable |
| 8 | @MainActor | 29 | @MainActor |
| 9 | final class LabelListViewModel { | 30 | final class LabelListViewModel { |
| @@ -13,18 +34,22 @@ final class LabelListViewModel { | |||
| 13 | private(set) var working = false | 34 | private(set) var working = false |
| 14 | 35 | ||
| 15 | private let client: GitbayClient | 36 | private let client: GitbayClient |
| 16 | let repoPath: String | 37 | let owner: LabelOwner |
| 17 | 38 | ||
| 18 | init(client: GitbayClient, repoPath: String) { | 39 | init(client: GitbayClient, owner: LabelOwner) { |
| 19 | self.client = client | 40 | self.client = client |
| 20 | self.repoPath = repoPath | 41 | self.owner = owner |
| 42 | } | ||
| 43 | |||
| 44 | convenience init(client: GitbayClient, repoPath: String) { | ||
| 45 | self.init(client: client, owner: .repo(repoPath)) | ||
| 21 | } | 46 | } |
| 22 | 47 | ||
| 23 | func load() async { | 48 | func load() async { |
| 24 | state = .loading | 49 | state = .loading |
| 25 | do { | 50 | do { |
| 26 | let labels = try await client.readList( | 51 | let labels = try await client.readList( |
| 27 | ["label", "list", repoPath], of: IssueLabel.self | 52 | owner.argv("label", "list"), of: IssueLabel.self |
| 28 | ) | 53 | ) |
| 29 | state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels) | 54 | state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels) |
| 30 | } catch { | 55 | } catch { |
| @@ -36,13 +61,13 @@ final class LabelListViewModel { | |||
| 36 | /// entirely, which the server reads as "make sure it exists" and | 61 | /// entirely, which the server reads as "make sure it exists" and |
| 37 | /// leaves any colour it already had; an empty string clears it. | 62 | /// leaves any colour it already had; an empty string clears it. |
| 38 | func setLabel(_ name: String, color: String?) async { | 63 | func setLabel(_ name: String, color: String?) async { |
| 39 | var argv = ["label", "set", repoPath, name] | 64 | var argv = owner.argv("label", "set") + [name] |
| 40 | if let color { argv.append(contentsOf: ["--color", color]) } | 65 | if let color { argv.append(contentsOf: ["--color", color]) } |
| 41 | await perform(argv) | 66 | await perform(argv) |
| 42 | } | 67 | } |
| 43 | 68 | ||
| 44 | func remove(_ name: String) async { | 69 | func remove(_ name: String) async { |
| 45 | await perform(["label", "remove", repoPath, name]) | 70 | await perform(owner.argv("label", "remove") + [name]) |
| 46 | } | 71 | } |
| 47 | 72 | ||
| 48 | private func perform(_ argv: [String]) async { | 73 | private func perform(_ argv: [String]) async { |
gitbay/Issues/MilestoneListViewModel.swift +14 −10
| @@ -1,8 +1,8 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `milestone list <repo> --state <s>` — milestones with their progress, | 4 | /// `milestone list --state <s>` — milestones with their progress, plus |
| 5 | /// plus `milestone create`, `close` and `reopen`. | 5 | /// `milestone create`, `close` and `reopen`, for a repository or an org. |
| 6 | @Observable | 6 | @Observable |
| 7 | @MainActor | 7 | @MainActor |
| 8 | final class MilestoneListViewModel { | 8 | final class MilestoneListViewModel { |
| @@ -23,18 +23,22 @@ final class MilestoneListViewModel { | |||
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | private let client: GitbayClient | 25 | private let client: GitbayClient |
| 26 | let repoPath: String | 26 | let owner: LabelOwner |
| 27 | 27 | ||
| 28 | init(client: GitbayClient, repoPath: String) { | 28 | init(client: GitbayClient, owner: LabelOwner) { |
| 29 | self.client = client | 29 | self.client = client |
| 30 | self.repoPath = repoPath | 30 | self.owner = owner |
| 31 | } | ||
| 32 | |||
| 33 | convenience init(client: GitbayClient, repoPath: String) { | ||
| 34 | self.init(client: client, owner: .repo(repoPath)) | ||
| 31 | } | 35 | } |
| 32 | 36 | ||
| 33 | func load() async { | 37 | func load() async { |
| 34 | state = .loading | 38 | state = .loading |
| 35 | do { | 39 | do { |
| 36 | let milestones = try await client.readList( | 40 | let milestones = try await client.readList( |
| 37 | ["milestone", "list", repoPath, "--state", filter.rawValue], of: Milestone.self | 41 | owner.argv("milestone", "list") + ["--state", filter.rawValue], of: Milestone.self |
| 38 | ) | 42 | ) |
| 39 | state = milestones.isEmpty | 43 | state = milestones.isEmpty |
| 40 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.") | 44 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.") |
| @@ -44,12 +48,12 @@ final class MilestoneListViewModel { | |||
| 44 | } | 48 | } |
| 45 | } | 49 | } |
| 46 | 50 | ||
| 47 | /// `milestone create <repo> <title> [--description <d>] [--due YYYY-MM-DD]`. | 51 | /// `milestone create <title> [--description <d>] [--due YYYY-MM-DD]`. |
| 48 | /// Empty description and due are omitted rather than sent blank. | 52 | /// Empty description and due are omitted rather than sent blank. |
| 49 | func create(title: String, description: String, due: String) async -> Bool { | 53 | func create(title: String, description: String, due: String) async -> Bool { |
| 50 | let title = title.trimmingCharacters(in: .whitespacesAndNewlines) | 54 | let title = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| 51 | guard !title.isEmpty else { return false } | 55 | guard !title.isEmpty else { return false } |
| 52 | var argv = ["milestone", "create", repoPath, title] | 56 | var argv = owner.argv("milestone", "create") + [title] |
| 53 | let description = description.trimmingCharacters(in: .whitespacesAndNewlines) | 57 | let description = description.trimmingCharacters(in: .whitespacesAndNewlines) |
| 54 | if !description.isEmpty { argv += ["--description", description] } | 58 | if !description.isEmpty { argv += ["--description", description] } |
| 55 | let due = due.trimmingCharacters(in: .whitespacesAndNewlines) | 59 | let due = due.trimmingCharacters(in: .whitespacesAndNewlines) |
| @@ -59,11 +63,11 @@ final class MilestoneListViewModel { | |||
| 59 | } | 63 | } |
| 60 | 64 | ||
| 61 | func close(_ title: String) async { | 65 | func close(_ title: String) async { |
| 62 | await perform(["milestone", "close", repoPath, title]) | 66 | await perform(owner.argv("milestone", "close") + [title]) |
| 63 | } | 67 | } |
| 64 | 68 | ||
| 65 | func reopen(_ title: String) async { | 69 | func reopen(_ title: String) async { |
| 66 | await perform(["milestone", "reopen", repoPath, title]) | 70 | await perform(owner.argv("milestone", "reopen") + [title]) |
| 67 | } | 71 | } |
| 68 | 72 | ||
| 69 | private func perform(_ argv: [String]) async { | 73 | private func perform(_ argv: [String]) async { |
gitbay/Views/Issues/LabelListView.swift +16 −6
| @@ -1,7 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | /// A repository's labels: their colour, how many issues carry each, and | 3 | /// A repository's or an org's labels: their colour, how many issues |
| 4 | /// the controls to create, recolour and remove them. | 4 | /// carry each, and the controls to create, recolour and remove them. |
| 5 | struct LabelListView: View { | 5 | struct LabelListView: View { |
| 6 | 6 | ||
| 7 | @State private var model: LabelListViewModel | 7 | @State private var model: LabelListViewModel |
| @@ -9,7 +9,11 @@ struct LabelListView: View { | |||
| 9 | @State private var pendingRemoval: IssueLabel? | 9 | @State private var pendingRemoval: IssueLabel? |
| 10 | 10 | ||
| 11 | init(client: GitbayClient, repo: String) { | 11 | init(client: GitbayClient, repo: String) { |
| 12 | _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo)) | 12 | self.init(client: client, owner: .repo(repo)) |
| 13 | } | ||
| 14 | |||
| 15 | init(client: GitbayClient, owner: LabelOwner) { | ||
| 16 | _model = State(initialValue: LabelListViewModel(client: client, owner: owner)) | ||
| 13 | } | 17 | } |
| 14 | 18 | ||
| 15 | private var colors: LabelColors { LabelColors(model.state.value ?? []) } | 19 | private var colors: LabelColors { LabelColors(model.state.value ?? []) } |
| @@ -39,7 +43,10 @@ struct LabelListView: View { | |||
| 39 | } | 43 | } |
| 40 | 44 | ||
| 41 | ForEach(model.state.value ?? []) { label in | 45 | ForEach(model.state.value ?? []) { label in |
| 42 | LabelRow(label: label, color: colors.color(label.name), working: model.working) { | 46 | // A repository sees its org's labels but cannot change |
| 47 | // them; the org's own screen can. | ||
| 48 | LabelRow(label: label, color: colors.color(label.name), | ||
| 49 | manageable: !label.org || model.owner.isOrg, working: model.working) { | ||
| 43 | hex in | 50 | hex in |
| 44 | Task { await model.setLabel(label.name, color: hex) } | 51 | Task { await model.setLabel(label.name, color: hex) } |
| 45 | } remove: { | 52 | } remove: { |
| @@ -68,7 +75,9 @@ struct LabelListView: View { | |||
| 68 | Button("Cancel", role: .cancel) { pendingRemoval = nil } | 75 | Button("Cancel", role: .cancel) { pendingRemoval = nil } |
| 69 | } message: { | 76 | } message: { |
| 70 | // The command takes the label off every issue, not just the set. | 77 | // The command takes the label off every issue, not just the set. |
| 71 | Text("This takes the label off every issue that carries it.") | 78 | Text(model.owner.isOrg |
| 79 | ? "This takes the label off every issue in every repository under the org." | ||
| 80 | : "This takes the label off every issue that carries it.") | ||
| 72 | } | 81 | } |
| 73 | } | 82 | } |
| 74 | } | 83 | } |
| @@ -76,6 +85,7 @@ struct LabelListView: View { | |||
| 76 | private struct LabelRow: View { | 85 | private struct LabelRow: View { |
| 77 | let label: IssueLabel | 86 | let label: IssueLabel |
| 78 | let color: Color | 87 | let color: Color |
| 88 | let manageable: Bool | ||
| 79 | let working: Bool | 89 | let working: Bool |
| 80 | let recolour: (String) -> Void | 90 | let recolour: (String) -> Void |
| 81 | let remove: () -> Void | 91 | let remove: () -> Void |
| @@ -89,7 +99,7 @@ private struct LabelRow: View { | |||
| 89 | Spacer() | 99 | Spacer() |
| 90 | // An org label is managed with `org label`; the repository's | 100 | // An org label is managed with `org label`; the repository's |
| 91 | // own commands refuse it, so it gets the mark and no menu. | 101 | // own commands refuse it, so it gets the mark and no menu. |
| 92 | if label.org { | 102 | if !manageable { |
| 93 | GBChip("org", .secondary) | 103 | GBChip("org", .secondary) |
| 94 | } else { | 104 | } else { |
| 95 | Menu { | 105 | Menu { |
gitbay/Views/Issues/MilestoneListView.swift +8 −4
| @@ -1,13 +1,17 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | /// Milestones and their progress. | 3 | /// A repository's or an org's milestones and their progress. |
| 4 | struct MilestoneListView: View { | 4 | struct MilestoneListView: View { |
| 5 | 5 | ||
| 6 | @State private var model: MilestoneListViewModel | 6 | @State private var model: MilestoneListViewModel |
| 7 | @State private var creating = false | 7 | @State private var creating = false |
| 8 | 8 | ||
| 9 | init(client: GitbayClient, repo: String) { | 9 | init(client: GitbayClient, repo: String) { |
| 10 | _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) | 10 | self.init(client: client, owner: .repo(repo)) |
| 11 | } | ||
| 12 | |||
| 13 | init(client: GitbayClient, owner: LabelOwner) { | ||
| 14 | _model = State(initialValue: MilestoneListViewModel(client: client, owner: owner)) | ||
| 11 | } | 15 | } |
| 12 | 16 | ||
| 13 | var body: some View { | 17 | var body: some View { |
| @@ -32,8 +36,8 @@ struct MilestoneListView: View { | |||
| 32 | .swipeActions { | 36 | .swipeActions { |
| 33 | // An org milestone is closed and reopened with | 37 | // An org milestone is closed and reopened with |
| 34 | // `org milestone`; the repository's commands | 38 | // `org milestone`; the repository's commands |
| 35 | // refuse it. | 39 | // refuse it. The org's own screen can. |
| 36 | if milestone.org { | 40 | if milestone.org && !model.owner.isOrg { |
| 37 | EmptyView() | 41 | EmptyView() |
| 38 | } else if milestone.state == "open" { | 42 | } else if milestone.state == "open" { |
| 39 | Button("Close") { | 43 | Button("Close") { |
gitbay/Views/Orgs/OrgView.swift +22
| @@ -27,6 +27,7 @@ struct OrgView: View { | |||
| 27 | } | 27 | } |
| 28 | membersSection(loaded.members) | 28 | membersSection(loaded.members) |
| 29 | teamsSection(loaded.teams) | 29 | teamsSection(loaded.teams) |
| 30 | issuesSection | ||
| 30 | } | 31 | } |
| 31 | } | 32 | } |
| 32 | .overlay { LoadStateOverlay(state: model.state) } | 33 | .overlay { LoadStateOverlay(state: model.state) } |
| @@ -136,6 +137,25 @@ struct OrgView: View { | |||
| 136 | } | 137 | } |
| 137 | } | 138 | } |
| 138 | 139 | ||
| 140 | /// Labels and milestones every repository under the org sees | ||
| 141 | /// (v1.19.0), managed here with `org label` and `org milestone`. | ||
| 142 | private var issuesSection: some View { | ||
| 143 | Section { | ||
| 144 | NavigationLink(value: OrgRoute.labels(model.orgName)) { | ||
| 145 | Label("Labels", systemImage: "tag") | ||
| 146 | .font(.gbSans(.subheadline)) | ||
| 147 | } | ||
| 148 | NavigationLink(value: OrgRoute.milestones(model.orgName)) { | ||
| 149 | Label("Milestones", systemImage: "flag") | ||
| 150 | .font(.gbSans(.subheadline)) | ||
| 151 | } | ||
| 152 | } header: { | ||
| 153 | Text("Issues") | ||
| 154 | } footer: { | ||
| 155 | Text("Every repository in the organization sees these; a same-named repository label or milestone folds into the org's.") | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 139 | private func teamsSection(_ teams: [String]) -> some View { | 159 | private func teamsSection(_ teams: [String]) -> some View { |
| 140 | Section { | 160 | Section { |
| 141 | ForEach(teams, id: \.self) { team in | 161 | ForEach(teams, id: \.self) { team in |
| @@ -175,4 +195,6 @@ struct OrgView: View { | |||
| 175 | nonisolated enum OrgRoute: Hashable { | 195 | nonisolated enum OrgRoute: Hashable { |
| 176 | case org(String) | 196 | case org(String) |
| 177 | case team(org: String, team: String) | 197 | case team(org: String, team: String) |
| 198 | case labels(String) | ||
| 199 | case milestones(String) | ||
| 178 | } | 200 | } |
gitbayTests/OrgLabelMilestoneTests.swift added +119
| @@ -0,0 +1,119 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 17 | return try #require(body["argv"] as? [String]) | ||
| 18 | } | ||
| 19 | |||
| 20 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 21 | |||
| 22 | private let orgLabelsJSON = """ | ||
| 23 | {"protocol_version":1,"data":[\ | ||
| 24 | {"name":"bug","color":"#cf222e","org":true,"issues":18},\ | ||
| 25 | {"name":"ci","org":true,"issues":2}],"exit_code":0} | ||
| 26 | """ | ||
| 27 | |||
| 28 | private let orgMilestonesJSON = """ | ||
| 29 | {"protocol_version":1,"data":[\ | ||
| 30 | {"title":"v2.0.0","state":"open","org":true,"open":3,"closed":9}],"exit_code":0} | ||
| 31 | """ | ||
| 32 | |||
| 33 | struct LabelOwnerTests { | ||
| 34 | |||
| 35 | /// A repository's set is `label <verb> <repo>`; an org's is | ||
| 36 | /// `org label <verb> <org>`. Milestones follow the same shape. | ||
| 37 | @Test func eachOwnerSpellsItsOwnCommand() { | ||
| 38 | #expect(LabelOwner.repo("krz/gitbay").argv("label", "set") == ["label", "set", "krz/gitbay"]) | ||
| 39 | #expect(LabelOwner.org("krz").argv("label", "set") == ["org", "label", "set", "krz"]) | ||
| 40 | #expect(LabelOwner.org("krz").argv("milestone", "list") == ["org", "milestone", "list", "krz"]) | ||
| 41 | #expect(LabelOwner.repo("krz/gitbay").isOrg == false) | ||
| 42 | #expect(LabelOwner.org("krz").isOrg) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | @MainActor | ||
| 47 | struct OrgLabelListTests { | ||
| 48 | |||
| 49 | @Test func anOrgListsItsLabelsThroughOrgLabel() async throws { | ||
| 50 | let (client, stub) = try makeClient() | ||
| 51 | stub.enqueue(.init(status: 200, json: orgLabelsJSON)) | ||
| 52 | let model = LabelListViewModel(client: client, owner: .org("krz")) | ||
| 53 | |||
| 54 | await model.load() | ||
| 55 | |||
| 56 | #expect(stub.seen.first?.url.query() == "argv=org&argv=label&argv=list&argv=krz") | ||
| 57 | #expect(model.state.value?.map(\.name) == ["bug", "ci"]) | ||
| 58 | #expect(model.state.value?.allSatisfy(\.org) == true) | ||
| 59 | } | ||
| 60 | |||
| 61 | @Test func setAndRemoveUseTheOrgCommands() async throws { | ||
| 62 | let (client, stub) = try makeClient() | ||
| 63 | stub.enqueue(.init(status: 200, json: orgLabelsJSON)) | ||
| 64 | let model = LabelListViewModel(client: client, owner: .org("krz")) | ||
| 65 | await model.load() | ||
| 66 | for _ in 0..<2 { | ||
| 67 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 68 | stub.enqueue(.init(status: 200, json: orgLabelsJSON, match: "argv=list")) | ||
| 69 | } | ||
| 70 | |||
| 71 | await model.setLabel("bug", color: "cf222e") | ||
| 72 | await model.remove("ci") | ||
| 73 | |||
| 74 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | ||
| 75 | #expect(writes == [ | ||
| 76 | ["org", "label", "set", "krz", "bug", "--color", "cf222e"], | ||
| 77 | ["org", "label", "remove", "krz", "ci"], | ||
| 78 | ]) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | @MainActor | ||
| 83 | struct OrgMilestoneListTests { | ||
| 84 | |||
| 85 | @Test func anOrgListsItsMilestonesWithTheState() async throws { | ||
| 86 | let (client, stub) = try makeClient() | ||
| 87 | stub.enqueue(.init(status: 200, json: orgMilestonesJSON)) | ||
| 88 | let model = MilestoneListViewModel(client: client, owner: .org("krz")) | ||
| 89 | |||
| 90 | await model.load() | ||
| 91 | |||
| 92 | #expect(stub.seen.first?.url.query() | ||
| 93 | == "argv=org&argv=milestone&argv=list&argv=krz&argv=--state&argv=open") | ||
| 94 | #expect(model.state.value?.map(\.title) == ["v2.0.0"]) | ||
| 95 | } | ||
| 96 | |||
| 97 | @Test func createCloseAndReopenUseTheOrgCommands() async throws { | ||
| 98 | let (client, stub) = try makeClient() | ||
| 99 | stub.enqueue(.init(status: 200, json: orgMilestonesJSON)) | ||
| 100 | let model = MilestoneListViewModel(client: client, owner: .org("krz")) | ||
| 101 | await model.load() | ||
| 102 | for _ in 0..<3 { | ||
| 103 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 104 | stub.enqueue(.init(status: 200, json: orgMilestonesJSON, match: "argv=list")) | ||
| 105 | } | ||
| 106 | |||
| 107 | let ok = await model.create(title: "v2.1.0", description: "next", due: "2026-10-01") | ||
| 108 | await model.close("v2.0.0") | ||
| 109 | await model.reopen("v2.0.0") | ||
| 110 | |||
| 111 | #expect(ok) | ||
| 112 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | ||
| 113 | #expect(writes == [ | ||
| 114 | ["org", "milestone", "create", "krz", "v2.1.0", "--description", "next", "--due", "2026-10-01"], | ||
| 115 | ["org", "milestone", "close", "krz", "v2.0.0"], | ||
| 116 | ["org", "milestone", "reopen", "krz", "v2.0.0"], | ||
| 117 | ]) | ||
| 118 | } | ||
| 119 | } | ||