/ git-bug.el
git-bug.el
  1  ;;; git-bug.el --- Conviences for git-bug local-first issues -*- lexical-binding: t; -*-
  2  ;; Copyright (C) 2025 Will Foran
  3  
  4  ;; Author: Will Foran <willforan+emacs@gmail.com>
  5  ;; URL: http://www.github.com/WillForan/emacs-git-bug
  6  ;; Version: 1.0.0
  7  ;; Keywords: tools vc processes
  8  ;; Package-Requires: ((emacs "29.1") )
  9  
 10  ;; This file is not part of GNU Emacs.
 11  
 12  ;;; Commentary:
 13  ;; A minimal interface to [[https://github.com/git-bug/][git-bug]] for EMACS.
 14  ;;
 15  ;; This package provides a =completing-read= menu to match existing bugs and another menu to act on a bug.
 16  ;;
 17  ;; Good entrypoints and canidates for assigned keybindings are
 18  ;;   * =git-bug-menu=
 19  ;;   * =git-bug-new-from-line=
 20  ;;
 21  ;; Usage:
 22  ;; (use-package git-bug
 23  ;;   :bind
 24  ;;   ("C-c b m" . git-bug-menu)
 25  ;;   ("C-c b c" . git-bug-new-from-line))
 26  ;;; Code:
 27  (defun git-bug-candidates ()
 28    "Shell out to git-bug.  Formatted id candidates for `ivy-read`."
 29    (let* ((cmd-bug-list-jq-tsv "git-bug bug -f json | jq -r '.[]|[.id,.edit_time.time,.author.name,.status,.title]|@tsv'|sort -k2,2nr")
 30           (res (shell-command-to-string cmd-bug-list-jq-tsv))
 31           (bugs (split-string res "\n" t)))
 32      (seq-map (lambda (line)
 33                 (let* ((fields (split-string line "\t"))
 34                        (id (nth 0 fields))
 35                        (date (nth 1 fields))
 36                        (status (nth 3 fields))
 37                        (title (nth 4 fields)))
 38                   (propertize (format "%.7s %.10s %s" id date (string-join (nthcdr 2 fields) "\t"))
 39                               'bug-id id
 40                               'bug-status status
 41                               'bug-date date
 42                               'bug-title title)))
 43               bugs)))
 44  
 45  (defun git-bug-extract-id-in-text ()
 46    "Find gb# on current line.  Search should match output of `git-bug-insert-bugid`."
 47    ;; TODO: be smarter. maybe search in both directions?
 48    ;; TODO: make 'gb#' prefix a package customize variable
 49    (save-excursion
 50             (move-beginning-of-line 1)
 51             (search-forward "gb#" (pos-eol) t)
 52             (thing-at-point 'word t)))
 53  
 54  (defun git-bug-completing-read ()
 55    "Completing-read for git-bug."
 56    (let ((init-input (git-bug-extract-id-in-text)))
 57      (when-let* ((selection (completing-read "bug:" (git-bug-candidates) nil nil init-input nil)))
 58        ;; NB. splitting on space as saved from `git-bug-completing-read` vs tab from command output.
 59        (car (split-string selection " "))))) ;; returns just the bug id.
 60  
 61        ;; completing-read does not return object with text properties?
 62        ;; (setq bugid (get-text-property 0 'bug-id selection))
 63  
 64  
 65  (defun git-bug-insert-bugid (&optional bugid title)
 66    "Insert gb# issue number at current position in buffer.
 67  Give `BUGID` and/or `TITLE` to avoid calling git-bug."
 68    (interactive "P")
 69    (when (not bugid) (setq bugid (git-bug-completing-read)))
 70    (insert (format "gb#%.7s" bugid))
 71    (when title (insert (format " \"%s\"" title)))
 72    ;; (insert " ")
 73    )
 74  
 75  (defun git-bug-ls ()
 76    "Use `git bug`'s built-in `org-mode` format to display all bugs."
 77    (interactive)
 78    (with-current-buffer (get-buffer-create "git-bug.org")
 79      (erase-buffer)
 80      (insert (shell-command-to-string "git-bug bug -f org-mode"))
 81      (goto-char (point-min))
 82      (pop-to-buffer (current-buffer))
 83      (org-mode)))
 84  
 85  (defun git-bug-show-bug (&optional bugid)
 86    "Create a new buffer to show this `BUGID`'s title and it's comments."
 87    (interactive "P")
 88    (when (not bugid) (setq bugid (git-bug-completing-read)))
 89    (with-current-buffer (get-buffer-create (format "gb#%s" bugid))
 90      (erase-buffer)
 91      (insert (shell-command-to-string
 92               (format "git-bug bug title \"%s\"" bugid)))
 93      (insert (shell-command-to-string
 94               (format "git-bug bug comment \"%s\"" bugid)))
 95      (goto-char (point-min))
 96      (pop-to-buffer (current-buffer))))
 97  
 98  (defun git-bug-edit-bug (bugid)
 99    "Edit bug `BUGID` in temporary buffer.
100  Currently only works well if EDITOR is set to emacsclient and
101  emacs-server is running.
102  TODO(gb#cc5fa60): refactor new and edit so edit can reuse temp buffer"
103    (start-process (format "git-bug-edit:%s" bugid)
104                   nil
105                   "git-bug" "bug" "title" "edit" bugid))
106  
107  (defun git-bug-comment-new (bugid)
108    "Create a new comment for bug `BUGID` in via EDITOR.
109  Like `git-bug-edit-bug` creates a new process that works best if
110    emacs-server is running and EDITOR=emacsclinet (gb#cc5fa60)"
111    (start-process (format "git-bug-comment:%s" bugid)
112                   nil
113                   "git-bug" "bug" "comment" "new" bugid))
114  
115  (defun git-bug-edit-at-line ()
116    "Edit first match of gb#1234567 on the current line."
117    (interactive)
118    (if-let*
119        ((bugid (git-bug-extract-id-in-text)))
120        (git-bug-edit-bug bugid)
121      (error "No bug like gh#1234567 on line")))
122  
123  (defun git-bug-cmd (bugid cmd)
124    "Apply `CMD` to `BUGID`.
125  Runs e.g. `git-bug bug status open $bugid`."
126    (let ((cmd (format "git-bug bug %s \"%s\"" cmd bugid)))
127      ;(message "running '%s'" cmd)
128      (shell-command-to-string cmd)))
129  
130  (defvar git-bug-menu-actions-alist
131    '(;; ("test" . (lambda (bid) (message "selected: %s" bid)))
132      ("show"   . git-bug-show-bug)
133      ("edit"   . git-bug-edit-bug)
134      ("insert" . git-bug-insert-bugid)
135      ("close"  . (lambda (bugid) "Close issue." (git-bug-cmd bugid "status close" )))
136      ("comment". git-bug-comment-new)
137      ("open"   . (lambda (bugid) "Reopen issue." (git-bug-cmd bugid "status open")))
138      ("rm"   . (lambda (bugid) "Remove issue." (git-bug-cmd bugid "rm"))))
139   "Bug actions preformed when given a `BUGID`.")
140  
141  (defun git-bug-menu (&optional bugid action)
142    "Choose a bug and than action each from a list.
143  Runs `ACTION` (in `git-bug-menu-actions-alist`)
144  on `BUGID` (`git-bug bug -f json`).
145  
146  `completing-read` for `BUGID` and/or `ACTION` if not provided."
147    (interactive)
148    (when (not bugid) (setq bugid (git-bug-completing-read)))
149    (when (not bugid) (error "Failed to select a bug id"))
150    ;; TODO(gb#7b002ae): bug-menu to exit and return when bug is saved. also regexp is wrong?
151    ;; (when (not (string-match "^[A-Za-z0-9]{9}$" bugid))
152    ;;            (setq bugid (git-bug-editmsg-new bugid)))
153    (when (not action)
154      (setq action (cdr (assoc
155                         (completing-read "Action:" git-bug-menu-actions-alist)
156                         git-bug-menu-actions-alist))))
157  
158    (funcall action bugid))
159  
160  ;; Edit buffer a la magit, org-capture, etc
161  (define-minor-mode git-bug-editmsg-mode
162    "A minor mode for committing temporary edits."
163    :keymap (let ((map (make-sparse-keymap)))
164              (define-key map (kbd "C-c C-c") #'git-bug-editmsg-save-and-close)
165              (define-key map (kbd "C-c C-k") #'git-bug-editmsg-close)
166              map)
167    :lighter "GBug")
168  
169  (defun git-bug-editmsg-close (&optional no-kill)
170    "Close edit buffer and remove the underlining file without save questions.
171  `NO-KILL` is set to t when called from `kill-buffer-hook` to avoid recursion."
172    (interactive)
173    ;; Dont do dangerous things to the wrong buffer
174    (if (not (bound-and-true-p git-bug-editmsg-mode))
175        (error "`git-bug-editmsg-close` when not in `git-bug-editmsg-mode`! ignorring"))
176    (let ((temp-file  (buffer-file-name)))
177      ;; kill-buffer hook or keymap trigger? avoid accidental recursion
178      (when (not no-kill)
179        (set-buffer-modified-p nil) ;; don't prompt to save
180        (kill-buffer (current-buffer)))
181  
182      ;; did we already delete the file? yes if C-c C-k?
183      (when (and temp-file (file-exists-p temp-file))
184        (delete-file temp-file))))
185  
186  (defun git-bug-editmsg-new (&optional initial-text)
187    "Open a new temporary \"BUG_MESSAGE\" file with `INITIAL-TEXT`.
188  Hook removes file when buffer is killed."
189    (interactive)
190    (let ((cur-dir default-directory)
191          (temp-file (make-temp-file "BUG_MESSAGE_EDITMSG" nil nil initial-text)))
192      (find-file temp-file)
193      ;; pretend to be where we came from so git commands work
194      (setq-local default-directory cur-dir)
195  
196      (goto-char (pos-eol))
197      (insert "\n\n# C-C C-c to save bug; C-C C-k to discard.")
198      (goto-char (point-min))
199      (git-bug-editmsg-mode 1)
200      (add-hook 'kill-buffer-hook (lambda () (git-bug-editmsg-close t)) nil t)))
201  
202  (defun git-bug-editmsg-save-and-close ()
203    "Use current buffer as input to git bug new and kill it.
204  Hook should also remove what is assumed to be a temporary file.
205  Returns git-bug id."
206    (interactive)
207    (save-buffer)
208    (let* ((cmd (format "git-bug bug new -F \"%s\"" (buffer-file-name)))
209           (cmd-res (shell-command-to-string cmd))
210           (bugid (replace-regexp-in-string " created\n" "" cmd-res)))
211      (when (or (not bugid) (string= bugid ""))
212        (error "Failed to create a bug?! No id returned by '%s' => '%s'" cmd cmd-res))
213      (message "new git-bug bugid: %s" bugid)
214      (git-bug-editmsg-close)
215      bugid))
216  
217  (defun git-bug-new-from-line ()
218    "Create a bug from TODO/FIX/BUG: on the current line.
219  Abuse `git-bug-editmsg-new` and `git-bug-editmsg-save-and-close`
220  as hidden buffers to run git-bug bug new command."
221    (interactive)
222    (save-excursion
223      (move-beginning-of-line 1)
224      (search-forward-regexp "TODO:\\|FIX:\\|BUG:\\|HACK:\\|XXX:" (pos-eol) t)
225      (let ((label-point (point)))
226        (if (= label-point (line-beginning-position))
227            (error "No label like TODO FIX or BUG found"))
228        (let* ((bug-title (string-trim (buffer-substring-no-properties label-point (pos-eol))))
229               ;; TODO(gb#59e13c7): git-bug-new-from-line should include file:line when creating
230               (bugid
231                (save-current-buffer
232                  (git-bug-editmsg-new bug-title)
233                  (git-bug-editmsg-save-and-close))))
234          (goto-char (- label-point 1)) ;; go back before the trailing ':'
235  
236          ;; final output looks like 'TODO(gb#1234567): title of bug/issue'
237          (insert "(")
238          (git-bug-insert-bugid bugid)
239          (insert ")")))))
240  
241  
242  ;;; TODOs
243  ;; TODO(gb#e7a8b7c): edit message color like commit-message
244  ;; TODO(gb#94e034c): git-bug porcelain for magit-forge
245  ;; TODO(gb#6588bc5): list of git-bug project directories for 'overview of all' page
246  ;; TODO(gb#3a93c2e): minor-mode for clickable buttons, company/cornfu completion?
247  
248  (provide 'git-bug)
249  ;;; git-bug.el ends here