From 0d05ccac55fa99d8e046187d22659ddb7073faad Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 11:44:26 +0000 Subject: [PATCH] feat(agentic): add scan CLI command Co-Authored-By: Virgil --- pkg/agentic/commands.go | 31 +++++++++++++++++ pkg/agentic/commands_test.go | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index 470153b..4d263a6 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -21,6 +21,7 @@ func (s *PrepSubsystem) registerCommands(ctx context.Context) { c.Command("run/orchestrator", core.Command{Description: "Run the queue orchestrator (standalone, no MCP)", Action: s.cmdOrchestrator}) c.Command("prep", core.Command{Description: "Prepare a workspace: clone repo, build prompt", Action: s.cmdPrep}) c.Command("generate", core.Command{Description: "Generate content from a prompt using the platform content pipeline", Action: s.cmdGenerate}) + c.Command("scan", core.Command{Description: "Scan Forge repos for actionable issues", Action: s.cmdScan}) c.Command("brain/ingest", core.Command{Description: "Bulk ingest memories into OpenBrain", Action: s.cmdBrainIngest}) c.Command("brain/seed-memory", core.Command{Description: "Import markdown memories into OpenBrain from a project memory directory", Action: s.cmdBrainSeedMemory}) c.Command("plan-cleanup", core.Command{Description: "Permanently delete archived plans past the retention period", Action: s.cmdPlanCleanup}) @@ -205,6 +206,36 @@ func (s *PrepSubsystem) cmdGenerate(options core.Options) core.Result { return core.Result{OK: true} } +func (s *PrepSubsystem) cmdScan(options core.Options) core.Result { + result := s.handleScan(s.commandContext(), core.NewOptions( + core.Option{Key: "org", Value: optionStringValue(options, "org")}, + core.Option{Key: "labels", Value: optionStringSliceValue(options, "labels")}, + core.Option{Key: "limit", Value: optionIntValue(options, "limit")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdScan", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(ScanOutput) + if !ok { + err := core.E("agentic.cmdScan", "invalid scan output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "count: %d", output.Count) + for _, issue := range output.Issues { + if len(issue.Labels) > 0 { + core.Print(nil, " %s#%d %s [%s]", issue.Repo, issue.Number, issue.Title, core.Join(",", issue.Labels...)) + continue + } + core.Print(nil, " %s#%d %s", issue.Repo, issue.Number, issue.Title) + } + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) cmdStatus(_ core.Options) core.Result { workspaceRoot := WorkspaceRoot() filesystem := s.Core().Fs() diff --git a/pkg/agentic/commands_test.go b/pkg/agentic/commands_test.go index fb72999..f8ff353 100644 --- a/pkg/agentic/commands_test.go +++ b/pkg/agentic/commands_test.go @@ -754,6 +754,71 @@ func TestCommands_CmdGenerate_Good_BriefTemplate(t *testing.T) { assert.Contains(t, output, "content: Template draft") } +func TestCommands_CmdScan_Good(t *testing.T) { + server := mockScanServer(t) + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge(server.URL, "secret-token"), + forgeURL: server.URL, + forgeToken: "secret-token", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + + output := captureStdout(t, func() { + r := s.cmdScan(core.NewOptions( + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "labels", Value: "agentic,bug"}, + core.Option{Key: "limit", Value: 5}, + )) + assert.True(t, r.OK) + }) + + assert.Contains(t, output, "count:") + assert.Contains(t, output, "go-io#10") + assert.Contains(t, output, "Add missing tests") +} + +func TestCommands_CmdScan_Bad_NoForgeToken(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + s.forgeToken = "" + + r := s.cmdScan(core.NewOptions()) + assert.False(t, r.OK) +} + +func TestCommands_CmdScan_Ugly_EmptyResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/orgs/core/repos": + _, _ = w.Write([]byte(core.JSONMarshalString([]map[string]any{ + {"name": "go-io"}, + }))) + default: + _, _ = w.Write([]byte(core.JSONMarshalString([]map[string]any{}))) + } + })) + defer server.Close() + + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge(server.URL, "secret-token"), + forgeURL: server.URL, + forgeToken: "secret-token", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + output := captureStdout(t, func() { + r := s.cmdScan(core.NewOptions( + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "limit", Value: 1}, + )) + assert.True(t, r.OK) + }) + + assert.Contains(t, output, "count: 0") +} + func TestCommands_CmdPlanCreate_Good(t *testing.T) { s, _ := testPrepWithCore(t, nil) @@ -867,6 +932,7 @@ func TestCommands_RegisterCommands_Good_AllRegistered(t *testing.T) { assert.Contains(t, cmds, "run/task") assert.Contains(t, cmds, "run/orchestrator") assert.Contains(t, cmds, "prep") + assert.Contains(t, cmds, "scan") assert.Contains(t, cmds, "status") assert.Contains(t, cmds, "prompt") assert.Contains(t, cmds, "extract")