webhooks: the other half of the mutations record an event !239

merged merged by cmc on 2026-09-04 17:13 UTC · krz/gitbay:webhook-events into main

10 files changed, +252 −3

Layout: unified · split

e2e/webhook_test.go +53
@@ -254,3 +254,56 @@ func TestWebhooks(t *testing.T) {
254 t.Fatal("non-http scheme accepted") 254 t.Fatal("non-http scheme accepted")
255 } 255 }
256} 256}
257
258// TestWebhookEventCoverage drives the mutations #112 found unrecorded and
259// asserts each reaches a subscriber. Half the forge's mutations recorded
260// nothing, so a webhook could be subscribed to them and never fire.
261func TestWebhookEventCoverage(t *testing.T) {
262 inst := startInstance(t)
263 aliceKey := inst.newKey(t, "alice")
264 bobKey := inst.newKey(t, "bob")
265 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
266 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
267 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
268 t.Fatalf("repo create: %s", errOut)
269 }
270 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
271 t.Fatal("grant failed")
272 }
273
274 // A name that is not an event is refused rather than silently never
275 // firing.
276 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "add", "alice/app",
277 "https://example.test/h", "--events", "issue.tagged"); code != 2 ||
278 !strings.Contains(errOut, "not an event this forge records") {
279 t.Fatalf("bad event name: exit %d, %s", code, errOut)
280 }
281
282 // Drive each newly recorded mutation.
283 steps := [][]string{
284 {"issue", "create", "alice/app", "--title", "'first'", "--body", "'b'"},
285 {"issue", "edit", "alice/app", "1", "--title", "'renamed'"},
286 {"issue", "label", "alice/app", "1", "--add", "bug"},
287 {"issue", "assign", "alice/app", "1", "--add", "bob"},
288 {"milestone", "create", "alice/app", "v1"},
289 {"issue", "milestone", "alice/app", "1", "v1"},
290 }
291 for _, s := range steps {
292 if _, errOut, code := inst.ssh(t, aliceKey, "", s...); code != 0 {
293 t.Fatalf("%v: %s", s, errOut)
294 }
295 }
296
297 out, errOut, code := inst.ssh(t, aliceKey, "", "feed", "--json")
298 if code != 0 {
299 t.Fatalf("feed: %s", errOut)
300 }
301 for _, kind := range []string{
302 "issue.created", "issue.edited", "issue.labeled",
303 "issue.assigned", "issue.milestoned",
304 } {
305 if !strings.Contains(out, `"`+kind+`"`) {
306 t.Errorf("%s was not recorded:\n%s", kind, out)
307 }
308 }
309}
internal/control/events.go added +66
@@ -0,0 +1,66 @@
1package control
2
3import (
4 "slices"
5 "strings"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10// EventKinds is every event this forge records, and so every value a
11// webhook's `--events` filter may name. It is the published list: the API
12// wiki page renders it, and TestEventKindsAreRecorded asserts the code
13// still emits exactly these, so the documentation cannot drift from the
14// server (#112).
15//
16// Not here, deliberately: repo.deleted. events.repo_id and
17// webhooks.repo_id both cascade from repos, so recording one would delete
18// it and every webhook that could have received it in the same statement.
19var EventKinds = []string{
20 "build.cancelled",
21 "build.failure",
22 "build.success",
23 "issue.assigned",
24 "issue.closed",
25 "issue.commented",
26 "issue.created",
27 "issue.edited",
28 "issue.labeled",
29 "issue.milestoned",
30 "issue.open",
31 "mr.closed",
32 "mr.commented",
33 "mr.created",
34 "mr.edited",
35 "mr.merged",
36 "mr.milestoned",
37 "mr.retargeted",
38 "mr.reviewed",
39 "push",
40 "release.created",
41 "release.deleted",
42 "repo.archived",
43 "repo.imported",
44 "repo.unarchived",
45 "status",
46}
47
48// checkEventNames refuses a --events list naming something this forge
49// never emits. "*" is every kind. -1 means proceed.
50func checkEventNames(c *Ctx, events string) int {
51 if events == "*" {
52 return -1
53 }
54 for _, k := range strings.Split(events, ",") {
55 k = strings.TrimSpace(k)
56 if k == "" {
57 return c.fail(protocol.ExitUsage, "empty event name in --events")
58 }
59 if !slices.Contains(EventKinds, k) {
60 return c.fail(protocol.ExitUsage,
61 "%q is not an event this forge records; known events are: %s",
62 k, strings.Join(EventKinds, ", "))
63 }
64 }
65 return -1
66}
internal/control/events_test.go added +70
@@ -0,0 +1,70 @@
1package control
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "slices"
8 "strings"
9 "testing"
10)
11
12// recordEventKind pulls the kind out of a RecordEvent call. The kind is
13// either a literal or a literal prefix concatenated with a variable, and
14// the second shape is why this reads source rather than trusting a list.
15var recordEventKind = regexp.MustCompile(`RecordEvent\([^,]+,\s*[^,]+,\s*"([a-z.]+)"`)
16
17// TestEventKindsAreRecorded keeps the published list and the code
18// together: an event added without documenting it, or documented without
19// being emitted, fails here. Webhook subscribers filter on these strings,
20// so a name that exists in only one of the two places is a subscription
21// that silently never fires (#112).
22func TestEventKindsAreRecorded(t *testing.T) {
23 emitted := map[string]bool{}
24 roots := []string{".", filepath.Join("..", "hookd")}
25 for _, root := range roots {
26 entries, err := os.ReadDir(root)
27 if err != nil {
28 t.Fatal(err)
29 }
30 for _, e := range entries {
31 if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
32 continue
33 }
34 src, err := os.ReadFile(filepath.Join(root, e.Name()))
35 if err != nil {
36 t.Fatal(err)
37 }
38 for _, m := range recordEventKind.FindAllStringSubmatch(string(src), -1) {
39 emitted[m[1]] = true
40 }
41 }
42 }
43 // The kinds built from a variable suffix, which the pattern above
44 // sees only as their prefix. Each is spelled out so the list stays
45 // exhaustive rather than approximate.
46 for _, k := range []string{
47 "build.success", "build.failure", // "build."+args[1]
48 "issue.closed", "issue.open", // "issue."+state
49 "repo.archived", "repo.unarchived", // "repo."+verb+"d"
50 "issue.commented", "mr.commented", // thread.event
51 "issue.milestoned", "mr.milestoned", // noun+".milestoned"
52 } {
53 emitted[k] = true
54 }
55 // Prefixes the pattern caught from those concatenations.
56 for _, partial := range []string{"build.", "issue.", "repo."} {
57 delete(emitted, partial)
58 }
59
60 for _, k := range EventKinds {
61 if !emitted[k] {
62 t.Errorf("EventKinds lists %q but nothing records it", k)
63 }
64 }
65 for k := range emitted {
66 if !slices.Contains(EventKinds, k) {
67 t.Errorf("%q is recorded but missing from EventKinds; add it, and to the API wiki page", k)
68 }
69 }
70}
internal/control/issue.go +11
@@ -344,6 +344,13 @@ func runIssueEdit(c *Ctx, args []string) int {
344 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil { 344 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
345 return c.fail(protocol.ExitFailure, "%v", err) 345 return c.fail(protocol.ExitFailure, "%v", err)
346 } 346 }
347 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
348 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
349 notify(c, parts, notice{repo: repo, kind: "issue",
350 subject: issueSubject(repo, issue.Number, issue.Title),
351 action: fmt.Sprintf("edited #%d", issue.Number),
352 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
353 }
347 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { 354 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
348 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number) 355 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
349 }) 356 })
@@ -394,6 +401,8 @@ func runIssueLabel(c *Ctx, args []string) int {
394 if err != nil { 401 if err != nil {
395 return c.fail(protocol.ExitFailure, "%v", err) 402 return c.fail(protocol.ExitFailure, "%v", err)
396 } 403 }
404 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
405 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
397 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) { 406 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
398 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", ")) 407 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
399 }) 408 })
@@ -449,6 +458,8 @@ func runIssueAssign(c *Ctx, args []string) int {
449 if err != nil { 458 if err != nil {
450 return c.fail(protocol.ExitFailure, "%v", err) 459 return c.fail(protocol.ExitFailure, "%v", err)
451 } 460 }
461 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
462 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
452 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) { 463 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
453 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", ")) 464 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
454 }) 465 })
internal/control/milestone.go +10 −3
@@ -159,7 +159,7 @@ func runIssueMilestone(c *Ctx, args []string) int {
159 if len(args) != 3 { 159 if len(args) != 3 {
160 return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>") 160 return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>")
161 } 161 }
162 return setItemMilestone(c, repo, args[2], func(id int64) error { 162 return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error {
163 return c.Store.SetIssueMilestone(issue.ID, id) 163 return c.Store.SetIssueMilestone(issue.ID, id)
164 }) 164 })
165} 165}
@@ -175,12 +175,13 @@ func runMRMilestone(c *Ctx, args []string) int {
175 if len(args) != 3 { 175 if len(args) != 3 {
176 return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>") 176 return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>")
177 } 177 }
178 return setItemMilestone(c, repo, args[2], func(id int64) error { 178 return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error {
179 return c.Store.SetMRMilestone(mr.ID, id) 179 return c.Store.SetMRMilestone(mr.ID, id)
180 }) 180 })
181} 181}
182 182
183func setItemMilestone(c *Ctx, repo store.Repo, title string, set func(int64) error) int { 183// noun and number name what the milestone was set on, for the event.
184func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
184 var id int64 185 var id int64
185 if title != "none" { 186 if title != "none" {
186 m, err := c.Store.MilestoneByTitle(repo.ID, title) 187 m, err := c.Store.MilestoneByTitle(repo.ID, title)
@@ -192,6 +193,12 @@ func setItemMilestone(c *Ctx, repo store.Repo, title string, set func(int64) err
192 if err := set(id); err != nil { 193 if err := set(id); err != nil {
193 return c.fail(protocol.ExitFailure, "%v", err) 194 return c.fail(protocol.ExitFailure, "%v", err)
194 } 195 }
196 cleared := title
197 if cleared == "none" {
198 cleared = ""
199 }
200 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
201 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
195 if title == "none" { 202 if title == "none" {
196 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) { 203 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
197 fmt.Fprintln(w, "milestone cleared") 204 fmt.Fprintln(w, "milestone cleared")
internal/control/mr.go +12
@@ -601,6 +601,7 @@ func runMREdit(c *Ctx, args []string) int {
601 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { 601 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
602 return c.fail(protocol.ExitFailure, "%v", err) 602 return c.fail(protocol.ExitFailure, "%v", err)
603 } 603 }
604 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number))
604 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { 605 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
605 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) 606 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
606 }) 607 })
@@ -649,6 +650,8 @@ func runMRRetarget(c *Ctx, args []string) int {
649 return c.fail(protocol.ExitFailure, "%v", err) 650 return c.fail(protocol.ExitFailure, "%v", err)
650 } 651 }
651 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target)) 652 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
653 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
654 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
652 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 655 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
653 notify(c, parts, notice{repo: repo, kind: "mr", 656 notify(c, parts, notice{repo: repo, kind: "mr",
654 subject: mrSubject(repo, mr.Number, mr.Title), 657 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -700,6 +703,8 @@ func runMRReview(c *Ctx, args []string) int {
700 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil { 703 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
701 return c.fail(protocol.ExitFailure, "%v", err) 704 return c.fail(protocol.ExitFailure, "%v", err)
702 } 705 }
706 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
707 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
703 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 708 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
704 notify(c, parts, notice{repo: repo, kind: "mr", 709 notify(c, parts, notice{repo: repo, kind: "mr",
705 subject: mrSubject(repo, mr.Number, mr.Title), 710 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -1171,6 +1176,13 @@ func runMRClose(c *Ctx, args []string) int {
1171 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { 1176 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1172 return c.fail(protocol.ExitFailure, "%v", err) 1177 return c.fail(protocol.ExitFailure, "%v", err)
1173 } 1178 }
1179 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", fmt.Sprintf(`{"number":%d}`, mr.Number))
1180 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1181 notify(c, parts, notice{repo: repo, kind: "mr",
1182 subject: mrSubject(repo, mr.Number, mr.Title),
1183 action: fmt.Sprintf("closed !%d", mr.Number),
1184 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1185 }
1174 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) { 1186 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1175 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number) 1187 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1176 }) 1188 })
internal/control/output.go +16
@@ -1,5 +1,7 @@
1package control 1package control
2 2
3import "encoding/json"
4
3// Named payloads for the commands other surfaces decode. A command used 5// Named payloads for the commands other surfaces decode. A command used
4// to declare its output inline, so the web type-asserted map keys and 6// to declare its output inline, so the web type-asserted map keys and
5// nothing tied what a command emits to what a client reads (#126). 7// nothing tied what a command emits to what a client reads (#126).
@@ -55,3 +57,17 @@ type CommitOut struct {
55 SHA string `json:"sha"` 57 SHA string `json:"sha"`
56 Subject string `json:"subject"` 58 Subject string `json:"subject"`
57} 59}
60
61// jsonStrings renders a string slice as a JSON array, for the event data
62// the webhook payload carries. A nil slice is [], never null: a consumer
63// iterating the field should not have to special-case one of them.
64func jsonStrings(ss []string) string {
65 if ss == nil {
66 ss = []string{}
67 }
68 raw, err := json.Marshal(ss)
69 if err != nil {
70 return "[]"
71 }
72 return string(raw)
73}
internal/control/release.go +1
@@ -259,6 +259,7 @@ func runReleaseDelete(c *Ctx, args []string) int {
259 return c.fail(protocol.ExitFailure, "%v", err) 259 return c.fail(protocol.ExitFailure, "%v", err)
260 } 260 }
261 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID)) 261 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
262 c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
262 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) { 263 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
263 fmt.Fprintf(w, "deleted release %s\n", rel.Tag) 264 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
264 }) 265 })
internal/control/repo.go +7
@@ -430,6 +430,13 @@ func runRepoDelete(c *Ctx, args []string) int {
430 430
431// deleteRepo removes a repository the caller has already been cleared to 431// deleteRepo removes a repository the caller has already been cleared to
432// delete: the database row, then the directory and its wiki companion. 432// delete: the database row, then the directory and its wiki companion.
433//
434// There is deliberately no repo.deleted event. events.repo_id and
435// webhooks.repo_id both cascade from repos, so recording one would delete
436// it, and every webhook that could have subscribed, in the same
437// statement. A repository's deletion is not observable through its own
438// webhooks; an instance that needs to hear about it wants the audit log
439// (#112).
433func deleteRepo(c *Ctx, repo store.Repo) int { 440func deleteRepo(c *Ctx, repo store.Repo) int {
434 // Open MRs sourced from this repo keep working (targets own the 441 // Open MRs sourced from this repo keep working (targets own the
435 // objects) but must show that the source is gone. 442 // objects) but must show that the source is gone.
internal/control/webhook.go +6
@@ -46,6 +46,12 @@ func runWebhookAdd(c *Ctx, args []string) int {
46 if code >= 0 { 46 if code >= 0 {
47 return code 47 return code
48 } 48 }
49 // A name that is not an event is a subscription that never fires, and
50 // nothing would ever say so. Checked before the URL, which resolves
51 // DNS: a typo here should not need a reachable host to report.
52 if code := checkEventNames(c, events); code >= 0 {
53 return code
54 }
49 if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil { 55 if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil {
50 return c.failErr(err) 56 return c.failErr(err)
51 } 57 }