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 /// kept. The diff is gone afterwards; the thread records who pruned it. 151 /// kept. The diff is gone afterwards; the thread records who pruned it.
152 func pruneHead() async { 152 func pruneHead() async {
153 await perform(["admin", "mr", "prune", repoPath, String(number), "--yes"]) 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 func cancelQueuedMerge() async { 158 func cancelQueuedMerge() async {
gitbay/Views/Discovery/ProfileView.swift +4
@@ -80,6 +80,10 @@ struct ProfileView: View {
80 } 80 }
81 .task { await model.load() } 81 .task { await model.load() }
82 .refreshable { await model.load() } 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 .sheet(isPresented: $editingProfile) { 87 .sheet(isPresented: $editingProfile) {
84 if let profile = model.state.value { 88 if let profile = model.state.value {
85 ProfileEditSheet(model: model, profile: profile) 89 ProfileEditSheet(model: model, profile: profile)
gitbay/Views/Orgs/OrgView.swift +17 −4
@@ -16,6 +16,9 @@ struct OrgView: View {
16 @State private var deleteConfirm = "" 16 @State private var deleteConfirm = ""
17 /// Set once the organization is deleted: nothing on this screen applies. 17 /// Set once the organization is deleted: nothing on this screen applies.
18 @State private var deleted = false 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 init(client: GitbayClient, org: String) { 23 init(client: GitbayClient, org: String) {
21 _model = State(initialValue: OrgViewModel(client: client, orgName: org)) 24 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -60,13 +63,23 @@ struct OrgView: View {
60 } 63 }
61 } 64 }
62 .task { await model.load() } 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 .alert("Delete \(model.orgName)", isPresented: $deletingOrg) { 75 .alert("Delete \(model.orgName)", isPresented: $deletingOrg) {
65 TextField("type \(model.orgName) to confirm", text: $deleteConfirm) 76 TextField("type \(model.orgName) to confirm", text: $deleteConfirm)
66 .autocorrectionDisabled() 77 .autocorrectionDisabled()
67 .textInputAutocapitalization(.never) 78 .textInputAutocapitalization(.never)
68 Button("Delete", role: .destructive) { 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 .disabled(deleteConfirm != model.orgName) 84 .disabled(deleteConfirm != model.orgName)
72 Button("Cancel", role: .cancel) {} 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 private var deleteSection: some View { 180 private var deleteSection: some View {
170 Section { 181 Section {
171 Button(role: .destructive) { 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 private var issuesSection: some View { 198 private var issuesSection: some View {
186 Section { 199 Section {
187 NavigationLink(value: OrgRoute.labels(model.orgName)) { 200 NavigationLink(value: OrgRoute.labels(model.orgName)) {
gitbayTests/OrgDeleteMRPruneTests.swift +6
@@ -71,14 +71,20 @@ struct MRPruneTests {
71 71
72 @Test func pruneNamesTheMergeRequestAndConfirms() async throws { 72 @Test func pruneNamesTheMergeRequestAndConfirms() async throws {
73 let (client, stub) = try makeClient() 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 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[{"number":7,"head":"abc123"}],"exit_code":0}"#, match: "cmd")) 76 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[{"number":7,"head":"abc123"}],"exit_code":0}"#, match: "cmd"))
75 stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show")) 77 stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show"))
76 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) 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 await model.pruneHead() 83 await model.pruneHead()
79 84
80 #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]]) 85 #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]])
81 #expect(model.actionError == nil) 86 #expect(model.actionError == nil)
87 #expect(model.canPruneHead == false, "a pruned head is not offered again")
82 } 88 }
83 89
84 @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws { 90 @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws {