Commit 380e448dea

380e448dea1b6a5e2389c2568e42fd410ed05243

parent: bfdfd9eda7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 19:06 UTC

Filter value types for the issue and merge request lists

Layout: unified · split

gitbay/Issues/IssueFilter.swift added +53
@@ -0,0 +1,53 @@
1import Foundation
2
3/// What `issue list` is narrowed to. Renders itself to the flags the
4/// command accepts; an empty value sends no flag at all, since
5/// `--search ""` is a different query from no search.
6nonisolated struct IssueFilter: Equatable, Sendable {
7
8 enum State: String, CaseIterable, Identifiable, Sendable {
9 case open, closed, all
10 var id: String { rawValue }
11 var label: String { rawValue.capitalized }
12 }
13
14 var state: State = .open
15 var search = ""
16 var label: String?
17 var assignee: String?
18 var author: String?
19 /// A milestone title, or the literal "none" for issues without one.
20 var milestone: String?
21
22 func flags() -> [String] {
23 var flags = ["--state", state.rawValue]
24 flags += filterFlag("--search", search)
25 flags += filterFlag("--label", label)
26 flags += filterFlag("--assignee", assignee)
27 flags += filterFlag("--author", author)
28 flags += filterFlag("--milestone", milestone)
29 return flags
30 }
31
32 /// True when anything beyond the state picker is set, which is what
33 /// decides whether the empty state blames the filters.
34 var isNarrowed: Bool { flags().count > 2 }
35
36 var emptyMessage: String {
37 if isNarrowed {
38 return state == .all
39 ? "No issues match these filters."
40 : "No \(state.rawValue) issues match these filters."
41 }
42 return state == .all ? "No issues." : "No \(state.rawValue) issues."
43 }
44}
45
46/// One flag, or nothing when the value is absent or blank. Shared by
47/// both filters, so it is named for what it does rather than taking the
48/// bare name `flag` in the module namespace.
49nonisolated func filterFlag(_ name: String, _ value: String?) -> [String] {
50 guard let value else { return [] }
51 let trimmed = value.trimmingCharacters(in: .whitespaces)
52 return trimmed.isEmpty ? [] : [name, trimmed]
53}
gitbay/MRs/MRFilter.swift added +42
@@ -0,0 +1,42 @@
1import Foundation
2
3/// What `mr list` is narrowed to. The command takes no `--label` and no
4/// `--assignee`, so this offers neither.
5nonisolated struct MRFilter: Equatable, Sendable {
6
7 enum State: String, CaseIterable, Identifiable, Sendable {
8 case open, merged, closed
9 case sourceGone = "source_gone"
10 case all
11
12 var id: String { rawValue }
13 var label: String {
14 self == .sourceGone ? "Source gone" : rawValue.capitalized
15 }
16 }
17
18 var state: State = .open
19 var search = ""
20 var author: String?
21 var milestone: String?
22
23 func flags() -> [String] {
24 var flags = ["--state", state.rawValue]
25 flags += filterFlag("--search", search)
26 flags += filterFlag("--author", author)
27 flags += filterFlag("--milestone", milestone)
28 return flags
29 }
30
31 var isNarrowed: Bool { flags().count > 2 }
32
33 var emptyMessage: String {
34 let noun = "merge requests"
35 if isNarrowed {
36 return state == .all
37 ? "No \(noun) match these filters."
38 : "No \(state.label.lowercased()) \(noun) match these filters."
39 }
40 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)."
41 }
42}
gitbayTests/ListFilterTests.swift added +131
@@ -0,0 +1,131 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5struct IssueFilterTests {
6
7 @Test func theDefaultIsOpenAndNothingElse() {
8 #expect(IssueFilter().flags() == ["--state", "open"])
9 #expect(IssueFilter().isNarrowed == false)
10 }
11
12 @Test func stateIsAlwaysFirst() {
13 var f = IssueFilter()
14 f.state = .closed
15 f.author = "cmc"
16 #expect(f.flags().prefix(2) == ["--state", "closed"])
17 }
18
19 @Test func eachFieldRendersItsOwnFlag() {
20 var f = IssueFilter()
21 f.label = "bug"
22 f.assignee = "cmc"
23 f.author = "rae"
24 f.milestone = "v1.0.0"
25 f.search = "crash on launch"
26 #expect(f.flags() == [
27 "--state", "open",
28 "--search", "crash on launch",
29 "--label", "bug",
30 "--assignee", "cmc",
31 "--author", "rae",
32 "--milestone", "v1.0.0",
33 ])
34 #expect(f.isNarrowed)
35 }
36
37 /// An empty value must send no flag. `--search ""` is a different
38 /// query from no search at all.
39 @Test func emptyAndWhitespaceValuesSendNoFlag() {
40 var f = IssueFilter()
41 f.search = " "
42 f.label = ""
43 f.assignee = nil
44 f.author = " "
45 f.milestone = ""
46 #expect(f.flags() == ["--state", "open"])
47 #expect(f.isNarrowed == false)
48 }
49
50 /// "none" is a real milestone filter meaning "has no milestone".
51 @Test func milestoneNoneIsAValueNotAnAbsence() {
52 var f = IssueFilter()
53 f.milestone = "none"
54 #expect(f.flags() == ["--state", "open", "--milestone", "none"])
55 #expect(f.isNarrowed)
56 }
57
58 /// The server quotes FTS5 terms itself, so an operator is a word to
59 /// match. The app must not escape or reject it.
60 @Test func searchTextIsPassedThroughUnquoted() {
61 var f = IssueFilter()
62 f.search = "c++ AND \"quoted"
63 #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"])
64 }
65
66 @Test func surroundingWhitespaceIsTrimmedFromValues() {
67 var f = IssueFilter()
68 f.search = " crash "
69 f.label = " bug "
70 #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"])
71 }
72
73 @Test func theEmptyMessageNamesTheNarrowing() {
74 #expect(IssueFilter().emptyMessage == "No open issues.")
75 var all = IssueFilter()
76 all.state = .all
77 #expect(all.emptyMessage == "No issues.")
78 var narrowed = IssueFilter()
79 narrowed.label = "bug"
80 #expect(narrowed.emptyMessage == "No open issues match these filters.")
81 }
82}
83
84struct MRFilterTests {
85
86 @Test func theDefaultIsOpenAndNothingElse() {
87 #expect(MRFilter().flags() == ["--state", "open"])
88 #expect(MRFilter().isNarrowed == false)
89 }
90
91 @Test func eachFieldRendersItsOwnFlag() {
92 var f = MRFilter()
93 f.author = "cmc"
94 f.milestone = "v1.0.0"
95 f.search = "retarget"
96 #expect(f.flags() == [
97 "--state", "open",
98 "--search", "retarget",
99 "--author", "cmc",
100 "--milestone", "v1.0.0",
101 ])
102 }
103
104 /// `mr list` accepts source_gone; the spelling is the wire spelling,
105 /// not the Swift case name.
106 @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() {
107 var f = MRFilter()
108 f.state = .sourceGone
109 #expect(f.flags() == ["--state", "source_gone"])
110 #expect(MRFilter.State.sourceGone.label == "Source gone")
111 }
112
113 @Test func everyStateTheCommandAcceptsIsOffered() {
114 #expect(MRFilter.State.allCases.map(\.rawValue)
115 == ["open", "merged", "closed", "source_gone", "all"])
116 }
117
118 @Test func emptyValuesSendNoFlag() {
119 var f = MRFilter()
120 f.search = " "
121 f.author = ""
122 #expect(f.flags() == ["--state", "open"])
123 }
124
125 @Test func theEmptyMessageNamesTheNarrowing() {
126 #expect(MRFilter().emptyMessage == "No open merge requests.")
127 var narrowed = MRFilter()
128 narrowed.search = "x"
129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
130 }
131}