Commit bebda41def

bebda41def5b4015a5cc17cd6a2290674f4ebfc1

parent: 663cde8b93

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:19 UTC

Delete an organization; prune a merge request's head

The organization screen ends with Delete organization, which asks for
the name to be typed and refuses, in the server's words, while the
organization owns repositories; the account screen's list follows. On
a merged or closed merge request an instance admin gets Prune Head,
which drops its head ref and the objects only it kept after
confirming; the thread then records it.

Closes #47
Closes #48

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +20
@@ -133,6 +133,26 @@ final class MRDetailViewModel {
133 await perform(argv) 133 await perform(argv)
134 } 134 }
135 135
136 /// Whether the head prune is offered: the merge request is merged or
137 /// closed and the account is an instance admin. Asked by the screen,
138 /// not by `load()`, so an open merge request costs no extra read.
139 private(set) var canPruneHead = false
140
141 func checkCanPruneHead() async {
142 guard let mr = state.value, !mr.isOpen else {
143 canPruneHead = false
144 return
145 }
146 canPruneHead = await AdminCheck.isAdmin(client)
147 }
148
149 /// `admin mr prune <repo> <n> --yes`, for instance admins: drops the
150 /// head ref of a merged or closed merge request and the objects only it
151 /// kept. The diff is gone afterwards; the thread records who pruned it.
152 func pruneHead() async {
153 await perform(["admin", "mr", "prune", repoPath, String(number), "--yes"])
154 }
155
136 func cancelQueuedMerge() async { 156 func cancelQueuedMerge() async {
137 await perform(["mr", "merge"] + ref + ["--cancel"]) 157 await perform(["mr", "merge"] + ref + ["--cancel"])
138 } 158 }
gitbay/Orgs/OrgViewModels.swift +23
@@ -94,6 +94,29 @@ final class OrgViewModel {
94 } 94 }
95 } 95 }
96 96
97 /// `org delete <name> --yes`. The screen asks for the name to be typed
98 /// first; --yes is that confirmation. The server refuses an
99 /// organization that still owns repositories, and says so.
100 func delete() async -> Bool {
101 working = true
102 actionError = nil
103 defer { working = false }
104 do {
105 try await client.run(["org", "delete", orgName, "--yes"])
106 } catch GitbayError.notFound {
107 // The client retries a failure once; a retry that finds the
108 // organization gone means the first attempt deleted it.
109 } catch let error as GitbayError {
110 actionError = error.userFacingMessage
111 return false
112 } catch {
113 actionError = GitbayError.transport(error).userFacingMessage
114 return false
115 }
116 NotificationCenter.default.post(name: .gitbayOrganizationsChanged, object: orgName)
117 return true
118 }
119
97 func createTeam(_ team: String) async { 120 func createTeam(_ team: String) async {
98 await perform(["org", "team", "create", orgName, team]) 121 await perform(["org", "team", "create", orgName, team])
99 } 122 }
gitbay/Views/Account/AccountView.swift +5
@@ -65,6 +65,11 @@ struct AccountView: View {
65 .navigationTitle("Account") 65 .navigationTitle("Account")
66 .navigationBarTitleDisplayMode(.inline) 66 .navigationBarTitleDisplayMode(.inline)
67 .task { await model.load() } 67 .task { await model.load() }
68 // An organization deleted on a screen pushed from here leaves this
69 // list until it reloads.
70 .onReceive(NotificationCenter.default.publisher(for: .gitbayOrganizationsChanged)) { _ in
71 Task { await model.load() }
72 }
68 .task { await model.loadNotificationPreferences() } 73 .task { await model.loadNotificationPreferences() }
69 .task { await model.loadEmails() } 74 .task { await model.loadEmails() }
70 .task { model.loadPendingDeletion(username: username) } 75 .task { model.loadPendingDeletion(username: username) }
gitbay/Views/MRs/MRView.swift +29
@@ -8,6 +8,7 @@ struct MRView: View {
8 @State private var confirmingMerge = false 8 @State private var confirmingMerge = false
9 @State private var confirmingQueue = false 9 @State private var confirmingQueue = false
10 @State private var confirmingClose = false 10 @State private var confirmingClose = false
11 @State private var confirmingPrune = false
11 @State private var editing = false 12 @State private var editing = false
12 @State private var draftTitle = "" 13 @State private var draftTitle = ""
13 @State private var draftBody = "" 14 @State private var draftBody = ""
@@ -83,6 +84,7 @@ struct MRView: View {
83 .navigationBarTitleDisplayMode(.inline) 84 .navigationBarTitleDisplayMode(.inline)
84 .toolbar { toolbar } 85 .toolbar { toolbar }
85 .task { await model.load() } 86 .task { await model.load() }
87 .task(id: model.state.value?.state) { await model.checkCanPruneHead() }
86 .refreshable { await model.load() } 88 .refreshable { await model.load() }
87 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { 89 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
88 strategyButtons(whenReady: false) 90 strategyButtons(whenReady: false)
@@ -98,6 +100,16 @@ struct MRView: View {
98 } message: { 100 } message: {
99 Text("The server merges it once every gate passes, and at once if they already do.") 101 Text("The server merges it once every gate passes, and at once if they already do.")
100 } 102 }
103 .confirmationDialog(
104 "Prune the head of !\(model.number)? Its head ref and the commits only it kept are deleted for good, and its diff can no longer be shown. The audit log records it.",
105 isPresented: $confirmingPrune,
106 titleVisibility: .visible
107 ) {
108 Button("Prune Head", role: .destructive) {
109 Task { await model.pruneHead() }
110 }
111 Button("Cancel", role: .cancel) {}
112 }
101 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { 113 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
102 Button("Close", role: .destructive) { Task { await model.close() } } 114 Button("Close", role: .destructive) { Task { await model.close() } }
103 Button("Close in favour of another…") { superseding = true } 115 Button("Close in favour of another…") { superseding = true }
@@ -684,6 +696,23 @@ struct MRView: View {
684 .accessibilityLabel("Actions") 696 .accessibilityLabel("Actions")
685 .disabled(model.working) 697 .disabled(model.working)
686 .accessibilityIdentifier("mr-actions-menu") 698 .accessibilityIdentifier("mr-actions-menu")
699 } else if model.canPruneHead {
700 Menu {
701 Button(role: .destructive) {
702 confirmingPrune = true
703 } label: {
704 Label("Prune Head…", systemImage: "scissors")
705 }
706 } label: {
707 if model.working {
708 ProgressView()
709 } else {
710 Image(systemName: "ellipsis.circle")
711 }
712 }
713 .accessibilityLabel("Actions")
714 .disabled(model.working)
715 .accessibilityIdentifier("mr-admin-menu")
687 } 716 }
688 } 717 }
689 } 718 }
gitbay/Views/Orgs/OrgView.swift +42 −1
@@ -12,6 +12,10 @@ struct OrgView: View {
12 @State private var deletingTeam: String? 12 @State private var deletingTeam: String?
13 @State private var renamingOrg = false 13 @State private var renamingOrg = false
14 @State private var newOrgName = "" 14 @State private var newOrgName = ""
15 @State private var deletingOrg = false
16 @State private var deleteConfirm = ""
17 /// Set once the organization is deleted: nothing on this screen applies.
18 @State private var deleted = false
15 19
16 init(client: GitbayClient, org: String) { 20 init(client: GitbayClient, org: String) {
17 _model = State(initialValue: OrgViewModel(client: client, orgName: org)) 21 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -19,7 +23,13 @@ struct OrgView: View {
19 23
20 var body: some View { 24 var body: some View {
21 List { 25 List {
22 if let loaded = model.state.value { 26 if deleted {
27 Section {
28 Label("\(model.orgName) is deleted", systemImage: "trash")
29 .font(.gbSans(.subheadline))
30 .accessibilityIdentifier("org-deleted")
31 }
32 } else if let loaded = model.state.value {
23 if let error = model.actionError { 33 if let error = model.actionError {
24 Section { 34 Section {
25 GBNotice(error, .gbWarn) 35 GBNotice(error, .gbWarn)
@@ -28,12 +38,14 @@ struct OrgView: View {
28 membersSection(loaded.members) 38 membersSection(loaded.members)
29 teamsSection(loaded.teams) 39 teamsSection(loaded.teams)
30 issuesSection 40 issuesSection
41 deleteSection
31 } 42 }
32 } 43 }
33 .overlay { LoadStateOverlay(state: model.state) } 44 .overlay { LoadStateOverlay(state: model.state) }
34 .navigationTitle(model.orgName) 45 .navigationTitle(model.orgName)
35 .navigationBarTitleDisplayMode(.inline) 46 .navigationBarTitleDisplayMode(.inline)
36 .toolbar { 47 .toolbar {
48 if !deleted {
37 ToolbarItem(placement: .topBarTrailing) { 49 ToolbarItem(placement: .topBarTrailing) {
38 Button { 50 Button {
39 newOrgName = "" 51 newOrgName = ""
@@ -45,9 +57,22 @@ struct OrgView: View {
45 .disabled(model.working) 57 .disabled(model.working)
46 .accessibilityIdentifier("org-rename-button") 58 .accessibilityIdentifier("org-rename-button")
47 } 59 }
60 }
48 } 61 }
49 .task { await model.load() } 62 .task { await model.load() }
50 .refreshable { await model.load() } 63 .refreshable { await model.load() }
64 .alert("Delete \(model.orgName)", isPresented: $deletingOrg) {
65 TextField("type \(model.orgName) to confirm", text: $deleteConfirm)
66 .autocorrectionDisabled()
67 .textInputAutocapitalization(.never)
68 Button("Delete", role: .destructive) {
69 Task { deleted = await model.delete() }
70 }
71 .disabled(deleteConfirm != model.orgName)
72 Button("Cancel", role: .cancel) {}
73 } message: {
74 Text("This deletes the organization with its teams, labels and milestones, and cannot be undone. It must own no repositories. Type \(model.orgName) to confirm.")
75 }
51 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) { 76 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
52 TextField(model.orgName, text: $newOrgName) 77 TextField(model.orgName, text: $newOrgName)
53 .autocorrectionDisabled() 78 .autocorrectionDisabled()
@@ -141,6 +166,22 @@ struct OrgView: View {
141 166
142 /// Labels and milestones every repository under the org sees 167 /// Labels and milestones every repository under the org sees
143 /// (v1.19.0), managed here with `org label` and `org milestone`. 168 /// (v1.19.0), managed here with `org label` and `org milestone`.
169 private var deleteSection: some View {
170 Section {
171 Button(role: .destructive) {
172 deleteConfirm = ""
173 deletingOrg = true
174 } label: {
175 Label("Delete organization", systemImage: "trash")
176 .font(.gbSans(.subheadline))
177 }
178 .disabled(model.working)
179 .accessibilityIdentifier("org-delete-button")
180 } footer: {
181 Text("Only an organization that owns no repositories can be deleted.")
182 }
183 }
184
144 private var issuesSection: some View { 185 private var issuesSection: some View {
145 Section { 186 Section {
146 NavigationLink(value: OrgRoute.labels(model.orgName)) { 187 NavigationLink(value: OrgRoute.labels(model.orgName)) {
gitbay/Views/Shared/ChangeNotices.swift +2
@@ -8,6 +8,8 @@ extension Notification.Name {
8 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged") 8 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged")
9 /// A saved query was saved, pinned, unpinned or removed. 9 /// A saved query was saved, pinned, unpinned or removed.
10 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged") 10 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged")
11 /// An organization was deleted; `object` is its name.
12 static let gitbayOrganizationsChanged = Notification.Name("gitbayOrganizationsChanged")
11 /// An instance admin created or deleted an account. 13 /// An instance admin created or deleted an account.
12 static let gitbayAccountsChanged = Notification.Name("gitbayAccountsChanged") 14 static let gitbayAccountsChanged = Notification.Name("gitbayAccountsChanged")
13} 15}
gitbayTests/OrgDeleteMRPruneTests.swift added +95
@@ -0,0 +1,95 @@
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 writes(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map {
17 let body = try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22private let mergedJSON = """
23 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
24 "state":"merged","author":"cmc","source":"client-envelope","target_ref":"main",\
25 "head_sha":"aabbcc","created_at":"2026-08-20T10:00:00.000Z",\
26 "merged_at":"2026-08-21T09:15:00.000Z","merged_by":"cmc"},"exit_code":0}
27 """
28
29@MainActor
30struct OrgDeleteTests {
31
32 @Test func deleteConfirmsAndTellsOtherScreens() async throws {
33 let (client, stub) = try makeClient()
34 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"acme"},"exit_code":0}"#, match: "cmd"))
35 let model = OrgViewModel(client: client, orgName: "acme")
36 var noticed: String?
37 let token = NotificationCenter.default.addObserver(forName: .gitbayOrganizationsChanged, object: nil, queue: nil) {
38 noticed = $0.object as? String
39 }
40 defer { NotificationCenter.default.removeObserver(token) }
41
42 #expect(await model.delete())
43
44 #expect(try writes(stub) == [["org", "delete", "acme", "--yes"]])
45 #expect(noticed == "acme")
46 }
47
48 @Test func aNonEmptyOrganizationIsRefusedWithTheReason() async throws {
49 let (client, stub) = try makeClient()
50 let refusal = #"{"protocol_version":1,"error":"the organization still owns 2 repositories; delete or transfer them first","exit_code":1}"#
51 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
52 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
53 let model = OrgViewModel(client: client, orgName: "acme")
54
55 #expect(await model.delete() == false)
56 #expect(model.actionError == "the organization still owns 2 repositories; delete or transfer them first")
57 }
58
59 @Test func aRetryThatFindsItGoneIsDone() async throws {
60 let (client, stub) = try makeClient()
61 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"database is locked","exit_code":1}"#, match: "cmd"))
62 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no organization acme","exit_code":3}"#, match: "cmd"))
63 let model = OrgViewModel(client: client, orgName: "acme")
64
65 #expect(await model.delete())
66 }
67}
68
69@MainActor
70struct MRPruneTests {
71
72 @Test func pruneNamesTheMergeRequestAndConfirms() async throws {
73 let (client, stub) = try makeClient()
74 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"))
76 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
77
78 await model.pruneHead()
79
80 #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]])
81 #expect(model.actionError == nil)
82 }
83
84 @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws {
85 let (client, stub) = try makeClient()
86 let refusal = #"{"protocol_version":1,"error":"!7 is still mergeable and its head is what makes it so; merge or close it first","exit_code":1}"#
87 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
88 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
89 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
90
91 await model.pruneHead()
92
93 #expect(model.actionError == "!7 is still mergeable and its head is what makes it so; merge or close it first")
94 }
95}