From acc647c24b4cd8716e45e5f3934fe1e8d69a8fe6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 07:42:42 +0000 Subject: [PATCH] fix(agentic): complete RFC action option mapping Co-Authored-By: Virgil --- pkg/agentic/actions.go | 355 +++++++++++++++++++++++++++++++----- pkg/agentic/actions_test.go | 132 ++++++++++++++ 2 files changed, 441 insertions(+), 46 deletions(-) diff --git a/pkg/agentic/actions.go b/pkg/agentic/actions.go index a1f2918..571ddf8 100644 --- a/pkg/agentic/actions.go +++ b/pkg/agentic/actions.go @@ -20,12 +20,7 @@ import ( // // )) func (s *PrepSubsystem) handleDispatch(ctx context.Context, options core.Options) core.Result { - input := DispatchInput{ - Repo: options.String("repo"), - Task: options.String("task"), - Agent: options.String("agent"), - Issue: options.Int("issue"), - } + input := dispatchInputFromOptions(options) _, out, err := s.dispatch(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -40,11 +35,7 @@ func (s *PrepSubsystem) handleDispatch(ctx context.Context, options core.Options // // )) func (s *PrepSubsystem) handlePrep(ctx context.Context, options core.Options) core.Result { - input := PrepInput{ - Repo: options.String("repo"), - Org: options.String("org"), - Issue: options.Int("issue"), - } + input := prepInputFromOptions(options) _, out, err := s.prepWorkspace(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -72,10 +63,7 @@ func (s *PrepSubsystem) handleStatus(ctx context.Context, options core.Options) // // )) func (s *PrepSubsystem) handleResume(ctx context.Context, options core.Options) core.Result { - input := ResumeInput{ - Workspace: options.String("workspace"), - Answer: options.String("answer"), - } + input := resumeInputFromOptions(options) _, out, err := s.resume(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -85,10 +73,7 @@ func (s *PrepSubsystem) handleResume(ctx context.Context, options core.Options) // result := c.Action("agentic.scan").Run(ctx, core.NewOptions()) func (s *PrepSubsystem) handleScan(ctx context.Context, options core.Options) core.Result { - input := ScanInput{ - Org: options.String("org"), - Limit: options.Int("limit"), - } + input := scanInputFromOptions(options) _, out, err := s.scan(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -102,13 +87,7 @@ func (s *PrepSubsystem) handleScan(ctx context.Context, options core.Options) co // // )) func (s *PrepSubsystem) handleWatch(ctx context.Context, options core.Options) core.Result { - input := WatchInput{ - PollInterval: options.Int("poll_interval"), - Timeout: options.Int("timeout"), - } - if workspace := options.String("workspace"); workspace != "" { - input.Workspaces = []string{workspace} - } + input := watchInputFromOptions(options) _, out, err := s.watch(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -298,9 +277,7 @@ func (s *PrepSubsystem) handlePoke(ctx context.Context, _ core.Options) core.Res // // )) func (s *PrepSubsystem) handleMirror(ctx context.Context, options core.Options) core.Result { - input := MirrorInput{ - Repo: options.String("repo"), - } + input := mirrorInputFromOptions(options) _, out, err := s.mirror(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -315,7 +292,7 @@ func (s *PrepSubsystem) handleMirror(ctx context.Context, options core.Options) // // )) func (s *PrepSubsystem) handleIssueGet(ctx context.Context, options core.Options) core.Result { - return s.cmdIssueGet(options) + return s.cmdIssueGet(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.issue.list").Run(ctx, core.NewOptions( @@ -324,7 +301,7 @@ func (s *PrepSubsystem) handleIssueGet(ctx context.Context, options core.Options // // )) func (s *PrepSubsystem) handleIssueList(ctx context.Context, options core.Options) core.Result { - return s.cmdIssueList(options) + return s.cmdIssueList(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.issue.create").Run(ctx, core.NewOptions( @@ -334,7 +311,7 @@ func (s *PrepSubsystem) handleIssueList(ctx context.Context, options core.Option // // )) func (s *PrepSubsystem) handleIssueCreate(ctx context.Context, options core.Options) core.Result { - return s.cmdIssueCreate(options) + return s.cmdIssueCreate(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.pr.get").Run(ctx, core.NewOptions( @@ -344,7 +321,7 @@ func (s *PrepSubsystem) handleIssueCreate(ctx context.Context, options core.Opti // // )) func (s *PrepSubsystem) handlePRGet(ctx context.Context, options core.Options) core.Result { - return s.cmdPRGet(options) + return s.cmdPRGet(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.pr.list").Run(ctx, core.NewOptions( @@ -353,7 +330,7 @@ func (s *PrepSubsystem) handlePRGet(ctx context.Context, options core.Options) c // // )) func (s *PrepSubsystem) handlePRList(ctx context.Context, options core.Options) core.Result { - return s.cmdPRList(options) + return s.cmdPRList(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.pr.merge").Run(ctx, core.NewOptions( @@ -363,7 +340,7 @@ func (s *PrepSubsystem) handlePRList(ctx context.Context, options core.Options) // // )) func (s *PrepSubsystem) handlePRMerge(ctx context.Context, options core.Options) core.Result { - return s.cmdPRMerge(options) + return s.cmdPRMerge(normaliseForgeActionOptions(options)) } // result := c.Action("agentic.review-queue").Run(ctx, core.NewOptions( @@ -372,11 +349,7 @@ func (s *PrepSubsystem) handlePRMerge(ctx context.Context, options core.Options) // // )) func (s *PrepSubsystem) handleReviewQueue(ctx context.Context, options core.Options) core.Result { - input := ReviewQueueInput{ - Limit: options.Int("limit"), - Reviewer: options.String("reviewer"), - DryRun: options.Bool("dry_run"), - } + input := reviewQueueInputFromOptions(options) _, out, err := s.reviewQueue(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -390,12 +363,7 @@ func (s *PrepSubsystem) handleReviewQueue(ctx context.Context, options core.Opti // // )) func (s *PrepSubsystem) handleEpic(ctx context.Context, options core.Options) core.Result { - input := EpicInput{ - Repo: options.String("repo"), - Org: options.String("org"), - Title: options.String("title"), - Body: options.String("body"), - } + input := epicInputFromOptions(options) _, out, err := s.createEpic(ctx, nil, input) if err != nil { return core.Result{Value: err, OK: false} @@ -403,6 +371,301 @@ func (s *PrepSubsystem) handleEpic(ctx context.Context, options core.Options) co return core.Result{Value: out, OK: true} } +func dispatchInputFromOptions(options core.Options) DispatchInput { + return DispatchInput{ + Repo: optionStringValue(options, "repo"), + Org: optionStringValue(options, "org"), + Task: optionStringValue(options, "task"), + Agent: optionStringValue(options, "agent"), + Template: optionStringValue(options, "template"), + PlanTemplate: optionStringValue(options, "plan_template", "plan-template"), + Variables: optionStringMapValue(options, "variables"), + Persona: optionStringValue(options, "persona"), + Issue: optionIntValue(options, "issue"), + PR: optionIntValue(options, "pr"), + Branch: optionStringValue(options, "branch"), + Tag: optionStringValue(options, "tag"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + } +} + +func prepInputFromOptions(options core.Options) PrepInput { + return PrepInput{ + Repo: optionStringValue(options, "repo"), + Org: optionStringValue(options, "org"), + Task: optionStringValue(options, "task"), + Agent: optionStringValue(options, "agent"), + Issue: optionIntValue(options, "issue"), + PR: optionIntValue(options, "pr"), + Branch: optionStringValue(options, "branch"), + Tag: optionStringValue(options, "tag"), + Template: optionStringValue(options, "template"), + PlanTemplate: optionStringValue(options, "plan_template", "plan-template"), + Variables: optionStringMapValue(options, "variables"), + Persona: optionStringValue(options, "persona"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + } +} + +func resumeInputFromOptions(options core.Options) ResumeInput { + return ResumeInput{ + Workspace: optionStringValue(options, "workspace"), + Answer: optionStringValue(options, "answer"), + Agent: optionStringValue(options, "agent"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + } +} + +func scanInputFromOptions(options core.Options) ScanInput { + return ScanInput{ + Org: optionStringValue(options, "org"), + Labels: optionStringSliceValue(options, "labels"), + Limit: optionIntValue(options, "limit"), + } +} + +func watchInputFromOptions(options core.Options) WatchInput { + workspaces := optionStringSliceValue(options, "workspaces") + if len(workspaces) == 0 { + if workspace := optionStringValue(options, "workspace"); workspace != "" { + workspaces = []string{workspace} + } + } + return WatchInput{ + Workspaces: workspaces, + PollInterval: optionIntValue(options, "poll_interval", "poll-interval"), + Timeout: optionIntValue(options, "timeout"), + } +} + +func mirrorInputFromOptions(options core.Options) MirrorInput { + return MirrorInput{ + Repo: optionStringValue(options, "repo"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + MaxFiles: optionIntValue(options, "max_files", "max-files"), + } +} + +func reviewQueueInputFromOptions(options core.Options) ReviewQueueInput { + return ReviewQueueInput{ + Limit: optionIntValue(options, "limit"), + Reviewer: optionStringValue(options, "reviewer"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + LocalOnly: optionBoolValue(options, "local_only", "local-only"), + } +} + +func epicInputFromOptions(options core.Options) EpicInput { + return EpicInput{ + Repo: optionStringValue(options, "repo"), + Org: optionStringValue(options, "org"), + Title: optionStringValue(options, "title"), + Body: optionStringValue(options, "body"), + Tasks: optionStringSliceValue(options, "tasks"), + Labels: optionStringSliceValue(options, "labels"), + Dispatch: optionBoolValue(options, "dispatch"), + Agent: optionStringValue(options, "agent"), + Template: optionStringValue(options, "template"), + } +} + +func normaliseForgeActionOptions(options core.Options) core.Options { + normalised := core.NewOptions(options.Items()...) + if normalised.String("_arg") == "" { + if repo := optionStringValue(options, "repo"); repo != "" { + normalised.Set("_arg", repo) + } + } + if number := optionStringValue(options, "number"); number != "" { + normalised.Set("number", number) + } + return normalised +} + +func optionStringValue(options core.Options, keys ...string) string { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + if value := stringValue(result.Value); value != "" { + return value + } + } + return "" +} + +func optionIntValue(options core.Options, keys ...string) int { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + switch value := result.Value.(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + case string: + parsed := parseInt(value) + if parsed != 0 || core.Trim(value) == "0" { + return parsed + } + return parseIntString(value) + } + } + return 0 +} + +func optionBoolValue(options core.Options, keys ...string) bool { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + switch value := result.Value.(type) { + case bool: + return value + case string: + switch core.Lower(core.Trim(value)) { + case "1", "true", "yes", "on": + return true + } + } + } + return false +} + +func optionStringSliceValue(options core.Options, keys ...string) []string { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + values := stringSliceValue(result.Value) + if len(values) > 0 { + return values + } + } + return nil +} + +func optionStringMapValue(options core.Options, keys ...string) map[string]string { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + values := stringMapValue(result.Value) + if len(values) > 0 { + return values + } + } + return nil +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case int: + return core.Sprint(typed) + case int64: + return core.Sprint(typed) + case float64: + return core.Sprint(int(typed)) + case bool: + return core.Sprint(typed) + } + return "" +} + +func stringSliceValue(value any) []string { + switch typed := value.(type) { + case []string: + return cleanStrings(typed) + case []any: + var values []string + for _, item := range typed { + if text := stringValue(item); text != "" { + values = append(values, text) + } + } + return cleanStrings(values) + case string: + trimmed := core.Trim(typed) + if trimmed == "" { + return nil + } + if core.HasPrefix(trimmed, "[") { + var values []string + if result := core.JSONUnmarshalString(trimmed, &values); result.OK { + return cleanStrings(values) + } + var generic []any + if result := core.JSONUnmarshalString(trimmed, &generic); result.OK { + return stringSliceValue(generic) + } + } + return cleanStrings(core.Split(trimmed, ",")) + default: + if text := stringValue(value); text != "" { + return []string{text} + } + } + return nil +} + +func stringMapValue(value any) map[string]string { + switch typed := value.(type) { + case map[string]string: + out := make(map[string]string, len(typed)) + for key, val := range typed { + if text := core.Trim(val); text != "" { + out[key] = text + } + } + return out + case map[string]any: + out := make(map[string]string, len(typed)) + for key, val := range typed { + if text := stringValue(val); text != "" { + out[key] = text + } + } + return out + case string: + trimmed := core.Trim(typed) + if trimmed == "" { + return nil + } + if core.HasPrefix(trimmed, "{") { + var values map[string]string + if result := core.JSONUnmarshalString(trimmed, &values); result.OK { + return stringMapValue(values) + } + var generic map[string]any + if result := core.JSONUnmarshalString(trimmed, &generic); result.OK { + return stringMapValue(generic) + } + } + } + return nil +} + +func cleanStrings(values []string) []string { + var cleaned []string + for _, value := range values { + trimmed := core.Trim(value) + if trimmed != "" { + cleaned = append(cleaned, trimmed) + } + } + return cleaned +} + // result := c.QUERY(agentic.WorkspaceQuery{Name: "core/go-io/task-42"}) // result := c.QUERY(agentic.WorkspaceQuery{Status: "blocked"}) func (s *PrepSubsystem) handleWorkspaceQuery(_ *core.Core, query core.Query) core.Result { diff --git a/pkg/agentic/actions_test.go b/pkg/agentic/actions_test.go index b4743bc..d47774a 100644 --- a/pkg/agentic/actions_test.go +++ b/pkg/agentic/actions_test.go @@ -135,3 +135,135 @@ func TestActions_HandleWorkspaceQuery_Bad(t *testing.T) { assert.False(t, r.OK) assert.Nil(t, r.Value) } + +func TestActions_DispatchInputFromOptions_Good_MapsRFCFields(t *testing.T) { + input := dispatchInputFromOptions(core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "task", Value: "Fix the failing tests"}, + core.Option{Key: "agent", Value: "codex:gpt-5.4"}, + core.Option{Key: "template", Value: "coding"}, + core.Option{Key: "plan_template", Value: "bug-fix"}, + core.Option{Key: "variables", Value: map[string]any{"ISSUE": 42, "MODE": "deep"}}, + core.Option{Key: "persona", Value: "code/reviewer"}, + core.Option{Key: "issue", Value: "42"}, + core.Option{Key: "pr", Value: 7}, + core.Option{Key: "branch", Value: "agent/fix-tests"}, + core.Option{Key: "tag", Value: "v0.8.0"}, + core.Option{Key: "dry-run", Value: "true"}, + )) + + assert.Equal(t, "go-io", input.Repo) + assert.Equal(t, "core", input.Org) + assert.Equal(t, "Fix the failing tests", input.Task) + assert.Equal(t, "codex:gpt-5.4", input.Agent) + assert.Equal(t, "coding", input.Template) + assert.Equal(t, "bug-fix", input.PlanTemplate) + assert.Equal(t, map[string]string{"ISSUE": "42", "MODE": "deep"}, input.Variables) + assert.Equal(t, "code/reviewer", input.Persona) + assert.Equal(t, 42, input.Issue) + assert.Equal(t, 7, input.PR) + assert.Equal(t, "agent/fix-tests", input.Branch) + assert.Equal(t, "v0.8.0", input.Tag) + assert.True(t, input.DryRun) +} + +func TestActions_PrepInputFromOptions_Good_MapsRFCFields(t *testing.T) { + input := prepInputFromOptions(core.NewOptions( + core.Option{Key: "repo", Value: "go-scm"}, + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "task", Value: "Prepare release branch"}, + core.Option{Key: "agent", Value: "claude"}, + core.Option{Key: "issue", Value: 12}, + core.Option{Key: "branch", Value: "dev"}, + core.Option{Key: "template", Value: "security"}, + core.Option{Key: "plan-template", Value: "release"}, + core.Option{Key: "variables", Value: "{\"REPO\":\"go-scm\",\"MODE\":\"resume\"}"}, + core.Option{Key: "persona", Value: "code/security"}, + core.Option{Key: "dry_run", Value: true}, + )) + + assert.Equal(t, "go-scm", input.Repo) + assert.Equal(t, "core", input.Org) + assert.Equal(t, "Prepare release branch", input.Task) + assert.Equal(t, "claude", input.Agent) + assert.Equal(t, 12, input.Issue) + assert.Equal(t, "dev", input.Branch) + assert.Equal(t, "security", input.Template) + assert.Equal(t, "release", input.PlanTemplate) + assert.Equal(t, map[string]string{"REPO": "go-scm", "MODE": "resume"}, input.Variables) + assert.Equal(t, "code/security", input.Persona) + assert.True(t, input.DryRun) +} + +func TestActions_WatchInputFromOptions_Good_ParsesWorkspaceList(t *testing.T) { + input := watchInputFromOptions(core.NewOptions( + core.Option{Key: "workspaces", Value: []any{"core/go-io/task-5", " core/go-scm/task-6 "}}, + core.Option{Key: "poll-interval", Value: "15"}, + core.Option{Key: "timeout", Value: "900"}, + )) + + assert.Equal(t, []string{"core/go-io/task-5", "core/go-scm/task-6"}, input.Workspaces) + assert.Equal(t, 15, input.PollInterval) + assert.Equal(t, 900, input.Timeout) +} + +func TestActions_ReviewQueueInputFromOptions_Good_MapsLocalOnly(t *testing.T) { + input := reviewQueueInputFromOptions(core.NewOptions( + core.Option{Key: "limit", Value: "4"}, + core.Option{Key: "reviewer", Value: "both"}, + core.Option{Key: "dry_run", Value: true}, + core.Option{Key: "local_only", Value: "yes"}, + )) + + assert.Equal(t, 4, input.Limit) + assert.Equal(t, "both", input.Reviewer) + assert.True(t, input.DryRun) + assert.True(t, input.LocalOnly) +} + +func TestActions_EpicInputFromOptions_Good_ParsesListFields(t *testing.T) { + input := epicInputFromOptions(core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "title", Value: "AX RFC follow-up"}, + core.Option{Key: "body", Value: "Finish the remaining wrappers"}, + core.Option{Key: "tasks", Value: "[\"Map action inputs\",\"Add tests\"]"}, + core.Option{Key: "labels", Value: "agentic, ax"}, + core.Option{Key: "dispatch", Value: "true"}, + core.Option{Key: "agent", Value: "codex"}, + core.Option{Key: "template", Value: "coding"}, + )) + + assert.Equal(t, "go-io", input.Repo) + assert.Equal(t, "core", input.Org) + assert.Equal(t, "AX RFC follow-up", input.Title) + assert.Equal(t, "Finish the remaining wrappers", input.Body) + assert.Equal(t, []string{"Map action inputs", "Add tests"}, input.Tasks) + assert.Equal(t, []string{"agentic", "ax"}, input.Labels) + assert.True(t, input.Dispatch) + assert.Equal(t, "codex", input.Agent) + assert.Equal(t, "coding", input.Template) +} + +func TestActions_NormaliseForgeActionOptions_Good_MapsRepoAndNumber(t *testing.T) { + options := normaliseForgeActionOptions(core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "number", Value: 12}, + core.Option{Key: "title", Value: "Fix watcher"}, + )) + + assert.Equal(t, "go-io", options.String("_arg")) + assert.Equal(t, "12", options.String("number")) + assert.Equal(t, "Fix watcher", options.String("title")) +} + +func TestActions_OptionHelpers_Ugly_IgnoreMalformedMapJSON(t *testing.T) { + input := dispatchInputFromOptions(core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "task", Value: "Review"}, + core.Option{Key: "variables", Value: "{\"BROKEN\""}, + )) + + assert.Nil(t, input.Variables) +}