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:
Snider 2026-03-16 06:27:53 +00:00
parent f8bee4b4ad
commit a9b7326c19
3 changed files with 127 additions and 1 deletions

View file

@ -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
View 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()
}

View file

@ -240,6 +240,9 @@ func (s *PrepSubsystem) drainQueue() {
writeStatus(wsDir, st2)
}
// Ingest scan findings as issues
s.ingestFindings(wsDir)
s.drainQueue()
}()