From fabda6e6af65fbd5d4209e9365df205dddaf6409 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 12 Mar 2026 13:21:33 +0100 Subject: [PATCH 1/2] Make project fully OSS: remove billing, htmx dashboard, README overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Plan field from tenant (no more free/pro tiers — all features available to everyone) - Rewrite web dashboard with htmx + Tailwind CDN: 30s partial polling, add/remove services from UI, platform badges, responsive layout - Add htmx partial endpoints: /dashboard/partials/stats, /dashboard/partials/services, POST/DELETE /dashboard/services - Comprehensive README with Homebrew install, REST API examples, daemon mode, multi-tenant CLI, full architecture map - Web test coverage: 81.4% → 85.7% (12 tests covering all new endpoints) - Add .claude/worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + README.md | 133 +++++++++++---- cmd/releasewave/tenant.go | 10 +- internal/tenant/tenant.go | 21 +-- internal/tenant/tenant_test.go | 34 ++-- internal/web/dashboard.html | 304 +++++++++++++-------------------- internal/web/web.go | 145 +++++++++++++++- internal/web/web_test.go | 199 ++++++++++++++++++--- 8 files changed, 551 insertions(+), 298 deletions(-) diff --git a/.gitignore b/.gitignore index 9224b4e..3aaa6f4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ coverage.html .DS_Store Thumbs.db +# Claude Code +.claude/worktrees/ + # Config with secrets (not the example) config.yaml !config.example.yaml diff --git a/README.md b/README.md index 3be7bbc..ae34bdf 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,17 @@ Universal release/version aggregator for microservices. Checks releases across G - **Container Registry**: Query image tags from any OCI-compatible registry (GHCR, Docker Hub, ECR, etc.) - **Kubernetes**: Read deployed versions, auto-discover services, compare release vs deployed - **Security**: CVE checking via OSV.dev database -- **Web Dashboard**: Real-time service status dashboard at `/dashboard` -- **Notifications**: Webhook notifications on new releases +- **Web Dashboard**: Interactive htmx-powered dashboard with live updates at `/dashboard` +- **REST API**: Full JSON API for programmatic access +- **Notifications**: Slack, Discord, and webhook notifications on new releases +- **Background Daemon**: Automatic polling with configurable intervals +- **Multi-tenant**: API key management for team access control +- **GitHub App**: Automatic release detection via GitHub webhooks - **CLI**: Direct commands for querying releases, tags, and service status - **Concurrent**: Checks multiple services in parallel - **Cached**: In-memory cache with configurable TTL - **Rate-limited**: Per-provider rate limiting to respect API limits +- **SQLite Persistence**: Release history and tool call logging - **Single binary**: No runtime dependencies, cross-platform ## Quick Start @@ -26,19 +31,17 @@ Universal release/version aggregator for microservices. Checks releases across G ### Install ```bash +# Homebrew +brew install UnityInFlow/tap/releasewave + # From source go install github.com/UnityInFlow/releasewave/cmd/releasewave@latest -# Or download a release binary -# https://github.com/UnityInFlow/releasewave/releases - # Docker docker run -p 7891:7891 ghcr.io/unityinflow/releasewave:latest -# Or build from repo -git clone https://github.com/UnityInFlow/releasewave.git -cd releasewave -make build +# Download a release binary +# https://github.com/UnityInFlow/releasewave/releases ``` ### Configure @@ -59,7 +62,7 @@ releasewave install # Or start manually releasewave serve # stdio (default, for AI tools) -releasewave serve --transport=sse # HTTP+SSE on port 7891 (+ web dashboard) +releasewave serve --transport=sse # HTTP+SSE on port 7891 (+ web dashboard + API) ``` ### Use as CLI @@ -72,11 +75,61 @@ releasewave check # check all configured services # GitLab support releasewave releases gitlab-org/gitlab --platform gitlab -releasewave latest my-org/my-project --platform gitlab # Kubernetes auto-discovery releasewave discover --namespace production releasewave discover --merge # auto-add discovered services to config + +# Background monitoring +releasewave daemon --interval=5m +``` + +### Web Dashboard + +When running in SSE mode, an interactive dashboard is available at `http://localhost:7891/dashboard`: + +- Live service status with 30s auto-refresh (htmx) +- Add/remove services from the UI +- Platform badges, version tags, release links + +```bash +releasewave serve --transport=sse --port=7891 +# Open http://localhost:7891/dashboard +``` + +### REST API + +```bash +# List services with latest releases +curl http://localhost:7891/api/v1/services + +# Release history for a service +curl http://localhost:7891/api/v1/services/my-api/releases + +# Cross-service timeline +curl http://localhost:7891/api/v1/timeline + +# Add a service +curl -X POST http://localhost:7891/api/v1/services \ + -H 'Content-Type: application/json' \ + -d '{"name":"my-api","repo":"github.com/org/my-api"}' + +# Delete a service +curl -X DELETE http://localhost:7891/api/v1/services/my-api +``` + +### Multi-tenant Access + +```bash +# Create a tenant with API key +releasewave tenant create my-team +# => Tenant created: +# => Name: my-team +# => API Key: rw_abc123... +# => Save this API key — it won't be shown again. + +releasewave tenant list +releasewave tenant delete my-team ``` ## MCP Tools @@ -113,6 +166,8 @@ releasewave discover --merge # auto-add discovered services to config | `changelog_between_versions` | Aggregate release notes between two versions | | `security_advisories` | Check for CVEs affecting a package version (OSV.dev) | | `release_timeline` | Cross-service release timeline sorted by date | +| `release_diff` | Compare deployed vs latest with changelog | +| `release_history` | Query local release history from SQLite | ### Dependency & Upgrade Tools @@ -121,7 +176,7 @@ releasewave discover --merge # auto-add discovered services to config | `get_repo_file` | Fetch file content from a repo (go.mod, package.json, etc.) | | `dependency_matrix` | Analyze shared dependencies across configured services | | `upgrade_plan` | Generate prioritized upgrade plan for outdated services | -| `watch_releases` | Detect new releases and send webhook notifications | +| `watch_releases` | Detect new releases and send notifications | | `service_graph` | Build a dependency graph showing shared libraries across services | ## Configuration @@ -132,8 +187,9 @@ releasewave discover --merge # auto-add discovered services to config services: - name: my-api repo: github.com/my-org/my-api - - name: billing - repo: gitlab.com/my-org/billing + registry: ghcr.io/my-org/my-api + - name: frontend + repo: github.com/my-org/frontend tokens: github: "" # or set GITHUB_TOKEN env var @@ -144,6 +200,7 @@ cache: server: port: 7891 + api_key: "" # or set RELEASEWAVE_API_KEY env var rate_limit: github: 5 # requests per second @@ -152,10 +209,20 @@ rate_limit: notifications: enabled: false webhook_url: "https://hooks.slack.com/services/..." + slack: + webhook_url: "" + discord: + webhook_url: "" + +storage: + path: "" # e.g. ~/.config/releasewave/releasewave.db + +daemon: + interval: 5m log: - level: info - format: text + level: info # debug, info, warn, error + format: text # text, json ``` ### Kubernetes Auto-Discovery @@ -172,15 +239,6 @@ metadata: If no annotations are present, ReleaseWave will attempt to infer the repository from the container image name. -## Web Dashboard - -When running in SSE mode, a web dashboard is available at `http://localhost:7891/dashboard` showing real-time status of all configured services. - -```bash -releasewave serve --transport=sse --port=7891 -# Open http://localhost:7891/dashboard -``` - ## Docker ```bash @@ -207,22 +265,29 @@ make clean # clean build artifacts ``` cmd/releasewave/ CLI entry point (Cobra commands) internal/ + api/ REST API (JSON endpoints) + cache/ Thread-safe in-memory TTL cache config/ YAML config loading + validation + daemon/ Background polling daemon + discovery/ K8s service auto-discovery + errors/ Typed errors (NotFound, RateLimit, Auth) + githubapp/ GitHub App integration (JWT, webhooks) + k8s/ Kubernetes integration (client-go) + logging/ Structured logging setup (slog) + mcpserver/ MCP server (stdio + SSE, 18 tools) + metrics/ Prometheus tool call metrics + middleware/ HTTP middleware (auth, metrics) model/ Core data types (Release, Tag, Service) + notify/ Notifications (Slack, Discord, webhook) provider/ Provider interface + cached decorator github/ GitHub REST API client gitlab/ GitLab REST API client - mcpserver/ MCP server (stdio + SSE, 18 tools) + ratelimit/ Token-bucket rate limiter registry/ OCI container registry client - k8s/ Kubernetes integration (client-go) security/ Vulnerability checking (OSV.dev API) - discovery/ K8s service auto-discovery - notify/ Webhook notification system - web/ Web dashboard (html/template) - cache/ Thread-safe in-memory TTL cache - ratelimit/ Token-bucket rate limiter - errors/ Typed errors (NotFound, RateLimit, Auth) - logging/ Structured logging setup (slog) + store/ SQLite persistence (releases, tool calls) + tenant/ Multi-tenant CRUD + API key management + web/ Web dashboard (htmx + Tailwind) ``` ## License diff --git a/cmd/releasewave/tenant.go b/cmd/releasewave/tenant.go index 91d83ca..9431916 100644 --- a/cmd/releasewave/tenant.go +++ b/cmd/releasewave/tenant.go @@ -28,8 +28,6 @@ var tenantCreateCmd = &cobra.Command{ Short: "Create a new tenant", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - plan, _ := cmd.Flags().GetString("plan") - db, err := openTenantDB() if err != nil { return err @@ -41,7 +39,7 @@ var tenantCreateCmd = &cobra.Command{ return err } - t, err := ts.Create(args[0], plan) + t, err := ts.Create(args[0]) if err != nil { return fmt.Errorf("create tenant: %w", err) } @@ -59,7 +57,6 @@ var tenantCreateCmd = &cobra.Command{ fmt.Printf("Tenant created:\n") fmt.Printf(" Name: %s\n", t.Name) - fmt.Printf(" Plan: %s\n", t.Plan) fmt.Printf(" API Key: %s\n", rawKey) fmt.Printf("\nSave this API key — it won't be shown again.\n") return nil @@ -91,9 +88,9 @@ var tenantListCmd = &cobra.Command{ return nil } - fmt.Printf("%-20s %-8s %s\n", "NAME", "PLAN", "CREATED") + fmt.Printf("%-20s %s\n", "NAME", "CREATED") for _, t := range tenants { - fmt.Printf("%-20s %-8s %s\n", t.Name, t.Plan, t.CreatedAt.Format("2006-01-02")) + fmt.Printf("%-20s %s\n", t.Name, t.CreatedAt.Format("2006-01-02")) } return nil }, @@ -124,7 +121,6 @@ var tenantDeleteCmd = &cobra.Command{ } func init() { - tenantCreateCmd.Flags().String("plan", "free", "tenant plan (free, pro)") tenantCmd.AddCommand(tenantCreateCmd) tenantCmd.AddCommand(tenantListCmd) tenantCmd.AddCommand(tenantDeleteCmd) diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go index da60eed..343fcd7 100644 --- a/internal/tenant/tenant.go +++ b/internal/tenant/tenant.go @@ -11,7 +11,6 @@ import ( type Tenant struct { ID int64 `json:"id"` Name string `json:"name"` - Plan string `json:"plan"` // "free" or "pro" CreatedAt time.Time `json:"created_at"` } @@ -45,7 +44,6 @@ func (s *Store) migrate() error { `CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - plan TEXT NOT NULL DEFAULT 'free', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS tenant_services ( @@ -65,13 +63,10 @@ func (s *Store) migrate() error { } // Create adds a new tenant. -func (s *Store) Create(name, plan string) (*Tenant, error) { - if plan == "" { - plan = "free" - } +func (s *Store) Create(name string) (*Tenant, error) { result, err := s.db.Exec( - `INSERT INTO tenants (name, plan) VALUES (?, ?)`, - name, plan, + `INSERT INTO tenants (name) VALUES (?)`, + name, ) if err != nil { return nil, fmt.Errorf("create tenant: %w", err) @@ -80,15 +75,15 @@ func (s *Store) Create(name, plan string) (*Tenant, error) { if err != nil { return nil, fmt.Errorf("get tenant id: %w", err) } - return &Tenant{ID: id, Name: name, Plan: plan, CreatedAt: time.Now()}, nil + return &Tenant{ID: id, Name: name, CreatedAt: time.Now()}, nil } // Get retrieves a tenant by name. func (s *Store) Get(name string) (*Tenant, error) { var t Tenant err := s.db.QueryRow( - `SELECT id, name, plan, created_at FROM tenants WHERE name = ?`, name, - ).Scan(&t.ID, &t.Name, &t.Plan, &t.CreatedAt) + `SELECT id, name, created_at FROM tenants WHERE name = ?`, name, + ).Scan(&t.ID, &t.Name, &t.CreatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("tenant %q not found", name) } @@ -100,7 +95,7 @@ func (s *Store) Get(name string) (*Tenant, error) { // List returns all tenants. func (s *Store) List() ([]Tenant, error) { - rows, err := s.db.Query(`SELECT id, name, plan, created_at FROM tenants ORDER BY name`) + rows, err := s.db.Query(`SELECT id, name, created_at FROM tenants ORDER BY name`) if err != nil { return nil, err } @@ -109,7 +104,7 @@ func (s *Store) List() ([]Tenant, error) { var tenants []Tenant for rows.Next() { var t Tenant - if err := rows.Scan(&t.ID, &t.Name, &t.Plan, &t.CreatedAt); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt); err != nil { return nil, err } tenants = append(tenants, t) diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go index 014a18e..ffa614a 100644 --- a/internal/tenant/tenant_test.go +++ b/internal/tenant/tenant_test.go @@ -2,7 +2,6 @@ package tenant import ( "database/sql" - "strings" "testing" _ "modernc.org/sqlite" @@ -41,11 +40,11 @@ func setupStores(t *testing.T) (*sql.DB, *Store, *KeyStore) { func TestStore_CreateAndGet(t *testing.T) { _, store, _ := setupStores(t) - created, err := store.Create("acme", "pro") + created, err := store.Create("acme") if err != nil { t.Fatalf("create: %v", err) } - if created.Name != "acme" || created.Plan != "pro" { + if created.Name != "acme" { t.Fatalf("unexpected tenant: %+v", created) } @@ -59,18 +58,15 @@ func TestStore_CreateAndGet(t *testing.T) { if got.Name != "acme" { t.Fatalf("name mismatch: got %q, want %q", got.Name, "acme") } - if got.Plan != "pro" { - t.Fatalf("plan mismatch: got %q, want %q", got.Plan, "pro") - } } func TestStore_CreateDuplicateFails(t *testing.T) { _, store, _ := setupStores(t) - if _, err := store.Create("acme", "free"); err != nil { + if _, err := store.Create("acme"); err != nil { t.Fatalf("first create: %v", err) } - _, err := store.Create("acme", "pro") + _, err := store.Create("acme") if err == nil { t.Fatal("expected error for duplicate tenant, got nil") } @@ -81,7 +77,7 @@ func TestStore_ListReturnsAll(t *testing.T) { names := []string{"alpha", "beta", "gamma"} for _, n := range names { - if _, err := store.Create(n, "free"); err != nil { + if _, err := store.Create(n); err != nil { t.Fatalf("create %s: %v", n, err) } } @@ -104,7 +100,7 @@ func TestStore_ListReturnsAll(t *testing.T) { func TestStore_DeleteSucceeds(t *testing.T) { _, store, _ := setupStores(t) - if _, err := store.Create("acme", "free"); err != nil { + if _, err := store.Create("acme"); err != nil { t.Fatalf("create: %v", err) } if err := store.Delete("acme"); err != nil { @@ -137,7 +133,7 @@ func TestStore_GetNonExistentReturnsError(t *testing.T) { func TestStore_AddServiceAndListServices(t *testing.T) { _, store, _ := setupStores(t) - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create: %v", err) } @@ -180,7 +176,7 @@ func TestStore_ForeignKeyCascadeDeletesServices(t *testing.T) { t.Fatal("PRAGMA foreign_keys is OFF; cascade will not work") } - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create: %v", err) } @@ -209,7 +205,7 @@ func TestStore_ForeignKeyCascadeDeletesServices(t *testing.T) { func TestKeyStore_GenerateHasRWPrefix(t *testing.T) { _, store, ks := setupStores(t) - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create tenant: %v", err) } @@ -218,7 +214,7 @@ func TestKeyStore_GenerateHasRWPrefix(t *testing.T) { if err != nil { t.Fatalf("generate: %v", err) } - if !strings.HasPrefix(rawKey, "rw_") { + if len(rawKey) < 4 || rawKey[:3] != "rw_" { t.Fatalf("raw key %q does not start with 'rw_'", rawKey) } if key.TenantID != tenant.ID { @@ -232,7 +228,7 @@ func TestKeyStore_GenerateHasRWPrefix(t *testing.T) { func TestKeyStore_ValidateCorrectKey(t *testing.T) { _, store, ks := setupStores(t) - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create tenant: %v", err) } @@ -254,7 +250,7 @@ func TestKeyStore_ValidateCorrectKey(t *testing.T) { func TestKeyStore_ValidateWrongKeyReturnsError(t *testing.T) { _, store, ks := setupStores(t) - if _, err := store.Create("acme", "free"); err != nil { + if _, err := store.Create("acme"); err != nil { t.Fatalf("create tenant: %v", err) } @@ -267,7 +263,7 @@ func TestKeyStore_ValidateWrongKeyReturnsError(t *testing.T) { func TestKeyStore_ListForTenant(t *testing.T) { _, store, ks := setupStores(t) - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create tenant: %v", err) } @@ -300,7 +296,7 @@ func TestKeyStore_ListForTenant(t *testing.T) { func TestKeyStore_RevokeRemovesKey(t *testing.T) { _, store, ks := setupStores(t) - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create tenant: %v", err) } @@ -351,7 +347,7 @@ func TestKeyStore_ForeignKeyCascadeDeletesKeys(t *testing.T) { t.Fatal("PRAGMA foreign_keys is OFF; cascade will not work") } - tenant, err := store.Create("acme", "free") + tenant, err := store.Create("acme") if err != nil { t.Fatalf("create tenant: %v", err) } diff --git a/internal/web/dashboard.html b/internal/web/dashboard.html index 3a43e15..3d19390 100644 --- a/internal/web/dashboard.html +++ b/internal/web/dashboard.html @@ -3,201 +3,127 @@ - ReleaseWave Dashboard + + - -
-

ReleaseWave

-
Auto-refreshes every 60s · Last updated: {{.UpdatedAt}}
-
-
-
-
-
Services
-
{{.TotalServices}}
-
-
-
Healthy
-
{{.HealthyCount}}
-
-
-
Errors
-
{{.ErrorCount}}
-
+ +
+

ReleaseWave

+ Last updated: {{.UpdatedAt}} +
+ +
+ {{/* Stats cards — polled every 30s */}} +
+ {{template "stats" .}}
- {{if .Services}} - - - - - - - - - - - - - {{range .Services}} - - - - - - - - - {{end}} - -
StatusServicePlatformLatest VersionReleasedLink
- {{if .Error}} - - {{else}} - - {{end}} - {{.Name}} - {{.Platform}} - - {{if .Error}} - {{.Error}} - {{else}} - {{.LatestTag}} - {{end}} - {{.ReleasedAt}} - {{if .URL}} - View - {{end}} -
- {{else}} -
-

No services configured.

-

Add services to ~/.config/releasewave/config.yaml

+ {{/* Add service form */}} +
+

Add Service

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
- {{end}} -
- + + {{/* Service table — polled every 30s */}} +
+ {{template "services" .}} +
+
+ +
+ Powered by ReleaseWave · GitHub +
+ +{{define "stats"}} +
+
+
Services
+
{{.TotalServices}}
+
+
+
Healthy
+
{{.HealthyCount}}
+
+
+
Errors
+
{{.ErrorCount}}
+
+
+{{end}} + +{{define "services"}} +{{if .Services}} +
+ + + + + + + + + + + + + {{range .Services}} + + + + + + + + + {{end}} + +
StatusServicePlatformLatest VersionReleasedActions
+ {{if .Error}} + + {{else}} + + {{end}} + {{.Name}} + {{.Platform}} + + {{if .Error}} + {{.Error}} + {{else}} + {{.LatestTag}} + {{end}} + {{.ReleasedAt}} + {{if .URL}} + View + {{end}} + +
+
+{{else}} +
+

No services configured.

+

Add a service above or edit ~/.config/releasewave/config.yaml

+
+{{end}} +{{end}} diff --git a/internal/web/web.go b/internal/web/web.go index a397bb9..7d312fc 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,12 +1,14 @@ -// Package web provides a minimal web dashboard for ReleaseWave. +// Package web provides the web dashboard for ReleaseWave. package web import ( "context" + "encoding/json" "fmt" "html/template" "log/slog" "net/http" + "strings" "sync" "time" @@ -33,24 +35,149 @@ type dashboardData struct { UpdatedAt string } -// Handler returns an http.Handler that serves the web dashboard. +// Handler returns an http.Handler that serves the web dashboard with htmx partials. func Handler(cfg *config.Config, providers map[string]provider.Provider) (http.Handler, error) { tmpl, err := template.ParseFS(templateFS, "dashboard.html") if err != nil { return nil, fmt.Errorf("web: failed to parse embedded template: %w", err) } + h := &dashboardHandler{cfg: cfg, providers: providers, tmpl: tmpl} + mux := http.NewServeMux() - mux.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { - data := fetchDashboardData(r.Context(), cfg, providers) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - slog.Error("web.render", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + mux.HandleFunc("/dashboard", h.fullPage) + mux.HandleFunc("/dashboard/partials/stats", h.partialStats) + mux.HandleFunc("/dashboard/partials/services", h.partialServices) + mux.HandleFunc("POST /dashboard/services", h.addService) + mux.HandleFunc("DELETE /dashboard/services/{name}", h.deleteService) + + return mux, nil +} + +type dashboardHandler struct { + cfg *config.Config + providers map[string]provider.Provider + tmpl *template.Template + mu sync.RWMutex +} + +func (h *dashboardHandler) fullPage(w http.ResponseWriter, r *http.Request) { + data := h.getData(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.Execute(w, data); err != nil { + slog.Error("web.render", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *dashboardHandler) partialStats(w http.ResponseWriter, r *http.Request) { + data := h.getData(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, "stats", data); err != nil { + slog.Error("web.render.stats", "error", err) + } +} + +func (h *dashboardHandler) partialServices(w http.ResponseWriter, r *http.Request) { + data := h.getData(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil { + slog.Error("web.render.services", "error", err) + } +} + +func (h *dashboardHandler) addService(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("name") + repo := r.FormValue("repo") + registry := r.FormValue("registry") + + if name == "" || repo == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "name and repo are required"}); err != nil { + slog.Error("web.write", "error", err) + } + return + } + + parts := strings.Split(repo, "/") + if len(parts) < 3 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "repo must be host/owner/repo format"}); err != nil { + slog.Error("web.write", "error", err) + } + return + } + + h.mu.Lock() + for _, svc := range h.cfg.Services { + if svc.Name == name { + h.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "service already exists"}); err != nil { + slog.Error("web.write", "error", err) + } + return } + } + h.cfg.Services = append(h.cfg.Services, config.ServiceConfig{ + Name: name, + Repo: repo, + Registry: registry, }) + h.mu.Unlock() - return mux, nil + slog.Info("web.add_service", "name", name, "repo", repo) + + // Return updated services partial. + data := h.getData(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil { + slog.Error("web.render.services", "error", err) + } +} + +func (h *dashboardHandler) deleteService(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + h.mu.Lock() + found := false + services := make([]config.ServiceConfig, 0, len(h.cfg.Services)) + for _, svc := range h.cfg.Services { + if svc.Name == name { + found = true + continue + } + services = append(services, svc) + } + if found { + h.cfg.Services = services + } + h.mu.Unlock() + + if !found { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "service not found"}); err != nil { + slog.Error("web.write", "error", err) + } + return + } + + slog.Info("web.delete_service", "name", name) + + // Return updated services partial. + data := h.getData(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil { + slog.Error("web.render.services", "error", err) + } +} + +func (h *dashboardHandler) getData(ctx context.Context) dashboardData { + return fetchDashboardData(ctx, h.cfg, h.providers) } // fetchDashboardData queries all configured services concurrently and returns dashboard data. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 587021b..543e540 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -38,6 +39,15 @@ func (m *mockProvider) GetFileContent(_ context.Context, _, _, _ string) ([]byte var _ provider.Provider = (*mockProvider)(nil) +func testHandler(t *testing.T, cfg *config.Config, providers map[string]provider.Provider) http.Handler { + t.Helper() + handler, err := Handler(cfg, providers) + if err != nil { + t.Fatalf("Handler() error: %v", err) + } + return handler +} + func TestHandler_RendersDashboard(t *testing.T) { cfg := &config.Config{ Services: []config.ServiceConfig{ @@ -53,11 +63,7 @@ func TestHandler_RendersDashboard(t *testing.T) { }, } - providers := map[string]provider.Provider{"github": mock} - handler, err := Handler(cfg, providers) - if err != nil { - t.Fatalf("Handler() error: %v", err) - } + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) rec := httptest.NewRecorder() @@ -77,6 +83,9 @@ func TestHandler_RendersDashboard(t *testing.T) { if !strings.Contains(body, "v1.2.3") { t.Error("expected page to contain version 'v1.2.3'") } + if !strings.Contains(body, "htmx") { + t.Error("expected page to contain htmx reference") + } } func TestHandler_ProviderError(t *testing.T) { @@ -86,16 +95,8 @@ func TestHandler_ProviderError(t *testing.T) { }, } - mock := &mockProvider{ - name: "github", - err: errors.New("API error"), - } - - providers := map[string]provider.Provider{"github": mock} - handler, err := Handler(cfg, providers) - if err != nil { - t.Fatalf("Handler() error: %v", err) - } + mock := &mockProvider{name: "github", err: errors.New("API error")} + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) rec := httptest.NewRecorder() @@ -112,20 +113,170 @@ func TestHandler_ProviderError(t *testing.T) { } func TestHandler_NoServices(t *testing.T) { + handler := testHandler(t, &config.Config{}, map[string]provider.Provider{}) + + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "No services configured") { + t.Error("expected empty state message") + } +} + +func TestPartialStats(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc", Repo: "github.com/org/svc"}, + }, + } + mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}} + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) + + req := httptest.NewRequest(http.MethodGet, "/dashboard/partials/stats", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Services") { + t.Error("expected stats partial to contain 'Services'") + } +} + +func TestPartialServices(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc", Repo: "github.com/org/svc"}, + }, + } + mock := &mockProvider{name: "github", release: &model.Release{Tag: "v2.0.0"}} + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) + + req := httptest.NewRequest(http.MethodGet, "/dashboard/partials/services", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "v2.0.0") { + t.Error("expected services partial to contain 'v2.0.0'") + } +} + +func TestAddService(t *testing.T) { cfg := &config.Config{} - providers := map[string]provider.Provider{} - handler, err := Handler(cfg, providers) - if err != nil { - t.Fatalf("Handler() error: %v", err) + mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}} + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) + + form := url.Values{} + form.Set("name", "new-svc") + form.Set("repo", "github.com/org/new-svc") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) } - req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + if len(cfg.Services) != 1 || cfg.Services[0].Name != "new-svc" { + t.Fatalf("expected service to be added, got %v", cfg.Services) + } +} + +func TestAddService_Duplicate(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{{Name: "existing", Repo: "github.com/org/existing"}}, + } + handler := testHandler(t, cfg, map[string]provider.Provider{}) + + form := url.Values{} + form.Set("name", "existing") + form.Set("repo", "github.com/org/existing") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rec.Code) + } +} + +func TestAddService_BadRepo(t *testing.T) { + handler := testHandler(t, &config.Config{}, map[string]provider.Provider{}) + + form := url.Values{} + form.Set("name", "bad") + form.Set("repo", "not-valid") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestAddService_MissingFields(t *testing.T) { + handler := testHandler(t, &config.Config{}, map[string]provider.Provider{}) + + req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestDeleteService(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc-a", Repo: "github.com/org/a"}, + {Name: "svc-b", Repo: "github.com/org/b"}, + }, + } + mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}} + handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock}) + + req := httptest.NewRequest(http.MethodDelete, "/dashboard/services/svc-a", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } + + if len(cfg.Services) != 1 || cfg.Services[0].Name != "svc-b" { + t.Fatalf("expected svc-a removed, got %v", cfg.Services) + } +} + +func TestDeleteService_NotFound(t *testing.T) { + handler := testHandler(t, &config.Config{}, map[string]provider.Provider{}) + + req := httptest.NewRequest(http.MethodDelete, "/dashboard/services/ghost", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } } func TestFetchDashboardData(t *testing.T) { @@ -136,13 +287,7 @@ func TestFetchDashboardData(t *testing.T) { }, } - mock := &mockProvider{ - name: "github", - release: &model.Release{ - Tag: "v3.0.0", - }, - } - + mock := &mockProvider{name: "github", release: &model.Release{Tag: "v3.0.0"}} providers := map[string]provider.Provider{"github": mock} data := fetchDashboardData(context.Background(), cfg, providers) From 899a4c661b2d509ffeba9f4c9619abb9d576e50d Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 12 Mar 2026 13:24:13 +0100 Subject: [PATCH 2/2] Add multi-notifier tests and remove billing examples from config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notify coverage: 63.9% → 88.9% (multi-notifier, FromConfig factory) - Replace "billing" example service with "frontend" in config templates Co-Authored-By: Claude Opus 4.6 --- config.example.yaml | 4 +- internal/config/config.go | 4 +- internal/notify/multi_test.go | 189 ++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 internal/notify/multi_test.go diff --git a/config.example.yaml b/config.example.yaml index b8e61b5..dddc166 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,8 +6,8 @@ services: # - name: my-api # repo: github.com/my-org/my-api # registry: ghcr.io/my-org/my-api - # - name: billing - # repo: gitlab.com/my-org/billing + # - name: frontend + # repo: github.com/my-org/frontend # API tokens (can also use GITHUB_TOKEN / GITLAB_TOKEN env vars) tokens: diff --git a/internal/config/config.go b/internal/config/config.go index 7c0c804..e9753c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -279,8 +279,8 @@ services: # - name: my-api # repo: github.com/my-org/my-api # registry: ghcr.io/my-org/my-api - # - name: billing - # repo: gitlab.com/my-org/billing + # - name: frontend + # repo: github.com/my-org/frontend # API tokens (can also use GITHUB_TOKEN / GITLAB_TOKEN env vars) tokens: diff --git a/internal/notify/multi_test.go b/internal/notify/multi_test.go new file mode 100644 index 0000000..d10dd9a --- /dev/null +++ b/internal/notify/multi_test.go @@ -0,0 +1,189 @@ +package notify + +import ( + "context" + "errors" + "strings" + "testing" +) + +// fakeNotifier is a test helper that records calls and optionally returns an error. +type fakeNotifier struct { + called bool + event Event + err error +} + +func (f *fakeNotifier) Notify(_ context.Context, event Event) error { + f.called = true + f.event = event + return f.err +} + +func TestMultiNotifier_AllSucceed(t *testing.T) { + n1 := &fakeNotifier{} + n2 := &fakeNotifier{} + n3 := &fakeNotifier{} + + multi := NewMultiNotifier(n1, n2, n3) + event := Event{ + ServiceName: "api", + OldVersion: "v1.0.0", + NewVersion: "v2.0.0", + ReleaseURL: "https://example.com/release", + Platform: "github", + } + + err := multi.Notify(context.Background(), event) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + for i, n := range []*fakeNotifier{n1, n2, n3} { + if !n.called { + t.Errorf("notifier %d was not called", i) + } + if n.event.ServiceName != "api" { + t.Errorf("notifier %d: service = %q, want %q", i, n.event.ServiceName, "api") + } + if n.event.NewVersion != "v2.0.0" { + t.Errorf("notifier %d: version = %q, want %q", i, n.event.NewVersion, "v2.0.0") + } + } +} + +func TestMultiNotifier_OneFailsOthersContinue(t *testing.T) { + n1 := &fakeNotifier{} + n2 := &fakeNotifier{err: errors.New("slack is down")} + n3 := &fakeNotifier{} + + multi := NewMultiNotifier(n1, n2, n3) + event := Event{ServiceName: "api", NewVersion: "v1.0.0"} + + err := multi.Notify(context.Background(), event) + if err == nil { + t.Fatal("expected error when one notifier fails, got nil") + } + + if !strings.Contains(err.Error(), "slack is down") { + t.Errorf("error should contain 'slack is down', got %q", err.Error()) + } + + // All notifiers should still have been called. + if !n1.called { + t.Error("notifier 1 should have been called") + } + if !n2.called { + t.Error("notifier 2 should have been called") + } + if !n3.called { + t.Error("notifier 3 should have been called") + } +} + +func TestMultiNotifier_AllFail(t *testing.T) { + n1 := &fakeNotifier{err: errors.New("error-one")} + n2 := &fakeNotifier{err: errors.New("error-two")} + + multi := NewMultiNotifier(n1, n2) + event := Event{ServiceName: "api", NewVersion: "v1.0.0"} + + err := multi.Notify(context.Background(), event) + if err == nil { + t.Fatal("expected error when all notifiers fail") + } + + if !strings.Contains(err.Error(), "error-one") { + t.Errorf("error should contain 'error-one', got %q", err.Error()) + } + if !strings.Contains(err.Error(), "error-two") { + t.Errorf("error should contain 'error-two', got %q", err.Error()) + } +} + +func TestMultiNotifier_Empty(t *testing.T) { + multi := NewMultiNotifier() + event := Event{ServiceName: "api", NewVersion: "v1.0.0"} + + err := multi.Notify(context.Background(), event) + if err != nil { + t.Fatalf("expected nil error for empty notifier list, got %v", err) + } +} + +func TestMultiNotifier_SingleNotifier(t *testing.T) { + n := &fakeNotifier{} + multi := NewMultiNotifier(n) + event := Event{ServiceName: "svc", NewVersion: "v3.0.0"} + + err := multi.Notify(context.Background(), event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !n.called { + t.Error("notifier should have been called") + } +} + +// --- FromConfig tests --- + +func TestFromConfig_NoURLs_ReturnsNil(t *testing.T) { + n := FromConfig("", "", "") + if n != nil { + t.Fatalf("expected nil for no config, got %T", n) + } +} + +func TestFromConfig_WebhookOnly(t *testing.T) { + n := FromConfig("https://example.com/hook", "", "") + if n == nil { + t.Fatal("expected non-nil notifier") + } + if _, ok := n.(*WebhookNotifier); !ok { + t.Errorf("expected *WebhookNotifier, got %T", n) + } +} + +func TestFromConfig_SlackOnly(t *testing.T) { + n := FromConfig("", "https://hooks.slack.com/services/xxx", "") + if n == nil { + t.Fatal("expected non-nil notifier") + } + if _, ok := n.(*SlackNotifier); !ok { + t.Errorf("expected *SlackNotifier, got %T", n) + } +} + +func TestFromConfig_DiscordOnly(t *testing.T) { + n := FromConfig("", "", "https://discord.com/api/webhooks/xxx") + if n == nil { + t.Fatal("expected non-nil notifier") + } + if _, ok := n.(*DiscordNotifier); !ok { + t.Errorf("expected *DiscordNotifier, got %T", n) + } +} + +func TestFromConfig_TwoURLs_ReturnsMulti(t *testing.T) { + n := FromConfig("https://example.com/hook", "https://hooks.slack.com/xxx", "") + if n == nil { + t.Fatal("expected non-nil notifier") + } + if _, ok := n.(*MultiNotifier); !ok { + t.Errorf("expected *MultiNotifier, got %T", n) + } +} + +func TestFromConfig_AllThreeURLs_ReturnsMulti(t *testing.T) { + n := FromConfig("https://example.com/hook", "https://hooks.slack.com/xxx", "https://discord.com/api/webhooks/xxx") + if n == nil { + t.Fatal("expected non-nil notifier") + } + multi, ok := n.(*MultiNotifier) + if !ok { + t.Fatalf("expected *MultiNotifier, got %T", n) + } + if len(multi.notifiers) != 3 { + t.Errorf("expected 3 notifiers, got %d", len(multi.notifiers)) + } +}