Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 105 additions & 24 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,36 @@ do not write a commit attestation or offer to commit/push.`

// Handlers contains injected command actions so CLI wiring can live outside main.
type Handlers struct {
RunReviewSimple cli.ActionFunc
RunReviewDebug cli.ActionFunc
RunEnsure cli.ActionFunc
RunUninstall cli.ActionFunc
RunHooksInstall cli.ActionFunc
RunHooksUninstall cli.ActionFunc
RunHooksEnable cli.ActionFunc
RunHooksDisable cli.ActionFunc
RunHooksStatus cli.ActionFunc
RunSelfUpdate cli.ActionFunc
RunReviewCleanup cli.ActionFunc
RunAttestationTrailer cli.ActionFunc
RunSetup cli.ActionFunc
RunUI cli.ActionFunc
RunUsageInspect cli.ActionFunc
RunInternalClaudePreToolUse cli.ActionFunc
RunInternalClaudeRunCommit cli.ActionFunc
RunInternalClaudeSetupStart cli.ActionFunc
RunInternalClaudeSetupWorker cli.ActionFunc
RunReviewSimple cli.ActionFunc
RunReviewDebug cli.ActionFunc
RunEnsure cli.ActionFunc
RunUninstall cli.ActionFunc
RunHooksInstall cli.ActionFunc
RunHooksUninstall cli.ActionFunc
RunHooksEnable cli.ActionFunc
RunHooksDisable cli.ActionFunc
RunHooksStatus cli.ActionFunc
RunSelfUpdate cli.ActionFunc
RunReviewCleanup cli.ActionFunc
RunAttestationTrailer cli.ActionFunc
RunSetup cli.ActionFunc
RunUI cli.ActionFunc
RunUsageInspect cli.ActionFunc
RunInternalClaudePreToolUse cli.ActionFunc
RunInternalClaudeRunCommit cli.ActionFunc
RunInternalClaudeSetupStart cli.ActionFunc
RunInternalClaudeSetupWorker cli.ActionFunc
RunInternalClaudeSetupSubmitKey cli.ActionFunc
RunInternalClaudeSetupStatus cli.ActionFunc
RunRemoveAttestation cli.ActionFunc
RunConfigInit cli.ActionFunc
RunConfigCheck cli.ActionFunc
RunConfigPreview cli.ActionFunc
RunInternalClaudeSetupStatus cli.ActionFunc
RunRemoveAttestation cli.ActionFunc
RunConfigInit cli.ActionFunc
RunConfigCheck cli.ActionFunc
RunConfigPreview cli.ActionFunc
RunQuery cli.ActionFunc
RunQueryAdd cli.ActionFunc
RunQueryList cli.ActionFunc
RunQueryView cli.ActionFunc
RunQueryDelete cli.ActionFunc
}

// BuildApp constructs the full CLI app with all command wiring.
Expand Down Expand Up @@ -347,6 +352,82 @@ func BuildApp(version, buildTime, gitCommit, reviewMode string, baseFlags, debug
},
},
},
{
Name: "query",
Usage: "Query LiveReview history with SQL or a saved alias (e.g. 'lrc query stats')",
Description: `Builds an in-memory SQLite table of this repo's review history (parsed
from the 'LiveReview Pre-Commit Check' commit trailers) and runs SQL — or a
saved alias — against it. Output as a table or, with --json, machine-readable.

TABLE: review_log (one row per commit)
hash TEXT full commit hash
short_hash TEXT abbreviated hash
author TEXT commit author name
email TEXT commit author email
date TEXT author date, ISO-8601 (sortable, e.g. 2026-06-17T10:30:00Z)
branch TEXT branch the query ran from
subject TEXT commit subject (first line)
action TEXT 'reviewed' | 'vouched' | 'skipped' | 'none'
iterations INTEGER review iterations (0 if none)
coverage INTEGER review coverage percent 0-100 (0 if none)

ALIASES: built-in (stats, by-author, recent) plus your own. Manage them with
'lrc query add|list|view|delete'. User aliases are saved in ~/.lrc/queries.toml:

[queries]
skipped = "SELECT date, subject FROM review_log WHERE action='skipped'"
my-cov = "SELECT ROUND(AVG(coverage),1) FROM review_log WHERE action='reviewed'"

EXAMPLES
lrc query stats # run a built-in alias
lrc query stats --json # same data, as JSON
lrc query list # show all aliases + a preview
lrc query view stats # show an alias's full SQL

# Was a specific commit reviewed? (incident forensics)
lrc query "SELECT short_hash, action, iterations, coverage FROM review_log WHERE hash LIKE 'a1b2c3%'"

# Per-author review effort
lrc query "SELECT author, COUNT(*) AS commits, SUM(action='reviewed') AS reviewed FROM review_log GROUP BY author ORDER BY commits DESC"

# Save and reuse your own query
lrc query add skipped "SELECT date, subject FROM review_log WHERE action='skipped'"
lrc query skipped --json

# Bound the scan on huge repos (Linux kernel = ~1.5M commits)
lrc query stats --from "2024-01-01" --to "2024-12-31"
lrc query stats --range main...feature # just this PR's commits`,
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "output machine-readable JSON"},
&cli.StringFlag{Name: "from", Usage: "only scan commits since this git date (e.g. 2024-01-01, '2 weeks ago') — bounds large repos"},
&cli.StringFlag{Name: "to", Usage: "only scan commits until this git date"},
&cli.StringFlag{Name: "range", Usage: "only scan a ref range, e.g. main...feature (per-PR stats)"},
},
Action: h.RunQuery,
Subcommands: []*cli.Command{
{
Name: "add",
Usage: "Save a query alias: lrc query add <name> \"<sql>\"",
ArgsUsage: "<name> \"<sql>\"",
Action: h.RunQueryAdd,
},
{
Name: "list",
Usage: "List saved and built-in query aliases",
Action: h.RunQueryList,
},
{
Name: "view",
Usage: "Print the SQL behind an alias",
Action: h.RunQueryView,
},
{
Name: "delete",
Usage: "Delete a saved alias",
Action: h.RunQueryDelete,
},
},
},
{
Name: "internal",
Usage: "Internal back-office commands (not for direct use)",
Expand Down
176 changes: 176 additions & 0 deletions internal/reviewquery/aliases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package reviewquery

import (
"fmt"
"maps"
"os"
"path/filepath"
"sort"
"strconv"
"strings"

"github.com/HexmosTech/git-lrc/configpath"
"github.com/HexmosTech/git-lrc/storage"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)

// builtinAliases ship with the binary so `lrc query <name>` works even before
// the installer writes ~/.lrc/queries.toml. User-defined aliases override these.
func builtinAliases() map[string]string {
return map[string]string{
"stats": "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage),1) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC",
"by-author": "SELECT author AS Author, COUNT(*) AS Commits, SUM(action = 'reviewed') AS Reviewed FROM review_log GROUP BY author ORDER BY Commits DESC",
"recent": "SELECT short_hash AS Hash, date AS Date, action AS Action, subject AS Subject FROM review_log ORDER BY date DESC LIMIT 20",
}
}

// AliasInfo describes one alias and where it came from.
type AliasInfo struct {
Name string
SQL string
Source string // "built-in" or "user"
}

// queriesPath returns ~/.lrc/queries.toml.
func queriesPath() (string, error) {
dir, err := configpath.ResolveLRCDataDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "queries.toml"), nil
}

// loadUserAliases reads ~/.lrc/queries.toml ([queries] table). Missing file is
// not an error — it returns an empty map.
func loadUserAliases() (map[string]string, error) {
path, err := queriesPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, fmt.Errorf("failed to access user aliases file %s: %w", path, err)
}

k := koanf.New(".")
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
return nil, fmt.Errorf("failed to parse user aliases file %s: %w", path, err)
}
// A non-empty file that lacks the [queries] table entirely is malformed —
// surface that instead of silently loading zero aliases.
if len(k.Keys()) > 0 && !k.Exists("queries") {
return nil, fmt.Errorf("user aliases file %s has no [queries] table", path)
}
out := map[string]string{}
maps.Copy(out, k.StringMap("queries"))
return out, nil
}

// ResolveAlias returns the SQL for an alias name (user file wins over built-in).
func ResolveAlias(name string) (string, bool, error) {
user, err := loadUserAliases()
if err != nil {
return "", false, err
}
if sql, ok := user[name]; ok {
return sql, true, nil
}
if sql, ok := builtinAliases()[name]; ok {
return sql, true, nil
}
return "", false, nil
}

// ListAliases returns every alias (built-in + user) sorted by name; a user
// alias shadows a built-in of the same name.
func ListAliases() ([]AliasInfo, error) {
user, err := loadUserAliases()
if err != nil {
return nil, err
}
merged := map[string]AliasInfo{}
for name, sql := range builtinAliases() {
merged[name] = AliasInfo{Name: name, SQL: sql, Source: "built-in"}
}
for name, sql := range user {
merged[name] = AliasInfo{Name: name, SQL: sql, Source: "user"}
}
names := make([]string, 0, len(merged))
for n := range merged {
names = append(names, n)
}
sort.Strings(names)
out := make([]AliasInfo, 0, len(names))
for _, n := range names {
out = append(out, merged[n])
}
return out, nil
}

// AddAlias saves (or overwrites) a user alias in ~/.lrc/queries.toml.
func AddAlias(name, sql string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("alias name cannot be empty")
}
if strings.ContainsAny(name, ". \t") {
return fmt.Errorf("alias name %q may not contain spaces or dots", name)
}
if strings.TrimSpace(sql) == "" {
return fmt.Errorf("alias SQL cannot be empty")
}
if err := validateReadOnlySQL(sql); err != nil {
return fmt.Errorf("alias SQL rejected: %w", err)
}
user, err := loadUserAliases()
if err != nil {
return err
}
user[name] = sql
return writeUserAliases(user)
}

// DeleteAlias removes a user alias. Built-in aliases cannot be deleted.
func DeleteAlias(name string) error {
user, err := loadUserAliases()
if err != nil {
return err
}
if _, ok := user[name]; !ok {
if _, isBuiltin := builtinAliases()[name]; isBuiltin {
return fmt.Errorf("%q is a built-in alias and cannot be deleted", name)
}
return fmt.Errorf("no user alias named %q", name)
}
delete(user, name)
return writeUserAliases(user)
}

// writeUserAliases serializes the alias map to ~/.lrc/queries.toml atomically.
func writeUserAliases(aliases map[string]string) error {
path, err := queriesPath()
if err != nil {
return err
}

names := make([]string, 0, len(aliases))
for n := range aliases {
names = append(names, n)
}
sort.Strings(names)

var b strings.Builder
b.WriteString("# git-lrc saved queries. Managed by `lrc query --add/--delete`.\n")
b.WriteString("[queries]\n")
for _, n := range names {
b.WriteString(n)
b.WriteString(" = ")
b.WriteString(strconv.Quote(aliases[n]))
b.WriteString("\n")
}
return storage.WriteFileAtomically(path, []byte(b.String()), 0o644)
}
Loading
Loading