From c2758d7a91328fefc8a26fa510da2555898e4957 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 7 Jun 2026 23:38:03 +0000 Subject: [PATCH] Notify distributor after report generation --- internal/app/app.go | 120 ++++++++++++++++ internal/app/app_test.go | 290 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index a253731..88d8e66 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" @@ -45,6 +46,7 @@ type GenerateRequest struct { Date time.Time StormStart time.Time StormEnd time.Time + Notifier Notifier } type BatchRequest struct { @@ -54,6 +56,7 @@ type BatchRequest struct { OutputDir string Renderer Renderer Store state.Store + Notifier Notifier } type FetchBundleRequest struct { @@ -73,6 +76,7 @@ type ReportRequest struct { OutputPath string Renderer Renderer Store state.Store + Notifier Notifier } type BriefingResult struct { @@ -94,6 +98,7 @@ type ReportResult struct { RecentChanges []changes.Change RenderResult *scriptorium.RenderResult RunResult *scriptorium.RunResult + Notification *NotificationResult } type BatchResult struct { @@ -139,6 +144,26 @@ type Renderer interface { Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) } +type Notifier interface { + Notify(context.Context, NotificationRequest) (*NotificationResult, error) +} + +type NotificationRequest struct { + ReportID report.ID + RunID string + BundleID string + IdempotencyKey string + ReportPath string + BundlePath string +} + +type NotificationResult struct { + BundleID string + IdempotencyKey string + RunID string + Status string +} + func Generate(ctx context.Context, req GenerateRequest) error { now := req.Now if now.IsZero() { @@ -153,6 +178,7 @@ func Generate(ctx context.Context, req GenerateRequest) error { Config: req.Config, Resolved: resolved, OutputPath: req.OutputPath, + Notifier: req.Notifier, }) return err } @@ -211,6 +237,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro OutputPath: outputPath, Renderer: req.Renderer, Store: store, + Notifier: req.Notifier, }) if err != nil { item.Status = "failed" @@ -481,6 +508,11 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro return nil, runErr } + notification, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier) + if err != nil { + return nil, err + } + return &ReportResult{ Briefing: briefingPackage, BriefingPath: briefingPath, @@ -495,6 +527,94 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro RecentChanges: recentChanges, RenderResult: renderResult, RunResult: runResult, + Notification: notification, + }, nil +} + +func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier) (*NotificationResult, error) { + notifier, enabled := reportNotifier(cfg, notifier) + if !enabled { + return nil, nil + } + notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata) + if err != nil { + return nil, err + } + result, err := notifier.Notify(ctx, notificationRequest) + if err != nil { + return nil, fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err) + } + return result, nil +} + +func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) { + if !cfg.Notify.Distributor.Enabled { + return noopNotifier{}, false + } + if notifier != nil { + return notifier, true + } + return distributorNotifier{ + client: distributoradapter.New(cfg.Notify.Distributor), + }, true +} + +func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) { + values := config.DistributorTemplateValues{ + LocationID: cfg.Location.ID, + ReportID: string(resolved.Definition.ID), + RunID: metadata.RunID, + ArtifactGroup: resolved.Definition.ArtifactGroup, + BatchOutputName: resolved.Definition.BatchOutputName, + } + bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values) + if err != nil { + return NotificationRequest{}, err + } + values.BundleID = bundleID + idempotencyKey, err := config.RenderDistributorIdempotencyKey(cfg.Notify.Distributor.IdempotencyKeyTemplate, values) + if err != nil { + return NotificationRequest{}, err + } + bundlePath, err := config.RenderDistributorReportPath(cfg.Notify.Distributor.ReportPathTemplate, values) + if err != nil { + return NotificationRequest{}, err + } + return NotificationRequest{ + ReportID: resolved.Definition.ID, + RunID: metadata.RunID, + BundleID: bundleID, + IdempotencyKey: idempotencyKey, + ReportPath: reportPath, + BundlePath: bundlePath, + }, nil +} + +type noopNotifier struct{} + +func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) { + return nil, nil +} + +type distributorNotifier struct { + client *distributoradapter.Client +} + +func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest) (*NotificationResult, error) { + result, err := n.client.Upload(ctx, distributoradapter.UploadRequest{ + BundleID: req.BundleID, + IdempotencyKey: req.IdempotencyKey, + SourcePath: req.ReportPath, + BundlePath: req.BundlePath, + }) + if err != nil { + return nil, err + } + return &NotificationResult{ + BundleID: req.BundleID, + IdempotencyKey: req.IdempotencyKey, + RunID: result.RunID, + Status: result.Status, }, nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 63df148..871f381 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -255,6 +255,209 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { } } +func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { + server := dailyBundleServer(t) + cfg := dailyTestConfig(t, server) + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{} + + _, err = GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: successfulRenderer("# Daily Report\n"), + Notifier: notifier, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if len(notifier.requests) != 0 { + t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests) + } +} + +func TestGenerateReportNotifiesManagedReportPath(t *testing.T) { + server := dailyBundleServer(t) + cfg := dailyTestConfig(t, server) + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{ + result: &NotificationResult{RunID: "distributor-run", Status: "accepted"}, + } + outputPath := filepath.Join(t.TempDir(), "daily-copy.md") + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: successfulRenderer("# Daily Report\n"), + Notifier: notifier, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if result.Notification == nil { + t.Fatal("Notification = nil, want notification result") + } + if result.Notification.RunID != "distributor-run" || result.Notification.Status != "accepted" { + t.Fatalf("Notification = %#v, want accepted distributor run", result.Notification) + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) + } + req := notifier.requests[0] + if req.ReportPath != result.ReportPath { + t.Fatalf("notification ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath) + } + if req.ReportPath == outputPath { + t.Fatalf("notification used output copy %q, want managed report path", outputPath) + } + if req.BundlePath != "daily.md" { + t.Fatalf("notification BundlePath = %q, want daily.md", req.BundlePath) + } + if req.BundleID != "weatherreporter.home.daily_today."+result.Metadata.RunID { + t.Fatalf("notification BundleID = %q, want default template", req.BundleID) + } + if req.IdempotencyKey != req.BundleID { + t.Fatalf("IdempotencyKey = %q, want bundle id %q", req.IdempotencyKey, req.BundleID) + } + if req.RunID != result.Metadata.RunID { + t.Fatalf("notification RunID = %q, want report run id %q", req.RunID, result.Metadata.RunID) + } +} + +func TestGenerateReportNotificationFailureFailsReport(t *testing.T) { + server := dailyBundleServer(t) + cfg := dailyTestConfig(t, server) + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{err: errors.New("upload rejected")} + + _, err = GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: successfulRenderer("# Daily Report\n"), + Notifier: notifier, + }) + if err == nil { + t.Fatal("GenerateReport() error = nil, want notification error") + } + if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") { + t.Fatalf("error = %q, want notification context", err.Error()) + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests)) + } +} + +func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) { + server := dailyBundleServer(t) + tests := []struct { + name string + renderer Renderer + }{ + { + name: "Render", + renderer: &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, + err: errors.New("render failed"), + }, + }, + { + name: "Run", + renderer: &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"}, + runErr: errors.New("run failed"), + runBody: "# Daily Report\n", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := dailyTestConfig(t, server) + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{} + + _, err = GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: tt.renderer, + Notifier: notifier, + }) + if err == nil { + t.Fatal("GenerateReport() error = nil, want generation error") + } + if len(notifier.requests) != 0 { + t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests) + } + }) + } +} + +func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = "" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{} + + _, err = GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: successfulRenderer("# Daily Report\n"), + Notifier: notifier, + }) + if err == nil { + t.Fatal("GenerateReport() error = nil, want fetch setup error") + } + if len(notifier.requests) != 0 { + t.Fatalf("notification requests = %#v, want none after fetch failure", notifier.requests) + } +} + func TestGenerateReportPersistsFailedPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() @@ -1008,6 +1211,53 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) { } } +func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + notifier := &recordingNotifier{ + errByReport: map[report.ID]error{ + report.ThreeDay: errors.New("distributor unavailable"), + }, + } + + result, err := RunBatchDetailed(context.Background(), BatchRequest{ + Config: cfg, + Batch: BatchMorning, + Now: mustParse("2026-05-29T05:00:00-05:00"), + Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, + Notifier: notifier, + }) + if err != nil { + t.Fatalf("RunBatchDetailed() error = %v", err) + } + + if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { + t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) + } + if len(notifier.requests) != 3 { + t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests)) + } + var failedThreeDay bool + for _, item := range result.Reports { + if item.ReportID == report.ThreeDay { + if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") { + failedThreeDay = true + } + continue + } + if item.Status != "succeeded" { + t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) + } + } + if !failedThreeDay { + t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports) + } +} + func TestRunBatchUsesOutputDirectory(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() @@ -1164,6 +1414,14 @@ type recordingRenderer struct { runBody string } +func successfulRenderer(body string) *recordingRenderer { + return &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: body, + } +} + type selectiveRenderer struct { renderCalls int runCalls int @@ -1171,6 +1429,38 @@ type selectiveRenderer struct { runBody string } +type recordingNotifier struct { + requests []NotificationRequest + result *NotificationResult + err error + errByReport map[report.ID]error +} + +func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) { + n.requests = append(n.requests, req) + if err := n.errByReport[req.ReportID]; err != nil { + return nil, err + } + if n.err != nil { + return nil, n.err + } + if n.result != nil { + result := *n.result + if result.BundleID == "" { + result.BundleID = req.BundleID + } + if result.IdempotencyKey == "" { + result.IdempotencyKey = req.IdempotencyKey + } + return &result, nil + } + return &NotificationResult{ + BundleID: req.BundleID, + IdempotencyKey: req.IdempotencyKey, + Status: "accepted", + }, nil +} + func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { r.renderCalls++ if req.PromptID == r.failRenderPrompt {