Commit 775ec56a14e5
Changed files (3)
tools
review-tool
internal
output
sources
tools/review-tool/internal/output/markdown.go
@@ -85,6 +85,27 @@ func writeMarkdownItem(w io.Writer, item activity.ActivityItem) {
} else {
fmt.Fprintf(w, "- %s (%s)\n", item.Title, timestamp)
}
+
+ // Show metadata for org items
+ if item.Category == activity.CategoryOrg {
+ var meta []string
+ if section := item.Metadata["section"]; section != "" {
+ meta = append(meta, section)
+ }
+ if file := item.Metadata["file"]; file != "" && !strings.HasSuffix(file, "todos.org") {
+ // Show shortened file path for archive items
+ if strings.Contains(file, "/archive/") {
+ parts := strings.Split(file, "/archive/")
+ if len(parts) > 1 {
+ meta = append(meta, "archive/"+parts[1])
+ }
+ }
+ }
+ if len(meta) > 0 {
+ fmt.Fprintf(w, " _(%s)_\n", strings.Join(meta, " | "))
+ }
+ }
+
if item.Description != "" {
fmt.Fprintf(w, " > %s\n", item.Description)
}
tools/review-tool/internal/sources/org.go
@@ -15,11 +15,18 @@ import (
var (
// ** DONE [#2] Some task title
- orgHeadingRe = regexp.MustCompile(`^(\*+)\s+(TODO|DONE|STRT|NEXT|WAIT|CANX|KILL)\s+(?:\[#\d\]\s+)?(.+)$`)
+ orgHeadingRe = regexp.MustCompile(`^(\*+)\s+(TODO|DONE|STRT|NEXT|WAIT|CANX|CANCELED|KILL)\s+(?:\[#\d\]\s+)?(.+)$`)
+ // * Section heading (level 1, no TODO state)
+ sectionHeadingRe = regexp.MustCompile(`^\*\s+([^*].*)$`)
// - State "DONE" from "TODO" [2026-01-25 Sat 15:30]
stateChangeRe = regexp.MustCompile(`^-\s+State\s+"(\w+)"\s+from\s+"(\w+)"\s+\[(\d{4}-\d{2}-\d{2}\s+\w{3}\s+\d{2}:\d{2})\]`)
// CLOCK: [2026-01-22 Wed 09:00]--[2026-01-22 Wed 11:15] => 2:15
clockEntryRe = regexp.MustCompile(`CLOCK:\s+\[([^\]]+)\]--\[([^\]]+)\]\s+=>\s+(\d+:\d+)`)
+ // Org-mode links: [[url][description]] or [[url]]
+ orgLinkWithDescRe = regexp.MustCompile(`\[\[([^\]]+)\]\[([^\]]+)\]\]`)
+ orgLinkBareRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
+ // Org-mode tags at end of heading: :tag1:tag2:
+ orgTagsRe = regexp.MustCompile(`\s+:[A-Za-z0-9_@#%:]+:\s*$`)
)
// OrgSource fetches activity from org-mode files.
@@ -145,12 +152,35 @@ func (o *OrgSource) parseOrgFile(filePath string, start, end time.Time) ([]activ
var items []activity.ActivityItem
var currentHeading string
+ var currentSection string // Reset for each file
seenItems := make(map[string]bool) // Dedup by heading+timestamp
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
+ // Track section (level-1 heading without TODO state)
+ // Must be exactly one asterisk followed by space (not ** or more)
+ if strings.HasPrefix(line, "* ") && !strings.HasPrefix(line, "** ") {
+ // Only update section if this is a plain heading (not a TODO)
+ if !orgHeadingRe.MatchString(line) {
+ // Extract section name and convert any org links
+ sectionName := strings.TrimPrefix(line, "* ")
+ sectionName = strings.TrimSpace(sectionName)
+ // Remove any tags at the end (like :ARCHIVE:)
+ if idx := strings.LastIndex(sectionName, ":"); idx > 0 {
+ // Check if this looks like a tag (ends with :)
+ if strings.HasSuffix(sectionName, ":") {
+ tagStart := strings.LastIndex(sectionName[:idx], " ")
+ if tagStart > 0 {
+ sectionName = strings.TrimSpace(sectionName[:tagStart])
+ }
+ }
+ }
+ currentSection = convertOrgLinksToMarkdown(sectionName)
+ }
+ }
+
// Track current heading context
if matches := orgHeadingRe.FindStringSubmatch(line); len(matches) > 0 {
currentHeading = strings.TrimSpace(matches[3])
@@ -178,16 +208,22 @@ func (o *OrgSource) parseOrgFile(filePath string, start, end time.Time) ([]activ
}
seenItems[key] = true
+ title := cleanOrgTitle(currentHeading)
+ metadata := map[string]string{
+ "file": filePath,
+ "from_state": matches[2],
+ }
+ if currentSection != "" {
+ metadata["section"] = currentSection
+ }
+
items = append(items, activity.ActivityItem{
ID: filePath + ":" + currentHeading,
- Title: currentHeading,
+ Title: title,
Type: "todo_done",
Category: activity.CategoryOrg,
Timestamp: ts,
- Metadata: map[string]string{
- "file": filePath,
- "from_state": matches[2],
- },
+ Metadata: metadata,
})
}
}
@@ -215,16 +251,22 @@ func (o *OrgSource) parseOrgFile(filePath string, start, end time.Time) ([]activ
}
seenItems[key] = true
+ title := cleanOrgTitle(currentHeading)
+ metadata := map[string]string{
+ "file": filePath,
+ "duration": duration,
+ }
+ if currentSection != "" {
+ metadata["section"] = currentSection
+ }
+
items = append(items, activity.ActivityItem{
ID: filePath + ":clock:" + currentHeading,
- Title: currentHeading,
+ Title: title,
Type: "clock_entry",
Category: activity.CategoryOrg,
Timestamp: ts,
- Metadata: map[string]string{
- "file": filePath,
- "duration": duration,
- },
+ Metadata: metadata,
})
}
}
@@ -259,3 +301,31 @@ func parseOrgTimestamp(s string) (time.Time, error) {
return time.Time{}, nil
}
+
+// convertOrgLinksToMarkdown converts org-mode links to markdown format.
+// [[url][description]] -> [description](url)
+// [[url]] -> [url](url)
+func convertOrgLinksToMarkdown(s string) string {
+ // First, replace links with descriptions: [[url][desc]] -> [desc](url)
+ s = orgLinkWithDescRe.ReplaceAllString(s, "[$2]($1)")
+
+ // Then, replace bare links: [[url]] -> [url](url)
+ s = orgLinkBareRe.ReplaceAllStringFunc(s, func(match string) string {
+ // Extract the URL from [[url]]
+ url := orgLinkBareRe.FindStringSubmatch(match)[1]
+ return "[" + url + "](" + url + ")"
+ })
+
+ return s
+}
+
+// cleanOrgTitle removes org-mode artifacts from a title.
+// - Converts org links to markdown
+// - Removes trailing tags like :ARCHIVE:, :CANX:, etc.
+func cleanOrgTitle(s string) string {
+ // Remove trailing tags
+ s = orgTagsRe.ReplaceAllString(s, "")
+ // Convert links
+ s = convertOrgLinksToMarkdown(s)
+ return strings.TrimSpace(s)
+}
tools/review-tool/internal/sources/org_test.go
@@ -335,3 +335,162 @@ CLOSED: [2026-01-23 Thu 16:00]
t.Error("did not find task from extensionless archive")
}
}
+
+func TestOrgSource_TracksSection(t *testing.T) {
+ dir := t.TempDir()
+ content := `#+title: Test TODOs
+
+* Work
+** DONE Task in Work section
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 10:00]
+:END:
+
+* Systems
+** DONE Task in Systems section
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 11:00]
+:END:
+
+* Projects
+** DONE Task in Projects section
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 12:00]
+:END:
+`
+ filePath := filepath.Join(dir, "todos.org")
+ if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write test org file: %v", err)
+ }
+
+ cfg := &config.OrgConfig{
+ Enabled: true,
+ Files: []string{filePath},
+ IncludeDone: true,
+ IncludeStateChanges: true,
+ }
+
+ source := NewOrgSource(cfg)
+ start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 1, 26, 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) != 3 {
+ t.Fatalf("expected 3 items, got %d", len(act.Items))
+ }
+
+ // Check section metadata
+ sections := make(map[string]string)
+ for _, item := range act.Items {
+ sections[item.Title] = item.Metadata["section"]
+ }
+
+ if sections["Task in Work section"] != "Work" {
+ t.Errorf("Work task section = %q, want %q", sections["Task in Work section"], "Work")
+ }
+ if sections["Task in Systems section"] != "Systems" {
+ t.Errorf("Systems task section = %q, want %q", sections["Task in Systems section"], "Systems")
+ }
+ if sections["Task in Projects section"] != "Projects" {
+ t.Errorf("Projects task section = %q, want %q", sections["Task in Projects section"], "Projects")
+ }
+}
+
+func TestOrgSource_ConvertsOrgLinksToMarkdown(t *testing.T) {
+ dir := t.TempDir()
+ content := `#+title: Test TODOs
+
+* Work
+** DONE Review [[https://github.com/org/repo/pull/123][PR #123 for feature]]
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 10:00]
+:END:
+
+** DONE Check [[https://example.com]]
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 11:00]
+:END:
+
+** DONE Look at [[file:notes.org][my notes]] and [[https://docs.example.com][docs]]
+:LOGBOOK:
+- State "DONE" from "TODO" [2026-01-25 Sat 12:00]
+:END:
+`
+ filePath := filepath.Join(dir, "todos.org")
+ if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write test org file: %v", err)
+ }
+
+ cfg := &config.OrgConfig{
+ Enabled: true,
+ Files: []string{filePath},
+ IncludeDone: true,
+ IncludeStateChanges: true,
+ }
+
+ source := NewOrgSource(cfg)
+ start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 1, 26, 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) != 3 {
+ t.Fatalf("expected 3 items, got %d", len(act.Items))
+ }
+
+ // Check link conversion
+ titles := make(map[string]bool)
+ for _, item := range act.Items {
+ titles[item.Title] = true
+ t.Logf("Title: %s", item.Title)
+ }
+
+ // [[url][desc]] -> [desc](url)
+ if !titles["Review [PR #123 for feature](https://github.com/org/repo/pull/123)"] {
+ t.Error("expected link with description to be converted")
+ }
+
+ // [[url]] -> [url](url)
+ if !titles["Check [https://example.com](https://example.com)"] {
+ t.Error("expected bare link to be converted")
+ }
+
+ // Multiple links in one title
+ if !titles["Look at [my notes](file:notes.org) and [docs](https://docs.example.com)"] {
+ t.Error("expected multiple links to be converted")
+ }
+}
+
+func TestConvertOrgLinksToMarkdown(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ // Link with description
+ {"[[https://example.com][Example]]", "[Example](https://example.com)"},
+ // Bare link
+ {"[[https://example.com]]", "[https://example.com](https://example.com)"},
+ // Multiple links
+ {"Check [[https://a.com][A]] and [[https://b.com][B]]", "Check [A](https://a.com) and [B](https://b.com)"},
+ // File link
+ {"[[file:notes.org][Notes]]", "[Notes](file:notes.org)"},
+ // No links
+ {"Plain text without links", "Plain text without links"},
+ // Denote link
+ {"[[denote:20250312T050706][Vibhav Bobade]]", "[Vibhav Bobade](denote:20250312T050706)"},
+ }
+
+ for _, tt := range tests {
+ got := convertOrgLinksToMarkdown(tt.input)
+ if got != tt.expected {
+ t.Errorf("convertOrgLinksToMarkdown(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ }
+}