agent/pkg/agentic/scan.go
Snider a0dc9c32e7 refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package

All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli

Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00

205 lines
5.2 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"encoding/json"
"net/http"
"strings"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ScanInput is the input for agentic_scan.
//
// input := agentic.ScanInput{Org: "core", Labels: []string{"agentic", "bug"}, Limit: 20}
type ScanInput struct {
Org string `json:"org,omitempty"` // default "core"
Labels []string `json:"labels,omitempty"` // filter by labels (default: agentic, help-wanted, bug)
Limit int `json:"limit,omitempty"` // max issues to return
}
// ScanOutput is the output for agentic_scan.
//
// out := agentic.ScanOutput{Success: true, Count: 1, Issues: []agentic.ScanIssue{{Repo: "go-io", Number: 12}}}
type ScanOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Issues []ScanIssue `json:"issues"`
}
// ScanIssue is a single actionable issue.
//
// issue := agentic.ScanIssue{Repo: "go-io", Number: 12, Title: "Replace fmt.Errorf"}
type ScanIssue struct {
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
Labels []string `json:"labels"`
Assignee string `json:"assignee,omitempty"`
URL string `json:"url"`
}
func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input ScanInput) (*mcp.CallToolResult, ScanOutput, error) {
if s.forgeToken == "" {
return nil, ScanOutput{}, core.E("scan", "no Forge token configured", nil)
}
if input.Org == "" {
input.Org = "core"
}
if input.Limit == 0 {
input.Limit = 20
}
if len(input.Labels) == 0 {
input.Labels = []string{"agentic", "help-wanted", "bug"}
}
var allIssues []ScanIssue
// Get repos for the org
repos, err := s.listOrgRepos(ctx, input.Org)
if err != nil {
return nil, ScanOutput{}, err
}
for _, repo := range repos {
for _, label := range input.Labels {
issues, err := s.listRepoIssues(ctx, input.Org, repo, label)
if err != nil {
continue
}
allIssues = append(allIssues, issues...)
if len(allIssues) >= input.Limit {
break
}
}
if len(allIssues) >= input.Limit {
break
}
}
// Deduplicate by repo+number
seen := make(map[string]bool)
var unique []ScanIssue
for _, issue := range allIssues {
key := core.Sprintf("%s#%d", issue.Repo, issue.Number)
if !seen[key] {
seen[key] = true
unique = append(unique, issue)
}
}
if len(unique) > input.Limit {
unique = unique[:input.Limit]
}
return nil, ScanOutput{
Success: true,
Count: len(unique),
Issues: unique,
}, nil
}
func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) {
var allNames []string
page := 1
for {
u := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50&page=%d", s.forgeURL, org, page)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, core.E("scan.listOrgRepos", "failed to create request", err)
}
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil {
return nil, core.E("scan.listOrgRepos", "failed to list repos", err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, core.E("scan.listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
}
var repos []struct {
Name string `json:"name"`
}
json.NewDecoder(resp.Body).Decode(&repos)
resp.Body.Close()
for _, r := range repos {
allNames = append(allNames, r.Name)
}
// If we got fewer than the limit, we've reached the last page
if len(repos) < 50 {
break
}
page++
}
return allNames, nil
}
func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) {
u := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&limit=10&type=issues",
s.forgeURL, org, repo)
if label != "" {
u += "&labels=" + core.Replace(core.Replace(label, " ", "%20"), "&", "%26")
}
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, core.E("scan.listRepoIssues", "failed to create request", err)
}
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil {
return nil, core.E("scan.listRepoIssues", "failed to list issues for "+repo, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, core.E("scan.listRepoIssues", core.Sprintf("HTTP %d listing issues for %s", resp.StatusCode, repo), nil)
}
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Assignee *struct {
Login string `json:"login"`
} `json:"assignee"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(resp.Body).Decode(&issues)
var result []ScanIssue
for _, issue := range issues {
var labels []string
for _, l := range issue.Labels {
labels = append(labels, l.Name)
}
assignee := ""
if issue.Assignee != nil {
assignee = issue.Assignee.Login
}
result = append(result, ScanIssue{
Repo: repo,
Number: issue.Number,
Title: issue.Title,
Labels: labels,
Assignee: assignee,
URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1),
})
}
return result, nil
}