Commit 775ec56a14e5

Vincent Demeester <vincent@sbr.pm>
2026-01-27 13:41:55
feat(review-tool): add org section tracking and link conversion
- Track parent section (level-1 heading) for each TODO item - Add section to metadata (Work, Projects, Systems, etc.) - Convert org-mode links to markdown format: - [[url][description]] -> [description](url) - [[url]] -> [url](url) - Strip org-mode tags from titles (:ARCHIVE:, :CANX:, etc.) - Display section and archive file path in markdown output - Add CANCELED to recognized TODO states for archive files - Add tests for section tracking and link conversion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9f3d4f3
Changed files (3)
tools
review-tool
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)
+		}
+	}
+}