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(defun daily-plan--insert-deadline-item (item)
154  "Insert an org ITEM with an upcoming DEADLINE."
155  (let-alist item
156    (insert (format "- <%s> %s %s\n" .deadline (or .state "") .heading))))
157
158;; ── Render: show ──
159
160(defun daily-plan--render-show (data)
161  "Render show DATA into current buffer."
162  (let-alist data
163    (insert (format "* Daily Plan — %s\n\n" .date))
164
165    (insert "** Org Agenda\n")
166    (if .agenda
167        (dolist (item .agenda)
168          (let-alist item
169            (insert (format "- %s %s\n" (or .state "") .heading))))
170      (insert "- (nothing scheduled)\n"))
171
172    ;; Upcoming deadlines that are not yet scheduled (candidates to slip).
173    (let ((unscheduled (seq-remove
174                        (lambda (it) (eq t (alist-get 'scheduled it)))
175                        .deadlines)))
176      (when unscheduled
177        (insert "\n** Upcoming Deadlines — unscheduled (next 7 days)  :deadline:\n")
178        (dolist (item unscheduled)
179          (daily-plan--insert-deadline-item item))))
180
181    (insert "\n** Jira — In Progress / Code Review\n")
182    (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
183
184    (insert "\n** Jira — Backlog\n")
185    (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
186
187    (insert "\n** GitHub — Assigned Issues\n")
188    (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
189
190    (insert "\n** GitHub — Assigned PRs\n")
191    (daily-plan--insert-items .github_assigned_prs #'daily-plan--insert-gh-item nil)
192
193    (insert "\n** GitHub — PRs Awaiting Review  :review:\n")
194    (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)
195
196    (insert "\n** GitHub — Your Open PRs\n")
197    (daily-plan--insert-items .github_prs #'daily-plan--insert-gh-item nil)))
198
199;; ── Render: inbox ──
200
201(defun daily-plan--insert-advisory (item)
202  "Insert a GitHub security advisory ITEM."
203  (let-alist item
204    (let ((start (point))
205          (id (or .cve_id .ghsa_id)))
206      (insert (format "- [[%s][%s]] %s =%s= /%s/\n"
207                      .url id .summary .severity .repo))
208      (put-text-property start (point) 'daily-plan-key id)
209      (put-text-property start (point) 'daily-plan-url .url)
210      (put-text-property start (point) 'daily-plan-type "advisory"))))
211
212(defun daily-plan--insert-dependabot (item)
213  "Insert a Dependabot alert ITEM."
214  (let-alist item
215    (let ((start (point))
216          (id (or .cve "dependabot")))
217      (insert (format "- [[%s][%s]] %s =%s= ~%s~ /%s/\n"
218                      .url id .summary .severity .package .repo))
219      (put-text-property start (point) 'daily-plan-key id)
220      (put-text-property start (point) 'daily-plan-url .url)
221      (put-text-property start (point) 'daily-plan-type "dependabot"))))
222
223(defun daily-plan--render-inbox (data)
224  "Render inbox DATA into current buffer."
225  (let-alist data
226    (insert (format "* Inbox — Since %s\n\n" .since))
227
228    (insert "** Security Advisories (triage/draft)  :security:\n")
229    (daily-plan--insert-items .github_security_advisories #'daily-plan--insert-advisory nil)
230
231    (insert "\n** Dependabot Alerts (critical/high)  :security:\n")
232    (daily-plan--insert-items .github_dependabot_alerts #'daily-plan--insert-dependabot nil)
233
234    (insert "\n** Jira — CVEs / Security Issues  :security:\n")
235    (daily-plan--insert-items .cves #'daily-plan--insert-jira-item nil)
236    (when (and .cve_total (> .cve_total (length .cves)))
237      (insert (format "  /(%d total across images)/\n" .cve_total)))
238
239    (insert "\n** Jira — Updated\n")
240    (daily-plan--insert-items .jira_updated #'daily-plan--insert-jira-item nil)
241
242    (insert "\n** GitHub — New Issues\n")
243    (daily-plan--insert-items .github_new_issues #'daily-plan--insert-gh-item nil)
244
245    (insert "\n** GitHub — New PRs\n")
246    (daily-plan--insert-items .github_new_prs #'daily-plan--insert-gh-item nil)
247
248    (insert "\n** GitHub — Review Requests  :review:\n")
249    (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)))
250
251;; ── Render: weekly ──
252
253(defun daily-plan--render-weekly (data)
254  "Render weekly DATA into current buffer."
255  (let-alist data
256    (insert (format "* Weekly Review — %s\n\n" .week))
257
258    ;; Org completed tasks
259    (when .org_done
260      (insert "** Completed Tasks (Org)  :done:\n")
261      ;; Group by section
262      (let ((by-section (make-hash-table :test 'equal))
263            (order '()))
264        (dolist (item .org_done)
265          (let ((section (or (alist-get 'section item) "(uncategorized)")))
266            (unless (gethash section by-section)
267              (push section order))
268            (push item (gethash section by-section))))
269        (dolist (section (nreverse order))
270          (insert (format "*** %s\n" section))
271          (dolist (item (nreverse (gethash section by-section)))
272            (daily-plan--insert-org-done-item item))))
273      (insert "\n"))
274
275    ;; AI sessions
276    (when .ai_sessions
277      (let ((filtered (seq-remove
278                       (lambda (item)
279                         (string-match-p "auto-recovered"
280                                         (downcase (or (alist-get 'title item) ""))))
281                       .ai_sessions)))
282        (when filtered
283          (insert (format "** AI Sessions (%d)  :ai:\n" (length filtered)))
284          (dolist (item filtered)
285            (daily-plan--insert-ai-item item))
286          (insert "\n"))))
287
288    ;; Jira completed
289    (insert "** Completed (Jira)  :done:\n")
290    (daily-plan--insert-items .jira_completed #'daily-plan--insert-jira-item "(nothing completed)")
291
292    ;; GitHub merged
293    (insert "\n** Merged PRs  :done:\n")
294    (daily-plan--insert-items .github_merged #'daily-plan--insert-gh-item "(no merged PRs)")
295
296    ;; Reviews given
297    (when .github_reviewed
298      (insert (format "\n** Reviews Given (%d)  :review:\n" (length .github_reviewed)))
299      (daily-plan--insert-items .github_reviewed #'daily-plan--insert-gh-item nil))
300
301    ;; Issues filed
302    (when .github_issues_created
303      (insert (format "\n** Issues Filed (%d)\n" (length .github_issues_created)))
304      (daily-plan--insert-items .github_issues_created #'daily-plan--insert-gh-item nil))
305
306    ;; Discussions
307    (when .github_discussions
308      (insert (format "\n** Discussions (%d)  :community:\n" (length .github_discussions)))
309      (daily-plan--insert-items .github_discussions #'daily-plan--insert-discussion-item nil))
310
311    ;; Comments
312    (when .github_comments
313      (insert (format "\n** Comments (%d)\n" (length .github_comments)))
314      (daily-plan--insert-items .github_comments #'daily-plan--insert-comment-item nil))
315
316    ;; Still in progress
317    (insert "\n** Still In Progress\n")
318    (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
319
320    ;; Backlog
321    (insert "\n** Backlog — Candidates for Next Week\n")
322    (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
323
324    ;; Assigned issues
325    (insert "\n** GitHub Issues (assigned)\n")
326    (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
327
328    (insert "\n** GitHub PRs (assigned)\n")
329    (daily-plan--insert-items .github_assigned_prs #'daily-plan--insert-gh-item nil)))
330
331;; ── Buffer management ──
332
333(defun daily-plan--create-buffer (name render-fn data)
334  "Create or reuse buffer NAME, render DATA with RENDER-FN."
335  (let ((buf (get-buffer-create name)))
336    (with-current-buffer buf
337      (let ((inhibit-read-only t)
338            (pos (point)))
339        (erase-buffer)
340        (funcall render-fn data)
341        (daily-plan-mode)
342        ;; Fold all sections to level 2 for overview
343        (goto-char (point-min))
344        (org-content 2)
345        (goto-char (min pos (point-max)))))
346    (pop-to-buffer buf)))
347
348(defun daily-plan--run-async (buf-name args render-fn)
349  "Run daily-plan with ARGS asynchronously, render into BUF-NAME with RENDER-FN."
350  (message "Fetching %s..." buf-name)
351  (let ((buf (get-buffer-create buf-name))
352        (proc-buf (generate-new-buffer " *daily-plan-proc*")))
353    ;; Show buffer immediately with loading message
354    (with-current-buffer buf
355      (let ((inhibit-read-only t))
356        (erase-buffer)
357        (insert "Loading...")
358        (daily-plan-mode)
359        (setq-local daily-plan--current-args args)
360        (setq-local daily-plan--current-render-fn render-fn)))
361    (pop-to-buffer buf)
362    ;; Run async
363    (make-process
364     :name "daily-plan"
365     :buffer proc-buf
366     :command (append (list daily-plan-command) args (list "--json"))
367     :sentinel
368     (lambda (proc _event)
369       (when (eq (process-status proc) 'exit)
370         (if (zerop (process-exit-status proc))
371             (let ((data (with-current-buffer proc-buf
372                           (goto-char (point-min))
373                           (condition-case nil
374                               (json-parse-buffer :object-type 'alist :array-type 'list)
375                             (error nil)))))
376               (if data
377                   (progn
378                     (daily-plan--create-buffer buf-name render-fn data)
379                     ;; Preserve local vars after re-render
380                     (with-current-buffer buf-name
381                       (setq-local daily-plan--current-args args)
382                       (setq-local daily-plan--current-render-fn render-fn))
383                     (message "%s ready." buf-name))
384                 (message "Failed to parse %s output" buf-name)))
385           (message "daily-plan failed with exit code %d" (process-exit-status proc)))
386         (kill-buffer proc-buf))))))
387
388;; ── Interactive commands ──
389
390;;;###autoload
391(defun daily-plan-show ()
392  "Show today's daily plan."
393  (interactive)
394  (daily-plan--run-async "*daily-plan*" '("show") #'daily-plan--render-show))
395
396;;;###autoload
397(defun daily-plan-inbox (&optional since)
398  "Show new/updated items since last check or SINCE date."
399  (interactive "sInbox since (empty=last check, or YYYY-MM-DD/\"last monday\"): ")
400  (let ((args (if (and since (not (string-empty-p since)))
401                  (list "inbox" since)
402                (list "inbox"))))
403    (daily-plan--run-async "*daily-plan-inbox*" args #'daily-plan--render-inbox)))
404
405;;;###autoload
406(defun daily-plan-weekly ()
407  "Show weekly review."
408  (interactive)
409  (daily-plan--run-async "*daily-plan-weekly*" '("weekly") #'daily-plan--render-weekly))
410
411;;;###autoload
412(defun daily-plan-review (&optional period)
413  "Show full activity review for PERIOD.
414PERIOD can be \"this week\", \"last week\", \"this month\", etc."
415  (interactive
416   (list (completing-read "Period: "
417                          '("this week" "last week" "this month" "last month")
418                          nil nil nil nil "this week")))
419  (let* ((buf-name (format "*daily-plan-review [%s]*" period))
420         (args (list "review" period)))
421    (setq daily-plan--current-period period)
422    (daily-plan--run-async buf-name args #'daily-plan--render-weekly)))
423
424;;;###autoload
425(defun daily-plan-schedule (key &optional date)
426  "Schedule a Jira or GitHub issue KEY for DATE.
427KEY should be PROJ-123 for Jira or owner/repo#123 for GitHub."
428  (interactive
429   (list (read-string "Issue key (PROJ-123 or org/repo#123): ")
430         (org-read-date nil nil nil "Schedule for: ")))
431  (let ((output (daily-plan--run-plain
432                 "schedule" key (or date (format-time-string "%Y-%m-%d")))))
433    (message "%s" output)))
434
435(defun daily-plan-schedule-at-point ()
436  "Schedule the item at point for a chosen date."
437  (interactive)
438  (let ((key (get-text-property (point) 'daily-plan-key)))
439    (if key
440        (let* ((date (org-read-date nil nil nil (format "Schedule %s for: " key)))
441               (output (daily-plan--run-plain "schedule" key date)))
442          (message "%s" output))
443      ;; Fallback: try to extract from org link
444      (save-excursion
445        (beginning-of-line)
446        (if (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" (line-end-position) t)
447            (let* ((ref (match-string 2))
448                   (date (org-read-date nil nil nil (format "Schedule %s for: " ref)))
449                   (output (daily-plan--run-plain "schedule" ref date)))
450              (message "%s" output))
451          (message "No schedulable item at point"))))))
452
453(defun daily-plan-open-at-point ()
454  "Open the URL of the item at point in the browser."
455  (interactive)
456  (let ((url (get-text-property (point) 'daily-plan-url)))
457    (if url
458        (browse-url url)
459      ;; Fallback to org link
460      (org-open-at-point))))
461
462(defun daily-plan-refresh ()
463  "Refresh the current daily-plan buffer."
464  (interactive)
465  (let ((args daily-plan--current-args)
466        (render-fn daily-plan--current-render-fn)
467        (buf-name (buffer-name)))
468    (cond
469     ;; Use stored args/render if available
470     ((and args render-fn)
471      (daily-plan--run-async buf-name args render-fn))
472     ;; Fallback by buffer name
473     ((string= buf-name "*daily-plan*") (daily-plan-show))
474     ((string= buf-name "*daily-plan-inbox*") (daily-plan-inbox))
475     ((string= buf-name "*daily-plan-weekly*") (daily-plan-weekly))
476     (t (message "Not a daily-plan buffer")))))
477
478(defun daily-plan-switch-period ()
479  "Switch the time period for review/weekly buffers."
480  (interactive)
481  (let ((period (completing-read "Period: "
482                                 '("this week" "last week" "this month" "last month")
483                                 nil nil nil nil (or daily-plan--current-period "this week"))))
484    (cond
485     ((string-match-p "review" (buffer-name))
486      (daily-plan-review period))
487     ((string-match-p "weekly" (buffer-name))
488      ;; Weekly always uses "this week", suggest review instead
489      (daily-plan-review period))
490     (t (daily-plan-review period)))))
491
492(defun daily-plan-yank-markdown ()
493  "Copy the current buffer content as markdown to the kill ring.
494Runs `daily-plan review` with the current period to get markdown."
495  (interactive)
496  (let* ((period (or daily-plan--current-period "this week"))
497         (output (daily-plan--run-plain "review" period)))
498    (kill-new output)
499    (message "Markdown copied to kill ring (%d chars)" (length output))))
500
501(provide 'daily-plan)
502;;; daily-plan.el ends here