Commit 64b34e381415
Changed files (15)
dots
config
emacs
site-lisp
jayrat
pkgs
systems
kyushu
tools
daily-plan
cmd
daily-plan
internal
dots/config/emacs/site-lisp/daily-plan.el
@@ -0,0 +1,1 @@
+../../../../tools/daily-plan/daily-plan.el
\ No newline at end of file
dots/config/emacs/init.el
@@ -1507,6 +1507,25 @@ minibuffer, even without explicitly focusing it."
(let ((url (format "https://issues.redhat.com/browse/%s" ticket)))
(kill-new url)
(message "Copied: %s" url)))
+
+ ;; Schedule actions (daily-plan integration)
+ (declare-function my/schedule-jira-ticket "init")
+ (declare-function my/schedule-github-issue "init")
+
+ (defun my/schedule-jira-ticket (ticket)
+ "Schedule Jira TICKET via daily-plan."
+ (let ((date (org-read-date nil nil nil (format "Schedule %s for: " ticket))))
+ (message "%s" (string-trim (shell-command-to-string
+ (format "daily-plan schedule %s %s" ticket date))))))
+
+ (defun my/schedule-github-issue (issue)
+ "Schedule GitHub ISSUE via daily-plan."
+ (let ((date (org-read-date nil nil nil (format "Schedule %s for: " issue))))
+ (message "%s" (string-trim (shell-command-to-string
+ (format "daily-plan schedule %s %s" issue date))))))
+
+ (define-key embark-jira-ticket-map "s" #'my/schedule-jira-ticket)
+ (define-key embark-github-issue-map "s" #'my/schedule-github-issue)
)
(use-package embark-consult
@@ -2217,6 +2236,21 @@ parameter), remove all other windows so the capture buffer fills the frame."
:after org-capture
:demand t)
+;; Daily Plan - Jira/GitHub → org-mode scheduling
+(use-package daily-plan
+ :commands (daily-plan-show daily-plan-inbox daily-plan-weekly
+ daily-plan-schedule daily-plan-schedule-at-point)
+ :bind-keymap ("C-c d" . daily-plan-prefix-map)
+ :config
+ (defvar daily-plan-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "d" #'daily-plan-show)
+ (define-key map "i" #'daily-plan-inbox)
+ (define-key map "w" #'daily-plan-weekly)
+ (define-key map "s" #'daily-plan-schedule)
+ map)
+ "Keymap for daily-plan commands under C-c d."))
+
(use-package org-habit
:after org
:custom
dots/config/jayrat/config.yaml
@@ -87,3 +87,41 @@ boards:
description: Team backlog
jql: "project = SRVKP AND statusCategory != Done"
order_by: priority
+
+ # ── Daily Planning boards ──
+ - name: daily-active
+ description: "Daily plan: my active work (In Progress + Code Review)"
+ jql: 'assignee = vdemeest AND status in ("In Progress", "Code Review") AND resolution = Unresolved'
+ order_by: priority
+ columns:
+ - key
+ - summary
+ - status
+ - priority
+ - name: daily-backlog
+ description: "Daily plan: my backlog to pick from"
+ jql: 'assignee = vdemeest AND status in ("To Do", "New") AND resolution = Unresolved'
+ order_by: priority
+ columns:
+ - key
+ - summary
+ - priority
+ - type
+ - name: daily-cves
+ description: "Daily plan: new security issues (last 7 days)"
+ jql: 'project = SRVKP AND labels = Security AND created >= -7d AND resolution = Unresolved'
+ order_by: priority
+ columns:
+ - key
+ - summary
+ - priority
+ - status
+ - name: daily-team
+ description: "Daily plan: team active work (for standup)"
+ jql: 'project = SRVKP AND status in ("In Progress", "Code Review") AND resolution = Unresolved'
+ order_by: assignee
+ columns:
+ - key
+ - summary
+ - status
+ - assignee
pkgs/default.nix
@@ -35,6 +35,7 @@ in
slack-archive = pkgs.callPackage ../tools/slack-archive { };
gcal-to-org = pkgs.callPackage ../tools/gcal-to-org { };
review-tool = pkgs.callPackage ../tools/review-tool { };
+ daily-plan = pkgs.callPackage ../tools/daily-plan { };
github-notif-manager = pkgs.callPackage ../tools/github-notif-manager { };
beets-lidarr-fields = pkgs.python3Packages.callPackage ./beets-lidarr-fields { };
beets-filetote = pkgs.python3Packages.callPackage ./beets-filetote { };
systems/kyushu/extra.nix
@@ -165,6 +165,7 @@ in
roadmode
jayrah
jayrat
+ daily-plan
# backup
virt-manager
];
tools/daily-plan/cmd/daily-plan/main.go
@@ -0,0 +1,543 @@
+// Package main provides the daily-plan CLI.
+//
+// daily-plan pulls work items from Jira and GitHub into an org-mode
+// daily planning workflow. Jira/GitHub are the source of truth;
+// org-mode provides scheduling and daily agenda.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/vdemeester/home/tools/daily-plan/internal/config"
+ "github.com/vdemeester/home/tools/daily-plan/internal/display"
+ "github.com/vdemeester/home/tools/daily-plan/internal/github"
+ "github.com/vdemeester/home/tools/daily-plan/internal/jira"
+ "github.com/vdemeester/home/tools/daily-plan/internal/org"
+)
+
+var outputJSON bool
+
+func main() {
+ if err := run(os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string) error {
+ cfg := config.DefaultConfig()
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ // Strip --json flag from anywhere in args
+ var filtered []string
+ for _, a := range args {
+ if a == "--json" {
+ outputJSON = true
+ } else {
+ filtered = append(filtered, a)
+ }
+ }
+ args = filtered
+
+ if len(args) == 0 {
+ return cmdShow(ctx, cfg)
+ }
+
+ switch args[0] {
+ case "show":
+ return cmdShow(ctx, cfg)
+ case "inbox":
+ sinceStr := ""
+ if len(args) > 1 {
+ sinceStr = args[1]
+ }
+ return cmdInbox(ctx, cfg, sinceStr)
+ case "schedule":
+ if len(args) < 2 {
+ return fmt.Errorf("usage: daily-plan schedule KEY [DATE]")
+ }
+ dateStr := ""
+ if len(args) > 2 {
+ dateStr = args[2]
+ }
+ return cmdSchedule(ctx, cfg, args[1], dateStr)
+ case "pick":
+ return cmdPick(ctx, cfg)
+ case "weekly":
+ return cmdWeekly(ctx, cfg)
+ case "help", "-h", "--help":
+ printHelp()
+ return nil
+ default:
+ return fmt.Errorf("unknown command: %s (try 'daily-plan help')", args[0])
+ }
+}
+
+// JSON output types for Emacs integration.
+type jsonShow struct {
+ Date string `json:"date"`
+ Agenda []jsonOrg `json:"agenda"`
+ JiraInProgress []jsonJira `json:"jira_in_progress"`
+ JiraBacklog []jsonJira `json:"jira_backlog"`
+ GHIssues []jsonGH `json:"github_issues"`
+ GHReviews []jsonGH `json:"github_reviews"`
+ GHPRs []jsonGH `json:"github_prs"`
+}
+
+type jsonInbox struct {
+ Since string `json:"since"`
+ CVEs []jsonJira `json:"cves"`
+ CVETotal int `json:"cve_total"`
+ Updated []jsonJira `json:"jira_updated"`
+ NewIssues []jsonGH `json:"github_new_issues"`
+ NewPRs []jsonGH `json:"github_new_prs"`
+ Reviews []jsonGH `json:"github_reviews"`
+}
+
+type jsonOrg struct {
+ State string `json:"state"`
+ Heading string `json:"heading"`
+}
+
+type jsonJira struct {
+ Key string `json:"key"`
+ Summary string `json:"summary"`
+ Status string `json:"status"`
+ Priority string `json:"priority,omitempty"`
+ URL string `json:"url"`
+}
+
+type jsonGH struct {
+ Repo string `json:"repo"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Author string `json:"author,omitempty"`
+ CreatedAt string `json:"created_at"`
+ URL string `json:"url"`
+}
+
+func jiraToJSON(issues []jira.Issue, baseURL string) []jsonJira {
+ out := make([]jsonJira, 0, len(issues))
+ for _, i := range issues {
+ out = append(out, jsonJira{
+ Key: i.Key,
+ Summary: i.Summary,
+ Status: i.Status,
+ Priority: i.Priority,
+ URL: fmt.Sprintf("%s/%s", baseURL, i.Key),
+ })
+ }
+ return out
+}
+
+func ghToJSON(items []github.Item) []jsonGH {
+ out := make([]jsonGH, 0, len(items))
+ for _, i := range items {
+ out = append(out, jsonGH{
+ Repo: i.Repo,
+ Number: i.Number,
+ Title: i.Title,
+ Author: i.Author,
+ CreatedAt: i.CreatedAt.Format("2006-01-02"),
+ URL: i.URL(),
+ })
+ }
+ return out
+}
+
+func emitJSON(v any) error {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(v)
+}
+
+func cmdShow(ctx context.Context, cfg *config.Config) error {
+ today := time.Now().Format("2006-01-02")
+
+ agenda, _ := org.TodayItems(cfg.Org.File)
+ inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+ todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+ ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+ reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+ reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
+ myPRs, _ := github.FetchMyPRs(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+
+ if outputJSON {
+ orgItems := make([]jsonOrg, 0, len(agenda))
+ for _, a := range agenda {
+ orgItems = append(orgItems, jsonOrg{State: a.State, Heading: a.Heading})
+ }
+ return emitJSON(jsonShow{
+ Date: today,
+ Agenda: orgItems,
+ JiraInProgress: jiraToJSON(inprog, cfg.Jira.BaseURL),
+ JiraBacklog: jiraToJSON(todo, cfg.Jira.BaseURL),
+ GHIssues: ghToJSON(ghIssues),
+ GHReviews: ghToJSON(reviews),
+ GHPRs: ghToJSON(myPRs),
+ })
+ }
+
+ display.Header(fmt.Sprintf("Today's Org Agenda (%s)", today))
+ display.OrgItems(agenda)
+
+ display.Header("Jira — In Progress / Code Review")
+ display.JiraIssues(inprog, "active")
+
+ display.Header("Jira — To Do / Backlog")
+ display.JiraIssuesLimited(todo, 10, "dim")
+
+ display.Header("GitHub — Assigned Issues")
+ display.GitHubItems(ghIssues, "issue")
+
+ display.Header("GitHub — PRs Awaiting Your Review")
+ display.GitHubItems(reviews, "review")
+
+ display.Header("GitHub — Your Open PRs")
+ display.GitHubItems(myPRs, "pr")
+
+ display.Hint("Run 'daily-plan inbox' for new items, 'daily-plan schedule KEY' to schedule")
+ return nil
+}
+
+func cmdInbox(ctx context.Context, cfg *config.Config, sinceStr string) error {
+ since, err := parseSince(cfg, sinceStr)
+ if err != nil {
+ return fmt.Errorf("invalid date: %w", err)
+ }
+
+ cves, _ := jira.FetchSecuritySince(ctx, since)
+ grouped, total := jira.GroupByCVE(cves)
+ updated, _ := jira.FetchUpdatedSince(ctx, since)
+ newIssues, _ := github.FetchNewIssuesSince(ctx, since, cfg.GitHub.Owners)
+ newPRs, _ := github.FetchNewPRsSince(ctx, since, cfg.GitHub.Owners)
+ newPRs = github.FilterBots(newPRs, cfg.GitHub.BotFilters)
+ reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+ reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
+
+ if outputJSON {
+ updateLastCheck(cfg)
+ return emitJSON(jsonInbox{
+ Since: since.Format("2006-01-02"),
+ CVEs: jiraToJSON(grouped, cfg.Jira.BaseURL),
+ CVETotal: total,
+ Updated: jiraToJSON(updated, cfg.Jira.BaseURL),
+ NewIssues: ghToJSON(newIssues),
+ NewPRs: ghToJSON(newPRs),
+ Reviews: ghToJSON(reviews),
+ })
+ }
+
+ display.Header(fmt.Sprintf("New/Updated Since %s", since.Format("2006-01-02")))
+
+ display.SubHeader("Jira — New CVEs / Security Issues")
+ display.CVEIssues(grouped, total)
+
+ display.SubHeader("Jira — Updated Issues (assigned/reported by you)")
+ display.JiraIssues(updated, "active")
+
+ display.SubHeader("GitHub — New Issues (across tektoncd + openshift-pipelines)")
+ display.GitHubItems(newIssues, "issue")
+
+ display.SubHeader("GitHub — New PRs")
+ display.GitHubItems(newPRs, "pr")
+
+ display.SubHeader("GitHub — Review Requests")
+ display.GitHubItems(reviews, "review")
+
+ // Update last check
+ updateLastCheck(cfg)
+ display.Hint(fmt.Sprintf("Last check updated to %s. Run 'daily-plan schedule KEY' to schedule items.", time.Now().Format("2006-01-02")))
+ return nil
+}
+
+func cmdSchedule(ctx context.Context, cfg *config.Config, key, dateStr string) error {
+ schedDate, err := parseDate(dateStr)
+ if err != nil {
+ return err
+ }
+
+ jiraRe := regexp.MustCompile(`^[A-Z]+-\d+$`)
+ ghRe := regexp.MustCompile(`^(.+)#(\d+)$`)
+
+ if jiraRe.MatchString(key) {
+ if org.HasJiraKey(cfg.Org.File, key) {
+ return fmt.Errorf("%s already exists in %s — use org to reschedule", key, cfg.Org.File)
+ }
+ summary, err := jira.FetchSummary(ctx, key)
+ if err != nil {
+ return fmt.Errorf("could not find Jira issue %s: %w", key, err)
+ }
+ url := fmt.Sprintf("%s/%s", cfg.Jira.BaseURL, key)
+ if err := org.ScheduleJiraIssue(cfg.Org.File, cfg.Org.Section, key, summary, url, schedDate); err != nil {
+ return err
+ }
+ fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s\033[0m (%s) for %s\n", key, summary, schedDate.Format("2006-01-02"))
+ return nil
+ }
+
+ if m := ghRe.FindStringSubmatch(key); m != nil {
+ repo := m[1]
+ number, _ := strconv.Atoi(m[2])
+ if org.HasGitHubIssue(cfg.Org.File, repo, number) {
+ return fmt.Errorf("%s already exists in %s — use org to reschedule", key, cfg.Org.File)
+ }
+ summary, err := github.FetchIssueSummary(ctx, repo, number)
+ if err != nil {
+ return fmt.Errorf("could not find GitHub issue %s: %w", key, err)
+ }
+ url := fmt.Sprintf("https://github.com/%s/issues/%d", repo, number)
+ if err := org.ScheduleGitHubIssue(cfg.Org.File, cfg.Org.Section, repo, number, summary, url, schedDate); err != nil {
+ return err
+ }
+ fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s#%d\033[0m (%s) for %s\n", repo, number, summary, schedDate.Format("2006-01-02"))
+ return nil
+ }
+
+ return fmt.Errorf("unknown key format: %s (use PROJ-123 for Jira or owner/repo#123 for GitHub)", key)
+}
+
+func cmdPick(_ context.Context, cfg *config.Config) error {
+ tmpfile := fmt.Sprintf("/tmp/jayrat-pick-%d.json", os.Getpid())
+ defer os.Remove(tmpfile)
+
+ fmt.Println("\033[1mOpening jayrat to pick issues...\033[0m")
+ fmt.Println("\033[2mPress Enter on an issue to select it, q to quit\033[0m")
+ fmt.Println()
+
+ // jayrat needs a real TTY, use syscall exec to replace this process
+ jayratArgs := []string{"jayrat", "--board", "myissue", "--choose", tmpfile}
+
+ // Find jayrat binary
+ jayratPath, err := findBinary("jayrat")
+ if err != nil {
+ return fmt.Errorf("jayrat not found: %w", err)
+ }
+
+ // Fork-exec jayrat with the current terminal
+ proc, err := os.StartProcess(jayratPath, jayratArgs, &os.ProcAttr{
+ Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
+ })
+ if err != nil {
+ return fmt.Errorf("failed to start jayrat: %w", err)
+ }
+ state, err := proc.Wait()
+ if err != nil || !state.Success() {
+ return nil // User quit jayrat, that's fine
+ }
+
+ // Read the selected issue
+ data, err := os.ReadFile(tmpfile)
+ if err != nil || len(data) == 0 {
+ fmt.Println("\033[2mNo issue selected.\033[0m")
+ return nil
+ }
+
+ // Parse JSON and schedule
+ // For now, just extract key from the JSON
+ keyRe := regexp.MustCompile(`"key"\s*:\s*"([^"]+)"`)
+ summaryRe := regexp.MustCompile(`"summary"\s*:\s*"([^"]+)"`)
+
+ keyMatch := keyRe.FindSubmatch(data)
+ summaryMatch := summaryRe.FindSubmatch(data)
+
+ if keyMatch == nil {
+ fmt.Println("\033[2mCould not parse selected issue.\033[0m")
+ return nil
+ }
+
+ key := string(keyMatch[1])
+ summary := ""
+ if summaryMatch != nil {
+ summary = string(summaryMatch[1])
+ }
+
+ fmt.Printf("\nSelected: \033[1m%s\033[0m — %s\n", key, summary)
+ fmt.Print("Schedule for which date? [today/tomorrow/YYYY-MM-DD] (default: today): ")
+
+ var dateInput string
+ fmt.Scanln(&dateInput)
+ if dateInput == "" {
+ dateInput = "today"
+ }
+
+ schedDate, err := parseDate(dateInput)
+ if err != nil {
+ return err
+ }
+
+ url := fmt.Sprintf("%s/%s", cfg.Jira.BaseURL, key)
+ if err := org.ScheduleJiraIssue(cfg.Org.File, cfg.Org.Section, key, summary, url, schedDate); err != nil {
+ return err
+ }
+ fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s\033[0m (%s) for %s\n", key, summary, schedDate.Format("2006-01-02"))
+ return nil
+}
+
+type jsonWeekly struct {
+ Week string `json:"week"`
+ NextMonday string `json:"next_monday"`
+ JiraCompleted []jsonJira `json:"jira_completed"`
+ GHMerged []jsonGH `json:"github_merged"`
+ JiraInProgress []jsonJira `json:"jira_in_progress"`
+ JiraBacklog []jsonJira `json:"jira_backlog"`
+ GHIssues []jsonGH `json:"github_issues"`
+}
+
+func cmdWeekly(ctx context.Context, cfg *config.Config) error {
+ now := time.Now()
+ // Find last Monday
+ daysFromMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7
+ monday := now.AddDate(0, 0, -daysFromMonday)
+ nextMonday := monday.AddDate(0, 0, 7)
+
+ completed, _ := jira.FetchCompletedSince(ctx, cfg.Jira.User, monday)
+ merged, _ := github.FetchMergedPRsSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
+ inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+ todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+ ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+
+ if outputJSON {
+ return emitJSON(jsonWeekly{
+ Week: monday.Format("2006-01-02"),
+ NextMonday: nextMonday.Format("2006-01-02"),
+ JiraCompleted: jiraToJSON(completed, cfg.Jira.BaseURL),
+ GHMerged: ghToJSON(merged),
+ JiraInProgress: jiraToJSON(inprog, cfg.Jira.BaseURL),
+ JiraBacklog: jiraToJSON(todo, cfg.Jira.BaseURL),
+ GHIssues: ghToJSON(ghIssues),
+ })
+ }
+
+ display.Header(fmt.Sprintf("Weekly Review (week of %s)", monday.Format("2006-01-02")))
+
+ display.SubHeader("Completed This Week (Jira)")
+ display.JiraIssues(completed, "done")
+
+ display.SubHeader("Completed This Week (GitHub — Merged PRs)")
+ display.GitHubItems(merged, "done")
+
+ display.SubHeader("Still In Progress (Jira)")
+ display.JiraIssues(inprog, "active")
+
+ display.SubHeader("Backlog — Candidates for Next Week")
+ display.Hint(" Run 'daily-plan pick' to select and schedule items")
+ display.JiraIssuesLimited(todo, 15, "dim")
+
+ fmt.Println()
+ display.SubHeader("GitHub Issues (assigned)")
+ display.GitHubItems(ghIssues, "issue")
+
+ display.Hint(fmt.Sprintf("Schedule items: daily-plan schedule SRVKP-1234 %s", nextMonday.Format("2006-01-02")))
+ return nil
+}
+
+// ── Helpers ──
+
+func parseSince(cfg *config.Config, input string) (time.Time, error) {
+ if input == "" {
+ return readLastCheck(cfg)
+ }
+
+ // Try YYYY-MM-DD
+ if t, err := time.Parse("2006-01-02", input); err == nil {
+ return t, nil
+ }
+
+ // Try natural language via date command
+ return parseNaturalDate(input)
+}
+
+func parseDate(input string) (time.Time, error) {
+ if input == "" || input == "today" {
+ return time.Now(), nil
+ }
+ if input == "tomorrow" {
+ return time.Now().AddDate(0, 0, 1), nil
+ }
+ if t, err := time.Parse("2006-01-02", input); err == nil {
+ return t, nil
+ }
+ return parseNaturalDate(input)
+}
+
+func parseNaturalDate(input string) (time.Time, error) {
+ // Use GNU date for natural language parsing
+ out, err := execCommand("date", "-d", input, "+%Y-%m-%d")
+ if err != nil {
+ return time.Time{}, fmt.Errorf("cannot parse date %q", input)
+ }
+ return time.Parse("2006-01-02", strings.TrimSpace(out))
+}
+
+func readLastCheck(cfg *config.Config) (time.Time, error) {
+ data, err := os.ReadFile(cfg.LastCheckFile())
+ if err != nil {
+ // Default to yesterday
+ return time.Now().AddDate(0, 0, -1), nil
+ }
+ return time.Parse("2006-01-02", strings.TrimSpace(string(data)))
+}
+
+func updateLastCheck(cfg *config.Config) {
+ os.MkdirAll(cfg.StateDir, 0755)
+ os.WriteFile(cfg.LastCheckFile(), []byte(time.Now().Format("2006-01-02")), 0644)
+}
+
+func findBinary(name string) (string, error) {
+ out, err := execCommand("which", name)
+ if err != nil {
+ return "", fmt.Errorf("%s not found in PATH", name)
+ }
+ return strings.TrimSpace(out), nil
+}
+
+func execCommand(name string, args ...string) (string, error) {
+ cmd := exec.Command(name, args...)
+ var stdout strings.Builder
+ cmd.Stdout = &stdout
+ if err := cmd.Run(); err != nil {
+ return "", err
+ }
+ return stdout.String(), nil
+}
+
+func printHelp() {
+ fmt.Println(`daily-plan — pull from Jira/GitHub into org-mode planning
+
+Commands:
+ show Show today's plan + current work (default)
+ inbox [DATE] New/updated items since last check (or DATE)
+ 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
+
+Flags:
+ --json Output as JSON (for Emacs/scripting integration)
+
+Date formats:
+ YYYY-MM-DD Explicit date
+ today, tomorrow Relative
+ "last monday" Natural language (via GNU date)
+
+Examples:
+ daily-plan
+ daily-plan show --json
+ daily-plan inbox
+ daily-plan inbox 2026-03-20
+ daily-plan inbox "last monday" --json
+ daily-plan schedule SRVKP-11036
+ daily-plan schedule SRVKP-11036 2026-03-28
+ daily-plan schedule tektoncd/pipeline#9149 tomorrow
+ daily-plan pick
+ daily-plan weekly`)
+}
tools/daily-plan/internal/config/config.go
@@ -0,0 +1,76 @@
+// Package config handles daily-plan configuration.
+package config
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// Config holds the daily-plan configuration.
+type Config struct {
+ // Jira configuration
+ Jira JiraConfig
+
+ // GitHub configuration
+ GitHub GitHubConfig
+
+ // Org-mode configuration
+ Org OrgConfig
+
+ // State directory for last-check tracking
+ StateDir string
+}
+
+// JiraConfig configures Jira integration.
+type JiraConfig struct {
+ User string // Jira username (e.g. "vdemeest")
+ BaseURL string // Jira browse URL (e.g. "https://issues.redhat.com/browse")
+ Project string // Default project (e.g. "SRVKP")
+}
+
+// GitHubConfig configures GitHub integration.
+type GitHubConfig struct {
+ Username string // GitHub username
+ Owners []string // GitHub orgs/users to track
+ // BotFilters are author patterns to filter out from inbox
+ BotFilters []string
+}
+
+// OrgConfig configures org-mode integration.
+type OrgConfig struct {
+ File string // Path to todos.org
+ Section string // Section to add TODOs under (e.g. "Work")
+}
+
+// DefaultConfig returns sensible defaults.
+func DefaultConfig() *Config {
+ home, _ := os.UserHomeDir()
+ return &Config{
+ Jira: JiraConfig{
+ User: "vdemeest",
+ BaseURL: "https://issues.redhat.com/browse",
+ Project: "SRVKP",
+ },
+ GitHub: GitHubConfig{
+ Username: "vdemeester",
+ Owners: []string{"tektoncd", "openshift-pipelines"},
+ BotFilters: []string{
+ "openshift-pipelines-bot",
+ "red-hat-konflux",
+ "dependabot[bot]",
+ "github-actions[bot]",
+ "openshift-cherrypick-robot",
+ },
+ },
+ Org: OrgConfig{
+ File: filepath.Join(home, "desktop", "org", "todos.org"),
+ Section: "Work",
+ },
+ StateDir: filepath.Join(home, ".local", "share", "daily-plan"),
+ }
+}
+
+// LastCheckFile returns the path to the last-check timestamp file.
+func (c *Config) LastCheckFile() string {
+ return filepath.Join(c.StateDir, "last-check")
+}
tools/daily-plan/internal/display/display.go
@@ -0,0 +1,147 @@
+// Package display provides terminal output formatting.
+package display
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/vdemeester/home/tools/daily-plan/internal/github"
+ "github.com/vdemeester/home/tools/daily-plan/internal/jira"
+ "github.com/vdemeester/home/tools/daily-plan/internal/org"
+)
+
+const (
+ bold = "\033[1m"
+ dim = "\033[2m"
+ red = "\033[0;31m"
+ green = "\033[0;32m"
+ yellow = "\033[0;33m"
+ blue = "\033[0;34m"
+ magenta = "\033[0;35m"
+ cyan = "\033[0;36m"
+ reset = "\033[0m"
+)
+
+// Header prints a section header.
+func Header(title string) {
+ fmt.Printf("\n%s%s── %s ──%s\n", bold, cyan, title, reset)
+}
+
+// SubHeader prints a subsection header.
+func SubHeader(title string) {
+ fmt.Printf(" %s%s%s%s\n", bold, magenta, title, reset)
+}
+
+// OrgItems prints scheduled org items.
+func OrgItems(items []org.ScheduledItem) {
+ if len(items) == 0 {
+ fmt.Printf(" %s(nothing scheduled)%s\n", dim, reset)
+ return
+ }
+ for _, item := range items {
+ state := item.State
+ if state != "" {
+ state += " "
+ }
+ fmt.Printf(" %s%s%s\n", state, item.Heading, reset)
+ }
+}
+
+// JiraIssues prints a list of Jira issues.
+func JiraIssues(issues []jira.Issue, style string) {
+ if len(issues) == 0 {
+ fmt.Printf(" %s(none)%s\n", dim, reset)
+ return
+ }
+ for _, issue := range issues {
+ color := yellow
+ switch style {
+ case "dim":
+ color = dim
+ case "alert":
+ color = red
+ case "done":
+ color = green
+ }
+
+ summary := issue.Summary
+ if len(summary) > 70 {
+ summary = summary[:67] + "..."
+ }
+
+ if style == "alert" {
+ fmt.Printf(" %s⚠ %-14s%s %s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
+ } else {
+ fmt.Printf(" %s%-14s%s %-70s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
+ }
+ }
+}
+
+// JiraIssuesLimited prints issues with a limit and "N more" indicator.
+func JiraIssuesLimited(issues []jira.Issue, limit int, style string) {
+ if len(issues) <= limit {
+ JiraIssues(issues, style)
+ return
+ }
+ JiraIssues(issues[:limit], style)
+ fmt.Printf(" %s... and %d more%s\n", dim, len(issues)-limit, reset)
+}
+
+// CVEIssues prints CVE issues grouped by CVE ID with total count.
+func CVEIssues(grouped []jira.Issue, totalCount int) {
+ if len(grouped) == 0 {
+ fmt.Printf(" %s(none)%s\n", dim, reset)
+ return
+ }
+ JiraIssues(grouped, "alert")
+ if totalCount > len(grouped) {
+ fmt.Printf(" %s(%d total issues across images — showing first per CVE)%s\n", dim, totalCount, reset)
+ }
+}
+
+// GitHubItems prints GitHub issues or PRs.
+func GitHubItems(items []github.Item, style string) {
+ if len(items) == 0 {
+ fmt.Printf(" %s(none)%s\n", dim, reset)
+ return
+ }
+ for _, item := range items {
+ color := blue
+ switch style {
+ case "review":
+ color = red
+ case "pr":
+ color = green
+ case "done":
+ color = green
+ }
+
+ ref := item.Ref()
+ title := item.Title
+ if len(title) > 60 {
+ title = title[:57] + "..."
+ }
+
+ extra := ""
+ if item.Author != "" {
+ extra = fmt.Sprintf("@%s", item.Author)
+ }
+ dateStr := item.CreatedAt.Format("2006-01-02")
+ if item.ClosedAt != nil {
+ dateStr = item.ClosedAt.Format("2006-01-02")
+ }
+
+ parts := []string{}
+ if extra != "" {
+ parts = append(parts, extra)
+ }
+ parts = append(parts, dateStr)
+
+ fmt.Printf(" %s%-40s%s %s %s(%s)%s\n", color, ref, reset, title, dim, strings.Join(parts, ", "), reset)
+ }
+}
+
+// Hint prints a dim hint line.
+func Hint(msg string) {
+ fmt.Printf("\n%s%s%s\n", dim, msg, reset)
+}
tools/daily-plan/internal/github/github.go
@@ -0,0 +1,200 @@
+// Package github provides GitHub issue/PR fetching via the gh CLI.
+package github
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// Item represents a GitHub issue or PR.
+type Item struct {
+ Repo string
+ Number int
+ Title string
+ Author string
+ CreatedAt time.Time
+ ClosedAt *time.Time
+ Kind string // "issue", "pr"
+}
+
+// Ref returns "org/repo#123" format.
+func (i Item) Ref() string {
+ return fmt.Sprintf("%s#%d", i.Repo, i.Number)
+}
+
+// URL returns the GitHub URL.
+func (i Item) URL() string {
+ kind := "issues"
+ if i.Kind == "pr" {
+ kind = "pull"
+ }
+ return fmt.Sprintf("https://github.com/%s/%s/%d", i.Repo, kind, i.Number)
+}
+
+type ghSearchResult struct {
+ Repository struct {
+ NameWithOwner string `json:"nameWithOwner"`
+ } `json:"repository"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Author struct {
+ Login string `json:"login"`
+ } `json:"author"`
+ CreatedAt time.Time `json:"createdAt"`
+ ClosedAt *time.Time `json:"closedAt"`
+}
+
+// FetchAssignedIssues returns open issues assigned to user across owners.
+func FetchAssignedIssues(ctx context.Context, username string, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "issues",
+ "--assignee", username,
+ "--state", "open",
+ "--limit", "30",
+ "--json", "repository,number,title,createdAt,author",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "issue")
+}
+
+// FetchReviewRequests returns open PRs where review is requested from user.
+func FetchReviewRequests(ctx context.Context, username string, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "prs",
+ "--review-requested", username,
+ "--state", "open",
+ "--limit", "30",
+ "--json", "repository,number,title,createdAt,author",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "pr")
+}
+
+// FetchMyPRs returns open PRs authored by user.
+func FetchMyPRs(ctx context.Context, username string, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "prs",
+ "--author", username,
+ "--state", "open",
+ "--limit", "30",
+ "--json", "repository,number,title,createdAt",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "pr")
+}
+
+// FetchNewIssuesSince returns issues created since the given date.
+func FetchNewIssuesSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "issues",
+ "--state", "open",
+ "--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+ "--limit", "30",
+ "--json", "repository,number,title,createdAt,author",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "issue")
+}
+
+// FetchNewPRsSince returns PRs created since the given date.
+func FetchNewPRsSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "prs",
+ "--state", "open",
+ "--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+ "--limit", "30",
+ "--json", "repository,number,title,createdAt,author",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "pr")
+}
+
+// FetchMergedPRsSince returns PRs by user merged since the given date.
+func FetchMergedPRsSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
+ args := []string{
+ "search", "prs",
+ "--author", username,
+ "--merged", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+ "--limit", "30",
+ "--json", "repository,number,title,closedAt",
+ }
+ args = append(args, ownerFlags(owners)...)
+ return runGHSearch(ctx, args, "pr")
+}
+
+// FetchIssueSummary fetches title for a single GitHub issue.
+func FetchIssueSummary(ctx context.Context, repo string, number int) (string, error) {
+ cmd := exec.CommandContext(ctx, "gh", "issue", "view",
+ fmt.Sprintf("%d", number),
+ "--repo", repo,
+ "--json", "title",
+ "--jq", ".title",
+ )
+ var stdout bytes.Buffer
+ cmd.Stdout = &stdout
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("failed to fetch %s#%d: %w", repo, number, err)
+ }
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+// FilterBots removes items from bot authors.
+func FilterBots(items []Item, botPatterns []string) []Item {
+ var filtered []Item
+ for _, item := range items {
+ isBot := false
+ for _, pattern := range botPatterns {
+ if strings.Contains(strings.ToLower(item.Author), strings.ToLower(pattern)) {
+ isBot = true
+ break
+ }
+ }
+ if !isBot {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
+
+func ownerFlags(owners []string) []string {
+ var flags []string
+ for _, owner := range owners {
+ flags = append(flags, "--owner", owner)
+ }
+ return flags
+}
+
+func runGHSearch(ctx context.Context, args []string, kind string) ([]Item, error) {
+ cmd := exec.CommandContext(ctx, "gh", args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("gh error: %s", stderr.String())
+ }
+
+ var results []ghSearchResult
+ if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
+ return nil, fmt.Errorf("json parse error: %w", err)
+ }
+
+ items := make([]Item, 0, len(results))
+ for _, r := range results {
+ items = append(items, Item{
+ Repo: r.Repository.NameWithOwner,
+ Number: r.Number,
+ Title: r.Title,
+ Author: r.Author.Login,
+ CreatedAt: r.CreatedAt,
+ ClosedAt: r.ClosedAt,
+ Kind: kind,
+ })
+ }
+ return items, nil
+}
tools/daily-plan/internal/jira/jira.go
@@ -0,0 +1,176 @@
+// Package jira provides Jira issue fetching via the jira CLI.
+package jira
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Issue represents a Jira issue.
+type Issue struct {
+ Key string
+ Summary string
+ Status string
+ Priority string
+ Type string
+ Labels string
+}
+
+// FetchByStatus fetches issues assigned to user with given statuses.
+func FetchByStatus(ctx context.Context, user string, statuses []string) ([]Issue, error) {
+ var allIssues []Issue
+ for _, status := range statuses {
+ issues, err := fetchIssueList(ctx, user, status)
+ if err != nil {
+ continue // Skip on error, don't fail entirely
+ }
+ allIssues = append(allIssues, issues...)
+ }
+ return allIssues, nil
+}
+
+// FetchUpdatedSince fetches issues assigned to the current user updated since the given date.
+func FetchUpdatedSince(ctx context.Context, since time.Time) ([]Issue, error) {
+ sinceStr := since.Format("2006-01-02")
+ args := []string{
+ "issue", "list", "--plain", "--no-truncate",
+ "-a", jiraMe(),
+ "--updated-after", sinceStr,
+ }
+ return runJiraList(ctx, args)
+}
+
+// FetchSecuritySince fetches Security-labeled issues created since the given date.
+func FetchSecuritySince(ctx context.Context, since time.Time) ([]Issue, error) {
+ sinceStr := since.Format("2006-01-02")
+ args := []string{
+ "issue", "list", "--plain", "--no-truncate",
+ "-l", "Security",
+ "--created-after", sinceStr,
+ }
+ return runJiraList(ctx, args)
+}
+
+// FetchCompletedSince fetches issues resolved by user since the given date.
+func FetchCompletedSince(ctx context.Context, user string, since time.Time) ([]Issue, error) {
+ var allIssues []Issue
+ for _, status := range []string{"Done", "Closed"} {
+ sinceStr := since.Format("2006-01-02")
+ args := []string{
+ "issue", "list", "--plain", "--no-truncate",
+ "-a", jiraMe(),
+ "-s", status,
+ "--updated-after", sinceStr,
+ }
+ issues, err := runJiraList(ctx, args)
+ if err != nil {
+ continue
+ }
+ allIssues = append(allIssues, issues...)
+ }
+ return allIssues, nil
+}
+
+// FetchSummary fetches the summary for a single issue key.
+func FetchSummary(ctx context.Context, key string) (string, error) {
+ cmd := exec.CommandContext(ctx, "jira", "issue", "view", key, "--plain")
+ var stdout bytes.Buffer
+ cmd.Stdout = &stdout
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("failed to view issue %s: %w", key, err)
+ }
+ // Extract summary from "# Title" line
+ for line := range strings.SplitSeq(stdout.String(), "\n") {
+ trimmed := strings.TrimSpace(line)
+ if after, ok := strings.CutPrefix(trimmed, "# "); ok {
+ return strings.TrimSpace(after), nil
+ }
+ }
+ return "", fmt.Errorf("could not find summary for %s", key)
+}
+
+// GroupByCVE deduplicates issues by CVE ID, returning one issue per CVE.
+func GroupByCVE(issues []Issue) ([]Issue, int) {
+ cveRe := regexp.MustCompile(`CVE-\d+-\d+`)
+ seen := make(map[string]bool)
+ var grouped []Issue
+ for _, issue := range issues {
+ cve := cveRe.FindString(issue.Summary)
+ if cve != "" {
+ if seen[cve] {
+ continue
+ }
+ seen[cve] = true
+ }
+ grouped = append(grouped, issue)
+ }
+ return grouped, len(issues)
+}
+
+func fetchIssueList(ctx context.Context, user, status string) ([]Issue, error) {
+ args := []string{
+ "issue", "list", "--plain", "--no-truncate",
+ "-a", jiraMe(),
+ "-s", status,
+ }
+ return runJiraList(ctx, args)
+}
+
+func runJiraList(ctx context.Context, args []string) ([]Issue, error) {
+ cmd := exec.CommandContext(ctx, "jira", args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("jira CLI error: %s", stderr.String())
+ }
+
+ return parsePlainOutput(stdout.String()), nil
+}
+
+func parsePlainOutput(output string) []Issue {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ if len(lines) < 2 {
+ return nil
+ }
+
+ var issues []Issue
+ // Skip header line, collapse consecutive tabs
+ tabCollapser := regexp.MustCompile(`\t+`)
+ for _, line := range lines[1:] {
+ collapsed := tabCollapser.ReplaceAllString(line, "\t")
+ fields := strings.Split(collapsed, "\t")
+ if len(fields) < 4 {
+ continue
+ }
+ issue := Issue{
+ Key: strings.TrimSpace(fields[1]),
+ Summary: strings.TrimSpace(fields[2]),
+ Status: strings.TrimSpace(fields[3]),
+ }
+ if issue.Key == "" {
+ continue
+ }
+ if len(fields) > 6 {
+ issue.Priority = strings.TrimSpace(fields[6])
+ }
+ issues = append(issues, issue)
+ }
+ return issues
+}
+
+func jiraMe() string {
+ cmd := exec.Command("jira", "me")
+ var stdout bytes.Buffer
+ cmd.Stdout = &stdout
+ if err := cmd.Run(); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(stdout.String())
+}
tools/daily-plan/internal/org/org.go
@@ -0,0 +1,170 @@
+// Package org provides org-mode integration for scheduling TODOs.
+package org
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// ScheduledItem represents an org TODO scheduled for a specific day.
+type ScheduledItem struct {
+ Heading string
+ State string // TODO, DONE, STRT, etc.
+}
+
+// TodayItems returns org items scheduled for today from the given file.
+func TodayItems(orgFile string) ([]ScheduledItem, error) {
+ today := time.Now().Format("2006-01-02")
+ return itemsForDate(orgFile, today)
+}
+
+func itemsForDate(orgFile, date string) ([]ScheduledItem, error) {
+ f, err := os.Open(orgFile)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var items []ScheduledItem
+ scanner := bufio.NewScanner(f)
+ var prevLine string
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.Contains(line, fmt.Sprintf("SCHEDULED: <%s", date)) ||
+ strings.Contains(line, fmt.Sprintf("DEADLINE: <%s", date)) {
+ // Previous line should be the heading
+ if strings.HasPrefix(strings.TrimSpace(prevLine), "**") {
+ heading := strings.TrimSpace(prevLine)
+ // Strip leading stars
+ for strings.HasPrefix(heading, "*") {
+ heading = strings.TrimPrefix(heading, "*")
+ }
+ heading = strings.TrimSpace(heading)
+
+ state := ""
+ for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
+ if strings.HasPrefix(heading, s+" ") {
+ state = s
+ heading = strings.TrimPrefix(heading, s+" ")
+ break
+ }
+ }
+ items = append(items, ScheduledItem{
+ Heading: strings.TrimSpace(heading),
+ State: state,
+ })
+ }
+ }
+ prevLine = line
+ }
+ return items, scanner.Err()
+}
+
+// HasJiraKey checks if a Jira issue key already exists in the org file (as a non-DONE TODO).
+func HasJiraKey(orgFile, key string) bool {
+ return hasProperty(orgFile, "JIRA_KEY", key)
+}
+
+// HasGitHubIssue checks if a GitHub issue already exists in the org file (as a non-DONE TODO).
+func HasGitHubIssue(orgFile, repo string, number int) bool {
+ return hasPropertyPair(orgFile, "GH_REPO", repo, "GH_NUMBER", fmt.Sprintf("%d", number))
+}
+
+func hasProperty(orgFile, propName, propValue string) bool {
+ f, err := os.Open(orgFile)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+
+ needle := fmt.Sprintf(":%s: %s", propName, propValue)
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ if strings.Contains(scanner.Text(), needle) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasPropertyPair(orgFile, prop1, val1, prop2, val2 string) bool {
+ f, err := os.Open(orgFile)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+
+ needle1 := fmt.Sprintf(":%s: %s", prop1, val1)
+ needle2 := fmt.Sprintf(":%s: %s", prop2, val2)
+ found1 := false
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.Contains(line, needle1) {
+ found1 = true
+ }
+ if found1 && strings.Contains(line, needle2) {
+ return true
+ }
+ // Reset if we leave a :PROPERTIES: block
+ if found1 && strings.TrimSpace(line) == ":END:" {
+ found1 = false
+ }
+ }
+ return false
+}
+
+// ScheduleJiraIssue creates an org TODO for a Jira issue under the given section.
+func ScheduleJiraIssue(orgFile, section, key, summary, url string, date time.Time) error {
+ dateStr := date.Format("2006-01-02 Mon")
+ createdStr := time.Now().Format("2006-01-02 Mon 15:04")
+
+ orgContent := fmt.Sprintf(
+ "\n** TODO [[%s][%s]] %s :jira:\nSCHEDULED: <%s>\n:PROPERTIES:\n:JIRA_KEY: %s\n:URL: %s\n:CREATED: [%s]\n:END:\n",
+ url, key, summary, dateStr, key, url, createdStr,
+ )
+
+ return appendToSection(orgFile, section, orgContent)
+}
+
+// ScheduleGitHubIssue creates an org TODO for a GitHub issue under the given section.
+func ScheduleGitHubIssue(orgFile, section, repo string, number int, summary, url string, date time.Time) error {
+ dateStr := date.Format("2006-01-02 Mon")
+ createdStr := time.Now().Format("2006-01-02 Mon 15:04")
+ ref := fmt.Sprintf("%s#%d", repo, number)
+
+ orgContent := fmt.Sprintf(
+ "\n** TODO [[%s][%s]] %s :github:\nSCHEDULED: <%s>\n:PROPERTIES:\n:GH_REPO: %s\n:GH_NUMBER: %d\n:URL: %s\n:CREATED: [%s]\n:END:\n",
+ url, ref, summary, dateStr, repo, number, url, createdStr,
+ )
+
+ return appendToSection(orgFile, section, orgContent)
+}
+
+func appendToSection(orgFile, section, content string) error {
+ escapedSection := strings.ReplaceAll(section, `"`, `\"`)
+ escapedContent := strings.ReplaceAll(content, `"`, `\"`)
+ escapedContent = strings.ReplaceAll(escapedContent, `\`, `\\`)
+
+ elisp := fmt.Sprintf(`
+ (with-current-buffer (find-file-noselect %q)
+ (goto-char (point-min))
+ (re-search-forward "^\\* %s")
+ (org-end-of-subtree t)
+ (insert %q)
+ (save-buffer)
+ "ok")`, orgFile, escapedSection, content)
+
+ cmd := exec.Command("emacsclient", "--eval", elisp)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("emacsclient error: %s: %w", stderr.String(), err)
+ }
+ return nil
+}
tools/daily-plan/.gitignore
@@ -0,0 +1,1 @@
+daily-plan
tools/daily-plan/daily-plan.el
@@ -0,0 +1,291 @@
+;;; daily-plan.el --- Emacs interface to daily-plan CLI -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integrates the daily-plan Go CLI with Emacs.
+;; Uses --json output for structured data, renders in org-mode buffers.
+;;
+;; Keybindings in daily-plan buffers:
+;; s - schedule item at point
+;; RET - open item URL in browser
+;; g - refresh buffer
+;; q - quit
+;;
+;; Interactive commands:
+;; M-x daily-plan-show - today's plan
+;; M-x daily-plan-inbox - new items since last check
+;; M-x daily-plan-weekly - weekly review
+;; M-x daily-plan-schedule - schedule by key
+
+;;; Code:
+
+(require 'json)
+(require 'org)
+
+(defvar daily-plan-command "daily-plan"
+ "Path or name of the daily-plan binary.")
+
+(defvar daily-plan-org-file (expand-file-name "~/desktop/org/todos.org")
+ "Path to the org file for scheduling.")
+
+;; ── JSON helpers ──
+
+(defun daily-plan--run-json (&rest args)
+ "Run daily-plan with ARGS and --json, return parsed JSON."
+ (with-temp-buffer
+ (let ((exit-code (apply #'call-process daily-plan-command nil t nil
+ (append args '("--json")))))
+ (when (zerop exit-code)
+ (goto-char (point-min))
+ (condition-case nil
+ (json-parse-buffer :object-type 'alist :array-type 'list)
+ (error nil))))))
+
+(defun daily-plan--run-plain (&rest args)
+ "Run daily-plan with ARGS, return output string."
+ (with-temp-buffer
+ (apply #'call-process daily-plan-command nil t nil args)
+ (string-trim (buffer-string))))
+
+;; ── Major mode ──
+
+(defvar daily-plan-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "s") #'daily-plan-schedule-at-point)
+ (define-key map (kbd "RET") #'daily-plan-open-at-point)
+ (define-key map (kbd "g") #'daily-plan-refresh)
+ (define-key map (kbd "q") #'quit-window)
+ map)
+ "Keymap for `daily-plan-mode'.")
+
+(define-derived-mode daily-plan-mode org-mode "DailyPlan"
+ "Major mode for daily-plan buffers.
+\\{daily-plan-mode-map}"
+ (read-only-mode 1)
+ (setq-local buffer-read-only t))
+
+;; ── Rendering ──
+
+(defun daily-plan--insert-jira-item (item)
+ "Insert a single Jira ITEM as an org list entry with text properties."
+ (let-alist item
+ (let ((start (point)))
+ (insert (format "- [[%s][%s]] %s =%s=" .url .key .summary .status))
+ (when (and .priority (not (string-empty-p .priority)))
+ (insert (format " /%s/" .priority)))
+ (insert "\n")
+ (put-text-property start (point) 'daily-plan-key .key)
+ (put-text-property start (point) 'daily-plan-url .url)
+ (put-text-property start (point) 'daily-plan-type "jira"))))
+
+(defun daily-plan--insert-gh-item (item)
+ "Insert a single GitHub ITEM as an org list entry with text properties."
+ (let-alist item
+ (let ((start (point))
+ (ref (format "%s#%d" .repo .number)))
+ (insert (format "- [[%s][%s]] %s" .url ref .title))
+ (when (and .author (not (string-empty-p .author)))
+ (insert (format " (@%s)" .author)))
+ (insert "\n")
+ (put-text-property start (point) 'daily-plan-key ref)
+ (put-text-property start (point) 'daily-plan-url .url)
+ (put-text-property start (point) 'daily-plan-type "github"))))
+
+(defun daily-plan--insert-items (items insert-fn empty-msg)
+ "Insert ITEMS using INSERT-FN, or EMPTY-MSG if empty."
+ (if items
+ (dolist (item items)
+ (funcall insert-fn item))
+ (insert (format "- %s\n" (or empty-msg "(none)")))))
+
+(defun daily-plan--render-show (data)
+ "Render show DATA into current buffer."
+ (let-alist data
+ (insert (format "* Daily Plan — %s\n\n" .date))
+
+ (insert "** Org Agenda\n")
+ (if .agenda
+ (dolist (item .agenda)
+ (let-alist item
+ (insert (format "- %s %s\n" (or .state "") .heading))))
+ (insert "- (nothing scheduled)\n"))
+
+ (insert "\n** Jira — In Progress / Code Review\n")
+ (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
+
+ (insert "\n** Jira — Backlog\n")
+ (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
+
+ (insert "\n** GitHub — Assigned Issues\n")
+ (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
+
+ (insert "\n** GitHub — PRs Awaiting Review :review:\n")
+ (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)
+
+ (insert "\n** GitHub — Your Open PRs\n")
+ (daily-plan--insert-items .github_prs #'daily-plan--insert-gh-item nil)))
+
+(defun daily-plan--render-inbox (data)
+ "Render inbox DATA into current buffer."
+ (let-alist data
+ (insert (format "* Inbox — Since %s\n\n" .since))
+
+ (insert "** CVEs / Security Issues :security:\n")
+ (daily-plan--insert-items .cves #'daily-plan--insert-jira-item nil)
+ (when (and .cve_total (> .cve_total (length .cves)))
+ (insert (format " /(%d total across images)/\n" .cve_total)))
+
+ (insert "\n** Jira — Updated\n")
+ (daily-plan--insert-items .jira_updated #'daily-plan--insert-jira-item nil)
+
+ (insert "\n** GitHub — New Issues\n")
+ (daily-plan--insert-items .github_new_issues #'daily-plan--insert-gh-item nil)
+
+ (insert "\n** GitHub — New PRs\n")
+ (daily-plan--insert-items .github_new_prs #'daily-plan--insert-gh-item nil)
+
+ (insert "\n** GitHub — Review Requests :review:\n")
+ (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)))
+
+(defun daily-plan--render-weekly (data)
+ "Render weekly DATA into current buffer."
+ (let-alist data
+ (insert (format "* Weekly Review — %s\n\n" .week))
+
+ (insert "** Completed (Jira) :done:\n")
+ (daily-plan--insert-items .jira_completed #'daily-plan--insert-jira-item "(nothing completed)")
+
+ (insert "\n** Completed (GitHub — Merged PRs) :done:\n")
+ (daily-plan--insert-items .github_merged #'daily-plan--insert-gh-item "(no merged PRs)")
+
+ (insert "\n** Still In Progress\n")
+ (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
+
+ (insert "\n** Backlog — Candidates for Next Week\n")
+ (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
+
+ (insert "\n** GitHub Issues (assigned)\n")
+ (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
+
+ (insert (format "\n/Schedule: daily-plan schedule SRVKP-1234 %s/\n" .next_monday))))
+
+;; ── Buffer management ──
+
+(defun daily-plan--create-buffer (name render-fn data)
+ "Create or reuse buffer NAME, render DATA with RENDER-FN."
+ (let ((buf (get-buffer-create name)))
+ (with-current-buffer buf
+ (let ((inhibit-read-only t)
+ (pos (point)))
+ (erase-buffer)
+ (funcall render-fn data)
+ (daily-plan-mode)
+ (goto-char (min pos (point-max)))))
+ (pop-to-buffer buf)))
+
+(defun daily-plan--run-async (buf-name args render-fn)
+ "Run daily-plan with ARGS asynchronously, render into BUF-NAME with RENDER-FN."
+ (message "Fetching %s..." buf-name)
+ (let ((buf (get-buffer-create buf-name))
+ (proc-buf (generate-new-buffer " *daily-plan-proc*")))
+ ;; Show buffer immediately with loading message
+ (with-current-buffer buf
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "Loading...")
+ (daily-plan-mode)))
+ (pop-to-buffer buf)
+ ;; Run async
+ (make-process
+ :name "daily-plan"
+ :buffer proc-buf
+ :command (append (list daily-plan-command) args (list "--json"))
+ :sentinel
+ (lambda (proc _event)
+ (when (eq (process-status proc) 'exit)
+ (if (zerop (process-exit-status proc))
+ (let ((data (with-current-buffer proc-buf
+ (goto-char (point-min))
+ (condition-case nil
+ (json-parse-buffer :object-type 'alist :array-type 'list)
+ (error nil)))))
+ (if data
+ (progn
+ (daily-plan--create-buffer buf-name render-fn data)
+ (message "%s ready." buf-name))
+ (message "Failed to parse %s output" buf-name)))
+ (message "daily-plan failed with exit code %d" (process-exit-status proc)))
+ (kill-buffer proc-buf))))))
+
+;; ── Interactive commands ──
+
+;;;###autoload
+(defun daily-plan-show ()
+ "Show today's daily plan."
+ (interactive)
+ (daily-plan--run-async "*daily-plan*" '("show") #'daily-plan--render-show))
+
+;;;###autoload
+(defun daily-plan-inbox (&optional since)
+ "Show new/updated items since last check or SINCE date."
+ (interactive "sInbox since (empty=last check, or YYYY-MM-DD/\"last monday\"): ")
+ (let ((args (if (and since (not (string-empty-p since)))
+ (list "inbox" since)
+ (list "inbox"))))
+ (daily-plan--run-async "*daily-plan-inbox*" args #'daily-plan--render-inbox)))
+
+;;;###autoload
+(defun daily-plan-weekly ()
+ "Show weekly review."
+ (interactive)
+ (daily-plan--run-async "*daily-plan-weekly*" '("weekly") #'daily-plan--render-weekly))
+
+;;;###autoload
+(defun daily-plan-schedule (key &optional date)
+ "Schedule a Jira or GitHub issue KEY for DATE.
+KEY should be PROJ-123 for Jira or owner/repo#123 for GitHub."
+ (interactive
+ (list (read-string "Issue key (PROJ-123 or org/repo#123): ")
+ (org-read-date nil nil nil "Schedule for: ")))
+ (let ((output (daily-plan--run-plain
+ "schedule" key (or date (format-time-string "%Y-%m-%d")))))
+ (message "%s" output)))
+
+(defun daily-plan-schedule-at-point ()
+ "Schedule the item at point for a chosen date."
+ (interactive)
+ (let ((key (get-text-property (point) 'daily-plan-key)))
+ (if key
+ (let* ((date (org-read-date nil nil nil (format "Schedule %s for: " key)))
+ (output (daily-plan--run-plain "schedule" key date)))
+ (message "%s" output))
+ ;; Fallback: try to extract from org link
+ (save-excursion
+ (beginning-of-line)
+ (if (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" (line-end-position) t)
+ (let* ((ref (match-string 2))
+ (date (org-read-date nil nil nil (format "Schedule %s for: " ref)))
+ (output (daily-plan--run-plain "schedule" ref date)))
+ (message "%s" output))
+ (message "No schedulable item at point"))))))
+
+(defun daily-plan-open-at-point ()
+ "Open the URL of the item at point in the browser."
+ (interactive)
+ (let ((url (get-text-property (point) 'daily-plan-url)))
+ (if url
+ (browse-url url)
+ ;; Fallback to org link
+ (org-open-at-point))))
+
+(defun daily-plan-refresh ()
+ "Refresh the current daily-plan buffer."
+ (interactive)
+ (let ((buf-name (buffer-name)))
+ (cond
+ ((string= buf-name "*daily-plan*") (daily-plan-show))
+ ((string= buf-name "*daily-plan-inbox*") (daily-plan-inbox))
+ ((string= buf-name "*daily-plan-weekly*") (daily-plan-weekly))
+ (t (message "Not a daily-plan buffer")))))
+
+(provide 'daily-plan)
+;;; daily-plan.el ends here
tools/daily-plan/default.nix
@@ -0,0 +1,37 @@
+{
+ buildGoModule,
+ lib,
+ makeWrapper,
+ gh,
+ jayrat ? null,
+}:
+
+buildGoModule {
+ pname = "daily-plan";
+ version = "0.1.0";
+ src = ./.;
+
+ vendorHash = null;
+
+ nativeBuildInputs = [ makeWrapper ];
+
+ subPackages = [ "cmd/daily-plan" ];
+
+ # jira CLI is NOT bundled — expects the host's wrapper (injects token via passage)
+ postInstall = ''
+ wrapProgram $out/bin/daily-plan \
+ --prefix PATH : ${
+ lib.makeBinPath (
+ [ gh ]
+ ++ lib.optional (jayrat != null) jayrat
+ )
+ }
+ '';
+
+ meta = {
+ description = "Pull from Jira/GitHub into org-mode daily planning";
+ license = lib.licenses.mit;
+ platforms = lib.platforms.unix;
+ mainProgram = "daily-plan";
+ };
+}
tools/daily-plan/go.mod
@@ -0,0 +1,3 @@
+module github.com/vdemeester/home/tools/daily-plan
+
+go 1.24