Delete an organization; prune a merge request's head !185

merged merged by cmc on 2026-10-16 03:33 UTC · krz/gitbay-ios:org-delete-mr-prune into main

8 files changed, +242 −2

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +22
@@ -133,6 +133,28 @@ 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 // Done: another prune would only add another system comment.
155 if actionError == nil { canPruneHead = false }
156 }
157
136158 func cancelQueuedMerge() async {
137159 await perform(["mr", "merge"] + ref + ["--cancel"])
138160 }
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/Discovery/ProfileView.swift +4
@@ -80,6 +80,10 @@ struct ProfileView: View {
8080 }
8181 .task { await model.load() }
8282 .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 }
8387 .sheet(isPresented: $editingProfile) {
8488 if let profile = model.state.value {
8589 ProfileEditSheet(model: model, profile: profile)
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 +56 −2
@@ -12,6 +12,13 @@ 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
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?
1522
1623 init(client: GitbayClient, org: String) {
1724 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -19,7 +26,13 @@ struct OrgView: View {
1926
2027 var body: some View {
2128 List {
22 if let loaded = model.state.value {
29 if deleted {
30 Section {
31 Label("\(model.orgName) is deleted", systemImage: "trash")
32 .font(.gbSans(.subheadline))
33 .accessibilityIdentifier("org-deleted")
34 }
35 } else if let loaded = model.state.value {
2336 if let error = model.actionError {
2437 Section {
2538 GBNotice(error, .gbWarn)
@@ -28,12 +41,14 @@ struct OrgView: View {
2841 membersSection(loaded.members)
2942 teamsSection(loaded.teams)
3043 issuesSection
44 deleteSection
3145 }
3246 }
3347 .overlay { LoadStateOverlay(state: model.state) }
3448 .navigationTitle(model.orgName)
3549 .navigationBarTitleDisplayMode(.inline)
3650 .toolbar {
51 if !deleted {
3752 ToolbarItem(placement: .topBarTrailing) {
3853 Button {
3954 newOrgName = ""
@@ -45,9 +60,32 @@ struct OrgView: View {
4560 .disabled(model.working)
4661 .accessibilityIdentifier("org-rename-button")
4762 }
63 }
4864 }
4965 .task { await model.load() }
50 .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 }
75 .alert("Delete \(model.orgName)", isPresented: $deletingOrg) {
76 TextField("type \(model.orgName) to confirm", text: $deleteConfirm)
77 .autocorrectionDisabled()
78 .textInputAutocapitalization(.never)
79 Button("Delete", role: .destructive) {
80 Task {
81 if await model.delete() { deleted = true } else { refusal = model.actionError }
82 }
83 }
84 .disabled(deleteConfirm != model.orgName)
85 Button("Cancel", role: .cancel) {}
86 } message: {
87 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.")
88 }
5189 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
5290 TextField(model.orgName, text: $newOrgName)
5391 .autocorrectionDisabled()
@@ -139,6 +177,22 @@ struct OrgView: View {
139177 }
140178 }
141179
180 private var deleteSection: some View {
181 Section {
182 Button(role: .destructive) {
183 deleteConfirm = ""
184 deletingOrg = true
185 } label: {
186 Label("Delete organization", systemImage: "trash")
187 .font(.gbSans(.subheadline))
188 }
189 .disabled(model.working)
190 .accessibilityIdentifier("org-delete-button")
191 } footer: {
192 Text("Only an organization that owns no repositories can be deleted.")
193 }
194 }
195
142196 /// Labels and milestones every repository under the org sees
143197 /// (v1.19.0), managed here with `org label` and `org milestone`.
144198 private var issuesSection: some View {
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 +101
@@ -0,0 +1,101 @@
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: 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"))
76 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[{"number":7,"head":"abc123"}],"exit_code":0}"#, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show"))
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")
82
83 await model.pruneHead()
84
85 #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]])
86 #expect(model.actionError == nil)
87 #expect(model.canPruneHead == false, "a pruned head is not offered again")
88 }
89
90 @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws {
91 let (client, stub) = try makeClient()
92 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}"#
93 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
94 stub.enqueue(.init(status: 500, json: refusal, match: "cmd"))
95 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
96
97 await model.pruneHead()
98
99 #expect(model.actionError == "!7 is still mergeable and its head is what makes it so; merge or close it first")
100 }
101}