/ 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