Commit bebda41def
Verified · cmc
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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | |||
| 15 | private 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 | |||
| 22 | private 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 | ||
| 30 | struct 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 | ||
| 70 | struct 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 | } | ||