Admin: repositories, symbol reindex and organization limits !175
7 files changed, +508 −0
Layout: unified · split
gitbay/Admin/AdminRepos.swift added +155
| @@ -0,0 +1,155 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// One row of `admin repo list`: every repository, with size and last push. | ||
| 5 | nonisolated struct AdminRepo: Decodable, Sendable, Hashable, Identifiable { | ||
| 6 | let path: String | ||
| 7 | let visibility: String | ||
| 8 | let archived: Bool? | ||
| 9 | let createdAt: Date | ||
| 10 | let lastPush: Date? | ||
| 11 | let bytes: Int64 | ||
| 12 | var id: String { path } | ||
| 13 | var isArchived: Bool { archived ?? false } | ||
| 14 | enum CodingKeys: String, CodingKey { | ||
| 15 | case path, visibility, archived, bytes | ||
| 16 | case createdAt = "created_at" | ||
| 17 | case lastPush = "last_push" | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | /// The writes an instance admin makes on any repository, each audited. | ||
| 22 | nonisolated enum AdminRepoAction: Sendable, Hashable { | ||
| 23 | case archive, unarchive, makePublic, makePrivate, reindex, delete | ||
| 24 | |||
| 25 | func argv(_ path: String) -> [String] { | ||
| 26 | switch self { | ||
| 27 | case .archive: ["admin", "repo", "archive", path] | ||
| 28 | case .unarchive: ["admin", "repo", "unarchive", path] | ||
| 29 | case .makePublic: ["admin", "repo", "visibility", path, "public"] | ||
| 30 | case .makePrivate: ["admin", "repo", "visibility", path, "private"] | ||
| 31 | case .reindex: ["admin", "symbols", "reindex", path] | ||
| 32 | case .delete: ["admin", "repo", "delete", path, "--yes"] | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | /// `admin repo list`, narrowed by owner and visibility, and the per-row writes. | ||
| 38 | @Observable | ||
| 39 | @MainActor | ||
| 40 | final class AdminReposViewModel { | ||
| 41 | |||
| 42 | let list: PagedListModel<AdminRepo> | ||
| 43 | var owner = "" | ||
| 44 | /// nil lists both. | ||
| 45 | var visibility: String? | ||
| 46 | private(set) var actionError: String? | ||
| 47 | private(set) var notice: String? | ||
| 48 | private(set) var working = false | ||
| 49 | |||
| 50 | private let client: GitbayClient | ||
| 51 | |||
| 52 | init(client: GitbayClient) { | ||
| 53 | self.client = client | ||
| 54 | list = PagedListModel(client: client, argv: ["admin", "repo", "list"], emptyMessage: "No repository matches.") | ||
| 55 | } | ||
| 56 | |||
| 57 | var argv: [String] { | ||
| 58 | var argv = ["admin", "repo", "list"] | ||
| 59 | let owner = owner.trimmingCharacters(in: .whitespaces) | ||
| 60 | if !owner.isEmpty { argv += ["--owner", owner] } | ||
| 61 | if let visibility { argv += ["--visibility", visibility] } | ||
| 62 | return argv | ||
| 63 | } | ||
| 64 | |||
| 65 | func load() async { | ||
| 66 | list.argv = argv | ||
| 67 | await list.reload() | ||
| 68 | } | ||
| 69 | |||
| 70 | func perform(_ action: AdminRepoAction, on repo: AdminRepo) async { | ||
| 71 | working = true | ||
| 72 | actionError = nil | ||
| 73 | notice = nil | ||
| 74 | defer { working = false } | ||
| 75 | do { | ||
| 76 | try await client.run(action.argv(repo.path)) | ||
| 77 | if action == .reindex { notice = "A rebuild of \(repo.path)'s symbol index is queued." } | ||
| 78 | changed(repo, action) | ||
| 79 | } catch GitbayError.notFound where action == .delete { | ||
| 80 | // A failure is retried once; a delete that went through and | ||
| 81 | // then failed answers the retry with not found. | ||
| 82 | changed(repo, action) | ||
| 83 | } catch let error as GitbayError { | ||
| 84 | actionError = error.userFacingMessage | ||
| 85 | } catch { | ||
| 86 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 87 | } | ||
| 88 | list.argv = argv | ||
| 89 | await list.refresh() | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Screens showing the repository elsewhere reload; a rebuilt index | ||
| 93 | /// changes nothing they draw. | ||
| 94 | private func changed(_ repo: AdminRepo, _ action: AdminRepoAction) { | ||
| 95 | guard action != .reindex else { return } | ||
| 96 | NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repo.path) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | /// `admin org limits <org>`: an organization's repository and storage caps. | ||
| 101 | @Observable | ||
| 102 | @MainActor | ||
| 103 | final class AdminOrgLimitsViewModel { | ||
| 104 | |||
| 105 | private(set) var limits: OwnerLimits? | ||
| 106 | private(set) var loadedOrg: String? | ||
| 107 | private(set) var actionError: String? | ||
| 108 | private(set) var working = false | ||
| 109 | |||
| 110 | private let client: GitbayClient | ||
| 111 | |||
| 112 | init(client: GitbayClient) { | ||
| 113 | self.client = client | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Not a read-only command (it also sets caps), so it goes as one. | ||
| 117 | func load(org: String) async { | ||
| 118 | let org = org.trimmingCharacters(in: .whitespaces) | ||
| 119 | guard !org.isEmpty else { return } | ||
| 120 | working = true | ||
| 121 | actionError = nil | ||
| 122 | defer { working = false } | ||
| 123 | do { | ||
| 124 | limits = try await client.run(["admin", "org", "limits", org], as: OwnerLimits.self) | ||
| 125 | loadedOrg = org | ||
| 126 | } catch let error as GitbayError { | ||
| 127 | limits = nil | ||
| 128 | loadedOrg = nil | ||
| 129 | actionError = error.messageForTypedInput | ||
| 130 | } catch { | ||
| 131 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | func set(repos: String, megabytes: String) async -> Bool { | ||
| 136 | guard let org = loadedOrg else { return false } | ||
| 137 | guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: nil) else { | ||
| 138 | actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's." | ||
| 139 | return false | ||
| 140 | } | ||
| 141 | guard !flags.isEmpty else { return true } | ||
| 142 | working = true | ||
| 143 | actionError = nil | ||
| 144 | defer { working = false } | ||
| 145 | do { | ||
| 146 | limits = try await client.run(["admin", "org", "limits", org] + flags, as: OwnerLimits.self) | ||
| 147 | return true | ||
| 148 | } catch let error as GitbayError { | ||
| 149 | actionError = error.messageForTypedInput | ||
| 150 | } catch { | ||
| 151 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 152 | } | ||
| 153 | return false | ||
| 154 | } | ||
| 155 | } | ||
gitbay/ContentView.swift +4
| @@ -357,6 +357,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 357 | AdminRunnersView(client: client) | 357 | AdminRunnersView(client: client) |
| 358 | case .adminInbound: | 358 | case .adminInbound: |
| 359 | AdminInboundView(client: client) | 359 | AdminInboundView(client: client) |
| 360 | case .adminRepos: | ||
| 361 | AdminReposView(client: client) | ||
| 362 | case .adminOrgLimits: | ||
| 363 | AdminOrgLimitsView(client: client) | ||
| 360 | } | 364 | } |
| 361 | } | 365 | } |
| 362 | 366 | ||
gitbay/Repos/PagedListModel.swift +7
| @@ -56,6 +56,13 @@ final class PagedListModel<Element: Decodable & Sendable> { | |||
| 56 | await fetch(cursor: nil, appendingTo: []) | 56 | await fetch(cursor: nil, appendingTo: []) |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | /// The first page again, keeping what is on screen until it arrives: | ||
| 60 | /// after a write on a row, the list should not blank. | ||
| 61 | func refresh() async { | ||
| 62 | next = nil | ||
| 63 | await fetch(cursor: nil, appendingTo: []) | ||
| 64 | } | ||
| 65 | |||
| 59 | /// Fetch the next page. Safe to call repeatedly from row-appear | 66 | /// Fetch the next page. Safe to call repeatedly from row-appear |
| 60 | /// triggers; it no-ops while a fetch is in flight or at the end. | 67 | /// triggers; it no-ops while a fetch is in flight or at the end. |
| 61 | func loadMore() async { | 68 | func loadMore() async { |
gitbay/Views/Admin/AdminReposViews.swift added +203
| @@ -0,0 +1,203 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// `admin repo list`: every repository on the instance, filtered by owner | ||
| 4 | /// and visibility, with the writes an admin makes on any of them. | ||
| 5 | struct AdminReposView: View { | ||
| 6 | |||
| 7 | @State private var model: AdminReposViewModel | ||
| 8 | @State private var deleting: AdminRepo? | ||
| 9 | @State private var typed = "" | ||
| 10 | /// Archive and visibility change what everyone sees, so they ask first. | ||
| 11 | @State private var confirming: Pending? | ||
| 12 | |||
| 13 | nonisolated struct Pending: Hashable, Sendable, Identifiable { | ||
| 14 | let action: AdminRepoAction | ||
| 15 | let repo: AdminRepo | ||
| 16 | var id: String { repo.path + String(describing: action) } | ||
| 17 | |||
| 18 | var title: String { | ||
| 19 | switch action { | ||
| 20 | case .archive: "Archive \(repo.path)?" | ||
| 21 | case .unarchive: "Unarchive \(repo.path)?" | ||
| 22 | case .makePublic: "Make \(repo.path) public?" | ||
| 23 | case .makePrivate: "Make \(repo.path) private?" | ||
| 24 | default: "" | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | var message: String { | ||
| 29 | switch action { | ||
| 30 | case .archive: "It becomes read-only: pushes, issues and merge requests are refused. Audited." | ||
| 31 | case .unarchive: "It takes pushes, issues and merge requests again. Audited." | ||
| 32 | case .makePublic: "Anyone, signed in or not, can read it. Audited." | ||
| 33 | case .makePrivate: "Only its owner and those granted access can read it. Audited." | ||
| 34 | default: "" | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | init(client: GitbayClient) { | ||
| 40 | _model = State(initialValue: AdminReposViewModel(client: client)) | ||
| 41 | } | ||
| 42 | |||
| 43 | var body: some View { | ||
| 44 | List { | ||
| 45 | Section { | ||
| 46 | LabeledContent("Owner") { | ||
| 47 | TextField("any", text: Bindable(model).owner) | ||
| 48 | .multilineTextAlignment(.trailing) | ||
| 49 | .autocorrectionDisabled() | ||
| 50 | .textInputAutocapitalization(.never) | ||
| 51 | .onSubmit { Task { await model.load() } } | ||
| 52 | .accessibilityIdentifier("admin-repos-owner") | ||
| 53 | } | ||
| 54 | Picker("Visibility", selection: Bindable(model).visibility) { | ||
| 55 | Text("Any").tag(String?.none) | ||
| 56 | Text("Public").tag(String?.some("public")) | ||
| 57 | Text("Private").tag(String?.some("private")) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | .font(.gbSans(.subheadline)) | ||
| 61 | if let error = model.actionError { | ||
| 62 | Section { GBNotice(error, .gbWarn) } | ||
| 63 | } | ||
| 64 | if let notice = model.notice { | ||
| 65 | Section { GBNotice(notice, .gbOK) } | ||
| 66 | } | ||
| 67 | ForEach(model.list.state.value ?? []) { repo in | ||
| 68 | row(repo) | ||
| 69 | .onAppear { | ||
| 70 | if repo.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | PageFooter(list: model.list) | ||
| 74 | } | ||
| 75 | .overlay { LoadStateOverlay(state: model.list.state) } | ||
| 76 | .navigationTitle("Repositories") | ||
| 77 | .navigationBarTitleDisplayMode(.inline) | ||
| 78 | .task { if model.list.needsLoad { await model.load() } } | ||
| 79 | .refreshable { await model.load() } | ||
| 80 | .onChange(of: model.visibility) { Task { await model.load() } } | ||
| 81 | .confirmationDialog(confirming?.title ?? "", | ||
| 82 | isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), | ||
| 83 | titleVisibility: .visible, presenting: confirming) { pending in | ||
| 84 | Button(pending.action == .makePublic ? "Make public" : "Confirm", | ||
| 85 | role: pending.action == .makePublic || pending.action == .archive ? .destructive : nil) { | ||
| 86 | Task { await model.perform(pending.action, on: pending.repo) } | ||
| 87 | } | ||
| 88 | Button("Cancel", role: .cancel) {} | ||
| 89 | } message: { pending in | ||
| 90 | Text(pending.message) | ||
| 91 | } | ||
| 92 | .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), | ||
| 93 | presenting: deleting) { repo in | ||
| 94 | TextField(repo.path, text: $typed) | ||
| 95 | .autocorrectionDisabled() | ||
| 96 | .textInputAutocapitalization(.never) | ||
| 97 | Button("Delete", role: .destructive) { Task { await model.perform(.delete, on: repo) } } | ||
| 98 | .disabled(typed.trimmingCharacters(in: .whitespaces) != repo.path) | ||
| 99 | Button("Cancel", role: .cancel) {} | ||
| 100 | } message: { repo in | ||
| 101 | Text("Deletes \(repo.path) with its issues, merge requests, releases and builds. Audited. Type the path to confirm.") | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | private func row(_ repo: AdminRepo) -> some View { | ||
| 106 | HStack { | ||
| 107 | VStack(alignment: .leading, spacing: 3) { | ||
| 108 | HStack(spacing: 6) { | ||
| 109 | Text(repo.path).font(.gbSans(.subheadline).weight(.medium)) | ||
| 110 | if repo.visibility == "private" { GBChip("Private", .secondary) } | ||
| 111 | if repo.isArchived { GBChip("archived", .gbWarn) } | ||
| 112 | } | ||
| 113 | HStack(spacing: 4) { | ||
| 114 | Text(repo.bytes.formatted(.byteCount(style: .file, spellsOutZero: false))) | ||
| 115 | Text("·") | ||
| 116 | Text(repo.lastPush.map { "pushed \($0.formatted(.relative(presentation: .named)))" } ?? "never pushed") | ||
| 117 | } | ||
| 118 | .font(.gbSans(.caption)) | ||
| 119 | .foregroundStyle(.secondary) | ||
| 120 | } | ||
| 121 | Spacer() | ||
| 122 | Menu { | ||
| 123 | Button(repo.isArchived ? "Unarchive" : "Archive") { | ||
| 124 | confirming = Pending(action: repo.isArchived ? .unarchive : .archive, repo: repo) | ||
| 125 | } | ||
| 126 | Button(repo.visibility == "private" ? "Make public" : "Make private") { | ||
| 127 | confirming = Pending(action: repo.visibility == "private" ? .makePublic : .makePrivate, repo: repo) | ||
| 128 | } | ||
| 129 | Button("Rebuild symbol index") { | ||
| 130 | Task { await model.perform(.reindex, on: repo) } | ||
| 131 | } | ||
| 132 | Divider() | ||
| 133 | Button("Delete", role: .destructive) { | ||
| 134 | typed = "" | ||
| 135 | deleting = repo | ||
| 136 | } | ||
| 137 | } label: { | ||
| 138 | Image(systemName: "ellipsis.circle") | ||
| 139 | .frame(minWidth: 44, minHeight: 44) | ||
| 140 | } | ||
| 141 | .disabled(model.working) | ||
| 142 | .accessibilityLabel("Actions for \(repo.path)") | ||
| 143 | .accessibilityIdentifier("admin-repo-menu-\(repo.path)") | ||
| 144 | } | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | /// `admin org limits`: an organization's repository and storage caps | ||
| 149 | /// against what it holds, and setting them. | ||
| 150 | struct AdminOrgLimitsView: View { | ||
| 151 | |||
| 152 | @State private var model: AdminOrgLimitsViewModel | ||
| 153 | @State private var org = "" | ||
| 154 | @State private var editing = false | ||
| 155 | |||
| 156 | init(client: GitbayClient) { | ||
| 157 | _model = State(initialValue: AdminOrgLimitsViewModel(client: client)) | ||
| 158 | } | ||
| 159 | |||
| 160 | var body: some View { | ||
| 161 | List { | ||
| 162 | Section { | ||
| 163 | LabeledContent("Organization") { | ||
| 164 | TextField("name", text: $org) | ||
| 165 | .multilineTextAlignment(.trailing) | ||
| 166 | .autocorrectionDisabled() | ||
| 167 | .textInputAutocapitalization(.never) | ||
| 168 | .submitLabel(.search) | ||
| 169 | .onSubmit { Task { await model.load(org: org) } } | ||
| 170 | .accessibilityIdentifier("org-limits-name") | ||
| 171 | } | ||
| 172 | .font(.gbSans(.subheadline)) | ||
| 173 | Button("Show") { Task { await model.load(org: org) } } | ||
| 174 | .disabled(org.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | ||
| 175 | .accessibilityIdentifier("org-limits-show") | ||
| 176 | } | ||
| 177 | if let error = model.actionError, !editing { | ||
| 178 | Section { GBNotice(error, .gbWarn) } | ||
| 179 | } | ||
| 180 | if let l = model.limits, let name = model.loadedOrg { | ||
| 181 | Section { | ||
| 182 | LabeledContent("Repositories", value: "\(l.reposOwned) of \(l.repos == 0 ? "unlimited" : String(l.repos))") | ||
| 183 | LabeledContent("Storage", value: "\(l.bytesOwned.formatted(.byteCount(style: .file, spellsOutZero: false))) of \(l.bytes == 0 ? "unlimited" : l.bytes.formatted(.byteCount(style: .file)))") | ||
| 184 | Button("Edit limits") { editing = true } | ||
| 185 | .accessibilityIdentifier("org-limits-edit") | ||
| 186 | } header: { | ||
| 187 | Text(name) | ||
| 188 | } footer: { | ||
| 189 | Text(l.override ? "Set on this organization." : "The instance's defaults.") | ||
| 190 | } | ||
| 191 | .font(.gbSans(.subheadline)) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | .navigationTitle("Organization Limits") | ||
| 195 | .navigationBarTitleDisplayMode(.inline) | ||
| 196 | .sheet(isPresented: $editing) { | ||
| 197 | LimitsSheet(heading: "Limits for \(model.loadedOrg ?? "")", hasOrgs: false, working: model.working, | ||
| 198 | error: model.actionError) { repos, megabytes, _ in | ||
| 199 | if await model.set(repos: repos, megabytes: megabytes) { editing = false } | ||
| 200 | } | ||
| 201 | } | ||
| 202 | } | ||
| 203 | } | ||
gitbay/Views/Admin/AdminView.swift +8
| @@ -18,6 +18,14 @@ struct AdminView: View { | |||
| 18 | Label("Accounts", systemImage: "person.2") | 18 | Label("Accounts", systemImage: "person.2") |
| 19 | } | 19 | } |
| 20 | .accessibilityIdentifier("admin-accounts") | 20 | .accessibilityIdentifier("admin-accounts") |
| 21 | NavigationLink(value: RepoRoute.adminRepos) { | ||
| 22 | Label("Repositories", systemImage: "books.vertical") | ||
| 23 | } | ||
| 24 | .accessibilityIdentifier("admin-repos") | ||
| 25 | NavigationLink(value: RepoRoute.adminOrgLimits) { | ||
| 26 | Label("Organization limits", systemImage: "building.2") | ||
| 27 | } | ||
| 28 | .accessibilityIdentifier("admin-org-limits") | ||
| 21 | NavigationLink(value: RepoRoute.adminStats) { | 29 | NavigationLink(value: RepoRoute.adminStats) { |
| 22 | Label("Statistics", systemImage: "chart.bar") | 30 | Label("Statistics", systemImage: "chart.bar") |
| 23 | } | 31 | } |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -37,4 +37,6 @@ nonisolated enum RepoRoute: Hashable { | |||
| 37 | case adminAudit | 37 | case adminAudit |
| 38 | case adminRunners | 38 | case adminRunners |
| 39 | case adminInbound | 39 | case adminInbound |
| 40 | case adminRepos | ||
| 41 | case adminOrgLimits | ||
| 40 | } | 42 | } |
gitbayTests/AdminRepoTests.swift added +129
| @@ -0,0 +1,129 @@ | |||
| 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 argv(_ url: URL) -> [String] { | ||
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? [] | ||
| 17 | } | ||
| 18 | |||
| 19 | private func posted(_ stub: StubProtocol.Box) throws -> [[String]] { | ||
| 20 | try stub.seen.filter { $0.method == "POST" }.map { seen in | ||
| 21 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 22 | return try #require(body["argv"] as? [String]) | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | private let listJSON = """ | ||
| 27 | {"protocol_version":1,"data":{"items":[\ | ||
| 28 | {"path":"sim/app","visibility":"public","created_at":"2026-10-14T04:19:38.964Z","last_push":"2026-10-14T06:04:58.461Z","bytes":38159},\ | ||
| 29 | {"path":"sim/old","visibility":"private","archived":true,"created_at":"2026-10-14T04:19:38.964Z","bytes":10}]}} | ||
| 30 | """ | ||
| 31 | |||
| 32 | @MainActor | ||
| 33 | struct AdminRepoTests { | ||
| 34 | |||
| 35 | @Test func theListIsFilteredByOwnerAndVisibility() async throws { | ||
| 36 | let (client, stub) = try makeClient() | ||
| 37 | stub.enqueue(.init(status: 200, json: listJSON)) | ||
| 38 | let model = AdminReposViewModel(client: client) | ||
| 39 | model.owner = " sim " | ||
| 40 | model.visibility = "private" | ||
| 41 | |||
| 42 | await model.load() | ||
| 43 | |||
| 44 | #expect(argv(try #require(stub.seen.first).url) | ||
| 45 | == ["admin", "repo", "list", "--owner", "sim", "--visibility", "private", "--limit", "50"]) | ||
| 46 | let repos = try #require(model.list.state.value) | ||
| 47 | #expect(repos.map(\.isArchived) == [false, true]) | ||
| 48 | #expect(repos.last?.lastPush == nil) | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func eachActionSendsItsCommand() { | ||
| 52 | #expect(AdminRepoAction.archive.argv("a/b") == ["admin", "repo", "archive", "a/b"]) | ||
| 53 | #expect(AdminRepoAction.unarchive.argv("a/b") == ["admin", "repo", "unarchive", "a/b"]) | ||
| 54 | #expect(AdminRepoAction.makePrivate.argv("a/b") == ["admin", "repo", "visibility", "a/b", "private"]) | ||
| 55 | #expect(AdminRepoAction.reindex.argv("a/b") == ["admin", "symbols", "reindex", "a/b"]) | ||
| 56 | #expect(AdminRepoAction.delete.argv("a/b") == ["admin", "repo", "delete", "a/b", "--yes"]) | ||
| 57 | } | ||
| 58 | |||
| 59 | @Test func aReindexSaysItIsQueued() async throws { | ||
| 60 | let (client, stub) = try makeClient() | ||
| 61 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 62 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"sim/app","state":"queued"},"exit_code":0}"#, match: "cmd")) | ||
| 63 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 64 | let model = AdminReposViewModel(client: client) | ||
| 65 | await model.load() | ||
| 66 | let repo = try #require(model.list.state.value?.first) | ||
| 67 | |||
| 68 | await model.perform(.reindex, on: repo) | ||
| 69 | |||
| 70 | #expect(try posted(stub) == [["admin", "symbols", "reindex", "sim/app"]]) | ||
| 71 | #expect(model.notice?.contains("queued") == true) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | @MainActor | ||
| 76 | struct AdminRepoDeleteTests { | ||
| 77 | |||
| 78 | @Test func aDeleteWhoseRetryFindsNothingIsDoneAndAnnounced() async throws { | ||
| 79 | let (client, stub) = try makeClient() | ||
| 80 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 81 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd")) | ||
| 82 | stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository sim/app","exit_code":3}"#, match: "cmd")) | ||
| 83 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 84 | let model = AdminReposViewModel(client: client) | ||
| 85 | await model.load() | ||
| 86 | let repo = try #require(model.list.state.value?.first) | ||
| 87 | let announced = NotificationCenter.default.notifications(named: .gitbayRepositoryChanged) | ||
| 88 | |||
| 89 | async let first = announced.first { _ in true } | ||
| 90 | await model.perform(.delete, on: repo) | ||
| 91 | |||
| 92 | #expect(model.actionError == nil) | ||
| 93 | #expect((await first)?.object as? String == "sim/app") | ||
| 94 | // The list stayed on screen through the reload. | ||
| 95 | #expect(model.list.state.value != nil) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | @MainActor | ||
| 100 | struct AdminOrgLimitsTests { | ||
| 101 | |||
| 102 | @Test func anOrgsLimitsLoadAndChange() async throws { | ||
| 103 | let (client, stub) = try makeClient() | ||
| 104 | let json = #"{"protocol_version":1,"data":{"org":"acme","repos":0,"bytes":0,"repos_owned":1,"bytes_owned":2,"override":false}}"# | ||
| 105 | stub.enqueue(.init(status: 200, json: json, match: "cmd")) | ||
| 106 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"org":"acme","repos":5,"bytes":0,"repos_owned":1,"bytes_owned":2,"override":true}}"#, match: "cmd")) | ||
| 107 | let model = AdminOrgLimitsViewModel(client: client) | ||
| 108 | |||
| 109 | await model.load(org: " acme ") | ||
| 110 | #expect(model.limits?.reposOwned == 1) | ||
| 111 | #expect(model.limits?.orgs == nil) | ||
| 112 | |||
| 113 | #expect(await model.set(repos: "5", megabytes: "")) | ||
| 114 | |||
| 115 | #expect(try posted(stub) == [["admin", "org", "limits", "acme"], ["admin", "org", "limits", "acme", "--repos", "5"]]) | ||
| 116 | #expect(model.limits?.repos == 5) | ||
| 117 | } | ||
| 118 | |||
| 119 | @Test func anUnknownOrgSaysSo() async throws { | ||
| 120 | let (client, stub) = try makeClient() | ||
| 121 | stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no organization \"nope\"","exit_code":3}"#, match: "cmd")) | ||
| 122 | let model = AdminOrgLimitsViewModel(client: client) | ||
| 123 | |||
| 124 | await model.load(org: "nope") | ||
| 125 | |||
| 126 | #expect(model.limits == nil) | ||
| 127 | #expect(model.actionError == #"no organization "nope""#) | ||
| 128 | } | ||
| 129 | } | ||