;;; beadwork-cli.el --- CLI interface layer for beadwork -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Philip Munksgaard ;; Author: Philip Munksgaard ;; SPDX-License-Identifier: GPL-3.0-or-later ;; This file is part of beadwork.el. ;; beadwork.el is free software: you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; beadwork.el is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with beadwork.el. If not, see . ;;; Commentary: ;; CLI interface layer for beadwork. Provides functions that shell out to the ;; `bw` command-line tool, parse its JSON output, and return Emacs Lisp data ;; structures. ;;; Code: (require 'json) ;;;; Customization (defgroup beadwork nil "Emacs frontend for the beadwork (bw) issue tracker." :group 'tools :prefix "beadwork-") (defcustom beadwork-cli-program "bw" "Path to the bw command-line program." :type 'string :group 'beadwork) ;;;; Constants (defconst beadwork-statuses '("open" "in_progress" "closed" "deferred") "Valid issue statuses.") (defconst beadwork-types '("task" "bug" "epic") "Common issue types.") (defconst beadwork-priorities '("0" "1" "2" "3" "4") "Valid priority values as strings for completing-read.") ;;;; Error handling (define-error 'beadwork-error "Beadwork error") (define-error 'beadwork-cli-error "Beadwork CLI error" 'beadwork-error) (define-error 'beadwork-project-error "Beadwork project error" 'beadwork-error) ;;;; Project root detection (defvar beadwork-cli--project-root-cache nil "Cache mapping directories to their beadwork project roots. An alist of (DIRECTORY . ROOT-OR-NIL).") (defun beadwork-cli--detect-project-root (&optional directory) "Detect the beadwork project root starting from DIRECTORY. DIRECTORY defaults to `default-directory'. A beadwork project root is a git repository that has a `beadwork' branch. Returns the absolute path to the project root, or nil." (let* ((dir (expand-file-name (or directory default-directory))) (cached (assoc dir beadwork-cli--project-root-cache))) (if cached (cdr cached) (let ((root (beadwork-cli--find-project-root dir))) (push (cons dir root) beadwork-cli--project-root-cache) root)))) (defun beadwork-cli--find-project-root (directory) "Walk up from DIRECTORY looking for a git repo with a beadwork branch." (let ((git-root (locate-dominating-file directory ".git"))) (when git-root (let ((git-root (expand-file-name git-root))) (if (beadwork-cli--has-beadwork-branch-p git-root) git-root (let ((parent (file-name-directory (directory-file-name git-root)))) (unless (string= parent git-root) (beadwork-cli--find-project-root parent)))))))) (defun beadwork-cli--has-beadwork-branch-p (directory) "Return non-nil if DIRECTORY is a git repo with a `beadwork' branch." (let ((default-directory directory)) (= 0 (call-process "git" nil nil nil "rev-parse" "--verify" "refs/heads/beadwork")))) (defun beadwork-cli-project-root () "Return the beadwork project root for the current directory. Signals `beadwork-project-error' if no project is found." (or (beadwork-cli--detect-project-root) (signal 'beadwork-project-error (list "No beadwork project found (no git repo with a `beadwork' branch)")))) (defun beadwork-cli-clear-project-cache () "Clear the project root cache." (setq beadwork-cli--project-root-cache nil)) ;;;; Internal helpers (defun beadwork-cli--vec-to-list (val) "Convert VAL to a list if it is a vector, otherwise return as-is." (if (vectorp val) (append val nil) val)) (defun beadwork-cli--plist-to-args (plist spec) "Convert PLIST to CLI argument list using SPEC. SPEC is a list of (KEYWORD FLAG &optional TRANSFORM) entries where KEYWORD is a plist key like :status, FLAG is the CLI flag like \"--status\", and TRANSFORM is an optional function to convert the value to a string (defaults to identity). Boolean-valued keys (where value is t) emit just the flag with no argument." (let (args) (pcase-dolist (`(,key ,flag . ,rest) spec) (let ((val (plist-get plist key))) (when val (push flag args) (unless (eq val t) (let ((transform (car rest))) (push (if transform (funcall transform val) val) args)))))) (nreverse args))) ;;;; Core CLI runner (defun beadwork-cli--run-process (args parse-json) "Run bw with ARGS in the project root. When PARSE-JSON is non-nil, append --json and parse the output. Otherwise return raw stdout as a string. Signals `beadwork-cli-error' on non-zero exit." (let ((default-directory (beadwork-cli-project-root)) (all-args (if parse-json (append args (list "--json")) args)) stderr-file stdout-buf) (unwind-protect (progn (setq stderr-file (make-temp-file "beadwork-stderr")) (setq stdout-buf (generate-new-buffer " *beadwork-stdout*")) (let ((exit-code (apply #'call-process beadwork-cli-program nil (list stdout-buf stderr-file) nil all-args))) (if (= exit-code 0) (with-current-buffer stdout-buf (if parse-json (progn (goto-char (point-min)) (if (= (point-min) (point-max)) nil (condition-case err (json-read) (json-error (signal 'beadwork-cli-error (list (format "Failed to parse JSON: %s\nOutput: %s" (error-message-string err) (buffer-string)))))))) (string-trim (buffer-string)))) (let ((stderr-output (with-temp-buffer (insert-file-contents stderr-file) (string-trim (buffer-string))))) (signal 'beadwork-cli-error (list (format "bw %s exited with %d: %s" (string-join all-args " ") exit-code stderr-output))))))) (when (and stdout-buf (buffer-live-p stdout-buf)) (kill-buffer stdout-buf)) (when stderr-file (delete-file stderr-file))))) (defun beadwork-cli-run (&rest args) "Run bw with ARGS, appending --json, and return parsed output." (beadwork-cli--run-process args t)) (defun beadwork-cli-run-no-json (&rest args) "Run bw with ARGS without --json. Returns raw stdout as a string." (beadwork-cli--run-process args nil)) ;;;; Issue data accessors (defun beadwork-cli-issue-id (issue) "Return the ID string of ISSUE." (alist-get 'id issue)) (defun beadwork-cli-issue-title (issue) "Return the title of ISSUE." (alist-get 'title issue)) (defun beadwork-cli-issue-status (issue) "Return the status string of ISSUE." (alist-get 'status issue)) (defun beadwork-cli-issue-priority (issue) "Return the priority (integer 0--4) of ISSUE." (alist-get 'priority issue)) (defun beadwork-cli-issue-type (issue) "Return the type string of ISSUE." (alist-get 'type issue)) (defun beadwork-cli-issue-assignee (issue) "Return the assignee string of ISSUE." (alist-get 'assignee issue)) (defun beadwork-cli-issue-description (issue) "Return the description of ISSUE." (alist-get 'description issue)) (defun beadwork-cli-issue-labels (issue) "Return the labels of ISSUE as a list of strings." (beadwork-cli--vec-to-list (alist-get 'labels issue))) (defun beadwork-cli-issue-blocked-by (issue) "Return the list of IDs that block ISSUE." (beadwork-cli--vec-to-list (alist-get 'blocked_by issue))) (defun beadwork-cli-issue-blocks (issue) "Return the list of IDs that ISSUE blocks." (beadwork-cli--vec-to-list (alist-get 'blocks issue))) (defun beadwork-cli-issue-comments (issue) "Return the comments of ISSUE as a list of alists." (beadwork-cli--vec-to-list (alist-get 'comments issue))) (defun beadwork-cli-issue-created (issue) "Return the creation timestamp of ISSUE." (alist-get 'created issue)) (defun beadwork-cli-issue-updated (issue) "Return the last-updated timestamp of ISSUE." (alist-get 'updated_at issue)) ;;;; CLI command wrappers (defconst beadwork-cli--list-spec '((:status "--status") (:assignee "--assignee") (:priority "--priority" number-to-string) (:type "--type") (:label "--label") (:grep "--grep") (:parent "--parent") (:limit "--limit" number-to-string) (:all "--all") (:deferred "--deferred") (:overdue "--overdue")) "Spec mapping filter plist keys to bw list CLI flags.") (defconst beadwork-cli--create-spec '((:priority "--priority" number-to-string) (:type "--type") (:description "--description") (:defer "--defer") (:due "--due") (:parent "--parent")) "Spec mapping create option plist keys to bw create CLI flags.") (defconst beadwork-cli--update-spec '((:title "--title") (:description "--description") (:priority "--priority" number-to-string) (:assignee "--assignee") (:type "--type") (:status "--status") (:defer "--defer") (:due "--due") (:parent "--parent")) "Spec mapping update option plist keys to bw update CLI flags.") (defun beadwork-cli-list (&optional filters) "List issues, returning a list of issue objects. FILTERS is an optional plist of filter arguments." (let ((args (append (list "list") (beadwork-cli--plist-to-args filters beadwork-cli--list-spec)))) (beadwork-cli--vec-to-list (apply #'beadwork-cli-run args)))) (defun beadwork-cli-show (id) "Show the full details of issue ID. Returns an issue alist." (beadwork-cli-run "show" id)) (defun beadwork-cli-create (title &optional options) "Create a new issue with TITLE. OPTIONS is an optional plist: :priority N, :type TYPE, :description TEXT, :defer DATE, :due DATE, :parent ID. Returns the created issue." (let ((args (append (list "create" title) (beadwork-cli--plist-to-args options beadwork-cli--create-spec)))) (apply #'beadwork-cli-run args))) (defun beadwork-cli-update (id &optional options) "Update issue ID with OPTIONS. OPTIONS is a plist: :title TEXT, :description TEXT, :priority N, :assignee WHO, :type TYPE, :status STATUS, :defer DATE, :due DATE, :parent ID. Returns the updated issue." (let ((args (append (list "update" id) (beadwork-cli--plist-to-args options beadwork-cli--update-spec)))) (apply #'beadwork-cli-run args))) (defun beadwork-cli-close (id &optional reason) "Close issue ID with optional REASON. Returns the closed issue." (if reason (beadwork-cli-run "close" id "--reason" reason) (beadwork-cli-run "close" id))) (defun beadwork-cli-delete (id &optional force) "Delete issue ID. If FORCE is non-nil, skip confirmation." (if force (beadwork-cli-run "delete" id "--force") (beadwork-cli-run "delete" id))) (defun beadwork-cli-start (id &optional assignee) "Start working on issue ID, optionally assigning to ASSIGNEE." (if assignee (beadwork-cli-run "start" id "--assignee" assignee) (beadwork-cli-run "start" id))) (defun beadwork-cli-reopen (id) "Reopen issue ID. Returns the reopened issue." (beadwork-cli-run "reopen" id)) (defun beadwork-cli-comment (id text &optional author) "Add a comment with TEXT to issue ID. Optional AUTHOR overrides the default author." (if author (beadwork-cli-run "comment" id text "--author" author) (beadwork-cli-run "comment" id text))) (defun beadwork-cli-label-add (id &rest labels) "Add LABELS to issue ID. LABELS are strings (without the + prefix)." (apply #'beadwork-cli-run "label" id (mapcar (lambda (l) (concat "+" l)) labels))) (defun beadwork-cli-label-remove (id &rest labels) "Remove LABELS from issue ID. LABELS are strings (without the - prefix)." (apply #'beadwork-cli-run "label" id (mapcar (lambda (l) (concat "-" l)) labels))) (defun beadwork-cli-ready () "Return a list of ready (unblocked, open) issues." (beadwork-cli--vec-to-list (beadwork-cli-run "ready"))) (defun beadwork-cli-blocked () "Return a list of blocked issues." (beadwork-cli--vec-to-list (beadwork-cli-run "blocked"))) (defun beadwork-cli-dep-add (blocker-id blocked-id) "Add a dependency: BLOCKER-ID blocks BLOCKED-ID." (beadwork-cli-run "dep" "add" blocker-id "blocks" blocked-id)) (defun beadwork-cli-dep-remove (blocker-id blocked-id) "Remove a dependency: BLOCKER-ID no longer blocks BLOCKED-ID." (beadwork-cli-run "dep" "remove" blocker-id "blocks" blocked-id)) (defun beadwork-cli-history (id &optional limit) "Return the history of issue ID as a list of entries." (beadwork-cli--vec-to-list (if limit (beadwork-cli-run "history" id "--limit" (number-to-string limit)) (beadwork-cli-run "history" id)))) (defun beadwork-cli-sync () "Run bw sync. Returns the raw output as a string." (beadwork-cli-run-no-json "sync")) (defun beadwork-cli-defer (id when) "Defer issue ID until WHEN (a date string)." (beadwork-cli-run-no-json "defer" id when)) (defun beadwork-cli-undefer (id) "Remove deferral from issue ID." (beadwork-cli-run-no-json "undefer" id)) (defun beadwork-cli-attach (id file-path) "Attach FILE-PATH to issue ID." (beadwork-cli-run-no-json "attach" id (expand-file-name file-path))) (provide 'beadwork-cli) ;;; beadwork-cli.el ends here