Transfer and delete a repository !166

merged merged by cmc on 2026-10-14 07:30 UTC · krz/gitbay-ios:repo-transfer-delete into main

5 files changed, +236 −2

Layout: unified · split

gitbay/Repos/RepoSettingsViewModel.swift +51
@@ -177,6 +177,7 @@ final class RepoSettingsViewModel {
177 defer { working = false } 177 defer { working = false }
178 do { 178 do {
179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self) 179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
180 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
180 return renamed?.repo 181 return renamed?.repo
181 } catch let error as GitbayError { 182 } catch let error as GitbayError {
182 actionError = error.userFacingMessage 183 actionError = error.userFacingMessage
@@ -186,6 +187,56 @@ final class RepoSettingsViewModel {
186 return nil 187 return nil
187 } 188 }
188 189
190 nonisolated private struct Transferred: Decodable, Sendable {
191 let repo: String
192 }
193
194 /// `repo transfer <owner/name> <new-owner>`: the repository moves to
195 /// another account or organization and its clone URLs change. Returns
196 /// the new path; this screen's is gone.
197 func transfer(to owner: String) async -> String? {
198 let owner = owner.trimmingCharacters(in: .whitespaces)
199 guard !owner.isEmpty else { return nil }
200 working = true
201 actionError = nil
202 defer { working = false }
203 do {
204 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
205 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
206 return moved?.repo
207 } catch let error as GitbayError {
208 // The owner is typed: a refusal of it says what to fix.
209 actionError = error.messageForTypedInput
210 } catch {
211 actionError = GitbayError.transport(error).userFacingMessage
212 }
213 return nil
214 }
215
216 /// `repo delete <owner/name> --yes`. The screen asks for the path to
217 /// be typed first, as the web does; --yes is that confirmation.
218 func delete() async -> Bool {
219 working = true
220 actionError = nil
221 defer { working = false }
222 do {
223 try await client.run(["repo", "delete", repoPath, "--yes"])
224 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
225 return true
226 } catch GitbayError.notFound {
227 // The client retries a failure once. A delete that removed the
228 // row and then failed cleaning up answers the retry with not
229 // found: the repository is gone either way.
230 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
231 return true
232 } catch let error as GitbayError {
233 actionError = error.userFacingMessage
234 } catch {
235 actionError = GitbayError.transport(error).userFacingMessage
236 }
237 return false
238 }
239
189 func loadBranches() async { 240 func loadBranches() async {
190 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 241 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
191 branches = refs?.branches.map(\.name) ?? [] 242 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Dashboard/DashboardView.swift +4
@@ -95,6 +95,10 @@ struct DashboardView: View {
95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in 95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
96 Task { await model.load() } 96 Task { await model.load() }
97 } 97 }
98 // A pinned repository deleted or moved elsewhere.
99 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { _ in
100 Task { await model.load() }
101 }
98 .onChange(of: model.state.value?.unread) { _, count in 102 .onChange(of: model.state.value?.unread) { _, count in
99 if let count { unread = count } 103 if let count { unread = count }
100 } 104 }
gitbay/Views/Repos/RepoSettingsView.swift +104 −2
@@ -17,6 +17,16 @@ struct RepoSettingsView: View {
17 /// Set once a rename succeeds: this screen's repository is gone and 17 /// Set once a rename succeeds: this screen's repository is gone and
18 /// the renamed one is a value push away. 18 /// the renamed one is a value push away.
19 @State private var renamedTo: String? 19 @State private var renamedTo: String?
20 @State private var transferring = false
21 @State private var newOwner = ""
22 @State private var transferConfirm = ""
23 @State private var deleting = false
24 @State private var deleteConfirm = ""
25 /// Set once the repository is deleted: nothing on this screen applies.
26 @State private var deleted = false
27 /// A refused rename, transfer or delete, shown where the user is: the
28 /// screen's banner sits at the top of a long list, out of sight.
29 @State private var refusal: String?
20 @State private var loadedOnce = false 30 @State private var loadedOnce = false
21 @State private var removingTopic: String? 31 @State private var removingTopic: String?
22 @State private var newGrantUser = "" 32 @State private var newGrantUser = ""
@@ -37,12 +47,21 @@ struct RepoSettingsView: View {
37 } 47 }
38 .accessibilityIdentifier("settings-renamed-link") 48 .accessibilityIdentifier("settings-renamed-link")
39 } header: { 49 } header: {
40 Text("Renamed") 50 Text("Moved")
41 } footer: { 51 } footer: {
42 Text("This screen's repository moved; nothing here applies to it any more.") 52 Text("This screen's repository moved; nothing here applies to it any more.")
43 } 53 }
44 } 54 }
45 if let loaded = model.state.value { 55 if deleted {
56 Section {
57 Label("\(model.repoPath) is deleted", systemImage: "trash")
58 .font(.gbSans(.subheadline))
59 .accessibilityIdentifier("settings-deleted")
60 } footer: {
61 Text("Its issues, merge requests, releases and builds went with it.")
62 }
63 }
64 if !deleted, renamedTo == nil, let loaded = model.state.value {
46 if let error = model.actionError { 65 if let error = model.actionError {
47 Section { 66 Section {
48 GBNotice(error, .gbWarn) 67 GBNotice(error, .gbWarn)
@@ -60,12 +79,17 @@ struct RepoSettingsView: View {
60 webhooksSection 79 webhooksSection
61 depsSection() 80 depsSection()
62 renameSection 81 renameSection
82 transferSection
83 deleteSection
63 } 84 }
64 } 85 }
65 .overlay { LoadStateOverlay(state: model.state) } 86 .overlay { LoadStateOverlay(state: model.state) }
66 .navigationTitle("Settings") 87 .navigationTitle("Settings")
67 .navigationBarTitleDisplayMode(.inline) 88 .navigationBarTitleDisplayMode(.inline)
68 .task { 89 .task {
90 // A screen returned to after its repository moved or went has
91 // nothing to load; the notice is all it shows.
92 guard !deleted, renamedTo == nil else { return }
69 await model.load() 93 await model.load()
70 if !loadedOnce, let loaded = model.state.value { 94 if !loadedOnce, let loaded = model.state.value {
71 descriptionText = loaded.detail.description ?? "" 95 descriptionText = loaded.detail.description ?? ""
@@ -77,6 +101,8 @@ struct RepoSettingsView: View {
77 .task { await model.loadGrants() } 101 .task { await model.loadGrants() }
78 .task { await model.loadBranches() } 102 .task { await model.loadBranches() }
79 .refreshable { 103 .refreshable {
104 // Nothing to reload once the repository is gone or moved.
105 guard !deleted, renamedTo == nil else { return }
80 await model.load() 106 await model.load()
81 await model.loadDeps() 107 await model.loadDeps()
82 await model.loadGrants() 108 await model.loadGrants()
@@ -108,6 +134,8 @@ struct RepoSettingsView: View {
108 Task { 134 Task {
109 if let path = await model.rename(to: newName) { 135 if let path = await model.rename(to: newName) {
110 renamedTo = path 136 renamedTo = path
137 } else {
138 refusal = model.actionError
111 } 139 }
112 } 140 }
113 } 141 }
@@ -117,6 +145,47 @@ struct RepoSettingsView: View {
117 } message: { 145 } message: {
118 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.") 146 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
119 } 147 }
148 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
149 Button("OK", role: .cancel) {}
150 } message: {
151 Text(refusal ?? "")
152 }
153 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
154 TextField("New owner", text: $newOwner)
155 .autocorrectionDisabled()
156 .textInputAutocapitalization(.never)
157 TextField("type \(model.repoPath) to confirm", text: $transferConfirm)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
160 Button("Transfer") {
161 Task {
162 if let path = await model.transfer(to: newOwner) {
163 renamedTo = path
164 } else {
165 refusal = model.actionError
166 }
167 }
168 }
169 .disabled(newOwner.trimmingCharacters(in: .whitespaces).isEmpty
170 || transferConfirm != model.repoPath)
171 Button("Cancel", role: .cancel) {}
172 } message: {
173 Text("The repository moves to another account or organization, and its clone URLs change. Type \(model.repoPath) to confirm.")
174 }
175 .alert("Delete \(model.repoPath)", isPresented: $deleting) {
176 TextField("type \(model.repoPath) to confirm", text: $deleteConfirm)
177 .autocorrectionDisabled()
178 .textInputAutocapitalization(.never)
179 Button("Delete", role: .destructive) {
180 Task {
181 if await model.delete() { deleted = true } else { refusal = model.actionError }
182 }
183 }
184 .disabled(deleteConfirm != model.repoPath)
185 Button("Cancel", role: .cancel) {}
186 } message: {
187 Text("This deletes the repository with its issues, merge requests, releases and builds, and cannot be undone. Type \(model.repoPath) to confirm.")
188 }
120 .confirmationDialog( 189 .confirmationDialog(
121 "Remove topic \(removingTopic ?? "")?", 190 "Remove topic \(removingTopic ?? "")?",
122 isPresented: Binding( 191 isPresented: Binding(
@@ -504,6 +573,39 @@ struct RepoSettingsView: View {
504 } 573 }
505 } 574 }
506 575
576 private var transferSection: some View {
577 Section {
578 Button {
579 newOwner = ""
580 transferConfirm = ""
581 transferring = true
582 } label: {
583 Label("Transfer repository", systemImage: "arrow.left.arrow.right")
584 .font(.gbSans(.subheadline))
585 }
586 .disabled(model.working)
587 .accessibilityIdentifier("settings-transfer-button")
588 } footer: {
589 Text("To another account or an organization you can create repositories in.")
590 }
591 }
592
593 private var deleteSection: some View {
594 Section {
595 Button(role: .destructive) {
596 deleteConfirm = ""
597 deleting = true
598 } label: {
599 Label("Delete repository", systemImage: "trash")
600 .font(.gbSans(.subheadline))
601 }
602 .disabled(model.working)
603 .accessibilityIdentifier("settings-delete-button")
604 } footer: {
605 Text("Deletes it with everything in it. Only its owner or an admin can.")
606 }
607 }
608
507 private func depsSection() -> some View { 609 private func depsSection() -> some View {
508 Section { 610 Section {
509 if let deps = model.deps { 611 if let deps = model.deps {
gitbay/Views/Repos/RepoView.swift +5
@@ -98,6 +98,11 @@ struct RepoView: View {
98 .toolbar { toolbar } 98 .toolbar { toolbar }
99 .task { await model.load() } 99 .task { await model.load() }
100 .refreshable { await model.load() } 100 .refreshable { await model.load() }
101 // Settings, pushed on top, may delete or move this repository;
102 // reloading turns this screen into the not-found it now is.
103 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { note in
104 if note.object as? String == path { Task { await model.load() } }
105 }
101 .alert("Fork \(path)", isPresented: $confirmingFork) { 106 .alert("Fork \(path)", isPresented: $confirmingFork) {
102 TextField("New name (optional)", text: $forkName) 107 TextField("New name (optional)", text: $forkName)
103 .autocorrectionDisabled() 108 .autocorrectionDisabled()
gitbayTests/RepoTransferDeleteTests.swift added +72
@@ -0,0 +1,72 @@
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 posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22@MainActor
23struct RepoTransferDeleteTests {
24
25 @Test func transferNamesTheNewOwnerAndReturnsTheNewPath() async throws {
26 let (client, stub) = try makeClient()
27 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"krz/app","was":"cmc/app"},"exit_code":0}"#, match: "cmd"))
28 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
29
30 let moved = await model.transfer(to: " krz ")
31
32 #expect(moved == "krz/app")
33 #expect(try posted(stub) == [["repo", "transfer", "cmc/app", "krz"]])
34 }
35
36 @Test func aRefusedTransferSaysWhy() async throws {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"you cannot create repositories in krz","exit_code":4}"#, match: "cmd"))
39 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
40
41 #expect(await model.transfer(to: "krz") == nil)
42 #expect(model.actionError == "you cannot create repositories in krz")
43 }
44
45 @Test func deleteSendsYesAndReportsIt() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"cmc/app"},"exit_code":0}"#, match: "cmd"))
48 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
49
50 #expect(await model.delete())
51 #expect(try posted(stub) == [["repo", "delete", "cmc/app", "--yes"]])
52 }
53
54 @Test func aRefusedDeleteSaysWhy() async throws {
55 let (client, stub) = try makeClient()
56 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"only the owner or an admin can delete cmc/app","exit_code":4}"#, match: "cmd"))
57 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
58
59 #expect(!(await model.delete()))
60 #expect(model.actionError == "only the owner or an admin can delete cmc/app")
61 }
62
63 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"removing the directory: busy","exit_code":1}"#, match: "cmd"))
66 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository cmc/app","exit_code":3}"#, match: "cmd"))
67 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
68
69 #expect(await model.delete())
70 #expect(model.actionError == nil)
71 }
72}