Replace separate go-io (coreio) and go-log (coreerr) packages with
Core's built-in Fs and error/logging functions. This is the reference
implementation for how all Core ecosystem packages should migrate.
Changes:
- coreio.Local.Read/Write/EnsureDir/Delete/IsFile → core.Fs methods
- coreerr.E() → core.E(), coreerr.Info/Warn/Error → core.Info/Warn/Error
- (value, error) return pattern → core.Result pattern (r.OK, r.Value)
- go-io and go-log moved from direct to indirect deps in go.mod
- Added AX usage-example comments on key public types
- Added newFs("/") helper for unrestricted filesystem access
Co-Authored-By: Virgil <virgil@lethean.io>
200 lines
4.9 KiB
Go
200 lines
4.9 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
core "dappco.re/go/core"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// ScanInput is the input for agentic_scan.
|
|
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.
|
|
type ScanOutput struct {
|
|
Success bool `json:"success"`
|
|
Count int `json:"count"`
|
|
Issues []ScanIssue `json:"issues"`
|
|
}
|
|
|
|
// ScanIssue is a single actionable issue.
|
|
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 := fmt.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 := fmt.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", fmt.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 := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&limit=10&type=issues",
|
|
s.forgeURL, org, repo)
|
|
if label != "" {
|
|
u += "&labels=" + strings.ReplaceAll(strings.ReplaceAll(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", fmt.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
|
|
}
|