Skip to content

Commit 7af59fc

Browse files
committed
add the subscription store and polling loop
internal/notifications holds the per-session subscription set and the poller behind review notifications (ADR 2.2). The store maps change numbers to cursors -- the last-seen updated timestamp -- and advances a cursor in one atomic compare-and-move, so a poll result racing an unsubscribe cannot resurrect the subscription. The poller batches every subscribed change into one change:A OR change:B query per tick and skips the network entirely when nothing is subscribed. Failures are logged to stderr and retried next tick: a background loop has no caller to return errors to, and a transient Gerrit failure must not end the session's subscriptions. Emission goes through the Emitter seam; the channel-backed implementation follows in a separate change. The tracer payload is the fact of movement -- a bare review_activity element; richer activity extraction arrives with the deltas phase. Refs: #51
1 parent 2d13b9d commit 7af59fc

4 files changed

Lines changed: 601 additions & 0 deletions

File tree

internal/notifications/poller.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package notifications
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
gerrit "github.com/andygrunwald/go-gerrit"
11+
12+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
13+
"dev.gaijin.team/go/go-gerrit-mcp/internal/llmxml"
14+
)
15+
16+
// Emitter delivers one rendered notification into the agent's session.
17+
// Content is a rendered llmxml payload; meta carries routing context that
18+
// becomes tag attributes on the injected block, so keys must be limited to
19+
// letters, digits, and underscores.
20+
type Emitter interface {
21+
Emit(ctx context.Context, content string, meta map[string]string) error
22+
}
23+
24+
// Poller periodically queries Gerrit for movement on subscribed changes and
25+
// hands detected activity to the emitter. Failures are logged and retried on
26+
// the next tick — a background loop has no caller to return errors to, and a
27+
// transient Gerrit failure must not end the session's subscriptions.
28+
type Poller struct {
29+
store *Store
30+
client *gerritclient.Client
31+
emitter Emitter
32+
interval time.Duration
33+
lgr *slog.Logger
34+
}
35+
36+
// NewPoller assembles a poller over the given subscription store. It does not
37+
// start anything; the caller runs [Poller.Run] on its own goroutine.
38+
func NewPoller(
39+
store *Store, client *gerritclient.Client, emitter Emitter, interval time.Duration, lgr *slog.Logger,
40+
) *Poller {
41+
return &Poller{
42+
store: store,
43+
client: client,
44+
emitter: emitter,
45+
interval: interval,
46+
lgr: lgr,
47+
}
48+
}
49+
50+
// Run polls at the configured interval until ctx is cancelled. It blocks;
51+
// the caller owns the goroutine.
52+
func (p *Poller) Run(ctx context.Context) {
53+
ticker := time.NewTicker(p.interval)
54+
defer ticker.Stop()
55+
56+
for {
57+
select {
58+
case <-ctx.Done():
59+
return
60+
case <-ticker.C:
61+
p.tick(ctx)
62+
}
63+
}
64+
}
65+
66+
// tick runs one poll cycle: a single batched query over the subscription
67+
// snapshot, cursor advancement, and emission for every change that moved.
68+
// An empty snapshot skips the cycle without touching the network.
69+
func (p *Poller) tick(ctx context.Context) {
70+
changes := p.store.Changes()
71+
if len(changes) == 0 {
72+
return
73+
}
74+
75+
res, err := p.client.QueryChanges(ctx, batchQuery(changes), len(changes), 0)
76+
if err != nil {
77+
p.lgr.Error("review notifications poll", "error", err)
78+
79+
return
80+
}
81+
82+
for i := range res.Changes {
83+
ci := &res.Changes[i]
84+
85+
if !p.store.Advance(ci.Number, ci.Updated.Time) {
86+
continue
87+
}
88+
89+
p.emit(ctx, ci)
90+
}
91+
}
92+
93+
func (p *Poller) emit(ctx context.Context, ci *gerrit.ChangeInfo) {
94+
meta := map[string]string{"change": strconv.Itoa(ci.Number)}
95+
96+
if err := p.emitter.Emit(ctx, renderActivity(ci), meta); err != nil {
97+
p.lgr.Error("review notification emit", "change", ci.Number, "error", err)
98+
}
99+
}
100+
101+
// batchQuery composes one change:A OR change:B query over the snapshot, so a
102+
// tick costs a single request regardless of subscription count.
103+
func batchQuery(changes []int) string {
104+
clauses := make([]string, len(changes))
105+
for i, n := range changes {
106+
clauses[i] = "change:" + strconv.Itoa(n)
107+
}
108+
109+
return strings.Join(clauses, " OR ")
110+
}
111+
112+
// renderActivity renders the tracer-bullet payload: the fact of movement on a
113+
// subscribed change. Activity details (messages, votes, comment threads)
114+
// arrive with the deltas phase.
115+
func renderActivity(ci *gerrit.ChangeInfo) string {
116+
return llmxml.NewElement("review_activity",
117+
llmxml.Attr("change", ci.Number),
118+
llmxml.Attr("project", ci.Project),
119+
llmxml.Attr("status", ci.Status),
120+
llmxml.Attr("updated", ci.Updated.UTC().Format(time.RFC3339)),
121+
).String()
122+
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package notifications
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
"dev.gaijin.team/go/golib/e"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
18+
"dev.gaijin.team/go/go-gerrit-mcp/internal/config"
19+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
20+
)
21+
22+
const selfJSON = ")]}'\n" + `{"_account_id":42,"name":"Review Bot","username":"bot"}`
23+
24+
// changeJSON renders one change query result; updated is a Gerrit-format
25+
// timestamp such as "2026-07-01 10:00:00.000000000".
26+
func changeJSON(updated string) string {
27+
return ")]}'\n" + `[{"_number":123,"project":"core","status":"NEW","updated":"` + updated + `"}]`
28+
}
29+
30+
type emitCall struct {
31+
content string
32+
meta map[string]string
33+
}
34+
35+
// fakeEmitter records emissions; a non-nil err makes every Emit fail.
36+
type fakeEmitter struct {
37+
mu sync.Mutex `exhaustruct:"optional"`
38+
calls []emitCall `exhaustruct:"optional"`
39+
err error `exhaustruct:"optional"`
40+
}
41+
42+
func (f *fakeEmitter) Emit(_ context.Context, content string, meta map[string]string) error {
43+
f.mu.Lock()
44+
defer f.mu.Unlock()
45+
46+
f.calls = append(f.calls, emitCall{content: content, meta: meta})
47+
48+
return f.err
49+
}
50+
51+
func (f *fakeEmitter) recorded() []emitCall {
52+
f.mu.Lock()
53+
defer f.mu.Unlock()
54+
55+
return append([]emitCall(nil), f.calls...)
56+
}
57+
58+
// gerritStub serves the credential-validation endpoint plus change queries,
59+
// counting query requests and answering them via the swappable handler.
60+
type gerritStub struct {
61+
queries atomic.Int64 `exhaustruct:"optional"`
62+
handler atomic.Pointer[http.HandlerFunc] `exhaustruct:"optional"`
63+
}
64+
65+
func (g *gerritStub) setHandler(h http.HandlerFunc) {
66+
g.handler.Store(&h)
67+
}
68+
69+
func (g *gerritStub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
70+
if r.URL.Path == "/a/accounts/self" {
71+
_, _ = w.Write([]byte(selfJSON))
72+
73+
return
74+
}
75+
76+
g.queries.Add(1)
77+
(*g.handler.Load())(w, r)
78+
}
79+
80+
// pollerFixture bundles a poller with the stub Gerrit, the recording
81+
// emitter, and the captured log output behind it.
82+
type pollerFixture struct {
83+
poller *Poller
84+
stub *gerritStub
85+
emitter *fakeEmitter
86+
logs *bytes.Buffer
87+
}
88+
89+
// newTestPoller wires a poller against a stub Gerrit; the interval only
90+
// matters for Run-based tests.
91+
func newTestPoller(t *testing.T, interval time.Duration) *pollerFixture {
92+
t.Helper()
93+
94+
stub := &gerritStub{}
95+
stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
96+
_, _ = w.Write([]byte(")]}'\n[]"))
97+
})
98+
99+
srv := httptest.NewServer(stub)
100+
t.Cleanup(srv.Close)
101+
102+
client, err := gerritclient.New(t.Context(), &config.Config{
103+
GerritURL: srv.URL,
104+
Username: "bot",
105+
Token: "s3cret",
106+
Groups: []config.Group{config.GroupRead},
107+
IncludeTools: nil,
108+
ExcludeTools: nil,
109+
Projects: nil,
110+
AllowForeignChanges: false,
111+
ReviewNotifications: true,
112+
ReviewNotificationsPollInterval: interval,
113+
})
114+
require.NoError(t, err)
115+
116+
emitter := &fakeEmitter{}
117+
logs := &bytes.Buffer{}
118+
lgr := slog.New(slog.NewTextHandler(logs, nil))
119+
120+
return &pollerFixture{
121+
poller: NewPoller(NewStore(), client, emitter, interval, lgr),
122+
stub: stub,
123+
emitter: emitter,
124+
logs: logs,
125+
}
126+
}
127+
128+
func Test_Poller_Tick(t *testing.T) {
129+
t.Parallel()
130+
131+
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
132+
133+
t.Run("movement emits once and advances the cursor", func(t *testing.T) {
134+
t.Parallel()
135+
136+
f := newTestPoller(t, time.Minute)
137+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
138+
_, _ = w.Write([]byte(changeJSON("2026-07-01 11:00:00.000000000")))
139+
})
140+
141+
f.poller.store.Add(123, base)
142+
143+
f.poller.tick(t.Context())
144+
f.poller.tick(t.Context())
145+
146+
calls := f.emitter.recorded()
147+
require.Len(t, calls, 1, "second tick over the same state must be silent")
148+
assert.Equal(t,
149+
`<review_activity change="123" project="core" status="NEW" updated="2026-07-01T11:00:00Z"/>`,
150+
calls[0].content)
151+
assert.Equal(t, map[string]string{"change": "123"}, calls[0].meta)
152+
})
153+
154+
t.Run("no movement emits nothing", func(t *testing.T) {
155+
t.Parallel()
156+
157+
f := newTestPoller(t, time.Minute)
158+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
159+
_, _ = w.Write([]byte(changeJSON("2026-07-01 10:00:00.000000000")))
160+
})
161+
162+
f.poller.store.Add(123, base)
163+
164+
f.poller.tick(t.Context())
165+
166+
assert.Empty(t, f.emitter.recorded())
167+
})
168+
169+
t.Run("empty subscription set skips the query", func(t *testing.T) {
170+
t.Parallel()
171+
172+
f := newTestPoller(t, time.Minute)
173+
174+
f.poller.tick(t.Context())
175+
176+
assert.Zero(t, f.stub.queries.Load(), "no subscriptions must mean no network request")
177+
assert.Empty(t, f.emitter.recorded())
178+
})
179+
180+
t.Run("query failure is logged and the next tick recovers", func(t *testing.T) {
181+
t.Parallel()
182+
183+
f := newTestPoller(t, time.Minute)
184+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
185+
w.WriteHeader(http.StatusInternalServerError)
186+
})
187+
188+
f.poller.store.Add(123, base)
189+
190+
f.poller.tick(t.Context())
191+
192+
assert.Empty(t, f.emitter.recorded())
193+
assert.Contains(t, f.logs.String(), "review notifications poll")
194+
195+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
196+
_, _ = w.Write([]byte(changeJSON("2026-07-01 11:00:00.000000000")))
197+
})
198+
199+
f.poller.tick(t.Context())
200+
201+
assert.Len(t, f.emitter.recorded(), 1, "poll failure must not end the subscription")
202+
})
203+
204+
t.Run("emit failure is logged and does not stop the tick", func(t *testing.T) {
205+
t.Parallel()
206+
207+
f := newTestPoller(t, time.Minute)
208+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
209+
_, _ = w.Write([]byte(changeJSON("2026-07-01 11:00:00.000000000")))
210+
})
211+
212+
f.emitter.err = e.New("session gone")
213+
214+
f.poller.store.Add(123, base)
215+
216+
f.poller.tick(t.Context())
217+
218+
assert.Len(t, f.emitter.recorded(), 1)
219+
assert.Contains(t, f.logs.String(), "review notification emit")
220+
})
221+
222+
t.Run("result for an unsubscribed change is ignored", func(t *testing.T) {
223+
t.Parallel()
224+
225+
f := newTestPoller(t, time.Minute)
226+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
227+
_, _ = w.Write([]byte(changeJSON("2026-07-01 11:00:00.000000000")))
228+
})
229+
230+
f.poller.store.Add(456, base)
231+
232+
f.poller.tick(t.Context())
233+
234+
assert.Empty(t, f.emitter.recorded())
235+
})
236+
}
237+
238+
func Test_Poller_Run(t *testing.T) {
239+
t.Parallel()
240+
241+
const pollDeadline = 10 * time.Second
242+
243+
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
244+
245+
f := newTestPoller(t, 5*time.Millisecond)
246+
f.stub.setHandler(func(w http.ResponseWriter, _ *http.Request) {
247+
_, _ = w.Write([]byte(changeJSON("2026-07-01 11:00:00.000000000")))
248+
})
249+
250+
f.poller.store.Add(123, base)
251+
252+
ctx, cancel := context.WithCancel(t.Context())
253+
done := make(chan struct{})
254+
255+
go func() {
256+
defer close(done)
257+
258+
f.poller.Run(ctx)
259+
}()
260+
261+
require.Eventually(t, func() bool {
262+
return len(f.emitter.recorded()) > 0
263+
}, pollDeadline, time.Millisecond, "ticker must fire and emit")
264+
265+
cancel()
266+
267+
select {
268+
case <-done:
269+
case <-time.After(pollDeadline):
270+
t.Fatal("Run did not stop on context cancellation")
271+
}
272+
}

0 commit comments

Comments
 (0)