Commit 6f729def95

6f729def95853cf77260b8f6acb5d83246e71d46

parent: 6d46dd6ba2

Unsigned

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

Webhooks (!75)

webhook list, add and remove behind a Webhooks link in repo settings, with a deliveries screen that can redeliver one. Removal confirms. An empty secret or event list is omitted so the server applies its default. The live suite adds and removes a hook on the scratch repo.

Layout: unified · split

gitbay/ContentView.swift +4
@@ -104,6 +104,10 @@ private struct RouteDestinations: ViewModifier {
104 LabelListView(client: client, repo: repo) 104 LabelListView(client: client, repo: repo)
105 case .bookmarks: 105 case .bookmarks:
106 BookmarkListView(client: client) 106 BookmarkListView(client: client)
107 case .webhooks(let repo):
108 WebhookListView(client: client, repo: repo)
109 case .webhookDeliveries(let repo):
110 WebhookDeliveriesView(client: client, repo: repo)
107 case .wiki(let repo): 111 case .wiki(let repo):
108 WikiView(client: client, repo: repo) 112 WikiView(client: client, repo: repo)
109 case .wikiPage(let repo, let page): 113 case .wikiPage(let repo, let page):
gitbay/Repos/WebhookModels.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3/// One row of `webhook list`.
4nonisolated struct Webhook: Decodable, Sendable, Hashable, Identifiable {
5 let id: Int64
6 let url: String
7 let events: String
8 let active: Bool
9 let hasSecret: Bool
10
11 enum CodingKeys: String, CodingKey {
12 case id, url, events, active
13 case hasSecret = "has_secret"
14 }
15}
16
17/// One row of `webhook deliveries`.
18nonisolated struct WebhookDelivery: Decodable, Sendable, Hashable, Identifiable {
19 let id: Int64
20 let url: String
21 let event: String
22 let status: String
23 let attempts: Int
24 let lastStatus: Int?
25 let lastError: String?
26
27 enum CodingKeys: String, CodingKey {
28 case id, url, event, status, attempts
29 case lastStatus = "last_status"
30 case lastError = "last_error"
31 }
32}
gitbay/Repos/WebhookViewModels.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2import Observation
3
4/// `webhook list`, `webhook add` and `webhook remove` for one repository.
5@Observable
6@MainActor
7final class WebhookListViewModel {
8
9 private(set) var state: LoadState<[Webhook]> = .loading
10 private(set) var actionError: String?
11 private(set) var working = false
12
13 private let client: GitbayClient
14 let repoPath: String
15
16 init(client: GitbayClient, repoPath: String) {
17 self.client = client
18 self.repoPath = repoPath
19 }
20
21 func load() async {
22 state = .loading
23 do {
24 let hooks = try await client.readList(["webhook", "list", repoPath], of: Webhook.self)
25 state = hooks.isEmpty ? .empty("No webhooks yet.") : .loaded(hooks)
26 } catch {
27 state = .from(error)
28 }
29 }
30
31 /// `webhook add <repo> <url> [--secret <s>] [--events <e>]` — an empty
32 /// secret or event list is left out, so the server applies its default.
33 func add(url: String, secret: String, events: String) async -> Bool {
34 let url = url.trimmingCharacters(in: .whitespaces)
35 guard !url.isEmpty else { return false }
36 var argv = ["webhook", "add", repoPath, url]
37 let secret = secret.trimmingCharacters(in: .whitespaces)
38 if !secret.isEmpty { argv += ["--secret", secret] }
39 let events = events.trimmingCharacters(in: .whitespaces)
40 if !events.isEmpty { argv += ["--events", events] }
41 await perform(argv)
42 return actionError == nil
43 }
44
45 func remove(id: Int64) async {
46 await perform(["webhook", "remove", repoPath, String(id)])
47 }
48
49 private func perform(_ argv: [String]) async {
50 working = true
51 actionError = nil
52 defer { working = false }
53 do {
54 try await client.run(argv)
55 await load()
56 } catch let error as GitbayError {
57 actionError = error.userFacingMessage
58 } catch {
59 actionError = GitbayError.transport(error).userFacingMessage
60 }
61 }
62}
63
64/// `webhook deliveries` and `webhook redeliver`.
65@Observable
66@MainActor
67final class WebhookDeliveriesViewModel {
68
69 private(set) var state: LoadState<[WebhookDelivery]> = .loading
70 private(set) var actionError: String?
71 private(set) var working = false
72
73 private let client: GitbayClient
74 let repoPath: String
75
76 init(client: GitbayClient, repoPath: String) {
77 self.client = client
78 self.repoPath = repoPath
79 }
80
81 func load() async {
82 state = .loading
83 do {
84 let rows = try await client.readList(
85 ["webhook", "deliveries", repoPath], of: WebhookDelivery.self)
86 state = rows.isEmpty ? .empty("No deliveries yet.") : .loaded(rows)
87 } catch {
88 state = .from(error)
89 }
90 }
91
92 func redeliver(id: Int64) async {
93 working = true
94 actionError = nil
95 defer { working = false }
96 do {
97 try await client.run(["webhook", "redeliver", repoPath, String(id)])
98 await load()
99 } catch let error as GitbayError {
100 actionError = error.userFacingMessage
101 } catch {
102 actionError = GitbayError.transport(error).userFacingMessage
103 }
104 }
105}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -18,6 +18,8 @@ nonisolated enum RepoRoute: Hashable {
18 case wikiPage(repo: String, page: String) 18 case wikiPage(repo: String, page: String)
19 case commit(repo: String, sha: String) 19 case commit(repo: String, sha: String)
20 case bookmarks 20 case bookmarks
21 case webhooks(repo: String)
22 case webhookDeliveries(repo: String)
21 case profile(String) 23 case profile(String)
22 case account 24 case account
23 case addAccount 25 case addAccount
gitbay/Views/Repos/RepoSettingsView.swift +11
@@ -34,6 +34,7 @@ struct RepoSettingsView: View {
34 mergeRulesSection(loaded) 34 mergeRulesSection(loaded)
35 daemonSection(loaded) 35 daemonSection(loaded)
36 accessSection 36 accessSection
37 webhooksSection
37 depsSection() 38 depsSection()
38 } 39 }
39 } 40 }
@@ -309,6 +310,16 @@ struct RepoSettingsView: View {
309 } 310 }
310 } 311 }
311 312
313 private var webhooksSection: some View {
314 Section {
315 NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) {
316 Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right")
317 .font(.gbSans(.subheadline))
318 }
319 .accessibilityIdentifier("settings-webhooks-link")
320 }
321 }
322
312 private func depsSection() -> some View { 323 private func depsSection() -> some View {
313 Section { 324 Section {
314 if let deps = model.deps { 325 if let deps = model.deps {
gitbay/Views/Repos/WebhookListView.swift added +213
@@ -0,0 +1,213 @@
1import SwiftUI
2
3/// A repository's webhooks: list, add, remove, and the way to its
4/// deliveries. Reached from repo settings.
5struct WebhookListView: View {
6
7 @State private var model: WebhookListViewModel
8 @State private var adding = false
9 @State private var removingHook: Webhook?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: WebhookListViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 if let error = model.actionError {
18 Section {
19 GBNotice(error, .gbWarn)
20 }
21 }
22 Section {
23 NavigationLink(value: RepoRoute.webhookDeliveries(repo: model.repoPath)) {
24 Label("Deliveries", systemImage: "paperplane")
25 .font(.gbSans(.subheadline))
26 }
27 .accessibilityIdentifier("webhook-deliveries-link")
28 }
29 Section {
30 ForEach(model.state.value ?? []) { hook in
31 VStack(alignment: .leading, spacing: 3) {
32 Text(hook.url)
33 .font(.gbMono(.caption))
34 .lineLimit(2)
35 HStack(spacing: 6) {
36 GBChip(hook.events, .secondary)
37 if hook.hasSecret {
38 GBChip("signed", .secondary)
39 }
40 if !hook.active {
41 GBChip("inactive", .gbWarn)
42 }
43 }
44 }
45 .padding(.vertical, 2)
46 .swipeActions {
47 Button("Remove", role: .destructive) {
48 removingHook = hook
49 }
50 .disabled(model.working)
51 }
52 }
53 }
54 }
55 .overlay { LoadStateOverlay(state: model.state) }
56 .navigationTitle("Webhooks")
57 .navigationBarTitleDisplayMode(.inline)
58 .toolbar {
59 ToolbarItem(placement: .topBarTrailing) {
60 Button {
61 adding = true
62 } label: {
63 Image(systemName: "plus")
64 }
65 .disabled(model.working)
66 .accessibilityIdentifier("webhook-add-button")
67 }
68 }
69 .sheet(isPresented: $adding) {
70 WebhookAddSheet(model: model) { adding = false }
71 }
72 .confirmationDialog(
73 "Remove the webhook for \(removingHook?.url ?? "")?",
74 isPresented: Binding(
75 get: { removingHook != nil },
76 set: { if !$0 { removingHook = nil } }
77 ),
78 titleVisibility: .visible
79 ) {
80 Button("Remove", role: .destructive) {
81 if let hook = removingHook {
82 Task { await model.remove(id: hook.id) }
83 }
84 removingHook = nil
85 }
86 Button("Cancel", role: .cancel) {}
87 }
88 .task { await model.load() }
89 .refreshable { await model.load() }
90 }
91}
92
93/// `webhook add` — URL, optional signing secret, optional event list.
94private struct WebhookAddSheet: View {
95 let model: WebhookListViewModel
96 let onAdded: () -> Void
97
98 @State private var url = ""
99 @State private var secret = ""
100 @State private var events = ""
101
102 var body: some View {
103 NavigationStack {
104 Form {
105 Section {
106 TextField("https://…", text: $url)
107 .autocorrectionDisabled()
108 .textInputAutocapitalization(.never)
109 .keyboardType(.URL)
110 .accessibilityIdentifier("webhook-url")
111 SecureField("Secret (optional)", text: $secret)
112 .autocorrectionDisabled()
113 .textInputAutocapitalization(.never)
114 } footer: {
115 Text("With a secret, every delivery is signed so the receiver can verify it.")
116 }
117 Section {
118 TextField("Events", text: $events)
119 .autocorrectionDisabled()
120 .textInputAutocapitalization(.never)
121 } footer: {
122 Text("Comma-separated, such as push,issue.created. Empty means every event.")
123 }
124 if let error = model.actionError {
125 Section {
126 GBNotice(error, .gbWarn)
127 }
128 }
129 }
130 .navigationTitle("New Webhook")
131 .navigationBarTitleDisplayMode(.inline)
132 .toolbar {
133 ToolbarItem(placement: .cancellationAction) {
134 Button("Cancel") { onAdded() }
135 .disabled(model.working)
136 }
137 ToolbarItem(placement: .confirmationAction) {
138 if model.working {
139 ProgressView()
140 } else {
141 Button("Add") {
142 Task {
143 if await model.add(url: url, secret: secret, events: events) {
144 onAdded()
145 }
146 }
147 }
148 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
149 .accessibilityIdentifier("webhook-submit")
150 }
151 }
152 }
153 }
154 .interactiveDismissDisabled(model.working)
155 }
156}
157
158/// `webhook deliveries` — recent deliveries, newest first, with a swipe
159/// to requeue one.
160struct WebhookDeliveriesView: View {
161
162 @State private var model: WebhookDeliveriesViewModel
163
164 init(client: GitbayClient, repo: String) {
165 _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo))
166 }
167
168 var body: some View {
169 List {
170 if let error = model.actionError {
171 Section {
172 GBNotice(error, .gbWarn)
173 }
174 }
175 ForEach(model.state.value ?? []) { delivery in
176 VStack(alignment: .leading, spacing: 3) {
177 HStack(spacing: 6) {
178 Text(delivery.event)
179 .font(.gbSans(.subheadline).weight(.medium))
180 GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn)
181 Spacer()
182 if let code = delivery.lastStatus {
183 Text(String(code))
184 .font(.gbMono(.caption))
185 .foregroundStyle(.secondary)
186 }
187 }
188 Text(delivery.url)
189 .font(.gbMono(.caption2))
190 .foregroundStyle(.secondary)
191 .lineLimit(1)
192 Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")"
193 + (delivery.lastError.map { " — \($0)" } ?? ""))
194 .font(.gbSans(.caption))
195 .foregroundStyle(.tertiary)
196 .lineLimit(2)
197 }
198 .padding(.vertical, 2)
199 .swipeActions {
200 Button("Redeliver") {
201 Task { await model.redeliver(id: delivery.id) }
202 }
203 .disabled(model.working)
204 }
205 }
206 }
207 .overlay { LoadStateOverlay(state: model.state) }
208 .navigationTitle("Deliveries")
209 .navigationBarTitleDisplayMode(.inline)
210 .task { await model.load() }
211 .refreshable { await model.load() }
212 }
213}
gitbayTests/WebhookTests.swift added +146
@@ -0,0 +1,146 @@
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 argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return try #require(body["argv"] as? [String])
18}
19
20private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
21
22private let hooksJSON = """
23 {"protocol_version":1,"data":[\
24 {"id":4,"url":"https://ci.example/hook","events":"*","active":true,"has_secret":true},\
25 {"id":9,"url":"https://chat.example/in","events":"push,issue.created","active":false,"has_secret":false}\
26 ],"exit_code":0}
27 """
28
29private let deliveriesJSON = """
30 {"protocol_version":1,"data":[\
31 {"id":71,"url":"https://ci.example/hook","event":"push","status":"delivered","attempts":1,"last_status":200},\
32 {"id":70,"url":"https://chat.example/in","event":"issue.created","status":"failed","attempts":3,\
33 "last_status":502,"last_error":"bad gateway"}\
34 ],"exit_code":0}
35 """
36
37@MainActor
38struct WebhookListViewModelTests {
39
40 private func loaded() async throws -> (WebhookListViewModel, StubProtocol.Box) {
41 let (client, stub) = try makeClient()
42 stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list"))
43 let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay")
44 await model.load()
45 return (model, stub)
46 }
47
48 @Test func listsHooksWithTheirEventsAndSecretFlag() async throws {
49 let (model, stub) = try await loaded()
50
51 let hooks = try #require(model.state.value)
52 #expect(hooks.map(\.id) == [4, 9])
53 #expect(hooks[0].hasSecret && hooks[0].active)
54 #expect(hooks[1].events == "push,issue.created")
55 #expect(stub.seen.first?.url.query() == "argv=webhook&argv=list&argv=krz/gitbay")
56 }
57
58 @Test func noHooksIsAnEmptyState() async throws {
59 let (client, stub) = try makeClient()
60 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#))
61 let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay")
62
63 await model.load()
64
65 guard case .empty = model.state else {
66 Issue.record("expected .empty, got \(model.state)")
67 return
68 }
69 }
70
71 @Test func addSendsSecretAndEventsOnlyWhenGiven() async throws {
72 let (model, stub) = try await loaded()
73 for _ in 0..<2 {
74 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":10,"url":"u","events":"*"},"exit_code":0}"#, match: "cmd"))
75 stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list"))
76 }
77
78 let first = await model.add(url: " https://a.example/h ", secret: "s3", events: "push")
79 let second = await model.add(url: "https://b.example/h", secret: "", events: "")
80
81 #expect(first && second)
82 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
83 #expect(writes == [
84 ["webhook", "add", "krz/gitbay", "https://a.example/h", "--secret", "s3", "--events", "push"],
85 ["webhook", "add", "krz/gitbay", "https://b.example/h"],
86 ])
87 }
88
89 @Test func removeSendsTheIdThenReloads() async throws {
90 let (model, stub) = try await loaded()
91 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
92 stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list"))
93
94 await model.remove(id: 9)
95
96 let write = try #require(stub.seen.first { $0.method == "POST" })
97 #expect(try argvOf(write) == ["webhook", "remove", "krz/gitbay", "9"])
98 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
99 }
100
101 @Test func aRefusedAddSurfacesAndReturnsFalse() async throws {
102 let (model, stub) = try await loaded()
103 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd"))
104
105 let ok = await model.add(url: "https://a.example/h", secret: "", events: "")
106
107 #expect(!ok)
108 #expect(model.actionError == "admin access required")
109 }
110}
111
112@MainActor
113struct WebhookDeliveriesViewModelTests {
114
115 @Test func listsDeliveriesWithStatusAndLastError() async throws {
116 let (client, stub) = try makeClient()
117 stub.enqueue(.init(status: 200, json: deliveriesJSON))
118 let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay")
119
120 await model.load()
121
122 let rows = try #require(model.state.value)
123 #expect(rows.map(\.id) == [71, 70])
124 #expect(rows[1].status == "failed")
125 #expect(rows[1].lastStatus == 502)
126 #expect(rows[1].lastError == "bad gateway")
127 #expect(rows[0].lastError == nil)
128 #expect(stub.seen.first?.url.query() == "argv=webhook&argv=deliveries&argv=krz/gitbay")
129 }
130
131 @Test func redeliverSendsTheDeliveryIdThenReloads() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries"))
134 let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay")
135 await model.load()
136 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"requeued":70},"exit_code":0}"#, match: "cmd"))
137 stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries"))
138
139 await model.redeliver(id: 70)
140
141 let write = try #require(stub.seen.first { $0.method == "POST" })
142 #expect(try argvOf(write) == ["webhook", "redeliver", "krz/gitbay", "70"])
143 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
144 #expect(model.actionError == nil)
145 }
146}
gitbayUITests/LiveSmokeUITests.swift +25
@@ -444,6 +444,31 @@ extension LiveSmokeUITests {
444 XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") 444 XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
445 confirmGrantRevoke.tap() 445 confirmGrantRevoke.tap()
446 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") 446 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
447
448 // --- webhooks: add one, see it listed, remove it ---
449 let webhooksLink = app.descendants(matching: .any)
450 .matching(identifier: "settings-webhooks-link").firstMatch
451 XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable")
452 webhooksLink.tap()
453 let addHook = app.descendants(matching: .any)
454 .matching(identifier: "webhook-add-button").firstMatch
455 XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load")
456 addHook.tap()
457 let hookURL = app.descendants(matching: .any)
458 .matching(identifier: "webhook-url").firstMatch
459 XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open")
460 focusAndType(hookURL, "https://example.invalid/ui-smoke")
461 app.descendants(matching: .any).matching(identifier: "webhook-submit")
462 .firstMatch.tap()
463 let hookRow = app.staticTexts["https://example.invalid/ui-smoke"].firstMatch
464 XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed")
465 hookRow.swipeLeft()
466 app.buttons["Remove"].firstMatch.tap()
467 let confirmHookRemove = app.buttons["Remove"].firstMatch
468 XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing")
469 confirmHookRemove.tap()
470 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
471 back() // webhooks -> settings
447 back() // settings -> repo 472 back() // settings -> repo
448 back() // repo -> list 473 back() // repo -> list
449 474