Commit 014ea9f840

014ea9f840afadc6312b1c2c45c0194d5964c481

parent: 59aa1b9a8f

Unsigned

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

Cancel a build, delete a release, create and rename an organisation (!54)

Three unrelated one-command actions, batched because each is a single
method and a menu item rather than because they share anything.

Cancelling a build is offered only while the build is pending or
running, which is what the server accepts — note the wire status is
`pending`, though the refusal message calls it "queued".

Deleting a release confirms first and says the assets go with it, since
the command requires `--yes` and that flag is the server asking for a
typed confirmation. The list refreshes on the way back, so a deleted tag
does not linger as a row that 404s.

Renaming an organisation warns that clone URLs change. The view model
moves to the new name only after the server confirms, so a refused
rename leaves it pointing where it was, and the reload that follows asks
for the right one.

Creating an organisation sits on your own profile, which is where your
organisations are listed and where the new one appears.

Closes three parity rows: build cancel, release delete, and organisation
create and rename.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr08-small-actions.md added +268
@@ -0,0 +1,268 @@
1# MR 8: Build cancel, release delete, org create and rename Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close three parity rows — `build cancel`, `release delete`, and `org create, rename`.
6
7**Architecture:** Three unrelated one-command actions, batched into one merge request because each is a single method plus a menu item on a screen that already has a write path. They share no code; they share only a shape.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## Global Constraints
12
13- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
14- Swift Testing only — never XCTest.
15- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
16- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
17- The label model is `IssueLabel`; the notification model is `InboxNotification`. Neither shadows a framework type.
18- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
19- Never commit to `main`.
20
21**The commands, verbatim from the registry:**
22
23```
24build cancel <owner/name> <n>
25release delete <owner/name> <tag> --yes
26org create <name>
27org rename <old> <new>
28```
29
30**`release delete` requires `--yes`.** It is not optional; omitting it is a usage error. That flag is the server asking for a typed confirmation, so the app confirms before sending it.
31
32**`build cancel` is refused unless the build is `pending` or `running`** (`internal/control/build.go:750`). The refusal reads *"build N is <status>; only a queued or running build can be cancelled"*. Note the wire status is **`pending`**, even though the prose says "queued" — match on `pending`, not `queued`.
33
34**Counting tests — use `xcresulttool`, not grep.** Grepping the build log undercounts by one, because `xcodebuild` splits a result line under parallel output:
35
36```bash
37RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
38xcrun xcresulttool get test-results summary --path "$RES"
39```
40
41Baseline: **311 total, 310 passed, 1 skipped, 0 failed.**
42
43---
44
45## File Structure
46
47| File | Responsibility |
48|------|----------------|
49| `gitbay/Builds/BuildDetailViewModel.swift` (modify) | `cancel()` |
50| `gitbay/Releases/ReleaseViewModels.swift` (modify) | `delete()` |
51| `gitbay/Orgs/OrgViewModels.swift` (modify) | `createOrg(_:)`, `rename(to:)` |
52| `gitbayTests/SmallActionTests.swift` (create) | Every test in this plan |
53| `gitbay/Views/Builds/BuildDetailView.swift` (modify) | Cancel action |
54| `gitbay/Views/Releases/ReleaseView.swift` (modify) | Delete action |
55| `gitbay/Views/Orgs/OrgView.swift` (modify) | Rename action |
56| `gitbay/Discovery/ProfileViewModel.swift` (modify) | `createOrg(_:)` |
57| `gitbay/Views/Discovery/ProfileView.swift` (modify) | Create action |
58
59---
60
61### Task 1: The four methods
62
63**Files:** Modify the three view models above; test in `gitbayTests/SmallActionTests.swift`
64
65**Interfaces produced:**
66- `BuildDetailViewModel.cancel() async` and `var isCancellable: Bool`
67- `ReleaseDetailViewModel.delete() async -> Bool` (true on success, so the view can pop)
68- `OrgViewModel.rename(to newName: String) async -> Bool`
69- `ProfileViewModel.createOrg(_ name: String) async -> Bool`
70
71Each routes through the existing `perform(argv:)` in its file. **Copy the pattern, do not extract a shared helper** — that is a standing decision in this codebase, and these are three unrelated files.
72
73Behaviour, each pinned by a test:
741. `cancel()` sends `["build", "cancel", repoPath, String(number)]`.
752. `isCancellable` is true only for status `pending` or `running`. **The wire value is `pending`** — matching on `"queued"` would leave the action permanently hidden, silently.
763. `delete()` sends `["release", "delete", repoPath, tag, "--yes"]`. The `--yes` is required by the command; a delete without it is a usage error.
774. `rename(to:)` sends `["org", "rename", oldName, newName]` — both positional, old first. Swapping them renames the wrong way with no error.
785. `createOrg(_:)` sends `["org", "create", name]`.
796. Every one trims its input and refuses to send a blank name or tag.
807. A refusal surfaces into `actionError`, returns false, and does not pretend success.
81
82- [ ] **Step 1: Write the failing tests**
83
84```swift
85import Foundation
86import Testing
87@testable import gitbay
88
89private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
90 let box = StubProtocol.box()
91 let client = GitbayClient(
92 instance: try GitbayInstance(url: "https://gitbay.org"),
93 token: "test-token",
94 session: box.session()
95 )
96 return (client, box)
97}
98
99private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
100 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
101 return try #require(body["argv"] as? [String])
102}
103
104private let ok = """
105 {"protocol_version":1,"exit_code":0}
106 """
107
108@MainActor
109struct BuildCancelTests {
110
111 /// The wire status is "pending", not "queued" — the refusal message
112 /// says "queued" but the value never does.
113 @Test func onlyPendingAndRunningBuildsAreCancellable() {
114 #expect(BuildDetailViewModel.isCancellable(status: "pending"))
115 #expect(BuildDetailViewModel.isCancellable(status: "running"))
116 for status in ["success", "failure", "cancelled", "queued", ""] {
117 #expect(BuildDetailViewModel.isCancellable(status: status) == false,
118 "\(status) should not be cancellable")
119 }
120 }
121
122 @Test func cancelSendsTheBuildNumber() async throws {
123 let (client, stub) = try makeClient()
124 // The view model's own load response, then the write, then a reload.
125 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
126 stub.enqueue(.init(status: 200, json: ok))
127 stub.enqueue(.init(status: 200, json: ok))
128 await model.cancel()
129
130 let write = try #require(stub.seen.first { $0.method == "POST" })
131 #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"])
132 }
133}
134
135@MainActor
136struct ReleaseDeleteTests {
137
138 /// `--yes` is required by the command, not optional.
139 @Test func deleteSendsTheConfirmationFlag() async throws {
140 let (client, stub) = try makeClient()
141 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
142 stub.enqueue(.init(status: 200, json: ok))
143 let deleted = await model.delete()
144
145 #expect(deleted)
146 let write = try #require(stub.seen.first { $0.method == "POST" })
147 #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"])
148 }
149
150 @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws {
151 let (client, stub) = try makeClient()
152 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
153 stub.enqueue(.init(status: 200, json: """
154 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
155 """))
156 let deleted = await model.delete()
157
158 #expect(deleted == false)
159 #expect(model.actionError?.isEmpty == false)
160 }
161}
162
163@MainActor
164struct OrgCreateRenameTests {
165
166 /// Both names are positional, OLD first. Swapping them renames the
167 /// wrong way with no error at all.
168 @Test func renameSendsOldThenNew() async throws {
169 let (client, stub) = try makeClient()
170 let model = OrgViewModel(client: client, orgName: "krz")
171 stub.enqueue(.init(status: 200, json: ok))
172 stub.enqueue(.init(status: 200, json: ok))
173 _ = await model.rename(to: "kerouac")
174
175 let write = try #require(stub.seen.first { $0.method == "POST" })
176 #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"])
177 }
178
179 @Test func aBlankRenameSendsNothing() async throws {
180 let (client, stub) = try makeClient()
181 let model = OrgViewModel(client: client, orgName: "krz")
182 let renamed = await model.rename(to: " ")
183
184 #expect(renamed == false)
185 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
186 }
187
188 @Test func createSendsTheName() async throws {
189 let (client, stub) = try makeClient()
190 stub.enqueue(.init(status: 200, json: ok))
191 stub.enqueue(.init(status: 200, json: ok))
192 let model = ProfileViewModel(client: client, name: "cmc")
193 _ = await model.createOrg("newco")
194
195 let write = try #require(stub.seen.first { $0.method == "POST" })
196 #expect(try argvOf(write) == ["org", "create", "newco"])
197 }
198
199 @Test func aBlankCreateSendsNothing() async throws {
200 let (client, stub) = try makeClient()
201 let model = ProfileViewModel(client: client, name: "cmc")
202 let created = await model.createOrg(" ")
203
204 #expect(created == false)
205 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
206 }
207}
208```
209
210**The real initialisers, which I checked** — match them exactly and do not change any to suit a test:
211- `BuildDetailViewModel(client:repoPath:number:)`
212- `ReleaseDetailViewModel(client:repoPath:tag:)`
213- `OrgViewModel(client:orgName:)` — note `orgName:`, not `org:`
214- `ProfileViewModel(client:name:)`
215
216**There is no organisation list view model.** Organisations are listed on the profile screen, from `ProfileViewModel.Profile.orgs` (`ProfileView.swift:28,99`), so `createOrg` belongs on `ProfileViewModel` and the button belongs on that screen. Check `ProfileViewModel`'s actual initialiser before writing the test.
217
218`isCancellable` is written above as a static taking a status so it is testable without a loaded model; an instance property derived from the loaded build is equally fine as long as the pending/running/everything-else distinction is directly testable.
219
220- [ ] **Step 2: Run to verify failure.**
221- [ ] **Step 3: Implement the four methods.**
222- [ ] **Step 4: Run the tests.**
223- [ ] **Step 5: Commit** — `git commit -m "Cancel a build, delete a release, create and rename an organisation"`
224
225---
226
227### Task 2: The four actions in their screens
228
229**Files:** the four view files above
230
231No unit tests — UI.
232
233- **Build cancel** — on the build detail screen, offered **only when `isCancellable`**. Confirm first: cancelling a running build kills the step within a couple of seconds. Say what it does rather than just "Cancel".
234- **Release delete** — on the release detail screen, destructive, confirmed, and the confirmation must say the assets go too. Pop back to the release list on success.
235- **Org rename** — on the organisation screen. The confirmation **must say clone URLs change**; the command's own usage says so, and a rename that silently breaks everyone's remotes is the worst outcome here.
236- **Org create** — on the profile screen, which is where organisations are listed. A name prompt and the action, then reload so the new organisation appears.
237
238Reuse each screen's existing notice mechanism; do not add a second one. Match how each screen already presents its destructive actions — the repository screen's archive and the label screen's remove are the closest precedents.
239
240- [ ] **Step 1: Build cancel, gated on `isCancellable`**
241- [ ] **Step 2: Release delete with its confirmation and pop**
242- [ ] **Step 3: Org rename with the clone-URL warning**
243- [ ] **Step 4: Org create**
244- [ ] **Step 5: Build and run the full suite.** No drop from 310 passed.
245- [ ] **Step 6: Commit** — `git commit -m "Cancel, delete and organisation actions in their screens"`
246
247---
248
249### Task 3: Flip three parity rows and open the merge request
250
251- [ ] **Step 1:** Set `build cancel`, `release delete` and `create, rename` (Organizations) to `yes` for iOS. Touch no other row — in particular leave `delete, transfer` and `API token mint`, which are SSH-only by design, and `dependency checks`/`dependency status`, which are the next merge request.
252
253Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s JSON rather than assuming it.**
254
255- [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers.
256- [ ] **Step 3:** Open the merge request.
257
258---
259
260## Notes for whoever executes this
261
262**The build status is `pending`, not `queued`.** The server's own refusal message says "queued or running", but the value it compares is `pending`. Matching on `"queued"` would hide the cancel action forever, silently.
263
264**`--yes` is not optional on `release delete`.** The command requires it.
265
266**`org rename` is old-then-new, both positional.** Reversed, it renames the wrong way and the server cannot tell.
267
268**Three unrelated files.** They share a shape, not code. Copy each file's own `perform`; do not unify them.
gitbay/Builds/BuildDetailViewModel.swift +33
@@ -14,6 +14,8 @@ nonisolated struct BuildDetail: Sendable {
1414final class BuildDetailViewModel {
1515
1616 private(set) var state: LoadState<BuildDetail> = .loading
17 private(set) var actionError: String?
18 private(set) var working = false
1719
1820 private let client: GitbayClient
1921 let repoPath: String
@@ -36,4 +38,35 @@ final class BuildDetailViewModel {
3638 state = .from(error)
3739 }
3840 }
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 }
3972}
gitbay/Discovery/ProfileViewModel.swift +24
@@ -96,6 +96,8 @@ final class ProfileViewModel {
9696 }
9797
9898 private(set) var state: LoadState<Profile> = .loading
99 private(set) var actionError: String?
100 private(set) var working = false
99101
100102 private let client: GitbayClient
101103 let name: String
@@ -112,4 +114,26 @@ final class ProfileViewModel {
112114 state = .from(error)
113115 }
114116 }
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 }
115139}
gitbay/Orgs/OrgViewModels.swift +28 −1
@@ -31,7 +31,7 @@ final class OrgViewModel {
3131 private(set) var working = false
3232
3333 private let client: GitbayClient
34 let orgName: String
34 private(set) var orgName: String
3535
3636 init(client: GitbayClient, orgName: String) {
3737 self.client = client
@@ -67,6 +67,33 @@ final class OrgViewModel {
6767 await perform(["org", "members", "add", orgName, member.user, "--role", role])
6868 }
6969
70 /// `org rename <old> <new>` — both positional, old first. Swapping
71 /// them renames the wrong way with no error at all.
72 ///
73 /// Doesn't go through `perform`: its automatic reload would still
74 /// query the old, now-renamed org. `orgName` only moves to the new
75 /// name once the rename itself has actually succeeded, and the
76 /// reload that follows targets that new name.
77 func rename(to newName: String) async -> Bool {
78 let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
79 guard !newName.isEmpty else { return false }
80 working = true
81 actionError = nil
82 defer { working = false }
83 do {
84 try await client.run(["org", "rename", orgName, newName])
85 orgName = newName
86 await load()
87 return true
88 } catch let error as GitbayError {
89 actionError = error.userFacingMessage
90 return false
91 } catch {
92 actionError = GitbayError.transport(error).userFacingMessage
93 return false
94 }
95 }
96
7097 func createTeam(_ team: String) async {
7198 await perform(["org", "team", "create", orgName, team])
7299 }
gitbay/Releases/ReleaseViewModels.swift +17
@@ -100,6 +100,23 @@ final class ReleaseDetailViewModel {
100100 }
101101 }
102102
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
103120 /// Where a browser downloads this asset — the web's own download
104121 /// route; the JSON surface has no binary-safe asset read.
105122 func downloadURL(for asset: Release.Asset) -> URL {
gitbay/Views/Builds/BuildDetailView.swift +36
@@ -4,6 +4,7 @@ import SwiftUI
44struct BuildDetailView: View {
55
66 @State private var model: BuildDetailViewModel
7 @State private var confirmingCancel = false
78
89 init(client: GitbayClient, repo: String, number: Int64) {
910 _model = State(initialValue: BuildDetailViewModel(
@@ -16,6 +17,11 @@ struct BuildDetailView: View {
1617 Color.clear
1718 if let detail = model.state.value {
1819 VStack(spacing: 0) {
20 if let error = model.actionError {
21 GBNotice(error, .gbWarn)
22 .padding(.horizontal, 16)
23 .padding(.vertical, 8)
24 }
1925 header(detail.build)
2026 Divider()
2127 log(detail.log)
@@ -25,8 +31,38 @@ struct BuildDetailView: View {
2531 .overlay { LoadStateOverlay(state: model.state) }
2632 .navigationTitle("Build #\(model.number)")
2733 .navigationBarTitleDisplayMode(.inline)
34 .toolbar {
35 ToolbarItem(placement: .topBarTrailing) {
36 if model.isCancellable {
37 if model.working {
38 ProgressView()
39 } else {
40 Button("Cancel Build", role: .destructive) {
41 confirmingCancel = true
42 }
43 .accessibilityIdentifier("build-cancel-button")
44 }
45 }
46 }
47 }
2848 .task { await model.load() }
2949 .refreshable { await model.load() }
50 .confirmationDialog(
51 "Cancel this build?",
52 isPresented: $confirmingCancel,
53 titleVisibility: .visible
54 ) {
55 Button("Cancel Build", role: .destructive) {
56 Task { await model.cancel() }
57 }
58 Button("Keep Running", role: .cancel) {}
59 } message: {
60 Text(
61 "A running step ends within a couple of seconds. If this is a " +
62 "duplicate of a commit that already passed, that earlier result " +
63 "is restored on the commit."
64 )
65 }
3066 }
3167
3268 private func header(_ build: Build) -> some View {
gitbay/Views/Discovery/ProfileView.swift +33
@@ -4,7 +4,10 @@ import SwiftUI
44/// have been doing, and what they own that you can see.
55struct ProfileView: View {
66
7 @Environment(SessionStore.self) private var session
78 @State private var model: ProfileViewModel
9 @State private var creatingOrg = false
10 @State private var newOrgName = ""
811
912 /// Someone else's profile is titled with their name. Your own is
1013 /// reached from a tab and titled to match it.
@@ -18,6 +21,11 @@ struct ProfileView: View {
1821 var body: some View {
1922 List {
2023 if let profile = model.state.value {
24 if let error = model.actionError {
25 Section {
26 GBNotice(error, .gbWarn)
27 }
28 }
2129 header(profile)
2230 peopleSection(profile)
2331 aboutSection(profile)
@@ -35,8 +43,33 @@ struct ProfileView: View {
3543 .overlay { LoadStateOverlay(state: model.state) }
3644 .navigationTitle(title ?? model.name)
3745 .navigationBarTitleDisplayMode(.inline)
46 .toolbar {
47 if let profile = model.state.value,
48 !profile.isOrg, session.current?.username == profile.name {
49 ToolbarItem(placement: .topBarTrailing) {
50 Button {
51 newOrgName = ""
52 creatingOrg = true
53 } label: {
54 Image(systemName: "plus.circle")
55 }
56 .disabled(model.working)
57 .accessibilityIdentifier("org-create-button")
58 }
59 }
60 }
3861 .task { await model.load() }
3962 .refreshable { await model.load() }
63 .alert("New Organization", isPresented: $creatingOrg) {
64 TextField("Name", text: $newOrgName)
65 .autocorrectionDisabled()
66 .textInputAutocapitalization(.never)
67 Button("Create") {
68 Task { await model.createOrg(newOrgName) }
69 }
70 .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
71 Button("Cancel", role: .cancel) {}
72 }
4073 }
4174
4275 // MARK: - Sections
gitbay/Views/Orgs/OrgView.swift +26
@@ -10,6 +10,8 @@ struct OrgView: View {
1010 @State private var newTeam = ""
1111 @State private var removingMember: OrgMember?
1212 @State private var deletingTeam: String?
13 @State private var renamingOrg = false
14 @State private var newOrgName = ""
1315
1416 init(client: GitbayClient, org: String) {
1517 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -30,8 +32,32 @@ struct OrgView: View {
3032 .overlay { LoadStateOverlay(state: model.state) }
3133 .navigationTitle(model.orgName)
3234 .navigationBarTitleDisplayMode(.inline)
35 .toolbar {
36 ToolbarItem(placement: .topBarTrailing) {
37 Button {
38 newOrgName = ""
39 renamingOrg = true
40 } label: {
41 Image(systemName: "pencil")
42 }
43 .disabled(model.working)
44 .accessibilityIdentifier("org-rename-button")
45 }
46 }
3347 .task { await model.load() }
3448 .refreshable { await model.load() }
49 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
50 TextField(model.orgName, text: $newOrgName)
51 .autocorrectionDisabled()
52 .textInputAutocapitalization(.never)
53 Button("Rename") {
54 Task { await model.rename(to: newOrgName) }
55 }
56 .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
57 Button("Cancel", role: .cancel) {}
58 } message: {
59 Text("Clone URLs for every repository in this organization change to the new name.")
60 }
3561 .confirmationDialog(
3662 "Remove \(removingMember?.user ?? "") from \(model.orgName)?",
3763 isPresented: Binding(
gitbay/Views/Releases/ReleaseListView.swift +12 −1
@@ -7,6 +7,7 @@ struct ReleaseListView: View {
77 @State private var draftTag = ""
88 @State private var draftTitle = ""
99 @State private var draftNotes = ""
10 @State private var hasLoadedOnce = false
1011
1112 init(client: GitbayClient, repo: String) {
1213 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
@@ -63,7 +64,17 @@ struct ReleaseListView: View {
6364 composing = false
6465 }
6566 }
66 .task { await model.load() }
67 .task {
68 await model.load()
69 hasLoadedOnce = true
70 }
71 // A deleted release pops back to this list; `.task` does not
72 // re-run on that pop, so a manual refresh here is what drops the
73 // now-gone row. Guarded so the initial `.task` load isn't repeated.
74 .onAppear {
75 guard hasLoadedOnce else { return }
76 Task { await model.load() }
77 }
6778 .refreshable { await model.load() }
6879 }
6980}
gitbay/Views/Releases/ReleaseView.swift +27
@@ -6,6 +6,8 @@ struct ReleaseView: View {
66 @State private var editing = false
77 @State private var draftTitle = ""
88 @State private var draftNotes = ""
9 @State private var confirmingDelete = false
10 @Environment(\.dismiss) private var dismiss
911
1012 init(client: GitbayClient, repo: String, tag: String) {
1113 _model = State(initialValue: ReleaseDetailViewModel(
@@ -89,6 +91,31 @@ struct ReleaseView: View {
8991 .accessibilityIdentifier("release-edit-button")
9092 }
9193 }
94 ToolbarItem(placement: .topBarTrailing) {
95 if model.state.value != nil {
96 Button("Delete", role: .destructive) {
97 confirmingDelete = true
98 }
99 .disabled(model.working)
100 .accessibilityIdentifier("release-delete-button")
101 }
102 }
103 }
104 .confirmationDialog(
105 "Delete \(model.tag)?",
106 isPresented: $confirmingDelete,
107 titleVisibility: .visible
108 ) {
109 Button("Delete", role: .destructive) {
110 Task {
111 if await model.delete() {
112 dismiss()
113 }
114 }
115 }
116 Button("Cancel", role: .cancel) {}
117 } message: {
118 Text("This deletes the release's assets along with it.")
92119 }
93120 .sheet(isPresented: $editing) {
94121 ComposeSheet(
gitbayTests/SmallActionTests.swift added +194
@@ -0,0 +1,194 @@
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 write, then its reload — `build show` and `build log` fire
41 // concurrently, so each needs its own matched stub.
42 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
43 stub.enqueue(.init(status: 200, json: ok))
44 stub.enqueue(.init(status: 200, json: """
45 {"protocol_version":1,"data":{"number":966,"job":"test","status":"cancelled",\
46 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
47 "exit_code":0}
48 """, match: "argv=show"))
49 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
50 await model.cancel()
51
52 let write = try #require(stub.seen.first { $0.method == "POST" })
53 #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"])
54 }
55
56 /// The view reads the instance property, not the static function —
57 /// nothing pinned that it tracks the loaded build, or that it starts
58 /// out false before anything has loaded.
59 @Test func instanceIsCancellableTracksTheLoadedBuild() async throws {
60 let (client, stub) = try makeClient()
61 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
62 #expect(model.isCancellable == false)
63
64 stub.enqueue(.init(status: 200, json: """
65 {"protocol_version":1,"data":{"number":966,"job":"test","status":"running",\
66 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
67 "exit_code":0}
68 """, match: "argv=show"))
69 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
70 await model.load()
71
72 #expect(model.isCancellable)
73 }
74}
75
76@MainActor
77struct ReleaseDeleteTests {
78
79 /// `--yes` is required by the command, not optional.
80 @Test func deleteSendsTheConfirmationFlag() async throws {
81 let (client, stub) = try makeClient()
82 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
83 stub.enqueue(.init(status: 200, json: ok))
84 let deleted = await model.delete()
85
86 #expect(deleted)
87 let write = try #require(stub.seen.first { $0.method == "POST" })
88 #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"])
89 }
90
91 @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws {
92 let (client, stub) = try makeClient()
93 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
94 stub.enqueue(.init(status: 200, json: """
95 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
96 """))
97 let deleted = await model.delete()
98
99 #expect(deleted == false)
100 #expect(model.actionError?.isEmpty == false)
101 }
102
103 /// A stale `actionError` surviving an unrelated later call has been the
104 /// bug five times over; every `perform` clears it up front, but nothing
105 /// pinned that.
106 @Test func actionErrorClearsOnALaterSuccess() async throws {
107 let (client, stub) = try makeClient()
108 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
109 stub.enqueue(.init(status: 200, json: """
110 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
111 """))
112 let firstDelete = await model.delete()
113
114 #expect(firstDelete == false)
115 #expect(model.actionError != nil)
116
117 stub.enqueue(.init(status: 200, json: ok))
118 let secondDelete = await model.delete()
119
120 #expect(secondDelete)
121 #expect(model.actionError == nil)
122 }
123}
124
125@MainActor
126struct OrgCreateRenameTests {
127
128 /// Both names are positional, OLD first. Swapping them renames the
129 /// wrong way with no error at all.
130 ///
131 /// A successful rename triggers `load()`, which fires two concurrent
132 /// GETs (`org show`, `org team list`) alongside the POST — all three
133 /// need a stub, or the reload fails and the model ends up showing an
134 /// error despite the rename having worked.
135 @Test func renameSendsOldThenNewAndReloadsTheNewName() async throws {
136 let (client, stub) = try makeClient()
137 let model = OrgViewModel(client: client, orgName: "krz")
138 stub.enqueue(.init(status: 200, json: ok))
139 stub.enqueue(.init(status: 200, json: """
140 {"protocol_version":1,"data":{"org":"kerouac","members":[]},"exit_code":0}
141 """, match: "argv=show"))
142 stub.enqueue(.init(status: 200, json: """
143 {"protocol_version":1,"data":[],"exit_code":0}
144 """, match: "argv=team"))
145 let renamed = await model.rename(to: "kerouac")
146
147 #expect(renamed)
148 let write = try #require(stub.seen.first { $0.method == "POST" })
149 #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"])
150
151 #expect(model.actionError == nil)
152 guard case .loaded = model.state else {
153 Issue.record("expected .loaded after a successful rename, got \(model.state)")
154 return
155 }
156 let reloads = try stub.seen.filter { $0.method == "GET" }.map { seen in
157 try #require(seen.url.query(percentEncoded: false)).components(separatedBy: "&")
158 }
159 for reload in reloads {
160 #expect(reload.contains("argv=kerouac"),
161 "expected the reload to target the new name, got \(reload)")
162 }
163 }
164
165 @Test func aBlankRenameSendsNothing() async throws {
166 let (client, stub) = try makeClient()
167 let model = OrgViewModel(client: client, orgName: "krz")
168 let renamed = await model.rename(to: " ")
169
170 #expect(renamed == false)
171 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
172 }
173
174 @Test func createSendsTheName() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json: ok))
177 stub.enqueue(.init(status: 200, json: ok))
178 let model = ProfileViewModel(client: client, name: "cmc")
179 let created = await model.createOrg("newco")
180
181 #expect(created)
182 let write = try #require(stub.seen.first { $0.method == "POST" })
183 #expect(try argvOf(write) == ["org", "create", "newco"])
184 }
185
186 @Test func aBlankCreateSendsNothing() async throws {
187 let (client, stub) = try makeClient()
188 let model = ProfileViewModel(client: client, name: "cmc")
189 let created = await model.createOrg(" ")
190
191 #expect(created == false)
192 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
193 }
194}