Skip to content

Accept milestone_title in assign_milestone safe-output validation#37529

Merged
dsyme merged 4 commits into
mainfrom
copilot/fix-assign-milestone-handler
Jun 7, 2026
Merged

Accept milestone_title in assign_milestone safe-output validation#37529
dsyme merged 4 commits into
mainfrom
copilot/fix-assign-milestone-handler

Conversation

Copilot AI commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

assign_milestone items were being dropped during post-processing when they provided milestone_title without milestone_number, despite the MCP schema and tool docs allowing either field. This caused valid sampled outputs to disappear before the safe-output handler could resolve titles.

  • Validation config alignment (pkg/workflow)

    • Updated assign_milestone validation to require one of milestone_number or milestone_title (instead of hard-requiring milestone_number).
    • Switched milestone_number to optional-positive-integer validation.
    • Added milestone_title as a validated/sanitized string field.
  • Regression coverage (Go config tests)

    • Added assertions that assign_milestone includes:
      • customValidation: requiresOneOf:milestone_number,milestone_title
      • both milestone_number and milestone_title fields
    • Added JSON serialization coverage to ensure generated validation config preserves this rule.
  • Regression coverage (JS validator tests)

    • Added a case where assign_milestone with only milestone_title is accepted.
    • Added a case where missing both milestone fields is rejected.
"assign_milestone": {
    DefaultMax:       1,
    CustomValidation: "requiresOneOf:milestone_number,milestone_title",
    Fields: map[string]FieldValidation{
        "issue_number":     {IssueNumberOrTemporaryID: true},
        "milestone_number": {OptionalPositiveInteger: true},
        "milestone_title":  {Type: "string", Sanitize: true, MaxLength: 128},
        "repo":             {Type: "string", MaxLength: 256},
    },
}

Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix assign milestone handler to accept milestone_title Accept milestone_title in assign_milestone safe-output validation Jun 7, 2026
Copilot finished work on behalf of dsyme June 7, 2026 10:54
Copilot AI requested a review from dsyme June 7, 2026 10:54
@dsyme dsyme marked this pull request as ready for review June 7, 2026 11:29
Copilot AI review requested due to automatic review settings June 7, 2026 11:29
@dsyme dsyme merged commit 1af803a into main Jun 7, 2026
58 checks passed
@dsyme dsyme deleted the copilot/fix-assign-milestone-handler branch June 7, 2026 11:30
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #37529 does not have the implementation label (has_implementation_label=false) and has 71 new lines of code in business logic directories, which is at or below the 100-line threshold (requires_adr_by_default_volume=false).

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions github-actions Bot mentioned this pull request Jun 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the safe-output validation configuration so assign_milestone accepts either milestone_number or milestone_title, preventing valid items from being dropped before handlers can resolve milestone titles. The PR also includes several unrelated linter-suppression tweaks (tolowerequalfold) and a small JS typing/prettier adjustment.

Changes:

  • Relax assign_milestone validation to require one of milestone_number or milestone_title, and add milestone_title field validation/sanitization.
  • Add Go and JS regression tests to ensure the new requiresOneOf rule is present and enforced.
  • Apply multiple //nolint:tolowerequalfold suppressions (and one EqualFold refactor) across various files.
Show a summary per file
File Description
pkg/workflow/safe_outputs_validation_config.go Adds milestone_title support + requires-one-of validation for assign_milestone.
pkg/workflow/safe_output_validation_config_test.go Adds regression tests for assign_milestone validation config and JSON serialization.
pkg/workflow/role_checks.go Adds a tolowerequalfold suppression in association normalization.
pkg/workflow/observability_otlp.go Adds a tolowerequalfold suppression in Sentry endpoint detection.
pkg/workflow/domains.go Adds a tolowerequalfold suppression in provider parsing.
pkg/cli/workflows.go Replaces ToLower(...) == with strings.EqualFold for README detection.
pkg/cli/outcome_eval_review.go Adds multiple tolowerequalfold suppressions in review outcome evaluation.
pkg/cli/model_costs.go Adds tolowerequalfold suppressions around normalization checks.
pkg/cli/effective_tokens.go Adds a tolowerequalfold suppression for empty normalized model key check.
pkg/cli/codemod_steps_run_secrets_env.go Adds a tolowerequalfold suppression in shell detection.
pkg/cli/audit_diff.go Adds a tolowerequalfold suppression in bash tool detection.
pkg/cli/add_package_manifest.go Adds a tolowerequalfold suppression around normalized filename key checks.
pkg/cli/add_interactive_schedule.go Adds tolowerequalfold suppressions in fuzzy cron placeholder matching.
actions/setup/js/safe_output_type_validator.test.cjs Adds assign_milestone validation config + acceptance/rejection tests for milestone title/number presence.
actions/setup/js/daily_effective_workflow_helpers.cjs Adjusts JSDoc casting to be prettier-stable and clearer.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 15/15 changed files
  • Comments generated: 4

Comment on lines 591 to 595
for _, association := range associations {
normalized := strings.ToUpper(strings.TrimSpace(association))
if normalized != "" {
if normalized != "" { //nolint:tolowerequalfold
normalizedAssociations = append(normalizedAssociations, normalized)
}
Comment on lines 95 to 99
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")
}
}
Comment thread pkg/workflow/domains.go
Comment on lines 246 to 250
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
Comment on lines 61 to 66
}
state := strings.ToUpper(outcomeString(review["state"]))
submittedAt := outcomeString(review["submitted_at"])
if state == "" || state == "PENDING" || submittedAt == "" {
if state == "" || state == "PENDING" || submittedAt == "" { //nolint:tolowerequalfold
continue
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd and /diagnose — no blocking issues, but four test-coverage gaps are worth addressing.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gaps: Three JS test cases missing (milestone_number-only, both-fields, original regression); Go tests verify config shape but not field-validator semantics
  • Handler pipeline unverified: The fix prevents the silent drop at validation, but there is no integration test confirming milestone_title actually resolves end-to-end through the handler

Positive Highlights

  • ✅ Core fix is correct and minimal — requiresOneOf + OptionalPositiveInteger + new milestone_title field aligns the validation config with the documented MCP schema
  • ✅ Two-layer test strategy (Go struct assertions + JS behavioral tests) is a good pattern
  • ✅ JSON round-trip test (TestAssignMilestoneValidationConfigJSON) is a valuable addition that would have caught a serialization regression
  • ✅ The 10 //nolint:tolowerequalfold additions are appropriate — those comparisons operate on already-normalized values; the one genuine simplification opportunity (workflows.go) was correctly refactored using strings.EqualFold

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 838.2K · 251.5 AIC · ⌖ 38.6 AIC

expect(result.isValid).toBe(true);
expect(result.normalizedItem).toBeDefined();
expect(result.normalizedItem.milestone_title).toBe("v1.0");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The milestone_title-only path is now exercised, but the original happy path — milestone_number alone — has no explicit regression test. Since milestone_number changed from {Required: true, PositiveInteger: true} to {OptionalPositiveInteger: true}, a test confirming numeric milestone references still validate correctly provides a clear regression guard.

💡 Suggested test
it("should validate assign_milestone with milestone_number only", async () => {
  const { validateItem } = await import("./safe_output_type_validator.cjs");

  const result = validateItem(
    { type: "assign_milestone", issue_number: 42, milestone_number: 3 },
    "assign_milestone",
    1
  );

  expect(result.isValid).toBe(true);
  expect(result.normalizedItem.milestone_number).toBe(3);
});

This guards the original use case and confirms the field-level refactor from PositiveInteger to OptionalPositiveInteger did not silently change acceptance criteria for the pre-existing input shape.

expect(result.error).toContain("requires at least one of");
expect(result.error).toContain("milestone_number");
expect(result.error).toContain("milestone_title");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The requiresOneOf semantic is "at least one" — so supplying both milestone_number and milestone_title simultaneously should be valid. There is no test for this case. Adding one makes the acceptance contract explicit and ensures the validator does not inadvertently reject a fully-specified item.

💡 Suggested test
it("should validate assign_milestone when both milestone_number and milestone_title are provided", async () => {
  const { validateItem } = await import("./safe_output_type_validator.cjs");

  const result = validateItem(
    { type: "assign_milestone", issue_number: 42, milestone_number: 3, milestone_title: "v1.0" },
    "assign_milestone",
    1
  );

  expect(result.isValid).toBe(true);
});

Documenting this case also clarifies what the downstream handler should do when both fields are present (prefer milestone_number as the canonical identifier, or use milestone_title for display).

"assign_milestone": {
DefaultMax: 1,
DefaultMax: 1,
CustomValidation: "requiresOneOf:milestone_number,milestone_title",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] The bug description states items were silently dropped before reaching the handler that resolves milestone_title. This validation fix prevents the drop, but there is no test exercising the end-to-end path: assign_milestone item with milestone_title only → validation passes → handler resolves title to a milestone ID. If the title-resolution code has a gap, that failure mode remains silent and untested.

💡 What to add

A test in the assign_milestone handler (or a higher-level integration test) that:

  1. Constructs an assign_milestone item with only milestone_title: "v1.0"
  2. Exercises the safe-output processor/handler
  3. Asserts the milestone assignment call receives the resolved milestone ID

This turns the regression description in the PR body into a verifiable spec that prevents the same silent-drop pattern from reappearing in the handler layer.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestAssignMilestoneValidationConfig verifies the shape of the config struct (field names present, CustomValidation string correct) but does not assert anything about the semantics of the field validators. For example, it does not confirm that OptionalPositiveInteger rejects a zero (milestone_number: 0) or a string value, whereas the previous PositiveInteger would have. A companion table-driven test covering field-level rejection cases would make the behavioral contract explicit at the Go layer.

💡 What to add
func TestAssignMilestoneFieldValidation(t *testing.T) {
    cfg := ValidationConfig["assign_milestone"]
    milestoneNumField := cfg.Fields["milestone_number"]

    // OptionalPositiveInteger: absent is fine, but 0 must be rejected
    if milestoneNumField.OptionalPositiveInteger != true {
        t.Error("milestone_number should be OptionalPositiveInteger")
    }
    // milestone_title: must have sanitize + maxLength
    milestoneTitle := cfg.Fields["milestone_title"]
    if milestoneTitle.MaxLength != 128 {
        t.Errorf("milestone_title MaxLength = %d, want 128", milestoneTitle.MaxLength)
    }
    if !milestoneTitle.Sanitize {
        t.Error("milestone_title should have Sanitize=true")
    }
}

This level of coverage catches a future maintainer accidentally weakening field-level validation while keeping the struct keys intact.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — one validation gap in the new milestone_title field

The assign_milestone requiresOneOf migration is correct in intent, but the milestone_title field is missing a MinLength: 1 constraint. An empty-string value passes every validation layer yet fails at runtime with a misleading error.

Full analysis

Blocking issue

milestone_title: "" traces through all three defenses without rejection:

  1. requiresOneOf check (safe_output_type_validator.cjs line 494): "" !== undefined && "" !== false → both true → considered present.
  2. Field validation: type: "string", no required, no minLength → passes.
  3. Handler (assign_milestone.cjs line 177): item.milestone_title || nullnull.
  4. Handler guard (line 181): !hasMilestoneNumber && !milestoneTitle → fires, emitting "Either milestone_number or milestone_title must be provided."

An AI agent that follows the validated schema and passes milestone_title: "" will see a validator-approved call fail with an error telling it to provide a field it already provided. This contradiction is hard to recover from.

Fix: add MinLength: 1 to the milestone_title entry in both the Go config (line 126) and the JS test config mirror (line 74). No new validation logic is needed — the validateField path already enforces minLength (line 401 of safe_output_type_validator.cjs).

Non-blocking observations

  • The nolint:tolowerequalfold suppressions are appropriate where strings.ToLower(x) == "" is an empty-check or where both sides are already the same case. The one real candidate (isWorkflowFile) was correctly converted to EqualFold.
  • daily_effective_workflow_helpers.cjs JSDoc cast fix is correct; the intermediate record variable is harmless but could be collapsed to a single // prettier-ignore + return line.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 513K · ⌖ 13 AIC

"issue_number": {IssueNumberOrTemporaryID: true},
"milestone_number": {Required: true, PositiveInteger: true},
"milestone_number": {OptionalPositiveInteger: true},
"milestone_title": {Type: "string", Sanitize: true, MaxLength: 128},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation accepts milestone_title: "" but the handler rejects it with a misleading error. "" satisfies the requiresOneOf check ("" !== undefined && "" !== false) and passes field validation (no minLength constraint), so the item is declared valid. But in the handler, item.milestone_title || null coerces "" to null, triggering the runtime error "Either milestone_number or milestone_title must be provided." An AI agent that passes milestone_title: "" gets a contradictory error — told to provide a field it already provided.

💡 Suggested fix

Add MinLength: 1 to the Go field definition:

"milestone_title": {Type: "string", Sanitize: true, MaxLength: 128, MinLength: 1},

Mirror it in the JS test config (safe_output_type_validator.test.cjs line 74):

milestone_title: { type: "string", sanitize: true, maxLength: 128, minLength: 1 },

The validateField path already enforces minLength after sanitization (validator line 401), so no new logic is needed. Add a test case { issue_number: 42, milestone_title: "" } to confirm rejection.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

⚠️ Test Quality Score: 75/100 — Acceptable, with suggestions

Analyzed 4 test(s): 4 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (4 tests analyzed)
Metric Value
New/modified tests analyzed 4
✅ Design tests (behavioral contracts) 4 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 2 (50%)
Duplicate test clusters 0
Test inflation detected Yes (both test files exceed 2:1 ratio)
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Issues Detected
TestAssignMilestoneValidationConfig pkg/workflow/safe_output_validation_config_test.go:354 ✅ Design No error-path coverage; verifies config structure only
TestAssignMilestoneValidationConfigJSON pkg/workflow/safe_output_validation_config_test.go:375 ✅ Design t.Fatalf on error returns gives partial coverage
should validate assign_milestone with milestone_title only actions/setup/js/safe_output_type_validator.test.cjs:657 ✅ Design Happy-path only; missing milestone_number-only counterpart
should fail assign_milestone when both milestone_number and milestone_title are missing actions/setup/js/safe_output_type_validator.test.cjs:667 ✅ Design Error case with multi-part message assertions

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 2 tests — unit (//go:build !integration) ✅
  • 🟨 JavaScript (*.test.cjs): 2 tests (vitest)
⚠️ Flagged Tests — Suggestions (4 item(s))

⚠️ TestAssignMilestoneValidationConfig (pkg/workflow/safe_output_validation_config_test.go:354)

Classification: Design test
Issue: Verifies that the assign_milestone entry exists and has the expected customValidation string and field names, but contains no test of what happens when the config is absent or mis-typed at runtime.
What design invariant does this test enforce? That the validation config correctly declares milestone_number and milestone_title as optional fields with the right requiresOneOf rule.
What would break if deleted? A misconfigured assign_milestone entry (wrong field name, missing entry) would not be caught before shipping.
Suggested improvement: Consider expanding to a table-driven test that also checks field-level constraints (e.g., milestone_title has sanitize: true and the correct maxLength).


⚠️ should validate assign_milestone with milestone_title only (safe_output_type_validator.test.cjs:657)

Classification: Design test
Issue 1: Missing expect(result.error).toBeUndefined() — a spurious error field alongside isValid: true would not be caught.
Issue 2: The symmetric valid case (milestone_number only, no milestone_title) is not covered; only one half of the requiresOneOf contract is exercised on the passing side.
What design invariant does this test enforce? That the validator accepts an assign_milestone payload when only milestone_title is supplied.
Suggested improvement: Add a parallel should validate assign_milestone with milestone_number only test, and add expect(result.error).toBeUndefined() to the existing happy-path test.


⚠️ should fail assign_milestone when both milestone_number and milestone_title are missing (safe_output_type_validator.test.cjs:667)

Classification: Design test ✅ — strong error coverage
Issue: No test for the both fields present case; it is undefined by the tests whether supplying both milestone_number and milestone_title simultaneously is valid or invalid.
Suggested improvement: Add a test for { type: "assign_milestone", issue_number: 42, milestone_number: 1, milestone_title: "v1.0" } to document the expected behaviour when both fields are provided.


⚠️ Test inflation detected

Files: both pkg/workflow/safe_output_validation_config_test.go (+48 test lines / +4 production lines = 12:1) and safe_output_type_validator.test.cjs (+30 test lines / +0 changes to its companion safe_output_type_validator.cjs = ∞:1).
Note: The ratio is misleading for the JS file — the production change lives in daily_effective_workflow_helpers.cjs (+3 lines), not in the directly tested .cjs file. The tests themselves are not redundant, but the formula flags them. The Go ratio (12:1) is genuinely high because 4 lines of config required 48 lines of test; consider whether some assertions could be combined into a single table-driven test.

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). No coding-guideline violations. Score penalty (25 pts) comes from partial error/edge-case coverage (50%) and test-inflation flag on both files.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §27091259316

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 2.8M · 840.8 AIC · ⌖ 28.7 AIC ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Quality Sentinel: 75/100. Test quality is acceptable — 0% of new tests are implementation tests (threshold: 30%). All 4 analyzed tests (2 Go, 2 JS) are design tests that verify behavioral contracts. Minor suggestions noted in the PR comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

assign_milestone safe-output handler rejects milestone_title (schema says either milestone_number or milestone_title is fine)

3 participants