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 {
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 // Done: another prune would only add another system comment.
155 if actionError == nil { canPruneHead = false }
156 }
157
136 func cancelQueuedMerge() async { 158 func cancelQueuedMerge() async {
137 await perform(["mr", "merge"] + ref + ["--cancel"]) 159 await perform(["mr", "merge"] + ref + ["--cancel"])
138 } 160 }
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/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/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 +56 −2
@@ -12,6 +12,13 @@ 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
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?
15 22
16 init(client: GitbayClient, org: String) { 23 init(client: GitbayClient, org: String) {
17 _model = State(initialValue: OrgViewModel(client: client, orgName: org)) 24 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
@@ -19,7 +26,13 @@ struct OrgView: View {
19 26
20 var body: some View { 27 var body: some View {
21 List { 28 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 {
23 if let error = model.actionError { 36 if let error = model.actionError {
24 Section { 37 Section {
25 GBNotice(error, .gbWarn) 38 GBNotice(error, .gbWarn)
@@ -28,12 +41,14 @@ struct OrgView: View {
28 membersSection(loaded.members) 41 membersSection(loaded.members)
29 teamsSection(loaded.teams) 42 teamsSection(loaded.teams)
30 issuesSection 43 issuesSection
44 deleteSection
31 } 45 }
32 } 46 }
33 .overlay { LoadStateOverlay(state: model.state) } 47 .overlay { LoadStateOverlay(state: model.state) }
34 .navigationTitle(model.orgName) 48 .navigationTitle(model.orgName)
35 .navigationBarTitleDisplayMode(.inline) 49 .navigationBarTitleDisplayMode(.inline)
36 .toolbar { 50 .toolbar {
51 if !deleted {
37 ToolbarItem(placement: .topBarTrailing) { 52 ToolbarItem(placement: .topBarTrailing) {
38 Button { 53 Button {
39 newOrgName = "" 54 newOrgName = ""
@@ -45,9 +60,32 @@ struct OrgView: View {
45 .disabled(model.working) 60 .disabled(model.working)
46 .accessibilityIdentifier("org-rename-button") 61 .accessibilityIdentifier("org-rename-button")
47 } 62 }
63 }
48 } 64 }
49 .task { await model.load() } 65 .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 }
51 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) { 89 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
52 TextField(model.orgName, text: $newOrgName) 90 TextField(model.orgName, text: $newOrgName)
53 .autocorrectionDisabled() 91 .autocorrectionDisabled()
@@ -139,6 +177,22 @@ struct OrgView: View {
139 } 177 }
140 } 178 }
141 179
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
142 /// Labels and milestones every repository under the org sees 196 /// Labels and milestones every repository under the org sees
143 /// (v1.19.0), managed here with `org label` and `org milestone`. 197 /// (v1.19.0), managed here with `org label` and `org milestone`.
144 private var issuesSection: some View { 198 private var issuesSection: some View {
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 +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}