Admin: repositories, symbol reindex and organization limits !175

merged merged by cmc on 2026-10-14 08:50 UTC · krz/gitbay-ios:admin-repos into main

7 files changed, +508 −0

Layout: unified · split

gitbay/Admin/AdminRepos.swift added +155
@@ -0,0 +1,155 @@
1import Foundation
2import Observation
3
4/// One row of `admin repo list`: every repository, with size and last push.
5nonisolated 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.
22nonisolated 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
40final 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
103final 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 {
357357 AdminRunnersView(client: client)
358358 case .adminInbound:
359359 AdminInboundView(client: client)
360 case .adminRepos:
361 AdminReposView(client: client)
362 case .adminOrgLimits:
363 AdminOrgLimitsView(client: client)
360364 }
361365 }
362366
gitbay/Repos/PagedListModel.swift +7
@@ -56,6 +56,13 @@ final class PagedListModel<Element: Decodable & Sendable> {
5656 await fetch(cursor: nil, appendingTo: [])
5757 }
5858
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
5966 /// Fetch the next page. Safe to call repeatedly from row-appear
6067 /// triggers; it no-ops while a fetch is in flight or at the end.
6168 func loadMore() async {
gitbay/Views/Admin/AdminReposViews.swift added +203
@@ -0,0 +1,203 @@
1import 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.
5struct 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.
150struct 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 {
1818 Label("Accounts", systemImage: "person.2")
1919 }
2020 .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")
2129 NavigationLink(value: RepoRoute.adminStats) {
2230 Label("Statistics", systemImage: "chart.bar")
2331 }
gitbay/Views/Repos/RepoRoute.swift +2
@@ -37,4 +37,6 @@ nonisolated enum RepoRoute: Hashable {
3737 case adminAudit
3838 case adminRunners
3939 case adminInbound
40 case adminRepos
41 case adminOrgLimits
4042}
gitbayTests/AdminRepoTests.swift added +129
@@ -0,0 +1,129 @@
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 argv(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? []
17}
18
19private 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
26private 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
33struct 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
76struct 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
100struct 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}