diff --git a/internal/cli/result.go b/internal/cli/result.go new file mode 100644 index 0000000..141cc4b --- /dev/null +++ b/internal/cli/result.go @@ -0,0 +1,162 @@ +package cli + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const ( + commandGenerate = "generate" + commandRun = "run" + + summaryStatusSucceeded = "succeeded" + summaryStatusFailed = "failed" +) + +type generateSummary struct { + Command string `json:"command"` + ReportID report.ID `json:"reportId"` + ReportName string `json:"reportName"` + PromptID string `json:"promptId"` + RunID string `json:"runId"` + Status string `json:"status"` + GeneratedAt time.Time `json:"generatedAt"` + ValidPeriod timeutil.Period `json:"validPeriod"` + ReportPath string `json:"reportPath,omitempty"` + OutputPath string `json:"outputPath,omitempty"` + MetadataPath string `json:"metadataPath,omitempty"` + DataPackagePath string `json:"dataPackagePath,omitempty"` + PreflightPath string `json:"preflightPath,omitempty"` + GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` + GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"` + GeneratedTextPath string `json:"generatedTextPath,omitempty"` + RenderContextPath string `json:"renderContextPath,omitempty"` + NotificationPath string `json:"notificationPath,omitempty"` + Notification *generateNotificationSummary `json:"notification,omitempty"` + Error string `json:"error,omitempty"` +} + +type generateNotificationSummary struct { + Status string `json:"status,omitempty"` + UploadStatus string `json:"uploadStatus,omitempty"` + StatusError string `json:"statusError,omitempty"` + RunID string `json:"runId,omitempty"` + PipelineID string `json:"pipelineId,omitempty"` + BundleID string `json:"bundleId,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + AcceptedAt *time.Time `json:"acceptedAt,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Error string `json:"error,omitempty"` +} + +type batchSummary struct { + Command string `json:"command"` + Batch app.BatchKind `json:"batch"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Notification *app.BatchNotificationResult `json:"notification,omitempty"` + Reports []app.BatchReportResult `json:"reports"` + Error string `json:"error,omitempty"` +} + +func newGenerateSummary(result *app.ReportResult, err error) generateSummary { + summary := generateSummary{Command: commandGenerate} + if result == nil { + return summary + } + + metadata := result.Metadata + summary.ReportID = metadata.ReportID + summary.ReportName = reportName(metadata.ReportID) + summary.PromptID = metadata.PromptID + summary.RunID = metadata.RunID + summary.Status = summaryStatusSucceeded + summary.GeneratedAt = metadata.GeneratedAt + summary.ValidPeriod = metadata.ValidPeriod + summary.ReportPath = result.ReportPath + summary.OutputPath = result.OutputPath + summary.MetadataPath = result.MetadataPath + summary.DataPackagePath = result.DataPackagePath + summary.PreflightPath = result.PreflightPath + summary.GeneratedTextRawPath = result.GeneratedTextRawPath + summary.GeneratedTextResultPath = result.GeneratedTextResultPath + summary.GeneratedTextPath = result.GeneratedTextPath + summary.RenderContextPath = result.RenderContextPath + summary.NotificationPath = result.NotificationPath + summary.Notification = newGenerateNotificationSummary(result.Notification) + if err != nil { + summary.Status = summaryStatusFailed + summary.Error = err.Error() + } + return summary +} + +func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary { + if result == nil { + return nil + } + summary := &generateNotificationSummary{ + Status: result.Status, + UploadStatus: result.UploadStatus, + StatusError: result.StatusError, + RunID: result.RunID, + PipelineID: result.PipelineID, + BundleID: result.BundleID, + IdempotencyKey: result.IdempotencyKey, + StartedAt: result.StartedAt, + FinishedAt: result.FinishedAt, + Error: result.Error, + } + if !result.AcceptedAt.IsZero() { + acceptedAt := result.AcceptedAt + summary.AcceptedAt = &acceptedAt + } + return summary +} + +func newBatchSummary(result *app.BatchResult) batchSummary { + summary := batchSummary{Command: commandRun} + if result == nil { + return summary + } + + summary.Batch = result.Batch + summary.Status = batchSummaryStatus(result) + summary.StartedAt = result.StartedAt + summary.FinishedAt = result.FinishedAt + summary.Total = result.Total + summary.Succeeded = result.Succeeded + summary.Failed = result.Failed + summary.Notification = result.Notification + summary.Reports = append([]app.BatchReportResult(nil), result.Reports...) + if summary.Status == summaryStatusFailed { + summary.Error = app.BatchError{Result: result}.Error() + } + return summary +} + +func batchSummaryStatus(result *app.BatchResult) string { + if result == nil { + return "" + } + if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) { + return summaryStatusFailed + } + return summaryStatusSucceeded +} + +func reportName(id report.ID) string { + definition, err := report.DefaultRegistry().Lookup(id) + if err != nil { + return string(id) + } + return definition.Name +} diff --git a/internal/cli/result_test.go b/internal/cli/result_test.go new file mode 100644 index 0000000..3cd8058 --- /dev/null +++ b/internal/cli/result_test.go @@ -0,0 +1,231 @@ +package cli + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/state" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { + generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + acceptedAt := generatedAt.Add(time.Minute) + startedAt := acceptedAt.Add(time.Minute) + finishedAt := startedAt.Add(time.Minute) + result := &app.ReportResult{ + DataPackagePath: "/runs/hourly/data_package.yaml", + PreflightPath: "/runs/hourly/preflight.json", + ReportPath: "/runs/hourly/report.md", + OutputPath: "/copies/hourly.md", + MetadataPath: "/runs/hourly/metadata.json", + GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json", + GeneratedTextResultPath: "/runs/hourly/generated_text_result.json", + GeneratedTextPath: "/runs/hourly/generated_text.json", + RenderContextPath: "/runs/hourly/render_context.json", + NotificationPath: "/runs/hourly/notification.json", + Metadata: state.Metadata{ + ReportID: report.Hourly, + PromptID: "weather.hourly_generated_text", + RunID: "20260529T133000Z_hourly", + GeneratedAt: generatedAt, + ValidPeriod: testSummaryPeriod(generatedAt), + }, + Notification: &app.NotificationResult{ + Status: "succeeded", + UploadStatus: "accepted", + RunID: "distributor-run", + PipelineID: "weatherreporter.hourly", + BundleID: "weatherreporter.home.hourly", + IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly", + AcceptedAt: acceptedAt, + StartedAt: &startedAt, + FinishedAt: &finishedAt, + Report: []byte(`{"actions":[{"action":"replace_older"}]}`), + }, + } + + summary := newGenerateSummary(result, nil) + + if summary.Command != "generate" || summary.Status != "succeeded" { + t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status) + } + if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" { + t.Fatalf("summary identity = %#v, want hourly report identity", summary) + } + if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { + t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) + } + if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) { + t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification) + } + data, err := json.Marshal(summary) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") { + t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data)) + } +} + +func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) { + generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + result := &app.ReportResult{ + DataPackagePath: "/runs/three-day/data_package.yaml", + PreflightPath: "/runs/three-day/preflight.json", + ReportPath: "/runs/three-day/report.md", + OutputPath: "/copies/three-day.md", + MetadataPath: "/runs/three-day/metadata.json", + Metadata: state.Metadata{ + ReportID: report.ThreeDay, + PromptID: "weather.three_day_outlook", + RunID: "20260529T133000Z_three_day", + GeneratedAt: generatedAt, + ValidPeriod: testSummaryPeriod(generatedAt), + }, + } + + summary := newGenerateSummary(result, nil) + + if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" { + t.Fatalf("summary = %#v, want successful 3-day summary", summary) + } + if summary.Notification != nil || summary.NotificationPath != "" { + t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath) + } + data, err := json.Marshal(summary) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} { + if strings.Contains(string(data), omitted) { + t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data)) + } + } +} + +func TestNewGenerateSummaryForNotificationFailure(t *testing.T) { + generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + result := &app.ReportResult{ + DataPackagePath: "/runs/hourly/data_package.yaml", + PreflightPath: "/runs/hourly/preflight.json", + ReportPath: "/runs/hourly/report.md", + OutputPath: "/copies/hourly.md", + MetadataPath: "/runs/hourly/metadata.json", + NotificationPath: "/runs/hourly/notification.json", + Metadata: state.Metadata{ + ReportID: report.Hourly, + PromptID: "weather.hourly_generated_text", + RunID: "20260529T133000Z_hourly", + GeneratedAt: generatedAt, + ValidPeriod: testSummaryPeriod(generatedAt), + }, + } + err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`) + + summary := newGenerateSummary(result, err) + + if summary.Status != "failed" || summary.Error != err.Error() { + t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error) + } + if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" { + t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath) + } +} + +func TestNewBatchSummaryStatusDerivation(t *testing.T) { + startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) + finishedAt := startedAt.Add(2 * time.Minute) + tests := []struct { + name string + result *app.BatchResult + wantStatus string + wantError string + }{ + { + name: "success", + result: &app.BatchResult{ + Batch: app.BatchMorning, + StartedAt: startedAt, + FinishedAt: finishedAt, + Total: 1, + Succeeded: 1, + Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}}, + }, + wantStatus: "succeeded", + }, + { + name: "report failure", + result: &app.BatchResult{ + Batch: app.BatchMorning, + Total: 2, + Succeeded: 1, + Failed: 1, + Reports: []app.BatchReportResult{ + {ReportID: report.Today, Status: "succeeded"}, + {ReportID: report.Tomorrow, Status: "failed", Error: "render failed"}, + }, + }, + wantStatus: "failed", + wantError: "batch morning failed: 1 of 2 reports failed", + }, + { + name: "skipped notification", + result: &app.BatchResult{ + Batch: app.BatchEvening, + Total: 2, + Succeeded: 1, + Failed: 1, + Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}}, + Notification: &app.BatchNotificationResult{ + Status: "skipped", + Reason: "one or more reports failed", + }, + }, + wantStatus: "failed", + wantError: "batch evening failed: 1 of 2 reports failed", + }, + { + name: "failed notification", + result: &app.BatchResult{ + Batch: app.BatchEvening, + Total: 1, + Succeeded: 1, + Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}}, + Notification: &app.BatchNotificationResult{ + Status: "failed", + Error: "notify batch evening: upload rejected", + }, + }, + wantStatus: "failed", + wantError: "batch evening notification failed: notify batch evening: upload rejected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := newBatchSummary(tt.result) + if summary.Command != "run" || summary.Status != tt.wantStatus { + t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus) + } + if summary.Error != tt.wantError { + t.Fatalf("error = %q, want %q", summary.Error, tt.wantError) + } + if len(summary.Reports) != len(tt.result.Reports) { + t.Fatalf("reports = %#v, want copied report list", summary.Reports) + } + }) + } +} + +func testSummaryPeriod(start time.Time) timeutil.Period { + return timeutil.Period{ + Start: start, + End: start.Add(6 * time.Hour), + } +}