Commit e8223db099e4
Changed files (13)
tools
review-tool
cmd
review-tool
internal
activity
config
filter
output
timerange
tools/review-tool/cmd/review-tool/main.go
@@ -9,15 +9,18 @@ import (
"github.com/vdemeester/home/tools/review-tool/internal/activity"
"github.com/vdemeester/home/tools/review-tool/internal/config"
+ "github.com/vdemeester/home/tools/review-tool/internal/filter"
"github.com/vdemeester/home/tools/review-tool/internal/output"
"github.com/vdemeester/home/tools/review-tool/internal/sources"
"github.com/vdemeester/home/tools/review-tool/internal/timerange"
)
var (
- cfgFile string
- outputFormat string
- sourcesFlag []string
+ cfgFile string
+ outputFormat string
+ sourcesFlag []string
+ includeFolders []string
+ excludeFolders []string
)
func main() {
@@ -52,6 +55,16 @@ func run(args []string) error {
i++
sourcesFlag = append(sourcesFlag, args[i])
}
+ case arg == "-d" || arg == "--folder":
+ if i+1 < len(args) {
+ i++
+ includeFolders = append(includeFolders, args[i])
+ }
+ case arg == "-D" || arg == "--exclude-folder":
+ if i+1 < len(args) {
+ i++
+ excludeFolders = append(excludeFolders, args[i])
+ }
case arg == "config":
return runConfigCmd()
case arg == "sources":
@@ -86,17 +99,27 @@ Arguments:
"past 3 days", "this month", "2026-01-01..2026-01-15"
Flags:
- -c, --config string Config file (default: ~/.config/review-tool/config.yaml)
- -f, --format string Output format: markdown, json, yaml/llm (default from config)
- -s, --source string Sources to include (can be repeated)
- -h, --help Show this help message
+ -c, --config string Config file (default: ~/.config/review-tool/config.yaml)
+ -f, --format string Output format: markdown, json, yaml/llm (default from config)
+ -s, --source string Sources to include (can be repeated)
+ -d, --folder string Include only items matching folder (can be repeated)
+ -D, --exclude-folder string Exclude items matching folder (can be repeated)
+ -h, --help Show this help message
+
+Folder filtering:
+ Matches against project, repository, and directory metadata (substring,
+ case-insensitive). Parenthetical descriptions are stripped before matching.
Examples:
- review-tool # Last week's activity in markdown
- review-tool today # Today's activity
- review-tool "past 3 days" # Last 3 days
- review-tool -f json "this week" # JSON output
- review-tool -s github -s org # Only GitHub and org sources`)
+ review-tool # Last week's activity
+ review-tool today # Today's activity
+ review-tool "past 3 days" # Last 3 days
+ review-tool -f json "this week" # JSON output
+ review-tool -s github -s org # Only GitHub and org
+ review-tool -d tektoncd # Only tektoncd projects
+ review-tool -d tektoncd -d chisel # tektoncd OR chisel
+ review-tool -D home # Exclude homelab
+ review-tool -d tektoncd -D tektoncd/plumbing # tektoncd except plumbing`)
}
func runReview(timeRangeStr string) error {
@@ -128,6 +151,13 @@ func runReview(timeRangeStr string) error {
activities = registry.FetchAll(ctx, tr.Start, tr.End)
}
+ // Apply folder filters
+ f := &filter.Filter{
+ IncludeFolders: includeFolders,
+ ExcludeFolders: excludeFolders,
+ }
+ f.Apply(activities)
+
// Build report
report := &activity.ReviewReport{
GeneratedAt: time.Now(),
@@ -168,7 +198,7 @@ func runConfigCmd() error {
fmt.Printf(" GitHub: enabled=%v\n", cfg.GitHub.Enabled)
fmt.Printf(" Org: enabled=%v, files=%d\n", cfg.Org.Enabled, len(cfg.Org.Files))
fmt.Printf(" Jira: enabled=%v\n", cfg.Jira.Enabled)
- fmt.Printf(" Claude: enabled=%v\n", cfg.Claude.Enabled)
+ fmt.Printf(" AI: enabled=%v, dir=%s\n", cfg.AI.Enabled, cfg.AI.Dir)
fmt.Printf("\nOutput: format=%s, summary=%v\n", cfg.Output.DefaultFormat, cfg.Output.IncludeSummary)
tools/review-tool/internal/activity/activity.go
@@ -10,7 +10,7 @@ const (
CategoryGitHub Category = "github"
CategoryOrg Category = "org"
CategoryJira Category = "jira"
- CategoryClaude Category = "claude"
+ CategoryAI Category = "ai"
)
// ActivityItem represents a single unit of work or event.
tools/review-tool/internal/config/config.go
@@ -15,7 +15,7 @@ type Config struct {
GitHub GitHubConfig `yaml:"github"`
Org OrgConfig `yaml:"org"`
Jira JiraConfig `yaml:"jira"`
- Claude ClaudeConfig `yaml:"claude"`
+ AI AIConfig `yaml:"ai"`
Output OutputConfig `yaml:"output"`
}
@@ -56,13 +56,14 @@ type JiraConfig struct {
Username string `yaml:"username"`
}
-// ClaudeConfig configures the Claude history source.
-type ClaudeConfig struct {
+// AIConfig configures the AI session history source (~/.local/share/ai).
+type AIConfig struct {
Enabled bool `yaml:"enabled"`
- HistoryDir string `yaml:"history_dir"`
+ Dir string `yaml:"dir"`
IncludeSessions bool `yaml:"include_sessions"`
IncludeLearnings bool `yaml:"include_learnings"`
IncludeResearch bool `yaml:"include_research"`
+ IncludePlans bool `yaml:"include_plans"`
}
// OutputConfig configures output formatting.
@@ -131,12 +132,13 @@ func DefaultConfig() *Config {
Jira: JiraConfig{
Enabled: false,
},
- Claude: ClaudeConfig{
+ AI: AIConfig{
Enabled: true,
- HistoryDir: filepath.Join(home, ".config", "claude", "history"),
+ Dir: filepath.Join(home, ".local", "share", "ai"),
IncludeSessions: true,
IncludeLearnings: true,
IncludeResearch: true,
+ IncludePlans: true,
},
Output: OutputConfig{
DefaultFormat: "markdown",
@@ -156,8 +158,8 @@ func (c *Config) expandPaths() {
// Expand org archive dir
c.Org.ArchiveDir = expandHome(c.Org.ArchiveDir, home)
- // Expand Claude history dir
- c.Claude.HistoryDir = expandHome(c.Claude.HistoryDir, home)
+ // Expand AI dir
+ c.AI.Dir = expandHome(c.AI.Dir, home)
}
func expandHome(path, home string) string {
tools/review-tool/internal/filter/filter.go
@@ -0,0 +1,121 @@
+// Package filter provides post-fetch activity filtering.
+package filter
+
+import (
+ "strings"
+
+ "github.com/vdemeester/home/tools/review-tool/internal/activity"
+)
+
+// Filter defines include/exclude rules for activity items.
+type Filter struct {
+ // IncludeFolders keeps only items whose project/repository/directory
+ // matches one of these values (substring, case-insensitive).
+ // Empty means no include constraint.
+ IncludeFolders []string
+ // ExcludeFolders removes items whose project/repository/directory
+ // matches any of these values (substring, case-insensitive).
+ ExcludeFolders []string
+}
+
+// IsEmpty returns true if no filters are configured.
+func (f *Filter) IsEmpty() bool {
+ return len(f.IncludeFolders) == 0 && len(f.ExcludeFolders) == 0
+}
+
+// Apply filters a map of activities in place, removing non-matching items.
+func (f *Filter) Apply(activities map[string]*activity.Activity) {
+ if f.IsEmpty() {
+ return
+ }
+ for name, act := range activities {
+ activities[name] = &activity.Activity{
+ Source: act.Source,
+ Items: f.FilterItems(act.Items),
+ Error: act.Error,
+ }
+ }
+}
+
+// FilterItems returns only the items that pass the filter.
+func (f *Filter) FilterItems(items []activity.ActivityItem) []activity.ActivityItem {
+ if f.IsEmpty() {
+ return items
+ }
+ var result []activity.ActivityItem
+ for _, item := range items {
+ if f.Match(item) {
+ result = append(result, item)
+ }
+ }
+ return result
+}
+
+// Match returns true if an item passes the filter.
+func (f *Filter) Match(item activity.ActivityItem) bool {
+ folders := itemFolders(item)
+
+ // Exclude check: if any folder matches an exclude pattern, reject
+ if len(f.ExcludeFolders) > 0 && matchesAny(folders, f.ExcludeFolders) {
+ return false
+ }
+
+ // Include check: at least one folder must match an include pattern
+ // Items with no folder metadata pass include filters (they can't be classified)
+ if len(f.IncludeFolders) > 0 {
+ if len(folders) == 0 {
+ return false
+ }
+ return matchesAny(folders, f.IncludeFolders)
+ }
+
+ return true
+}
+
+// itemFolders extracts all folder-like values from an item's metadata.
+// It looks at project, repository, and directory fields.
+func itemFolders(item activity.ActivityItem) []string {
+ var folders []string
+ for _, key := range []string{"project", "repository", "directory"} {
+ if val, ok := item.Metadata[key]; ok && val != "" {
+ folders = append(folders, normalizePath(val))
+ }
+ }
+ return folders
+}
+
+// matchesAny returns true if any folder matches any pattern.
+// Matching is substring-based and case-insensitive.
+func matchesAny(folders []string, patterns []string) bool {
+ for _, folder := range folders {
+ for _, pattern := range patterns {
+ if containsFold(folder, normalizePath(pattern)) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// normalizePath cleans a path for matching:
+// - strips trailing descriptions in parens: "~/src/home (homelab)" → "~/src/home"
+// - strips trailing whitespace
+// - strips trailing slashes
+func normalizePath(path string) string {
+ path = strings.TrimSpace(path)
+
+ // Strip parenthetical suffix: "~/src/home (homelab monorepo)" → "~/src/home"
+ if idx := strings.Index(path, " ("); idx > 0 {
+ path = path[:idx]
+ }
+
+ // Strip trailing slashes
+ path = strings.TrimRight(path, "/")
+
+ return path
+}
+
+// containsFold is a case-insensitive substring check.
+func containsFold(s, substr string) bool {
+ return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
+}
tools/review-tool/internal/filter/filter_test.go
@@ -0,0 +1,177 @@
+package filter
+
+import (
+ "testing"
+
+ "github.com/vdemeester/home/tools/review-tool/internal/activity"
+)
+
+func item(meta map[string]string) activity.ActivityItem {
+ return activity.ActivityItem{
+ Title: "test",
+ Metadata: meta,
+ }
+}
+
+func TestFilter_IncludeFolder(t *testing.T) {
+ f := &Filter{IncludeFolders: []string{"tektoncd"}}
+
+ tests := []struct {
+ name string
+ item activity.ActivityItem
+ match bool
+ }{
+ {"exact repo match", item(map[string]string{"repository": "tektoncd/pipeline"}), true},
+ {"project match", item(map[string]string{"project": "tektoncd/plumbing"}), true},
+ {"directory match", item(map[string]string{"directory": "~/src/tektoncd/pipeline"}), true},
+ {"no match", item(map[string]string{"project": "~/src/home"}), false},
+ {"no metadata", item(map[string]string{}), false},
+ {"case insensitive", item(map[string]string{"project": "TektonCD/Pipeline"}), true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := f.Match(tt.item); got != tt.match {
+ t.Errorf("Match() = %v, want %v", got, tt.match)
+ }
+ })
+ }
+}
+
+func TestFilter_ExcludeFolder(t *testing.T) {
+ f := &Filter{ExcludeFolders: []string{"home"}}
+
+ tests := []struct {
+ name string
+ item activity.ActivityItem
+ match bool
+ }{
+ {"excludes home project", item(map[string]string{"project": "~/src/home (homelab)"}), false},
+ {"excludes home dir", item(map[string]string{"directory": "~/src/home"}), false},
+ {"keeps tektoncd", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+ {"no metadata passes", item(map[string]string{}), true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := f.Match(tt.item); got != tt.match {
+ t.Errorf("Match() = %v, want %v", got, tt.match)
+ }
+ })
+ }
+}
+
+func TestFilter_IncludeAndExclude(t *testing.T) {
+ // Include tektoncd but exclude tektoncd/plumbing
+ f := &Filter{
+ IncludeFolders: []string{"tektoncd"},
+ ExcludeFolders: []string{"tektoncd/plumbing"},
+ }
+
+ tests := []struct {
+ name string
+ item activity.ActivityItem
+ match bool
+ }{
+ {"includes pipeline", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+ {"excludes plumbing", item(map[string]string{"project": "tektoncd/plumbing"}), false},
+ {"excludes non-tektoncd", item(map[string]string{"project": "~/src/home"}), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := f.Match(tt.item); got != tt.match {
+ t.Errorf("Match() = %v, want %v", got, tt.match)
+ }
+ })
+ }
+}
+
+func TestFilter_NormalizePath(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"~/src/home (homelab)", "~/src/home"},
+ {"~/src/home (homelab monorepo)", "~/src/home"},
+ {"tektoncd/pipeline", "tektoncd/pipeline"},
+ {" ~/src/home/ ", "~/src/home"},
+ {"home", "home"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ if got := normalizePath(tt.input); got != tt.want {
+ t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFilter_Apply(t *testing.T) {
+ f := &Filter{IncludeFolders: []string{"tektoncd"}}
+
+ activities := map[string]*activity.Activity{
+ "ai": {
+ Source: "ai",
+ Items: []activity.ActivityItem{
+ item(map[string]string{"project": "tektoncd/pipeline"}),
+ item(map[string]string{"project": "~/src/home"}),
+ item(map[string]string{"project": "tektoncd/plumbing"}),
+ },
+ },
+ "github": {
+ Source: "github",
+ Items: []activity.ActivityItem{
+ item(map[string]string{"repository": "tektoncd/pipeline"}),
+ item(map[string]string{"repository": "vdemeester/home"}),
+ },
+ },
+ }
+
+ f.Apply(activities)
+
+ if len(activities["ai"].Items) != 2 {
+ t.Errorf("ai items = %d, want 2", len(activities["ai"].Items))
+ }
+ if len(activities["github"].Items) != 1 {
+ t.Errorf("github items = %d, want 1", len(activities["github"].Items))
+ }
+}
+
+func TestFilter_Empty(t *testing.T) {
+ f := &Filter{}
+
+ if !f.IsEmpty() {
+ t.Error("expected empty filter")
+ }
+
+ // Empty filter should pass everything
+ i := item(map[string]string{"project": "anything"})
+ if !f.Match(i) {
+ t.Error("empty filter should match everything")
+ }
+}
+
+func TestFilter_MultipleIncludeFolders(t *testing.T) {
+ // Either tektoncd or chisel
+ f := &Filter{IncludeFolders: []string{"tektoncd", "chisel"}}
+
+ tests := []struct {
+ name string
+ item activity.ActivityItem
+ match bool
+ }{
+ {"tektoncd matches", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+ {"chisel matches", item(map[string]string{"project": "github.com/vdemeester/chisel"}), true},
+ {"home excluded", item(map[string]string{"project": "~/src/home"}), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := f.Match(tt.item); got != tt.match {
+ t.Errorf("Match() = %v, want %v", got, tt.match)
+ }
+ })
+ }
+}
tools/review-tool/internal/output/markdown.go
@@ -37,7 +37,7 @@ func WriteMarkdown(w io.Writer, report *activity.ReviewReport) error {
}
// Activities by source
- sourceOrder := []string{"github", "org", "jira", "claude"}
+ sourceOrder := []string{"github", "org", "jira", "ai"}
for _, source := range sourceOrder {
act, ok := report.Activities[source]
if !ok || len(act.Items) == 0 {
@@ -124,7 +124,7 @@ func formatSourceName(source string) string {
"github": "GitHub",
"org": "Org-mode",
"jira": "Jira",
- "claude": "Claude Sessions",
+ "ai": "AI Sessions",
}
if name, ok := names[source]; ok {
return name
tools/review-tool/internal/output/markdown_test.go
@@ -117,7 +117,7 @@ func TestFormatSourceName(t *testing.T) {
{"github", "GitHub"},
{"org", "Org-mode"},
{"jira", "Jira"},
- {"claude", "Claude Sessions"},
+ {"ai", "AI Sessions"},
{"unknown", "Unknown"},
}
tools/review-tool/internal/output/yaml.go
@@ -39,7 +39,7 @@ func WriteYAML(w io.Writer, report *activity.ReviewReport) error {
// Activities by source
fmt.Fprintln(w, "activities:")
- sourceOrder := []string{"github", "org", "jira", "claude"}
+ sourceOrder := []string{"github", "org", "jira", "ai"}
for _, source := range sourceOrder {
act, ok := report.Activities[source]
if !ok || len(act.Items) == 0 {
tools/review-tool/internal/sources/claude.go → tools/review-tool/internal/sources/ai.go
@@ -25,54 +25,64 @@ var (
// Filename patterns: 2026-01-21-something.md or 20260121-something.md
filenameDateRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
filenameDateCompactRe = regexp.MustCompile(`^(\d{8})`)
+ // **Key:** value or **Key**: value (with optional trailing **)
+ // Captures key and value from markdown bold metadata lines
+ mdMetadataRe = regexp.MustCompile(`^\*\*([A-Za-z][A-Za-z ]*?)\*?\*?:\*?\*?\s*(.+)$`)
)
-// ClaudeSource fetches activity from Claude session history.
-type ClaudeSource struct {
- cfg *config.ClaudeConfig
+// AISource fetches activity from AI session history (~/.local/share/ai).
+type AISource struct {
+ cfg *config.AIConfig
}
-// NewClaudeSource creates a new Claude history source.
-func NewClaudeSource(cfg *config.ClaudeConfig) *ClaudeSource {
- return &ClaudeSource{cfg: cfg}
+// NewAISource creates a new AI history source.
+func NewAISource(cfg *config.AIConfig) *AISource {
+ return &AISource{cfg: cfg}
}
// Name returns the source identifier.
-func (c *ClaudeSource) Name() string {
- return "claude"
+func (s *AISource) Name() string {
+ return "ai"
}
-// Validate checks if history directory exists.
-func (c *ClaudeSource) Validate() error {
- if _, err := os.Stat(c.cfg.HistoryDir); err != nil {
+// Validate checks if the base directory exists.
+func (s *AISource) Validate() error {
+ if _, err := os.Stat(s.cfg.Dir); err != nil {
return err
}
return nil
}
-// Fetch retrieves Claude session activities within the time range.
-func (c *ClaudeSource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
+// Fetch retrieves AI session activities within the time range.
+func (s *AISource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
act := &activity.Activity{
- Source: "claude",
+ Source: "ai",
Items: []activity.ActivityItem{},
}
- if c.cfg.IncludeSessions {
- items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "sessions"), "session", start, end)
+ if s.cfg.IncludeSessions {
+ items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "sessions"), "session", start, end)
if err == nil {
act.Items = append(act.Items, items...)
}
}
- if c.cfg.IncludeLearnings {
- items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "learnings"), "learning", start, end)
+ if s.cfg.IncludeLearnings {
+ items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "learnings"), "learning", start, end)
if err == nil {
act.Items = append(act.Items, items...)
}
}
- if c.cfg.IncludeResearch {
- items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "research"), "research", start, end)
+ if s.cfg.IncludeResearch {
+ items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "research"), "research", start, end)
+ if err == nil {
+ act.Items = append(act.Items, items...)
+ }
+ }
+
+ if s.cfg.IncludePlans {
+ items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "plans"), "plan", start, end)
if err == nil {
act.Items = append(act.Items, items...)
}
@@ -81,11 +91,18 @@ func (c *ClaudeSource) Fetch(ctx context.Context, start, end time.Time) (*activi
return act, nil
}
-func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.Time) ([]activity.ActivityItem, error) {
+func (s *AISource) scanDirectory(baseDir, itemType string, start, end time.Time) ([]activity.ActivityItem, error) {
var items []activity.ActivityItem
+ // Resolve symlinks so filepath.Walk can traverse symlinked directories
+ // (e.g. home-manager managed paths via nix store)
+ resolvedDir, err := filepath.EvalSymlinks(baseDir)
+ if err != nil {
+ resolvedDir = baseDir // Fall back to original if resolution fails
+ }
+
// Walk through YYYY-MM subdirectories
- err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
+ err = filepath.Walk(resolvedDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors, continue walking
}
@@ -105,7 +122,7 @@ func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.T
return nil
}
- item, err := c.parseFile(path, itemType, ext)
+ item, err := s.parseFile(path, itemType, ext, info.ModTime())
if err != nil {
return nil // Skip files that fail to parse
}
@@ -122,7 +139,7 @@ func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.T
return items, err
}
-func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.ActivityItem, error) {
+func (s *AISource) parseFile(path, itemType, ext string, modTime time.Time) (*activity.ActivityItem, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
@@ -131,17 +148,17 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
var title string
var dateStr string
+ metadata := map[string]string{"path": path}
filename := filepath.Base(path)
scanner := bufio.NewScanner(file)
lineCount := 0
- for scanner.Scan() && lineCount < 30 {
+ for scanner.Scan() && lineCount < 40 {
line := scanner.Text()
lineCount++
// Parse based on file type
if ext == ".org" {
- // Org-mode: #+title: and #+date:
if matches := orgTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
title = strings.TrimSpace(matches[1])
}
@@ -149,19 +166,37 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
dateStr = matches[1]
}
} else {
- // Markdown: # Title and **Date:**
+ // Markdown title
if matches := mdTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
title = strings.TrimSpace(matches[1])
}
- if matches := mdDateRe.FindStringSubmatch(line); len(matches) > 1 && dateStr == "" {
- dateStr = matches[1]
+ // Extract **Key:** value metadata
+ if matches := mdMetadataRe.FindStringSubmatch(line); len(matches) > 2 {
+ key := strings.ToLower(strings.TrimSpace(matches[1]))
+ val := cleanMetadataValue(matches[2])
+ switch key {
+ case "date":
+ if dateStr == "" {
+ // Extract YYYY-MM-DD from various date formats
+ if d := extractDate(val); d != "" {
+ dateStr = d
+ }
+ }
+ case "project":
+ metadata["project"] = val
+ case "repository", "repositories", "repo":
+ metadata["repository"] = val
+ case "directory", "working directory":
+ metadata["directory"] = val
+ case "tool":
+ metadata["tool"] = val
+ case "tags":
+ metadata["tags"] = val
+ case "host":
+ metadata["host"] = val
+ }
}
}
-
- // Early exit if we have both
- if title != "" && dateStr != "" {
- break
- }
}
// Fallback: extract date from filename
@@ -169,7 +204,6 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
if matches := filenameDateRe.FindStringSubmatch(filename); len(matches) > 1 {
dateStr = matches[1]
} else if matches := filenameDateCompactRe.FindStringSubmatch(filename); len(matches) > 1 {
- // Convert 20260121 to 2026-01-21
d := matches[1]
dateStr = d[:4] + "-" + d[4:6] + "-" + d[6:8]
}
@@ -180,8 +214,8 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
title = cleanFilename(filename)
}
- // Parse date
- ts := time.Now() // fallback
+ // Parse date: content > filename > file modification time
+ ts := modTime
if dateStr != "" {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
ts = parsed
@@ -192,14 +226,37 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
ID: path,
Title: title,
Type: itemType,
- Category: activity.CategoryClaude,
+ Category: activity.CategoryAI,
Timestamp: ts,
- Metadata: map[string]string{
- "path": path,
- },
+ Metadata: metadata,
}, nil
}
+// cleanMetadataValue strips markdown formatting from a metadata value.
+// Removes backticks, trailing parenthetical descriptions, etc.
+func cleanMetadataValue(val string) string {
+ val = strings.TrimSpace(val)
+ // Strip backticks
+ val = strings.ReplaceAll(val, "`", "")
+ return val
+}
+
+// extractDate pulls a YYYY-MM-DD date from various formats:
+// "2026-01-21", "January 21, 2026", "February 18, 2026"
+func extractDate(val string) string {
+ // Direct YYYY-MM-DD
+ if m := filenameDateRe.FindString(val); m != "" {
+ return m
+ }
+ // Try standard Go date parsing for verbose formats
+ for _, layout := range []string{"January 2, 2006", "January 02, 2006", "Jan 2, 2006"} {
+ if t, err := time.Parse(layout, strings.TrimSpace(val)); err == nil {
+ return t.Format("2006-01-02")
+ }
+ }
+ return ""
+}
+
func cleanFilename(filename string) string {
// Remove extension
name := strings.TrimSuffix(filename, filepath.Ext(filename))
tools/review-tool/internal/sources/claude_integration_test.go → tools/review-tool/internal/sources/ai_integration_test.go
@@ -10,24 +10,24 @@ import (
"github.com/vdemeester/home/tools/review-tool/internal/config"
)
-func TestClaudeSource_RealHistory(t *testing.T) {
- // This test uses the real Claude history directory
+func TestAISource_RealHistory(t *testing.T) {
home, _ := os.UserHomeDir()
- historyDir := filepath.Join(home, ".config", "claude", "history")
+ aiDir := filepath.Join(home, ".local", "share", "ai")
- if _, err := os.Stat(historyDir); err != nil {
- t.Skipf("Claude history not found at %s", historyDir)
+ if _, err := os.Stat(aiDir); err != nil {
+ t.Skipf("AI history not found at %s", aiDir)
}
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: true,
IncludeLearnings: true,
IncludeResearch: true,
+ IncludePlans: true,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
// Last 7 days
now := time.Now()
@@ -49,6 +49,6 @@ func TestClaudeSource_RealHistory(t *testing.T) {
// Should find at least some items in the last 7 days
if len(act.Items) == 0 {
- t.Error("Expected to find some Claude history items")
+ t.Error("Expected to find some AI history items")
}
}
tools/review-tool/internal/sources/claude_test.go → tools/review-tool/internal/sources/ai_test.go
@@ -10,7 +10,7 @@ import (
)
// Helper to create temp directory with test files
-func setupTestHistoryDir(t *testing.T) string {
+func setupTestAIDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -81,18 +81,22 @@ Research on local CI systems.
return dir
}
-func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
- historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesSessionWithMetadata(t *testing.T) {
+ aiDir := setupTestAIDir(t)
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: true,
IncludeLearnings: false,
IncludeResearch: false,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
+
+ if source.Name() != "ai" {
+ t.Errorf("Name() = %q, want %q", source.Name(), "ai")
+ }
// Query for January 2026
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -107,6 +111,10 @@ func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
t.Fatal("expected at least one session item")
}
+ if act.Source != "ai" {
+ t.Errorf("Source = %q, want %q", act.Source, "ai")
+ }
+
// Find the terraform session
var found bool
for _, item := range act.Items {
@@ -127,18 +135,18 @@ func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
}
}
-func TestClaudeSource_ParsesDateFromFilename(t *testing.T) {
- historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesDateFromFilename(t *testing.T) {
+ aiDir := setupTestAIDir(t)
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: true,
IncludeLearnings: false,
IncludeResearch: false,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -165,18 +173,18 @@ func TestClaudeSource_ParsesDateFromFilename(t *testing.T) {
}
}
-func TestClaudeSource_ParsesLearnings(t *testing.T) {
- historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesLearnings(t *testing.T) {
+ aiDir := setupTestAIDir(t)
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: false,
IncludeLearnings: true,
IncludeResearch: false,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -199,18 +207,18 @@ func TestClaudeSource_ParsesLearnings(t *testing.T) {
}
}
-func TestClaudeSource_ParsesResearchOrgMode(t *testing.T) {
- historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesResearchOrgMode(t *testing.T) {
+ aiDir := setupTestAIDir(t)
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: false,
IncludeLearnings: false,
IncludeResearch: true,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -233,18 +241,18 @@ func TestClaudeSource_ParsesResearchOrgMode(t *testing.T) {
}
}
-func TestClaudeSource_FiltersOutsideDateRange(t *testing.T) {
- historyDir := setupTestHistoryDir(t)
+func TestAISource_FiltersOutsideDateRange(t *testing.T) {
+ aiDir := setupTestAIDir(t)
- cfg := &config.ClaudeConfig{
+ cfg := &config.AIConfig{
Enabled: true,
- HistoryDir: historyDir,
+ Dir: aiDir,
IncludeSessions: true,
IncludeLearnings: true,
IncludeResearch: true,
}
- source := NewClaudeSource(cfg)
+ source := NewAISource(cfg)
// Query for December 2025 - should return nothing
start := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)
@@ -259,3 +267,180 @@ func TestClaudeSource_FiltersOutsideDateRange(t *testing.T) {
t.Errorf("expected 0 items outside date range, got %d", len(act.Items))
}
}
+
+func TestAISource_Plans(t *testing.T) {
+ dir := t.TempDir()
+
+ // Plans are flat (no YYYY-MM subdirectories)
+ plansDir := filepath.Join(dir, "plans")
+ if err := os.MkdirAll(plansDir, 0o755); err != nil {
+ t.Fatalf("failed to create dir: %v", err)
+ }
+
+ content := `# Migration Plan
+
+**Date:** 2026-01-22
+
+## Overview
+Plan for migrating services.
+`
+ if err := os.WriteFile(filepath.Join(plansDir, "migration-plan.md"), []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ cfg := &config.AIConfig{
+ Enabled: true,
+ Dir: dir,
+ IncludePlans: true,
+ }
+
+ source := NewAISource(cfg)
+
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+ act, err := source.Fetch(t.Context(), start, end)
+ if err != nil {
+ t.Fatalf("Fetch() error = %v", err)
+ }
+
+ if len(act.Items) != 1 {
+ t.Fatalf("expected 1 plan item, got %d", len(act.Items))
+ }
+
+ item := act.Items[0]
+ if item.Type != "plan" {
+ t.Errorf("Type = %q, want %q", item.Type, "plan")
+ }
+ if item.Title != "Migration Plan" {
+ t.Errorf("Title = %q, want %q", item.Title, "Migration Plan")
+ }
+}
+
+func TestAISource_ExtractsMetadata(t *testing.T) {
+ dir := t.TempDir()
+ sessionsDir := filepath.Join(dir, "sessions", "2026-01")
+ if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ content := `# Fix Pipeline Release
+
+**Date:** 2026-01-21
+**Project:** tektoncd/pipeline
+**Repository:** tektoncd/pipeline
+**Directory:** ~/src/tektoncd/pipeline
+**Tool:** pi
+**Host:** kyushu
+**Tags:** #tekton #ci
+
+## Summary
+Fixed release pipeline.
+`
+ if err := os.WriteFile(filepath.Join(sessionsDir, "2026-01-21-fix-pipeline.md"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &config.AIConfig{
+ Enabled: true,
+ Dir: dir,
+ IncludeSessions: true,
+ }
+ source := NewAISource(cfg)
+
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+ act, err := source.Fetch(t.Context(), start, end)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(act.Items) != 1 {
+ t.Fatalf("expected 1 item, got %d", len(act.Items))
+ }
+
+ item := act.Items[0]
+ checks := map[string]string{
+ "project": "tektoncd/pipeline",
+ "repository": "tektoncd/pipeline",
+ "directory": "~/src/tektoncd/pipeline",
+ "tool": "pi",
+ "host": "kyushu",
+ "tags": "#tekton #ci",
+ }
+ for key, want := range checks {
+ if got := item.Metadata[key]; got != want {
+ t.Errorf("Metadata[%q] = %q, want %q", key, got, want)
+ }
+ }
+}
+
+func TestAISource_ExtractsMetadataVariants(t *testing.T) {
+ dir := t.TempDir()
+ sessionsDir := filepath.Join(dir, "sessions", "2026-01")
+ if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test variant formats: **Key**: value (colon outside bold)
+ content := `# Session Title
+
+**Date**: 2026-01-20
+**Project**: tektoncd/plumbing
+**Working Directory**: /home/user/src/tektoncd/plumbing
+**Tool**: pi (Claude)
+
+## Summary
+Did some work.
+`
+ if err := os.WriteFile(filepath.Join(sessionsDir, "2026-01-20-variant.md"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &config.AIConfig{
+ Enabled: true,
+ Dir: dir,
+ IncludeSessions: true,
+ }
+ source := NewAISource(cfg)
+
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+ act, err := source.Fetch(t.Context(), start, end)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(act.Items) != 1 {
+ t.Fatalf("expected 1 item, got %d", len(act.Items))
+ }
+
+ item := act.Items[0]
+ if item.Metadata["project"] != "tektoncd/plumbing" {
+ t.Errorf("project = %q, want %q", item.Metadata["project"], "tektoncd/plumbing")
+ }
+ if item.Metadata["directory"] != "/home/user/src/tektoncd/plumbing" {
+ t.Errorf("directory = %q, want %q", item.Metadata["directory"], "/home/user/src/tektoncd/plumbing")
+ }
+ if item.Metadata["tool"] != "pi (Claude)" {
+ t.Errorf("tool = %q, want %q", item.Metadata["tool"], "pi (Claude)")
+ }
+ if item.Timestamp.Day() != 20 {
+ t.Errorf("day = %d, want 20", item.Timestamp.Day())
+ }
+}
+
+func TestAISource_ValidateMissingDir(t *testing.T) {
+ cfg := &config.AIConfig{
+ Enabled: true,
+ Dir: "/nonexistent/dir/that/doesnt/exist",
+ }
+
+ source := NewAISource(cfg)
+
+ if err := source.Validate(); err == nil {
+ t.Error("expected error for missing directory")
+ }
+}
tools/review-tool/internal/sources/source.go
@@ -40,8 +40,8 @@ func NewRegistry(cfg *config.Config) *Registry {
if cfg.Jira.Enabled {
r.sources["jira"] = NewJiraSource(&cfg.Jira)
}
- if cfg.Claude.Enabled {
- r.sources["claude"] = NewClaudeSource(&cfg.Claude)
+ if cfg.AI.Enabled {
+ r.sources["ai"] = NewAISource(&cfg.AI)
}
return r
tools/review-tool/internal/timerange/timerange_integration_test.go
@@ -14,14 +14,27 @@ func TestParse_PastDays_Boundaries(t *testing.T) {
t.Logf("Start: %v", tr.Start)
t.Logf("End: %v", tr.End)
- // Check that a date like 2026-01-20 (parsed as UTC midnight) would be included
- testDate, _ := time.Parse("2006-01-02", "2026-01-20")
- t.Logf("Test date (2026-01-20 parsed): %v", testDate)
+ // A date 3 days ago (formatted as YYYY-MM-DD, parsed as UTC midnight)
+ // must fall within "past 7 days".
+ threeDaysAgo := time.Now().AddDate(0, 0, -3)
+ dateStr := threeDaysAgo.Format("2006-01-02")
+ testDate, _ := time.Parse("2006-01-02", dateStr)
+ t.Logf("Test date (%s parsed): %v", dateStr, testDate)
inRange := !testDate.Before(tr.Start) && !testDate.After(tr.End)
- t.Logf("2026-01-20 in range? %v", inRange)
+ t.Logf("%s in range? %v", dateStr, inRange)
if !inRange {
- t.Error("Expected 2026-01-20 to be in range for past 7 days")
+ t.Errorf("Expected %s to be in range for past 7 days", dateStr)
+ }
+
+ // A date 10 days ago should NOT be in range
+ tenDaysAgo := time.Now().AddDate(0, 0, -10)
+ dateStr2 := tenDaysAgo.Format("2006-01-02")
+ testDate2, _ := time.Parse("2006-01-02", dateStr2)
+
+ inRange2 := !testDate2.Before(tr.Start) && !testDate2.After(tr.End)
+ if inRange2 {
+ t.Errorf("Expected %s to NOT be in range for past 7 days", dateStr2)
}
}