Skip to content

Commit 9c38736

Browse files
authored
Merge pull request #130 from iconicvenom/add-stats-command
feat: add 'lrc stats' command for review history (#60)
2 parents 301e8d1 + 94fbab1 commit 9c38736

13 files changed

Lines changed: 1250 additions & 24 deletions

File tree

cmd/app.go

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,31 +36,36 @@ do not write a commit attestation or offer to commit/push.`
3636

3737
// Handlers contains injected command actions so CLI wiring can live outside main.
3838
type Handlers struct {
39-
RunReviewSimple cli.ActionFunc
40-
RunReviewDebug cli.ActionFunc
41-
RunEnsure cli.ActionFunc
42-
RunUninstall cli.ActionFunc
43-
RunHooksInstall cli.ActionFunc
44-
RunHooksUninstall cli.ActionFunc
45-
RunHooksEnable cli.ActionFunc
46-
RunHooksDisable cli.ActionFunc
47-
RunHooksStatus cli.ActionFunc
48-
RunSelfUpdate cli.ActionFunc
49-
RunReviewCleanup cli.ActionFunc
50-
RunAttestationTrailer cli.ActionFunc
51-
RunSetup cli.ActionFunc
52-
RunUI cli.ActionFunc
53-
RunUsageInspect cli.ActionFunc
54-
RunInternalClaudePreToolUse cli.ActionFunc
55-
RunInternalClaudeRunCommit cli.ActionFunc
56-
RunInternalClaudeSetupStart cli.ActionFunc
57-
RunInternalClaudeSetupWorker cli.ActionFunc
39+
RunReviewSimple cli.ActionFunc
40+
RunReviewDebug cli.ActionFunc
41+
RunEnsure cli.ActionFunc
42+
RunUninstall cli.ActionFunc
43+
RunHooksInstall cli.ActionFunc
44+
RunHooksUninstall cli.ActionFunc
45+
RunHooksEnable cli.ActionFunc
46+
RunHooksDisable cli.ActionFunc
47+
RunHooksStatus cli.ActionFunc
48+
RunSelfUpdate cli.ActionFunc
49+
RunReviewCleanup cli.ActionFunc
50+
RunAttestationTrailer cli.ActionFunc
51+
RunSetup cli.ActionFunc
52+
RunUI cli.ActionFunc
53+
RunUsageInspect cli.ActionFunc
54+
RunInternalClaudePreToolUse cli.ActionFunc
55+
RunInternalClaudeRunCommit cli.ActionFunc
56+
RunInternalClaudeSetupStart cli.ActionFunc
57+
RunInternalClaudeSetupWorker cli.ActionFunc
5858
RunInternalClaudeSetupSubmitKey cli.ActionFunc
59-
RunInternalClaudeSetupStatus cli.ActionFunc
60-
RunRemoveAttestation cli.ActionFunc
61-
RunConfigInit cli.ActionFunc
62-
RunConfigCheck cli.ActionFunc
63-
RunConfigPreview cli.ActionFunc
59+
RunInternalClaudeSetupStatus cli.ActionFunc
60+
RunRemoveAttestation cli.ActionFunc
61+
RunConfigInit cli.ActionFunc
62+
RunConfigCheck cli.ActionFunc
63+
RunConfigPreview cli.ActionFunc
64+
RunQuery cli.ActionFunc
65+
RunQueryAdd cli.ActionFunc
66+
RunQueryList cli.ActionFunc
67+
RunQueryView cli.ActionFunc
68+
RunQueryDelete cli.ActionFunc
6469
}
6570

6671
// BuildApp constructs the full CLI app with all command wiring.
@@ -347,6 +352,82 @@ func BuildApp(version, buildTime, gitCommit, reviewMode string, baseFlags, debug
347352
},
348353
},
349354
},
355+
{
356+
Name: "query",
357+
Usage: "Query LiveReview history with SQL or a saved alias (e.g. 'lrc query stats')",
358+
Description: `Builds an in-memory SQLite table of this repo's review history (parsed
359+
from the 'LiveReview Pre-Commit Check' commit trailers) and runs SQL — or a
360+
saved alias — against it. Output as a table or, with --json, machine-readable.
361+
362+
TABLE: review_log (one row per commit)
363+
hash TEXT full commit hash
364+
short_hash TEXT abbreviated hash
365+
author TEXT commit author name
366+
email TEXT commit author email
367+
date TEXT author date, ISO-8601 (sortable, e.g. 2026-06-17T10:30:00Z)
368+
branch TEXT branch the query ran from
369+
subject TEXT commit subject (first line)
370+
action TEXT 'reviewed' | 'vouched' | 'skipped' | 'none'
371+
iterations INTEGER review iterations (0 if none)
372+
coverage INTEGER review coverage percent 0-100 (0 if none)
373+
374+
ALIASES: built-in (stats, by-author, recent) plus your own. Manage them with
375+
'lrc query add|list|view|delete'. User aliases are saved in ~/.lrc/queries.toml:
376+
377+
[queries]
378+
skipped = "SELECT date, subject FROM review_log WHERE action='skipped'"
379+
my-cov = "SELECT ROUND(AVG(coverage),1) FROM review_log WHERE action='reviewed'"
380+
381+
EXAMPLES
382+
lrc query stats # run a built-in alias
383+
lrc query stats --json # same data, as JSON
384+
lrc query list # show all aliases + a preview
385+
lrc query view stats # show an alias's full SQL
386+
387+
# Was a specific commit reviewed? (incident forensics)
388+
lrc query "SELECT short_hash, action, iterations, coverage FROM review_log WHERE hash LIKE 'a1b2c3%'"
389+
390+
# Per-author review effort
391+
lrc query "SELECT author, COUNT(*) AS commits, SUM(action='reviewed') AS reviewed FROM review_log GROUP BY author ORDER BY commits DESC"
392+
393+
# Save and reuse your own query
394+
lrc query add skipped "SELECT date, subject FROM review_log WHERE action='skipped'"
395+
lrc query skipped --json
396+
397+
# Bound the scan on huge repos (Linux kernel = ~1.5M commits)
398+
lrc query stats --from "2024-01-01" --to "2024-12-31"
399+
lrc query stats --range main...feature # just this PR's commits`,
400+
Flags: []cli.Flag{
401+
&cli.BoolFlag{Name: "json", Usage: "output machine-readable JSON"},
402+
&cli.StringFlag{Name: "from", Usage: "only scan commits since this git date (e.g. 2024-01-01, '2 weeks ago') — bounds large repos"},
403+
&cli.StringFlag{Name: "to", Usage: "only scan commits until this git date"},
404+
&cli.StringFlag{Name: "range", Usage: "only scan a ref range, e.g. main...feature (per-PR stats)"},
405+
},
406+
Action: h.RunQuery,
407+
Subcommands: []*cli.Command{
408+
{
409+
Name: "add",
410+
Usage: "Save a query alias: lrc query add <name> \"<sql>\"",
411+
ArgsUsage: "<name> \"<sql>\"",
412+
Action: h.RunQueryAdd,
413+
},
414+
{
415+
Name: "list",
416+
Usage: "List saved and built-in query aliases",
417+
Action: h.RunQueryList,
418+
},
419+
{
420+
Name: "view",
421+
Usage: "Print the SQL behind an alias",
422+
Action: h.RunQueryView,
423+
},
424+
{
425+
Name: "delete",
426+
Usage: "Delete a saved alias",
427+
Action: h.RunQueryDelete,
428+
},
429+
},
430+
},
350431
{
351432
Name: "internal",
352433
Usage: "Internal back-office commands (not for direct use)",

internal/reviewquery/aliases.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package reviewquery
2+
3+
import (
4+
"fmt"
5+
"maps"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/HexmosTech/git-lrc/configpath"
13+
"github.com/HexmosTech/git-lrc/storage"
14+
"github.com/knadh/koanf/parsers/toml"
15+
"github.com/knadh/koanf/providers/file"
16+
"github.com/knadh/koanf/v2"
17+
)
18+
19+
// builtinAliases ship with the binary so `lrc query <name>` works even before
20+
// the installer writes ~/.lrc/queries.toml. User-defined aliases override these.
21+
func builtinAliases() map[string]string {
22+
return map[string]string{
23+
"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",
24+
"by-author": "SELECT author AS Author, COUNT(*) AS Commits, SUM(action = 'reviewed') AS Reviewed FROM review_log GROUP BY author ORDER BY Commits DESC",
25+
"recent": "SELECT short_hash AS Hash, date AS Date, action AS Action, subject AS Subject FROM review_log ORDER BY date DESC LIMIT 20",
26+
}
27+
}
28+
29+
// AliasInfo describes one alias and where it came from.
30+
type AliasInfo struct {
31+
Name string
32+
SQL string
33+
Source string // "built-in" or "user"
34+
}
35+
36+
// queriesPath returns ~/.lrc/queries.toml.
37+
func queriesPath() (string, error) {
38+
dir, err := configpath.ResolveLRCDataDir()
39+
if err != nil {
40+
return "", err
41+
}
42+
return filepath.Join(dir, "queries.toml"), nil
43+
}
44+
45+
// loadUserAliases reads ~/.lrc/queries.toml ([queries] table). Missing file is
46+
// not an error — it returns an empty map.
47+
func loadUserAliases() (map[string]string, error) {
48+
path, err := queriesPath()
49+
if err != nil {
50+
return nil, err
51+
}
52+
if _, err := os.Stat(path); err != nil {
53+
if os.IsNotExist(err) {
54+
return map[string]string{}, nil
55+
}
56+
return nil, fmt.Errorf("failed to access user aliases file %s: %w", path, err)
57+
}
58+
59+
k := koanf.New(".")
60+
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
61+
return nil, fmt.Errorf("failed to parse user aliases file %s: %w", path, err)
62+
}
63+
// A non-empty file that lacks the [queries] table entirely is malformed —
64+
// surface that instead of silently loading zero aliases.
65+
if len(k.Keys()) > 0 && !k.Exists("queries") {
66+
return nil, fmt.Errorf("user aliases file %s has no [queries] table", path)
67+
}
68+
out := map[string]string{}
69+
maps.Copy(out, k.StringMap("queries"))
70+
return out, nil
71+
}
72+
73+
// ResolveAlias returns the SQL for an alias name (user file wins over built-in).
74+
func ResolveAlias(name string) (string, bool, error) {
75+
user, err := loadUserAliases()
76+
if err != nil {
77+
return "", false, err
78+
}
79+
if sql, ok := user[name]; ok {
80+
return sql, true, nil
81+
}
82+
if sql, ok := builtinAliases()[name]; ok {
83+
return sql, true, nil
84+
}
85+
return "", false, nil
86+
}
87+
88+
// ListAliases returns every alias (built-in + user) sorted by name; a user
89+
// alias shadows a built-in of the same name.
90+
func ListAliases() ([]AliasInfo, error) {
91+
user, err := loadUserAliases()
92+
if err != nil {
93+
return nil, err
94+
}
95+
merged := map[string]AliasInfo{}
96+
for name, sql := range builtinAliases() {
97+
merged[name] = AliasInfo{Name: name, SQL: sql, Source: "built-in"}
98+
}
99+
for name, sql := range user {
100+
merged[name] = AliasInfo{Name: name, SQL: sql, Source: "user"}
101+
}
102+
names := make([]string, 0, len(merged))
103+
for n := range merged {
104+
names = append(names, n)
105+
}
106+
sort.Strings(names)
107+
out := make([]AliasInfo, 0, len(names))
108+
for _, n := range names {
109+
out = append(out, merged[n])
110+
}
111+
return out, nil
112+
}
113+
114+
// AddAlias saves (or overwrites) a user alias in ~/.lrc/queries.toml.
115+
func AddAlias(name, sql string) error {
116+
name = strings.TrimSpace(name)
117+
if name == "" {
118+
return fmt.Errorf("alias name cannot be empty")
119+
}
120+
if strings.ContainsAny(name, ". \t") {
121+
return fmt.Errorf("alias name %q may not contain spaces or dots", name)
122+
}
123+
if strings.TrimSpace(sql) == "" {
124+
return fmt.Errorf("alias SQL cannot be empty")
125+
}
126+
if err := validateReadOnlySQL(sql); err != nil {
127+
return fmt.Errorf("alias SQL rejected: %w", err)
128+
}
129+
user, err := loadUserAliases()
130+
if err != nil {
131+
return err
132+
}
133+
user[name] = sql
134+
return writeUserAliases(user)
135+
}
136+
137+
// DeleteAlias removes a user alias. Built-in aliases cannot be deleted.
138+
func DeleteAlias(name string) error {
139+
user, err := loadUserAliases()
140+
if err != nil {
141+
return err
142+
}
143+
if _, ok := user[name]; !ok {
144+
if _, isBuiltin := builtinAliases()[name]; isBuiltin {
145+
return fmt.Errorf("%q is a built-in alias and cannot be deleted", name)
146+
}
147+
return fmt.Errorf("no user alias named %q", name)
148+
}
149+
delete(user, name)
150+
return writeUserAliases(user)
151+
}
152+
153+
// writeUserAliases serializes the alias map to ~/.lrc/queries.toml atomically.
154+
func writeUserAliases(aliases map[string]string) error {
155+
path, err := queriesPath()
156+
if err != nil {
157+
return err
158+
}
159+
160+
names := make([]string, 0, len(aliases))
161+
for n := range aliases {
162+
names = append(names, n)
163+
}
164+
sort.Strings(names)
165+
166+
var b strings.Builder
167+
b.WriteString("# git-lrc saved queries. Managed by `lrc query --add/--delete`.\n")
168+
b.WriteString("[queries]\n")
169+
for _, n := range names {
170+
b.WriteString(n)
171+
b.WriteString(" = ")
172+
b.WriteString(strconv.Quote(aliases[n]))
173+
b.WriteString("\n")
174+
}
175+
return storage.WriteFileAtomically(path, []byte(b.String()), 0o644)
176+
}

0 commit comments

Comments
 (0)