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 {
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 +28 −1
@@ -31,7 +31,7 @@ final class OrgViewModel {
31 private(set) var working = false 31 private(set) var working = false
32 32
33 private let client: GitbayClient 33 private let client: GitbayClient
34 let orgName: String 34 private(set) var orgName: String
35 35
36 init(client: GitbayClient, orgName: String) { 36 init(client: GitbayClient, orgName: String) {
37 self.client = client 37 self.client = client
@@ -67,6 +67,33 @@ 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 ///
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
70 func createTeam(_ team: String) async { 97 func createTeam(_ team: String) async {
71 await perform(["org", "team", "create", orgName, team]) 98 await perform(["org", "team", "create", orgName, team])
72 } 99 }
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 {
gitbay/Views/Builds/BuildDetailView.swift +36
@@ -4,6 +4,7 @@ import SwiftUI
4struct BuildDetailView: View { 4struct BuildDetailView: View {
5 5
6 @State private var model: BuildDetailViewModel 6 @State private var model: BuildDetailViewModel
7 @State private var confirmingCancel = false
7 8
8 init(client: GitbayClient, repo: String, number: Int64) { 9 init(client: GitbayClient, repo: String, number: Int64) {
9 _model = State(initialValue: BuildDetailViewModel( 10 _model = State(initialValue: BuildDetailViewModel(
@@ -16,6 +17,11 @@ struct BuildDetailView: View {
16 Color.clear 17 Color.clear
17 if let detail = model.state.value { 18 if let detail = model.state.value {
18 VStack(spacing: 0) { 19 VStack(spacing: 0) {
20 if let error = model.actionError {
21 GBNotice(error, .gbWarn)
22 .padding(.horizontal, 16)
23 .padding(.vertical, 8)
24 }
19 header(detail.build) 25 header(detail.build)
20 Divider() 26 Divider()
21 log(detail.log) 27 log(detail.log)
@@ -25,8 +31,38 @@ struct BuildDetailView: View {
25 .overlay { LoadStateOverlay(state: model.state) } 31 .overlay { LoadStateOverlay(state: model.state) }
26 .navigationTitle("Build #\(model.number)") 32 .navigationTitle("Build #\(model.number)")
27 .navigationBarTitleDisplayMode(.inline) 33 .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 }
28 .task { await model.load() } 48 .task { await model.load() }
29 .refreshable { await model.load() } 49 .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 }
30 } 66 }
31 67
32 private func header(_ build: Build) -> some View { 68 private func header(_ build: Build) -> some View {
gitbay/Views/Discovery/ProfileView.swift +33
@@ -4,7 +4,10 @@ import SwiftUI
4/// have been doing, and what they own that you can see. 4/// have been doing, and what they own that you can see.
5struct ProfileView: View { 5struct ProfileView: View {
6 6
7 @Environment(SessionStore.self) private var session
7 @State private var model: ProfileViewModel 8 @State private var model: ProfileViewModel
9 @State private var creatingOrg = false
10 @State private var newOrgName = ""
8 11
9 /// Someone else's profile is titled with their name. Your own is 12 /// Someone else's profile is titled with their name. Your own is
10 /// reached from a tab and titled to match it. 13 /// reached from a tab and titled to match it.
@@ -18,6 +21,11 @@ struct ProfileView: View {
18 var body: some View { 21 var body: some View {
19 List { 22 List {
20 if let profile = model.state.value { 23 if let profile = model.state.value {
24 if let error = model.actionError {
25 Section {
26 GBNotice(error, .gbWarn)
27 }
28 }
21 header(profile) 29 header(profile)
22 peopleSection(profile) 30 peopleSection(profile)
23 aboutSection(profile) 31 aboutSection(profile)
@@ -35,8 +43,33 @@ struct ProfileView: View {
35 .overlay { LoadStateOverlay(state: model.state) } 43 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle(title ?? model.name) 44 .navigationTitle(title ?? model.name)
37 .navigationBarTitleDisplayMode(.inline) 45 .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 }
38 .task { await model.load() } 61 .task { await model.load() }
39 .refreshable { await model.load() } 62 .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 }
40 } 73 }
41 74
42 // MARK: - Sections 75 // MARK: - Sections
gitbay/Views/Orgs/OrgView.swift +26
@@ -10,6 +10,8 @@ struct OrgView: View {
10 @State private var newTeam = "" 10 @State private var newTeam = ""
11 @State private var removingMember: OrgMember? 11 @State private var removingMember: OrgMember?
12 @State private var deletingTeam: String? 12 @State private var deletingTeam: String?
13 @State private var renamingOrg = false
14 @State private var newOrgName = ""
13 15
14 init(client: GitbayClient, org: String) { 16 init(client: GitbayClient, org: String) {
15 _model = State(initialValue: OrgViewModel(client: client, orgName: org)) 17 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -30,8 +32,32 @@ struct OrgView: View {
30 .overlay { LoadStateOverlay(state: model.state) } 32 .overlay { LoadStateOverlay(state: model.state) }
31 .navigationTitle(model.orgName) 33 .navigationTitle(model.orgName)
32 .navigationBarTitleDisplayMode(.inline) 34 .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 }
33 .task { await model.load() } 47 .task { await model.load() }
34 .refreshable { await model.load() } 48 .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 }
35 .confirmationDialog( 61 .confirmationDialog(
36 "Remove \(removingMember?.user ?? "") from \(model.orgName)?", 62 "Remove \(removingMember?.user ?? "") from \(model.orgName)?",
37 isPresented: Binding( 63 isPresented: Binding(
gitbay/Views/Releases/ReleaseListView.swift +12 −1
@@ -7,6 +7,7 @@ struct ReleaseListView: View {
7 @State private var draftTag = "" 7 @State private var draftTag = ""
8 @State private var draftTitle = "" 8 @State private var draftTitle = ""
9 @State private var draftNotes = "" 9 @State private var draftNotes = ""
10 @State private var hasLoadedOnce = false
10 11
11 init(client: GitbayClient, repo: String) { 12 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo)) 13 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
@@ -63,7 +64,17 @@ struct ReleaseListView: View {
63 composing = false 64 composing = false
64 } 65 }
65 } 66 }
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 }
67 .refreshable { await model.load() } 78 .refreshable { await model.load() }
68 } 79 }
69} 80}
gitbay/Views/Releases/ReleaseView.swift +27
@@ -6,6 +6,8 @@ struct ReleaseView: View {
6 @State private var editing = false 6 @State private var editing = false
7 @State private var draftTitle = "" 7 @State private var draftTitle = ""
8 @State private var draftNotes = "" 8 @State private var draftNotes = ""
9 @State private var confirmingDelete = false
10 @Environment(\.dismiss) private var dismiss
9 11
10 init(client: GitbayClient, repo: String, tag: String) { 12 init(client: GitbayClient, repo: String, tag: String) {
11 _model = State(initialValue: ReleaseDetailViewModel( 13 _model = State(initialValue: ReleaseDetailViewModel(
@@ -89,6 +91,31 @@ struct ReleaseView: View {
89 .accessibilityIdentifier("release-edit-button") 91 .accessibilityIdentifier("release-edit-button")
90 } 92 }
91 } 93 }
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.")
92 } 119 }
93 .sheet(isPresented: $editing) { 120 .sheet(isPresented: $editing) {
94 ComposeSheet( 121 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}