Notify hourly generated reports after success

This commit is contained in:
2026-06-14 05:13:40 +00:00
parent 9e14a9b7c7
commit f6e20d1412
5 changed files with 188 additions and 15 deletions

View File

@@ -720,6 +720,27 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err != nil {
return nil, err
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
}
notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store)
if notificationPath != "" {
req.metadata.NotificationPath = notificationPath
metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if notificationErr != nil {
return nil, notificationErr
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
@@ -728,7 +749,8 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: req.metadata,
MetadataPath: metadataPath,
RecentChanges: req.recentChanges,
@@ -738,6 +760,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
Notification: notification,
}, nil
}

View File

@@ -501,6 +501,137 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
}
}
func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.result = &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("output copy differs from managed report")
}
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 report path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
wantBundlePaths := []string{"2026-05-29/hourly/hourly.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.PipelineID != "weatherreporter.hourly" {
t.Fatalf("notification PipelineID = %q, want weatherreporter.hourly", req.PipelineID)
}
if req.BundleID != "weatherreporter.home.hourly" {
t.Fatalf("notification BundleID = %q, want weatherreporter.home.hourly", req.BundleID)
}
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
t.Fatalf("notification IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
}
if result.Notification == nil || result.Notification.RunID != "distributor-run" {
t.Fatalf("Notification = %#v, want distributor result", result.Notification)
}
if result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("metadata NotificationPath = %q, want %q", result.Metadata.NotificationPath, result.NotificationPath)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notificationArtifact.SourcePath != result.ReportPath || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.RunStatus == nil {
t.Fatalf("notification artifact = %#v, want managed source and run status", notificationArtifact)
}
}
func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.err = errors.New("upload rejected")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
}
var notificationErr *NotificationError
if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err)
}
if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want hourly notification context", err.Error())
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.RenderedReport, outputPath, paths.Metadata, paths.Notification)
if notifier.requests[0].ReportPath != paths.RenderedReport {
t.Fatalf("notification ReportPath = %q, want managed report path %q", notifier.requests[0].ReportPath, paths.RenderedReport)
}
notificationData, readErr := os.ReadFile(paths.Notification)
if readErr != nil {
t.Fatalf("read notification artifact after failure: %v", readErr)
}
var notification state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notification); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") || notification.SourcePath != paths.RenderedReport {
t.Fatalf("notification failure artifact = %+v, want failed managed-source context", notification)
}
}
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{
@@ -1876,6 +2007,18 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
}
}
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
definition, err := report.DefaultRegistry().Lookup(report.Hourly)
if err != nil {
t.Fatalf("Lookup(hourly) error = %v", err)
}
outputDir := filepath.Join(t.TempDir(), "reports")
want := filepath.Join(outputDir, "hourly.md")
if got := batchOutputPath(outputDir, definition); got != want {
t.Fatalf("batchOutputPath() = %q, want %q", got, want)
}
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {