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

merged merged by cmc on 2026-09-07 00:42 UTC · krz/gitbay-ios:small-actions into main

11 files changed, +698 −2

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}