This codebase follows modern Go best practices with Rob Pike's philosophy: simple, clear, maintainable.
-
Clear is better than clever
// Good - obvious if user == "" { return ErrNoUser } // Avoid - too clever return map[bool]error{user == "": ErrNoUser}[true]
-
A little copying is better than a little dependency
- Duplicate small functions rather than create complex abstractions
- Keep packages independent
-
Make zero values useful
var cache Cache // Immediately usable, no New() required
-
Interfaces are small
- 1-3 methods is ideal
- Defined where consumed, not where implemented
- See
internal/bot/interfaces.go
- No
Getprefixes -User()notGetUser() - Contexts first -
func Do(ctx context.Context, ...) - Accept interfaces, return structs
- Error wrapping -
fmt.Errorf("action failed: %w", err) - Short names in small scopes -
i,n,ctxare fine
All implementation lives in internal/ - external projects cannot import these packages.
internal/
├── bot/ # Orchestration
├── config/ # Configuration
├── github/ # GitHub client
├── notify/ # Notifications
├── slack/ # Slack client
└── usermapping/ # User mapping
Each package has:
- Single responsibility
- No circular dependencies
- Clear public API
- Package-level documentation
Before creating a new package, ask:
- Does it have a single, clear purpose?
- Can it be tested independently?
- Is it genuinely internal-only?
- Does it avoid circular dependencies?
If yes, create it in internal/.
Use table-driven tests:
func TestFoo(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"simple", "hello", "HELLO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Foo(tt.in)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}Define interfaces in test files, not in mocks/ packages:
// foo_test.go
type slackClient interface {
PostMessage(ctx context.Context, channel, text string) error
}
type mockSlack struct {
postFunc func(context.Context, string, string) error
}
func (m *mockSlack) PostMessage(ctx context.Context, channel, text string) error {
if m.postFunc != nil {
return m.postFunc(ctx, channel, text)
}
return nil
}if err != nil {
return fmt.Errorf("descriptive context: %w", err)
}if errors.Is(err, ErrNotFound) {
// Handle specifically
}slog.Error("operation failed",
"error", err,
"context_field", value)Use structured logging with slog:
slog.Info("processing PR",
"owner", owner,
"repo", repo,
"pr", prNumber,
"state", prState)Levels:
Debug- Verbose details (development only)Info- Normal operationsWarn- Recoverable issuesError- Requires attention
Don't log:
- Secrets or tokens
- Personally identifiable information
- Expected errors (use Debug)
-
Protect shared state
type Cache struct { mu sync.RWMutex data map[string]string } func (c *Cache) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] }
-
Use WaitGroups
var wg sync.WaitGroup for _, item := range items { wg.Add(1) go func(i Item) { defer wg.Done() process(i) }(item) } wg.Wait()
-
Propagate context
func process(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case <-done: return nil } }
- Creating goroutines without cleanup
- Goroutines that can leak
- Shared state without locking
- Creating contexts in libraries
make fmt # Format code
make lint # Run linters
make test # Run tests
make build # Verify buildAll must pass.
Short summary (50 chars or less)
More detailed explanation if needed. Wrap at 72 characters.
- Bullet points are fine
- Use present tense: "add feature" not "added feature"
err := retry.Do(
func() error {
return doThing()
},
retry.Attempts(5),
retry.Delay(2*time.Second),
retry.MaxDelay(2*time.Minute),
retry.DelayType(retry.BackOffDelay),
retry.MaxJitter(time.Second),
retry.Context(ctx),
)eg, ctx := errgroup.WithContext(ctx)
eg.Go(func() error {
return server.ListenAndServe()
})
eg.Go(func() error {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
5*time.Second,
)
defer cancel()
return server.Shutdown(shutdownCtx)
})
return eg.Wait()type cache struct {
mu sync.RWMutex
entries map[string]entry
}
type entry struct {
value interface{}
expiresAt time.Time
}
func (c *cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.value, true
}- Make it work - Correctness first
- Make it right - Clean code
- Make it fast - Only if needed
- Profile first - Don't guess
- Big O matters - Algorithm beats micro-optimization
- Cache wisely - Memory is cheaper than latency
- Concurrency helps - But adds complexity
Current caches and their TTLs:
- Slack team info: 1 hour
- Slack bot info: 1 hour
- Channel resolution: 1 hour
- User mappings: 24 hours
- PR threads: indefinite (process lifetime)
- Never log secrets - Check before adding logging
- Validate input - All user-provided data
- Verify signatures - All webhook requests
- Use timeouts - All external calls
- Sanitize errors - Don't leak internal details
Secrets come from environment or Google Secret Manager:
token := os.Getenv("SLACK_TOKEN")
if token == "" {
token, _ = gsm.Fetch(ctx, "slack-token")
}Never commit secrets. Never log secrets.
Read:
- This file
ARCHITECTURE.md- System designinternal/README.md- Package structure.claude/CLAUDE.md- Project overview
Then ask! File an issue or start a discussion.