diff --git a/actions/setup/js/daily_effective_workflow_helpers.cjs b/actions/setup/js/daily_effective_workflow_helpers.cjs index d8caffb7125..dbcd2edc233 100644 --- a/actions/setup/js/daily_effective_workflow_helpers.cjs +++ b/actions/setup/js/daily_effective_workflow_helpers.cjs @@ -193,7 +193,9 @@ function sumAICFromUsageJSONLFiles(filePaths) { */ function normalizeUsageRecord(usage) { if (usage && typeof usage === "object" && !Array.isArray(usage)) { - return /** @type {Record} */ usage; + // prettier-ignore + const record = /** @type {Record} */ (usage); + return record; } return null; } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 6b4246464bd..bb3ed8bfdb5 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -65,6 +65,15 @@ const SAMPLE_VALIDATION_CONFIG = { agent: { type: "string", sanitize: true, maxLength: 128 }, }, }, + assign_milestone: { + defaultMax: 1, + customValidation: "requiresOneOf:milestone_number,milestone_title", + fields: { + issue_number: { issueNumberOrTemporaryId: true }, + milestone_number: { optionalPositiveInteger: true }, + milestone_title: { type: "string", sanitize: true, maxLength: 128 }, + }, + }, create_pull_request_review_comment: { defaultMax: 1, customValidation: "startLineLessOrEqualLine", @@ -648,6 +657,27 @@ describe("safe_output_type_validator", () => { expect(result.error).toContain("requires at least one of"); }); + it("should validate assign_milestone with milestone_title only", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "assign_milestone", issue_number: 42, milestone_title: "v1.0" }, "assign_milestone", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem).toBeDefined(); + expect(result.normalizedItem.milestone_title).toBe("v1.0"); + }); + + it("should fail assign_milestone when both milestone_number and milestone_title are missing", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "assign_milestone", issue_number: 42 }, "assign_milestone", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("requires at least one of"); + expect(result.error).toContain("milestone_number"); + expect(result.error).toContain("milestone_title"); + }); + it("should pass for update_pull_request when update_branch is true", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); diff --git a/pkg/cli/add_interactive_schedule.go b/pkg/cli/add_interactive_schedule.go index 3f8103900dd..93c7cdf235e 100644 --- a/pkg/cli/add_interactive_schedule.go +++ b/pkg/cli/add_interactive_schedule.go @@ -143,10 +143,10 @@ func classifyScheduleFrequency(scheduleStr string) string { } // Fuzzy cron placeholder matches (produced by the compiler during preprocessing) - if strings.HasPrefix(normalized, "fuzzy:hourly/1 ") || normalized == "fuzzy:hourly/1" { + if strings.HasPrefix(normalized, "fuzzy:hourly/1 ") || normalized == "fuzzy:hourly/1" { //nolint:tolowerequalfold return "hourly" } - if strings.HasPrefix(normalized, "fuzzy:hourly/3 ") || normalized == "fuzzy:hourly/3" { + if strings.HasPrefix(normalized, "fuzzy:hourly/3 ") || normalized == "fuzzy:hourly/3" { //nolint:tolowerequalfold return "3-hourly" } if strings.HasPrefix(normalized, "fuzzy:daily") { diff --git a/pkg/cli/add_package_manifest.go b/pkg/cli/add_package_manifest.go index 905382aaf8d..2665134883d 100644 --- a/pkg/cli/add_package_manifest.go +++ b/pkg/cli/add_package_manifest.go @@ -848,7 +848,7 @@ func validateUniqueManifestWorkflowFilenames(paths []string, manifestPath string } filenameWithoutExt := strings.TrimSuffix(filepath.Base(installPath), filepath.Ext(installPath)) key := strings.ToLower(strings.TrimSpace(filenameWithoutExt)) - if key == "" { + if key == "" { //nolint:tolowerequalfold continue } if previous, exists := seen[key]; exists { diff --git a/pkg/cli/audit_diff.go b/pkg/cli/audit_diff.go index 28cf06fbbad..4ac6f830c31 100644 --- a/pkg/cli/audit_diff.go +++ b/pkg/cli/audit_diff.go @@ -594,7 +594,7 @@ func computeRunMetricsDiff(summary1, summary2 *RunSummary) *RunMetricsDiff { // per-command "bash_*" entries generated by the Codex log parser. func isBashTool(name string) bool { lower := strings.ToLower(name) - return lower == "bash" || strings.HasPrefix(lower, "bash_") + return lower == "bash" || strings.HasPrefix(lower, "bash_") //nolint:tolowerequalfold } // computeToolCallsDiff diffs engine-level tool calls from two LogMetrics values. diff --git a/pkg/cli/codemod_steps_run_secrets_env.go b/pkg/cli/codemod_steps_run_secrets_env.go index ab0d896b73b..762693bac1a 100644 --- a/pkg/cli/codemod_steps_run_secrets_env.go +++ b/pkg/cli/codemod_steps_run_secrets_env.go @@ -179,7 +179,7 @@ func rewriteStepRunSecretsToEnv(stepLines []string, stepIndent string) ([]string shellMatch, shellValue, _ := parseStepKeyLine(trimmed, indent, stepIndent, "shell") if shellMatch { v := strings.ToLower(strings.TrimSpace(shellValue)) - if v == "pwsh" || v == "powershell" { + if v == "pwsh" || v == "powershell" { //nolint:tolowerequalfold shellIsPowerShell = true } break diff --git a/pkg/cli/effective_tokens.go b/pkg/cli/effective_tokens.go index 19302e96f94..25d198c2e8e 100644 --- a/pkg/cli/effective_tokens.go +++ b/pkg/cli/effective_tokens.go @@ -277,7 +277,7 @@ func computeModelEffectiveTokensWithWeights(opts effectiveTokensOptions) int { func getModelMultiplier(model string, multipliers map[string]float64) float64 { key := strings.ToLower(strings.TrimSpace(model)) - if key == "" { + if key == "" { //nolint:tolowerequalfold return 1.0 } if m, ok := multipliers[key]; ok { diff --git a/pkg/cli/model_costs.go b/pkg/cli/model_costs.go index e97f164ecff..0d4b1574803 100644 --- a/pkg/cli/model_costs.go +++ b/pkg/cli/model_costs.go @@ -45,12 +45,12 @@ func initModelPrices() { modelPriceRecords = make([]modelPriceRecord, 0) for providerName, providerData := range data.Providers { normalizedProvider := strings.ToLower(strings.TrimSpace(providerName)) - if normalizedProvider == "" { + if normalizedProvider == "" { //nolint:tolowerequalfold continue } for modelName, entry := range providerData.Models { normalizedModel := strings.ToLower(strings.TrimSpace(modelName)) - if normalizedModel == "" { + if normalizedModel == "" { //nolint:tolowerequalfold continue } normalizedID := normalizedProvider + "/" + normalizedModel @@ -77,7 +77,7 @@ func findModelPricing(provider, model string) (map[string]float64, bool) { normalizedProvider := normalizeCatalogProvider(provider) normalizedModel := strings.ToLower(strings.TrimSpace(model)) comparableModel := normalizeComparableModelID(normalizedModel) - if normalizedModel == "" { + if normalizedModel == "" { //nolint:tolowerequalfold return nil, false } diff --git a/pkg/cli/outcome_eval_review.go b/pkg/cli/outcome_eval_review.go index 59d39775b2b..05330d2bf5b 100644 --- a/pkg/cli/outcome_eval_review.go +++ b/pkg/cli/outcome_eval_review.go @@ -61,7 +61,7 @@ func evalAddReviewer(item CreatedItemReport, repoOverride string) OutcomeReport } state := strings.ToUpper(outcomeString(review["state"])) submittedAt := outcomeString(review["submitted_at"]) - if state == "" || state == "PENDING" || submittedAt == "" { + if state == "" || state == "PENDING" || submittedAt == "" { //nolint:tolowerequalfold continue } if !timestampOnOrAfter(submittedAt, item.Timestamp) { @@ -206,7 +206,7 @@ func evalSubmitPullRequestReview(item CreatedItemReport, repoOverride string) Ou prState, _ := pr["state"].(string) switch { - case reviewState == "DISMISSED": + case reviewState == "DISMISSED": //nolint:tolowerequalfold report.Result = OutcomeRejected report.Detail = "review dismissed by repo admin" report.OutcomeEvaluation = OutcomeEvaluation{ @@ -215,7 +215,7 @@ func evalSubmitPullRequestReview(item CreatedItemReport, repoOverride string) Ou Signal: "review_dismissed", } return report - case prMerged && reviewState == "APPROVED": + case prMerged && reviewState == "APPROVED": //nolint:tolowerequalfold report.Result = OutcomeAccepted report.Detail = "approved review followed by merge" report.TimeToOutcomeHours = timeBetween(item.Timestamp, outcomeString(pr["merged_at"])) @@ -225,7 +225,7 @@ func evalSubmitPullRequestReview(item CreatedItemReport, repoOverride string) Ou Signal: "review_approved", } return report - case prMerged && reviewState == "CHANGES_REQUESTED": + case prMerged && reviewState == "CHANGES_REQUESTED": //nolint:tolowerequalfold commits, err := outcomeReviewGHAPIGetArray(fmt.Sprintf("pulls/%d/commits", num), repo) if err == nil && hasCommitAfterTimestamp(commits, reviewSubmittedAt) { report.Result = OutcomeAccepted @@ -385,7 +385,7 @@ func timestampOnOrAfter(candidate string, threshold string) bool { func hasReviewAfterTimestamp(reviews []map[string]any, threshold string) bool { for _, review := range reviews { state := strings.ToUpper(outcomeString(review["state"])) - if state == "" || state == "PENDING" { + if state == "" || state == "PENDING" { //nolint:tolowerequalfold continue } if timestampOnOrAfter(outcomeString(review["submitted_at"]), threshold) { @@ -412,7 +412,7 @@ func latestReviewAfterTimestamp(reviews []map[string]any, threshold string) map[ for _, review := range reviews { state := strings.ToUpper(outcomeString(review["state"])) submittedAt := outcomeString(review["submitted_at"]) - if state == "" || state == "PENDING" || submittedAt == "" { + if state == "" || state == "PENDING" || submittedAt == "" { //nolint:tolowerequalfold continue } if !timestampOnOrAfter(submittedAt, threshold) { diff --git a/pkg/cli/workflows.go b/pkg/cli/workflows.go index ac9357aa8e8..f62e76d24df 100644 --- a/pkg/cli/workflows.go +++ b/pkg/cli/workflows.go @@ -264,8 +264,7 @@ func suggestWorkflowNames(target string) []string { // isWorkflowFile returns true if the file should be treated as a workflow file. // README.md files are excluded as they are documentation, not workflows. func isWorkflowFile(filename string) bool { - base := strings.ToLower(filepath.Base(filename)) - return base != "readme.md" + return !strings.EqualFold(filepath.Base(filename), "readme.md") } // filterWorkflowFiles filters out non-workflow files from a list of markdown files. diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go index dcd4d784166..b24f115bf01 100644 --- a/pkg/workflow/domains.go +++ b/pkg/workflow/domains.go @@ -244,7 +244,7 @@ func extractProviderFromModel(model string) (string, error) { return "", nil } provider := strings.ToLower(parts[0]) - if provider == "" { + if provider == "" { //nolint:tolowerequalfold return "", fmt.Errorf("invalid engine.model %q: provider prefix is empty; use provider/model format (for example: openai/gpt-4.1, anthropic/claude-sonnet-4)", model) } return provider, nil diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 90b67e39c88..8ec9b443184 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -93,7 +93,7 @@ func shouldRewriteAuthorizationForSentry(endpoint string) bool { lowerTrimmed := strings.ToLower(trimmed) if parsed, err := url.Parse(trimmed); err == nil { - if host := strings.ToLower(parsed.Hostname()); host != "" { + if host := strings.ToLower(parsed.Hostname()); host != "" { //nolint:tolowerequalfold return strings.Contains(host, "sentry") } } diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 2d357a9fc44..8fe8a86bbf4 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -590,7 +590,7 @@ func (c *Compiler) extractSkipAuthorAssociations(frontmatter map[string]any) map normalizedAssociations := make([]string, 0, len(associations)) for _, association := range associations { normalized := strings.ToUpper(strings.TrimSpace(association)) - if normalized != "" { + if normalized != "" { //nolint:tolowerequalfold normalizedAssociations = append(normalizedAssociations, normalized) } } diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 8820dd24c17..c8c4bc27ccc 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -266,15 +266,16 @@ func TestUpdatePullRequestValidationConfig(t *testing.T) { func TestValidationConfigConsistency(t *testing.T) { // Verify that all types with customValidation have valid validation rules validCustomValidations := map[string]bool{ - "requiresOneOf:status,title,body": true, - "requiresOneOf:title,body": true, - "requiresOneOf:title,body,update_branch": true, - "requiresOneOf:title,body,labels": true, - "requiresOneOf:issue_number,pull_number": true, - "requiresOneOf:field_name,field_node_id": true, - "requiresOneOf:reviewers,team_reviewers": true, - "startLineLessOrEqualLine": true, - "parentAndSubDifferent": true, + "requiresOneOf:status,title,body": true, + "requiresOneOf:title,body": true, + "requiresOneOf:title,body,update_branch": true, + "requiresOneOf:title,body,labels": true, + "requiresOneOf:issue_number,pull_number": true, + "requiresOneOf:milestone_number,milestone_title": true, + "requiresOneOf:field_name,field_node_id": true, + "requiresOneOf:reviewers,team_reviewers": true, + "startLineLessOrEqualLine": true, + "parentAndSubDifferent": true, } for typeName, config := range ValidationConfig { @@ -353,6 +354,44 @@ func TestCreatePullRequestBaseValidationMaxLength(t *testing.T) { } } +func TestAssignMilestoneValidationConfig(t *testing.T) { + config, ok := ValidationConfig["assign_milestone"] + if !ok { + t.Fatal("assign_milestone not found in ValidationConfig") + } + + if config.CustomValidation != "requiresOneOf:milestone_number,milestone_title" { + t.Errorf("assign_milestone customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:milestone_number,milestone_title") + } + + if _, ok := config.Fields["milestone_number"]; !ok { + t.Error("assign_milestone Fields is missing the 'milestone_number' field") + } + if _, ok := config.Fields["milestone_title"]; !ok { + t.Error("assign_milestone Fields is missing the 'milestone_title' field") + } +} + +func TestAssignMilestoneValidationConfigJSON(t *testing.T) { + jsonStr, err := GetValidationConfigJSON([]string{"assign_milestone"}, nil) + if err != nil { + t.Fatalf("GetValidationConfigJSON() error = %v", err) + } + + var parsed map[string]TypeValidationConfig + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + + cfg, ok := parsed["assign_milestone"] + if !ok { + t.Fatal("assign_milestone not found in serialized validation config") + } + if cfg.CustomValidation != "requiresOneOf:milestone_number,milestone_title" { + t.Errorf("assign_milestone customValidation in JSON = %q, want %q", cfg.CustomValidation, "requiresOneOf:milestone_number,milestone_title") + } +} + func TestBuildValidationConfigCacheKey(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index ff68b719098..4e41891fcc8 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -118,10 +118,12 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, }, "assign_milestone": { - DefaultMax: 1, + DefaultMax: 1, + CustomValidation: "requiresOneOf:milestone_number,milestone_title", Fields: map[string]FieldValidation{ "issue_number": {IssueNumberOrTemporaryID: true}, - "milestone_number": {Required: true, PositiveInteger: true}, + "milestone_number": {OptionalPositiveInteger: true}, + "milestone_title": {Type: "string", Sanitize: true, MaxLength: 128}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, },