From 42d662e9b243a3f1ce420aca439c824a1125a993 Mon Sep 17 00:00:00 2001 From: betterandbetterii Date: Thu, 25 Jun 2026 16:10:45 +0100 Subject: [PATCH] chore: add scheduled cleanup ticket metadata --- internal/scheduledjob/coverage_test.go | 37 ++++++--- internal/scheduledjob/service.go | 105 +++++++++++++++++++++---- 2 files changed, 115 insertions(+), 27 deletions(-) diff --git a/internal/scheduledjob/coverage_test.go b/internal/scheduledjob/coverage_test.go index cded4cd8..119f5dbe 100644 --- a/internal/scheduledjob/coverage_test.go +++ b/internal/scheduledjob/coverage_test.go @@ -12,13 +12,15 @@ import ( func TestScheduledJobTemplateParsingHelpers(t *testing.T) { repoID := uuid.New() template, err := ParseRawTicketTemplate(map[string]any{ - "title": " Daily Build ", - "description": " Run the workflow ", - "status": " Todo ", - "priority": "high", - "type": "bugfix", - "created_by": " system:test ", - "budget_usd": 4.5, + "title": " Daily Build ", + "description": " Run the workflow ", + "status": " Todo ", + "priority": "high", + "type": "bugfix", + "created_by": " system:test ", + "budget_usd": 4.5, + "cleanup_done_tickets": true, + "cleanup_keep_statuses": []any{" Preview Deploy ", "In Review", "In Review"}, "repo_scopes": []map[string]any{{ "repo_id": repoID.String(), "branch_name": " release/candidate ", @@ -27,9 +29,12 @@ func TestScheduledJobTemplateParsingHelpers(t *testing.T) { if err != nil { t.Fatalf("ParseRawTicketTemplate() error = %v", err) } - if template.Title != "Daily Build" || template.Description != "Run the workflow" || template.Status != "Todo" || template.Priority != ticketservice.PriorityHigh || template.Type != ticketservice.TypeBugfix || template.CreatedBy != "system:test" || template.BudgetUSD != 4.5 { + if template.Title != "Daily Build" || template.Description != "Run the workflow" || template.Status != "Todo" || template.Priority != ticketservice.PriorityHigh || template.Type != ticketservice.TypeBugfix || template.CreatedBy != "system:test" || template.BudgetUSD != 4.5 || !template.CleanupDoneTickets { t.Fatalf("ParseRawTicketTemplate() = %+v", template) } + if got := len(template.CleanupKeepStatuses); got != 2 || template.CleanupKeepStatuses[0] != "Preview Deploy" || template.CleanupKeepStatuses[1] != "In Review" { + t.Fatalf("ParseRawTicketTemplate() cleanup keep statuses = %+v", template.CleanupKeepStatuses) + } if len(template.RepoScopes) != 1 || template.RepoScopes[0].RepoID != repoID { t.Fatalf("ParseRawTicketTemplate() repo scopes = %+v", template.RepoScopes) } @@ -38,9 +43,12 @@ func TestScheduledJobTemplateParsingHelpers(t *testing.T) { } raw := template.Raw() - if raw["title"] != "Daily Build" || raw["budget_usd"] != 4.5 { + if raw["title"] != "Daily Build" || raw["budget_usd"] != 4.5 || raw["cleanup_done_tickets"] != true { t.Fatalf("TicketTemplate.Raw() = %+v", raw) } + if got := raw["cleanup_keep_statuses"].([]string); len(got) != 2 || got[0] != "Preview Deploy" || got[1] != "In Review" { + t.Fatalf("TicketTemplate.Raw() cleanup_keep_statuses = %+v", raw["cleanup_keep_statuses"]) + } if len(raw["repo_scopes"].([]map[string]any)) != 1 { t.Fatalf("TicketTemplate.Raw() repo_scopes = %+v", raw["repo_scopes"]) } @@ -48,7 +56,7 @@ func TestScheduledJobTemplateParsingHelpers(t *testing.T) { if err != nil { t.Fatalf("ParseRawTicketTemplate(minimal) error = %v", err) } - if minimal.Priority != ticketservice.DefaultPriority || minimal.Type != ticketservice.DefaultType || minimal.CreatedBy != defaultCreatedBy { + if minimal.Priority != ticketservice.DefaultPriority || minimal.Type != ticketservice.DefaultType || minimal.CreatedBy != defaultCreatedBy || minimal.CleanupDoneTickets || len(minimal.CleanupKeepStatuses) != 0 { t.Fatalf("ParseRawTicketTemplate(minimal) = %+v", minimal) } if got := minimal.Raw(); got["created_by"] != defaultCreatedBy { @@ -76,6 +84,15 @@ func TestScheduledJobTemplateParsingHelpers(t *testing.T) { if _, err := ParseRawTicketTemplate(map[string]any{"title": "Task", "budget_usd": -1.0}); err == nil { t.Fatal("ParseRawTicketTemplate(budget negative) expected error") } + if _, err := ParseRawTicketTemplate(map[string]any{"title": "Task", "cleanup_done_tickets": "yes"}); err == nil { + t.Fatal("ParseRawTicketTemplate(cleanup_done_tickets type) expected error") + } + if _, err := ParseRawTicketTemplate(map[string]any{"title": "Task", "cleanup_keep_statuses": "In Review"}); err == nil { + t.Fatal("ParseRawTicketTemplate(cleanup_keep_statuses type) expected error") + } + if _, err := ParseRawTicketTemplate(map[string]any{"title": "Task", "cleanup_keep_statuses": []any{"In Review", " "}}); err == nil { + t.Fatal("ParseRawTicketTemplate(cleanup_keep_statuses blank) expected error") + } if _, err := ParseRawTicketTemplate(map[string]any{ "title": "Task", "repo_scopes": []map[string]any{ diff --git a/internal/scheduledjob/service.go b/internal/scheduledjob/service.go index 443f31fe..652b99e6 100644 --- a/internal/scheduledjob/service.go +++ b/internal/scheduledjob/service.go @@ -39,14 +39,16 @@ func Some[T any](value T) Optional[T] { } type TicketTemplate struct { - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status,omitempty"` - Priority ticketservice.Priority `json:"priority"` - Type ticketservice.Type `json:"type"` - CreatedBy string `json:"created_by"` - BudgetUSD float64 `json:"budget_usd,omitempty"` - RepoScopes []TicketTemplateRepoScope `json:"repo_scopes,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status,omitempty"` + Priority ticketservice.Priority `json:"priority"` + Type ticketservice.Type `json:"type"` + CreatedBy string `json:"created_by"` + BudgetUSD float64 `json:"budget_usd,omitempty"` + RepoScopes []TicketTemplateRepoScope `json:"repo_scopes,omitempty"` + CleanupDoneTickets bool `json:"cleanup_done_tickets,omitempty"` + CleanupKeepStatuses []string `json:"cleanup_keep_statuses,omitempty"` } type TicketTemplateRepoScope struct { @@ -138,7 +140,7 @@ func ParseRawTicketTemplate(raw map[string]any) (TicketTemplate, error) { for key := range raw { switch key { - case "title", "description", "status", "priority", "type", "created_by", "budget_usd", "repo_scopes": + case "title", "description", "status", "priority", "type", "created_by", "budget_usd", "repo_scopes", "cleanup_done_tickets", "cleanup_keep_statuses": default: return TicketTemplate{}, fmt.Errorf("%w: ticket_template.%s is not supported", ErrInvalidTicketTemplate, key) } @@ -204,16 +206,26 @@ func ParseRawTicketTemplate(raw map[string]any) (TicketTemplate, error) { if err != nil { return TicketTemplate{}, err } + cleanupDoneTickets, err := parseOptionalBool(raw, "cleanup_done_tickets") + if err != nil { + return TicketTemplate{}, err + } + cleanupKeepStatuses, err := parseOptionalStringList(raw, "cleanup_keep_statuses") + if err != nil { + return TicketTemplate{}, err + } return TicketTemplate{ - Title: title, - Description: description, - Status: status, - Priority: priority, - Type: ticketType, - CreatedBy: createdBy, - BudgetUSD: budgetUSD, - RepoScopes: repoScopes, + Title: title, + Description: description, + Status: status, + Priority: priority, + Type: ticketType, + CreatedBy: createdBy, + BudgetUSD: budgetUSD, + RepoScopes: repoScopes, + CleanupDoneTickets: cleanupDoneTickets, + CleanupKeepStatuses: cleanupKeepStatuses, }, nil } @@ -236,6 +248,12 @@ func (t TicketTemplate) Raw() map[string]any { if len(t.RepoScopes) > 0 { raw["repo_scopes"] = rawTicketTemplateRepoScopes(t.RepoScopes) } + if t.CleanupDoneTickets { + raw["cleanup_done_tickets"] = true + } + if len(t.CleanupKeepStatuses) > 0 { + raw["cleanup_keep_statuses"] = append([]string(nil), t.CleanupKeepStatuses...) + } return raw } @@ -704,6 +722,59 @@ func parseBudgetUSD(value any) (float64, error) { return floatValue, nil } +func parseOptionalBool(raw map[string]any, fieldName string) (bool, error) { + value, ok := raw[fieldName] + if !ok { + return false, nil + } + boolValue, ok := value.(bool) + if !ok { + return false, fmt.Errorf("%w: ticket_template.%s must be a boolean", ErrInvalidTicketTemplate, fieldName) + } + return boolValue, nil +} + +func parseOptionalStringList(raw map[string]any, fieldName string) ([]string, error) { + value, ok := raw[fieldName] + if !ok { + return nil, nil + } + items, ok := value.([]any) + if !ok { + if typed, ok := value.([]string); ok { + return normalizeStringList(fieldName, typed) + } + return nil, fmt.Errorf("%w: ticket_template.%s must be an array", ErrInvalidTicketTemplate, fieldName) + } + + stringsOnly := make([]string, 0, len(items)) + for index, item := range items { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%w: ticket_template.%s[%d] must be a string", ErrInvalidTicketTemplate, fieldName, index) + } + stringsOnly = append(stringsOnly, text) + } + return normalizeStringList(fieldName, stringsOnly) +} + +func normalizeStringList(fieldName string, values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, fmt.Errorf("%w: ticket_template.%s[%d] must not be empty", ErrInvalidTicketTemplate, fieldName, index) + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result, nil +} + func parseTicketTemplateRepoScopes(raw map[string]any) ([]TicketTemplateRepoScope, error) { value, ok := raw["repo_scopes"] if !ok {