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
570 lines
14 KiB
Go
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
|
|
}
|
|
}
|