refactor(ax): round 2 AX sweep — usage examples, predictable names, dead code
- ai/ai.go: replace architectural prose with concrete usage examples (AX-2) - ai/metrics.go: add usage examples to all exported/unexported functions; rename metricsWriteMutex (already was changed in round 1); drop redundant prose comment on the day-iteration loop - ai/metrics_bench_test.go: remove dead `dir` allocation and `_ = dir` suppression; rename `n` param → `count` in seedEvents; add usage examples to helpers (AX-1/AX-2) - ai/rag.go: rename qdrantCfg/ollamaCfg/queryCfg → qdrantConfig/ollamaConfig/queryConfig (AX-1) - cmd/metrics/cmd.go: rename local `n` → `count` in parseDuration (AX-1) - cmd/security/cmd_security.go: add usage example to checkGH and AlertSummary.Add (AX-2) - cmd/security/cmd_alerts.go: add usage-example comments to fetch* functions (AX-2) - cmd/security/cmd_jobs.go: rename `sb` → `builder` in buildJobIssueBody; add usage examples to createJobForTarget and buildJobIssueBody (AX-1/AX-2) - cmd/security/cmd_scan.go: remove redundant "Default if not specified" inline comment (AX-2) Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
51d117de0b
commit
38bee76d96
9 changed files with 169 additions and 111 deletions
17
ai/ai.go
17
ai/ai.go
|
|
@ -1,11 +1,12 @@
|
|||
// Package ai provides the unified AI package for the core CLI.
|
||||
// Package ai is the unified AI entry point for the Core CLI.
|
||||
//
|
||||
// It composes functionality from pkg/rag (vector search) and pkg/agentic
|
||||
// (task management) into a single public API surface. New AI features
|
||||
// should be added here; existing packages remain importable but pkg/ai
|
||||
// is the canonical entry point.
|
||||
// Record and query metrics:
|
||||
//
|
||||
// Sub-packages composed:
|
||||
// - pkg/rag: Qdrant vector database + Ollama embeddings
|
||||
// - pkg/agentic: Task queue client and context building
|
||||
// ai.Record(ai.Event{Type: "security.scan", Repo: "core-php"})
|
||||
// events, _ := ai.ReadEvents(time.Now().Add(-7 * 24 * time.Hour))
|
||||
// summary := ai.Summary(events)
|
||||
//
|
||||
// Query RAG for task context:
|
||||
//
|
||||
// ctx, _ := ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth bug", Description: "JWT expiry not checked"})
|
||||
package ai
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import (
|
|||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// metricsMu protects concurrent file writes in Record.
|
||||
var metricsMu sync.Mutex
|
||||
var metricsWriteMutex sync.Mutex
|
||||
|
||||
// Event represents a recorded AI/security metric event.
|
||||
// Event is a recorded AI or security metric entry.
|
||||
//
|
||||
// ai.Record(ai.Event{Type: "security.scan", Repo: "core-php", AgentID: "codex-1"})
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
|
@ -27,7 +28,9 @@ type Event struct {
|
|||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// metricsDir returns the base directory for metrics storage.
|
||||
// metricsDir returns the storage root for daily metric files.
|
||||
//
|
||||
// dir, _ := metricsDir() // → "/home/user/.core/ai/metrics"
|
||||
func metricsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
|
@ -36,20 +39,23 @@ func metricsDir() (string, error) {
|
|||
return filepath.Join(home, ".core", "ai", "metrics"), nil
|
||||
}
|
||||
|
||||
// metricsFilePath returns the JSONL file path for the given date.
|
||||
// metricsFilePath returns the JSONL path for a given date.
|
||||
//
|
||||
// ai.metricsFilePath("/home/user/.core/ai/metrics", time.Now()) // → "/home/user/.core/ai/metrics/2026-03-31.jsonl"
|
||||
func metricsFilePath(dir string, t time.Time) string {
|
||||
return filepath.Join(dir, t.Format("2006-01-02")+".jsonl")
|
||||
}
|
||||
|
||||
// Record appends an event to the daily JSONL file at
|
||||
// ~/.core/ai/metrics/YYYY-MM-DD.jsonl.
|
||||
// Record appends an event to the daily JSONL file at ~/.core/ai/metrics/YYYY-MM-DD.jsonl.
|
||||
//
|
||||
// ai.Record(ai.Event{Type: "security.scan", Repo: "go-ai", AgentID: "codex-1"})
|
||||
func Record(event Event) (err error) {
|
||||
if event.Timestamp.IsZero() {
|
||||
event.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
metricsMu.Lock()
|
||||
defer metricsMu.Unlock()
|
||||
metricsWriteMutex.Lock()
|
||||
defer metricsWriteMutex.Unlock()
|
||||
|
||||
dir, err := metricsDir()
|
||||
if err != nil {
|
||||
|
|
@ -62,12 +68,12 @@ func Record(event Event) (err error) {
|
|||
|
||||
path := metricsFilePath(dir, event.Timestamp)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
w, err := coreio.Local.Append(path)
|
||||
if err != nil {
|
||||
return coreerr.E("ai.Record", "open metrics file", err)
|
||||
return coreerr.E("ai.Record", "open metrics file for append", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
if cerr := w.Close(); cerr != nil && err == nil {
|
||||
err = coreerr.E("ai.Record", "close metrics file", cerr)
|
||||
}
|
||||
}()
|
||||
|
|
@ -77,14 +83,16 @@ func Record(event Event) (err error) {
|
|||
return coreerr.E("ai.Record", "marshal event", err)
|
||||
}
|
||||
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||
return coreerr.E("ai.Record", "write event", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadEvents reads events from JSONL files within the given time range.
|
||||
// ReadEvents reads all events recorded on or after since.
|
||||
//
|
||||
// events, _ := ai.ReadEvents(time.Now().Add(-7 * 24 * time.Hour))
|
||||
func ReadEvents(since time.Time) ([]Event, error) {
|
||||
dir, err := metricsDir()
|
||||
if err != nil {
|
||||
|
|
@ -94,7 +102,6 @@ func ReadEvents(since time.Time) ([]Event, error) {
|
|||
var events []Event
|
||||
now := time.Now()
|
||||
|
||||
// Iterate each day from since to now.
|
||||
for d := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.Local); !d.After(now); d = d.AddDate(0, 0, 1) {
|
||||
path := metricsFilePath(dir, d)
|
||||
|
||||
|
|
@ -108,23 +115,26 @@ func ReadEvents(since time.Time) ([]Event, error) {
|
|||
return events, nil
|
||||
}
|
||||
|
||||
// readMetricsFile reads events from a single JSONL file, returning only those at or after since.
|
||||
// readMetricsFile reads events from a single JSONL file, skipping lines before since.
|
||||
//
|
||||
// events, _ := readMetricsFile("/home/user/.core/ai/metrics/2026-03-31.jsonl", time.Now().Add(-24*time.Hour))
|
||||
func readMetricsFile(path string, since time.Time) ([]Event, error) {
|
||||
f, err := os.Open(path)
|
||||
if !coreio.Local.Exists(path) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
r, err := coreio.Local.ReadStream(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, coreerr.E("ai.readMetricsFile", "open metrics file", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
var events []Event
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
var ev Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil {
|
||||
continue // skip malformed lines
|
||||
continue
|
||||
}
|
||||
if !ev.Timestamp.Before(since) {
|
||||
events = append(events, ev)
|
||||
|
|
@ -137,6 +147,10 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) {
|
|||
}
|
||||
|
||||
// Summary aggregates events into counts by type, repo, and agent.
|
||||
//
|
||||
// summary := ai.Summary(events)
|
||||
// summary["total"] // int — total event count
|
||||
// summary["by_type"] // []map[string]any — sorted by count descending
|
||||
func Summary(events []Event) map[string]any {
|
||||
byType := make(map[string]int)
|
||||
byRepo := make(map[string]int)
|
||||
|
|
@ -160,7 +174,9 @@ func Summary(events []Event) map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
// sortedMap returns a slice of key-count pairs sorted by count descending.
|
||||
// sortedMap converts a string→count map to a slice sorted by count descending.
|
||||
//
|
||||
// sortedMap(map[string]int{"build": 5, "test": 2}) // → [{key: "build", count: 5}, ...]
|
||||
func sortedMap(m map[string]int) []map[string]any {
|
||||
type entry struct {
|
||||
key string
|
||||
|
|
|
|||
|
|
@ -12,15 +12,13 @@ import (
|
|||
|
||||
// --- Helpers ---
|
||||
|
||||
// setupBenchMetricsDir overrides the metrics directory to a temp dir for benchmarks.
|
||||
// Returns a cleanup function to restore the original.
|
||||
// setupBenchMetricsDir overrides HOME to a temp dir and returns the metrics path.
|
||||
//
|
||||
// metricsPath := setupBenchMetricsDir(b) // → "/tmp/.../home/.core/ai/metrics"
|
||||
func setupBenchMetricsDir(b *testing.B) string {
|
||||
b.Helper()
|
||||
dir := b.TempDir()
|
||||
// Override HOME so metricsDir() resolves to our temp dir
|
||||
origHome := os.Getenv("HOME")
|
||||
tmpHome := b.TempDir()
|
||||
// Create the metrics path under the fake HOME
|
||||
metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics")
|
||||
if err := coreio.Local.EnsureDir(metricsPath); err != nil {
|
||||
b.Fatalf("Failed to create metrics dir: %v", err)
|
||||
|
|
@ -29,15 +27,16 @@ func setupBenchMetricsDir(b *testing.B) string {
|
|||
b.Cleanup(func() {
|
||||
os.Setenv("HOME", origHome)
|
||||
})
|
||||
_ = dir
|
||||
return metricsPath
|
||||
}
|
||||
|
||||
// seedEvents writes n events to the metrics directory for the current day.
|
||||
func seedEvents(b *testing.B, n int) {
|
||||
// seedEvents writes count events to the metrics directory for the current day.
|
||||
//
|
||||
// seedEvents(b, 10_000) // writes 10K events spread across the last 10 seconds
|
||||
func seedEvents(b *testing.B, count int) {
|
||||
b.Helper()
|
||||
now := time.Now()
|
||||
for i := range n {
|
||||
for i := range count {
|
||||
ev := Event{
|
||||
Type: fmt.Sprintf("type-%d", i%10),
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Millisecond),
|
||||
|
|
|
|||
24
ai/rag.go
24
ai/rag.go
|
|
@ -8,34 +8,34 @@ import (
|
|||
"forge.lthn.ai/core/go-rag"
|
||||
)
|
||||
|
||||
// TaskInfo carries the minimal task data needed for RAG queries,
|
||||
// avoiding a direct dependency on pkg/agentic (which imports pkg/ai).
|
||||
// TaskInfo carries the task data used to build a RAG query.
|
||||
//
|
||||
// ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth", Description: "JWT expiry not checked"})
|
||||
type TaskInfo struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
// QueryRAGForTask queries Qdrant for documentation relevant to a task.
|
||||
// It builds a query from the task title and description, queries with
|
||||
// sensible defaults, and returns formatted context.
|
||||
// QueryRAGForTask returns formatted documentation context for a task from Qdrant.
|
||||
//
|
||||
// ctx, _ := ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth bug", Description: "JWT expiry not checked"})
|
||||
func QueryRAGForTask(task TaskInfo) (string, error) {
|
||||
query := task.Title + " " + task.Description
|
||||
|
||||
// Truncate to 500 runes to keep the embedding focused.
|
||||
runes := []rune(query)
|
||||
if len(runes) > 500 {
|
||||
query = string(runes[:500])
|
||||
}
|
||||
|
||||
qdrantCfg := rag.DefaultQdrantConfig()
|
||||
qdrantClient, err := rag.NewQdrantClient(qdrantCfg)
|
||||
qdrantConfig := rag.DefaultQdrantConfig()
|
||||
qdrantClient, err := rag.NewQdrantClient(qdrantConfig)
|
||||
if err != nil {
|
||||
return "", coreerr.E("ai.QueryRAGForTask", "rag qdrant client", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
ollamaCfg := rag.DefaultOllamaConfig()
|
||||
ollamaClient, err := rag.NewOllamaClient(ollamaCfg)
|
||||
ollamaConfig := rag.DefaultOllamaConfig()
|
||||
ollamaClient, err := rag.NewOllamaClient(ollamaConfig)
|
||||
if err != nil {
|
||||
return "", coreerr.E("ai.QueryRAGForTask", "rag ollama client", err)
|
||||
}
|
||||
|
|
@ -43,13 +43,13 @@ func QueryRAGForTask(task TaskInfo) (string, error) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
queryCfg := rag.QueryConfig{
|
||||
queryConfig := rag.QueryConfig{
|
||||
Collection: "hostuk-docs",
|
||||
Limit: 3,
|
||||
Threshold: 0.5,
|
||||
}
|
||||
|
||||
results, err := rag.Query(ctx, qdrantClient, ollamaClient, query, queryCfg)
|
||||
results, err := rag.Query(ctx, qdrantClient, ollamaClient, query, queryConfig)
|
||||
if err != nil {
|
||||
return "", coreerr.E("ai.QueryRAGForTask", "rag query", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ func initMetricsFlags() {
|
|||
metricsCmd.Flags().BoolVar(&metricsJSON, "json", false, i18n.T("common.flag.json"))
|
||||
}
|
||||
|
||||
// AddMetricsCommand adds the 'metrics' command to the parent.
|
||||
// AddMetricsCommand registers the metrics subcommand.
|
||||
//
|
||||
// metrics.AddMetricsCommand(rootCmd) // → core ai metrics --since 7d
|
||||
func AddMetricsCommand(parent *cli.Command) {
|
||||
initMetricsFlags()
|
||||
parent.AddCommand(metricsCmd)
|
||||
|
|
@ -101,7 +103,11 @@ func runMetrics() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// parseDuration parses a human-friendly duration like "7d", "24h", "30d".
|
||||
// parseDuration parses a human-friendly duration string.
|
||||
//
|
||||
// parseDuration("7d") // → 7 * 24 * time.Hour
|
||||
// parseDuration("24h") // → 24 * time.Hour
|
||||
// parseDuration("30m") // → 30 * time.Minute
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if len(s) < 2 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil)
|
||||
|
|
@ -110,22 +116,22 @@ func parseDuration(s string) (time.Duration, error) {
|
|||
unit := s[len(s)-1]
|
||||
value := s[:len(s)-1]
|
||||
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
|
||||
var count int
|
||||
if _, err := fmt.Sscanf(value, "%d", &count); err != nil {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil)
|
||||
}
|
||||
|
||||
if n <= 0 {
|
||||
if count <= 0 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", s), nil)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'd':
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
return time.Duration(count) * 24 * time.Hour, nil
|
||||
case 'h':
|
||||
return time.Duration(n) * time.Hour, nil
|
||||
return time.Duration(count) * time.Hour, nil
|
||||
case 'm':
|
||||
return time.Duration(n) * time.Minute, nil
|
||||
return time.Duration(count) * time.Minute, nil
|
||||
default:
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unit, s), nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func addAlertsCommand(parent *cli.Command) {
|
|||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// AlertOutput represents a unified alert for output.
|
||||
// AlertOutput is the unified alert shape written to JSON or the terminal.
|
||||
type AlertOutput struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
|
|
@ -44,7 +44,6 @@ func runAlerts() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runAlertsForTarget(securityTarget)
|
||||
}
|
||||
|
|
@ -65,7 +64,6 @@ func runAlerts() error {
|
|||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
|
||||
// Fetch Dependabot alerts
|
||||
depAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
|
|
@ -89,7 +87,6 @@ func runAlerts() error {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch code scanning alerts
|
||||
codeAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
|
|
@ -113,7 +110,6 @@ func runAlerts() error {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch secret scanning alerts
|
||||
secretAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
|
|
@ -123,7 +119,7 @@ func runAlerts() error {
|
|||
if !filterBySeverity("high", securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add("high") // Secrets are always high severity
|
||||
summary.Add("high")
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: "high",
|
||||
|
|
@ -144,7 +140,6 @@ func runAlerts() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Alerts:"), summary.String())
|
||||
cli.Blank()
|
||||
|
|
@ -153,11 +148,9 @@ func runAlerts() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Print table
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
|
||||
// Format: repo SEVERITY ID package/location type
|
||||
location := alert.Package
|
||||
if location == "" {
|
||||
location = alert.Location
|
||||
|
|
@ -179,7 +172,9 @@ func runAlerts() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// runAlertsForTarget runs unified alert checks against an external repo target.
|
||||
// runAlertsForTarget runs unified alert checks against an external owner/repo target.
|
||||
//
|
||||
// runAlertsForTarget("wailsapp/wails")
|
||||
func runAlertsForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
|
|
@ -189,7 +184,6 @@ func runAlertsForTarget(target string) error {
|
|||
var allAlerts []AlertOutput
|
||||
summary := &AlertSummary{}
|
||||
|
||||
// Fetch Dependabot alerts
|
||||
depAlerts, err := fetchDependabotAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
|
|
@ -213,7 +207,6 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch code scanning alerts
|
||||
codeAlerts, err := fetchCodeScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
|
|
@ -237,7 +230,6 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch secret scanning alerts
|
||||
secretAlerts, err := fetchSecretScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
|
|
@ -297,6 +289,9 @@ func runAlertsForTarget(target string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// fetchDependabotAlerts returns open Dependabot vulnerability alerts for the given repo.
|
||||
//
|
||||
// alerts, _ := fetchDependabotAlerts("host-uk/core-php")
|
||||
func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
|
|
@ -311,6 +306,9 @@ func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) {
|
|||
return alerts, nil
|
||||
}
|
||||
|
||||
// fetchCodeScanningAlerts returns open code-scanning alerts for the given repo.
|
||||
//
|
||||
// alerts, _ := fetchCodeScanningAlerts("host-uk/core-php")
|
||||
func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
|
|
@ -325,6 +323,9 @@ func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) {
|
|||
return alerts, nil
|
||||
}
|
||||
|
||||
// fetchSecretScanningAlerts returns open secret-scanning alerts for the given repo.
|
||||
//
|
||||
// alerts, _ := fetchSecretScanningAlerts("host-uk/core-php")
|
||||
func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ func runJobs() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// createJobForTarget gathers all security findings for target and creates a GitHub issue.
|
||||
//
|
||||
// createJobForTarget("wailsapp/wails")
|
||||
func createJobForTarget(target string) error {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
|
|
@ -195,36 +198,38 @@ func createJobForTarget(target string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// buildJobIssueBody renders the GitHub issue body for a security scan job.
|
||||
//
|
||||
// body := buildJobIssueBody("wailsapp/wails", summary, findings)
|
||||
func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string {
|
||||
var sb strings.Builder
|
||||
var builder strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&sb, "**Summary:** %s\n\n", summary.String())
|
||||
fmt.Fprintf(&builder, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&builder, "**Summary:** %s\n\n", summary.String())
|
||||
|
||||
sb.WriteString("### Findings\n\n")
|
||||
builder.WriteString("### Findings\n\n")
|
||||
if len(findings) > 50 {
|
||||
// Truncate long lists
|
||||
for _, f := range findings[:50] {
|
||||
sb.WriteString(f + "\n")
|
||||
builder.WriteString(f + "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n... and %d more\n", len(findings)-50)
|
||||
fmt.Fprintf(&builder, "\n... and %d more\n", len(findings)-50)
|
||||
} else {
|
||||
for _, f := range findings {
|
||||
sb.WriteString(f + "\n")
|
||||
builder.WriteString(f + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n### Checklist\n\n")
|
||||
sb.WriteString("- [ ] Review findings above\n")
|
||||
sb.WriteString("- [ ] Triage by severity (critical/high first)\n")
|
||||
sb.WriteString("- [ ] Create PRs for fixes\n")
|
||||
sb.WriteString("- [ ] Verify fixes resolve alerts\n")
|
||||
builder.WriteString("\n### Checklist\n\n")
|
||||
builder.WriteString("- [ ] Review findings above\n")
|
||||
builder.WriteString("- [ ] Triage by severity (critical/high first)\n")
|
||||
builder.WriteString("- [ ] Create PRs for fixes\n")
|
||||
builder.WriteString("- [ ] Verify fixes resolve alerts\n")
|
||||
|
||||
sb.WriteString("\n### Instructions\n\n")
|
||||
sb.WriteString("1. Claim this issue by assigning yourself\n")
|
||||
fmt.Fprintf(&sb, "2. Run `core security alerts --target %s` for the latest findings\n", target)
|
||||
sb.WriteString("3. Work through the checklist above\n")
|
||||
sb.WriteString("4. Close this issue when all findings are addressed\n")
|
||||
builder.WriteString("\n### Instructions\n\n")
|
||||
builder.WriteString("1. Claim this issue by assigning yourself\n")
|
||||
fmt.Fprintf(&builder, "2. Run `core security alerts --target %s` for the latest findings\n", target)
|
||||
builder.WriteString("3. Work through the checklist above\n")
|
||||
builder.WriteString("4. Close this issue when all findings are addressed\n")
|
||||
|
||||
return sb.String()
|
||||
return builder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ func addScanCommand(parent *cli.Command) {
|
|||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// ScanAlert represents a code scanning alert for output.
|
||||
// ScanAlert is a code scanning alert entry written to JSON or the terminal.
|
||||
type ScanAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
|
|
@ -51,7 +51,6 @@ func runScan() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runScanForTarget(securityTarget)
|
||||
}
|
||||
|
|
@ -90,7 +89,7 @@ func runScan() error {
|
|||
|
||||
severity := alert.Rule.Severity
|
||||
if severity == "" {
|
||||
severity = "medium" // Default if not specified
|
||||
severity = "medium"
|
||||
}
|
||||
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
|
|
@ -163,7 +162,9 @@ func runScan() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// runScanForTarget runs a code scanning check against an external repo target.
|
||||
// runScanForTarget runs code scanning checks against an external owner/repo target.
|
||||
//
|
||||
// runScanForTarget("wailsapp/wails")
|
||||
func runScanForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ var (
|
|||
securityTarget string // External repo target (e.g. "wailsapp/wails")
|
||||
)
|
||||
|
||||
// AddSecurityCommands adds the 'security' command to the root.
|
||||
// AddSecurityCommands registers the security subcommand tree.
|
||||
//
|
||||
// security.AddSecurityCommands(rootCmd) // → core security alerts|deps|scan|secrets|jobs
|
||||
func AddSecurityCommands(root *cli.Command) {
|
||||
secCmd := &cli.Command{
|
||||
Use: "security",
|
||||
|
|
@ -39,7 +41,7 @@ func AddSecurityCommands(root *cli.Command) {
|
|||
root.AddCommand(secCmd)
|
||||
}
|
||||
|
||||
// DependabotAlert represents a Dependabot vulnerability alert.
|
||||
// DependabotAlert is a GitHub Dependabot vulnerability alert (from repos/{org}/{repo}/dependabot/alerts).
|
||||
type DependabotAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -68,7 +70,7 @@ type DependabotAlert struct {
|
|||
} `json:"security_vulnerability"`
|
||||
}
|
||||
|
||||
// CodeScanningAlert represents a code scanning alert.
|
||||
// CodeScanningAlert is a GitHub code scanning alert (from repos/{org}/{repo}/code-scanning/alerts).
|
||||
type CodeScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -95,7 +97,7 @@ type CodeScanningAlert struct {
|
|||
} `json:"most_recent_instance"`
|
||||
}
|
||||
|
||||
// SecretScanningAlert represents a secret scanning alert.
|
||||
// SecretScanningAlert is a GitHub secret scanning alert (from repos/{org}/{repo}/secret-scanning/alerts).
|
||||
type SecretScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -105,7 +107,10 @@ type SecretScanningAlert struct {
|
|||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
// loadRegistry loads the repository registry.
|
||||
// loadRegistry loads the registry from registryPath, or auto-discovers it from the working directory.
|
||||
//
|
||||
// loadRegistry("") // auto-discover repos.yaml
|
||||
// loadRegistry("/path/to/repos.yaml")
|
||||
func loadRegistry(registryPath string) (*repos.Registry, error) {
|
||||
if registryPath != "" {
|
||||
reg, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
|
|
@ -126,7 +131,9 @@ func loadRegistry(registryPath string) (*repos.Registry, error) {
|
|||
return reg, nil
|
||||
}
|
||||
|
||||
// checkGH verifies gh CLI is available.
|
||||
// checkGH returns an error if the gh CLI is not on PATH.
|
||||
//
|
||||
// if err := checkGH(); err != nil { return err }
|
||||
func checkGH() error {
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
return coreerr.E("security.checkGH", i18n.T("error.gh_not_found"), nil)
|
||||
|
|
@ -134,7 +141,9 @@ func checkGH() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// runGHAPI runs a gh api command and returns the output.
|
||||
// runGHAPI calls gh api with pagination and returns the raw JSON body.
|
||||
//
|
||||
// runGHAPI("repos/host-uk/core-php/dependabot/alerts?state=open")
|
||||
func runGHAPI(endpoint string) ([]byte, error) {
|
||||
cmd := exec.Command("gh", "api", endpoint, "--paginate")
|
||||
output, err := cmd.Output()
|
||||
|
|
@ -154,7 +163,10 @@ func runGHAPI(endpoint string) ([]byte, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
// severityStyle returns the appropriate style for a severity level.
|
||||
// severityStyle maps a severity string to its terminal render style.
|
||||
//
|
||||
// severityStyle("critical") // → cli.ErrorStyle
|
||||
// severityStyle("high") // → cli.WarningStyle
|
||||
func severityStyle(severity string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
|
|
@ -168,7 +180,11 @@ func severityStyle(severity string) *cli.AnsiStyle {
|
|||
}
|
||||
}
|
||||
|
||||
// filterBySeverity checks if the severity matches the filter.
|
||||
// filterBySeverity reports whether severity matches the comma-separated filter list.
|
||||
//
|
||||
// filterBySeverity("high", "critical,high") // → true
|
||||
// filterBySeverity("low", "critical,high") // → false
|
||||
// filterBySeverity("high", "") // → true (empty = all pass)
|
||||
func filterBySeverity(severity, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
|
|
@ -180,7 +196,10 @@ func filterBySeverity(severity, filter string) bool {
|
|||
})
|
||||
}
|
||||
|
||||
// getReposToCheck returns the list of repos to check based on flags.
|
||||
// getReposToCheck returns a single repo when repoFilter is set, or all repos in the registry.
|
||||
//
|
||||
// getReposToCheck(reg, "core-php") // → [core-php]
|
||||
// getReposToCheck(reg, "") // → all repos
|
||||
func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo {
|
||||
if repoFilter != "" {
|
||||
if repo, ok := reg.Get(repoFilter); ok {
|
||||
|
|
@ -191,7 +210,9 @@ func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo {
|
|||
return reg.List()
|
||||
}
|
||||
|
||||
// buildTargetRepo creates a synthetic Repo entry for an external target (e.g. "wailsapp/wails").
|
||||
// buildTargetRepo parses an owner/repo target string into a Repo and its full name.
|
||||
//
|
||||
// buildTargetRepo("wailsapp/wails") // → &Repo{Name:"wails"}, "wailsapp/wails"
|
||||
func buildTargetRepo(target string) (*repos.Repo, string) {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
|
|
@ -200,7 +221,11 @@ func buildTargetRepo(target string) (*repos.Repo, string) {
|
|||
return &repos.Repo{Name: parts[1]}, target
|
||||
}
|
||||
|
||||
// AlertSummary holds aggregated alert counts.
|
||||
// AlertSummary tracks alert counts by severity across a scan run.
|
||||
//
|
||||
// s := &AlertSummary{}
|
||||
// s.Add("critical")
|
||||
// s.String() // → "1 critical"
|
||||
type AlertSummary struct {
|
||||
Critical int
|
||||
High int
|
||||
|
|
@ -210,7 +235,9 @@ type AlertSummary struct {
|
|||
Total int
|
||||
}
|
||||
|
||||
// Add increments summary counters for the provided severity.
|
||||
// Add increments the counter for the given severity level.
|
||||
//
|
||||
// s.Add("critical") // s.Critical == 1, s.Total == 1
|
||||
func (s *AlertSummary) Add(severity string) {
|
||||
s.Total++
|
||||
switch strings.ToLower(severity) {
|
||||
|
|
@ -227,7 +254,9 @@ func (s *AlertSummary) Add(severity string) {
|
|||
}
|
||||
}
|
||||
|
||||
// String renders a human-readable summary of alert counts.
|
||||
// String renders a styled, human-readable summary of alert counts.
|
||||
//
|
||||
// (&AlertSummary{Critical: 1, High: 2}).String() // → "1 critical | 2 high"
|
||||
func (s *AlertSummary) String() string {
|
||||
parts := []string{}
|
||||
if s.Critical > 0 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue