a native ios client for gitbay

client ios swift

https://gitbay.org

branches and tags, milestones, and an MR's milestone !16

merged cmc wants to merge krz/gitbay-ios:refs-milestones into main

13 files changed, +516 −4

gitbay/ContentView.swift +4
@@ -80,6 +80,10 @@ private struct RouteDestinations: ViewModifier {
8080 GrepView(client: client, repo: repo)
8181 case .blame(let repo, let path, let ref):
8282 BlameView(client: client, repo: repo, path: path, ref: ref)
83 case .refs(let repo):
84 RefsView(client: client, repo: repo)
85 case .milestones(let repo):
86 MilestoneListView(client: client, repo: repo)
8387 case .commit(let repo, let sha):
8488 CommitView(client: client, repo: repo, sha: sha)
8589 case .commitDiff(let repo, let sha):
gitbay/Issues/MilestoneListViewModel.swift added +43
@@ -0,0 +1,43 @@
1import Foundation
2import Observation
3
4/// `milestone list <repo> --state <s>` milestones with their progress.
5@Observable
6@MainActor
7final class MilestoneListViewModel {
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<[Milestone]> = .loading
15 var filter: StateFilter = .open {
16 didSet {
17 guard filter != oldValue else { return }
18 Task { await load() }
19 }
20 }
21
22 private let client: GitbayClient
23 let repoPath: String
24
25 init(client: GitbayClient, repoPath: String) {
26 self.client = client
27 self.repoPath = repoPath
28 }
29
30 func load() async {
31 state = .loading
32 do {
33 let milestones = try await client.readList(
34 ["milestone", "list", repoPath, "--state", filter.rawValue], of: Milestone.self
35 )
36 state = milestones.isEmpty
37 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.")
38 : .loaded(milestones)
39 } catch {
40 state = .from(error)
41 }
42 }
43}
gitbay/MRs/MRDetailViewModel.swift +15
@@ -66,6 +66,21 @@ final class MRDetailViewModel {
6666 )
6767 }
6868
69 /// Open milestones for the picker; fetched on first use.
70 private(set) var availableMilestones: [Milestone]?
71
72 func loadMilestones() async {
73 guard availableMilestones == nil else { return }
74 availableMilestones = (try? await client.readList(
75 ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self
76 )) ?? []
77 }
78
79 /// nil clears it; the command spells that "none".
80 func setMilestone(_ title: String?) async {
81 await perform(["mr", "milestone"] + ref + [title ?? "none"])
82 }
83
6984 func comment(_ text: String) async {
7085 // Long text travels in stdin, but the server only reads it when
7186 // argv says so: --file - is required, not implied.
gitbay/MRs/MRModels.swift +2 −1
@@ -35,6 +35,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
3535 let targetRef: String
3636 let headSHA: String
3737 let body: String?
38 let milestone: String?
3839 let createdAt: Date
3940 let checks: [Check]?
4041 let checksCombined: String?
@@ -44,7 +45,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
4445 let reviews: [Review]?
4546
4647 enum CodingKeys: String, CodingKey {
47 case number, title, state, author, source, body, checks, commits, comments, reviews
48 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews
4849 case targetRef = "target_ref"
4950 case headSHA = "head_sha"
5051 case createdAt = "created_at"
gitbay/Repos/RefsViewModel.swift added +45
@@ -0,0 +1,45 @@
1import Foundation
2import Observation
3
4/// `repo refs` the branches and tags the server knows, so a ref is
5/// picked rather than typed.
6@Observable
7@MainActor
8final class RefsViewModel {
9
10 private(set) var state: LoadState<RepoRefs> = .loading
11 var searchText = ""
12
13 private let client: GitbayClient
14 let repoPath: String
15 /// The repo's default branch, marked in the list.
16 private(set) var defaultBranch: String?
17
18 init(client: GitbayClient, repoPath: String) {
19 self.client = client
20 self.repoPath = repoPath
21 }
22
23 private func matching(_ refs: [RepoRef]) -> [RepoRef] {
24 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
25 guard !query.isEmpty else { return refs }
26 return refs.filter { $0.name.lowercased().contains(query) }
27 }
28
29 var branches: [RepoRef] { matching(state.value?.branches ?? []) }
30 var tags: [RepoRef] { matching(state.value?.tags ?? []) }
31
32 func load() async {
33 do {
34 async let refs = client.read(["repo", "refs", repoPath], as: RepoRefs.self)
35 async let detail = try? client.read(["repo", "show", repoPath], as: RepoDetail.self)
36 let loaded = try await refs
37 defaultBranch = await detail?.defaultBranch
38 state = (loaded.branches.isEmpty && loaded.tags.isEmpty)
39 ? .empty("No branches or tags yet.")
40 : .loaded(loaded)
41 } catch {
42 state = .from(error)
43 }
44 }
45}
gitbay/Repos/RepoModels.swift +1 −3
@@ -38,9 +38,7 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
3838 var isArchived: Bool { archived ?? false }
3939 }
4040
41/// The shape a `repo refs <owner/name>` command would return. No such
42/// command exists yet krz/gitbay#45 proposes it, and until it lands
43/// branch names are typed, not picked. Unused on purpose.
41/// `repo refs <owner/name>` the branches and tags the server knows.
4442 nonisolated struct RepoRefs: Decodable, Sendable, Hashable {
4543 let branches: [RepoRef]
4644 let tags: [RepoRef]
gitbay/Views/Issues/MilestoneListView.swift added +74
@@ -0,0 +1,74 @@
1import SwiftUI
2
3/// Milestones and their progress.
4struct MilestoneListView: View {
5
6 @State private var model: MilestoneListViewModel
7
8 init(client: GitbayClient, repo: String) {
9 _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo))
10 }
11
12 var body: some View {
13 List {
14 Picker("State", selection: Bindable(model).filter) {
15 ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in
16 Text(filter.rawValue.capitalized).tag(filter)
17 }
18 }
19 .pickerStyle(.segmented)
20 .listRowBackground(Color.clear)
21 .listRowInsets(EdgeInsets())
22
23 ForEach(model.state.value ?? []) { milestone in
24 MilestoneRow(milestone: milestone)
25 }
26 }
27 .overlay { LoadStateOverlay(state: model.state) }
28 .navigationTitle("Milestones")
29 .navigationBarTitleDisplayMode(.inline)
30 .task { await model.load() }
31 .refreshable { await model.load() }
32 }
33}
34
35private struct MilestoneRow: View {
36 let milestone: Milestone
37
38 private var total: Int { milestone.open + milestone.closed }
39 private var fraction: Double {
40 total == 0 ? 0 : Double(milestone.closed) / Double(total)
41 }
42
43 var body: some View {
44 VStack(alignment: .leading, spacing: 5) {
45 HStack(spacing: 6) {
46 Text(milestone.title)
47 .font(.gbSans(.subheadline).weight(.medium))
48 if milestone.state != "open" {
49 GBChip(milestone.state, .gbDone)
50 }
51 Spacer()
52 if let due = milestone.due, !due.isEmpty {
53 Text(due)
54 .font(.gbSans(.caption))
55 .foregroundStyle(.tertiary)
56 }
57 }
58 if let description = milestone.description, !description.isEmpty {
59 Text(description)
60 .font(.gbSans(.caption))
61 .foregroundStyle(.secondary)
62 .lineLimit(2)
63 }
64 if total > 0 {
65 ProgressView(value: fraction)
66 .tint(.gbAccent)
67 Text("\(milestone.closed) of \(total) closed")
68 .font(.gbSans(.caption2))
69 .foregroundStyle(.secondary)
70 }
71 }
72 .padding(.vertical, 2)
73 }
74}
gitbay/Views/MRs/MRView.swift +26
@@ -34,6 +34,7 @@ struct MRView: View {
3434 }
3535 }
3636
37 milestoneSection(mr)
3738 diffSection
3839
3940 if let commits = mr.commits, !commits.isEmpty {
@@ -117,6 +118,31 @@ struct MRView: View {
117118 }
118119 }
119120
121 private func milestoneSection(_ mr: MRDetail) -> some View {
122 Section("Milestone") {
123 Menu {
124 Button("None") { Task { await model.setMilestone(nil) } }
125 ForEach(model.availableMilestones ?? []) { milestone in
126 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
127 Task { await model.setMilestone(milestone.title) }
128 }
129 }
130 } label: {
131 HStack {
132 Label(mr.milestone ?? "None", systemImage: "flag")
133 .font(.gbSans(.subheadline))
134 Spacer()
135 Image(systemName: "chevron.up.chevron.down")
136 .font(.gbSans(.caption2))
137 .foregroundStyle(.secondary)
138 }
139 }
140 .disabled(model.working)
141 .task { await model.loadMilestones() }
142 .accessibilityIdentifier("mr-milestone-menu")
143 }
144 }
145
120146 private var diffSection: some View {
121147 Section {
122148 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
gitbay/Views/Repos/RefsView.swift added +69
@@ -0,0 +1,69 @@
1import SwiftUI
2
3/// Branches and tags. Tapping one browses the repository at that ref.
4struct RefsView: View {
5
6 @State private var model: RefsViewModel
7
8 init(client: GitbayClient, repo: String) {
9 _model = State(initialValue: RefsViewModel(client: client, repoPath: repo))
10 }
11
12 var body: some View {
13 List {
14 if !model.branches.isEmpty {
15 Section("Branches") {
16 ForEach(model.branches) { ref in
17 NavigationLink(value: RepoRoute.tree(
18 repo: model.repoPath, directory: "", ref: ref.name
19 )) {
20 RefRow(ref: ref, isDefault: ref.name == model.defaultBranch)
21 }
22 }
23 }
24 }
25 if !model.tags.isEmpty {
26 Section("Tags") {
27 ForEach(model.tags) { ref in
28 NavigationLink(value: RepoRoute.tree(
29 repo: model.repoPath, directory: "", ref: ref.name
30 )) {
31 RefRow(ref: ref, isDefault: false)
32 }
33 }
34 }
35 }
36 }
37 .overlay {
38 LoadStateOverlay(
39 state: model.state,
40 isEmpty: model.branches.isEmpty && model.tags.isEmpty
41 )
42 }
43 .searchable(text: Bindable(model).searchText, prompt: "Filter refs")
44 .navigationTitle("Branches & Tags")
45 .navigationBarTitleDisplayMode(.inline)
46 .task { await model.load() }
47 .refreshable { await model.load() }
48 }
49}
50
51private struct RefRow: View {
52 let ref: RepoRef
53 let isDefault: Bool
54
55 var body: some View {
56 HStack(spacing: 8) {
57 Text(ref.name)
58 .font(.gbSans(.subheadline))
59 .lineLimit(1)
60 if isDefault {
61 GBChip("default", .gbAccent)
62 }
63 Spacer()
64 Text(String(ref.sha.prefix(10)))
65 .font(.gbMono(.caption2))
66 .foregroundStyle(.tertiary)
67 }
68 }
69}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -10,6 +10,8 @@ nonisolated enum RepoRoute: Hashable {
1010 case settings(repo: String)
1111 case grep(repo: String)
1212 case blame(repo: String, path: String, ref: String?)
13 case refs(repo: String)
14 case milestones(repo: String)
1315 case commit(repo: String, sha: String)
1416 case commitDiff(repo: String, sha: String)
1517 case profile(String)
gitbay/Views/Repos/RepoView.swift +6
@@ -29,12 +29,18 @@ struct RepoView: View {
2929 NavigationLink(value: RepoRoute.log(repo: path)) {
3030 Label("History", systemImage: "clock")
3131 }
32 NavigationLink(value: RepoRoute.refs(repo: path)) {
33 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
34 }
3235 NavigationLink(value: MRRoute.list(repo: path)) {
3336 Label("Merge Requests", systemImage: "arrow.triangle.merge")
3437 }
3538 NavigationLink(value: IssueRoute.list(repo: path)) {
3639 Label("Issues", systemImage: "smallcircle.filled.circle")
3740 }
41 NavigationLink(value: RepoRoute.milestones(repo: path)) {
42 Label("Milestones", systemImage: "flag")
43 }
3844 NavigationLink(value: BuildRoute.list(repo: path)) {
3945 Label("Builds", systemImage: "hammer")
4046 }
gitbayTests/RefsMilestoneTests.swift added +163
@@ -0,0 +1,163 @@
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 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
20private let refsJSON = """
21 {"protocol_version":1,"data":{"branches":[\
22 {"name":"main","sha":"aaaaaaaaaabbbbbbbbbb"},{"name":"feature","sha":"cccccccccc"}],\
23 "tags":[{"name":"v1.0.0","sha":"dddddddddd"}]},"exit_code":0}
24 """
25private let repoShowJSON = """
26 {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\
27 "default_branch":"main"},"exit_code":0}
28 """
29private let milestonesJSON = """
30 {"protocol_version":1,"data":[\
31 {"title":"v1.0.0","description":"launch","state":"open","open":3,"closed":9},\
32 {"title":"v0.5.0","state":"closed","open":0,"closed":4}],"exit_code":0}
33 """
34
35@MainActor
36struct RefsViewModelTests {
37
38 private func loadedModel() async throws -> (RefsViewModel, StubProtocol.Box) {
39 let (client, stub) = try makeClient()
40 stub.enqueue(.init(status: 200, json: refsJSON, match: "argv=refs"))
41 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
42 let model = RefsViewModel(client: client, repoPath: "krz/gitbay")
43 await model.load()
44 return (model, stub)
45 }
46
47 @Test func loadsBranchesTagsAndTheDefault() async throws {
48 let (model, stub) = try await loadedModel()
49
50 #expect(model.branches.map(\.name) == ["main", "feature"])
51 #expect(model.tags.map(\.name) == ["v1.0.0"])
52 #expect(model.defaultBranch == "main")
53 #expect(stub.seen.contains { $0.url.query() == "argv=repo&argv=refs&argv=krz/gitbay" })
54 }
55
56 @Test func theFilterMatchesBothBranchesAndTags() async throws {
57 let (model, _) = try await loadedModel()
58
59 model.searchText = "v1"
60 #expect(model.branches.isEmpty)
61 #expect(model.tags.map(\.name) == ["v1.0.0"])
62
63 model.searchText = "feat"
64 #expect(model.branches.map(\.name) == ["feature"])
65 #expect(model.tags.isEmpty)
66 }
67
68 @Test func aRepoWithoutRefsIsAnEmptyState() async throws {
69 let (client, stub) = try makeClient()
70 stub.enqueue(.init(status: 200, json:
71 #"{"protocol_version":1,"data":{"branches":[],"tags":[]},"exit_code":0}"#,
72 match: "argv=refs"))
73 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
74 let model = RefsViewModel(client: client, repoPath: "krz/empty")
75
76 await model.load()
77
78 guard case .empty = model.state else {
79 Issue.record("expected .empty, got \(model.state)")
80 return
81 }
82 }
83}
84
85@MainActor
86struct MilestoneListViewModelTests {
87
88 @Test func loadsMilestonesWithProgress() async throws {
89 let (client, stub) = try makeClient()
90 stub.enqueue(.init(status: 200, json: milestonesJSON))
91 let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
92
93 await model.load()
94
95 let milestones = try #require(model.state.value)
96 #expect(milestones.map(\.title) == ["v1.0.0", "v0.5.0"])
97 #expect(milestones[0].open == 3 && milestones[0].closed == 9)
98 #expect(stub.seen.first?.url.query() ==
99 "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open")
100 }
101
102 @Test func changingTheFilterReloadsWithThatState() async throws {
103 let (client, stub) = try makeClient()
104 stub.enqueue(.init(status: 200, json: milestonesJSON))
105 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#))
106 let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
107 await model.load()
108
109 model.filter = .closed
110 try await Task.sleep(for: .milliseconds(300))
111
112 #expect(stub.seen.count == 2)
113 #expect(stub.seen[1].url.query()?.contains("argv=closed") == true)
114 }
115}
116
117@MainActor
118struct MRMilestoneTests {
119
120 private let mrShow = """
121 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
122 "source":"b","target_ref":"main","head_sha":"aa","milestone":"v1.0.0",\
123 "created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0}
124 """
125
126 private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
127 let (client, stub) = try makeClient()
128 stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
129 stub.enqueue(.init(status: 200, json:
130 #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
131 stub.enqueue(.init(status: 200, json:
132 #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
133 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
134 await model.load()
135 return (model, stub)
136 }
137
138 @Test func mrShowCarriesItsMilestone() async throws {
139 let (model, _) = try await loadedModel()
140 // Previously a blind write: you could set it and nothing read back.
141 #expect(model.state.value?.milestone == "v1.0.0")
142 }
143
144 @Test func settingAndClearingUseTheCommandsSpelling() async throws {
145 let (model, stub) = try await loadedModel()
146 for _ in 0..<2 {
147 stub.enqueue(.init(status: 200, json:
148 #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
149 stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
150 stub.enqueue(.init(status: 200, json:
151 #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
152 stub.enqueue(.init(status: 200, json:
153 #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
154 }
155
156 await model.setMilestone("v1.1.0")
157 await model.setMilestone(nil)
158
159 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
160 #expect(writes[0] == ["mr", "milestone", "krz/gitbay", "7", "v1.1.0"])
161 #expect(writes[1] == ["mr", "milestone", "krz/gitbay", "7", "none"])
162 }
163}
gitbayUITests/LiveSmokeUITests.swift +66
@@ -156,6 +156,18 @@ final class LiveSmokeUITests: XCTestCase {
156156 keys.tap()
157157 }
158158
159 /// Scroll a list until an element is in the hierarchy. Offscreen
160 /// rows do not exist to XCUITest, so waitForExistence alone fails on
161 /// anything below the fold.
162 @discardableResult
163 func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
164 for _ in 0..<swipes {
165 if element.exists { return true }
166 app.swipeUp()
167 }
168 return element.exists
169 }
170
159171 /// Switch tabs and wait until that tab is actually front. A tap
160172 /// dispatched before the app is interactive which happens right
161173 /// after launch when there is no sign-in to slow things down is
@@ -700,3 +712,57 @@ extension LiveSmokeUITests {
700712 "commit diff rendered nothing")
701713 }
702714 }
715
716extension LiveSmokeUITests {
717
718 /// The three gaps that needed no server work: branches and tags,
719 /// milestones, and an MR's milestone. Read-only except the MR
720 /// milestone, which is set and cleared back.
721 func testRefsAndMilestoneFlows() throws {
722 openRepo("krz/gitbay")
723
724 // --- branches and tags, and browsing at a ref ---
725 app.staticTexts["Branches & Tags"].firstMatch.tap()
726 let main = app.staticTexts["main"].firstMatch
727 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
728 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
729 "the default branch is not marked")
730 XCTAssertTrue(app.staticTexts["Tags"].firstMatch.exists, "tags section missing")
731 main.tap()
732 // Tapping a ref browses the repository there.
733 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
734 "browsing at a ref showed nothing")
735 back()
736 back()
737
738 // --- milestones with progress ---
739 app.staticTexts["Milestones"].firstMatch.tap()
740 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
741 "milestones did not load")
742 app.segmentedControls.buttons["All"].firstMatch.tap()
743 XCTAssertTrue(app.staticTexts
744 .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
745 .waitForExistence(timeout: 20), "milestone progress missing")
746 back()
747
748 // --- an MR's milestone reads back, and can be cleared ---
749 app.staticTexts["Merge Requests"].firstMatch.tap()
750 app.segmentedControls.buttons["All"].firstMatch.tap()
751 let mrNumber = app.staticTexts
752 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
753 XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
754 mrNumber.tap()
755 let milestone = app.descendants(matching: .any)
756 .matching(identifier: "mr-milestone-menu").firstMatch
757 // The nav bar is the "opened" signal; everything else on this
758 // screen can be below the fold.
759 XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
760 "MR detail did not open")
761 XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
762 milestone.tap()
763 // The picker offers None plus the open milestones.
764 XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
765 "milestone picker did not open")
766 app.buttons["None"].firstMatch.tap()
767 }
768}