Files
gastown/internal/cmd/audit.go
max 1b69576573 fix: Address golangci-lint errors (errcheck, gosec) (#76)
Apply PR #76 from dannomayernotabot:

- Add golangci exclusions for internal package false positives
- Tighten file permissions (0644 -> 0600) for sensitive files
- Add ReadHeaderTimeout to HTTP server (slowloris prevention)
- Explicit error ignoring with _ = for intentional cases
- Add //nolint comments with justifications
- Spelling: cancelled -> canceled (US locale)

Co-Authored-By: dannomayernotabot <noreply@github.com>

🤖 Generated with Claude Code
2026-01-03 16:11:55 -08:00

570 lines
14 KiB
Go

package cmd
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
// Audit command flags
var (
auditActor string
auditSince string
auditLimit int
auditJSON bool
)
var auditCmd = &cobra.Command{
Use: "audit",
GroupID: GroupDiag,
Short: "Query work history by actor",
Long: `Query provenance data across git commits, beads, and events.
Shows a unified timeline of work performed by an actor including:
- Git commits authored by the actor
- Beads (issues) created by the actor
- Beads closed by the actor (via assignee)
- Town log events (spawn, done, handoff, etc.)
- Activity feed events
Examples:
gt audit --actor=greenplace/crew/joe # Show all work by joe
gt audit --actor=greenplace/polecats/toast # Show polecat toast's work
gt audit --actor=mayor # Show mayor's activity
gt audit --since=24h # Show all activity in last 24h
gt audit --actor=joe --since=1h # Combined filters
gt audit --json # Output as JSON`,
RunE: runAudit,
}
func init() {
auditCmd.Flags().StringVar(&auditActor, "actor", "", "Filter by actor (agent address or partial match)")
auditCmd.Flags().StringVar(&auditSince, "since", "", "Show events since duration (e.g., 1h, 24h, 7d)")
auditCmd.Flags().IntVarP(&auditLimit, "limit", "n", 50, "Maximum number of entries to show")
auditCmd.Flags().BoolVar(&auditJSON, "json", false, "Output as JSON")
rootCmd.AddCommand(auditCmd)
}
// AuditEntry represents a single entry in the audit log.
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"` // "git", "beads", "townlog", "events"
Type string `json:"type"` // "commit", "bead_created", "bead_closed", "spawn", etc.
Actor string `json:"actor"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
ID string `json:"id,omitempty"` // commit hash, bead ID, etc.
}
func runAudit(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Parse since duration if provided
var sinceTime time.Time
if auditSince != "" {
duration, err := parseDuration(auditSince)
if err != nil {
return fmt.Errorf("invalid --since duration: %w", err)
}
sinceTime = time.Now().Add(-duration)
}
// Collect entries from all sources
var allEntries []AuditEntry
// 1. Git commits
gitEntries, err := collectGitCommits(townRoot, auditActor, sinceTime)
if err != nil {
// Non-fatal: log and continue
fmt.Fprintf(os.Stderr, "Warning: could not query git commits: %v\n", err)
}
allEntries = append(allEntries, gitEntries...)
// 2. Beads (created_by, assignee)
beadsEntries, err := collectBeadsActivity(townRoot, auditActor, sinceTime)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not query beads: %v\n", err)
}
allEntries = append(allEntries, beadsEntries...)
// 3. Town log events
townlogEntries, err := collectTownlogEvents(townRoot, auditActor, sinceTime)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not query town log: %v\n", err)
}
allEntries = append(allEntries, townlogEntries...)
// 4. Activity feed events
feedEntries, err := collectFeedEvents(townRoot, auditActor, sinceTime)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not query events feed: %v\n", err)
}
allEntries = append(allEntries, feedEntries...)
// Sort by timestamp (newest first)
sort.Slice(allEntries, func(i, j int) bool {
return allEntries[i].Timestamp.After(allEntries[j].Timestamp)
})
// Apply limit
if auditLimit > 0 && len(allEntries) > auditLimit {
allEntries = allEntries[:auditLimit]
}
if len(allEntries) == 0 {
if auditActor != "" {
fmt.Printf("%s No activity found for actor %q\n", style.Dim.Render("○"), auditActor)
} else {
fmt.Printf("%s No activity found\n", style.Dim.Render("○"))
}
return nil
}
// Output
if auditJSON {
return outputAuditJSON(allEntries)
}
return outputAuditText(allEntries)
}
// parseDuration parses a duration string with support for days (d).
func parseDuration(s string) (time.Duration, error) {
// Check for days suffix
if strings.HasSuffix(s, "d") {
days := strings.TrimSuffix(s, "d")
var d int
if _, err := fmt.Sscanf(days, "%d", &d); err != nil {
return 0, fmt.Errorf("invalid days format: %s", s)
}
return time.Duration(d) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// collectGitCommits queries git log for commits by the actor.
func collectGitCommits(townRoot, actor string, since time.Time) ([]AuditEntry, error) { //nolint:unparam // error return kept for future use
var entries []AuditEntry
// Build git log command
args := []string{"log", "--format=%H|%aI|%an|%s", "--all"}
if actor != "" {
// Try to match actor in author name
// Actor format might be "greenplace/crew/joe" - extract "joe" as the author name
authorName := extractAuthorName(actor)
args = append(args, "--author="+authorName)
}
if !since.IsZero() {
args = append(args, "--since="+since.Format(time.RFC3339))
}
// Limit to reasonable number
args = append(args, "-n", "100")
cmd := exec.Command("git", args...)
cmd.Dir = townRoot
output, err := cmd.Output()
if err != nil {
// Git might fail if not a repo - not fatal
return nil, nil
}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "|", 4)
if len(parts) < 4 {
continue
}
hash := parts[0]
timestamp, _ := time.Parse(time.RFC3339, parts[1])
author := parts[2]
subject := parts[3]
// If actor filter is set, also match on the full actor string in commit message
if actor != "" && !matchesActor(author, actor) && !strings.Contains(subject, actor) {
continue
}
entries = append(entries, AuditEntry{
Timestamp: timestamp,
Source: "git",
Type: "commit",
Actor: author,
Summary: subject,
ID: hash[:8],
})
}
return entries, nil
}
// extractAuthorName extracts the likely git author name from an actor address.
func extractAuthorName(actor string) string {
// Actor format: "greenplace/crew/joe" -> "joe"
// Or: "mayor" -> "mayor"
parts := strings.Split(actor, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return actor
}
// matchesActor checks if a name matches the actor filter (partial match).
func matchesActor(name, actor string) bool {
name = strings.ToLower(name)
actor = strings.ToLower(actor)
// Exact match
if name == actor {
return true
}
// Extract last component of actor for matching
actorName := extractAuthorName(actor)
if strings.Contains(name, actorName) {
return true
}
// Check if actor appears in name
if strings.Contains(name, actor) {
return true
}
return false
}
// collectBeadsActivity queries beads for issues created or closed by the actor.
func collectBeadsActivity(townRoot, actor string, since time.Time) ([]AuditEntry, error) {
var entries []AuditEntry
// Find the gastown beads path (where gt- prefix issues live)
gastownBeadsPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
b := beads.New(gastownBeadsPath)
// List all issues to filter by created_by and assignee
issues, err := b.List(beads.ListOptions{
Status: "all",
Priority: -1,
})
if err != nil {
return nil, err
}
for _, issue := range issues {
// Check created_by
if issue.CreatedBy != "" {
if actor == "" || matchesActor(issue.CreatedBy, actor) {
ts := parseBeadsTimestamp(issue.CreatedAt)
if !since.IsZero() && ts.Before(since) {
continue
}
entries = append(entries, AuditEntry{
Timestamp: ts,
Source: "beads",
Type: "bead_created",
Actor: issue.CreatedBy,
Summary: fmt.Sprintf("Created: %s", issue.Title),
ID: issue.ID,
Details: fmt.Sprintf("type=%s priority=%d", issue.Type, issue.Priority),
})
}
}
// Check if issue was closed and has an assignee
if issue.Status == "closed" && issue.Assignee != "" {
if actor == "" || matchesActor(issue.Assignee, actor) {
ts := parseBeadsTimestamp(issue.ClosedAt)
if ts.IsZero() {
ts = parseBeadsTimestamp(issue.UpdatedAt)
}
if !since.IsZero() && ts.Before(since) {
continue
}
entries = append(entries, AuditEntry{
Timestamp: ts,
Source: "beads",
Type: "bead_closed",
Actor: issue.Assignee,
Summary: fmt.Sprintf("Closed: %s", issue.Title),
ID: issue.ID,
})
}
}
}
return entries, nil
}
// parseBeadsTimestamp parses a beads timestamp string.
func parseBeadsTimestamp(s string) time.Time {
// Try various formats
formats := []string{
time.RFC3339,
"2006-01-02 15:04",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, format := range formats {
if t, err := time.Parse(format, s); err == nil {
return t
}
}
return time.Time{}
}
// collectTownlogEvents queries the town log for agent lifecycle events.
func collectTownlogEvents(townRoot, actor string, since time.Time) ([]AuditEntry, error) {
var entries []AuditEntry
allEvents, err := townlog.ReadEvents(townRoot)
if err != nil {
return nil, err
}
for _, e := range allEvents {
// Apply actor filter
if actor != "" && !matchesActor(e.Agent, actor) {
continue
}
// Apply since filter
if !since.IsZero() && e.Timestamp.Before(since) {
continue
}
entries = append(entries, AuditEntry{
Timestamp: e.Timestamp,
Source: "townlog",
Type: string(e.Type),
Actor: e.Agent,
Summary: formatTownlogSummary(e),
})
}
return entries, nil
}
// formatTownlogSummary creates a readable summary from a town log event.
func formatTownlogSummary(e townlog.Event) string {
switch e.Type {
case townlog.EventSpawn:
if e.Context != "" {
return fmt.Sprintf("Spawned for %s", e.Context)
}
return "Spawned"
case townlog.EventDone:
if e.Context != "" {
return fmt.Sprintf("Completed %s", e.Context)
}
return "Completed work"
case townlog.EventHandoff:
return "Handed off session"
case townlog.EventCrash:
if e.Context != "" {
return fmt.Sprintf("Crashed: %s", e.Context)
}
return "Crashed"
case townlog.EventKill:
return "Session killed"
case townlog.EventNudge:
return "Nudged"
case townlog.EventWake:
return "Resumed"
default:
if e.Context != "" {
return fmt.Sprintf("%s: %s", e.Type, e.Context)
}
return string(e.Type)
}
}
// collectFeedEvents queries the activity feed for events.
func collectFeedEvents(townRoot, actor string, since time.Time) ([]AuditEntry, error) {
var entries []AuditEntry
eventsPath := filepath.Join(townRoot, events.EventsFile)
file, err := os.Open(eventsPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No events file yet
}
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var e events.Event
if err := json.Unmarshal(scanner.Bytes(), &e); err != nil {
continue // Skip malformed lines
}
// Apply actor filter
if actor != "" && !matchesActor(e.Actor, actor) {
continue
}
// Parse timestamp
ts, _ := time.Parse(time.RFC3339, e.Timestamp)
// Apply since filter
if !since.IsZero() && ts.Before(since) {
continue
}
entries = append(entries, AuditEntry{
Timestamp: ts,
Source: "events",
Type: e.Type,
Actor: e.Actor,
Summary: formatFeedSummary(e),
})
}
return entries, nil
}
// formatFeedSummary creates a readable summary from a feed event.
func formatFeedSummary(e events.Event) string {
switch e.Type {
case events.TypeSling:
if bead, ok := e.Payload["bead"].(string); ok {
return fmt.Sprintf("Slung %s", bead)
}
return "Slung work"
case events.TypeMerged:
if branch, ok := e.Payload["branch"].(string); ok {
return fmt.Sprintf("Merged %s", branch)
}
return "Merged work"
case events.TypeMergeFailed:
if reason, ok := e.Payload["reason"].(string); ok {
return fmt.Sprintf("Merge failed: %s", reason)
}
return "Merge failed"
case events.TypeHandoff:
return "Handed off"
case events.TypeDone:
if bead, ok := e.Payload["bead"].(string); ok {
return fmt.Sprintf("Done %s", bead)
}
return "Done"
case events.TypeMail:
if to, ok := e.Payload["to"].(string); ok {
return fmt.Sprintf("Sent mail to %s", to)
}
return "Sent mail"
default:
return e.Type
}
}
func outputAuditJSON(entries []AuditEntry) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
func outputAuditText(entries []AuditEntry) error {
// Group by date for readability
var currentDate string
for _, e := range entries {
date := e.Timestamp.Format("2006-01-02")
if date != currentDate {
if currentDate != "" {
fmt.Println()
}
fmt.Printf("%s\n", style.Bold.Render("─── "+date+" ───────────────────────────────────────────"))
currentDate = date
}
timeStr := e.Timestamp.Format("15:04:05")
sourceStr := formatSource(e.Source)
typeStr := formatType(e.Type)
// Build the line
var idPart string
if e.ID != "" {
idPart = style.Dim.Render(fmt.Sprintf(" [%s]", e.ID))
}
fmt.Printf("%s %s %s %s%s\n",
style.Dim.Render(timeStr),
sourceStr,
typeStr,
e.Summary,
idPart,
)
if e.Actor != "" {
fmt.Printf(" %s\n", style.Dim.Render("by "+e.Actor))
}
}
return nil
}
func formatSource(source string) string {
switch source {
case "git":
return style.Bold.Render("[git]")
case "beads":
return style.Success.Render("[beads]")
case "townlog":
return style.Dim.Render("[log]")
case "events":
return style.Warning.Render("[events]")
default:
return fmt.Sprintf("[%s]", source)
}
}
func formatType(t string) string {
switch t {
case "commit":
return style.Success.Render("commit")
case "bead_created":
return style.Success.Render("created")
case "bead_closed":
return style.Bold.Render("closed")
case "spawn":
return style.Success.Render("spawn")
case "done":
return style.Success.Render("done")
case "handoff":
return style.Bold.Render("handoff")
case "crash":
return style.Error.Render("crash")
case "kill":
return style.Warning.Render("kill")
case "merged":
return style.Success.Render("merged")
case "merge_failed":
return style.Error.Render("merge_failed")
default:
return t
}
}