Notify distributor after report generation

This commit is contained in:
2026-06-07 23:38:03 +00:00
parent 64cae8c4d9
commit c2758d7a91
2 changed files with 410 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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 {