From a7c9f5b69047572e8e6ffc0b516ccac4bfa3c053 Mon Sep 17 00:00:00 2001 From: StatPan Date: Sat, 23 May 2026 16:08:24 +0900 Subject: [PATCH] Add approval evidence to sprint dry-runs --- docs/agent-kernel-adapter-contract.md | 2 +- internal/cli/cli.go | 16 ++ internal/cli/cli_test.go | 107 ++++++++++++- internal/gira/approval.go | 208 ++++++++++++++++++++++++++ internal/gira/sprint.go | 94 +++++++++--- internal/gira/sprint_test.go | 51 +++++++ 6 files changed, 456 insertions(+), 22 deletions(-) diff --git a/docs/agent-kernel-adapter-contract.md b/docs/agent-kernel-adapter-contract.md index 9e5c0f5..42b79ad 100644 --- a/docs/agent-kernel-adapter-contract.md +++ b/docs/agent-kernel-adapter-contract.md @@ -236,7 +236,7 @@ before broad adapter use: | Gap | Impact | Follow-up | | --- | --- | --- | -| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, milestone, and cache prune dry-runs, but sprint and Jira transition plans may still need command-specific normalization. | Extend the shared `approval` object to the remaining non-ticket dry-run mutation reports. | +| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, milestone, cache prune, and sprint dry-runs, but Jira transition plans still need command-specific normalization and must not be treated as Jira mutation approval. | Extend the shared `approval` object only where the dry-run authorizes a matching Gira apply boundary. | | Some command families remain text-first or partially JSON-covered. | Automation confidence drops and adapters need fragile parsing. | Add JSON contracts or mark those commands unsupported for adapters. | | No explicit post-apply verification link in every apply report. | Adapters need command-specific knowledge to know which read command proves completion. | Add `post_apply_verification` fields to apply reports. | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 43d69c0..944aea6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7456,6 +7456,10 @@ func runSprint(args []string, stdout io.Writer, stderr io.Writer) int { fmt.Fprintf(stderr, "%v\n", err) return 1 } + gira.EnsureSprintPlanReportSchema(&report) + if report.Mode == "dry-run" { + report.Approval = gira.SprintPlanApprovalEvidence(report) + } output, _ := json.MarshalIndent(report, "", " ") fmt.Fprintf(stdout, "%s\n", output) return 0 @@ -7487,6 +7491,10 @@ func runSprint(args []string, stdout io.Writer, stderr io.Writer) int { fmt.Fprintf(stderr, "%v\n", err) return 1 } + gira.EnsureSprintStartReportSchema(&report) + if report.Mode == "dry-run" { + report.Approval = gira.SprintStartApprovalEvidence(report) + } output, _ := json.MarshalIndent(report, "", " ") if *jsonOutput { fmt.Fprintf(stdout, "%s\n", output) @@ -7530,6 +7538,10 @@ func runSprint(args []string, stdout io.Writer, stderr io.Writer) int { fmt.Fprintf(stderr, "%v\n", err) return 1 } + gira.EnsureSprintCloseReportSchema(&report) + if report.Mode == "dry-run" { + report.Approval = gira.SprintCloseApprovalEvidence(report) + } output, _ := json.MarshalIndent(report, "", " ") if *jsonOutput { fmt.Fprintf(stdout, "%s\n", output) @@ -7565,6 +7577,10 @@ func runSprint(args []string, stdout io.Writer, stderr io.Writer) int { fmt.Fprintf(stderr, "%v\n", err) return 1 } + gira.EnsureSprintRolloverReportSchema(&report) + if report.Mode == "dry-run" { + report.Approval = gira.SprintRolloverApprovalEvidence(report) + } output, _ := json.MarshalIndent(report, "", " ") if *jsonOutput { fmt.Fprintf(stdout, "%s\n", output) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 1ce6998..9d6df80 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4439,16 +4439,57 @@ func TestSprintPlanStartCloseJSONLifecycle(t *testing.T) { t.Cleanup(func() { _ = os.Chdir(cwd) }) var stdout, stderr bytes.Buffer - code := Run([]string{"sprint", "plan", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--capacity", "2", "--issues", "3,1,2", "--apply", "--json"}, &stdout, &stderr) + code := Run([]string{"sprint", "plan", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--capacity", "2", "--issues", "3,1,2", "--dry-run", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("plan dry-run exit code=%d stderr=%s", code, stderr.String()) + } + var dryPlan gira.SprintPlanReport + if err := json.Unmarshal(stdout.Bytes(), &dryPlan); err != nil { + t.Fatalf("decode sprint plan dry-run JSON: %v\n%s", err, stdout.String()) + } + if dryPlan.SchemaVersion != gira.SprintPlanReportSchemaVersion || dryPlan.Approval == nil { + t.Fatalf("sprint plan dry-run JSON missing schema or approval:\n%s", stdout.String()) + } + if dryPlan.Approval.ApplyCommand != "gira sprint plan --repo StatPan/gira --iteration 2026-W18 --capacity 2 --issues 1,2,3 --apply" || dryPlan.Approval.OutputSchema != gira.SprintPlanReportSchemaVersion { + t.Fatalf("unexpected sprint plan approval evidence: %+v", dryPlan.Approval) + } + if dryPlan.Approval.Blockers == nil || dryPlan.Approval.Warnings == nil { + t.Fatalf("approval blockers and warnings must be stable arrays: %+v", dryPlan.Approval) + } + + stdout.Reset() + stderr.Reset() + code = Run([]string{"sprint", "plan", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--capacity", "2", "--issues", "3,1,2", "--apply", "--json"}, &stdout, &stderr) if code != 0 { t.Fatalf("plan exit code=%d stderr=%s", code, stderr.String()) } + var applyPlan gira.SprintPlanReport + if err := json.Unmarshal(stdout.Bytes(), &applyPlan); err != nil { + t.Fatalf("decode sprint plan apply JSON: %v\n%s", err, stdout.String()) + } + if applyPlan.SchemaVersion != gira.SprintPlanReportSchemaVersion || applyPlan.Approval != nil { + t.Fatalf("sprint plan apply JSON should have schema and omit approval: %+v", applyPlan) + } for _, want := range []string{`"mode": "apply"`, `"capacity_target": 2`, `"commit_count": 3`, `"capacity_breach": true`} { if !strings.Contains(stdout.String(), want) { t.Fatalf("sprint plan JSON missing %q:\n%s", want, stdout.String()) } } + stdout.Reset() + stderr.Reset() + code = Run([]string{"sprint", "start", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--dry-run", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("start dry-run exit code=%d stderr=%s", code, stderr.String()) + } + var dryStart gira.SprintStartReport + if err := json.Unmarshal(stdout.Bytes(), &dryStart); err != nil { + t.Fatalf("decode sprint start dry-run JSON: %v\n%s", err, stdout.String()) + } + if dryStart.SchemaVersion != gira.SprintStartReportSchemaVersion || dryStart.Approval == nil || dryStart.Approval.ApplyCommand != "gira sprint start --repo StatPan/gira --iteration 2026-W18 --apply" { + t.Fatalf("unexpected sprint start approval evidence: %+v", dryStart) + } + stdout.Reset() stderr.Reset() code = Run([]string{"sprint", "start", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--apply", "--json"}, &stdout, &stderr) @@ -4461,6 +4502,23 @@ func TestSprintPlanStartCloseJSONLifecycle(t *testing.T) { } } + stdout.Reset() + stderr.Reset() + code = Run([]string{"sprint", "close", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--completed", "1,3", "--spillover-disposition", "carry", "--rollover-reason", "dependency blocked", "--dry-run", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("close dry-run exit code=%d stderr=%s", code, stderr.String()) + } + var dryClose gira.SprintCloseReport + if err := json.Unmarshal(stdout.Bytes(), &dryClose); err != nil { + t.Fatalf("decode sprint close dry-run JSON: %v\n%s", err, stdout.String()) + } + if dryClose.SchemaVersion != gira.SprintCloseReportSchemaVersion || dryClose.Approval == nil { + t.Fatalf("sprint close dry-run JSON missing schema or approval: %+v", dryClose) + } + if dryClose.Approval.ApplyCommand != "gira sprint close --repo StatPan/gira --iteration 2026-W18 --completed 1,3 --spillover-disposition carry --rollover-reason 'dependency blocked' --apply" { + t.Fatalf("unexpected sprint close approval command: %+v", dryClose.Approval) + } + stdout.Reset() stderr.Reset() code = Run([]string{"sprint", "close", "--repo", "StatPan/gira", "--iteration", "2026-W18", "--completed", "1,3", "--spillover-disposition", "carry", "--rollover-reason", "dependency blocked", "--apply", "--json"}, &stdout, &stderr) @@ -4474,6 +4532,9 @@ func TestSprintPlanStartCloseJSONLifecycle(t *testing.T) { if closeReport.Mode != "apply" || fmt.Sprint(closeReport.Summary.CompletedItems) != "[1 3]" || fmt.Sprint(closeReport.Summary.SpilloverItems) != "[2]" || closeReport.Summary.SpilloverDisposition != "carry" || closeReport.Summary.RolloverReason != "dependency blocked" { t.Fatalf("unexpected sprint close report: %+v", closeReport) } + if closeReport.SchemaVersion != gira.SprintCloseReportSchemaVersion || closeReport.Approval != nil { + t.Fatalf("sprint close apply JSON should have schema and omit approval: %+v", closeReport) + } } func TestSprintRolloverJSONUsesInjectedReport(t *testing.T) { @@ -4503,6 +4564,50 @@ func TestSprintRolloverJSONUsesInjectedReport(t *testing.T) { t.Fatalf("sprint rollover JSON missing %q:\n%s", want, stdout.String()) } } + var report gira.SprintRolloverReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode sprint rollover JSON: %v\n%s", err, stdout.String()) + } + if report.SchemaVersion != gira.SprintRolloverReportSchemaVersion || report.Approval != nil { + t.Fatalf("sprint rollover apply JSON should have schema and omit approval: %+v", report) + } +} + +func TestSprintRolloverDryRunJSONUsesInjectedApproval(t *testing.T) { + restore := newSprintRolloverReport + t.Cleanup(func() { newSprintRolloverReport = restore }) + newSprintRolloverReport = func(repo gira.RepoRef, toMilestone string, apply bool) (gira.SprintRolloverReport, error) { + if repo.FullName() != "StatPan/gira" || toMilestone != "W18" || apply { + t.Fatalf("unexpected rollover args repo=%s to=%s apply=%t", repo.FullName(), toMilestone, apply) + } + return gira.SprintRolloverReport{ + Repo: repo.FullName(), + Mode: "dry-run", + TargetMilestone: &gira.SprintRolloverTarget{Number: 2, Title: "W18"}, + TargetResolution: "explicit --to", + Summary: gira.SprintRolloverSummary{Candidates: 1, Applied: 1}, + Items: []gira.SprintRolloverItem{{IssueNumber: 10, IssueTitle: "Carry me", FromMilestone: "W17", CandidateReason: "source milestone due date passed", Action: "would-apply", TargetMilestone: "W18"}}, + }, nil + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"sprint", "rollover", "--repo", "StatPan/gira", "--to", "W18", "--dry-run", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("rollover exit code=%d stderr=%s", code, stderr.String()) + } + var report gira.SprintRolloverReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode sprint rollover JSON: %v\n%s", err, stdout.String()) + } + if report.SchemaVersion != gira.SprintRolloverReportSchemaVersion || report.Approval == nil { + t.Fatalf("sprint rollover dry-run JSON missing schema or approval:\n%s", stdout.String()) + } + if report.Approval.ApplyCommand != "gira sprint rollover --repo StatPan/gira --to W18 --apply" || report.Approval.OutputSchema != gira.SprintRolloverReportSchemaVersion { + t.Fatalf("unexpected sprint rollover approval evidence: %+v", report.Approval) + } + if report.Approval.Blockers == nil || report.Approval.Warnings == nil { + t.Fatalf("approval blockers and warnings must be stable arrays: %+v", report.Approval) + } } func TestTriageMissingRepoReturnsTwo(t *testing.T) { diff --git a/internal/gira/approval.go b/internal/gira/approval.go index bbd2378..a854b06 100644 --- a/internal/gira/approval.go +++ b/internal/gira/approval.go @@ -1099,3 +1099,211 @@ func cachePruneApprovalBlockers(report CachePruneReport) []string { } return stableStringSlice(blockers) } + +func SprintPlanApprovalEvidence(report SprintPlanReport) *ApprovalEvidence { + applyCommand := sprintPlanApprovalCommand(report, "--apply") + dryRunCommand := sprintPlanApprovalCommand(report, "--dry-run") + return &ApprovalEvidence{ + SchemaVersion: ApprovalPlanSchemaVersion, + Capability: AdapterCapabilityApplyMutation, + CanonicalCommand: "gira sprint plan", + DryRunCommand: dryRunCommand, + ApplyCommand: applyCommand, + Repo: report.Repo, + OutputSchema: SprintPlanReportSchemaVersion, + PlannedActions: sprintPlanApprovalActions(report), + Blockers: []string{}, + Warnings: sprintPlanApprovalWarnings(report), + PostApplyVerification: sprintStartApprovalCommand(SprintStartReport{Repo: report.Repo, Iteration: report.Iteration}, "--dry-run") + " --json", + } +} + +func sprintPlanApprovalCommand(report SprintPlanReport, mode string) string { + args := []string{ + "gira sprint plan", + "--repo", QuoteShellArg(report.Repo), + "--iteration", QuoteShellArg(report.Iteration), + "--capacity", fmt.Sprintf("%d", report.Capacity), + } + if len(report.Sprint.CommittedItems) > 0 { + args = append(args, "--issues", joinIssueNumbers(report.Sprint.CommittedItems)) + } + args = append(args, mode) + return strings.Join(args, " ") +} + +func sprintPlanApprovalActions(report SprintPlanReport) []ApprovalPlannedAction { + return []ApprovalPlannedAction{{ + Action: "sprint:plan", + Target: report.Iteration, + Detail: fmt.Sprintf("persist sprint plan capacity=%d committed=%s", report.Capacity, joinIssueNumbers(report.Sprint.CommittedItems)), + }} +} + +func sprintPlanApprovalWarnings(report SprintPlanReport) []string { + if report.CapacityBreach { + return []string{"sprint_capacity_breach"} + } + return []string{} +} + +func SprintStartApprovalEvidence(report SprintStartReport) *ApprovalEvidence { + applyCommand := sprintStartApprovalCommand(report, "--apply") + dryRunCommand := sprintStartApprovalCommand(report, "--dry-run") + return &ApprovalEvidence{ + SchemaVersion: ApprovalPlanSchemaVersion, + Capability: AdapterCapabilityApplyMutation, + CanonicalCommand: "gira sprint start", + DryRunCommand: dryRunCommand, + ApplyCommand: applyCommand, + Repo: report.Repo, + OutputSchema: SprintStartReportSchemaVersion, + PlannedActions: sprintStartApprovalActions(report), + Blockers: []string{}, + Warnings: []string{}, + PostApplyVerification: dryRunCommand + " --json", + } +} + +func sprintStartApprovalCommand(report SprintStartReport, mode string) string { + args := []string{ + "gira sprint start", + "--repo", QuoteShellArg(report.Repo), + "--iteration", QuoteShellArg(report.Iteration), + mode, + } + return strings.Join(args, " ") +} + +func sprintStartApprovalActions(report SprintStartReport) []ApprovalPlannedAction { + return []ApprovalPlannedAction{{ + Action: "sprint:start", + Target: report.Iteration, + Detail: "freeze sprint commitment and record started_at", + }} +} + +func SprintCloseApprovalEvidence(report SprintCloseReport) *ApprovalEvidence { + applyCommand := sprintCloseApprovalCommand(report, "--apply") + dryRunCommand := sprintCloseApprovalCommand(report, "--dry-run") + return &ApprovalEvidence{ + SchemaVersion: ApprovalPlanSchemaVersion, + Capability: AdapterCapabilityApplyMutation, + CanonicalCommand: "gira sprint close", + DryRunCommand: dryRunCommand, + ApplyCommand: applyCommand, + Repo: report.Repo, + OutputSchema: SprintCloseReportSchemaVersion, + PlannedActions: sprintCloseApprovalActions(report), + Blockers: sprintCloseApprovalBlockers(report), + Warnings: []string{}, + PostApplyVerification: dryRunCommand + " --json", + } +} + +func sprintCloseApprovalCommand(report SprintCloseReport, mode string) string { + args := []string{ + "gira sprint close", + "--repo", QuoteShellArg(report.Repo), + "--iteration", QuoteShellArg(report.Iteration), + } + if len(report.Summary.CompletedItems) > 0 { + args = append(args, "--completed", joinIssueNumbers(report.Summary.CompletedItems)) + } + if strings.TrimSpace(report.Summary.SpilloverDisposition) != "" { + args = append(args, "--spillover-disposition", QuoteShellArg(report.Summary.SpilloverDisposition)) + } + if strings.TrimSpace(report.Summary.RolloverReason) != "" { + args = append(args, "--rollover-reason", QuoteShellArg(report.Summary.RolloverReason)) + } + args = append(args, mode) + return strings.Join(args, " ") +} + +func sprintCloseApprovalActions(report SprintCloseReport) []ApprovalPlannedAction { + detail := fmt.Sprintf("completed=%s spillover=%s disposition=%s", joinIssueNumbers(report.Summary.CompletedItems), joinIssueNumbers(report.Summary.SpilloverItems), report.Summary.SpilloverDisposition) + if strings.TrimSpace(report.Summary.RolloverReason) != "" { + detail = appendApprovalDetail(detail, "reason="+report.Summary.RolloverReason) + } + return []ApprovalPlannedAction{{ + Action: "sprint:close", + Target: report.Iteration, + Detail: detail, + }} +} + +func sprintCloseApprovalBlockers(report SprintCloseReport) []string { + blockers := []string{} + if strings.TrimSpace(report.Summary.SpilloverDisposition) == "" { + blockers = appendUniqueStrings(blockers, "sprint_close_missing_spillover_disposition") + } + if strings.TrimSpace(report.Summary.RolloverReason) == "" { + blockers = appendUniqueStrings(blockers, "sprint_close_missing_rollover_reason") + } + return stableStringSlice(blockers) +} + +func SprintRolloverApprovalEvidence(report SprintRolloverReport) *ApprovalEvidence { + applyCommand := sprintRolloverApprovalCommand(report, "--apply") + dryRunCommand := sprintRolloverApprovalCommand(report, "--dry-run") + return &ApprovalEvidence{ + SchemaVersion: ApprovalPlanSchemaVersion, + Capability: AdapterCapabilityApplyMutation, + CanonicalCommand: "gira sprint rollover", + DryRunCommand: dryRunCommand, + ApplyCommand: applyCommand, + Repo: report.Repo, + OutputSchema: SprintRolloverReportSchemaVersion, + PlannedActions: sprintRolloverApprovalActions(report), + Blockers: sprintRolloverApprovalBlockers(report), + Warnings: []string{}, + PostApplyVerification: dryRunCommand + " --json", + } +} + +func sprintRolloverApprovalCommand(report SprintRolloverReport, mode string) string { + args := []string{ + "gira sprint rollover", + "--repo", QuoteShellArg(report.Repo), + } + if report.TargetMilestone != nil && report.TargetResolution == "explicit --to" { + args = append(args, "--to", QuoteShellArg(report.TargetMilestone.Title)) + } + args = append(args, mode) + return strings.Join(args, " ") +} + +func sprintRolloverApprovalActions(report SprintRolloverReport) []ApprovalPlannedAction { + actions := []ApprovalPlannedAction{} + for _, item := range report.Items { + if item.Action != "would-apply" { + continue + } + detail := item.FromMilestone + " -> " + item.TargetMilestone + if strings.TrimSpace(item.CandidateReason) != "" { + detail = appendApprovalDetail(detail, "reason="+item.CandidateReason) + } + actions = append(actions, ApprovalPlannedAction{ + Action: "issue:rollover-milestone", + Target: fmt.Sprintf("#%d", item.IssueNumber), + Detail: detail, + }) + } + return actions +} + +func sprintRolloverApprovalBlockers(report SprintRolloverReport) []string { + blockers := []string{} + if report.Mode == "dry-run" && len(sprintRolloverApprovalActions(report)) == 0 { + blockers = appendUniqueStrings(blockers, "sprint_rollover_no_planned_actions") + } + for _, item := range report.Items { + if item.Action == "skipped" && item.SkipReason == "no target open milestone" { + blockers = appendUniqueStrings(blockers, "sprint_rollover_no_target_milestone") + } + if item.Action == "skipped" && strings.HasPrefix(item.SkipReason, "apply failed:") { + blockers = appendUniqueStrings(blockers, "sprint_rollover_apply_failed") + } + } + return stableStringSlice(blockers) +} diff --git a/internal/gira/sprint.go b/internal/gira/sprint.go index 851f08f..38ecde3 100644 --- a/internal/gira/sprint.go +++ b/internal/gira/sprint.go @@ -28,38 +28,77 @@ type Sprint struct { ClosedAt string `json:"closed_at,omitempty"` } +const ( + SprintPlanReportSchemaVersion = "sprint-plan-report/v1" + SprintStartReportSchemaVersion = "sprint-start-report/v1" + SprintCloseReportSchemaVersion = "sprint-close-report/v1" + SprintRolloverReportSchemaVersion = "sprint-rollover-report/v1" +) + type SprintPlanReport struct { - Repo string `json:"repo"` - Iteration string `json:"iteration"` - Mode string `json:"mode"` - Capacity int `json:"capacity_target"` - CommitCount int `json:"commit_count"` - CapacityBreach bool `json:"capacity_breach"` - Sprint Sprint `json:"sprint"` + SchemaVersion string `json:"schema_version,omitempty"` + Repo string `json:"repo"` + Iteration string `json:"iteration"` + Mode string `json:"mode"` + Capacity int `json:"capacity_target"` + CommitCount int `json:"commit_count"` + CapacityBreach bool `json:"capacity_breach"` + Sprint Sprint `json:"sprint"` + Approval *ApprovalEvidence `json:"approval,omitempty"` } type SprintStartReport struct { - Repo string `json:"repo"` - Iteration string `json:"iteration"` - Mode string `json:"mode"` - Frozen bool `json:"commitment_frozen"` - StartedAt string `json:"started_at"` + SchemaVersion string `json:"schema_version,omitempty"` + Repo string `json:"repo"` + Iteration string `json:"iteration"` + Mode string `json:"mode"` + Frozen bool `json:"commitment_frozen"` + StartedAt string `json:"started_at"` + Approval *ApprovalEvidence `json:"approval,omitempty"` } type SprintCloseReport struct { - Repo string `json:"repo"` - Iteration string `json:"iteration"` - Mode string `json:"mode"` - Summary Sprint `json:"summary"` + SchemaVersion string `json:"schema_version,omitempty"` + Repo string `json:"repo"` + Iteration string `json:"iteration"` + Mode string `json:"mode"` + Summary Sprint `json:"summary"` + Approval *ApprovalEvidence `json:"approval,omitempty"` } type SprintRolloverReport struct { + SchemaVersion string `json:"schema_version,omitempty"` Repo string `json:"repo"` Mode string `json:"mode"` TargetMilestone *SprintRolloverTarget `json:"target_milestone,omitempty"` TargetResolution string `json:"target_resolution"` Summary SprintRolloverSummary `json:"summary"` Items []SprintRolloverItem `json:"items"` + Approval *ApprovalEvidence `json:"approval,omitempty"` +} + +func EnsureSprintPlanReportSchema(report *SprintPlanReport) { + if report != nil && strings.TrimSpace(report.SchemaVersion) == "" { + report.SchemaVersion = SprintPlanReportSchemaVersion + } +} + +func EnsureSprintStartReportSchema(report *SprintStartReport) { + if report != nil && strings.TrimSpace(report.SchemaVersion) == "" { + report.SchemaVersion = SprintStartReportSchemaVersion + } +} + +func EnsureSprintCloseReportSchema(report *SprintCloseReport) { + if report != nil && strings.TrimSpace(report.SchemaVersion) == "" { + report.SchemaVersion = SprintCloseReportSchemaVersion + } +} + +func EnsureSprintRolloverReportSchema(report *SprintRolloverReport) { + if report != nil && strings.TrimSpace(report.SchemaVersion) == "" { + report.SchemaVersion = SprintRolloverReportSchemaVersion + } } type SprintRolloverTarget struct { @@ -115,7 +154,11 @@ func PlanSprint(path string, repo RepoRef, iteration string, capacity int, commi return SprintPlanReport{}, err } } - return SprintPlanReport{Repo: repo.FullName(), Iteration: iteration, Mode: mode, Capacity: capacity, CommitCount: len(committed), CapacityBreach: breach, Sprint: sprint}, nil + report := SprintPlanReport{SchemaVersion: SprintPlanReportSchemaVersion, Repo: repo.FullName(), Iteration: iteration, Mode: mode, Capacity: capacity, CommitCount: len(committed), CapacityBreach: breach, Sprint: sprint} + if !apply { + report.Approval = SprintPlanApprovalEvidence(report) + } + return report, nil } func StartSprint(path string, repo RepoRef, iteration string, apply bool, now time.Time) (SprintStartReport, error) { @@ -141,7 +184,11 @@ func StartSprint(path string, repo RepoRef, iteration string, apply bool, now ti return SprintStartReport{}, err } } - return SprintStartReport{Repo: repo.FullName(), Iteration: iteration, Mode: mode, Frozen: true, StartedAt: started}, nil + report := SprintStartReport{SchemaVersion: SprintStartReportSchemaVersion, Repo: repo.FullName(), Iteration: iteration, Mode: mode, Frozen: true, StartedAt: started} + if !apply { + report.Approval = SprintStartApprovalEvidence(report) + } + return report, nil } func CloseSprint(path string, repo RepoRef, iteration string, completed []int, disposition string, reason string, apply bool, now time.Time) (SprintCloseReport, error) { @@ -179,7 +226,11 @@ func CloseSprint(path string, repo RepoRef, iteration string, completed []int, d return SprintCloseReport{}, err } } - return SprintCloseReport{Repo: repo.FullName(), Iteration: iteration, Mode: mode, Summary: summary}, nil + report := SprintCloseReport{SchemaVersion: SprintCloseReportSchemaVersion, Repo: repo.FullName(), Iteration: iteration, Mode: mode, Summary: summary} + if !apply { + report.Approval = SprintCloseApprovalEvidence(report) + } + return report, nil } func SprintRollover(repo RepoRef, toMilestone string, apply bool, now time.Time, runner CommandRunner) (SprintRolloverReport, error) { @@ -206,7 +257,7 @@ func SprintRolloverForClient(client StatusClient, runner CommandRunner, toMilest if apply { mode = "apply" } - report := SprintRolloverReport{Repo: client.Repo().FullName(), Mode: mode, Items: make([]SprintRolloverItem, 0)} + report := SprintRolloverReport{SchemaVersion: SprintRolloverReportSchemaVersion, Repo: client.Repo().FullName(), Mode: mode, Items: make([]SprintRolloverItem, 0)} target, resolution := resolveRolloverTarget(milestones, strings.TrimSpace(toMilestone), now) report.TargetResolution = resolution if target != nil { @@ -268,6 +319,9 @@ func SprintRolloverForClient(client StatusClient, runner CommandRunner, toMilest } sort.Slice(report.Items, func(i, j int) bool { return report.Items[i].IssueNumber < report.Items[j].IssueNumber }) + if !apply { + report.Approval = SprintRolloverApprovalEvidence(report) + } return report, nil } diff --git a/internal/gira/sprint_test.go b/internal/gira/sprint_test.go index e7bc6cf..821a541 100644 --- a/internal/gira/sprint_test.go +++ b/internal/gira/sprint_test.go @@ -13,6 +13,20 @@ func TestSprintFlowPlanStartClose(t *testing.T) { repo := RepoRef{Owner: "StatPan", Name: "gira"} statePath := filepath.Join(t.TempDir(), "state.json") + dryPlan, err := PlanSprint(statePath, repo, "2026-W18", 2, []int{11, 12, 13}, false) + if err != nil { + t.Fatalf("PlanSprint dry-run error: %v", err) + } + if dryPlan.SchemaVersion != SprintPlanReportSchemaVersion || dryPlan.Approval == nil { + t.Fatalf("expected sprint plan schema and approval evidence: %+v", dryPlan) + } + if dryPlan.Approval.ApplyCommand != "gira sprint plan --repo StatPan/gira --iteration 2026-W18 --capacity 2 --issues 11,12,13 --apply" || dryPlan.Approval.PostApplyVerification != "gira sprint start --repo StatPan/gira --iteration 2026-W18 --dry-run --json" { + t.Fatalf("unexpected sprint plan approval commands: %+v", dryPlan.Approval) + } + if dryPlan.Approval.Blockers == nil || !approvalHasAction(dryPlan.Approval.PlannedActions, "sprint:plan") || len(dryPlan.Approval.Warnings) != 1 || dryPlan.Approval.Warnings[0] != "sprint_capacity_breach" { + t.Fatalf("unexpected sprint plan approval evidence: %+v", dryPlan.Approval) + } + plan, err := PlanSprint(statePath, repo, "2026-W18", 2, []int{11, 12, 13}, true) if err != nil { t.Fatalf("PlanSprint error: %v", err) @@ -20,12 +34,34 @@ func TestSprintFlowPlanStartClose(t *testing.T) { if !plan.CapacityBreach { t.Fatalf("expected capacity breach true") } + if plan.SchemaVersion != SprintPlanReportSchemaVersion || plan.Approval != nil { + t.Fatalf("apply sprint plan should include schema and omit approval: %+v", plan) + } now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + dryStart, err := StartSprint(statePath, repo, "2026-W18", false, now) + if err != nil { + t.Fatalf("StartSprint dry-run error: %v", err) + } + if dryStart.SchemaVersion != SprintStartReportSchemaVersion || dryStart.Approval == nil || dryStart.Approval.ApplyCommand != "gira sprint start --repo StatPan/gira --iteration 2026-W18 --apply" { + t.Fatalf("unexpected sprint start approval evidence: %+v", dryStart) + } if _, err := StartSprint(statePath, repo, "2026-W18", true, now); err != nil { t.Fatalf("StartSprint error: %v", err) } + dryClose, err := CloseSprint(statePath, repo, "2026-W18", []int{11}, "carry", "dependency blocked", false, now) + if err != nil { + t.Fatalf("CloseSprint dry-run error: %v", err) + } + if dryClose.SchemaVersion != SprintCloseReportSchemaVersion || dryClose.Approval == nil { + t.Fatalf("expected sprint close schema and approval evidence: %+v", dryClose) + } + expectedCloseApply := "gira sprint close --repo StatPan/gira --iteration 2026-W18 --completed 11 --spillover-disposition carry --rollover-reason 'dependency blocked' --apply" + if dryClose.Approval.ApplyCommand != expectedCloseApply || !approvalHasAction(dryClose.Approval.PlannedActions, "sprint:close") { + t.Fatalf("unexpected sprint close approval evidence: %+v", dryClose.Approval) + } + closeReport, err := CloseSprint(statePath, repo, "2026-W18", []int{11}, "carry", "dependency blocked", true, now) if err != nil { t.Fatalf("CloseSprint error: %v", err) @@ -33,6 +69,9 @@ func TestSprintFlowPlanStartClose(t *testing.T) { if len(closeReport.Summary.SpilloverItems) != 2 || closeReport.Summary.SpilloverItems[0] != 12 || closeReport.Summary.SpilloverItems[1] != 13 { t.Fatalf("unexpected spillover items: %#v", closeReport.Summary.SpilloverItems) } + if closeReport.SchemaVersion != SprintCloseReportSchemaVersion || closeReport.Approval != nil { + t.Fatalf("apply sprint close should include schema and omit approval: %+v", closeReport) + } } func TestPlanRespectsFreeze(t *testing.T) { @@ -66,6 +105,15 @@ func TestSprintRolloverDryRunDetectsCandidatesAndTarget(t *testing.T) { if len(report.Items) != 1 || report.Items[0].Action != "would-apply" { t.Fatalf("unexpected items: %#v", report.Items) } + if report.SchemaVersion != SprintRolloverReportSchemaVersion || report.Approval == nil { + t.Fatalf("expected sprint rollover schema and approval evidence: %+v", report) + } + if report.Approval.ApplyCommand != "gira sprint rollover --repo StatPan/gira --apply" || report.Approval.PostApplyVerification != "gira sprint rollover --repo StatPan/gira --dry-run --json" { + t.Fatalf("unexpected sprint rollover approval commands: %+v", report.Approval) + } + if report.Approval.Blockers == nil || report.Approval.Warnings == nil || !approvalHasAction(report.Approval.PlannedActions, "issue:rollover-milestone") { + t.Fatalf("unexpected sprint rollover approval plan: %+v", report.Approval) + } if report.Items[0].LifecycleStatus != "open" || report.Items[0].NextStep != "gira ticket status --repo StatPan/gira --ticket 10" { t.Fatalf("missing lifecycle evidence: %#v", report.Items[0]) } @@ -106,6 +154,9 @@ func TestSprintRolloverApplyCallsMilestonePatch(t *testing.T) { if report.Summary.Applied != 1 { t.Fatalf("applied=%d, want 1", report.Summary.Applied) } + if report.SchemaVersion != SprintRolloverReportSchemaVersion || report.Approval != nil { + t.Fatalf("apply sprint rollover should include schema and omit approval: %+v", report) + } if len(runner.calls) != 1 { t.Fatalf("runner calls=%v, want 1", runner.calls) }