a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 43f4f01b01

43f4f01b015a8d29146f6b5de453dea0e3c91343

parent: 944674b560

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T05:15:48Z

issues and builds: triage and logs

issue list with a state filter; issue show with markdown body and
comments; comment (stdin), close, reopen, and label/assignee editing
via the --add/--remove flags. Build list with status per row and the
raw build log rendered monospaced with both-axis scrolling.

Denied triage actions surface the server's message verbatim without
losing the loaded screen.

Ref #11
gitbay/Builds/BuildListViewModel.swift added +29
@@ -0,0 +1,29 @@
1import Foundation
2import Observation
3
4/// `build list <repo>`.
5@Observable
6@MainActor
7final class BuildListViewModel {
8
9 private(set) var state: LoadState<[Build]> = .loading
10
11 private let client: GitbayClient
12 let repoPath: String
13
14 init(client: GitbayClient, repoPath: String) {
15 self.client = client
16 self.repoPath = repoPath
17 }
18
19 func load() async {
20 do {
21 let builds = try await client.readList(["build", "list", repoPath], of: Build.self)
22 state = builds.isEmpty
23 ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
24 : .loaded(builds.sorted { $0.number > $1.number })
25 } catch {
26 state = .from(error)
27 }
28 }
29}
gitbay/Builds/BuildModels.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2
3/// One row of `build list`.
4nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let job: String
7 let status: String
8 let sha: String
9 let ref: String
10 let createdAt: Date
11 let finishedAt: Date?
12
13 enum CodingKeys: String, CodingKey {
14 case number, job, status, sha, ref
15 case createdAt = "created_at"
16 case finishedAt = "finished_at"
17 }
18
19 var id: Int64 { number }
20 var shortSHA: String { String(sha.prefix(10)) }
21}
gitbay/ContentView.swift +26
@@ -15,6 +15,12 @@ struct ContentView: View {
1515 .navigationDestination(for: MRRoute.self) { route in
1616 destination(route, client: client)
1717 }
18 .navigationDestination(for: IssueRoute.self) { route in
19 destination(route, client: client)
20 }
21 .navigationDestination(for: BuildRoute.self) { route in
22 destination(route, client: client)
23 }
1824 } else {
1925 SignInView()
2026 }
@@ -48,6 +54,26 @@ struct ContentView: View {
4854 DiffView(client: client, repo: repo, number: number)
4955 }
5056 }
57
58 @ViewBuilder
59 private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
60 switch route {
61 case .list(let repo):
62 IssueListView(client: client, repo: repo)
63 case .issue(let repo, let number):
64 IssueView(client: client, repo: repo, number: number)
65 }
66 }
67
68 @ViewBuilder
69 private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
70 switch route {
71 case .list(let repo):
72 BuildListView(client: client, repo: repo)
73 case .log(let repo, let number):
74 BuildLogView(client: client, repo: repo, number: number)
75 }
76 }
5177 }
5278
5379 #Preview {
gitbay/Issues/IssueDetailViewModel.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Observation
3
4/// One issue and everything triage needs: comment, close, reopen, labels,
5/// assignees. Writes reload; refusals surface verbatim.
6@Observable
7@MainActor
8final class IssueDetailViewModel {
9
10 private(set) var state: LoadState<IssueDetail> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
13
14 private let client: GitbayClient
15 let repoPath: String
16 let number: Int64
17
18 init(client: GitbayClient, repoPath: String, number: Int64) {
19 self.client = client
20 self.repoPath = repoPath
21 self.number = number
22 }
23
24 private var ref: [String] { [repoPath, String(number)] }
25
26 func load() async {
27 do {
28 state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
29 } catch {
30 state = .from(error)
31 }
32 }
33
34 func comment(_ text: String) async {
35 await perform(["issue", "comment"] + ref, stdin: text)
36 }
37
38 func close() async {
39 await perform(["issue", "close"] + ref)
40 }
41
42 func reopen() async {
43 await perform(["issue", "reopen"] + ref)
44 }
45
46 func addLabel(_ label: String) async {
47 await perform(["issue", "label"] + ref + ["--add", label])
48 }
49
50 func removeLabel(_ label: String) async {
51 await perform(["issue", "label"] + ref + ["--remove", label])
52 }
53
54 func addAssignee(_ user: String) async {
55 await perform(["issue", "assign"] + ref + ["--add", user])
56 }
57
58 func removeAssignee(_ user: String) async {
59 await perform(["issue", "assign"] + ref + ["--remove", user])
60 }
61
62 private func perform(_ argv: [String], stdin: String? = nil) async {
63 working = true
64 actionError = nil
65 defer { working = false }
66 do {
67 try await client.run(argv, stdin: stdin)
68 await load()
69 } catch let error as GitbayError {
70 actionError = error.userFacingMessage
71 } catch {
72 actionError = GitbayError.transport(error).userFacingMessage
73 }
74 }
75}
gitbay/Issues/IssueListViewModel.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2import Observation
3
4/// `issue list <repo> --state <s>`.
5@Observable
6@MainActor
7final class IssueListViewModel {
8
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, closed, all
11 var id: String { rawValue }
12 }
13
14 private(set) var state: LoadState<[Issue]> = .loading
15 var filter: StateFilter = .open {
16 didSet { if filter != oldValue { Task { await load() } } }
17 }
18
19 private let client: GitbayClient
20 let repoPath: String
21
22 init(client: GitbayClient, repoPath: String) {
23 self.client = client
24 self.repoPath = repoPath
25 }
26
27 func load() async {
28 state = .loading
29 do {
30 let issues = try await client.readList(
31 ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self
32 )
33 state = issues.isEmpty
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.")
35 : .loaded(issues.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
39 }
40}
gitbay/Issues/IssueModels.swift added +56
@@ -0,0 +1,56 @@
1import Foundation
2
3/// One issue; `issue list` rows and the header of `issue show`.
4nonisolated struct Issue: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let title: String
7 let state: String
8 let author: String
9 let milestone: String?
10 let labels: [String]?
11 let assignees: [String]?
12 let body: String?
13 let createdAt: Date
14
15 enum CodingKeys: String, CodingKey {
16 case number, title, state, author, milestone, labels, assignees, body
17 case createdAt = "created_at"
18 }
19
20 var id: Int64 { number }
21 var isOpen: Bool { state == "open" }
22}
23
24/// `issue show <owner/name> <n>` the issue plus its comments.
25nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
26 let number: Int64
27 let title: String
28 let state: String
29 let author: String
30 let milestone: String?
31 let labels: [String]?
32 let assignees: [String]?
33 let body: String?
34 let createdAt: Date
35 let comments: [Comment]?
36
37 enum CodingKeys: String, CodingKey {
38 case number, title, state, author, milestone, labels, assignees, body, comments
39 case createdAt = "created_at"
40 }
41
42 var isOpen: Bool { state == "open" }
43
44 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
45 let author: String
46 let body: String
47 let createdAt: Date
48
49 enum CodingKeys: String, CodingKey {
50 case author, body
51 case createdAt = "created_at"
52 }
53
54 var id: String { author + createdAt.timeIntervalSince1970.description + body }
55 }
56}
gitbay/Views/Builds/BuildListView.swift added +81
@@ -0,0 +1,81 @@
1import SwiftUI
2
3struct BuildListView: View {
4
5 @State private var model: BuildListViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 ForEach(model.state.value ?? []) { build in
14 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) {
15 BuildRow(build: build)
16 }
17 }
18 }
19 .overlay { LoadStateOverlay(state: model.state) }
20 .navigationTitle("Builds")
21 .navigationBarTitleDisplayMode(.inline)
22 .task { await model.load() }
23 .refreshable { await model.load() }
24 }
25}
26
27private struct BuildRow: View {
28 let build: Build
29
30 var body: some View {
31 HStack(spacing: 10) {
32 Image(systemName: icon)
33 .foregroundStyle(color)
34 VStack(alignment: .leading, spacing: 2) {
35 Text("#\(build.number) \(build.job)")
36 .font(.subheadline.weight(.medium))
37 HStack(spacing: 6) {
38 Text(build.ref)
39 Text(build.shortSHA)
40 .font(.caption.monospaced())
41 }
42 .font(.caption)
43 .foregroundStyle(.secondary)
44 }
45 Spacer()
46 VStack(alignment: .trailing, spacing: 2) {
47 Text(build.status)
48 .font(.caption.weight(.medium))
49 .foregroundStyle(color)
50 Text(build.createdAt, format: .relative(presentation: .named))
51 .font(.caption)
52 .foregroundStyle(.tertiary)
53 }
54 }
55 .padding(.vertical, 2)
56 }
57
58 private var icon: String {
59 switch build.status {
60 case "success": "checkmark.circle.fill"
61 case "failure", "error": "xmark.circle.fill"
62 case "running": "circle.dotted"
63 case "queued", "pending": "clock"
64 default: "questionmark.circle"
65 }
66 }
67
68 private var color: Color {
69 switch build.status {
70 case "success": .green
71 case "failure", "error": .red
72 case "running", "queued", "pending": .orange
73 default: .secondary
74 }
75 }
76}
77
78nonisolated enum BuildRoute: Hashable {
79 case list(repo: String)
80 case log(repo: String, number: Int64)
81}
gitbay/Views/Builds/BuildLogView.swift added +55
@@ -0,0 +1,55 @@
1import SwiftUI
2
3/// `build log` plain text, can be large. Monospaced, both-axis scroll.
4struct BuildLogView: View {
5
6 private let client: GitbayClient
7 private let repo: String
8 private let number: Int64
9 @State private var state: LoadState<String> = .loading
10
11 init(client: GitbayClient, repo: String, number: Int64) {
12 self.client = client
13 self.repo = repo
14 self.number = number
15 }
16
17 var body: some View {
18 ZStack {
19 Color.clear
20 if let log = state.value {
21 if log.isEmpty {
22 ContentUnavailableView {
23 Label("No log yet", systemImage: "doc.text")
24 }
25 } else {
26 ScrollView([.horizontal, .vertical]) {
27 Text(log)
28 .font(.caption2.monospaced())
29 .frame(maxWidth: .infinity, alignment: .leading)
30 .padding(12)
31 .textSelection(.enabled)
32 }
33 }
34 }
35 }
36 .overlay { LoadStateOverlay(state: state) }
37 .navigationTitle("Build #\(number)")
38 .navigationBarTitleDisplayMode(.inline)
39 .task {
40 do {
41 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
42 } catch {
43 state = .from(error)
44 }
45 }
46 .refreshable {
47 state = .loading
48 do {
49 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
50 } catch {
51 state = .from(error)
52 }
53 }
54 }
55}
gitbay/Views/Issues/IssueListView.swift added +79
@@ -0,0 +1,79 @@
1import SwiftUI
2
3struct IssueListView: View {
4
5 @State private var model: IssueListViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 Picker("State", selection: Bindable(model).filter) {
14 ForEach(IssueListViewModel.StateFilter.allCases) { filter in
15 Text(filter.rawValue.capitalized).tag(filter)
16 }
17 }
18 .pickerStyle(.segmented)
19 .listRowBackground(Color.clear)
20 .listRowInsets(EdgeInsets())
21
22 ForEach(model.state.value ?? []) { issue in
23 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
24 IssueRow(issue: issue)
25 }
26 }
27 }
28 .overlay { LoadStateOverlay(state: model.state) }
29 .navigationTitle("Issues")
30 .navigationBarTitleDisplayMode(.inline)
31 .task { await model.load() }
32 .refreshable { await model.load() }
33 }
34}
35
36private struct IssueRow: View {
37 let issue: Issue
38
39 var body: some View {
40 VStack(alignment: .leading, spacing: 4) {
41 HStack(alignment: .firstTextBaseline, spacing: 6) {
42 Text("#\(issue.number)")
43 .font(.caption.monospaced())
44 .foregroundStyle(.secondary)
45 Text(issue.title)
46 .font(.subheadline.weight(.medium))
47 .lineLimit(2)
48 }
49 HStack(spacing: 6) {
50 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
51 .font(.caption2)
52 .foregroundStyle(issue.isOpen ? .green : .purple)
53 ForEach(issue.labels ?? [], id: \.self) { label in
54 Text(label)
55 .font(.caption2)
56 .padding(.horizontal, 5)
57 .padding(.vertical, 1)
58 .background(.quaternary, in: Capsule())
59 }
60 Spacer()
61 if let assignees = issue.assignees, !assignees.isEmpty {
62 Text(assignees.joined(separator: ", "))
63 .font(.caption)
64 .foregroundStyle(.secondary)
65 .lineLimit(1)
66 }
67 Text(issue.createdAt, format: .relative(presentation: .named))
68 .font(.caption)
69 .foregroundStyle(.tertiary)
70 }
71 }
72 .padding(.vertical, 2)
73 }
74}
75
76nonisolated enum IssueRoute: Hashable {
77 case list(repo: String)
78 case issue(repo: String, number: Int64)
79}
gitbay/Views/Issues/IssueView.swift added +182
@@ -0,0 +1,182 @@
1import SwiftUI
2
3struct IssueView: View {
4
5 @State private var model: IssueDetailViewModel
6 @State private var commentText = ""
7 @State private var editingLabel = ""
8 @State private var editingAssignee = ""
9
10 init(client: GitbayClient, repo: String, number: Int64) {
11 _model = State(initialValue: IssueDetailViewModel(
12 client: client, repoPath: repo, number: number
13 ))
14 }
15
16 var body: some View {
17 List {
18 if let issue = model.state.value {
19 header(issue)
20
21 if let error = model.actionError {
22 Section {
23 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
26 }
27 }
28
29 if let body = issue.body, !body.isEmpty {
30 Section {
31 MarkdownView(markdown: body)
32 .padding(.vertical, 4)
33 }
34 }
35
36 triageSection(issue)
37 commentsSection(issue.comments ?? [])
38 }
39 }
40 .overlay { LoadStateOverlay(state: model.state) }
41 .navigationTitle("#\(model.number)")
42 .navigationBarTitleDisplayMode(.inline)
43 .toolbar { toolbar }
44 .task { await model.load() }
45 .refreshable { await model.load() }
46 }
47
48 @ViewBuilder
49 private func header(_ issue: IssueDetail) -> some View {
50 Section {
51 VStack(alignment: .leading, spacing: 6) {
52 Text(issue.title)
53 .font(.headline)
54 HStack(spacing: 6) {
55 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
56 .font(.caption.weight(.medium))
57 .foregroundStyle(issue.isOpen ? .green : .purple)
58 Text("by \(issue.author)")
59 Text(issue.createdAt, format: .relative(presentation: .named))
60 .foregroundStyle(.tertiary)
61 if let milestone = issue.milestone {
62 Label(milestone, systemImage: "flag")
63 }
64 }
65 .font(.caption)
66 .foregroundStyle(.secondary)
67 }
68 .padding(.vertical, 2)
69 }
70 }
71
72 @ViewBuilder
73 private func triageSection(_ issue: IssueDetail) -> some View {
74 Section("Labels") {
75 labelFlow(issue.labels ?? [], remove: { label in
76 Task { await model.removeLabel(label) }
77 })
78 HStack {
79 TextField("Add label", text: $editingLabel)
80 .autocorrectionDisabled()
81 .textInputAutocapitalization(.never)
82 Button {
83 let label = editingLabel.trimmingCharacters(in: .whitespaces)
84 editingLabel = ""
85 Task { await model.addLabel(label) }
86 } label: {
87 Image(systemName: "plus.circle.fill")
88 }
89 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
90 }
91 }
92 Section("Assignees") {
93 labelFlow(issue.assignees ?? [], remove: { user in
94 Task { await model.removeAssignee(user) }
95 })
96 HStack {
97 TextField("Assign user", text: $editingAssignee)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 Button {
101 let user = editingAssignee.trimmingCharacters(in: .whitespaces)
102 editingAssignee = ""
103 Task { await model.addAssignee(user) }
104 } label: {
105 Image(systemName: "plus.circle.fill")
106 }
107 .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
108 }
109 }
110 }
111
112 @ViewBuilder
113 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
114 if !items.isEmpty {
115 ScrollView(.horizontal, showsIndicators: false) {
116 HStack(spacing: 6) {
117 ForEach(items, id: \.self) { item in
118 HStack(spacing: 3) {
119 Text(item)
120 Button {
121 remove(item)
122 } label: {
123 Image(systemName: "xmark.circle.fill")
124 .foregroundStyle(.tertiary)
125 }
126 .disabled(model.working)
127 }
128 .font(.caption)
129 .padding(.horizontal, 8)
130 .padding(.vertical, 3)
131 .background(.quaternary, in: Capsule())
132 }
133 }
134 }
135 }
136 }
137
138 private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
139 Section("Comments") {
140 ForEach(comments) { comment in
141 VStack(alignment: .leading, spacing: 4) {
142 HStack {
143 Text(comment.author)
144 .font(.caption.weight(.semibold))
145 Text(comment.createdAt, format: .relative(presentation: .named))
146 .font(.caption)
147 .foregroundStyle(.tertiary)
148 }
149 MarkdownView(markdown: comment.body)
150 .font(.subheadline)
151 }
152 .padding(.vertical, 2)
153 }
154
155 HStack {
156 TextField("Comment", text: $commentText, axis: .vertical)
157 .lineLimit(1...5)
158 Button {
159 let text = commentText
160 commentText = ""
161 Task { await model.comment(text) }
162 } label: {
163 Image(systemName: "arrow.up.circle.fill")
164 }
165 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
166 || model.working)
167 }
168 }
169 }
170
171 @ToolbarContentBuilder
172 private var toolbar: some ToolbarContent {
173 ToolbarItem(placement: .topBarTrailing) {
174 if let issue = model.state.value {
175 Button(issue.isOpen ? "Close" : "Reopen") {
176 Task { issue.isOpen ? await model.close() : await model.reopen() }
177 }
178 .disabled(model.working)
179 }
180 }
181 }
182}
gitbay/Views/Repos/RepoView.swift +6
@@ -25,6 +25,12 @@ struct RepoView: View {
2525 NavigationLink(value: MRRoute.list(repo: path)) {
2626 Label("Merge Requests", systemImage: "arrow.triangle.merge")
2727 }
28 NavigationLink(value: IssueRoute.list(repo: path)) {
29 Label("Issues", systemImage: "smallcircle.filled.circle")
30 }
31 NavigationLink(value: BuildRoute.list(repo: path)) {
32 Label("Builds", systemImage: "hammer")
33 }
2834 }
2935
3036 if let readme = model.readme {
gitbayTests/IssueBuildViewModelTests.swift added +162
@@ -0,0 +1,162 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private let issueListJSON = """
16 {"protocol_version":1,"data":[\
17 {"number":11,"title":"iOS app","state":"open","author":"krz",\
18 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\
19 {"number":40,"title":"pagination","state":"open","author":"krz",\
20 "created_at":"2026-08-21T10:00:00.000Z"}\
21 ],"exit_code":0}
22 """
23
24private let issueShowJSON = """
25 {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\
26 "author":"krz","labels":["app"],"assignees":["cmc"],"body":"Build it.",\
27 "created_at":"2026-08-20T10:00:00.000Z",\
28 "comments":[{"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z"}]},\
29 "exit_code":0}
30 """
31
32private let buildListJSON = """
33 {"protocol_version":1,"data":[\
34 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
36 "finished_at":"2026-08-20T10:05:00.000Z"},\
37 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
38 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\
39 ],"exit_code":0}
40 """
41
42@MainActor
43struct IssueListViewModelTests {
44
45 @Test func listsNewestFirstWithLabelsAndAssignees() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: issueListJSON))
48 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
49
50 await model.load()
51
52 let issues = try #require(model.state.value)
53 #expect(issues.map(\.number) == [40, 11])
54 #expect(issues[1].labels == ["app"])
55 #expect(issues[1].assignees == ["cmc"])
56 #expect(stub.seen.first?.url.query() ==
57 "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open")
58 }
59}
60
61@MainActor
62struct IssueDetailViewModelTests {
63
64 private func loadedModel() async throws -> (IssueDetailViewModel, StubProtocol.Box) {
65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: issueShowJSON))
67 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
68 await model.load()
69 return (model, stub)
70 }
71
72 @Test func loadsIssueWithComments() async throws {
73 let (model, _) = try await loadedModel()
74
75 let issue = try #require(model.state.value)
76 #expect(issue.title == "iOS app")
77 #expect(issue.comments?.count == 1)
78 }
79
80 @Test func commentTravelsInStdin() async throws {
81 let (model, stub) = try await loadedModel()
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
83 stub.enqueue(.init(status: 200, json: issueShowJSON))
84
85 await model.comment("triaged from the phone")
86
87 let write = stub.seen[1]
88 #expect(write.method == "POST")
89 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
90 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11"])
91 #expect(body["stdin"] as? String == "triaged from the phone")
92 }
93
94 @Test func labelAndAssigneeEditsUseAddRemoveFlags() async throws {
95 let (model, stub) = try await loadedModel()
96 for _ in 0..<4 {
97 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
98 stub.enqueue(.init(status: 200, json: issueShowJSON))
99 }
100
101 await model.addLabel("bug")
102 await model.removeLabel("app")
103 await model.addAssignee("krz")
104 await model.removeAssignee("cmc")
105
106 let writes = stub.seen.filter { $0.method == "POST" }
107 let argvs = try writes.map {
108 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
109 }
110 #expect(argvs[0] == ["issue", "label", "krz/gitbay", "11", "--add", "bug"])
111 #expect(argvs[1] == ["issue", "label", "krz/gitbay", "11", "--remove", "app"])
112 #expect(argvs[2] == ["issue", "assign", "krz/gitbay", "11", "--add", "krz"])
113 #expect(argvs[3] == ["issue", "assign", "krz/gitbay", "11", "--remove", "cmc"])
114 }
115
116 @Test func closeAndReopenAreSeparateCommands() async throws {
117 let (model, stub) = try await loadedModel()
118 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
119 stub.enqueue(.init(status: 200, json: issueShowJSON))
120 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
121 stub.enqueue(.init(status: 200, json: issueShowJSON))
122
123 await model.close()
124 await model.reopen()
125
126 let writes = stub.seen.filter { $0.method == "POST" }
127 let argvs = try writes.map {
128 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
129 }
130 #expect(argvs[0] == ["issue", "close", "krz/gitbay", "11"])
131 #expect(argvs[1] == ["issue", "reopen", "krz/gitbay", "11"])
132 }
133
134 @Test func aDeniedTriageActionSurfacesTheRule() async throws {
135 let (model, stub) = try await loadedModel()
136 stub.enqueue(.init(status: 403, json:
137 #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#))
138
139 await model.close()
140
141 #expect(model.actionError == "krz/gitbay is archived and read-only")
142 #expect(model.state.value != nil)
143 }
144}
145
146@MainActor
147struct BuildListViewModelTests {
148
149 @Test func listsNewestFirst() async throws {
150 let (client, stub) = try makeClient()
151 stub.enqueue(.init(status: 200, json: buildListJSON))
152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
153
154 await model.load()
155
156 let builds = try #require(model.state.value)
157 #expect(builds.map(\.number) == [4, 3])
158 #expect(builds[0].status == "running")
159 #expect(builds[0].finishedAt == nil)
160 #expect(builds[1].finishedAt != nil)
161 }
162}