Commit 79e1aa103f
Unsigned
Layout: unified · split
gitbay/ContentView.swift +13
| @@ -55,6 +55,9 @@ private struct RouteDestinations: ViewModifier { | |||
| 55 | .navigationDestination(for: BuildRoute.self) { route in | 55 | .navigationDestination(for: BuildRoute.self) { route in |
| 56 | destination(route, client: client) | 56 | destination(route, client: client) |
| 57 | } | 57 | } |
| 58 | .navigationDestination(for: ReleaseRoute.self) { route in | ||
| 59 | destination(route, client: client) | ||
| 60 | } | ||
| 58 | } | 61 | } |
| 59 | 62 | ||
| 60 | @ViewBuilder | 63 | @ViewBuilder |
| @@ -101,6 +104,16 @@ private struct RouteDestinations: ViewModifier { | |||
| 101 | } | 104 | } |
| 102 | } | 105 | } |
| 103 | 106 | ||
| 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 | |||
| 104 | @ViewBuilder | 117 | @ViewBuilder |
| 105 | private func destination(_ route: BuildRoute, client: GitbayClient) -> some View { | 118 | private func destination(_ route: BuildRoute, client: GitbayClient) -> some View { |
| 106 | switch route { | 119 | switch route { |
gitbay/Discovery/FeedEvent.swift +4
| @@ -60,6 +60,9 @@ nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable { | |||
| 60 | /// Where tapping the row goes, when the event names something the app | 60 | /// Where tapping the row goes, when the event names something the app |
| 61 | /// has a screen for. | 61 | /// has a screen for. |
| 62 | var destination: FeedDestination? { | 62 | var destination: FeedDestination? { |
| 63 | if kind == "release.created", let tag = data?.tag { | ||
| 64 | return .release(repo: repo, tag: tag) | ||
| 65 | } | ||
| 63 | guard let number = data?.number else { | 66 | guard let number = data?.number else { |
| 64 | return .repo(repo) | 67 | return .repo(repo) |
| 65 | } | 68 | } |
| @@ -77,4 +80,5 @@ nonisolated enum FeedDestination: Hashable { | |||
| 77 | case mr(repo: String, number: Int64) | 80 | case mr(repo: String, number: Int64) |
| 78 | case issue(repo: String, number: Int64) | 81 | case issue(repo: String, number: Int64) |
| 79 | case build(repo: String, number: Int64) | 82 | case build(repo: String, number: Int64) |
| 83 | case release(repo: String, tag: String) | ||
| 80 | } | 84 | } |
gitbay/Releases/ReleaseModels.swift added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One release; `release list` rows and `release show`. | ||
| 4 | nonisolated 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `release list <owner/name>`. | ||
| 5 | @Observable | ||
| 6 | @MainActor | ||
| 7 | final 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 | ||
| 61 | final 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 { | |||
| 42 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } | 42 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } |
| 43 | case .build(let repo, let number): | 43 | case .build(let repo, let number): |
| 44 | NavigationLink(value: BuildRoute.log(repo: repo, number: number)) { FeedRow(event: event) } | 44 | 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) } | ||
| 45 | case nil: | 47 | case nil: |
| 46 | FeedRow(event: event) | 48 | FeedRow(event: event) |
| 47 | } | 49 | } |
gitbay/Views/Releases/ReleaseListView.swift added +142
| @@ -0,0 +1,142 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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. | ||
| 73 | private 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 | |||
| 139 | nonisolated 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 { | |||
| 40 | NavigationLink(value: BuildRoute.list(repo: path)) { | 40 | NavigationLink(value: BuildRoute.list(repo: path)) { |
| 41 | Label("Builds", systemImage: "hammer") | 41 | Label("Builds", systemImage: "hammer") |
| 42 | } | 42 | } |
| 43 | NavigationLink(value: ReleaseRoute.list(repo: path)) { | ||
| 44 | Label("Releases", systemImage: "shippingbox") | ||
| 45 | } | ||
| 43 | NavigationLink(value: RepoRoute.grep(repo: path)) { | 46 | NavigationLink(value: RepoRoute.grep(repo: path)) { |
| 44 | Label("Search in Files", systemImage: "text.magnifyingglass") | 47 | Label("Search in Files", systemImage: "text.magnifyingglass") |
| 45 | } | 48 | } |
gitbayTests/DiscoveryTests.swift +1 −1
| @@ -40,7 +40,7 @@ struct FeedEventTests { | |||
| 40 | "data":{"tag":"v2.1.0"},"created_at":"2026-08-27T15:31:10Z"} | 40 | "data":{"tag":"v2.1.0"},"created_at":"2026-08-27T15:31:10Z"} |
| 41 | """) | 41 | """) |
| 42 | #expect(release.phrase == "released v2.1.0") | 42 | #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")) |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | @Test func unknownKindsRenderRawAndFallBackToTheRepo() throws { | 46 | @Test func unknownKindsRenderRawAndFallBackToTheRepo() throws { |
gitbayTests/ReleaseTests.swift added +143
| @@ -0,0 +1,143 @@ | |||
| 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], 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 | |||
| 20 | private 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 | ||
| 29 | struct 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 | ||
| 89 | struct 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 | |||
| 132 | struct 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 { | |||
| 308 | XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") | 308 | XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") |
| 309 | } | 309 | } |
| 310 | } | 310 | } |
| 311 | |||
| 312 | extension 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 | } | ||