From 78a672314361ad8965115effaf83bba838275aa8 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Mon, 5 Oct 2026 01:57:05 +0100 Subject: [PATCH] fix(web): run automatic actions after a repository rescan The rescan button of one repository now triggers automatic actions too. The webhook calls run in the background so the response does not wait for them. --- README.md | 2 +- internal/collector/collector.go | 14 +++++++++--- internal/collector/collector_test.go | 34 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9d2ae73..bd83b3e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ actions: ### Automatic actions -An action with `auto: true` fires without a click. After every scan, and once at startup, the server sends the webhook for each current alert whose kind is in `kinds`. It sends once per action, repo, alert URL and head commit. A new commit on a pull request is a new send. +An action with `auto: true` fires without a click. After every scan, after every single-repository rescan, and once at startup, the server sends the webhook for each current alert whose kind is in `kinds`. It sends once per action, repo, alert URL and head commit. A new commit on a pull request is a new send. ```yaml actions: diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 6ae067f..eee0f20 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -119,10 +119,13 @@ func (c *Collector) OnRefresh(fn func(context.Context, View)) { c.onRefresh = ap func (c *Collector) refreshAndNotify(ctx context.Context) { c.Refresh(ctx) - if len(c.onRefresh) == 0 || ctx.Err() != nil { + if ctx.Err() != nil { return } - v := c.View() + c.notify(ctx, c.View()) +} + +func (c *Collector) notify(ctx context.Context, v View) { for _, fn := range c.onRefresh { fn(ctx, v) } @@ -239,7 +242,12 @@ func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error) c.mu.Lock() c.repos[canon] = r c.mu.Unlock() - return c.View(), nil + v := c.View() + if len(c.onRefresh) > 0 { + // The request context ends with the response, which must not wait for webhooks. + go c.notify(context.WithoutCancel(ctx), v) + } + return v, nil } func (c *Collector) configured(name string) (string, bool) { diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index b6e1d9e..697a2fe 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -519,3 +519,37 @@ func TestPROpenDoesNotChangeCounts(t *testing.T) { t.Fatalf("view = %+v", v) } } + +func TestRefreshRepoRunsRefreshHooksWithTheNewView(t *testing.T) { + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, 3)}, nil + }} + c := newCollector(f, "o/a") + got := make(chan View, 1) + c.OnRefresh(func(_ context.Context, v View) { got <- v }) + if _, err := c.RefreshRepo(context.Background(), "o/a"); err != nil { + t.Fatal(err) + } + select { + case v := <-got: + if len(v.Repos) != 1 || len(v.Repos[0].Issues) != 3 { + t.Fatalf("hook saw a stale view: %+v", v.Repos) + } + case <-time.After(2 * time.Second): + t.Fatal("refresh hook did not run after a repository refresh") + } +} + +func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) { + c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a") + called := make(chan struct{}, 1) + c.OnRefresh(func(context.Context, View) { called <- struct{}{} }) + if _, err := c.RefreshRepo(context.Background(), "unknown/x"); err == nil { + t.Fatal("unknown repository must fail") + } + select { + case <-called: + t.Fatal("hook ran for a refused refresh") + case <-time.After(200 * time.Millisecond): + } +}