Commit 553ada2a6a

553ada2a6acef389f6d8e8c81e5bbfee5fdbe0a6

parent: f8fa21e25c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 08:31 UTC

Admin: repositories, symbol reindex and organization limits

Repositories under Admin lists every repository with its size and last
push, filtered by owner and visibility. Each row archives or unarchives
it, flips its visibility, queues a rebuild of its symbol index, or
deletes it with the path typed out. Organization limits shows an
organization's repository and storage caps against what it holds, and
sets them.

Closes #38

Layout: unified · split

gitbay/Admin/AdminRepos.swift added +145
@@ -0,0 +1,145 @@
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 if action == .delete {
79 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repo.path)
80 }
81 } catch let error as GitbayError {
82 actionError = error.userFacingMessage
83 } catch {
84 actionError = GitbayError.transport(error).userFacingMessage
85 }
86 await load()
87 }
88}
89
90/// `admin org limits <org>`: an organization's repository and storage caps.
91@Observable
92@MainActor
93final class AdminOrgLimitsViewModel {
94
95 private(set) var limits: OwnerLimits?
96 private(set) var loadedOrg: String?
97 private(set) var actionError: String?
98 private(set) var working = false
99
100 private let client: GitbayClient
101
102 init(client: GitbayClient) {
103 self.client = client
104 }
105
106 /// Not a read-only command (it also sets caps), so it goes as one.
107 func load(org: String) async {
108 let org = org.trimmingCharacters(in: .whitespaces)
109 guard !org.isEmpty else { return }
110 working = true
111 actionError = nil
112 defer { working = false }
113 do {
114 limits = try await client.run(["admin", "org", "limits", org], as: OwnerLimits.self)
115 loadedOrg = org
116 } catch let error as GitbayError {
117 limits = nil
118 loadedOrg = nil
119 actionError = error.messageForTypedInput
120 } catch {
121 actionError = GitbayError.transport(error).userFacingMessage
122 }
123 }
124
125 func set(repos: String, megabytes: String) async -> Bool {
126 guard let org = loadedOrg else { return false }
127 guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: nil) else {
128 actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's."
129 return false
130 }
131 guard !flags.isEmpty else { return true }
132 working = true
133 actionError = nil
134 defer { working = false }
135 do {
136 limits = try await client.run(["admin", "org", "limits", org] + flags, as: OwnerLimits.self)
137 return true
138 } catch let error as GitbayError {
139 actionError = error.messageForTypedInput
140 } catch {
141 actionError = GitbayError.transport(error).userFacingMessage
142 }
143 return false
144 }
145}
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/Views/Admin/AdminReposViews.swift added +160
@@ -0,0 +1,160 @@
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
11 init(client: GitbayClient) {
12 _model = State(initialValue: AdminReposViewModel(client: client))
13 }
14
15 var body: some View {
16 List {
17 Section {
18 LabeledContent("Owner") {
19 TextField("any", text: Bindable(model).owner)
20 .multilineTextAlignment(.trailing)
21 .autocorrectionDisabled()
22 .textInputAutocapitalization(.never)
23 .onSubmit { Task { await model.load() } }
24 .accessibilityIdentifier("admin-repos-owner")
25 }
26 Picker("Visibility", selection: Bindable(model).visibility) {
27 Text("Any").tag(String?.none)
28 Text("Public").tag(String?.some("public"))
29 Text("Private").tag(String?.some("private"))
30 }
31 }
32 .font(.gbSans(.subheadline))
33 if let error = model.actionError {
34 Section { GBNotice(error, .gbWarn) }
35 }
36 if let notice = model.notice {
37 Section { GBNotice(notice, .gbOK) }
38 }
39 ForEach(model.list.state.value ?? []) { repo in
40 row(repo)
41 .onAppear {
42 if repo.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } }
43 }
44 }
45 PageFooter(list: model.list)
46 }
47 .overlay { LoadStateOverlay(state: model.list.state) }
48 .navigationTitle("Repositories")
49 .navigationBarTitleDisplayMode(.inline)
50 .task { if model.list.needsLoad { await model.load() } }
51 .refreshable { await model.load() }
52 .onChange(of: model.visibility) { Task { await model.load() } }
53 .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
54 presenting: deleting) { repo in
55 TextField(repo.path, text: $typed)
56 .autocorrectionDisabled()
57 .textInputAutocapitalization(.never)
58 Button("Delete", role: .destructive) { Task { await model.perform(.delete, on: repo) } }
59 .disabled(typed.trimmingCharacters(in: .whitespaces) != repo.path)
60 Button("Cancel", role: .cancel) {}
61 } message: { repo in
62 Text("Deletes \(repo.path) with its issues, merge requests, releases and builds. Audited. Type the path to confirm.")
63 }
64 }
65
66 private func row(_ repo: AdminRepo) -> some View {
67 HStack {
68 VStack(alignment: .leading, spacing: 3) {
69 HStack(spacing: 6) {
70 Text(repo.path).font(.gbSans(.subheadline).weight(.medium))
71 if repo.visibility == "private" { GBChip("Private", .secondary) }
72 if repo.isArchived { GBChip("archived", .gbWarn) }
73 }
74 HStack(spacing: 4) {
75 Text(repo.bytes.formatted(.byteCount(style: .file, spellsOutZero: false)))
76 Text("·")
77 Text(repo.lastPush.map { "pushed \($0.formatted(.relative(presentation: .named)))" } ?? "never pushed")
78 }
79 .font(.gbSans(.caption))
80 .foregroundStyle(.secondary)
81 }
82 Spacer()
83 Menu {
84 Button(repo.isArchived ? "Unarchive" : "Archive") {
85 Task { await model.perform(repo.isArchived ? .unarchive : .archive, on: repo) }
86 }
87 Button(repo.visibility == "private" ? "Make public" : "Make private") {
88 Task { await model.perform(repo.visibility == "private" ? .makePublic : .makePrivate, on: repo) }
89 }
90 Button("Rebuild symbol index") {
91 Task { await model.perform(.reindex, on: repo) }
92 }
93 Divider()
94 Button("Delete", role: .destructive) {
95 typed = ""
96 deleting = repo
97 }
98 } label: {
99 Image(systemName: "ellipsis.circle")
100 .frame(minWidth: 44, minHeight: 44)
101 }
102 .disabled(model.working)
103 .accessibilityLabel("Actions for \(repo.path)")
104 .accessibilityIdentifier("admin-repo-menu-\(repo.path)")
105 }
106 }
107}
108
109/// `admin org limits`: an organization's repository and storage caps
110/// against what it holds, and setting them.
111struct AdminOrgLimitsView: View {
112
113 @State private var model: AdminOrgLimitsViewModel
114 @State private var org = ""
115 @State private var editing = false
116
117 init(client: GitbayClient) {
118 _model = State(initialValue: AdminOrgLimitsViewModel(client: client))
119 }
120
121 var body: some View {
122 List {
123 Section {
124 LabeledContent("Organization") {
125 TextField("name", text: $org)
126 .multilineTextAlignment(.trailing)
127 .autocorrectionDisabled()
128 .textInputAutocapitalization(.never)
129 .onSubmit { Task { await model.load(org: org) } }
130 .accessibilityIdentifier("org-limits-name")
131 }
132 .font(.gbSans(.subheadline))
133 }
134 if let error = model.actionError, !editing {
135 Section { GBNotice(error, .gbWarn) }
136 }
137 if let l = model.limits, let name = model.loadedOrg {
138 Section {
139 LabeledContent("Repositories", value: "\(l.reposOwned) of \(l.repos == 0 ? "unlimited" : String(l.repos))")
140 LabeledContent("Storage", value: "\(l.bytesOwned.formatted(.byteCount(style: .file, spellsOutZero: false))) of \(l.bytes == 0 ? "unlimited" : l.bytes.formatted(.byteCount(style: .file)))")
141 Button("Edit limits") { editing = true }
142 .accessibilityIdentifier("org-limits-edit")
143 } header: {
144 Text(name)
145 } footer: {
146 Text(l.override ? "Set on this organization." : "The instance's defaults.")
147 }
148 .font(.gbSans(.subheadline))
149 }
150 }
151 .navigationTitle("Organization Limits")
152 .navigationBarTitleDisplayMode(.inline)
153 .sheet(isPresented: $editing) {
154 LimitsSheet(heading: "Limits for \(model.loadedOrg ?? "")", hasOrgs: false, working: model.working,
155 error: model.actionError) { repos, megabytes, _ in
156 if await model.set(repos: repos, megabytes: megabytes) { editing = false }
157 }
158 }
159 }
160}
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 +105
@@ -0,0 +1,105 @@
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 AdminOrgLimitsTests {
77
78 @Test func anOrgsLimitsLoadAndChange() async throws {
79 let (client, stub) = try makeClient()
80 let json = #"{"protocol_version":1,"data":{"org":"acme","repos":0,"bytes":0,"repos_owned":1,"bytes_owned":2,"override":false}}"#
81 stub.enqueue(.init(status: 200, json: json, match: "cmd"))
82 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"))
83 let model = AdminOrgLimitsViewModel(client: client)
84
85 await model.load(org: " acme ")
86 #expect(model.limits?.reposOwned == 1)
87 #expect(model.limits?.orgs == nil)
88
89 #expect(await model.set(repos: "5", megabytes: ""))
90
91 #expect(try posted(stub) == [["admin", "org", "limits", "acme"], ["admin", "org", "limits", "acme", "--repos", "5"]])
92 #expect(model.limits?.repos == 5)
93 }
94
95 @Test func anUnknownOrgSaysSo() async throws {
96 let (client, stub) = try makeClient()
97 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no organization \"nope\"","exit_code":3}"#, match: "cmd"))
98 let model = AdminOrgLimitsViewModel(client: client)
99
100 await model.load(org: "nope")
101
102 #expect(model.limits == nil)
103 #expect(model.actionError == #"no organization "nope""#)
104 }
105}