Skip to content

Commit 67d4cf0

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
add daily reminder
1 parent 97b05fe commit 67d4cf0

File tree

4 files changed

+786
-0
lines changed

4 files changed

+786
-0
lines changed
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)