356 lines
9.3 KiB
Markdown
356 lines
9.3 KiB
Markdown
# AGENTS.md - Developer Guide for oslstats
|
|
|
|
This document provides guidelines for AI coding agents and developers working on the oslstats codebase.
|
|
|
|
## Project Overview
|
|
|
|
**Module**: `git.haelnorr.com/h/oslstats`
|
|
**Language**: Go 1.25.5
|
|
**Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates
|
|
**Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries
|
|
|
|
## Build and Development Commands
|
|
|
|
### Building
|
|
NEVER BUILD MANUALLY
|
|
```bash
|
|
# Full production build (tailwind → templ → go generate → go build)
|
|
just build
|
|
|
|
# Build and run
|
|
just run
|
|
```
|
|
|
|
### Development Mode
|
|
```bash
|
|
# Watch mode with hot reload (templ, air, tailwindcss in parallel)
|
|
just dev
|
|
|
|
# Development server runs on:
|
|
# - Proxy: http://localhost:3000 (use this)
|
|
# - App: http://localhost:3333 (internal)
|
|
```
|
|
|
|
### Database Migrations
|
|
|
|
**oslstats uses Bun's migration framework for safe, incremental schema changes.**
|
|
|
|
#### Quick Reference
|
|
|
|
**New Migration System**: Migrations now accept a count parameter. Default is 1 migration at a time.
|
|
|
|
```bash
|
|
# Show migration status
|
|
just migrate status
|
|
|
|
# Run 1 migration (default, with automatic backup)
|
|
just migrate up 1
|
|
# OR just
|
|
just migrate up
|
|
|
|
# Run 3 migrations
|
|
just migrate up 3
|
|
|
|
# Run all pending migrations
|
|
just migrate up all
|
|
|
|
# Run with a specific environment file
|
|
just migrate up 3 .test.env
|
|
|
|
# Rollback works the same for all arguments
|
|
just migrate down 2 .test.env
|
|
|
|
# Create new migration
|
|
just migrate new add_email_to_users
|
|
|
|
# Dev: Reset database (DESTRUCTIVE - deletes all data)
|
|
just reset-db
|
|
```
|
|
|
|
#### Creating a New Migration
|
|
|
|
**Example: Adding an email field to users table**
|
|
|
|
1. **Generate migration file:**
|
|
```bash
|
|
just migrate new add_leagues_and_slap_version
|
|
```
|
|
Creates: `cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go`
|
|
|
|
2. **Edit the migration file:**
|
|
```go
|
|
package migrations
|
|
|
|
import (
|
|
"context"
|
|
"github.com/uptrace/bun"
|
|
)
|
|
|
|
func init() {
|
|
Migrations.MustRegister(
|
|
// UP: Add email column
|
|
func(ctx context.Context, db *bun.DB) error {
|
|
_, err := dbConn.NewAddColumn().
|
|
Model((*db.Season)(nil)).
|
|
ColumnExpr("slap_version VARCHAR NOT NULL").
|
|
IfNotExists().
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create leagues table
|
|
_, err = dbConn.NewCreateTable().
|
|
Model((*db.League)(nil)).
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create season_leagues join table
|
|
_, err = dbConn.NewCreateTable().
|
|
Model((*db.SeasonLeague)(nil)).
|
|
Exec(ctx)
|
|
return err
|
|
},
|
|
// DOWN: Remove email column (for rollback)
|
|
func(ctx context.Context, db *bun.DB) error {
|
|
// Drop season_leagues join table first
|
|
_, err := dbConn.NewDropTable().
|
|
Model((*db.SeasonLeague)(nil)).
|
|
IfExists().
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Drop leagues table
|
|
_, err = dbConn.NewDropTable().
|
|
Model((*db.League)(nil)).
|
|
IfExists().
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove slap_version column from seasons table
|
|
_, err = dbConn.NewDropColumn().
|
|
Model((*db.Season)(nil)).
|
|
ColumnExpr("slap_version").
|
|
Exec(ctx)
|
|
return err
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
3. **Update the model** (`internal/db/user.go`):
|
|
```go
|
|
type Season struct {
|
|
bun.BaseModel `bun:"table:seasons,alias:s"`
|
|
|
|
ID int `bun:"id,pk,autoincrement"`
|
|
Name string `bun:"name,unique"`
|
|
SlapVersion string `bun:"slap_version"` // NEW FIELD
|
|
}
|
|
```
|
|
|
|
4. **Apply the migration:**
|
|
```bash
|
|
just migrate up 1
|
|
```
|
|
Output:
|
|
```
|
|
[INFO] Step 1/5: Validating migrations...
|
|
[INFO] Migration validation passed ✓
|
|
[INFO] Step 2/5: Checking for pending migrations...
|
|
[INFO] Running 1 migration(s):
|
|
📋 20250124150030_add_email_to_users
|
|
[INFO] Step 3/5: Creating backup...
|
|
[INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB)
|
|
[INFO] Step 4/5: Acquiring migration lock...
|
|
[INFO] Migration lock acquired
|
|
[INFO] Step 5/5: Applying migrations...
|
|
[INFO] Migrated to group 2
|
|
✅ 20250124150030_add_email_to_users
|
|
[INFO] Migration lock released
|
|
```
|
|
|
|
#### Environment Variables
|
|
|
|
```bash
|
|
# Backup directory (default: backups)
|
|
DB_BACKUP_DIR=backups
|
|
|
|
# Number of backups to keep (default: 10)
|
|
DB_BACKUP_RETENTION=10
|
|
```
|
|
|
|
#### Troubleshooting
|
|
|
|
**"pg_dump not found"**
|
|
- Migrations will still run, but backups will be skipped
|
|
- Install PostgreSQL client tools for backups:
|
|
```bash
|
|
# Ubuntu/Debian
|
|
sudo apt-get install postgresql-client
|
|
|
|
# macOS
|
|
brew install postgresql
|
|
|
|
# Arch
|
|
sudo pacman -S postgresql-libs
|
|
```
|
|
|
|
**"migration already in progress"**
|
|
- Another instance is running migrations
|
|
- Wait for it to complete (max 5 minutes)
|
|
- If stuck, check for hung database connections
|
|
|
|
**"migration build failed"**
|
|
- Migration file has syntax errors
|
|
- Fix the errors and try again
|
|
- Use `go build ./cmd/oslstats/migrations` to debug
|
|
|
|
### Configuration Management
|
|
```bash
|
|
# Generate .env template file
|
|
just genenv
|
|
# OR with custom output: just genenv .env.example
|
|
|
|
# Show environment variable documentation
|
|
just envdoc
|
|
|
|
# Show current environment values
|
|
just showenv
|
|
```
|
|
|
|
## Code Style Guidelines
|
|
|
|
### Error Handling
|
|
|
|
**Always wrap errors** with context using `github.com/pkg/errors`:
|
|
|
|
```go
|
|
if err != nil {
|
|
return errors.Wrap(err, "package.FunctionName")
|
|
}
|
|
```
|
|
|
|
**Validate inputs at function start**:
|
|
```go
|
|
func DoSomething(cfg *Config, data string) error {
|
|
if cfg == nil {
|
|
return errors.New("cfg cannot be nil")
|
|
}
|
|
if data == "" {
|
|
return errors.New("data cannot be empty")
|
|
}
|
|
// ... rest of function
|
|
}
|
|
```
|
|
|
|
**HTTP error helpers** (in handlers package):
|
|
*Note: `msg` should be a frontend presentable message*
|
|
- `throwInternalServiceError(s, w, r, msg, err)` - 500 errors
|
|
- `throwBadRequest(s, w, r, msg, err)` - 400 errors
|
|
- `throwForbidden(s, w, r, msg, err)` - 403 errors (normal)
|
|
- `throwForbiddenSecurity(s, w, r, msg, err)` - 403 security violations (WARN level)
|
|
- `throwUnauthorized(s, w, r, msg, err)` - 401 errors (normal)
|
|
- `throwUnauthorizedSecurity(s, w, r, msg, err)` - 401 security violations (WARN level)
|
|
- `throwNotFound(s, w, r, path)` - 404 errors
|
|
|
|
### Common Patterns
|
|
|
|
**HTTP Handler Pattern**:
|
|
```go
|
|
func HandlerName(server *hws.Server, deps ...) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
// Handler logic here
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
**Database Operation Pattern**:
|
|
```go
|
|
func GetSomething(ctx context.Context, tx bun.Tx, id int) (*Result, error) {
|
|
result := new(Result)
|
|
err := tx.NewSelect().
|
|
Model(result).
|
|
Where("id = ?", id).
|
|
Scan(ctx)
|
|
if err != nil {
|
|
if err.Error() == "sql: no rows in result set" {
|
|
return nil, nil // Return nil, nil for not found
|
|
}
|
|
return nil, errors.Wrap(err, "tx.Select")
|
|
}
|
|
return result, nil
|
|
}
|
|
```
|
|
|
|
**Setup Function Pattern** (returns instance, cleanup func, error):
|
|
```go
|
|
func setupSomething(ctx context.Context, cfg *Config) (*Type, func() error, error) {
|
|
instance := newInstance()
|
|
|
|
err := configure(instance)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "configure")
|
|
}
|
|
|
|
return instance, instance.Close, nil
|
|
}
|
|
```
|
|
|
|
**Configuration Pattern** (using ezconf):
|
|
```go
|
|
type Config struct {
|
|
Field string // ENV FIELD_NAME: Description (required/default: value)
|
|
}
|
|
|
|
func ConfigFromEnv() (any, error) {
|
|
cfg := &Config{
|
|
Field: env.String("FIELD_NAME", "default"),
|
|
}
|
|
// Validation here
|
|
return cfg, nil
|
|
}
|
|
```
|
|
|
|
### Formatting & Types
|
|
|
|
**Formatting**:
|
|
- Use `gofmt` (standard Go formatting)
|
|
|
|
**Types**:
|
|
- Prefer explicit types over inference when it improves clarity
|
|
- Use struct tags for ORM and JSON marshaling:
|
|
```go
|
|
type User struct {
|
|
bun.BaseModel `bun:"table:users,alias:u"`
|
|
ID int `bun:"id,pk,autoincrement"`
|
|
Username string `bun:"username,unique"`
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
```
|
|
|
|
**Comments**:
|
|
- Document exported functions and types
|
|
- Use inline comments for ENV var documentation in Config structs
|
|
- Explain security-critical code flows
|
|
|
|
## Notes for AI Agents
|
|
|
|
1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css)
|
|
2. **Database operations** should use `bun.Tx` for transaction safety
|
|
3. **Templates** are written in templ, not Go html/template - run `just templ` after changes
|
|
4. **Static files** are embedded via `//go:embed` - check internal/embedfs/
|
|
5. **Error messages** should be descriptive and use errors.Wrap for context
|
|
6. **Security is critical** - especially in OAuth flows (see pkg/oauth/state_test.go for examples)
|
|
7. **Air proxy** runs on port 3000 during development; app runs on 3333
|
|
8. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
|
|
9. When in plan mode, always use the interactive question tool if available
|