feat(agentic): auto-ingest scan findings as issues
When an agent completes, if the output contains file:line references (indicating real findings), automatically creates an issue via the lthn.sh API. Scans → issues → sprints → PRs → release. The loop closes. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
f8bee4b4ad
commit
a9b7326c19
3 changed files with 127 additions and 1 deletions
|
|
@ -59,7 +59,7 @@ func agentCommand(agent, prompt string) (string, []string, error) {
|
|||
|
||||
switch base {
|
||||
case "gemini":
|
||||
args := []string{"-p", prompt, "--yolo"}
|
||||
args := []string{"-p", prompt, "--yolo", "--sandbox"}
|
||||
if model != "" {
|
||||
args = append(args, "-m", "gemini-2.5-"+model)
|
||||
}
|
||||
|
|
@ -209,6 +209,9 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
writeStatus(wsDir, st)
|
||||
}
|
||||
|
||||
// Ingest scan findings as issues
|
||||
s.ingestFindings(wsDir)
|
||||
|
||||
// Drain queue: pop next queued workspace and spawn it
|
||||
s.drainQueue()
|
||||
}()
|
||||
|
|
|
|||
120
pkg/mcp/agentic/ingest.go
Normal file
120
pkg/mcp/agentic/ingest.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ingestFindings reads the agent output log and creates issues via the API
|
||||
// for scan/audit results. Only runs for conventions and security templates.
|
||||
func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
||||
st, err := readStatus(wsDir)
|
||||
if err != nil || st.Status != "completed" {
|
||||
return
|
||||
}
|
||||
|
||||
// Read the log file
|
||||
logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
|
||||
if len(logFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(logFiles[0])
|
||||
if err != nil || len(content) < 100 {
|
||||
return
|
||||
}
|
||||
|
||||
body := string(content)
|
||||
|
||||
// Skip quota errors
|
||||
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ingest if there are actual findings (file:line references)
|
||||
findings := countFileRefs(body)
|
||||
if findings < 2 {
|
||||
return // No meaningful findings
|
||||
}
|
||||
|
||||
// Determine issue type from the template used
|
||||
issueType := "task"
|
||||
priority := "normal"
|
||||
if strings.Contains(body, "security") || strings.Contains(body, "Security") {
|
||||
issueType = "bug"
|
||||
priority = "high"
|
||||
}
|
||||
|
||||
// Create a single issue per repo with all findings in the body
|
||||
title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings)
|
||||
|
||||
// Truncate body to reasonable size for issue description
|
||||
description := body
|
||||
if len(description) > 10000 {
|
||||
description = description[:10000] + "\n\n... (truncated, see full log in workspace)"
|
||||
}
|
||||
|
||||
s.createIssueViaAPI(st.Repo, title, description, issueType, priority, "scan")
|
||||
}
|
||||
|
||||
// countFileRefs counts file:line references in the output (indicates real findings)
|
||||
func countFileRefs(body string) int {
|
||||
count := 0
|
||||
for i := 0; i < len(body)-5; i++ {
|
||||
if body[i] == '`' {
|
||||
// Look for pattern: `file.go:123`
|
||||
j := i + 1
|
||||
for j < len(body) && body[j] != '`' && j-i < 100 {
|
||||
j++
|
||||
}
|
||||
if j < len(body) && body[j] == '`' {
|
||||
ref := body[i+1 : j]
|
||||
if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// createIssueViaAPI posts an issue to the lthn.sh API
|
||||
func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, priority, source string) {
|
||||
if s.brainKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Read the agent API key from file
|
||||
home, _ := os.UserHomeDir()
|
||||
apiKeyData, err := os.ReadFile(filepath.Join(home, ".claude", "agent-api.key"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(string(apiKeyData))
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"description": description,
|
||||
"type": issueType,
|
||||
"priority": priority,
|
||||
"reporter": "cladius",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
|
@ -240,6 +240,9 @@ func (s *PrepSubsystem) drainQueue() {
|
|||
writeStatus(wsDir, st2)
|
||||
}
|
||||
|
||||
// Ingest scan findings as issues
|
||||
s.ingestFindings(wsDir)
|
||||
|
||||
s.drainQueue()
|
||||
}()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue