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 {
133133 await perform(argv)
134134 }
135135
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
136156 func cancelQueuedMerge() async {
137157 await perform(["mr", "merge"] + ref + ["--cancel"])
138158 }
gitbay/Orgs/OrgViewModels.swift +23
@@ -94,6 +94,29 @@ final class OrgViewModel {
9494 }
9595 }
9696
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
97120 func createTeam(_ team: String) async {
98121 await perform(["org", "team", "create", orgName, team])
99122 }
gitbay/Views/Account/AccountView.swift +5
@@ -65,6 +65,11 @@ struct AccountView: View {
6565 .navigationTitle("Account")
6666 .navigationBarTitleDisplayMode(.inline)
6767 .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 }
6873 .task { await model.loadNotificationPreferences() }
6974 .task { await model.loadEmails() }
7075 .task { model.loadPendingDeletion(username: username) }
gitbay/Views/MRs/MRView.swift +29
@@ -8,6 +8,7 @@ struct MRView: View {
88 @State private var confirmingMerge = false
99 @State private var confirmingQueue = false
1010 @State private var confirmingClose = false
11 @State private var confirmingPrune = false
1112 @State private var editing = false
1213 @State private var draftTitle = ""
1314 @State private var draftBody = ""
@@ -83,6 +84,7 @@ struct MRView: View {
8384 .navigationBarTitleDisplayMode(.inline)
8485 .toolbar { toolbar }
8586 .task { await model.load() }
87 .task(id: model.state.value?.state) { await model.checkCanPruneHead() }
8688 .refreshable { await model.load() }
8789 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
8890 strategyButtons(whenReady: false)
@@ -98,6 +100,16 @@ struct MRView: View {
98100 } message: {
99101 Text("The server merges it once every gate passes, and at once if they already do.")
100102 }
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 }
101113 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
102114 Button("Close", role: .destructive) { Task { await model.close() } }
103115 Button("Close in favour of another…") { superseding = true }
@@ -684,6 +696,23 @@ struct MRView: View {
684696 .accessibilityLabel("Actions")
685697 .disabled(model.working)
686698 .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")
687716 }
688717 }
689718 }
gitbay/Views/Orgs/OrgView.swift +42 −1
@@ -12,6 +12,10 @@ struct OrgView: View {
1212 @State private var deletingTeam: String?
1313 @State private var renamingOrg = false
1414 @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
1519
1620 init(client: GitbayClient, org: String) {
1721 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -19,7 +23,13 @@ struct OrgView: View {
1923
2024 var body: some View {
2125 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 {
2333 if let error = model.actionError {
2434 Section {
2535 GBNotice(error, .gbWarn)
@@ -28,12 +38,14 @@ struct OrgView: View {
2838 membersSection(loaded.members)
2939 teamsSection(loaded.teams)
3040 issuesSection
41 deleteSection
3142 }
3243 }
3344 .overlay { LoadStateOverlay(state: model.state) }
3445 .navigationTitle(model.orgName)
3546 .navigationBarTitleDisplayMode(.inline)
3647 .toolbar {
48 if !deleted {
3749 ToolbarItem(placement: .topBarTrailing) {
3850 Button {
3951 newOrgName = ""
@@ -45,9 +57,22 @@ struct OrgView: View {
4557 .disabled(model.working)
4658 .accessibilityIdentifier("org-rename-button")
4759 }
60 }
4861 }
4962 .task { await model.load() }
5063 .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 }
5176 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
5277 TextField(model.orgName, text: $newOrgName)
5378 .autocorrectionDisabled()
@@ -141,6 +166,22 @@ struct OrgView: View {
141166
142167 /// Labels and milestones every repository under the org sees
143168 /// (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
144185 private var issuesSection: some View {
145186 Section {
146187 NavigationLink(value: OrgRoute.labels(model.orgName)) {
gitbay/Views/Shared/ChangeNotices.swift +2
@@ -8,6 +8,8 @@ extension Notification.Name {
88 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged")
99 /// A saved query was saved, pinned, unpinned or removed.
1010 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged")
11 /// An organization was deleted; `object` is its name.
12 static let gitbayOrganizationsChanged = Notification.Name("gitbayOrganizationsChanged")
1113 /// An instance admin created or deleted an account.
1214 static let gitbayAccountsChanged = Notification.Name("gitbayAccountsChanged")
1315}
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}