Commit 7dac615a6f

7dac615a6f159e93dee21146ea3465c8d690bbf4

parent: 98579f8580

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 00:09 UTC

Cancel a build, delete a release, create and rename an organisation

Layout: unified · split

gitbay/Builds/BuildDetailViewModel.swift +33
@@ -14,6 +14,8 @@ nonisolated struct BuildDetail: Sendable {
14final class BuildDetailViewModel { 14final 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 @@
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 ok = """
21 {"protocol_version":1,"exit_code":0}
22 """
23
24@MainActor
25struct 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
52struct 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
80struct 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}