Commit 96bfba0e47

96bfba0e47ae0242f87f2e839589ef7303d2230e

parent: 63a1edda14

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:12 UTC

Remove a release asset (!67)

Swipe on an asset row, confirm, and the app dispatches release asset remove and reloads the release. No live-suite step: the app cannot add an asset, so there is no fixture to remove.

Layout: unified · split

gitbay/Releases/ReleaseViewModels.swift +15
@@ -100,6 +100,21 @@ final class ReleaseDetailViewModel {
100 } 100 }
101 } 101 }
102 102
103 /// `release asset remove <owner/name> <tag> <filename>`.
104 func removeAsset(named name: String) async {
105 working = true
106 actionError = nil
107 defer { working = false }
108 do {
109 try await client.run(["release", "asset", "remove", repoPath, tag, name])
110 await load()
111 } catch let error as GitbayError {
112 actionError = error.userFacingMessage
113 } catch {
114 actionError = GitbayError.transport(error).userFacingMessage
115 }
116 }
117
103 /// `release delete <owner/name> <tag> --yes` — `--yes` is required by 118 /// `release delete <owner/name> <tag> --yes` — `--yes` is required by
104 /// the command, not optional; omitting it is a usage error. 119 /// the command, not optional; omitting it is a usage error.
105 func delete() async -> Bool { 120 func delete() async -> Bool {
gitbay/Views/Releases/ReleaseView.swift +23
@@ -7,6 +7,7 @@ struct ReleaseView: View {
7 @State private var draftTitle = "" 7 @State private var draftTitle = ""
8 @State private var draftNotes = "" 8 @State private var draftNotes = ""
9 @State private var confirmingDelete = false 9 @State private var confirmingDelete = false
10 @State private var removingAsset: Release.Asset?
10 @Environment(\.dismiss) private var dismiss 11 @Environment(\.dismiss) private var dismiss
11 12
12 init(client: GitbayClient, repo: String, tag: String) { 13 init(client: GitbayClient, repo: String, tag: String) {
@@ -71,6 +72,12 @@ struct ReleaseView: View {
71 .foregroundStyle(.secondary) 72 .foregroundStyle(.secondary)
72 } 73 }
73 } 74 }
75 .swipeActions {
76 Button("Remove", role: .destructive) {
77 removingAsset = asset
78 }
79 .disabled(model.working)
80 }
74 } 81 }
75 } 82 }
76 } 83 }
@@ -117,6 +124,22 @@ struct ReleaseView: View {
117 } message: { 124 } message: {
118 Text("This deletes the release's assets along with it.") 125 Text("This deletes the release's assets along with it.")
119 } 126 }
127 .confirmationDialog(
128 "Remove \(removingAsset?.name ?? "") from \(model.tag)?",
129 isPresented: Binding(
130 get: { removingAsset != nil },
131 set: { if !$0 { removingAsset = nil } }
132 ),
133 titleVisibility: .visible
134 ) {
135 Button("Remove", role: .destructive) {
136 if let asset = removingAsset {
137 Task { await model.removeAsset(named: asset.name) }
138 }
139 removingAsset = nil
140 }
141 Button("Cancel", role: .cancel) {}
142 }
120 .sheet(isPresented: $editing) { 143 .sheet(isPresented: $editing) {
121 ComposeSheet( 144 ComposeSheet(
122 heading: "Edit \(model.tag)", 145 heading: "Edit \(model.tag)",
gitbayTests/ReleaseTests.swift +29
@@ -117,6 +117,35 @@ struct ReleaseDetailViewModelTests {
117 #expect(stdin == "new notes") 117 #expect(stdin == "new notes")
118 } 118 }
119 119
120 @Test func removeAssetSendsTheCommandAndReloads() async throws {
121 let (client, stub) = try makeClient()
122 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
123 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
124 await model.load()
125 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
126 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
127
128 await model.removeAsset(named: "SHA256SUMS")
129
130 let (argv, _) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
131 #expect(argv == ["release", "asset", "remove", "krz/gitbay", "v1.0.0", "SHA256SUMS"])
132 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
133 #expect(model.actionError == nil)
134 }
135
136 @Test func aRefusedAssetRemovalSurfaces() async throws {
137 let (client, stub) = try makeClient()
138 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
139 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
140 await model.load()
141 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd"))
142
143 await model.removeAsset(named: "SHA256SUMS")
144
145 #expect(model.actionError == "write access required")
146 #expect(model.state.value?.assets?.count == 1)
147 }
148
120 @Test func downloadURLsUseTheWebRouteOnTheInstance() async throws { 149 @Test func downloadURLsUseTheWebRouteOnTheInstance() async throws {
121 let (client, _) = try makeClient() 150 let (client, _) = try makeClient()
122 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") 151 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")