Skip to content

Commit 0e658fe

Browse files
authored
Merge pull request #51 from codeGROOVE-dev/reliable
daily reminders, better email lookups
2 parents 24a17fa + 573e7dc commit 0e658fe

File tree

7 files changed

+810
-22
lines changed

7 files changed

+810
-22
lines changed

go.mod

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ go 1.25.1
44

55
require (
66
cloud.google.com/go/datastore v1.21.0
7-
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251019162917-c3412c017b1f
7+
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9
88
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22
99
github.com/codeGROOVE-dev/retry v1.2.0
1010
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251020171924-1aac68f58e14
11-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251018202306-7cdc0d51856e
11+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251022064427-5a712e1e10e6
1212
github.com/golang-jwt/jwt/v5 v5.3.0
1313
github.com/google/go-github/v50 v50.2.0
1414
github.com/gorilla/mux v1.8.1
@@ -27,7 +27,7 @@ require (
2727
cloud.google.com/go/compute/metadata v0.9.0 // indirect
2828
github.com/ProtonMail/go-crypto v1.3.0 // indirect
2929
github.com/cloudflare/circl v1.6.1 // indirect
30-
github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29 // indirect
30+
github.com/codeGROOVE-dev/prx v0.0.0-20251024000018-35ba2605d031 // indirect
3131
github.com/felixge/httpsnoop v1.0.4 // indirect
3232
github.com/go-logr/logr v1.4.3 // indirect
3333
github.com/go-logr/stdr v1.2.2 // indirect
@@ -47,10 +47,10 @@ require (
4747
golang.org/x/net v0.46.0 // indirect
4848
golang.org/x/sys v0.37.0 // indirect
4949
golang.org/x/text v0.30.0 // indirect
50-
google.golang.org/api v0.252.0 // indirect
51-
google.golang.org/genproto v0.0.0-20251020155222-88f65dc88635 // indirect
52-
google.golang.org/genproto/googleapis/api v0.0.0-20251020155222-88f65dc88635 // indirect
53-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 // indirect
50+
google.golang.org/api v0.253.0 // indirect
51+
google.golang.org/genproto v0.0.0-20251022142026-3a174f9686a8 // indirect
52+
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
53+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
5454
google.golang.org/grpc v1.76.0 // indirect
5555
google.golang.org/protobuf v1.36.10 // indirect
5656
)

go.sum

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ
1414
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
1515
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
1616
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
17-
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251019162917-c3412c017b1f h1:lkuu2eQdeS1BYrBQ6yH2QHdNRLH4spoGjWdRIUJr7eE=
18-
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251019162917-c3412c017b1f/go.mod h1:4Hr2ySB8dcpeZqZq/7UbXdEJ/5RK9coYGHvW90ZfieE=
17+
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9 h1:eyWcEZd3xyLV2WxShoyKWakFyxQGvOSv89ponU3Ah0I=
18+
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9/go.mod h1:4Hr2ySB8dcpeZqZq/7UbXdEJ/5RK9coYGHvW90ZfieE=
1919
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22 h1:gtN3rOc6YspO646BkcOxBhPjEqKUz+jl175jIqglfDg=
2020
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22/go.mod h1:KV+w19ubP32PxZPE1hOtlCpTaNpF0Bpb32w5djO8UTg=
21-
github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29 h1:MSBy3Ywr3ky/LXhDSFbeJXDdAsfMMrzNdMNehyTvSuA=
22-
github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29/go.mod h1:7qLbi18baOyS8yO/6/64SBIqtyzSzLFdsDST15NPH3w=
21+
github.com/codeGROOVE-dev/prx v0.0.0-20251024000018-35ba2605d031 h1:wLeo/dwpE5F2E/j/W+lb2HJ9nEX8KYIfJ1yEtyZYdrg=
22+
github.com/codeGROOVE-dev/prx v0.0.0-20251024000018-35ba2605d031/go.mod h1:7qLbi18baOyS8yO/6/64SBIqtyzSzLFdsDST15NPH3w=
2323
github.com/codeGROOVE-dev/retry v1.2.0 h1:xYpYPX2PQZmdHwuiQAGGzsBm392xIMl4nfMEFApQnu8=
2424
github.com/codeGROOVE-dev/retry v1.2.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E=
2525
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251020171924-1aac68f58e14 h1:AKcULaDrbhKDkf6vpWGo36iyLoiOVhLu1MFcnNmDbWg=
2626
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251020171924-1aac68f58e14/go.mod h1:/kd3ncsRNldD0MUpbtp5ojIzfCkyeXB7JdOrpuqG7Gg=
27-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251018202306-7cdc0d51856e h1:3qoY6h8SgoeNsIYRM7P6PegTXAHPo8OSOapUunVP/Gs=
28-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251018202306-7cdc0d51856e/go.mod h1:fYwtN9Ql6lY8t2WvCfENx+mP5FUwjlqwXCLx9CVLY20=
27+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251022064427-5a712e1e10e6 h1:7FCmaftkl362oTZHVJyUg+xhxqfQFx+JisBf7RgklL8=
28+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251022064427-5a712e1e10e6/go.mod h1:fYwtN9Ql6lY8t2WvCfENx+mP5FUwjlqwXCLx9CVLY20=
2929
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3030
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3131
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
@@ -116,14 +116,14 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
116116
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
117117
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
118118
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
119-
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
120-
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
121-
google.golang.org/genproto v0.0.0-20251020155222-88f65dc88635 h1:I5FLgnlmGA5voD3BZp9Rc17FGiius/DlMB3WsJ1C4Xw=
122-
google.golang.org/genproto v0.0.0-20251020155222-88f65dc88635/go.mod h1:1Ic78BnpzY8OaTCmzxJDP4qC9INZPbGZl+54RKjtyeI=
123-
google.golang.org/genproto/googleapis/api v0.0.0-20251020155222-88f65dc88635 h1:1wvBeYv+A2zfEbxROscJl69OP0m74S8wGEO+Syat26o=
124-
google.golang.org/genproto/googleapis/api v0.0.0-20251020155222-88f65dc88635/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
125-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 h1:3uycTxukehWrxH4HtPRtn1PDABTU331ViDjyqrUbaog=
126-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
119+
google.golang.org/api v0.253.0 h1:apU86Eq9Q2eQco3NsUYFpVTfy7DwemojL7LmbAj7g/I=
120+
google.golang.org/api v0.253.0/go.mod h1:PX09ad0r/4du83vZVAaGg7OaeyGnaUmT/CYPNvtLCbw=
121+
google.golang.org/genproto v0.0.0-20251022142026-3a174f9686a8 h1:a12a2/BiVRxRWIqBbfqoSK6tgq8cyUgMnEI81QlPge0=
122+
google.golang.org/genproto v0.0.0-20251022142026-3a174f9686a8/go.mod h1:1Ic78BnpzY8OaTCmzxJDP4qC9INZPbGZl+54RKjtyeI=
123+
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
124+
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
125+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
126+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
127127
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
128128
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
129129
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package config
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestConfigCacheRace tests that concurrent cache access doesn't race.
10+
// Run with: go test -race -run TestConfigCacheRace
11+
func TestConfigCacheRace(t *testing.T) {
12+
cache := &configCache{
13+
entries: make(map[string]configCacheEntry),
14+
ttl: 1 * time.Hour,
15+
}
16+
17+
// Populate cache with test data
18+
testConfig := &RepoConfig{}
19+
cache.set("test-org", testConfig)
20+
21+
// Spawn 100 concurrent goroutines that all try to read from cache
22+
// This would trigger a race condition if counters weren't atomic
23+
const numGoroutines = 100
24+
var wg sync.WaitGroup
25+
wg.Add(numGoroutines)
26+
27+
for i := 0; i < numGoroutines; i++ {
28+
go func() {
29+
defer wg.Done()
30+
// Each goroutine performs 100 cache accesses
31+
for j := 0; j < 100; j++ {
32+
// Mix of hits and misses
33+
if j%2 == 0 {
34+
cache.get("test-org") // Cache hit
35+
} else {
36+
cache.get("nonexistent-org") // Cache miss
37+
}
38+
}
39+
}()
40+
}
41+
42+
wg.Wait()
43+
44+
// Verify statistics are consistent
45+
hits, misses := cache.stats()
46+
total := hits + misses
47+
expected := numGoroutines * 100 // 100 goroutines * 100 accesses each
48+
49+
if total != int64(expected) {
50+
t.Errorf("cache statistics inconsistent: got %d total accesses (hits=%d, misses=%d), expected %d",
51+
total, hits, misses, expected)
52+
}
53+
54+
t.Logf("Cache statistics after concurrent access: hits=%d, misses=%d, total=%d", hits, misses, total)
55+
}
56+
57+
// TestConfigCacheStatsNoLock verifies that stats() doesn't need locks.
58+
func TestConfigCacheStatsNoLock(t *testing.T) {
59+
cache := &configCache{
60+
entries: make(map[string]configCacheEntry),
61+
ttl: 1 * time.Hour,
62+
}
63+
64+
testConfig := &RepoConfig{}
65+
cache.set("test-org", testConfig)
66+
67+
// Spawn goroutines that continuously read stats while others access cache
68+
var wg sync.WaitGroup
69+
done := make(chan struct{})
70+
71+
// Reader goroutines - continuously call stats()
72+
for i := 0; i < 10; i++ {
73+
wg.Add(1)
74+
go func() {
75+
defer wg.Done()
76+
for {
77+
select {
78+
case <-done:
79+
return
80+
default:
81+
cache.stats() // Should not race with get() calls
82+
}
83+
}
84+
}()
85+
}
86+
87+
// Writer goroutines - continuously call get()
88+
for i := 0; i < 10; i++ {
89+
wg.Add(1)
90+
go func() {
91+
defer wg.Done()
92+
for j := 0; j < 1000; j++ {
93+
cache.get("test-org")
94+
}
95+
}()
96+
}
97+
98+
// Let readers complete, then signal stats readers to stop
99+
time.Sleep(100 * time.Millisecond)
100+
close(done)
101+
wg.Wait()
102+
103+
hits, misses := cache.stats()
104+
t.Logf("Final cache statistics: hits=%d, misses=%d", hits, misses)
105+
}

internal/notify/daily_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package notify
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/codeGROOVE-dev/slacker/pkg/home"
9+
)
10+
11+
func TestFormatDigestMessage(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
time time.Time
15+
incoming []home.PR
16+
outgoing []home.PR
17+
expected string
18+
}{
19+
{
20+
name: "incoming PRs only at 8:30am",
21+
time: time.Date(2025, 10, 22, 8, 30, 0, 0, time.UTC),
22+
incoming: []home.PR{
23+
{
24+
URL: "https://github.com/codeGROOVE-dev/slacker/pull/123",
25+
Title: "Add daily digest feature",
26+
Author: "otheruser",
27+
ActionKind: "review",
28+
},
29+
},
30+
outgoing: nil,
31+
expected: `☀️ *Good morning!*
32+
33+
*To Review:*
34+
:hourglass: <https://github.com/codeGROOVE-dev/slacker/pull/123|Add daily digest feature> · review
35+
36+
_Your daily digest from Ready to Review_`,
37+
},
38+
{
39+
name: "outgoing PRs only at 8:15am",
40+
time: time.Date(2025, 10, 22, 8, 15, 0, 0, time.UTC),
41+
incoming: nil,
42+
outgoing: []home.PR{
43+
{
44+
URL: "https://github.com/codeGROOVE-dev/slacker/pull/124",
45+
Title: "Fix authentication bug",
46+
Author: "testuser",
47+
ActionKind: "address-feedback",
48+
},
49+
},
50+
expected: `🌻 *Hello sunshine!*
51+
52+
*Your PRs:*
53+
:hourglass: <https://github.com/codeGROOVE-dev/slacker/pull/124|Fix authentication bug> · address-feedback
54+
55+
_Your daily digest from Ready to Review_`,
56+
},
57+
{
58+
name: "both incoming and outgoing at 8:45am",
59+
time: time.Date(2025, 10, 22, 8, 45, 0, 0, time.UTC),
60+
incoming: []home.PR{
61+
{
62+
URL: "https://github.com/codeGROOVE-dev/goose/pull/456",
63+
Title: "Implement new API endpoint",
64+
Author: "colleague",
65+
ActionKind: "review",
66+
},
67+
{
68+
URL: "https://github.com/codeGROOVE-dev/goose/pull/457",
69+
Title: "Refactor database layer",
70+
Author: "teammate",
71+
ActionKind: "approve",
72+
},
73+
},
74+
outgoing: []home.PR{
75+
{
76+
URL: "https://github.com/codeGROOVE-dev/goose/pull/458",
77+
Title: "Update documentation",
78+
Author: "testuser",
79+
ActionKind: "merge",
80+
},
81+
},
82+
expected: `🌻 *Hello sunshine!*
83+
84+
*To Review:*
85+
:hourglass: <https://github.com/codeGROOVE-dev/goose/pull/456|Implement new API endpoint> · review
86+
:hourglass: <https://github.com/codeGROOVE-dev/goose/pull/457|Refactor database layer> · approve
87+
88+
*Your PRs:*
89+
:hourglass: <https://github.com/codeGROOVE-dev/goose/pull/458|Update documentation> · merge
90+
91+
_Your daily digest from Ready to Review_`,
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
scheduler := &DailyDigestScheduler{}
98+
got := scheduler.formatDigestMessageAt(tt.incoming, tt.outgoing, tt.time)
99+
100+
if got != tt.expected {
101+
t.Errorf("formatDigestMessageAt() mismatch\nGot:\n%s\n\nExpected:\n%s", got, tt.expected)
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestDigestMessageVariety(t *testing.T) {
108+
// Test that different times produce different greetings
109+
scheduler := &DailyDigestScheduler{}
110+
111+
incoming := []home.PR{
112+
{
113+
URL: "https://github.com/codeGROOVE-dev/slacker/pull/1",
114+
Title: "Test PR",
115+
Author: "other",
116+
ActionKind: "review",
117+
},
118+
}
119+
120+
// Collect messages at different times
121+
messages := make(map[string]bool)
122+
for hour := 8; hour < 9; hour++ {
123+
for minute := 0; minute < 60; minute += 15 {
124+
testTime := time.Date(2025, 10, 22, hour, minute, 0, 0, time.UTC)
125+
msg := scheduler.formatDigestMessageAt(incoming, nil, testTime)
126+
messages[msg] = true
127+
}
128+
}
129+
130+
// Should have at least 2 different message variations in the 8-9am window
131+
if len(messages) < 2 {
132+
t.Errorf("Expected message variety, but got only %d unique messages in 1-hour window", len(messages))
133+
}
134+
135+
t.Logf("Generated %d unique message variations across the 8-9am window", len(messages))
136+
}
137+
138+
// TestDailyDigestExample shows what an actual daily digest looks like with both sections.
139+
func TestDailyDigestExample(t *testing.T) {
140+
scheduler := &DailyDigestScheduler{}
141+
142+
// Example: User has 2 incoming PRs to review and 1 outgoing PR needing attention at 8:30am
143+
exampleTime := time.Date(2025, 10, 22, 8, 30, 0, 0, time.UTC)
144+
145+
exampleIncoming := []home.PR{
146+
{
147+
URL: "https://github.com/codeGROOVE-dev/goose/pull/127",
148+
Title: "Add support for custom prompts",
149+
Author: "colleague",
150+
ActionKind: "review",
151+
},
152+
{
153+
URL: "https://github.com/codeGROOVE-dev/sprinkler/pull/15",
154+
Title: "Implement WebSocket reconnection logic",
155+
Author: "teammate",
156+
ActionKind: "approve",
157+
},
158+
}
159+
160+
exampleOutgoing := []home.PR{
161+
{
162+
URL: "https://github.com/codeGROOVE-dev/slacker/pull/48",
163+
Title: "Update DM messages when PR is merged",
164+
Author: "testuser",
165+
ActionKind: "address-feedback",
166+
},
167+
}
168+
169+
message := scheduler.formatDigestMessageAt(exampleIncoming, exampleOutgoing, exampleTime)
170+
171+
// Log the example for documentation purposes
172+
t.Logf("Example daily digest DM:\n\n%s\n", message)
173+
174+
// Verify it has the expected structure
175+
if len(message) == 0 {
176+
t.Error("Message should not be empty")
177+
}
178+
179+
// Should contain both section headers
180+
if !strings.Contains(message, "*To Review:*") {
181+
t.Error("Message should contain 'To Review:' header")
182+
}
183+
if !strings.Contains(message, "*Your PRs:*") {
184+
t.Error("Message should contain 'Your PRs:' header")
185+
}
186+
187+
// Should contain all PR URLs
188+
allPRs := append(exampleIncoming, exampleOutgoing...)
189+
for _, pr := range allPRs {
190+
if !strings.Contains(message, pr.URL) {
191+
t.Errorf("Message should contain PR URL: %s", pr.URL)
192+
}
193+
}
194+
195+
// Should contain footer
196+
if !strings.Contains(message, "Your daily digest from Ready to Review") {
197+
t.Error("Message should contain footer")
198+
}
199+
}

0 commit comments

Comments
 (0)