Commit 250c56ddb3
250c56ddb3c79770a96c3ea23dfa9679807ce98a
parent: bebda41def
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-16 03:33 UTC
Org delete and prune: a refusal in an alert, the profile follows, a pruned head is not offered again
A refused organization delete is shown as an alert where the button is, as repository delete does; the org's profile reloads after a delete and a deleted org's screen does not reload. After a prune the menu no longer offers it.
Ref #47
Ref #48
Layout: unified · split
gitbay/MRs/MRDetailViewModel.swift
+2
| @@ -151,6 +151,8 @@ final class MRDetailViewModel { |
| 151 | 151 | /// kept. The diff is gone afterwards; the thread records who pruned it. |
| 152 | 152 | func pruneHead() async { |
| 153 | 153 | await perform(["admin", "mr", "prune", repoPath, String(number), "--yes"]) |
| 154 | // Done: another prune would only add another system comment. |
| 155 | if actionError == nil { canPruneHead = false } |
| 154 | 156 | } |
| 155 | 157 | |
| 156 | 158 | func cancelQueuedMerge() async { |
gitbay/Views/Discovery/ProfileView.swift
+4
| @@ -80,6 +80,10 @@ struct ProfileView: View { |
| 80 | 80 | } |
| 81 | 81 | .task { await model.load() } |
| 82 | 82 | .refreshable { await model.load() } |
| 83 | // The organization screen, pushed from an org's profile, may delete it. |
| 84 | .onReceive(NotificationCenter.default.publisher(for: .gitbayOrganizationsChanged)) { note in |
| 85 | if note.object as? String == model.name { Task { await model.load() } } |
| 86 | } |
| 83 | 87 | .sheet(isPresented: $editingProfile) { |
| 84 | 88 | if let profile = model.state.value { |
| 85 | 89 | ProfileEditSheet(model: model, profile: profile) |
gitbay/Views/Orgs/OrgView.swift
+17 −4
| @@ -16,6 +16,9 @@ struct OrgView: View { |
| 16 | 16 | @State private var deleteConfirm = "" |
| 17 | 17 | /// Set once the organization is deleted: nothing on this screen applies. |
| 18 | 18 | @State private var deleted = false |
| 19 | /// A delete refused, shown where the button is rather than in the |
| 20 | /// banner at the top of a list scrolled past it. |
| 21 | @State private var refusal: String? |
| 19 | 22 | |
| 20 | 23 | init(client: GitbayClient, org: String) { |
| 21 | 24 | _model = State(initialValue: OrgViewModel(client: client, orgName: org)) |
| @@ -60,13 +63,23 @@ struct OrgView: View { |
| 60 | 63 | } |
| 61 | 64 | } |
| 62 | 65 | .task { await model.load() } |
| 63 | | .refreshable { await model.load() } |
| 66 | .refreshable { |
| 67 | guard !deleted else { return } |
| 68 | await model.load() |
| 69 | } |
| 70 | .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) { |
| 71 | Button("OK", role: .cancel) {} |
| 72 | } message: { |
| 73 | Text(refusal ?? "") |
| 74 | } |
| 64 | 75 | .alert("Delete \(model.orgName)", isPresented: $deletingOrg) { |
| 65 | 76 | TextField("type \(model.orgName) to confirm", text: $deleteConfirm) |
| 66 | 77 | .autocorrectionDisabled() |
| 67 | 78 | .textInputAutocapitalization(.never) |
| 68 | 79 | Button("Delete", role: .destructive) { |
| 69 | | Task { deleted = await model.delete() } |
| 80 | Task { |
| 81 | if await model.delete() { deleted = true } else { refusal = model.actionError } |
| 82 | } |
| 70 | 83 | } |
| 71 | 84 | .disabled(deleteConfirm != model.orgName) |
| 72 | 85 | Button("Cancel", role: .cancel) {} |
| @@ -164,8 +177,6 @@ struct OrgView: View { |
| 164 | 177 | } |
| 165 | 178 | } |
| 166 | 179 | |
| 167 | | /// Labels and milestones every repository under the org sees |
| 168 | | /// (v1.19.0), managed here with `org label` and `org milestone`. |
| 169 | 180 | private var deleteSection: some View { |
| 170 | 181 | Section { |
| 171 | 182 | Button(role: .destructive) { |
| @@ -182,6 +193,8 @@ struct OrgView: View { |
| 182 | 193 | } |
| 183 | 194 | } |
| 184 | 195 | |
| 196 | /// Labels and milestones every repository under the org sees |
| 197 | /// (v1.19.0), managed here with `org label` and `org milestone`. |
| 185 | 198 | private var issuesSection: some View { |
| 186 | 199 | Section { |
| 187 | 200 | NavigationLink(value: OrgRoute.labels(model.orgName)) { |
gitbayTests/OrgDeleteMRPruneTests.swift
+6
| @@ -71,14 +71,20 @@ struct MRPruneTests { |
| 71 | 71 | |
| 72 | 72 | @Test func pruneNamesTheMergeRequestAndConfirms() async throws { |
| 73 | 73 | let (client, stub) = try makeClient() |
| 74 | stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show")) |
| 75 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"cmc","admin":true},"exit_code":0}"#, match: "argv=whoami")) |
| 74 | 76 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[{"number":7,"head":"abc123"}],"exit_code":0}"#, match: "cmd")) |
| 75 | 77 | stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show")) |
| 76 | 78 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) |
| 79 | await model.load() |
| 80 | await model.checkCanPruneHead() |
| 81 | #expect(model.canPruneHead, "an admin on a merged merge request is offered the prune") |
| 77 | 82 | |
| 78 | 83 | await model.pruneHead() |
| 79 | 84 | |
| 80 | 85 | #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]]) |
| 81 | 86 | #expect(model.actionError == nil) |
| 87 | #expect(model.canPruneHead == false, "a pruned head is not offered again") |
| 82 | 88 | } |
| 83 | 89 | |
| 84 | 90 | @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws { |