a native ios client for gitbay

client ios swift

https://gitbay.org

releases: list, detail, create, edit !8

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

11 files changed, +610 −1

gitbay/ContentView.swift +13
@@ -55,6 +55,9 @@ private struct RouteDestinations: ViewModifier {
5555 .navigationDestination(for: BuildRoute.self) { route in
5656 destination(route, client: client)
5757 }
58 .navigationDestination(for: ReleaseRoute.self) { route in
59 destination(route, client: client)
60 }
5861 }
5962
6063 @ViewBuilder
@@ -101,6 +104,16 @@ private struct RouteDestinations: ViewModifier {
101104 }
102105 }
103106
107 @ViewBuilder
108 private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
109 switch route {
110 case .list(let repo):
111 ReleaseListView(client: client, repo: repo)
112 case .release(let repo, let tag):
113 ReleaseView(client: client, repo: repo, tag: tag)
114 }
115 }
116
104117 @ViewBuilder
105118 private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
106119 switch route {
gitbay/Discovery/FeedEvent.swift +4
@@ -60,6 +60,9 @@ nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable {
6060 /// Where tapping the row goes, when the event names something the app
6161 /// has a screen for.
6262 var destination: FeedDestination? {
63 if kind == "release.created", let tag = data?.tag {
64 return .release(repo: repo, tag: tag)
65 }
6366 guard let number = data?.number else {
6467 return .repo(repo)
6568 }
@@ -77,4 +80,5 @@ nonisolated enum FeedDestination: Hashable {
7780 case mr(repo: String, number: Int64)
7881 case issue(repo: String, number: Int64)
7982 case build(repo: String, number: Int64)
83 case release(repo: String, tag: String)
8084 }
gitbay/Releases/ReleaseModels.swift added +25
@@ -0,0 +1,25 @@
1import Foundation
2
3/// One release; `release list` rows and `release show`.
4nonisolated struct Release: Decodable, Sendable, Hashable, Identifiable {
5 let tag: String
6 let title: String
7 let notes: String?
8 let author: String?
9 let createdAt: Date
10 let assets: [Asset]?
11
12 enum CodingKeys: String, CodingKey {
13 case tag, title, notes, author, assets
14 case createdAt = "created_at"
15 }
16
17 var id: String { tag }
18
19 nonisolated struct Asset: Decodable, Sendable, Hashable, Identifiable {
20 let name: String
21 let size: Int64
22 let sha256: String
23 var id: String { name }
24 }
25}
gitbay/Releases/ReleaseViewModels.swift added +112
@@ -0,0 +1,112 @@
1import Foundation
2import Observation
3
4/// `release list <owner/name>`.
5@Observable
6@MainActor
7final class ReleaseListViewModel {
8
9 private(set) var state: LoadState<[Release]> = .loading
10 private(set) var createError: String?
11 private(set) var working = false
12
13 private let client: GitbayClient
14 let repoPath: String
15
16 init(client: GitbayClient, repoPath: String) {
17 self.client = client
18 self.repoPath = repoPath
19 }
20
21 func load() async {
22 do {
23 let releases = try await client.readList(["release", "list", repoPath], of: Release.self)
24 state = releases.isEmpty
25 ? .empty("No releases. Tag a commit, push the tag, then release it.")
26 : .loaded(releases)
27 } catch {
28 state = .from(error)
29 }
30 }
31
32 /// `release create <owner/name> <tag> [--title <t>] [--file -]`
33 /// the tag must already exist; the server's refusal says so.
34 func create(tag: String, title: String, notes: String) async -> Bool {
35 working = true
36 createError = nil
37 defer { working = false }
38 do {
39 var argv = ["release", "create", repoPath, tag]
40 if !title.isEmpty { argv.append(contentsOf: ["--title", title]) }
41 var stdin: String?
42 if !notes.isEmpty {
43 argv.append(contentsOf: ["--file", "-"])
44 stdin = notes
45 }
46 try await client.run(argv, stdin: stdin)
47 await load()
48 return true
49 } catch let error as GitbayError {
50 createError = error.userFacingMessage
51 } catch {
52 createError = GitbayError.transport(error).userFacingMessage
53 }
54 return false
55 }
56}
57
58/// One release: `release show`, plus title/notes editing.
59@Observable
60@MainActor
61final class ReleaseDetailViewModel {
62
63 private(set) var state: LoadState<Release> = .loading
64 private(set) var actionError: String?
65 private(set) var working = false
66
67 private let client: GitbayClient
68 let repoPath: String
69 let tag: String
70
71 init(client: GitbayClient, repoPath: String, tag: String) {
72 self.client = client
73 self.repoPath = repoPath
74 self.tag = tag
75 }
76
77 func load() async {
78 do {
79 state = .loaded(try await client.read(
80 ["release", "show", repoPath, tag], as: Release.self))
81 } catch {
82 state = .from(error)
83 }
84 }
85
86 func edit(title: String, notes: String) async {
87 working = true
88 actionError = nil
89 defer { working = false }
90 do {
91 try await client.run(
92 ["release", "edit", repoPath, tag, "--title", title, "--file", "-"],
93 stdin: notes
94 )
95 await load()
96 } catch let error as GitbayError {
97 actionError = error.userFacingMessage
98 } catch {
99 actionError = GitbayError.transport(error).userFacingMessage
100 }
101 }
102
103 /// Where a browser downloads this asset the web's own download
104 /// route; the JSON surface has no binary-safe asset read.
105 func downloadURL(for asset: Release.Asset) -> URL {
106 client.instance.baseURL
107 .appending(path: repoPath)
108 .appending(path: "releases/download")
109 .appending(path: tag)
110 .appending(path: asset.name)
111 }
112}
gitbay/Views/Discovery/FeedView.swift +2
@@ -42,6 +42,8 @@ struct FeedView: View {
4242 NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) }
4343 case .build(let repo, let number):
4444 NavigationLink(value: BuildRoute.log(repo: repo, number: number)) { FeedRow(event: event) }
45 case .release(let repo, let tag):
46 NavigationLink(value: ReleaseRoute.release(repo: repo, tag: tag)) { FeedRow(event: event) }
4547 case nil:
4648 FeedRow(event: event)
4749 }
gitbay/Views/Releases/ReleaseListView.swift added +142
@@ -0,0 +1,142 @@
1import SwiftUI
2
3struct ReleaseListView: View {
4
5 @State private var model: ReleaseListViewModel
6 @State private var composing = false
7 @State private var draftTag = ""
8 @State private var draftTitle = ""
9 @State private var draftNotes = ""
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 ForEach(model.state.value ?? []) { release in
18 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
19 VStack(alignment: .leading, spacing: 3) {
20 Text(release.title.isEmpty ? release.tag : release.title)
21 .font(.subheadline.weight(.medium))
22 .lineLimit(2)
23 HStack(spacing: 6) {
24 Text(release.tag)
25 .font(.caption.monospaced())
26 .foregroundStyle(.secondary)
27 if let author = release.author {
28 Text("by \(author)")
29 .font(.caption)
30 .foregroundStyle(.secondary)
31 }
32 Spacer()
33 Text(release.createdAt, format: .relative(presentation: .named))
34 .font(.caption)
35 .foregroundStyle(.tertiary)
36 }
37 }
38 .padding(.vertical, 2)
39 }
40 }
41 }
42 .overlay { LoadStateOverlay(state: model.state) }
43 .navigationTitle("Releases")
44 .navigationBarTitleDisplayMode(.inline)
45 .toolbar {
46 ToolbarItem(placement: .topBarTrailing) {
47 Button {
48 composing = true
49 } label: {
50 Image(systemName: "plus")
51 }
52 .accessibilityIdentifier("release-create-button")
53 }
54 }
55 .sheet(isPresented: $composing) {
56 ReleaseCreateSheet(
57 model: model,
58 tag: $draftTag, title: $draftTitle, notes: $draftNotes
59 ) {
60 draftTag = ""
61 draftTitle = ""
62 draftNotes = ""
63 composing = false
64 }
65 }
66 .task { await model.load() }
67 .refreshable { await model.load() }
68 }
69}
70
71/// The tag is typed and must already exist on the server the refusal
72/// ("push the tag first") is the explanation.
73private struct ReleaseCreateSheet: View {
74
75 let model: ReleaseListViewModel
76 @Binding var tag: String
77 @Binding var title: String
78 @Binding var notes: String
79 let onCreated: () -> Void
80
81 @Environment(\.dismiss) private var dismiss
82
83 var body: some View {
84 NavigationStack {
85 Form {
86 Section("Tag") {
87 TextField("v1.0.0", text: $tag)
88 .autocorrectionDisabled()
89 .textInputAutocapitalization(.never)
90 .accessibilityIdentifier("release-tag")
91 }
92 Section("Title") {
93 TextField("Title (optional)", text: $title)
94 .autocorrectionDisabled()
95 }
96 Section("Notes") {
97 TextEditor(text: $notes)
98 .frame(minHeight: 140)
99 .autocorrectionDisabled()
100 }
101 if let error = model.createError {
102 Section {
103 Label(error, systemImage: "exclamationmark.triangle")
104 .foregroundStyle(.red)
105 .font(.subheadline)
106 }
107 }
108 }
109 .navigationTitle("New Release")
110 .navigationBarTitleDisplayMode(.inline)
111 .toolbar {
112 ToolbarItem(placement: .cancellationAction) {
113 Button("Cancel") { dismiss() }
114 }
115 ToolbarItem(placement: .confirmationAction) {
116 if model.working {
117 ProgressView()
118 } else {
119 Button("Create") {
120 Task {
121 if await model.create(
122 tag: tag.trimmingCharacters(in: .whitespaces),
123 title: title, notes: notes
124 ) {
125 onCreated()
126 }
127 }
128 }
129 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
130 .accessibilityIdentifier("release-submit")
131 }
132 }
133 }
134 .interactiveDismissDisabled(model.working)
135 }
136 }
137}
138
139nonisolated enum ReleaseRoute: Hashable {
140 case list(repo: String)
141 case release(repo: String, tag: String)
142}
gitbay/Views/Releases/ReleaseView.swift added +113
@@ -0,0 +1,113 @@
1import SwiftUI
2
3struct ReleaseView: View {
4
5 @State private var model: ReleaseDetailViewModel
6 @State private var editing = false
7 @State private var draftTitle = ""
8 @State private var draftNotes = ""
9
10 init(client: GitbayClient, repo: String, tag: String) {
11 _model = State(initialValue: ReleaseDetailViewModel(
12 client: client, repoPath: repo, tag: tag
13 ))
14 }
15
16 var body: some View {
17 List {
18 if let release = model.state.value {
19 if let error = model.actionError {
20 Section {
21 Label(error, systemImage: "hand.raised")
22 .foregroundStyle(.orange)
23 .font(.subheadline)
24 }
25 }
26 Section {
27 VStack(alignment: .leading, spacing: 6) {
28 Text(release.title.isEmpty ? release.tag : release.title)
29 .font(.headline)
30 HStack(spacing: 6) {
31 Text(release.tag)
32 .font(.caption.monospaced())
33 if let author = release.author {
34 Text("by \(author)")
35 }
36 Text(release.createdAt, format: .relative(presentation: .named))
37 .foregroundStyle(.tertiary)
38 }
39 .font(.caption)
40 .foregroundStyle(.secondary)
41 }
42 .padding(.vertical, 2)
43 }
44
45 if let notes = release.notes, !notes.isEmpty {
46 Section {
47 MarkdownView(markdown: notes)
48 .padding(.vertical, 4)
49 }
50 }
51
52 if let assets = release.assets, !assets.isEmpty {
53 Section("Assets") {
54 ForEach(assets) { asset in
55 Link(destination: model.downloadURL(for: asset)) {
56 HStack {
57 VStack(alignment: .leading, spacing: 2) {
58 Text(asset.name)
59 .font(.caption.monospaced())
60 .foregroundStyle(.primary)
61 .lineLimit(1)
62 Text(String(asset.sha256.prefix(16)))
63 .font(.caption2.monospaced())
64 .foregroundStyle(.tertiary)
65 }
66 Spacer()
67 Text(asset.size.formatted(.byteCount(style: .file)))
68 .font(.caption)
69 .foregroundStyle(.secondary)
70 Image(systemName: "arrow.down.circle")
71 .foregroundStyle(.secondary)
72 }
73 }
74 }
75 }
76 }
77 }
78 }
79 .overlay { LoadStateOverlay(state: model.state) }
80 .navigationTitle(model.tag)
81 .navigationBarTitleDisplayMode(.inline)
82 .toolbar {
83 ToolbarItem(placement: .topBarTrailing) {
84 if let release = model.state.value {
85 Button("Edit") {
86 draftTitle = release.title
87 draftNotes = release.notes ?? ""
88 editing = true
89 }
90 .disabled(model.working)
91 .accessibilityIdentifier("release-edit-button")
92 }
93 }
94 }
95 .sheet(isPresented: $editing) {
96 ComposeSheet(
97 heading: "Edit \(model.tag)",
98 submitLabel: "Save",
99 working: model.working,
100 errorMessage: model.actionError,
101 title: $draftTitle,
102 bodyText: $draftNotes
103 ) {
104 Task {
105 await model.edit(title: draftTitle, notes: draftNotes)
106 if model.actionError == nil { editing = false }
107 }
108 }
109 }
110 .task { await model.load() }
111 .refreshable { await model.load() }
112 }
113}
gitbay/Views/Repos/RepoView.swift +3
@@ -40,6 +40,9 @@ struct RepoView: View {
4040 NavigationLink(value: BuildRoute.list(repo: path)) {
4141 Label("Builds", systemImage: "hammer")
4242 }
43 NavigationLink(value: ReleaseRoute.list(repo: path)) {
44 Label("Releases", systemImage: "shippingbox")
45 }
4346 NavigationLink(value: RepoRoute.grep(repo: path)) {
4447 Label("Search in Files", systemImage: "text.magnifyingglass")
4548 }
gitbayTests/DiscoveryTests.swift +1 −1
@@ -40,7 +40,7 @@ struct FeedEventTests {
4040 "data":{"tag":"v2.1.0"},"created_at":"2026-08-27T15:31:10Z"}
4141 """)
4242 #expect(release.phrase == "released v2.1.0")
43 #expect(release.destination == .repo("krz/orgo"))
43 #expect(release.destination == .release(repo: "krz/orgo", tag: "v2.1.0"))
4444 }
4545
4646 @Test func unknownKindsRenderRawAndFallBackToTheRepo() throws {
gitbayTests/ReleaseTests.swift added +143
@@ -0,0 +1,143 @@
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], String?) {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
18}
19
20private let releaseShowJSON = """
21 {"protocol_version":1,"data":{"tag":"v1.0.0","title":"v1.0.0 the CLI is the interface",\
22 "notes":"Replace the binary and restart.","author":"cmc",\
23 "created_at":"2026-08-26T05:27:36.148Z",\
24 "assets":[{"name":"SHA256SUMS","size":557,\
25 "sha256":"1646f3ec57f91bb2c346078735d47e6f853e4ee0ee5e5176123337663cf8c7f4"}]},"exit_code":0}
26 """
27
28@MainActor
29struct ReleaseListViewModelTests {
30
31 @Test func listsReleases() async throws {
32 let (client, stub) = try makeClient()
33 stub.enqueue(.init(status: 200, json: """
34 {"protocol_version":1,"data":[\
35 {"tag":"v1.0.0","title":"one","created_at":"2026-08-26T05:27:36.148Z"},\
36 {"tag":"v0.5.0","title":"half","created_at":"2026-08-20T05:27:36.148Z"}\
37 ],"exit_code":0}
38 """))
39 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
40
41 await model.load()
42
43 #expect(model.state.value?.map(\.tag) == ["v1.0.0", "v0.5.0"])
44 #expect(stub.seen.first?.url.query() == "argv=release&argv=list&argv=krz/gitbay")
45 }
46
47 @Test func createSendsTagTitleAndNotesOverStdin() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
50 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list"))
51 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
52
53 let created = await model.create(tag: "v1.1.0", title: "next", notes: "notes body")
54
55 #expect(created)
56 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
57 #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0",
58 "--title", "next", "--file", "-"])
59 #expect(stdin == "notes body")
60 }
61
62 @Test func aMissingTagRefusalSurfacesVerbatim() async throws {
63 let (client, stub) = try makeClient()
64 stub.enqueue(.init(status: 404, json:
65 #"{"protocol_version":1,"error":"no tag \"v9\" in krz/gitbay — push the tag first","exit_code":3}"#))
66 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
67
68 let created = await model.create(tag: "v9", title: "", notes: "")
69
70 #expect(!created)
71 #expect(model.createError == #"no tag "v9" in krz/gitbay push the tag first"#)
72 }
73
74 @Test func emptyTitleAndNotesAreOmittedFromArgv() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list"))
78 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
79
80 _ = await model.create(tag: "v1.1.0", title: "", notes: "")
81
82 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
83 #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0"])
84 #expect(stdin == nil)
85 }
86}
87
88@MainActor
89struct ReleaseDetailViewModelTests {
90
91 @Test func loadsShowWithAssets() async throws {
92 let (client, stub) = try makeClient()
93 stub.enqueue(.init(status: 200, json: releaseShowJSON))
94 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
95
96 await model.load()
97
98 let release = try #require(model.state.value)
99 #expect(release.assets?.count == 1)
100 #expect(stub.seen.first?.url.query() ==
101 "argv=release&argv=show&argv=krz/gitbay&argv=v1.0.0")
102 }
103
104 @Test func editSendsTitleAndNotes() async throws {
105 let (client, stub) = try makeClient()
106 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
107 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
108 await model.load()
109 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
110 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
111
112 await model.edit(title: "new title", notes: "new notes")
113
114 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
115 #expect(argv == ["release", "edit", "krz/gitbay", "v1.0.0",
116 "--title", "new title", "--file", "-"])
117 #expect(stdin == "new notes")
118 }
119
120 @Test func downloadURLsUseTheWebRouteOnTheInstance() async throws {
121 let (client, _) = try makeClient()
122 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
123 let asset = Release.Asset(name: "gitbay-v1.0.0-linux-amd64", size: 1, sha256: "aa")
124
125 let url = model.downloadURL(for: asset)
126
127 #expect(url.absoluteString ==
128 "https://gitbay.org/krz/gitbay/releases/download/v1.0.0/gitbay-v1.0.0-linux-amd64")
129 }
130}
131
132struct FeedReleaseDestinationTests {
133
134 @Test func releaseEventsNavigateToTheRelease() throws {
135 let decoder = JSONDecoder()
136 decoder.dateDecodingStrategy = .iso8601
137 let event = try decoder.decode(FeedEvent.self, from: Data("""
138 {"id":2,"repo":"krz/gitbay","actor":"cmc","kind":"release.created",\
139 "data":{"tag":"v1.0.0"},"created_at":"2026-08-26T05:27:36Z"}
140 """.utf8))
141 #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0"))
142 }
143}
gitbayUITests/LiveSmokeUITests.swift +52
@@ -308,3 +308,55 @@ extension LiveSmokeUITests {
308308 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
309309 }
310310 }
311
312extension LiveSmokeUITests {
313
314 /// Releases: list and detail on a real release, an edit round-trip
315 /// that saves the prefilled content (a no-op write), and the
316 /// missing-tag refusal on create. Nothing changes state.
317 func testReleaseFlows() throws {
318 openRepo("krz/gitbay")
319 app.staticTexts["Releases"].firstMatch.tap()
320
321 let row = app.staticTexts
322 .containing(NSPredicate(format: "label CONTAINS 'v1.0.0'")).firstMatch
323 XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
324 row.tap()
325
326 // Notes render and assets carry sizes.
327 XCTAssertTrue(app.staticTexts
328 .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch
329 .waitForExistence(timeout: 15), "assets missing")
330
331 // Edit sheet prefills; saving unchanged content round-trips.
332 app.descendants(matching: .any).matching(identifier: "release-edit-button")
333 .firstMatch.tap()
334 let title = app.descendants(matching: .any)
335 .matching(identifier: "compose-title").firstMatch
336 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
337 XCTAssertTrue((title.value as? String)?.contains("v1.0.0") == true,
338 "edit sheet did not prefill")
339 app.descendants(matching: .any).matching(identifier: "compose-submit")
340 .firstMatch.tap()
341 XCTAssertTrue(waitForDisappearance(title, timeout: 15),
342 "release edit did not dismiss")
343
344 back()
345
346 // Create with a tag that does not exist: the server's refusal is
347 // the UI contract.
348 app.descendants(matching: .any).matching(identifier: "release-create-button")
349 .firstMatch.tap()
350 let tag = app.descendants(matching: .any)
351 .matching(identifier: "release-tag").firstMatch
352 XCTAssertTrue(tag.waitForExistence(timeout: 5))
353 tag.tap()
354 tag.typeText("v9.9.9")
355 app.descendants(matching: .any).matching(identifier: "release-submit")
356 .firstMatch.tap()
357 XCTAssertTrue(app.staticTexts
358 .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
359 .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
360 app.buttons["Cancel"].firstMatch.tap()
361 }
362}