flake-update-20260201
1package sources
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7 "time"
8
9 "github.com/vdemeester/home/tools/review-tool/internal/config"
10)
11
12func setupTestOrgFile(t *testing.T) string {
13 t.Helper()
14 dir := t.TempDir()
15
16 content := `#+title: Test TODOs
17
18* Work
19** DONE [#2] Review PR for feature X
20CLOSED: [2026-01-25 Sat 15:30] SCHEDULED: <2026-01-25 Sat>
21:PROPERTIES:
22:CREATED: [2026-01-20 Tue 10:00]
23:CATEGORY: work
24:END:
25:LOGBOOK:
26- State "DONE" from "TODO" [2026-01-25 Sat 15:30]
27:END:
28
29Reviewed and merged the PR.
30
31** DONE Fix critical bug in authentication
32CLOSED: [2026-01-22 Wed 11:15]
33:LOGBOOK:
34- State "DONE" from "STRT" [2026-01-22 Wed 11:15]
35- State "STRT" from "TODO" [2026-01-22 Wed 09:00]
36CLOCK: [2026-01-22 Wed 09:00]--[2026-01-22 Wed 11:15] => 2:15
37:END:
38
39Fixed the authentication bug.
40
41** TODO Pending task (should not appear)
42SCHEDULED: <2026-01-28 Tue>
43
44This is a pending task.
45
46* Projects
47** DONE Keyboard firmware update
48CLOSED: [2026-01-15 Wed 18:00]
49:LOGBOOK:
50- State "DONE" from "TODO" [2026-01-15 Wed 18:00]
51:END:
52
53Updated firmware for both keyboards.
54`
55 filePath := filepath.Join(dir, "todos.org")
56 if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
57 t.Fatalf("failed to write test org file: %v", err)
58 }
59
60 return filePath
61}
62
63func TestOrgSource_ParsesStateChanges(t *testing.T) {
64 filePath := setupTestOrgFile(t)
65
66 cfg := &config.OrgConfig{
67 Enabled: true,
68 Files: []string{filePath},
69 IncludeDone: true,
70 IncludeStateChanges: true,
71 IncludeClockEntries: false,
72 }
73
74 source := NewOrgSource(cfg)
75
76 // Query for late January 2026
77 start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
78 end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
79
80 act, err := source.Fetch(t.Context(), start, end)
81 if err != nil {
82 t.Fatalf("Fetch() error = %v", err)
83 }
84
85 // Should find 2 items (the 2 that were marked DONE in range)
86 // Not the one from Jan 15 (out of range) or pending task
87 if len(act.Items) < 2 {
88 t.Errorf("expected at least 2 items, got %d", len(act.Items))
89 for _, item := range act.Items {
90 t.Logf(" %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
91 }
92 }
93
94 // Check for specific items
95 foundReviewPR := false
96 foundAuthBug := false
97 for _, item := range act.Items {
98 if item.Title == "Review PR for feature X" {
99 foundReviewPR = true
100 if item.Timestamp.Day() != 25 {
101 t.Errorf("Review PR date = %d, want 25", item.Timestamp.Day())
102 }
103 }
104 if item.Title == "Fix critical bug in authentication" {
105 foundAuthBug = true
106 if item.Timestamp.Day() != 22 {
107 t.Errorf("Auth bug date = %d, want 22", item.Timestamp.Day())
108 }
109 }
110 }
111
112 if !foundReviewPR {
113 t.Error("did not find 'Review PR for feature X'")
114 }
115 if !foundAuthBug {
116 t.Error("did not find 'Fix critical bug in authentication'")
117 }
118}
119
120func TestOrgSource_ParsesClockEntries(t *testing.T) {
121 filePath := setupTestOrgFile(t)
122
123 cfg := &config.OrgConfig{
124 Enabled: true,
125 Files: []string{filePath},
126 IncludeDone: false,
127 IncludeStateChanges: false,
128 IncludeClockEntries: true,
129 }
130
131 source := NewOrgSource(cfg)
132
133 start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
134 end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
135
136 act, err := source.Fetch(t.Context(), start, end)
137 if err != nil {
138 t.Fatalf("Fetch() error = %v", err)
139 }
140
141 // Should find 1 clock entry
142 if len(act.Items) != 1 {
143 t.Errorf("expected 1 clock entry, got %d", len(act.Items))
144 for _, item := range act.Items {
145 t.Logf(" %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
146 }
147 return
148 }
149
150 item := act.Items[0]
151 if item.Type != "clock_entry" {
152 t.Errorf("Type = %q, want %q", item.Type, "clock_entry")
153 }
154 if item.Metadata["duration"] != "2:15" {
155 t.Errorf("duration = %q, want %q", item.Metadata["duration"], "2:15")
156 }
157}
158
159func TestOrgSource_FiltersOutsideDateRange(t *testing.T) {
160 filePath := setupTestOrgFile(t)
161
162 cfg := &config.OrgConfig{
163 Enabled: true,
164 Files: []string{filePath},
165 IncludeDone: true,
166 IncludeStateChanges: true,
167 IncludeClockEntries: true,
168 }
169
170 source := NewOrgSource(cfg)
171
172 // Query for December 2025 - should return nothing
173 start := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)
174 end := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC)
175
176 act, err := source.Fetch(t.Context(), start, end)
177 if err != nil {
178 t.Fatalf("Fetch() error = %v", err)
179 }
180
181 if len(act.Items) != 0 {
182 t.Errorf("expected 0 items outside date range, got %d", len(act.Items))
183 }
184}
185
186func TestOrgSource_ExtractsHeadingTitle(t *testing.T) {
187 filePath := setupTestOrgFile(t)
188
189 cfg := &config.OrgConfig{
190 Enabled: true,
191 Files: []string{filePath},
192 IncludeDone: true,
193 IncludeStateChanges: true,
194 IncludeClockEntries: false,
195 }
196
197 source := NewOrgSource(cfg)
198
199 start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
200 end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
201
202 act, err := source.Fetch(t.Context(), start, end)
203 if err != nil {
204 t.Fatalf("Fetch() error = %v", err)
205 }
206
207 // All items should have cleaned titles (no TODO state, no priority)
208 for _, item := range act.Items {
209 if item.Title == "" {
210 t.Error("found item with empty title")
211 }
212 // Title should not contain TODO/DONE keywords
213 if containsKeyword(item.Title) {
214 t.Errorf("title contains org keyword: %q", item.Title)
215 }
216 }
217}
218
219func containsKeyword(s string) bool {
220 keywords := []string{"TODO", "DONE", "STRT", "NEXT", "WAIT", "CANX"}
221 for _, kw := range keywords {
222 if len(s) >= len(kw) && s[:len(kw)] == kw {
223 return true
224 }
225 }
226 return false
227}
228
229func TestOrgSource_ParsesArchiveDirectory(t *testing.T) {
230 // Create main org file
231 mainDir := t.TempDir()
232 mainContent := `#+title: Main TODOs
233
234* Work
235** DONE Main task done this week
236:LOGBOOK:
237- State "DONE" from "TODO" [2026-01-25 Sat 10:00]
238:END:
239`
240 mainFile := filepath.Join(mainDir, "todos.org")
241 if err := os.WriteFile(mainFile, []byte(mainContent), 0o644); err != nil {
242 t.Fatalf("failed to write main org file: %v", err)
243 }
244
245 // Create archive directory with archived TODOs
246 archiveDir := filepath.Join(mainDir, "archive")
247 if err := os.MkdirAll(archiveDir, 0o755); err != nil {
248 t.Fatalf("failed to create archive directory: %v", err)
249 }
250
251 // Archive file WITH .org extension
252 archiveContent := `#+title: Archived Work
253
254* Archived
255** DONE Archived task from last week
256:LOGBOOK:
257- State "DONE" from "TODO" [2026-01-22 Wed 14:30]
258:END:
259`
260 archiveFile := filepath.Join(archiveDir, "work.org")
261 if err := os.WriteFile(archiveFile, []byte(archiveContent), 0o644); err != nil {
262 t.Fatalf("failed to write archive org file: %v", err)
263 }
264
265 // Archive file WITHOUT extension (common org-mode archive format)
266 noExtContent := `# -*- mode: org -*-
267
268Archived entries from file /home/user/todos.org
269
270* DONE Task from extensionless archive
271CLOSED: [2026-01-23 Thu 16:00]
272:PROPERTIES:
273:ARCHIVE_TIME: 2026-01-23 Thu 16:00
274:END:
275:LOGBOOK:
276- State "DONE" from "TODO" [2026-01-23 Thu 16:00]
277:END:
278`
279 noExtFile := filepath.Join(archiveDir, "work")
280 if err := os.WriteFile(noExtFile, []byte(noExtContent), 0o644); err != nil {
281 t.Fatalf("failed to write extensionless archive file: %v", err)
282 }
283
284 cfg := &config.OrgConfig{
285 Enabled: true,
286 Files: []string{mainFile},
287 ArchiveDir: archiveDir,
288 IncludeDone: true,
289 IncludeStateChanges: true,
290 IncludeClockEntries: false,
291 }
292
293 source := NewOrgSource(cfg)
294
295 start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
296 end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
297
298 act, err := source.Fetch(t.Context(), start, end)
299 if err != nil {
300 t.Fatalf("Fetch() error = %v", err)
301 }
302
303 // Should find all 3 tasks: main + .org archive + extensionless archive
304 if len(act.Items) != 3 {
305 t.Errorf("expected 3 items (main + 2 archives), got %d", len(act.Items))
306 for _, item := range act.Items {
307 t.Logf(" %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
308 }
309 return
310 }
311
312 // Check for all tasks
313 foundMain := false
314 foundArchived := false
315 foundNoExt := false
316 for _, item := range act.Items {
317 if item.Title == "Main task done this week" {
318 foundMain = true
319 }
320 if item.Title == "Archived task from last week" {
321 foundArchived = true
322 }
323 if item.Title == "Task from extensionless archive" {
324 foundNoExt = true
325 }
326 }
327
328 if !foundMain {
329 t.Error("did not find main task")
330 }
331 if !foundArchived {
332 t.Error("did not find archived task (.org)")
333 }
334 if !foundNoExt {
335 t.Error("did not find task from extensionless archive")
336 }
337}
338
339func TestOrgSource_TracksSection(t *testing.T) {
340 dir := t.TempDir()
341 content := `#+title: Test TODOs
342
343* Work
344** DONE Task in Work section
345:LOGBOOK:
346- State "DONE" from "TODO" [2026-01-25 Sat 10:00]
347:END:
348
349* Systems
350** DONE Task in Systems section
351:LOGBOOK:
352- State "DONE" from "TODO" [2026-01-25 Sat 11:00]
353:END:
354
355* Projects
356** DONE Task in Projects section
357:LOGBOOK:
358- State "DONE" from "TODO" [2026-01-25 Sat 12:00]
359:END:
360`
361 filePath := filepath.Join(dir, "todos.org")
362 if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
363 t.Fatalf("failed to write test org file: %v", err)
364 }
365
366 cfg := &config.OrgConfig{
367 Enabled: true,
368 Files: []string{filePath},
369 IncludeDone: true,
370 IncludeStateChanges: true,
371 }
372
373 source := NewOrgSource(cfg)
374 start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
375 end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
376
377 act, err := source.Fetch(t.Context(), start, end)
378 if err != nil {
379 t.Fatalf("Fetch() error = %v", err)
380 }
381
382 if len(act.Items) != 3 {
383 t.Fatalf("expected 3 items, got %d", len(act.Items))
384 }
385
386 // Check section metadata
387 sections := make(map[string]string)
388 for _, item := range act.Items {
389 sections[item.Title] = item.Metadata["section"]
390 }
391
392 if sections["Task in Work section"] != "Work" {
393 t.Errorf("Work task section = %q, want %q", sections["Task in Work section"], "Work")
394 }
395 if sections["Task in Systems section"] != "Systems" {
396 t.Errorf("Systems task section = %q, want %q", sections["Task in Systems section"], "Systems")
397 }
398 if sections["Task in Projects section"] != "Projects" {
399 t.Errorf("Projects task section = %q, want %q", sections["Task in Projects section"], "Projects")
400 }
401}
402
403func TestOrgSource_ConvertsOrgLinksToMarkdown(t *testing.T) {
404 dir := t.TempDir()
405 content := `#+title: Test TODOs
406
407* Work
408** DONE Review [[https://github.com/org/repo/pull/123][PR #123 for feature]]
409:LOGBOOK:
410- State "DONE" from "TODO" [2026-01-25 Sat 10:00]
411:END:
412
413** DONE Check [[https://example.com]]
414:LOGBOOK:
415- State "DONE" from "TODO" [2026-01-25 Sat 11:00]
416:END:
417
418** DONE Look at [[file:notes.org][my notes]] and [[https://docs.example.com][docs]]
419:LOGBOOK:
420- State "DONE" from "TODO" [2026-01-25 Sat 12:00]
421:END:
422`
423 filePath := filepath.Join(dir, "todos.org")
424 if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
425 t.Fatalf("failed to write test org file: %v", err)
426 }
427
428 cfg := &config.OrgConfig{
429 Enabled: true,
430 Files: []string{filePath},
431 IncludeDone: true,
432 IncludeStateChanges: true,
433 }
434
435 source := NewOrgSource(cfg)
436 start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
437 end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
438
439 act, err := source.Fetch(t.Context(), start, end)
440 if err != nil {
441 t.Fatalf("Fetch() error = %v", err)
442 }
443
444 if len(act.Items) != 3 {
445 t.Fatalf("expected 3 items, got %d", len(act.Items))
446 }
447
448 // Check link conversion
449 titles := make(map[string]bool)
450 for _, item := range act.Items {
451 titles[item.Title] = true
452 t.Logf("Title: %s", item.Title)
453 }
454
455 // [[url][desc]] -> [desc](url)
456 if !titles["Review [PR #123 for feature](https://github.com/org/repo/pull/123)"] {
457 t.Error("expected link with description to be converted")
458 }
459
460 // [[url]] -> [url](url)
461 if !titles["Check [https://example.com](https://example.com)"] {
462 t.Error("expected bare link to be converted")
463 }
464
465 // Multiple links in one title
466 if !titles["Look at [my notes](file:notes.org) and [docs](https://docs.example.com)"] {
467 t.Error("expected multiple links to be converted")
468 }
469}
470
471func TestConvertOrgLinksToMarkdown(t *testing.T) {
472 tests := []struct {
473 input string
474 expected string
475 }{
476 // Link with description
477 {"[[https://example.com][Example]]", "[Example](https://example.com)"},
478 // Bare link
479 {"[[https://example.com]]", "[https://example.com](https://example.com)"},
480 // Multiple links
481 {"Check [[https://a.com][A]] and [[https://b.com][B]]", "Check [A](https://a.com) and [B](https://b.com)"},
482 // File link
483 {"[[file:notes.org][Notes]]", "[Notes](file:notes.org)"},
484 // No links
485 {"Plain text without links", "Plain text without links"},
486 // Denote link
487 {"[[denote:20250312T050706][Vibhav Bobade]]", "[Vibhav Bobade](denote:20250312T050706)"},
488 }
489
490 for _, tt := range tests {
491 got := convertOrgLinksToMarkdown(tt.input)
492 if got != tt.expected {
493 t.Errorf("convertOrgLinksToMarkdown(%q) = %q, want %q", tt.input, got, tt.expected)
494 }
495 }
496}