main
1;;; daily-plan.el --- Emacs interface to daily-plan CLI -*- lexical-binding: t; -*-
2
3;;; Commentary:
4;; Integrates the daily-plan Go CLI with Emacs.
5;; Uses --json output for structured data, renders in org-mode buffers.
6;;
7;; Keybindings in daily-plan buffers:
8;; s - schedule item at point
9;; RET - open item URL in browser
10;; g - refresh buffer
11;; q - quit
12;; y - yank buffer as markdown (for reports)
13;; p - switch period (weekly/review buffers)
14;; TAB - cycle section visibility
15;;
16;; Interactive commands:
17;; M-x daily-plan-show - today's plan
18;; M-x daily-plan-inbox - new items since last check
19;; M-x daily-plan-weekly - weekly review
20;; M-x daily-plan-review - full activity review (markdown output)
21;; M-x daily-plan-schedule - schedule by key
22
23;;; Code:
24
25(require 'json)
26(require 'org)
27
28(defvar daily-plan-command "daily-plan"
29 "Path or name of the daily-plan binary.")
30
31(defvar daily-plan-org-file (expand-file-name "~/desktop/org/todos.org")
32 "Path to the org file for scheduling.")
33
34(defvar-local daily-plan--current-period nil
35 "Current period for review/weekly buffers.")
36
37(defvar-local daily-plan--current-command nil
38 "Current command for refresh.")
39
40(defvar-local daily-plan--current-args nil
41 "Current args for refresh.")
42
43(defvar-local daily-plan--current-render-fn nil
44 "Current render function for refresh.")
45
46;; ── JSON helpers ──
47
48(defun daily-plan--run-json (&rest args)
49 "Run daily-plan with ARGS and --json, return parsed JSON."
50 (with-temp-buffer
51 (let ((exit-code (apply #'call-process daily-plan-command nil t nil
52 (append args '("--json")))))
53 (when (zerop exit-code)
54 (goto-char (point-min))
55 (condition-case nil
56 (json-parse-buffer :object-type 'alist :array-type 'list)
57 (error nil))))))
58
59(defun daily-plan--run-plain (&rest args)
60 "Run daily-plan with ARGS, return output string."
61 (with-temp-buffer
62 (apply #'call-process daily-plan-command nil t nil args)
63 (string-trim (buffer-string))))
64
65;; ── Major mode ──
66
67(defvar daily-plan-mode-map
68 (let ((map (make-sparse-keymap)))
69 (define-key map (kbd "s") #'daily-plan-schedule-at-point)
70 (define-key map (kbd "RET") #'daily-plan-open-at-point)
71 (define-key map (kbd "g") #'daily-plan-refresh)
72 (define-key map (kbd "q") #'quit-window)
73 (define-key map (kbd "y") #'daily-plan-yank-markdown)
74 (define-key map (kbd "p") #'daily-plan-switch-period)
75 (define-key map (kbd "TAB") #'org-cycle)
76 (define-key map (kbd "S-TAB") #'org-shifttab)
77 map)
78 "Keymap for `daily-plan-mode'.")
79
80(define-derived-mode daily-plan-mode org-mode "DailyPlan"
81 "Major mode for daily-plan buffers.
82\\{daily-plan-mode-map}"
83 (read-only-mode 1)
84 (setq-local buffer-read-only t))
85
86;; ── Rendering helpers ──
87
88(defun daily-plan--insert-jira-item (item)
89 "Insert a single Jira ITEM as an org list entry with text properties."
90 (let-alist item
91 (let ((start (point)))
92 (insert (format "- [[%s][%s]] %s =%s=" .url .key .summary .status))
93 (when (and .priority (not (string-empty-p .priority)))
94 (insert (format " /%s/" .priority)))
95 (insert "\n")
96 (put-text-property start (point) 'daily-plan-key .key)
97 (put-text-property start (point) 'daily-plan-url .url)
98 (put-text-property start (point) 'daily-plan-type "jira"))))
99
100(defun daily-plan--insert-gh-item (item)
101 "Insert a single GitHub ITEM as an org list entry with text properties."
102 (let-alist item
103 (let ((start (point))
104 (ref (format "%s#%d" .repo .number)))
105 (insert (format "- [[%s][%s]] %s" .url ref .title))
106 (when (and .author (not (string-empty-p .author)))
107 (insert (format " (@%s)" .author)))
108 (insert "\n")
109 (put-text-property start (point) 'daily-plan-key ref)
110 (put-text-property start (point) 'daily-plan-url .url)
111 (put-text-property start (point) 'daily-plan-type "github"))))
112
113(defun daily-plan--insert-items (items insert-fn empty-msg)
114 "Insert ITEMS using INSERT-FN, or EMPTY-MSG if empty."
115 (if items
116 (dolist (item items)
117 (funcall insert-fn item))
118 (insert (format "- %s\n" (or empty-msg "(none)")))))
119
120(defun daily-plan--insert-org-done-item (item)
121 "Insert a completed org ITEM."
122 (let-alist item
123 (insert (format "- ✓ %s /(%s)/\n" .title .completed_at))))
124
125(defun daily-plan--insert-discussion-item (item)
126 "Insert a GitHub discussion ITEM."
127 (let-alist item
128 (let ((start (point)))
129 (insert (format "- [[%s][%s]] %s" .url .repo .title))
130 (when (and .category (not (string-empty-p .category)))
131 (insert (format " =[%s]=" .category)))
132 (insert "\n")
133 (put-text-property start (point) 'daily-plan-url .url)
134 (put-text-property start (point) 'daily-plan-type "discussion"))))
135
136(defun daily-plan--insert-comment-item (item)
137 "Insert a GitHub comment ITEM."
138 (let-alist item
139 (let ((start (point))
140 (ref (format "%s#%d" .repo .issue_number)))
141 (insert (format "- [[%s][%s]] %s\n" .url ref .issue_title))
142 (put-text-property start (point) 'daily-plan-url .url)
143 (put-text-property start (point) 'daily-plan-type "comment"))))
144
145(defun daily-plan--insert-ai-item (item)
146 "Insert an AI session ITEM."
147 (let-alist item
148 (insert (format "- *%s* %s" .type .title))
149 (when (and .project (not (string-empty-p .project)))
150 (insert (format " =[%s]=" .project)))
151 (insert (format " /(%s)/\n" .date))))
152
153;; ── Render: show ──
154
155(defun daily-plan--render-show (data)
156 "Render show DATA into current buffer."
157 (let-alist data
158 (insert (format "* Daily Plan — %s\n\n" .date))
159
160 (insert "** Org Agenda\n")
161 (if .agenda
162 (dolist (item .agenda)
163 (let-alist item
164 (insert (format "- %s %s\n" (or .state "") .heading))))
165 (insert "- (nothing scheduled)\n"))
166
167 (insert "\n** Jira — In Progress / Code Review\n")
168 (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
169
170 (insert "\n** Jira — Backlog\n")
171 (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
172
173 (insert "\n** GitHub — Assigned Issues\n")
174 (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
175
176 (insert "\n** GitHub — Assigned PRs\n")
177 (daily-plan--insert-items .github_assigned_prs #'daily-plan--insert-gh-item nil)
178
179 (insert "\n** GitHub — PRs Awaiting Review :review:\n")
180 (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)
181
182 (insert "\n** GitHub — Your Open PRs\n")
183 (daily-plan--insert-items .github_prs #'daily-plan--insert-gh-item nil)))
184
185;; ── Render: inbox ──
186
187(defun daily-plan--insert-advisory (item)
188 "Insert a GitHub security advisory ITEM."
189 (let-alist item
190 (let ((start (point))
191 (id (or .cve_id .ghsa_id)))
192 (insert (format "- [[%s][%s]] %s =%s= /%s/\n"
193 .url id .summary .severity .repo))
194 (put-text-property start (point) 'daily-plan-key id)
195 (put-text-property start (point) 'daily-plan-url .url)
196 (put-text-property start (point) 'daily-plan-type "advisory"))))
197
198(defun daily-plan--insert-dependabot (item)
199 "Insert a Dependabot alert ITEM."
200 (let-alist item
201 (let ((start (point))
202 (id (or .cve "dependabot")))
203 (insert (format "- [[%s][%s]] %s =%s= ~%s~ /%s/\n"
204 .url id .summary .severity .package .repo))
205 (put-text-property start (point) 'daily-plan-key id)
206 (put-text-property start (point) 'daily-plan-url .url)
207 (put-text-property start (point) 'daily-plan-type "dependabot"))))
208
209(defun daily-plan--render-inbox (data)
210 "Render inbox DATA into current buffer."
211 (let-alist data
212 (insert (format "* Inbox — Since %s\n\n" .since))
213
214 (insert "** Security Advisories (triage/draft) :security:\n")
215 (daily-plan--insert-items .github_security_advisories #'daily-plan--insert-advisory nil)
216
217 (insert "\n** Dependabot Alerts (critical/high) :security:\n")
218 (daily-plan--insert-items .github_dependabot_alerts #'daily-plan--insert-dependabot nil)
219
220 (insert "\n** Jira — CVEs / Security Issues :security:\n")
221 (daily-plan--insert-items .cves #'daily-plan--insert-jira-item nil)
222 (when (and .cve_total (> .cve_total (length .cves)))
223 (insert (format " /(%d total across images)/\n" .cve_total)))
224
225 (insert "\n** Jira — Updated\n")
226 (daily-plan--insert-items .jira_updated #'daily-plan--insert-jira-item nil)
227
228 (insert "\n** GitHub — New Issues\n")
229 (daily-plan--insert-items .github_new_issues #'daily-plan--insert-gh-item nil)
230
231 (insert "\n** GitHub — New PRs\n")
232 (daily-plan--insert-items .github_new_prs #'daily-plan--insert-gh-item nil)
233
234 (insert "\n** GitHub — Review Requests :review:\n")
235 (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)))
236
237;; ── Render: weekly ──
238
239(defun daily-plan--render-weekly (data)
240 "Render weekly DATA into current buffer."
241 (let-alist data
242 (insert (format "* Weekly Review — %s\n\n" .week))
243
244 ;; Org completed tasks
245 (when .org_done
246 (insert "** Completed Tasks (Org) :done:\n")
247 ;; Group by section
248 (let ((by-section (make-hash-table :test 'equal))
249 (order '()))
250 (dolist (item .org_done)
251 (let ((section (or (alist-get 'section item) "(uncategorized)")))
252 (unless (gethash section by-section)
253 (push section order))
254 (push item (gethash section by-section))))
255 (dolist (section (nreverse order))
256 (insert (format "*** %s\n" section))
257 (dolist (item (nreverse (gethash section by-section)))
258 (daily-plan--insert-org-done-item item))))
259 (insert "\n"))
260
261 ;; AI sessions
262 (when .ai_sessions
263 (let ((filtered (seq-remove
264 (lambda (item)
265 (string-match-p "auto-recovered"
266 (downcase (or (alist-get 'title item) ""))))
267 .ai_sessions)))
268 (when filtered
269 (insert (format "** AI Sessions (%d) :ai:\n" (length filtered)))
270 (dolist (item filtered)
271 (daily-plan--insert-ai-item item))
272 (insert "\n"))))
273
274 ;; Jira completed
275 (insert "** Completed (Jira) :done:\n")
276 (daily-plan--insert-items .jira_completed #'daily-plan--insert-jira-item "(nothing completed)")
277
278 ;; GitHub merged
279 (insert "\n** Merged PRs :done:\n")
280 (daily-plan--insert-items .github_merged #'daily-plan--insert-gh-item "(no merged PRs)")
281
282 ;; Reviews given
283 (when .github_reviewed
284 (insert (format "\n** Reviews Given (%d) :review:\n" (length .github_reviewed)))
285 (daily-plan--insert-items .github_reviewed #'daily-plan--insert-gh-item nil))
286
287 ;; Issues filed
288 (when .github_issues_created
289 (insert (format "\n** Issues Filed (%d)\n" (length .github_issues_created)))
290 (daily-plan--insert-items .github_issues_created #'daily-plan--insert-gh-item nil))
291
292 ;; Discussions
293 (when .github_discussions
294 (insert (format "\n** Discussions (%d) :community:\n" (length .github_discussions)))
295 (daily-plan--insert-items .github_discussions #'daily-plan--insert-discussion-item nil))
296
297 ;; Comments
298 (when .github_comments
299 (insert (format "\n** Comments (%d)\n" (length .github_comments)))
300 (daily-plan--insert-items .github_comments #'daily-plan--insert-comment-item nil))
301
302 ;; Still in progress
303 (insert "\n** Still In Progress\n")
304 (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
305
306 ;; Backlog
307 (insert "\n** Backlog — Candidates for Next Week\n")
308 (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
309
310 ;; Assigned issues
311 (insert "\n** GitHub Issues (assigned)\n")
312 (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
313
314 (insert "\n** GitHub PRs (assigned)\n")
315 (daily-plan--insert-items .github_assigned_prs #'daily-plan--insert-gh-item nil)))
316
317;; ── Buffer management ──
318
319(defun daily-plan--create-buffer (name render-fn data)
320 "Create or reuse buffer NAME, render DATA with RENDER-FN."
321 (let ((buf (get-buffer-create name)))
322 (with-current-buffer buf
323 (let ((inhibit-read-only t)
324 (pos (point)))
325 (erase-buffer)
326 (funcall render-fn data)
327 (daily-plan-mode)
328 ;; Fold all sections to level 2 for overview
329 (goto-char (point-min))
330 (org-content 2)
331 (goto-char (min pos (point-max)))))
332 (pop-to-buffer buf)))
333
334(defun daily-plan--run-async (buf-name args render-fn)
335 "Run daily-plan with ARGS asynchronously, render into BUF-NAME with RENDER-FN."
336 (message "Fetching %s..." buf-name)
337 (let ((buf (get-buffer-create buf-name))
338 (proc-buf (generate-new-buffer " *daily-plan-proc*")))
339 ;; Show buffer immediately with loading message
340 (with-current-buffer buf
341 (let ((inhibit-read-only t))
342 (erase-buffer)
343 (insert "Loading...")
344 (daily-plan-mode)
345 (setq-local daily-plan--current-args args)
346 (setq-local daily-plan--current-render-fn render-fn)))
347 (pop-to-buffer buf)
348 ;; Run async
349 (make-process
350 :name "daily-plan"
351 :buffer proc-buf
352 :command (append (list daily-plan-command) args (list "--json"))
353 :sentinel
354 (lambda (proc _event)
355 (when (eq (process-status proc) 'exit)
356 (if (zerop (process-exit-status proc))
357 (let ((data (with-current-buffer proc-buf
358 (goto-char (point-min))
359 (condition-case nil
360 (json-parse-buffer :object-type 'alist :array-type 'list)
361 (error nil)))))
362 (if data
363 (progn
364 (daily-plan--create-buffer buf-name render-fn data)
365 ;; Preserve local vars after re-render
366 (with-current-buffer buf-name
367 (setq-local daily-plan--current-args args)
368 (setq-local daily-plan--current-render-fn render-fn))
369 (message "%s ready." buf-name))
370 (message "Failed to parse %s output" buf-name)))
371 (message "daily-plan failed with exit code %d" (process-exit-status proc)))
372 (kill-buffer proc-buf))))))
373
374;; ── Interactive commands ──
375
376;;;###autoload
377(defun daily-plan-show ()
378 "Show today's daily plan."
379 (interactive)
380 (daily-plan--run-async "*daily-plan*" '("show") #'daily-plan--render-show))
381
382;;;###autoload
383(defun daily-plan-inbox (&optional since)
384 "Show new/updated items since last check or SINCE date."
385 (interactive "sInbox since (empty=last check, or YYYY-MM-DD/\"last monday\"): ")
386 (let ((args (if (and since (not (string-empty-p since)))
387 (list "inbox" since)
388 (list "inbox"))))
389 (daily-plan--run-async "*daily-plan-inbox*" args #'daily-plan--render-inbox)))
390
391;;;###autoload
392(defun daily-plan-weekly ()
393 "Show weekly review."
394 (interactive)
395 (daily-plan--run-async "*daily-plan-weekly*" '("weekly") #'daily-plan--render-weekly))
396
397;;;###autoload
398(defun daily-plan-review (&optional period)
399 "Show full activity review for PERIOD.
400PERIOD can be \"this week\", \"last week\", \"this month\", etc."
401 (interactive
402 (list (completing-read "Period: "
403 '("this week" "last week" "this month" "last month")
404 nil nil nil nil "this week")))
405 (let* ((buf-name (format "*daily-plan-review [%s]*" period))
406 (args (list "review" period)))
407 (setq daily-plan--current-period period)
408 (daily-plan--run-async buf-name args #'daily-plan--render-weekly)))
409
410;;;###autoload
411(defun daily-plan-schedule (key &optional date)
412 "Schedule a Jira or GitHub issue KEY for DATE.
413KEY should be PROJ-123 for Jira or owner/repo#123 for GitHub."
414 (interactive
415 (list (read-string "Issue key (PROJ-123 or org/repo#123): ")
416 (org-read-date nil nil nil "Schedule for: ")))
417 (let ((output (daily-plan--run-plain
418 "schedule" key (or date (format-time-string "%Y-%m-%d")))))
419 (message "%s" output)))
420
421(defun daily-plan-schedule-at-point ()
422 "Schedule the item at point for a chosen date."
423 (interactive)
424 (let ((key (get-text-property (point) 'daily-plan-key)))
425 (if key
426 (let* ((date (org-read-date nil nil nil (format "Schedule %s for: " key)))
427 (output (daily-plan--run-plain "schedule" key date)))
428 (message "%s" output))
429 ;; Fallback: try to extract from org link
430 (save-excursion
431 (beginning-of-line)
432 (if (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" (line-end-position) t)
433 (let* ((ref (match-string 2))
434 (date (org-read-date nil nil nil (format "Schedule %s for: " ref)))
435 (output (daily-plan--run-plain "schedule" ref date)))
436 (message "%s" output))
437 (message "No schedulable item at point"))))))
438
439(defun daily-plan-open-at-point ()
440 "Open the URL of the item at point in the browser."
441 (interactive)
442 (let ((url (get-text-property (point) 'daily-plan-url)))
443 (if url
444 (browse-url url)
445 ;; Fallback to org link
446 (org-open-at-point))))
447
448(defun daily-plan-refresh ()
449 "Refresh the current daily-plan buffer."
450 (interactive)
451 (let ((args daily-plan--current-args)
452 (render-fn daily-plan--current-render-fn)
453 (buf-name (buffer-name)))
454 (cond
455 ;; Use stored args/render if available
456 ((and args render-fn)
457 (daily-plan--run-async buf-name args render-fn))
458 ;; Fallback by buffer name
459 ((string= buf-name "*daily-plan*") (daily-plan-show))
460 ((string= buf-name "*daily-plan-inbox*") (daily-plan-inbox))
461 ((string= buf-name "*daily-plan-weekly*") (daily-plan-weekly))
462 (t (message "Not a daily-plan buffer")))))
463
464(defun daily-plan-switch-period ()
465 "Switch the time period for review/weekly buffers."
466 (interactive)
467 (let ((period (completing-read "Period: "
468 '("this week" "last week" "this month" "last month")
469 nil nil nil nil (or daily-plan--current-period "this week"))))
470 (cond
471 ((string-match-p "review" (buffer-name))
472 (daily-plan-review period))
473 ((string-match-p "weekly" (buffer-name))
474 ;; Weekly always uses "this week", suggest review instead
475 (daily-plan-review period))
476 (t (daily-plan-review period)))))
477
478(defun daily-plan-yank-markdown ()
479 "Copy the current buffer content as markdown to the kill ring.
480Runs `daily-plan review` with the current period to get markdown."
481 (interactive)
482 (let* ((period (or daily-plan--current-period "this week"))
483 (output (daily-plan--run-plain "review" period)))
484 (kill-new output)
485 (message "Markdown copied to kill ring (%d chars)" (length output))))
486
487(provide 'daily-plan)
488;;; daily-plan.el ends here