Commit 7dac615a6f
Verified · cmc
Layout: unified · split
gitbay/Builds/BuildDetailViewModel.swift +33
| @@ -14,6 +14,8 @@ nonisolated struct BuildDetail: Sendable { | |||
| 14 | final class BuildDetailViewModel { | 14 | final class BuildDetailViewModel { |
| 15 | 15 | ||
| 16 | private(set) var state: LoadState<BuildDetail> = .loading | 16 | private(set) var state: LoadState<BuildDetail> = .loading |
| 17 | private(set) var actionError: String? | ||
| 18 | private(set) var working = false | ||
| 17 | 19 | ||
| 18 | private let client: GitbayClient | 20 | private let client: GitbayClient |
| 19 | let repoPath: String | 21 | let repoPath: String |
| @@ -36,4 +38,35 @@ final class BuildDetailViewModel { | |||
| 36 | state = .from(error) | 38 | state = .from(error) |
| 37 | } | 39 | } |
| 38 | } | 40 | } |
| 41 | |||
| 42 | /// Only `pending` or `running` builds can be cancelled server-side | ||
| 43 | /// (`internal/control/build.go:750`). The refusal message there says | ||
| 44 | /// "queued", but the wire status is always "pending" — matching on | ||
| 45 | /// "queued" would hide this action forever with no error. | ||
| 46 | static func isCancellable(status: String) -> Bool { | ||
| 47 | status == "pending" || status == "running" | ||
| 48 | } | ||
| 49 | |||
| 50 | var isCancellable: Bool { | ||
| 51 | guard let build = state.value?.build else { return false } | ||
| 52 | return Self.isCancellable(status: build.status) | ||
| 53 | } | ||
| 54 | |||
| 55 | func cancel() async { | ||
| 56 | await perform(["build", "cancel", repoPath, String(number)]) | ||
| 57 | } | ||
| 58 | |||
| 59 | private func perform(_ argv: [String]) async { | ||
| 60 | working = true | ||
| 61 | actionError = nil | ||
| 62 | defer { working = false } | ||
| 63 | do { | ||
| 64 | try await client.run(argv) | ||
| 65 | await load() | ||
| 66 | } catch let error as GitbayError { | ||
| 67 | actionError = error.userFacingMessage | ||
| 68 | } catch { | ||
| 69 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 70 | } | ||
| 71 | } | ||
| 39 | } | 72 | } |
gitbay/Discovery/ProfileViewModel.swift +24
| @@ -96,6 +96,8 @@ final class ProfileViewModel { | |||
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | private(set) var state: LoadState<Profile> = .loading | 98 | private(set) var state: LoadState<Profile> = .loading |
| 99 | private(set) var actionError: String? | ||
| 100 | private(set) var working = false | ||
| 99 | 101 | ||
| 100 | private let client: GitbayClient | 102 | private let client: GitbayClient |
| 101 | let name: String | 103 | let name: String |
| @@ -112,4 +114,26 @@ final class ProfileViewModel { | |||
| 112 | state = .from(error) | 114 | state = .from(error) |
| 113 | } | 115 | } |
| 114 | } | 116 | } |
| 117 | |||
| 118 | /// `org create <name>`. | ||
| 119 | func createOrg(_ name: String) async -> Bool { | ||
| 120 | let name = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 121 | guard !name.isEmpty else { return false } | ||
| 122 | await perform(["org", "create", name]) | ||
| 123 | return actionError == nil | ||
| 124 | } | ||
| 125 | |||
| 126 | private func perform(_ argv: [String]) async { | ||
| 127 | working = true | ||
| 128 | actionError = nil | ||
| 129 | defer { working = false } | ||
| 130 | do { | ||
| 131 | try await client.run(argv) | ||
| 132 | await load() | ||
| 133 | } catch let error as GitbayError { | ||
| 134 | actionError = error.userFacingMessage | ||
| 135 | } catch { | ||
| 136 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 137 | } | ||
| 138 | } | ||
| 115 | } | 139 | } |
gitbay/Orgs/OrgViewModels.swift +9
| @@ -67,6 +67,15 @@ final class OrgViewModel { | |||
| 67 | await perform(["org", "members", "add", orgName, member.user, "--role", role]) | 67 | await perform(["org", "members", "add", orgName, member.user, "--role", role]) |
| 68 | } | 68 | } |
| 69 | 69 | ||
| 70 | /// `org rename <old> <new>` — both positional, old first. Swapping | ||
| 71 | /// them renames the wrong way with no error at all. | ||
| 72 | func rename(to newName: String) async -> Bool { | ||
| 73 | let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 74 | guard !newName.isEmpty else { return false } | ||
| 75 | await perform(["org", "rename", orgName, newName]) | ||
| 76 | return actionError == nil | ||
| 77 | } | ||
| 78 | |||
| 70 | func createTeam(_ team: String) async { | 79 | func createTeam(_ team: String) async { |
| 71 | await perform(["org", "team", "create", orgName, team]) | 80 | await perform(["org", "team", "create", orgName, team]) |
| 72 | } | 81 | } |
gitbay/Releases/ReleaseViewModels.swift +17
| @@ -100,6 +100,23 @@ final class ReleaseDetailViewModel { | |||
| 100 | } | 100 | } |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// `release delete <owner/name> <tag> --yes` — `--yes` is required by | ||
| 104 | /// the command, not optional; omitting it is a usage error. | ||
| 105 | func delete() async -> Bool { | ||
| 106 | working = true | ||
| 107 | actionError = nil | ||
| 108 | defer { working = false } | ||
| 109 | do { | ||
| 110 | try await client.run(["release", "delete", repoPath, tag, "--yes"]) | ||
| 111 | return true | ||
| 112 | } catch let error as GitbayError { | ||
| 113 | actionError = error.userFacingMessage | ||
| 114 | } catch { | ||
| 115 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 116 | } | ||
| 117 | return false | ||
| 118 | } | ||
| 119 | |||
| 103 | /// Where a browser downloads this asset — the web's own download | 120 | /// Where a browser downloads this asset — the web's own download |
| 104 | /// route; the JSON surface has no binary-safe asset read. | 121 | /// route; the JSON surface has no binary-safe asset read. |
| 105 | func downloadURL(for asset: Release.Asset) -> URL { | 122 | func downloadURL(for asset: Release.Asset) -> URL { |
gitbayTests/SmallActionTests.swift added +123
| @@ -0,0 +1,123 @@ | |||
| 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] { | ||
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 17 | return try #require(body["argv"] as? [String]) | ||
| 18 | } | ||
| 19 | |||
| 20 | private let ok = """ | ||
| 21 | {"protocol_version":1,"exit_code":0} | ||
| 22 | """ | ||
| 23 | |||
| 24 | @MainActor | ||
| 25 | struct BuildCancelTests { | ||
| 26 | |||
| 27 | /// The wire status is "pending", not "queued" — the refusal message | ||
| 28 | /// says "queued" but the value never does. | ||
| 29 | @Test func onlyPendingAndRunningBuildsAreCancellable() { | ||
| 30 | #expect(BuildDetailViewModel.isCancellable(status: "pending")) | ||
| 31 | #expect(BuildDetailViewModel.isCancellable(status: "running")) | ||
| 32 | for status in ["success", "failure", "cancelled", "queued", ""] { | ||
| 33 | #expect(BuildDetailViewModel.isCancellable(status: status) == false, | ||
| 34 | "\(status) should not be cancellable") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | @Test func cancelSendsTheBuildNumber() async throws { | ||
| 39 | let (client, stub) = try makeClient() | ||
| 40 | // The view model's own load response, then the write, then a reload. | ||
| 41 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) | ||
| 42 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 43 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 44 | await model.cancel() | ||
| 45 | |||
| 46 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 47 | #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"]) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | @MainActor | ||
| 52 | struct ReleaseDeleteTests { | ||
| 53 | |||
| 54 | /// `--yes` is required by the command, not optional. | ||
| 55 | @Test func deleteSendsTheConfirmationFlag() async throws { | ||
| 56 | let (client, stub) = try makeClient() | ||
| 57 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 58 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 59 | let deleted = await model.delete() | ||
| 60 | |||
| 61 | #expect(deleted) | ||
| 62 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 63 | #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"]) | ||
| 64 | } | ||
| 65 | |||
| 66 | @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws { | ||
| 67 | let (client, stub) = try makeClient() | ||
| 68 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 69 | stub.enqueue(.init(status: 200, json: """ | ||
| 70 | {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} | ||
| 71 | """)) | ||
| 72 | let deleted = await model.delete() | ||
| 73 | |||
| 74 | #expect(deleted == false) | ||
| 75 | #expect(model.actionError?.isEmpty == false) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | @MainActor | ||
| 80 | struct OrgCreateRenameTests { | ||
| 81 | |||
| 82 | /// Both names are positional, OLD first. Swapping them renames the | ||
| 83 | /// wrong way with no error at all. | ||
| 84 | @Test func renameSendsOldThenNew() async throws { | ||
| 85 | let (client, stub) = try makeClient() | ||
| 86 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 87 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 88 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 89 | _ = await model.rename(to: "kerouac") | ||
| 90 | |||
| 91 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 92 | #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"]) | ||
| 93 | } | ||
| 94 | |||
| 95 | @Test func aBlankRenameSendsNothing() async throws { | ||
| 96 | let (client, stub) = try makeClient() | ||
| 97 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 98 | let renamed = await model.rename(to: " ") | ||
| 99 | |||
| 100 | #expect(renamed == false) | ||
| 101 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 102 | } | ||
| 103 | |||
| 104 | @Test func createSendsTheName() async throws { | ||
| 105 | let (client, stub) = try makeClient() | ||
| 106 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 107 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 108 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 109 | _ = await model.createOrg("newco") | ||
| 110 | |||
| 111 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 112 | #expect(try argvOf(write) == ["org", "create", "newco"]) | ||
| 113 | } | ||
| 114 | |||
| 115 | @Test func aBlankCreateSendsNothing() async throws { | ||
| 116 | let (client, stub) = try makeClient() | ||
| 117 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 118 | let created = await model.createOrg(" ") | ||
| 119 | |||
| 120 | #expect(created == false) | ||
| 121 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 122 | } | ||
| 123 | } | ||