Commit 17f512025467

Vincent Demeester <vincent@sbr.pm>
2026-03-27 11:30:00
feat(daily-plan): add review command with markdown output
Added 'daily-plan review [PERIOD]' for generating markdown activity reports. Supports periods: this/last week, this/last month, date ranges (YYYY-MM-DD..YYYY-MM-DD), and natural language. Combines all sources: org done, Jira, GitHub PRs, reviews, issues filed, discussions, comments, AI sessions.
1 parent 44ae208
Changed files (1)
tools
daily-plan
cmd
daily-plan
tools/daily-plan/cmd/daily-plan/main.go
@@ -88,6 +88,12 @@ func run(args []string) error {
 		return cmdPick(ctx, cfg)
 	case "weekly":
 		return cmdWeekly(ctx, cfg)
+	case "review":
+		period := "this week"
+		if len(args) > 1 {
+			period = strings.Join(args[1:], " ")
+		}
+		return cmdReview(ctx, cfg, period)
 	case "help", "-h", "--help":
 		printHelp()
 		return nil
@@ -774,6 +780,212 @@ func execCommand(name string, args ...string) (string, error) {
 	return stdout.String(), nil
 }
 
+func cmdReview(ctx context.Context, cfg *config.Config, period string) error {
+	start, end, err := parsePeriod(period)
+	if err != nil {
+		return fmt.Errorf("invalid period: %w", err)
+	}
+
+	startStr := start.Format("2006-01-02")
+	endStr := end.Format("2006-01-02")
+
+	// Fetch all sources (reuse cache)
+	orgDone, _ := cache.GetOrFetch(apiCache, "org:done:"+startStr, func() ([]org.DoneItem, error) {
+		return org.FetchDoneItems(cfg.Org.Files, cfg.Org.ArchiveDir, start, end)
+	})
+	aiItems, _ := cache.GetOrFetch(apiCache, "ai:items:"+startStr, func() ([]ai.Item, error) {
+		return ai.FetchItemsSince(cfg.AI.Dir, start, end)
+	})
+	completed, _ := cache.GetOrFetch(apiCache, "jira:completed:"+startStr, func() ([]jira.Issue, error) {
+		return jira.FetchCompletedSince(ctx, cfg.Jira.User, start)
+	})
+	merged, _ := cache.GetOrFetch(apiCache, "gh:merged:"+startStr, func() ([]github.Item, error) {
+		return github.FetchMergedPRsSince(ctx, cfg.GitHub.Username, start, cfg.GitHub.Owners)
+	})
+	reviewed, _ := cache.GetOrFetch(apiCache, "gh:reviewed:"+startStr, func() ([]github.Item, error) {
+		return github.FetchReviewsGivenSince(ctx, cfg.GitHub.Username, start, cfg.GitHub.Owners)
+	})
+	issuesCreated, _ := cache.GetOrFetch(apiCache, "gh:issues-created:"+startStr, func() ([]github.Item, error) {
+		return github.FetchIssuesCreatedSince(ctx, cfg.GitHub.Username, start, cfg.GitHub.Owners)
+	})
+
+	var discussions []github.DiscussionItem
+	var comments []github.CommentItem
+	if !noDiscussions {
+		discussions, _ = cache.GetOrFetch(apiCache, "gh:discussions:"+startStr, func() ([]github.DiscussionItem, error) {
+			return github.FetchDiscussionsSince(ctx, start)
+		})
+	}
+	if !noComments {
+		comments, _ = cache.GetOrFetch(apiCache, "gh:comments:"+startStr, func() ([]github.CommentItem, error) {
+			return github.FetchCommentsSince(ctx, start)
+		})
+	}
+
+	if outputJSON {
+		return emitJSON(jsonWeekly{
+			Week:            startStr,
+			NextMonday:      endStr,
+			OrgDone:         orgDoneToJSON(orgDone),
+			AISessions:      aiToJSON(aiItems),
+			JiraCompleted:   jiraToJSON(completed, cfg.Jira.BaseURL),
+			GHMerged:        ghToJSON(merged),
+			GHReviewed:      ghToJSON(reviewed),
+			GHIssuesCreated: ghToJSON(issuesCreated),
+			GHDiscussions:   discussionsToJSON(discussions),
+			GHComments:      commentsToJSON(comments),
+		})
+	}
+
+	// Markdown output
+	fmt.Printf("# Activity Review: %s to %s\n\n", startStr, endStr)
+
+	if len(orgDone) > 0 {
+		fmt.Println("## Completed Tasks (Org)")
+		// Group by section
+		bySection := make(map[string][]org.DoneItem)
+		var order []string
+		for _, item := range orgDone {
+			s := item.Section
+			if s == "" {
+				s = "(uncategorized)"
+			}
+			if _, exists := bySection[s]; !exists {
+				order = append(order, s)
+			}
+			bySection[s] = append(bySection[s], item)
+		}
+		for _, section := range order {
+			fmt.Printf("\n### %s\n\n", section)
+			for _, item := range bySection[section] {
+				fmt.Printf("- โœ“ %s (%s)\n", item.Title, item.CompletedAt.Format("2006-01-02"))
+			}
+		}
+		fmt.Println()
+	}
+
+	if len(completed) > 0 {
+		fmt.Println("\n## Completed (Jira)")
+		for _, issue := range completed {
+			fmt.Printf("- [%s](%s/%s) %s\n", issue.Key, cfg.Jira.BaseURL, issue.Key, issue.Summary)
+		}
+		fmt.Println()
+	}
+
+	if len(merged) > 0 {
+		fmt.Println("\n## Merged PRs")
+		for _, item := range merged {
+			fmt.Printf("- [%s](%s) %s\n", item.Ref(), item.URL(), item.Title)
+		}
+		fmt.Println()
+	}
+
+	if len(reviewed) > 0 {
+		fmt.Printf("## Reviews Given (%d)\n\n", len(reviewed))
+		for _, item := range reviewed {
+			fmt.Printf("- [%s](%s) %s\n", item.Ref(), item.URL(), item.Title)
+		}
+		fmt.Println()
+	}
+
+	if len(issuesCreated) > 0 {
+		fmt.Printf("## Issues Filed (%d)\n\n", len(issuesCreated))
+		for _, item := range issuesCreated {
+			fmt.Printf("- [%s](%s) %s\n", item.Ref(), item.URL(), item.Title)
+		}
+		fmt.Println()
+	}
+
+	if !noDiscussions && len(discussions) > 0 {
+		fmt.Printf("## Discussions (%d)\n\n", len(discussions))
+		for _, d := range discussions {
+			prefix := ""
+			if d.Type == "discussion_comment" {
+				prefix = "Comment: "
+			}
+			fmt.Printf("- [%s%s](%s) (%s)\n", prefix, d.Title, d.URL, d.Repo)
+		}
+		fmt.Println()
+	}
+
+	if !noComments && len(comments) > 0 {
+		fmt.Printf("## Comments (%d)\n\n", len(comments))
+		for _, c := range comments {
+			fmt.Printf("- [%s#%d](%s) %s\n", c.Repo, c.IssueNumber, c.URL, c.IssueTitle)
+		}
+		fmt.Println()
+	}
+
+	if len(aiItems) > 0 {
+		// Filter auto-recovered
+		var filtered []ai.Item
+		for _, item := range aiItems {
+			if !strings.Contains(strings.ToLower(item.Title), "auto-recovered") {
+				filtered = append(filtered, item)
+			}
+		}
+		if len(filtered) > 0 {
+			fmt.Printf("## AI Sessions (%d)\n\n", len(filtered))
+			for _, item := range filtered {
+				project := ""
+				if item.Project != "" {
+					project = fmt.Sprintf(" [%s]", item.Project)
+				}
+				fmt.Printf("- **%s** %s%s (%s)\n", item.Type, item.Title, project, item.Date.Format("2006-01-02"))
+			}
+			fmt.Println()
+		}
+	}
+
+	return nil
+}
+
+func parsePeriod(period string) (time.Time, time.Time, error) {
+	now := time.Now()
+
+	switch strings.ToLower(strings.TrimSpace(period)) {
+	case "this week", "week", "":
+		daysFromMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7
+		monday := now.AddDate(0, 0, -daysFromMonday)
+		monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location())
+		return monday, now, nil
+	case "last week":
+		daysFromMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7
+		thisMonday := now.AddDate(0, 0, -daysFromMonday)
+		lastMonday := thisMonday.AddDate(0, 0, -7)
+		lastMonday = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, now.Location())
+		thisMondayStart := time.Date(thisMonday.Year(), thisMonday.Month(), thisMonday.Day(), 0, 0, 0, 0, now.Location())
+		return lastMonday, thisMondayStart, nil
+	case "this month", "month":
+		firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+		return firstOfMonth, now, nil
+	case "last month":
+		firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+		firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
+		return firstOfLastMonth, firstOfThisMonth, nil
+	}
+
+	// Try YYYY-MM-DD..YYYY-MM-DD range
+	if parts := strings.SplitN(period, "..", 2); len(parts) == 2 {
+		start, err := time.Parse("2006-01-02", strings.TrimSpace(parts[0]))
+		if err != nil {
+			return time.Time{}, time.Time{}, fmt.Errorf("invalid start date: %s", parts[0])
+		}
+		end, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
+		if err != nil {
+			return time.Time{}, time.Time{}, fmt.Errorf("invalid end date: %s", parts[1])
+		}
+		return start, end.Add(24 * time.Hour), nil
+	}
+
+	// Try natural language via GNU date
+	start, err := parseNaturalDate(period)
+	if err != nil {
+		return time.Time{}, time.Time{}, err
+	}
+	return start, now, nil
+}
+
 func printHelp() {
 	fmt.Println(`daily-plan โ€” pull from Jira/GitHub into org-mode planning
 
@@ -783,11 +995,12 @@ Commands:
   pick                    Interactive pick with jayrat โ†’ schedule
   schedule KEY [DATE]     Schedule a Jira/GH issue (PROJ-123 or org/repo#42)
   weekly                  Weekly review: completed, in progress, backlog
+  review [PERIOD]         Full activity review as markdown (for reports)
 
 Flags:
   --json                  Output as JSON (for Emacs/scripting integration)
-  --no-discussions        Skip GitHub discussions in weekly
-  --no-comments           Skip GitHub comments in weekly
+  --no-discussions        Skip GitHub discussions
+  --no-comments           Skip GitHub comments
 
 Date formats:
   YYYY-MM-DD              Explicit date
@@ -804,5 +1017,10 @@ Examples:
   daily-plan schedule SRVKP-11036 2026-03-28
   daily-plan schedule tektoncd/pipeline#9149 tomorrow
   daily-plan pick
-  daily-plan weekly`)
+  daily-plan weekly
+  daily-plan review
+  daily-plan review "last week"
+  daily-plan review "this month"
+  daily-plan review "2026-03-01..2026-03-15"`)
+
 }