refactor(ax): tighten remaining AX naming and examples
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
eaf26d3f93
commit
bc3b36c3da
14 changed files with 452 additions and 479 deletions
|
|
@ -16,7 +16,7 @@ import (
|
|||
|
||||
var metricsWriteMutex sync.Mutex
|
||||
|
||||
// Event is a recorded AI or security metric entry.
|
||||
// Event records an AI or security metric entry.
|
||||
//
|
||||
// ai.Record(ai.Event{Type: "security.scan", Repo: "core-php", AgentID: "codex-1"})
|
||||
type Event struct {
|
||||
|
|
@ -39,14 +39,14 @@ func metricsDir() (string, error) {
|
|||
return filepath.Join(home, ".core", "ai", "metrics"), nil
|
||||
}
|
||||
|
||||
// metricsFilePath returns the JSONL path for a given date.
|
||||
// metricsFilePath joins the daily JSONL filename onto dir.
|
||||
//
|
||||
// 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 ~/.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) {
|
||||
|
|
@ -68,13 +68,13 @@ func Record(event Event) (err error) {
|
|||
|
||||
path := metricsFilePath(dir, event.Timestamp)
|
||||
|
||||
w, err := coreio.Local.Append(path)
|
||||
writer, err := coreio.Local.Append(path)
|
||||
if err != nil {
|
||||
return coreerr.E("ai.Record", "open metrics file for append", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := w.Close(); cerr != nil && err == nil {
|
||||
err = coreerr.E("ai.Record", "close metrics file", cerr)
|
||||
if closeErr := writer.Close(); closeErr != nil && err == nil {
|
||||
err = coreerr.E("ai.Record", "close metrics file", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -83,14 +83,14 @@ func Record(event Event) (err error) {
|
|||
return coreerr.E("ai.Record", "marshal event", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||
if _, err := writer.Write(append(data, '\n')); err != nil {
|
||||
return coreerr.E("ai.Record", "write event", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadEvents reads all events recorded on or after since.
|
||||
// ReadEvents returns events recorded on or after since.
|
||||
//
|
||||
// events, _ := ai.ReadEvents(time.Now().Add(-7 * 24 * time.Hour))
|
||||
func ReadEvents(since time.Time) ([]Event, error) {
|
||||
|
|
@ -102,8 +102,8 @@ func ReadEvents(since time.Time) ([]Event, error) {
|
|||
var events []Event
|
||||
now := time.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)
|
||||
for currentDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.Local); !currentDay.After(now); currentDay = currentDay.AddDate(0, 0, 1) {
|
||||
path := metricsFilePath(dir, currentDay)
|
||||
|
||||
dayEvents, err := readMetricsFile(path, since)
|
||||
if err != nil {
|
||||
|
|
@ -115,7 +115,7 @@ func ReadEvents(since time.Time) ([]Event, error) {
|
|||
return events, nil
|
||||
}
|
||||
|
||||
// readMetricsFile reads events from a single JSONL file, skipping lines before since.
|
||||
// readMetricsFile returns events from one JSONL file on or after 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) {
|
||||
|
|
@ -123,21 +123,21 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
r, err := coreio.Local.ReadStream(path)
|
||||
reader, err := coreio.Local.ReadStream(path)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("ai.readMetricsFile", "open metrics file", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
var events []Event
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
var ev Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil {
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
if !ev.Timestamp.Before(since) {
|
||||
events = append(events, ev)
|
||||
if !event.Timestamp.Before(since) {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
|
|
@ -146,11 +146,11 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) {
|
|||
return events, nil
|
||||
}
|
||||
|
||||
// Summary aggregates events into counts by type, repo, and agent.
|
||||
// Summary groups events 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
|
||||
// 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)
|
||||
|
|
@ -174,26 +174,26 @@ func Summary(events []Event) map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
// sortedMap converts a string→count map to a slice sorted by count descending.
|
||||
// sortedMap converts counts into rows 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 {
|
||||
func sortedMap(countByName map[string]int) []map[string]any {
|
||||
type countEntry struct {
|
||||
key string
|
||||
count int
|
||||
}
|
||||
entries := make([]entry, 0, len(m))
|
||||
for k, v := range m {
|
||||
entries = append(entries, entry{k, v})
|
||||
countEntries := make([]countEntry, 0, len(countByName))
|
||||
for name, count := range countByName {
|
||||
countEntries = append(countEntries, countEntry{name, count})
|
||||
}
|
||||
|
||||
slices.SortFunc(entries, func(a, b entry) int {
|
||||
return cmp.Compare(b.count, a.count)
|
||||
slices.SortFunc(countEntries, func(left, right countEntry) int {
|
||||
return cmp.Compare(right.count, left.count)
|
||||
})
|
||||
|
||||
result := make([]map[string]any, len(entries))
|
||||
for i, entry := range entries {
|
||||
result[i] = map[string]any{"key": entry.key, "count": entry.count}
|
||||
rows := make([]map[string]any, len(countEntries))
|
||||
for rowIndex, countEntry := range countEntries {
|
||||
rows[rowIndex] = map[string]any{"key": countEntry.key, "count": countEntry.count}
|
||||
}
|
||||
return result
|
||||
return rows
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import (
|
|||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// setupBenchMetricsDir overrides HOME to a temp dir and returns the metrics path.
|
||||
//
|
||||
// metricsPath := setupBenchMetricsDir(b) // → "/tmp/.../home/.core/ai/metrics"
|
||||
|
|
@ -36,22 +34,20 @@ func setupBenchMetricsDir(b *testing.B) string {
|
|||
func seedEvents(b *testing.B, count int) {
|
||||
b.Helper()
|
||||
now := time.Now()
|
||||
for i := range count {
|
||||
ev := Event{
|
||||
Type: fmt.Sprintf("type-%d", i%10),
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", i%5),
|
||||
Repo: fmt.Sprintf("repo-%d", i%3),
|
||||
Data: map[string]any{"i": i, "tool": "bench_tool"},
|
||||
for eventIndex := range count {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
|
||||
Data: map[string]any{"index": eventIndex, "tool": "bench_tool"},
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
b.Fatalf("Failed to record event %d: %v", i, err)
|
||||
if err := Record(event); err != nil {
|
||||
b.Fatalf("Failed to record event %d: %v", eventIndex, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
// BenchmarkMetricsRecord benchmarks writing individual metric events.
|
||||
func BenchmarkMetricsRecord(b *testing.B) {
|
||||
setupBenchMetricsDir(b)
|
||||
|
|
@ -59,16 +55,16 @@ func BenchmarkMetricsRecord(b *testing.B) {
|
|||
now := time.Now()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := range b.N {
|
||||
ev := Event{
|
||||
for sampleIndex := range b.N {
|
||||
event := Event{
|
||||
Type: "bench_record",
|
||||
Timestamp: now,
|
||||
AgentID: "bench-agent",
|
||||
Repo: "bench-repo",
|
||||
Data: map[string]any{"i": i},
|
||||
Data: map[string]any{"index": sampleIndex},
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
b.Fatalf("Record failed at iteration %d: %v", i, err)
|
||||
if err := Record(event); err != nil {
|
||||
b.Fatalf("Record failed at iteration %d: %v", sampleIndex, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,19 +77,19 @@ func BenchmarkMetricsRecord_Parallel(b *testing.B) {
|
|||
b.ResetTimer()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
sampleIndex := 0
|
||||
for pb.Next() {
|
||||
ev := Event{
|
||||
event := Event{
|
||||
Type: "bench_parallel",
|
||||
Timestamp: now,
|
||||
AgentID: "bench-agent",
|
||||
Repo: "bench-repo",
|
||||
Data: map[string]any{"i": i},
|
||||
Data: map[string]any{"index": sampleIndex},
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
if err := Record(event); err != nil {
|
||||
b.Fatalf("Parallel Record failed: %v", err)
|
||||
}
|
||||
i++
|
||||
sampleIndex++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -163,14 +159,14 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) {
|
|||
now := time.Now()
|
||||
|
||||
// Write 10K events
|
||||
for i := range 10_000 {
|
||||
ev := Event{
|
||||
Type: fmt.Sprintf("type-%d", i%10),
|
||||
for eventIndex := range 10_000 {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now,
|
||||
AgentID: "bench",
|
||||
Repo: "bench-repo",
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
if err := Record(event); err != nil {
|
||||
b.Fatalf("Record failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -187,52 +183,45 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for metrics at scale ---
|
||||
|
||||
// TestMetricsBench_RecordAndRead_10K_Good writes 10K events and reads them back.
|
||||
func TestMetricsBench_RecordAndRead_10K_Good(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
now := time.Now()
|
||||
const n = 10_000
|
||||
const eventCount = 10_000
|
||||
|
||||
// Write events
|
||||
for i := range n {
|
||||
ev := Event{
|
||||
Type: fmt.Sprintf("type-%d", i%10),
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", i%5),
|
||||
Repo: fmt.Sprintf("repo-%d", i%3),
|
||||
Data: map[string]any{"index": i},
|
||||
for eventIndex := range eventCount {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
|
||||
Data: map[string]any{"index": eventIndex},
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
t.Fatalf("Record failed at %d: %v", i, err)
|
||||
if err := Record(event); err != nil {
|
||||
t.Fatalf("Record failed at %d: %v", eventIndex, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read back
|
||||
since := now.Add(-24 * time.Hour)
|
||||
events, err := ReadEvents(since)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != n {
|
||||
t.Errorf("Expected %d events, got %d", n, len(events))
|
||||
if len(events) != eventCount {
|
||||
t.Errorf("Expected %d events, got %d", eventCount, len(events))
|
||||
}
|
||||
|
||||
// Summarise
|
||||
summary := Summary(events)
|
||||
total, ok := summary["total"].(int)
|
||||
if !ok || total != n {
|
||||
t.Errorf("Expected total %d, got %v", n, summary["total"])
|
||||
if !ok || total != eventCount {
|
||||
t.Errorf("Expected total %d, got %v", eventCount, summary["total"])
|
||||
}
|
||||
|
||||
// Verify aggregation counts
|
||||
byType, ok := summary["by_type"].([]map[string]any)
|
||||
if !ok || len(byType) == 0 {
|
||||
t.Fatal("Expected non-empty by_type")
|
||||
}
|
||||
// Each of 10 types should have n/10 = 1000 events
|
||||
for _, entry := range byType {
|
||||
count, _ := entry["count"].(int)
|
||||
if count != 1000 {
|
||||
|
|
|
|||
|
|
@ -22,18 +22,16 @@ func withTempHome(t *testing.T) {
|
|||
t.Cleanup(func() { os.Setenv("HOME", origHome) })
|
||||
}
|
||||
|
||||
// --- Record ---
|
||||
|
||||
func TestMetrics_Record_Good(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
ev := Event{
|
||||
event := Event{
|
||||
Type: "test_event",
|
||||
AgentID: "agent-1",
|
||||
Repo: "repo-1",
|
||||
Data: map[string]any{"key": "value"},
|
||||
}
|
||||
if err := Record(ev); err != nil {
|
||||
if err := Record(event); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -56,8 +54,8 @@ func TestMetrics_Record_Good_AutoTimestamp(t *testing.T) {
|
|||
withTempHome(t)
|
||||
|
||||
before := time.Now()
|
||||
ev := Event{Type: "auto_ts"}
|
||||
if err := Record(ev); err != nil {
|
||||
event := Event{Type: "auto_ts"}
|
||||
if err := Record(event); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
after := time.Now()
|
||||
|
|
@ -79,8 +77,8 @@ func TestMetrics_Record_Good_PresetTimestamp(t *testing.T) {
|
|||
withTempHome(t)
|
||||
|
||||
fixed := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
|
||||
ev := Event{Type: "preset_ts", Timestamp: fixed}
|
||||
if err := Record(ev); err != nil {
|
||||
event := Event{Type: "preset_ts", Timestamp: fixed}
|
||||
if err := Record(event); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -96,8 +94,6 @@ func TestMetrics_Record_Good_PresetTimestamp(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- ReadEvents ---
|
||||
|
||||
func TestMetrics_ReadEvents_Good_Empty(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
|
|
@ -116,19 +112,16 @@ func TestMetrics_ReadEvents_Good_MultiDay(t *testing.T) {
|
|||
now := time.Now()
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
|
||||
// Write event for yesterday
|
||||
ev1 := Event{Type: "yesterday", Timestamp: yesterday}
|
||||
if err := Record(ev1); err != nil {
|
||||
yesterdayEvent := Event{Type: "yesterday", Timestamp: yesterday}
|
||||
if err := Record(yesterdayEvent); err != nil {
|
||||
t.Fatalf("Record yesterday: %v", err)
|
||||
}
|
||||
|
||||
// Write event for today
|
||||
ev2 := Event{Type: "today", Timestamp: now}
|
||||
if err := Record(ev2); err != nil {
|
||||
todayEvent := Event{Type: "today", Timestamp: now}
|
||||
if err := Record(todayEvent); err != nil {
|
||||
t.Fatalf("Record today: %v", err)
|
||||
}
|
||||
|
||||
// Read from 2 days ago — should get both
|
||||
events, err := ReadEvents(now.AddDate(0, 0, -2))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
|
|
@ -142,17 +135,15 @@ func TestMetrics_ReadEvents_Good_FiltersBySince(t *testing.T) {
|
|||
withTempHome(t)
|
||||
|
||||
now := time.Now()
|
||||
// Write an old event and a recent one
|
||||
old := Event{Type: "old", Timestamp: now.Add(-2 * time.Hour)}
|
||||
if err := Record(old); err != nil {
|
||||
oldEvent := Event{Type: "old", Timestamp: now.Add(-2 * time.Hour)}
|
||||
if err := Record(oldEvent); err != nil {
|
||||
t.Fatalf("Record old: %v", err)
|
||||
}
|
||||
recent := Event{Type: "recent", Timestamp: now}
|
||||
if err := Record(recent); err != nil {
|
||||
recentEvent := Event{Type: "recent", Timestamp: now}
|
||||
if err := Record(recentEvent); err != nil {
|
||||
t.Fatalf("Record recent: %v", err)
|
||||
}
|
||||
|
||||
// Read only events from the last hour
|
||||
events, err := ReadEvents(now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
|
|
@ -165,8 +156,6 @@ func TestMetrics_ReadEvents_Good_FiltersBySince(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- readMetricsFile ---
|
||||
|
||||
func TestMetrics_ReadMetricsFile_Good_MalformedLines(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
|
|
@ -175,7 +164,6 @@ func TestMetrics_ReadMetricsFile_Good_MalformedLines(t *testing.T) {
|
|||
t.Fatalf("metricsDir: %v", err)
|
||||
}
|
||||
|
||||
// Write a file with a mix of valid and invalid lines
|
||||
path := metricsFilePath(dir, time.Now())
|
||||
content := `{"type":"valid","timestamp":"2026-03-15T10:00:00Z"}
|
||||
not-json
|
||||
|
|
@ -205,8 +193,6 @@ func TestMetrics_ReadMetricsFile_Good_NonExistent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- metricsFilePath ---
|
||||
|
||||
func TestMetrics_MetricsFilePath_Good(t *testing.T) {
|
||||
ts := time.Date(2026, 3, 17, 14, 30, 0, 0, time.UTC)
|
||||
got := metricsFilePath("/base", ts)
|
||||
|
|
@ -216,13 +202,11 @@ func TestMetrics_MetricsFilePath_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Summary ---
|
||||
|
||||
func TestMetrics_Summary_Good_Empty(t *testing.T) {
|
||||
s := Summary(nil)
|
||||
total, ok := s["total"].(int)
|
||||
summary := Summary(nil)
|
||||
total, ok := summary["total"].(int)
|
||||
if !ok || total != 0 {
|
||||
t.Errorf("expected total 0, got %v", s["total"])
|
||||
t.Errorf("expected total 0, got %v", summary["total"])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,25 +217,22 @@ func TestMetrics_Summary_Good(t *testing.T) {
|
|||
{Type: "test", Repo: "core-api", AgentID: "agent-1"},
|
||||
}
|
||||
|
||||
s := Summary(events)
|
||||
summary := Summary(events)
|
||||
|
||||
total, _ := s["total"].(int)
|
||||
total, _ := summary["total"].(int)
|
||||
if total != 3 {
|
||||
t.Errorf("expected total 3, got %d", total)
|
||||
}
|
||||
|
||||
byType, _ := s["by_type"].([]map[string]any)
|
||||
byType, _ := summary["by_type"].([]map[string]any)
|
||||
if len(byType) != 2 {
|
||||
t.Fatalf("expected 2 types, got %d", len(byType))
|
||||
}
|
||||
// Sorted by count descending — "build" (2) first
|
||||
if byType[0]["key"] != "build" || byType[0]["count"] != 2 {
|
||||
t.Errorf("expected build:2 first, got %v:%v", byType[0]["key"], byType[0]["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- sortedMap ---
|
||||
|
||||
func TestMetrics_SortedMap_Good_Empty(t *testing.T) {
|
||||
result := sortedMap(map[string]int{})
|
||||
if len(result) != 0 {
|
||||
|
|
@ -260,12 +241,11 @@ func TestMetrics_SortedMap_Good_Empty(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestMetrics_SortedMap_Good_Ordering(t *testing.T) {
|
||||
m := map[string]int{"a": 1, "b": 3, "c": 2}
|
||||
result := sortedMap(m)
|
||||
counts := map[string]int{"a": 1, "b": 3, "c": 2}
|
||||
result := sortedMap(counts)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(result))
|
||||
}
|
||||
// Descending by count
|
||||
if result[0]["key"] != "b" {
|
||||
t.Errorf("expected first key 'b', got %v", result[0]["key"])
|
||||
}
|
||||
|
|
|
|||
20
ai/rag.go
20
ai/rag.go
|
|
@ -8,7 +8,7 @@ import (
|
|||
"forge.lthn.ai/core/go-rag"
|
||||
)
|
||||
|
||||
// TaskInfo carries the task data used to build a RAG query.
|
||||
// TaskInfo is the input for QueryRAGForTask.
|
||||
//
|
||||
// ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth", Description: "JWT expiry not checked"})
|
||||
type TaskInfo struct {
|
||||
|
|
@ -20,22 +20,22 @@ type TaskInfo struct {
|
|||
//
|
||||
// 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
|
||||
queryText := task.Title + " " + task.Description
|
||||
|
||||
runes := []rune(query)
|
||||
runes := []rune(queryText)
|
||||
if len(runes) > 500 {
|
||||
query = string(runes[:500])
|
||||
queryText = string(runes[:500])
|
||||
}
|
||||
|
||||
qdrantConfig := rag.DefaultQdrantConfig()
|
||||
qdrantClient, err := rag.NewQdrantClient(qdrantConfig)
|
||||
qdrantConfiguration := rag.DefaultQdrantConfig()
|
||||
qdrantClient, err := rag.NewQdrantClient(qdrantConfiguration)
|
||||
if err != nil {
|
||||
return "", coreerr.E("ai.QueryRAGForTask", "rag qdrant client", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
ollamaConfig := rag.DefaultOllamaConfig()
|
||||
ollamaClient, err := rag.NewOllamaClient(ollamaConfig)
|
||||
ollamaConfiguration := rag.DefaultOllamaConfig()
|
||||
ollamaClient, err := rag.NewOllamaClient(ollamaConfiguration)
|
||||
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()
|
||||
|
||||
queryConfig := rag.QueryConfig{
|
||||
searchConfiguration := rag.QueryConfig{
|
||||
Collection: "hostuk-docs",
|
||||
Limit: 3,
|
||||
Threshold: 0.5,
|
||||
}
|
||||
|
||||
results, err := rag.Query(ctx, qdrantClient, ollamaClient, query, queryConfig)
|
||||
results, err := rag.Query(ctx, qdrantClient, ollamaClient, queryText, searchConfiguration)
|
||||
if err != nil {
|
||||
return "", coreerr.E("ai.QueryRAGForTask", "rag query", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,11 @@ import (
|
|||
|
||||
var ollamaURL = flag.String("ollama", "http://localhost:11434", "Ollama base URL")
|
||||
|
||||
// models to benchmark
|
||||
var models = []string{
|
||||
"nomic-embed-text",
|
||||
"embeddinggemma",
|
||||
}
|
||||
|
||||
// Test corpus: real-ish agent memories grouped by topic.
|
||||
// Memories within a group should be similar; across groups should be distant.
|
||||
var memoryGroups = []struct {
|
||||
topic string
|
||||
memories []string
|
||||
|
|
@ -73,7 +70,6 @@ var memoryGroups = []struct {
|
|||
},
|
||||
}
|
||||
|
||||
// Queries to test recall quality — each has a target topic it should match best.
|
||||
var queries = []struct {
|
||||
query string
|
||||
targetTopic string
|
||||
|
|
@ -98,46 +94,43 @@ func main() {
|
|||
fmt.Printf("\n## Model: %s\n", model)
|
||||
fmt.Println(strings.Repeat("-", 40))
|
||||
|
||||
// Check model is available
|
||||
if !modelAvailable(model) {
|
||||
fmt.Printf(" SKIPPED — model not pulled (run: ollama pull %s)\n", model)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Embed all memories
|
||||
allMemories := []string{}
|
||||
allTopics := []string{}
|
||||
memoryTexts := []string{}
|
||||
memoryTopics := []string{}
|
||||
for _, group := range memoryGroups {
|
||||
for _, mem := range group.memories {
|
||||
allMemories = append(allMemories, mem)
|
||||
allTopics = append(allTopics, group.topic)
|
||||
for _, memoryText := range group.memories {
|
||||
memoryTexts = append(memoryTexts, memoryText)
|
||||
memoryTopics = append(memoryTopics, group.topic)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Embedding %d memories...\n", len(allMemories))
|
||||
fmt.Printf(" Embedding %d memories...\n", len(memoryTexts))
|
||||
start := time.Now()
|
||||
memVectors := make([][]float64, len(allMemories))
|
||||
for i, mem := range allMemories {
|
||||
vec, err := embed(model, mem)
|
||||
memoryVectors := make([][]float64, len(memoryTexts))
|
||||
for memoryIndex, memoryText := range memoryTexts {
|
||||
vector, err := embed(model, memoryText)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR embedding memory %d: %v\n", i, err)
|
||||
fmt.Printf(" ERROR embedding memory %d: %v\n", memoryIndex, err)
|
||||
break
|
||||
}
|
||||
memVectors[i] = vec
|
||||
memoryVectors[memoryIndex] = vector
|
||||
}
|
||||
embedTime := time.Since(start)
|
||||
fmt.Printf(" Embedded in %v (%.0fms/memory)\n", embedTime, float64(embedTime.Milliseconds())/float64(len(allMemories)))
|
||||
fmt.Printf(" Vector dimension: %d\n", len(memVectors[0]))
|
||||
fmt.Printf(" Embedded in %v (%.0fms/memory)\n", embedTime, float64(embedTime.Milliseconds())/float64(len(memoryTexts)))
|
||||
fmt.Printf(" Vector dimension: %d\n", len(memoryVectors[0]))
|
||||
|
||||
// 2. Intra-group vs inter-group similarity
|
||||
var intraSims, interSims []float64
|
||||
for i := 0; i < len(allMemories); i++ {
|
||||
for j := i + 1; j < len(allMemories); j++ {
|
||||
sim := cosine(memVectors[i], memVectors[j])
|
||||
if allTopics[i] == allTopics[j] {
|
||||
intraSims = append(intraSims, sim)
|
||||
for leftIndex := 0; leftIndex < len(memoryTexts); leftIndex++ {
|
||||
for rightIndex := leftIndex + 1; rightIndex < len(memoryTexts); rightIndex++ {
|
||||
similarity := cosine(memoryVectors[leftIndex], memoryVectors[rightIndex])
|
||||
if memoryTopics[leftIndex] == memoryTopics[rightIndex] {
|
||||
intraSims = append(intraSims, similarity)
|
||||
} else {
|
||||
interSims = append(interSims, sim)
|
||||
interSims = append(interSims, similarity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,28 +144,26 @@ func main() {
|
|||
fmt.Printf(" Inter-group similarity (diff topic): %.4f\n", interAvg)
|
||||
fmt.Printf(" Separation gap: %.4f %s\n", separation, qualityLabel(separation))
|
||||
|
||||
// 3. Query recall accuracy
|
||||
fmt.Printf("\n Query recall (top-1 accuracy):\n")
|
||||
correct := 0
|
||||
for _, queryCase := range queries {
|
||||
queryVec, err := embed(model, queryCase.query)
|
||||
queryVector, err := embed(model, queryCase.query)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best match
|
||||
bestIdx := 0
|
||||
bestSim := -1.0
|
||||
for i, mv := range memVectors {
|
||||
sim := cosine(queryVec, mv)
|
||||
if sim > bestSim {
|
||||
bestSim = sim
|
||||
bestIdx = i
|
||||
bestIndex := 0
|
||||
bestSimilarity := -1.0
|
||||
for memoryIndex, memoryVector := range memoryVectors {
|
||||
similarity := cosine(queryVector, memoryVector)
|
||||
if similarity > bestSimilarity {
|
||||
bestSimilarity = similarity
|
||||
bestIndex = memoryIndex
|
||||
}
|
||||
}
|
||||
|
||||
matchTopic := allTopics[bestIdx]
|
||||
matchTopic := memoryTopics[bestIndex]
|
||||
hit := matchTopic == queryCase.targetTopic
|
||||
if hit {
|
||||
correct++
|
||||
|
|
@ -181,29 +172,30 @@ func main() {
|
|||
if !hit {
|
||||
marker = "✗"
|
||||
}
|
||||
fmt.Printf(" %s %.4f %q → %s (want: %s)\n", marker, bestSim, truncate(queryCase.query, 40), matchTopic, queryCase.targetTopic)
|
||||
fmt.Printf(" %s %.4f %q → %s (want: %s)\n", marker, bestSimilarity, truncate(queryCase.query, 40), matchTopic, queryCase.targetTopic)
|
||||
}
|
||||
|
||||
accuracy := float64(correct) / float64(len(queries)) * 100
|
||||
fmt.Printf("\n Top-1 accuracy: %.0f%% (%d/%d)\n", accuracy, correct, len(queries))
|
||||
|
||||
// 4. Top-3 recall
|
||||
correct3 := 0
|
||||
for _, queryCase := range queries {
|
||||
queryVec, _ := embed(model, queryCase.query)
|
||||
queryVector, _ := embed(model, queryCase.query)
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
sim float64
|
||||
type scoredResult struct {
|
||||
index int
|
||||
sim float64
|
||||
}
|
||||
var ranked []scored
|
||||
for i, mv := range memVectors {
|
||||
ranked = append(ranked, scored{i, cosine(queryVec, mv)})
|
||||
var rankedResults []scoredResult
|
||||
for memoryIndex, memoryVector := range memoryVectors {
|
||||
rankedResults = append(rankedResults, scoredResult{memoryIndex, cosine(queryVector, memoryVector)})
|
||||
}
|
||||
sort.Slice(ranked, func(a, b int) bool { return ranked[a].sim > ranked[b].sim })
|
||||
sort.Slice(rankedResults, func(leftIndex, rightIndex int) bool {
|
||||
return rankedResults[leftIndex].sim > rankedResults[rightIndex].sim
|
||||
})
|
||||
|
||||
for _, rankedResult := range ranked[:3] {
|
||||
if allTopics[rankedResult.idx] == queryCase.targetTopic {
|
||||
for _, rankedResult := range rankedResults[:3] {
|
||||
if memoryTopics[rankedResult.index] == queryCase.targetTopic {
|
||||
correct3++
|
||||
break
|
||||
}
|
||||
|
|
@ -217,7 +209,6 @@ func main() {
|
|||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
// httpClient trusts self-signed certs for .lan domains behind Traefik.
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // .lan only
|
||||
|
|
@ -272,7 +263,6 @@ func modelAvailable(model string) bool {
|
|||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
for _, registeredModel := range result.Models {
|
||||
// Match "nomic-embed-text:latest" against "nomic-embed-text"
|
||||
if registeredModel.Name == model || strings.HasPrefix(registeredModel.Name, model+":") {
|
||||
return true
|
||||
}
|
||||
|
|
@ -284,32 +274,32 @@ func modelAvailable(model string) bool {
|
|||
//
|
||||
// cosine([]float64{1, 0}, []float64{1, 0}) // → 1.0
|
||||
// cosine([]float64{1, 0}, []float64{0, 1}) // → 0.0
|
||||
func cosine(a, b []float64) float64 {
|
||||
var dot, normA, normB float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
func cosine(firstVector, secondVector []float64) float64 {
|
||||
var dotProduct, firstMagnitudeSquared, secondMagnitudeSquared float64
|
||||
for index := range firstVector {
|
||||
dotProduct += firstVector[index] * secondVector[index]
|
||||
firstMagnitudeSquared += firstVector[index] * firstVector[index]
|
||||
secondMagnitudeSquared += secondVector[index] * secondVector[index]
|
||||
}
|
||||
denom := math.Sqrt(normA) * math.Sqrt(normB)
|
||||
if denom == 0 {
|
||||
denominator := math.Sqrt(firstMagnitudeSquared) * math.Sqrt(secondMagnitudeSquared)
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / denom
|
||||
return dotProduct / denominator
|
||||
}
|
||||
|
||||
// avg returns the arithmetic mean of a float64 slice, or 0 for an empty slice.
|
||||
//
|
||||
// avg([]float64{0.8, 0.6, 0.9}) // → 0.7666...
|
||||
func avg(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
func avg(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, value := range vals {
|
||||
sum += value
|
||||
total := 0.0
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
return sum / float64(len(vals))
|
||||
return total / float64(len(values))
|
||||
}
|
||||
|
||||
// qualityLabel maps a cosine separation gap to a human-readable quality band.
|
||||
|
|
@ -332,14 +322,13 @@ func qualityLabel(gap float64) string {
|
|||
// truncate shortens s to at most n characters, appending "..." if truncated.
|
||||
//
|
||||
// truncate("How does the emotional scoring work?", 20) // → "How does the emot..."
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
func truncate(text string, limit int) string {
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
return text[:limit-3] + "..."
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Ensure stderr doesn't buffer
|
||||
os.Stderr.Sync()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,17 +21,17 @@ func init() {
|
|||
cli.RegisterCommands(AddLabCommands)
|
||||
}
|
||||
|
||||
var labCmd = &cli.Command{
|
||||
var labCommand = &cli.Command{
|
||||
Use: "lab",
|
||||
Short: "Homelab monitoring dashboard",
|
||||
Long: "Lab dashboard with real-time monitoring of machines, training runs, models, and services.",
|
||||
}
|
||||
|
||||
var (
|
||||
labBind string
|
||||
labBindAddress string
|
||||
)
|
||||
|
||||
var serveCmd = &cli.Command{
|
||||
var serveCommand = &cli.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the lab dashboard web server",
|
||||
Long: "Starts the lab dashboard HTTP server with live-updating collectors for system stats, Docker, Forgejo, HuggingFace, InfluxDB, and more.",
|
||||
|
|
@ -39,99 +39,99 @@ var serveCmd = &cli.Command{
|
|||
}
|
||||
|
||||
func init() {
|
||||
serveCmd.Flags().StringVar(&labBind, "bind", ":8080", "HTTP listen address")
|
||||
serveCommand.Flags().StringVar(&labBindAddress, "bind", ":8080", "HTTP listen address")
|
||||
}
|
||||
|
||||
// AddLabCommands registers the lab command tree with the root command.
|
||||
//
|
||||
// lab.AddLabCommands(rootCmd) // → core lab serve --bind :8080
|
||||
func AddLabCommands(root *cli.Command) {
|
||||
labCmd.AddCommand(serveCmd)
|
||||
root.AddCommand(labCmd)
|
||||
labCommand.AddCommand(serveCommand)
|
||||
root.AddCommand(labCommand)
|
||||
}
|
||||
|
||||
func runServe(cmd *cli.Command, args []string) error {
|
||||
cfg := lab.LoadConfig()
|
||||
cfg.Addr = labBind
|
||||
func runServe(_ *cli.Command, _ []string) error {
|
||||
configuration := lab.LoadConfig()
|
||||
configuration.Addr = labBindAddress
|
||||
|
||||
store := lab.NewStore()
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
reg := collector.NewRegistry(logger)
|
||||
reg.Register(collector.NewSystem(cfg, store), 60*time.Second)
|
||||
reg.Register(collector.NewPrometheus(cfg.PrometheusURL, store),
|
||||
time.Duration(cfg.PrometheusInterval)*time.Second)
|
||||
reg.Register(collector.NewHuggingFace(cfg.HFAuthor, store),
|
||||
time.Duration(cfg.HFInterval)*time.Second)
|
||||
reg.Register(collector.NewDocker(store),
|
||||
time.Duration(cfg.DockerInterval)*time.Second)
|
||||
registry := collector.NewRegistry(logger)
|
||||
registry.Register(collector.NewSystem(configuration, store), 60*time.Second)
|
||||
registry.Register(collector.NewPrometheus(configuration.PrometheusURL, store),
|
||||
time.Duration(configuration.PrometheusInterval)*time.Second)
|
||||
registry.Register(collector.NewHuggingFace(configuration.HFAuthor, store),
|
||||
time.Duration(configuration.HFInterval)*time.Second)
|
||||
registry.Register(collector.NewDocker(store),
|
||||
time.Duration(configuration.DockerInterval)*time.Second)
|
||||
|
||||
if cfg.ForgeToken != "" {
|
||||
reg.Register(collector.NewForgejo(cfg.ForgeURL, cfg.ForgeToken, store),
|
||||
time.Duration(cfg.ForgeInterval)*time.Second)
|
||||
if configuration.ForgeToken != "" {
|
||||
registry.Register(collector.NewForgejo(configuration.ForgeURL, configuration.ForgeToken, store),
|
||||
time.Duration(configuration.ForgeInterval)*time.Second)
|
||||
}
|
||||
|
||||
reg.Register(collector.NewTraining(cfg, store),
|
||||
time.Duration(cfg.TrainingInterval)*time.Second)
|
||||
reg.Register(collector.NewServices(store), 60*time.Second)
|
||||
registry.Register(collector.NewTraining(configuration, store),
|
||||
time.Duration(configuration.TrainingInterval)*time.Second)
|
||||
registry.Register(collector.NewServices(store), 60*time.Second)
|
||||
|
||||
if cfg.InfluxToken != "" {
|
||||
reg.Register(collector.NewInfluxDB(cfg, store),
|
||||
time.Duration(cfg.InfluxInterval)*time.Second)
|
||||
if configuration.InfluxToken != "" {
|
||||
registry.Register(collector.NewInfluxDB(configuration, store),
|
||||
time.Duration(configuration.InfluxInterval)*time.Second)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
shutdownContext, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
reg.Start(ctx)
|
||||
defer reg.Stop()
|
||||
registry.Start(shutdownContext)
|
||||
defer registry.Stop()
|
||||
|
||||
web := handler.NewWebHandler(store)
|
||||
api := handler.NewAPIHandler(store)
|
||||
webHandler := handler.NewWebHandler(store)
|
||||
apiHandler := handler.NewAPIHandler(store)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
serveMux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /", web.Dashboard)
|
||||
mux.HandleFunc("GET /models", web.Models)
|
||||
mux.HandleFunc("GET /training", web.Training)
|
||||
mux.HandleFunc("GET /dataset", web.Dataset)
|
||||
mux.HandleFunc("GET /golden-set", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveMux.HandleFunc("GET /", webHandler.Dashboard)
|
||||
serveMux.HandleFunc("GET /models", webHandler.Models)
|
||||
serveMux.HandleFunc("GET /training", webHandler.Training)
|
||||
serveMux.HandleFunc("GET /dataset", webHandler.Dataset)
|
||||
serveMux.HandleFunc("GET /golden-set", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/dataset", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.HandleFunc("GET /runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveMux.HandleFunc("GET /runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/training", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.HandleFunc("GET /agents", web.Agents)
|
||||
mux.HandleFunc("GET /services", web.Services)
|
||||
serveMux.HandleFunc("GET /agents", webHandler.Agents)
|
||||
serveMux.HandleFunc("GET /services", webHandler.Services)
|
||||
|
||||
mux.HandleFunc("GET /events", web.Events)
|
||||
serveMux.HandleFunc("GET /events", webHandler.Events)
|
||||
|
||||
mux.HandleFunc("GET /api/status", api.Status)
|
||||
mux.HandleFunc("GET /api/models", api.Models)
|
||||
mux.HandleFunc("GET /api/training", api.Training)
|
||||
mux.HandleFunc("GET /api/dataset", api.GoldenSet)
|
||||
mux.HandleFunc("GET /api/golden-set", api.GoldenSet)
|
||||
mux.HandleFunc("GET /api/runs", api.Runs)
|
||||
mux.HandleFunc("GET /api/agents", api.Agents)
|
||||
mux.HandleFunc("GET /api/services", api.Services)
|
||||
mux.HandleFunc("GET /health", api.Health)
|
||||
serveMux.HandleFunc("GET /api/status", apiHandler.Status)
|
||||
serveMux.HandleFunc("GET /api/models", apiHandler.Models)
|
||||
serveMux.HandleFunc("GET /api/training", apiHandler.Training)
|
||||
serveMux.HandleFunc("GET /api/dataset", apiHandler.GoldenSet)
|
||||
serveMux.HandleFunc("GET /api/golden-set", apiHandler.GoldenSet)
|
||||
serveMux.HandleFunc("GET /api/runs", apiHandler.Runs)
|
||||
serveMux.HandleFunc("GET /api/agents", apiHandler.Agents)
|
||||
serveMux.HandleFunc("GET /api/services", apiHandler.Services)
|
||||
serveMux.HandleFunc("GET /health", apiHandler.Health)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
server := &http.Server{
|
||||
Addr: configuration.Addr,
|
||||
Handler: serveMux,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
<-shutdownContext.Done()
|
||||
logger.Info("shutting down")
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutCancel()
|
||||
srv.Shutdown(shutCtx)
|
||||
shutdownDeadline, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
server.Shutdown(shutdownDeadline)
|
||||
}()
|
||||
|
||||
logger.Info("lab dashboard starting", "addr", cfg.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Info("lab dashboard starting", "addr", configuration.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ package metrics
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"dappco.re/go/core/i18n"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -21,7 +23,7 @@ var metricsCmd = &cli.Command{
|
|||
Use: "metrics",
|
||||
Short: i18n.T("cmd.ai.metrics.short"),
|
||||
Long: i18n.T("cmd.ai.metrics.long"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runMetrics()
|
||||
},
|
||||
}
|
||||
|
|
@ -40,54 +42,54 @@ func AddMetricsCommand(parent *cli.Command) {
|
|||
}
|
||||
|
||||
func runMetrics() error {
|
||||
since, err := parseDuration(metricsSince)
|
||||
sinceDuration, err := parseDuration(metricsSince)
|
||||
if err != nil {
|
||||
return cli.Err("invalid --since value %q: %v", metricsSince, err)
|
||||
}
|
||||
|
||||
sinceTime := time.Now().Add(-since)
|
||||
sinceTime := time.Now().Add(-sinceDuration)
|
||||
events, err := ai.ReadEvents(sinceTime)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "read", "metrics")
|
||||
}
|
||||
|
||||
if metricsJSON {
|
||||
summary := ai.Summary(events)
|
||||
output, err := json.MarshalIndent(summary, "", " ")
|
||||
eventSummary := ai.Summary(events)
|
||||
jsonOutput, err := json.MarshalIndent(eventSummary, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
cli.Text(string(jsonOutput))
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := ai.Summary(events)
|
||||
eventSummary := ai.Summary(events)
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Period:"), metricsSince)
|
||||
total, _ := summary["total"].(int)
|
||||
cli.Print("%s %d\n", cli.DimStyle.Render("Total events:"), total)
|
||||
eventCount, _ := eventSummary["total"].(int)
|
||||
cli.Print("%s %d\n", cli.DimStyle.Render("Total events:"), eventCount)
|
||||
cli.Blank()
|
||||
|
||||
if byType, ok := summary["by_type"].([]map[string]any); ok && len(byType) > 0 {
|
||||
if typeCounts, ok := eventSummary["by_type"].([]map[string]any); ok && len(typeCounts) > 0 {
|
||||
cli.Print("%s\n", cli.DimStyle.Render("By type:"))
|
||||
for _, entry := range byType {
|
||||
for _, entry := range typeCounts {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
if byRepo, ok := summary["by_repo"].([]map[string]any); ok && len(byRepo) > 0 {
|
||||
if repoCounts, ok := eventSummary["by_repo"].([]map[string]any); ok && len(repoCounts) > 0 {
|
||||
cli.Print("%s\n", cli.DimStyle.Render("By repo:"))
|
||||
for _, entry := range byRepo {
|
||||
for _, entry := range repoCounts {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
if byAgent, ok := summary["by_agent"].([]map[string]any); ok && len(byAgent) > 0 {
|
||||
if agentCounts, ok := eventSummary["by_agent"].([]map[string]any); ok && len(agentCounts) > 0 {
|
||||
cli.Print("%s\n", cli.DimStyle.Render("By contributor:"))
|
||||
for _, entry := range byAgent {
|
||||
for _, entry := range agentCounts {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
|
|
@ -105,31 +107,31 @@ func runMetrics() error {
|
|||
// 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)
|
||||
func parseDuration(durationText string) (time.Duration, error) {
|
||||
if len(durationText) < 2 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
|
||||
}
|
||||
|
||||
unitChar := s[len(s)-1]
|
||||
numericPart := s[:len(s)-1]
|
||||
unitSuffix := durationText[len(durationText)-1]
|
||||
countText := strings.TrimSpace(durationText[:len(durationText)-1])
|
||||
|
||||
var count int
|
||||
if _, err := fmt.Sscanf(numericPart, "%d", &count); err != nil {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil)
|
||||
quantity, err := strconv.Atoi(countText)
|
||||
if err != nil {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", s), nil)
|
||||
if quantity <= 0 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", durationText), nil)
|
||||
}
|
||||
|
||||
switch unitChar {
|
||||
switch unitSuffix {
|
||||
case 'd':
|
||||
return time.Duration(count) * 24 * time.Hour, nil
|
||||
return time.Duration(quantity) * 24 * time.Hour, nil
|
||||
case 'h':
|
||||
return time.Duration(count) * time.Hour, nil
|
||||
return time.Duration(quantity) * time.Hour, nil
|
||||
case 'm':
|
||||
return time.Duration(count) * time.Minute, nil
|
||||
return time.Duration(quantity) * time.Minute, nil
|
||||
default:
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitChar, s), nil)
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,15 +53,15 @@ func TestCmd_ParseDuration_Ugly(t *testing.T) {
|
|||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
// Leading whitespace — Sscanf skips it, so " 7d" parses as 7d (valid).
|
||||
// Leading whitespace is trimmed from the count, so " 7d" still parses.
|
||||
{" 7d", false},
|
||||
// Trailing whitespace after unit — unit is last char, not whitespace, so this is invalid unit ' '.
|
||||
// Trailing whitespace changes the unit suffix to space, so this is invalid.
|
||||
{"7d ", true},
|
||||
// Very large count that still parses — 9999d is valid Go duration arithmetic.
|
||||
{"9999d", false},
|
||||
// Mixed case unit — 'D' is not 'd', so unknown unit.
|
||||
{"7D", true},
|
||||
// Float value — Sscanf %d won't accept "7.5", so invalid numeric.
|
||||
// Float value — strconv.Atoi rejects "7.5" as non-integer text.
|
||||
{"7.5d", true},
|
||||
// Just the unit char, no number — too short (len < 2 for "d"; "hd" has no valid number).
|
||||
{"hd", true},
|
||||
|
|
|
|||
|
|
@ -4,30 +4,32 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addAlertsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "alerts",
|
||||
Short: i18n.T("cmd.security.alerts.short"),
|
||||
Long: i18n.T("cmd.security.alerts.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runAlerts()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
parent.AddCommand(command)
|
||||
}
|
||||
|
||||
// AlertOutput is the unified alert shape written to JSON or the terminal.
|
||||
// AlertOutput is the unified alert row used by the security commands.
|
||||
//
|
||||
// AlertOutput{Repo: "core-php", Severity: "high", Type: "dependabot"}
|
||||
type AlertOutput struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
|
|
@ -48,12 +50,12 @@ func runAlerts() error {
|
|||
return runAlertsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
registry, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
repoList := getReposToCheck(registry, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
|
@ -62,11 +64,11 @@ func runAlerts() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
depAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
for _, alert := range dependabotAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -87,9 +89,9 @@ func runAlerts() error {
|
|||
}
|
||||
}
|
||||
|
||||
codeAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
for _, alert := range codeScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -110,9 +112,9 @@ func runAlerts() error {
|
|||
}
|
||||
}
|
||||
|
||||
secretAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
for _, alert := range secretScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -149,7 +151,7 @@ func runAlerts() error {
|
|||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
|
||||
location := alert.Package
|
||||
if location == "" {
|
||||
|
|
@ -161,7 +163,7 @@ func runAlerts() error {
|
|||
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
|
|
@ -184,9 +186,9 @@ func runAlertsForTarget(target string) error {
|
|||
var allAlerts []AlertOutput
|
||||
summary := &AlertSummary{}
|
||||
|
||||
depAlerts, err := fetchDependabotAlerts(fullName)
|
||||
dependabotAlerts, err := fetchDependabotAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
for _, alert := range dependabotAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -207,9 +209,9 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
codeAlerts, err := fetchCodeScanningAlerts(fullName)
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
for _, alert := range codeScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -230,9 +232,9 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
secretAlerts, err := fetchSecretScanningAlerts(fullName)
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
for _, alert := range secretScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -268,7 +270,7 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
location := alert.Package
|
||||
if location == "" {
|
||||
location = alert.Location
|
||||
|
|
@ -278,7 +280,7 @@ func runAlertsForTarget(target string) error {
|
|||
}
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
|
|
|
|||
|
|
@ -4,30 +4,32 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addDepsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "deps",
|
||||
Short: i18n.T("cmd.security.deps.short"),
|
||||
Long: i18n.T("cmd.security.deps.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runDeps()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
parent.AddCommand(command)
|
||||
}
|
||||
|
||||
// DepAlert is a Dependabot dependency vulnerability entry written to JSON or the terminal.
|
||||
// DepAlert is the Dependabot row used by `core security deps`.
|
||||
//
|
||||
// DepAlert{Repo: "core-php", CVE: "CVE-2026-1234", Vulnerable: ">=1.2 <1.3"}
|
||||
type DepAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
|
|
@ -49,12 +51,12 @@ func runDeps() error {
|
|||
return runDepsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
registry, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
repoList := getReposToCheck(registry, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
|
@ -63,15 +65,15 @@ func runDeps() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchDependabotAlerts(repoFullName)
|
||||
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range dependabotAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -83,7 +85,7 @@ func runDeps() error {
|
|||
|
||||
summary.Add(severity)
|
||||
|
||||
depAlert := DepAlert{
|
||||
dependabotAlert := DepAlert{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
CVE: alert.Advisory.CVEID,
|
||||
|
|
@ -94,7 +96,7 @@ func runDeps() error {
|
|||
Manifest: alert.Dependency.ManifestPath,
|
||||
Summary: alert.Advisory.Summary,
|
||||
}
|
||||
allAlerts = append(allAlerts, depAlert)
|
||||
allAlerts = append(allAlerts, dependabotAlert)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +118,7 @@ func runDeps() error {
|
|||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
|
|
@ -125,7 +127,7 @@ func runDeps() error {
|
|||
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
|
|
@ -148,12 +150,12 @@ func runDepsForTarget(target string) error {
|
|||
var allAlerts []DepAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
alerts, err := fetchDependabotAlerts(fullName)
|
||||
dependabotAlerts, err := fetchDependabotAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch dependabot alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range dependabotAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -189,14 +191,14 @@ func runDepsForTarget(target string) error {
|
|||
cli.Blank()
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
}
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"dappco.re/go/core/i18n"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -20,21 +20,21 @@ var (
|
|||
)
|
||||
|
||||
func addJobsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "jobs",
|
||||
Short: i18n.T("cmd.security.jobs.short"),
|
||||
Long: i18n.T("cmd.security.jobs.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runJobs()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&jobsTargets, "targets", nil, i18n.T("cmd.security.jobs.flag.targets"))
|
||||
cmd.Flags().StringVar(&jobsIssueRepo, "issue-repo", "host-uk/core", i18n.T("cmd.security.jobs.flag.issue_repo"))
|
||||
cmd.Flags().BoolVar(&jobsDryRun, "dry-run", false, i18n.T("cmd.security.jobs.flag.dry_run"))
|
||||
cmd.Flags().IntVar(&jobsCopies, "copies", 1, i18n.T("cmd.security.jobs.flag.copies"))
|
||||
command.Flags().StringSliceVar(&jobsTargets, "targets", nil, i18n.T("cmd.security.jobs.flag.targets"))
|
||||
command.Flags().StringVar(&jobsIssueRepo, "issue-repo", "host-uk/core", i18n.T("cmd.security.jobs.flag.issue_repo"))
|
||||
command.Flags().BoolVar(&jobsDryRun, "dry-run", false, i18n.T("cmd.security.jobs.flag.dry_run"))
|
||||
command.Flags().IntVar(&jobsCopies, "copies", 1, i18n.T("cmd.security.jobs.flag.copies"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
parent.AddCommand(command)
|
||||
}
|
||||
|
||||
func runJobs() error {
|
||||
|
|
@ -70,8 +70,8 @@ func runJobs() error {
|
|||
//
|
||||
// createJobForTarget("wailsapp/wails")
|
||||
func createJobForTarget(target string) error {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
targetParts := strings.SplitN(target, "/", 2)
|
||||
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
|
||||
return coreerr.E("security.createJobForTarget", "invalid target format: use owner/repo", nil)
|
||||
}
|
||||
|
||||
|
|
@ -79,13 +79,13 @@ func createJobForTarget(target string) error {
|
|||
var findings []string
|
||||
var fetchErrors int
|
||||
|
||||
codeAlerts, err := fetchCodeScanningAlerts(target)
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch code scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
for _, alert := range codeScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -100,13 +100,13 @@ func createJobForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
depAlerts, err := fetchDependabotAlerts(target)
|
||||
dependabotAlerts, err := fetchDependabotAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch dependabot alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
for _, alert := range dependabotAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -117,13 +117,13 @@ func createJobForTarget(target string) error {
|
|||
}
|
||||
}
|
||||
|
||||
secretAlerts, err := fetchSecretScanningAlerts(target)
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch secret scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
for _, alert := range secretScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -142,12 +142,12 @@ func createJobForTarget(target string) error {
|
|||
}
|
||||
|
||||
title := fmt.Sprintf("Security scan: %s", target)
|
||||
body := buildJobIssueBody(target, summary, findings)
|
||||
issueBody := buildJobIssueBody(target, summary, findings)
|
||||
|
||||
for i := range jobsCopies {
|
||||
for copyIndex := range jobsCopies {
|
||||
issueTitle := title
|
||||
if jobsCopies > 1 {
|
||||
issueTitle = fmt.Sprintf("%s (#%d)", title, i+1)
|
||||
issueTitle = fmt.Sprintf("%s (#%d)", title, copyIndex+1)
|
||||
}
|
||||
|
||||
if jobsDryRun {
|
||||
|
|
@ -159,14 +159,14 @@ func createJobForTarget(target string) error {
|
|||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", "issue", "create",
|
||||
issueCommand := exec.Command("gh", "issue", "create",
|
||||
"--repo", jobsIssueRepo,
|
||||
"--title", issueTitle,
|
||||
"--body", body,
|
||||
"--body", issueBody,
|
||||
"--label", "type:security-scan,repo:"+target,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := issueCommand.CombinedOutput()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, fmt.Sprintf("create issue for %s: %s", target, string(output)))
|
||||
}
|
||||
|
|
@ -195,34 +195,34 @@ func createJobForTarget(target string) error {
|
|||
//
|
||||
// body := buildJobIssueBody("wailsapp/wails", summary, findings)
|
||||
func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string {
|
||||
var builder strings.Builder
|
||||
var issueBodyBuilder strings.Builder
|
||||
|
||||
fmt.Fprintf(&builder, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&builder, "**Summary:** %s\n\n", summary.String())
|
||||
fmt.Fprintf(&issueBodyBuilder, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&issueBodyBuilder, "**Summary:** %s\n\n", summary.String())
|
||||
|
||||
builder.WriteString("### Findings\n\n")
|
||||
issueBodyBuilder.WriteString("### Findings\n\n")
|
||||
if len(findings) > 50 {
|
||||
for _, finding := range findings[:50] {
|
||||
builder.WriteString(finding + "\n")
|
||||
issueBodyBuilder.WriteString(finding + "\n")
|
||||
}
|
||||
fmt.Fprintf(&builder, "\n... and %d more\n", len(findings)-50)
|
||||
fmt.Fprintf(&issueBodyBuilder, "\n... and %d more\n", len(findings)-50)
|
||||
} else {
|
||||
for _, finding := range findings {
|
||||
builder.WriteString(finding + "\n")
|
||||
issueBodyBuilder.WriteString(finding + "\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")
|
||||
issueBodyBuilder.WriteString("\n### Checklist\n\n")
|
||||
issueBodyBuilder.WriteString("- [ ] Review findings above\n")
|
||||
issueBodyBuilder.WriteString("- [ ] Triage by severity (critical/high first)\n")
|
||||
issueBodyBuilder.WriteString("- [ ] Create PRs for fixes\n")
|
||||
issueBodyBuilder.WriteString("- [ ] Verify fixes resolve alerts\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")
|
||||
issueBodyBuilder.WriteString("\n### Instructions\n\n")
|
||||
issueBodyBuilder.WriteString("1. Claim this issue by assigning yourself\n")
|
||||
fmt.Fprintf(&issueBodyBuilder, "2. Run `core security alerts --target %s` for the latest findings\n", target)
|
||||
issueBodyBuilder.WriteString("3. Work through the checklist above\n")
|
||||
issueBodyBuilder.WriteString("4. Close this issue when all findings are addressed\n")
|
||||
|
||||
return builder.String()
|
||||
return issueBodyBuilder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"time"
|
||||
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -15,26 +15,28 @@ var (
|
|||
)
|
||||
|
||||
func addScanCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "scan",
|
||||
Short: i18n.T("cmd.security.scan.short"),
|
||||
Long: i18n.T("cmd.security.scan.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runScan()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
command.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool"))
|
||||
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
parent.AddCommand(command)
|
||||
}
|
||||
|
||||
// ScanAlert is a code scanning alert entry written to JSON or the terminal.
|
||||
// ScanAlert is the code-scanning row used by `core security scan`.
|
||||
//
|
||||
// ScanAlert{Repo: "core-php", RuleID: "go/unused", Path: "main.go", Line: 42}
|
||||
type ScanAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
|
|
@ -55,12 +57,12 @@ func runScan() error {
|
|||
return runScanForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
registry, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
repoList := getReposToCheck(registry, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
|
@ -69,15 +71,15 @@ func runScan() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range codeScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -141,13 +143,13 @@ func runScan() error {
|
|||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
|
|
@ -170,12 +172,12 @@ func runScanForTarget(target string) error {
|
|||
var allAlerts []ScanAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
alerts, err := fetchCodeScanningAlerts(fullName)
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch code-scanning alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range codeScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -234,11 +236,11 @@ func runScanForTarget(target string) error {
|
|||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
|
|
|
|||
|
|
@ -4,29 +4,31 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addSecretsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "secrets",
|
||||
Short: i18n.T("cmd.security.secrets.short"),
|
||||
Long: i18n.T("cmd.security.secrets.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
RunE: func(_ *cli.Command, _ []string) error {
|
||||
return runSecrets()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
parent.AddCommand(command)
|
||||
}
|
||||
|
||||
// SecretAlert is a secret scanning alert entry written to JSON or the terminal.
|
||||
// SecretAlert is the secret-scanning row used by `core security secrets`.
|
||||
//
|
||||
// SecretAlert{Repo: "core-php", Number: 42, SecretType: "github_token"}
|
||||
type SecretAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
|
|
@ -45,12 +47,12 @@ func runSecrets() error {
|
|||
return runSecretsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
registry, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
repoList := getReposToCheck(registry, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
|
@ -59,19 +61,18 @@ func runSecrets() error {
|
|||
openCount := 0
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range secretScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
openCount++
|
||||
|
||||
secretAlert := SecretAlert{
|
||||
Repo: repo.Name,
|
||||
Number: alert.Number,
|
||||
|
|
@ -135,12 +136,12 @@ func runSecretsForTarget(target string) error {
|
|||
var allAlerts []SecretAlert
|
||||
openCount := 0
|
||||
|
||||
alerts, err := fetchSecretScanningAlerts(fullName)
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch secret-scanning alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
for _, alert := range secretScanningAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import (
|
|||
"slices"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/i18n"
|
||||
"dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core/scm/repos"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -25,22 +25,24 @@ var (
|
|||
//
|
||||
// security.AddSecurityCommands(rootCmd) // → core security alerts|deps|scan|secrets|jobs
|
||||
func AddSecurityCommands(root *cli.Command) {
|
||||
secCmd := &cli.Command{
|
||||
command := &cli.Command{
|
||||
Use: "security",
|
||||
Short: i18n.T("cmd.security.short"),
|
||||
Long: i18n.T("cmd.security.long"),
|
||||
}
|
||||
|
||||
addAlertsCommand(secCmd)
|
||||
addDepsCommand(secCmd)
|
||||
addScanCommand(secCmd)
|
||||
addSecretsCommand(secCmd)
|
||||
addJobsCommand(secCmd)
|
||||
addAlertsCommand(command)
|
||||
addDepsCommand(command)
|
||||
addScanCommand(command)
|
||||
addSecretsCommand(command)
|
||||
addJobsCommand(command)
|
||||
|
||||
root.AddCommand(secCmd)
|
||||
root.AddCommand(command)
|
||||
}
|
||||
|
||||
// DependabotAlert is a GitHub Dependabot vulnerability alert (from repos/{org}/{repo}/dependabot/alerts).
|
||||
// DependabotAlert maps one response item from GitHub's dependabot alerts API.
|
||||
//
|
||||
// alerts, _ := fetchDependabotAlerts("host-uk/core-php")
|
||||
type DependabotAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -69,7 +71,9 @@ type DependabotAlert struct {
|
|||
} `json:"security_vulnerability"`
|
||||
}
|
||||
|
||||
// CodeScanningAlert is a GitHub code scanning alert (from repos/{org}/{repo}/code-scanning/alerts).
|
||||
// CodeScanningAlert maps one response item from GitHub's code-scanning alerts API.
|
||||
//
|
||||
// alerts, _ := fetchCodeScanningAlerts("host-uk/core-php")
|
||||
type CodeScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -96,7 +100,9 @@ type CodeScanningAlert struct {
|
|||
} `json:"most_recent_instance"`
|
||||
}
|
||||
|
||||
// SecretScanningAlert is a GitHub secret scanning alert (from repos/{org}/{repo}/secret-scanning/alerts).
|
||||
// SecretScanningAlert maps one response item from GitHub's secret-scanning alerts API.
|
||||
//
|
||||
// alerts, _ := fetchSecretScanningAlerts("host-uk/core-php")
|
||||
type SecretScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
|
|
@ -112,22 +118,22 @@ type SecretScanningAlert struct {
|
|||
// loadRegistry("/path/to/repos.yaml")
|
||||
func loadRegistry(registryPath string) (*repos.Registry, error) {
|
||||
if registryPath != "" {
|
||||
reg, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
registry, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "load registry")
|
||||
}
|
||||
return reg, nil
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
path, err := repos.FindRegistry(io.Local)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "find registry")
|
||||
}
|
||||
reg, err := repos.LoadRegistry(io.Local, path)
|
||||
registry, err := repos.LoadRegistry(io.Local, path)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "load registry")
|
||||
}
|
||||
return reg, nil
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// checkGH returns an error if the gh CLI is not on PATH.
|
||||
|
|
@ -144,8 +150,8 @@ func checkGH() error {
|
|||
//
|
||||
// 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()
|
||||
ghCommand := exec.Command("gh", "api", endpoint, "--paginate")
|
||||
output, err := ghCommand.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
|
|
@ -196,34 +202,34 @@ func filterBySeverity(severity, filter string) bool {
|
|||
|
||||
// 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 {
|
||||
// getReposToCheck(registry, "core-php") // → [core-php]
|
||||
// getReposToCheck(registry, "") // → all repos
|
||||
func getReposToCheck(registry *repos.Registry, repoFilter string) []*repos.Repo {
|
||||
if repoFilter != "" {
|
||||
if repo, ok := reg.Get(repoFilter); ok {
|
||||
return []*repos.Repo{repo}
|
||||
if matchedRepo, ok := registry.Get(repoFilter); ok {
|
||||
return []*repos.Repo{matchedRepo}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return reg.List()
|
||||
return registry.List()
|
||||
}
|
||||
|
||||
// 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] == "" {
|
||||
targetParts := strings.SplitN(target, "/", 2)
|
||||
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
|
||||
return nil, ""
|
||||
}
|
||||
return &repos.Repo{Name: parts[1]}, target
|
||||
return &repos.Repo{Name: targetParts[1]}, target
|
||||
}
|
||||
|
||||
// AlertSummary tracks alert counts by severity across a scan run.
|
||||
//
|
||||
// s := &AlertSummary{}
|
||||
// s.Add("critical")
|
||||
// s.String() // → "1 critical"
|
||||
// summary := &AlertSummary{}
|
||||
// summary.Add("critical")
|
||||
// summary.String() // → "1 critical"
|
||||
type AlertSummary struct {
|
||||
Critical int
|
||||
High int
|
||||
|
|
@ -235,45 +241,45 @@ type AlertSummary struct {
|
|||
|
||||
// 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++
|
||||
// summary.Add("critical") // summary.Critical == 1, summary.Total == 1
|
||||
func (summary *AlertSummary) Add(severity string) {
|
||||
summary.Total++
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
s.Critical++
|
||||
summary.Critical++
|
||||
case "high":
|
||||
s.High++
|
||||
summary.High++
|
||||
case "medium":
|
||||
s.Medium++
|
||||
summary.Medium++
|
||||
case "low":
|
||||
s.Low++
|
||||
summary.Low++
|
||||
default:
|
||||
s.Unknown++
|
||||
summary.Unknown++
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
parts = append(parts, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", s.Critical)))
|
||||
func (summary *AlertSummary) String() string {
|
||||
segments := []string{}
|
||||
if summary.Critical > 0 {
|
||||
segments = append(segments, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", summary.Critical)))
|
||||
}
|
||||
if s.High > 0 {
|
||||
parts = append(parts, cli.WarningStyle.Render(fmt.Sprintf("%d high", s.High)))
|
||||
if summary.High > 0 {
|
||||
segments = append(segments, cli.WarningStyle.Render(fmt.Sprintf("%d high", summary.High)))
|
||||
}
|
||||
if s.Medium > 0 {
|
||||
parts = append(parts, cli.ValueStyle.Render(fmt.Sprintf("%d medium", s.Medium)))
|
||||
if summary.Medium > 0 {
|
||||
segments = append(segments, cli.ValueStyle.Render(fmt.Sprintf("%d medium", summary.Medium)))
|
||||
}
|
||||
if s.Low > 0 {
|
||||
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d low", s.Low)))
|
||||
if summary.Low > 0 {
|
||||
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d low", summary.Low)))
|
||||
}
|
||||
if s.Unknown > 0 {
|
||||
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d unknown", s.Unknown)))
|
||||
if summary.Unknown > 0 {
|
||||
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d unknown", summary.Unknown)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
if len(segments) == 0 {
|
||||
return cli.SuccessStyle.Render("No alerts")
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
return strings.Join(segments, " | ")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue