2149 lines
85 KiB
EmacsLisp
2149 lines
85 KiB
EmacsLisp
|
|
;;; forge-topic.el --- Topics support -*- lexical-binding:t -*-
|
||
|
|
|
||
|
|
;; Copyright (C) 2018-2026 Jonas Bernoulli
|
||
|
|
|
||
|
|
;; Author: Jonas Bernoulli <emacs.forge@jonas.bernoulli.dev>
|
||
|
|
;; Maintainer: Jonas Bernoulli <emacs.forge@jonas.bernoulli.dev>
|
||
|
|
|
||
|
|
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
|
|
||
|
|
;; This file 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.
|
||
|
|
;;
|
||
|
|
;; This file 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 this file. If not, see <https://www.gnu.org/licenses/>.
|
||
|
|
|
||
|
|
;;; Code:
|
||
|
|
|
||
|
|
(require 'bookmark)
|
||
|
|
(require 'bug-reference)
|
||
|
|
(require 'eieio-custom)
|
||
|
|
(require 'markdown-mode)
|
||
|
|
(require 'parse-time)
|
||
|
|
(require 'yaml)
|
||
|
|
|
||
|
|
(require 'forge)
|
||
|
|
(require 'forge-post)
|
||
|
|
|
||
|
|
(defvar bug-reference-auto-setup-functions)
|
||
|
|
|
||
|
|
(define-obsolete-face-alias 'forge-topic-slug-completed
|
||
|
|
'forge-topic-slug-realized "Forge 0.5.0")
|
||
|
|
|
||
|
|
(define-obsolete-face-alias 'forge-topic-slug-unplanned
|
||
|
|
'forge-topic-slug-expunged "Forge 0.5.0")
|
||
|
|
|
||
|
|
(define-obsolete-face-alias 'forge-issue-unplanned
|
||
|
|
'forge-issue-expunged "Forge 0.5.0")
|
||
|
|
|
||
|
|
;;; Options
|
||
|
|
|
||
|
|
(defcustom forge-limit-topic-choices t
|
||
|
|
"Whether to initially limit completion candidates to active topics."
|
||
|
|
:package-version '(forge . "0.4.0")
|
||
|
|
:group 'forge
|
||
|
|
:type 'boolean)
|
||
|
|
|
||
|
|
(defcustom forge-post-heading-format "%a %C\n"
|
||
|
|
"Format for post headings in topic view.
|
||
|
|
|
||
|
|
The following %-sequences are supported:
|
||
|
|
|
||
|
|
`%a' The forge nickname of the author.
|
||
|
|
`%c' The absolute creation date.
|
||
|
|
`%C' The relative creation date."
|
||
|
|
:package-version '(forge . "0.1.0")
|
||
|
|
:group 'forge
|
||
|
|
:type 'string)
|
||
|
|
|
||
|
|
(defcustom forge-post-fill-region t
|
||
|
|
"Whether to call `fill-region' before displaying forge posts."
|
||
|
|
:package-version '(forge . "0.1.0")
|
||
|
|
:group 'forge
|
||
|
|
:type 'boolean)
|
||
|
|
|
||
|
|
(defcustom forge-topic-wash-title-hook
|
||
|
|
(list #'magit-highlight-bracket-keywords)
|
||
|
|
"Functions used to highlight parts of each individual topic title.
|
||
|
|
|
||
|
|
These functions are called in order, in a buffer that containing the
|
||
|
|
topic title. They should set text properties as they see fit, usually
|
||
|
|
just `font-lock-face'. Before each function is called, point is at the
|
||
|
|
beginning of the buffer."
|
||
|
|
:package-version '(forge . "0.4.7")
|
||
|
|
:group 'forge
|
||
|
|
:type 'hook
|
||
|
|
:options (list #'magit-highlight-bracket-keywords))
|
||
|
|
|
||
|
|
(defcustom forge-topic-repository-slug-width 28
|
||
|
|
"Width of repository slugs (i.e., \"OWNER/NAME\")."
|
||
|
|
:package-version '(forge . "0.4.0")
|
||
|
|
:group 'forge
|
||
|
|
:type 'natnum)
|
||
|
|
|
||
|
|
(defcustom forge-bug-reference-hooks
|
||
|
|
'(find-file-hook
|
||
|
|
forge-post-mode-hook
|
||
|
|
git-commit-setup-hook
|
||
|
|
magit-mode-hook)
|
||
|
|
"Hooks to which `forge-bug-reference-setup' is added.
|
||
|
|
This variable has to be customized before `forge' is loaded."
|
||
|
|
:package-version '(forge . "0.2.0")
|
||
|
|
:group 'forge
|
||
|
|
:options '(find-file-hook
|
||
|
|
forge-post-mode-hook
|
||
|
|
git-commit-setup-hook
|
||
|
|
magit-mode-hook)
|
||
|
|
:type '(list :convert-widget custom-hook-convert-widget))
|
||
|
|
|
||
|
|
(defvar forge-format-avatar-function nil
|
||
|
|
"Function used to insert avatars in certain locations.
|
||
|
|
This is experimental and intended for users who wish to
|
||
|
|
implement such a function themselves. See #447.")
|
||
|
|
|
||
|
|
;;; Faces
|
||
|
|
;;;; Common
|
||
|
|
|
||
|
|
(defface forge-dimmed '((t :foreground "#93a1a1"))
|
||
|
|
"Parent face or faces used for text that shouldn't stand out.
|
||
|
|
|
||
|
|
This face is not directly, instead several faces inherit from it
|
||
|
|
either directly or via an intermediate face. This face should
|
||
|
|
only specify the `:foreground' attribute, which is why this face
|
||
|
|
does not inherit from `magit-dimmed'."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-header-line
|
||
|
|
'((t :inherit magit-header-line :foreground reset))
|
||
|
|
"Face for the `header-line' in `forge-topic-mode' buffers."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-discussion-answer-heading
|
||
|
|
'((t :inherit magit-diff-added))
|
||
|
|
"Face for headings of discussion replies marked as the answer."
|
||
|
|
:group 'magit-faces)
|
||
|
|
|
||
|
|
;;;; Topic and Notification Slugs
|
||
|
|
|
||
|
|
(defface forge-topic-slug-open
|
||
|
|
'((t :inherit forge-dimmed))
|
||
|
|
"Face uses for slugs of open topics."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-slug-realized
|
||
|
|
'((t :inherit forge-dimmed))
|
||
|
|
"Face used for slugs of realized topics.
|
||
|
|
Realized topics include:
|
||
|
|
- completed issues and
|
||
|
|
- merged pull-requests."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-slug-expunged
|
||
|
|
'((t :inherit forge-dimmed :strike-through t))
|
||
|
|
"Face used for slugs of expunged topics.
|
||
|
|
Expunged topics include:
|
||
|
|
- issues closes as unplanned,
|
||
|
|
- issues closed as duplicates, and
|
||
|
|
- pull-requests closed without merging."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-slug-saved
|
||
|
|
'((t :foreground "orange"))
|
||
|
|
"Face used for slugs of topics with saved notifications."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-slug-unread
|
||
|
|
'((t :weight bold))
|
||
|
|
"Face used for slugs of topics with unread notifications."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;; Topic and Notification Summaries
|
||
|
|
;;;;; Notifications
|
||
|
|
|
||
|
|
(defface forge-topic-unread
|
||
|
|
'((t :weight bold
|
||
|
|
:box (:line-width (-1 . -1) :style nil)))
|
||
|
|
"Face used for summaries of entities with unread notifications.
|
||
|
|
This face is always used together with, and takes preference over,
|
||
|
|
a `forge-{issue,pullreq}-STATE' face and should not specify any
|
||
|
|
attribute that is specified by any of those faces. Likewise those
|
||
|
|
faces should not set `:weight' or `:slant'."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-pending
|
||
|
|
'((t :weight bold))
|
||
|
|
"Face used for summaries of entities with open notifications.
|
||
|
|
This face is always used together with, and takes preference over,
|
||
|
|
a `forge-{issue,pullreq}-STATE' face and should not specify any
|
||
|
|
attribute that is specified by any of those faces. Likewise those
|
||
|
|
faces should not set `:weight' or `:slant'."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-topic-done
|
||
|
|
'((t))
|
||
|
|
"Face used for summaries of entities with no unread or open notification.
|
||
|
|
This face is always used together with, and takes preference over,
|
||
|
|
a `forge-{issue,pullreq}-STATE' face and should not specify any
|
||
|
|
attribute that is specified by any of those faces. Likewise those
|
||
|
|
faces should not set `:weight' or `:slant'."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;;; Discussions
|
||
|
|
|
||
|
|
(defface forge-discussion-open
|
||
|
|
'((t :slant italic))
|
||
|
|
"Face used for summaries of open discussions."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-discussion-completed
|
||
|
|
'((t :inherit forge-dimmed :slant italic))
|
||
|
|
"Face used for summaries of discussions closed as completed."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-discussion-expunged
|
||
|
|
'((t :inherit forge-dimmed :slant italic :strike-through t))
|
||
|
|
"Face used for summaries of expunged discussions.
|
||
|
|
Expunged discussions include:
|
||
|
|
- discussions closes as unplanned, and
|
||
|
|
- discussions closed as duplicates."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;;; Issues
|
||
|
|
|
||
|
|
(defface forge-issue-open
|
||
|
|
'((t))
|
||
|
|
"Face used for summaries of open issues."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-issue-completed
|
||
|
|
'((t :inherit forge-dimmed))
|
||
|
|
"Face used for summaries of issues closed as completed."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-issue-expunged
|
||
|
|
'((t :inherit forge-dimmed :strike-through t))
|
||
|
|
"Face used for summaries of expunged issues.
|
||
|
|
Expunged issues include:
|
||
|
|
- issues closes as unplanned, and
|
||
|
|
- issues closed as duplicates."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;;; Pull-Requests
|
||
|
|
|
||
|
|
(defface forge-pullreq-open
|
||
|
|
'((t :foreground "LimeGreen"))
|
||
|
|
"Face used for summaries of open pull-requests."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-pullreq-merged
|
||
|
|
'((t :foreground "MediumPurple"))
|
||
|
|
"Face used for summaries of merged pull-requests."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-pullreq-rejected
|
||
|
|
'((t :foreground "MediumPurple" :strike-through t))
|
||
|
|
"Face used for summaries of closed pull-requests, that weren't merged."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-pullreq-draft
|
||
|
|
'((t :inherit highlight))
|
||
|
|
"Face used for summaries of draft pull-requests.
|
||
|
|
A face attribute should be used that is not already used by any
|
||
|
|
`forge-topic-STATUS' or `forge-{issue,pullreq}-STATE' face."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;; Labels
|
||
|
|
|
||
|
|
(defface forge-topic-label
|
||
|
|
'((t :inherit secondary-selection
|
||
|
|
:box (:line-width (-1 . -1) :style released-button)))
|
||
|
|
"Face used for topic labels, marks and milestones."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;;; Post Details
|
||
|
|
|
||
|
|
(defface forge-post-author
|
||
|
|
'((t :inherit bold))
|
||
|
|
"Face used for post author in topic view."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
(defface forge-post-date
|
||
|
|
'((t :inherit italic))
|
||
|
|
"Face used for post date in topic view."
|
||
|
|
:group 'forge-faces)
|
||
|
|
|
||
|
|
;;; Class
|
||
|
|
|
||
|
|
(defclass forge-topic (forge-post) () :abstract t)
|
||
|
|
|
||
|
|
(cl-defmethod forge--object-id ((class (subclass forge-topic)) repo number)
|
||
|
|
"Return the id for a CLASS object in REPO identified by id NUMBER."
|
||
|
|
(base64-encode-string
|
||
|
|
(encode-coding-string
|
||
|
|
(format "%s:%s%s"
|
||
|
|
(base64-decode-string (oref repo id))
|
||
|
|
(substring (symbol-name class)
|
||
|
|
(length (oref-default class closql-class-prefix)))
|
||
|
|
number)
|
||
|
|
'utf-8)
|
||
|
|
t))
|
||
|
|
|
||
|
|
(cl-defmethod forge--object-id ((prefix string) number-or-id)
|
||
|
|
(and number-or-id
|
||
|
|
(base64-encode-string
|
||
|
|
(encode-coding-string
|
||
|
|
(format "%s:%s"
|
||
|
|
(base64-decode-string prefix)
|
||
|
|
(if (numberp number-or-id)
|
||
|
|
number-or-id
|
||
|
|
;; Currently every ID is base64 encoded. Unfortunately
|
||
|
|
;; we cannot use the IDs of Gitlab labels (see comment
|
||
|
|
;; in the respective `forge--update-labels' method),
|
||
|
|
;; and have to use their names, which are not encoded.
|
||
|
|
(or (ignore-errors (base64-decode-string number-or-id))
|
||
|
|
number-or-id)))
|
||
|
|
'utf-8)
|
||
|
|
t)))
|
||
|
|
|
||
|
|
(cl-defmethod forge-topic-mark-read ((topic forge-topic))
|
||
|
|
(when (eq (oref topic status) 'unread)
|
||
|
|
(oset topic status 'pending)))
|
||
|
|
|
||
|
|
(cl-defmethod forge--set-topic-marks ((_repo forge-repository) topic marks)
|
||
|
|
(oset topic marks
|
||
|
|
(forge-sql-car [:select id :from mark :where (in name $v1)]
|
||
|
|
(vconcat marks)))
|
||
|
|
(forge-refresh-buffer))
|
||
|
|
|
||
|
|
;;; Query
|
||
|
|
;;;; Get
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-parent ((topic forge-topic))
|
||
|
|
(forge-get-repository topic))
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-repository ((topic forge-topic))
|
||
|
|
(closql-get (forge-db)
|
||
|
|
(oref topic repository)
|
||
|
|
'forge-repository))
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-topic ((topic forge-topic))
|
||
|
|
topic)
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-topic ((repo forge-repository) number-or-id)
|
||
|
|
(cond ((stringp number-or-id)
|
||
|
|
(or (forge-get-discussion number-or-id)
|
||
|
|
(forge-get-issue number-or-id)
|
||
|
|
(forge-get-pullreq number-or-id)))
|
||
|
|
((< number-or-id 0)
|
||
|
|
(forge-get-pullreq repo (abs number-or-id)))
|
||
|
|
((forge-get-discussion repo number-or-id))
|
||
|
|
((forge-get-issue repo number-or-id))
|
||
|
|
((forge-get-pullreq repo number-or-id))))
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-topic ((number integer))
|
||
|
|
(if (< number 0)
|
||
|
|
(forge-get-pullreq (abs number))
|
||
|
|
(or (forge-get-discussion number)
|
||
|
|
(forge-get-issue number)
|
||
|
|
(forge-get-pullreq number))))
|
||
|
|
|
||
|
|
(cl-defmethod forge-get-topic ((id string))
|
||
|
|
(or (forge-get-discussion id)
|
||
|
|
(forge-get-issue id)
|
||
|
|
(forge-get-pullreq id)))
|
||
|
|
|
||
|
|
;;;; Current
|
||
|
|
|
||
|
|
(defun forge-current-topic (&optional demand)
|
||
|
|
"Return the topic at point or being visited.
|
||
|
|
If there is no such topic and DEMAND is non-nil, then signal
|
||
|
|
an error."
|
||
|
|
(or (forge-topic-at-point)
|
||
|
|
forge-buffer-topic
|
||
|
|
(and demand (user-error "No current topic"))))
|
||
|
|
|
||
|
|
(defun forge-topic-at-point (&optional demand)
|
||
|
|
"Return the topic at point.
|
||
|
|
If there is no such topic and DEMAND is non-nil, then signal
|
||
|
|
an error."
|
||
|
|
(or (thing-at-point 'forge-topic)
|
||
|
|
(magit-section-value-if '(discussion issue pullreq))
|
||
|
|
(forge-get-pullreq :branch)
|
||
|
|
(and demand (user-error "No topic at point"))))
|
||
|
|
|
||
|
|
(put 'forge-topic 'thing-at-point #'forge-thingatpt--topic)
|
||
|
|
(defun forge-thingatpt--topic ()
|
||
|
|
(and-let ((_(thing-at-point-looking-at "\\([#!]\\)\\([0-9]+\\)\\_>"))
|
||
|
|
(prefix (match-string-no-properties 1))
|
||
|
|
(number (string-to-number (match-string-no-properties 2)))
|
||
|
|
(repo (forge--repo-for-thingatpt)))
|
||
|
|
(cond ((equal prefix "#")
|
||
|
|
(forge-get-topic repo number))
|
||
|
|
((forge-gitlab-repository--eieio-childp repo)
|
||
|
|
(forge-get-pullreq repo number)))))
|
||
|
|
|
||
|
|
(defun forge-region-topics ()
|
||
|
|
(magit-region-values '(discussion issue pullreq)))
|
||
|
|
|
||
|
|
(defun forge-current-topic-type ()
|
||
|
|
(magit-section-case
|
||
|
|
([* discussions] 'discussion)
|
||
|
|
([* issues] 'issue)
|
||
|
|
([* pullreqs] 'pullreq)
|
||
|
|
(t (or (and forge--buffer-topics-spec
|
||
|
|
(oref forge--buffer-topics-spec type))
|
||
|
|
'topic))))
|
||
|
|
|
||
|
|
;;;; List
|
||
|
|
|
||
|
|
(defvar-local forge--buffer-topics-spec nil)
|
||
|
|
(put 'forge--buffer-topics-spec 'permanent-local t)
|
||
|
|
|
||
|
|
(defun forge--init-buffer-topics-spec ()
|
||
|
|
(unless forge--buffer-topics-spec
|
||
|
|
(setq forge--buffer-topics-spec
|
||
|
|
(clone forge-status-buffer-default-topic-filters))))
|
||
|
|
(add-hook 'magit-status-mode-hook #'forge--init-buffer-topics-spec)
|
||
|
|
|
||
|
|
(defun forge--clone-buffer-topics-spec ()
|
||
|
|
(forge--init-buffer-topics-spec)
|
||
|
|
(clone forge--buffer-topics-spec))
|
||
|
|
|
||
|
|
(defclass forge--topics-spec ()
|
||
|
|
((type :documentation "\
|
||
|
|
Limit list based on topic type."
|
||
|
|
:initarg :type
|
||
|
|
:initform 'topic
|
||
|
|
:type (member topic discussion issue pullreq nil)
|
||
|
|
:custom (choice
|
||
|
|
(const topic)
|
||
|
|
(const discussion)
|
||
|
|
(const issue)
|
||
|
|
(const pullreq)
|
||
|
|
(const :tag "disable topic sections (nil)" nil)))
|
||
|
|
(active :documentation "\
|
||
|
|
Limit list to active topics.
|
||
|
|
|
||
|
|
A topic is \"active\" if its state (public condition) is open and/or
|
||
|
|
its status (private condition) is unread or pending.
|
||
|
|
|
||
|
|
When this is t, then the value of the `state' and `status' slots are
|
||
|
|
ignored."
|
||
|
|
:initarg :active
|
||
|
|
:initform t
|
||
|
|
:type boolean
|
||
|
|
:custom boolean)
|
||
|
|
(state :documentation "\
|
||
|
|
Limit list based on topic (public) state.
|
||
|
|
|
||
|
|
State is the \"public condition\". I.e., is the topic still open?"
|
||
|
|
:initarg :state
|
||
|
|
:initform 'open
|
||
|
|
:type (satisfies
|
||
|
|
(lambda (val)
|
||
|
|
(member val '(open
|
||
|
|
closed
|
||
|
|
(completed merged)
|
||
|
|
completed
|
||
|
|
merged
|
||
|
|
(unplanned duplicate outdated rejected)
|
||
|
|
unplanned
|
||
|
|
duplicate
|
||
|
|
outdated
|
||
|
|
rejected
|
||
|
|
nil))))
|
||
|
|
:custom (choice
|
||
|
|
(const open)
|
||
|
|
(const closed)
|
||
|
|
(const (completed merged))
|
||
|
|
(const completed)
|
||
|
|
(const merged)
|
||
|
|
(const (unplanned duplicate outdated rejected))
|
||
|
|
(const unplanned)
|
||
|
|
(const duplicate)
|
||
|
|
(const outdated)
|
||
|
|
(const rejected)
|
||
|
|
(const :tag "all (nil)" nil)))
|
||
|
|
(status :documentation "\
|
||
|
|
Limit list based on topic (private) status.
|
||
|
|
|
||
|
|
Status is the \"private condition\". I.e., have you decided yet
|
||
|
|
that *you* are done with the topic, and have others made changes,
|
||
|
|
which *you* have not seen yet?
|
||
|
|
|
||
|
|
`inbox' means \"`unread' or `pending'\"."
|
||
|
|
:initarg :status
|
||
|
|
:initform nil
|
||
|
|
:type (member inbox unread pending done nil)
|
||
|
|
:custom (choice
|
||
|
|
(const inbox)
|
||
|
|
(const unread)
|
||
|
|
(const pending)
|
||
|
|
(const done)
|
||
|
|
(const :tag "all (nil)" nil)))
|
||
|
|
(updated :documentation "\
|
||
|
|
Date when topic was last updated."
|
||
|
|
:initarg :updated
|
||
|
|
:initform nil
|
||
|
|
:type (or string null))
|
||
|
|
(category :documentation "\
|
||
|
|
Limit list to discussions of given category.
|
||
|
|
Issues and pull-requests are unaffected."
|
||
|
|
:initarg :category
|
||
|
|
:initform nil
|
||
|
|
:type (or string null)
|
||
|
|
:custom (choice
|
||
|
|
(string :tag "name")
|
||
|
|
(const :tag "all (nil)" nil)))
|
||
|
|
(milestone :documentation "\
|
||
|
|
Limit list to issues and pull-requests assigned to given milestone.
|
||
|
|
Discussions are unaffected."
|
||
|
|
:initarg :milestone
|
||
|
|
:initform nil
|
||
|
|
:type (or string null)
|
||
|
|
:custom (choice
|
||
|
|
(string :tag "name")
|
||
|
|
(const :tag "all (nil)" nil)))
|
||
|
|
(labels :documentation "\
|
||
|
|
Limit list to topics with at least one of the given labels."
|
||
|
|
:initarg :labels
|
||
|
|
:initform nil
|
||
|
|
:type (list-of string)
|
||
|
|
:custom (repeat string))
|
||
|
|
(marks :documentation "\
|
||
|
|
Limit list to topics with at least one of the given marks.
|
||
|
|
Marks are like labels, but they are private and local to the
|
||
|
|
current Forge database."
|
||
|
|
:initarg :marks
|
||
|
|
:initform nil
|
||
|
|
:type (list-of string)
|
||
|
|
:custom (repeat string))
|
||
|
|
(saved :documentation "Limit list to saved topics."
|
||
|
|
:initarg :saved
|
||
|
|
:initform nil
|
||
|
|
:type boolean
|
||
|
|
:custom boolean)
|
||
|
|
(author :documentation "\
|
||
|
|
Limit list to topics created by given user."
|
||
|
|
:initarg :author
|
||
|
|
:initform nil
|
||
|
|
:label "Author"
|
||
|
|
:type (or string null)
|
||
|
|
:custom (choice
|
||
|
|
(string :tag "username")
|
||
|
|
(const :tag "no filter (nil)" nil)))
|
||
|
|
(assignee :documentation "\
|
||
|
|
Limit list to topics assigned to given user."
|
||
|
|
:initarg :assignee
|
||
|
|
:initform nil
|
||
|
|
:label "Assignee"
|
||
|
|
:type (or string null)
|
||
|
|
:custom (choice
|
||
|
|
(string :tag "username")
|
||
|
|
(const :tag "no filter (nil)" nil)))
|
||
|
|
(reviewer :documentation "\
|
||
|
|
Limit list to topics for which a review by the given user was requested."
|
||
|
|
:initarg :reviewer
|
||
|
|
:initform nil
|
||
|
|
:label "Reviewer"
|
||
|
|
:type (or string null)
|
||
|
|
:custom (choice
|
||
|
|
(string :tag "username")
|
||
|
|
(const :tag "no filter (nil)" nil)))
|
||
|
|
(global :documentation "Whether to list topics for all repositories."
|
||
|
|
:initarg :global
|
||
|
|
:initform nil
|
||
|
|
:type boolean)
|
||
|
|
(order :documentation "Order in which topics are listed."
|
||
|
|
:initarg :order
|
||
|
|
:initform 'newest
|
||
|
|
:type (member newest oldest recently-updated anciently-updated)
|
||
|
|
:custom (choice (const newest)
|
||
|
|
(const oldest)
|
||
|
|
(const recently-updated)
|
||
|
|
(const anciently-updated)))
|
||
|
|
(limit :documentation "Number of topics to list at most."
|
||
|
|
:initarg :limit
|
||
|
|
:initform 200
|
||
|
|
:type (or integer null)
|
||
|
|
:custom (choice natnum (const :tag "no limit" nil)))
|
||
|
|
(grouped :documentation "Whether to group topics by repository."
|
||
|
|
:initarg :grouped
|
||
|
|
:initform nil
|
||
|
|
:type boolean
|
||
|
|
:custom boolean)))
|
||
|
|
|
||
|
|
(defun forge--cast-topics-spec-state (spec)
|
||
|
|
(when-let ((cast (pcase (list (oref spec type) (oref spec state))
|
||
|
|
(`(topic ,(or 'unplanned 'duplicate 'rejected))
|
||
|
|
'(unplanned duplicate rejected))
|
||
|
|
('(issue rejected)
|
||
|
|
'(unplanned duplicate rejected))
|
||
|
|
(`(pullreq ,(or 'unplanned 'duplicate))
|
||
|
|
'(unplanned duplicate rejected))
|
||
|
|
(`(topic ,(or 'completed 'merged))
|
||
|
|
'(completed merged))
|
||
|
|
('(issue merged)
|
||
|
|
'(completed merged))
|
||
|
|
('(pullreq completed)
|
||
|
|
'(completed merged)))))
|
||
|
|
(oset spec state cast)))
|
||
|
|
|
||
|
|
(cl-defun forge--list-topics
|
||
|
|
(&optional (spec forge--buffer-topics-spec)
|
||
|
|
(repo (forge-get-repository :tracked?))
|
||
|
|
(type (oref spec type)))
|
||
|
|
(when (oref spec reviewer)
|
||
|
|
(setq type 'pullreq))
|
||
|
|
(if (eq type 'topic)
|
||
|
|
(pcase-let ((`(,pred ,slot) (pcase (oref spec order)
|
||
|
|
('newest '(> number))
|
||
|
|
('oldest '(< number))
|
||
|
|
('recently-updated '(string> updated))
|
||
|
|
('anciently-updated '(string< updated)))))
|
||
|
|
(compat-call
|
||
|
|
sort (nconc (forge--list-topics-1 spec repo 'discussion)
|
||
|
|
(forge--list-topics-1 spec repo 'issue)
|
||
|
|
(forge--list-topics-1 spec repo 'pullreq))
|
||
|
|
:lessp pred :key (##eieio-oref % slot)))
|
||
|
|
(forge--list-topics-1 spec repo type)))
|
||
|
|
|
||
|
|
(defun forge--list-topics-1 (spec repo type)
|
||
|
|
(mapcar (partial #'closql--remake-instance
|
||
|
|
(pcase type
|
||
|
|
('discussion 'forge-discussion)
|
||
|
|
('issue 'forge-issue)
|
||
|
|
('pullreq 'forge-pullreq))
|
||
|
|
(forge-db))
|
||
|
|
(forge-sql (forge--list-topics-2 spec repo type))))
|
||
|
|
|
||
|
|
(defun forge--list-topics-2 (spec repo type)
|
||
|
|
(pcase-let (((eieio active state status category milestone labels marks
|
||
|
|
saved author assignee reviewer global order limit)
|
||
|
|
spec))
|
||
|
|
(cond (active
|
||
|
|
(setq state 'open)
|
||
|
|
(setq status '(unread pending)))
|
||
|
|
((eq status 'inbox)
|
||
|
|
(setq status '(unread pending))))
|
||
|
|
(when (eq state 'closed)
|
||
|
|
(setq state '( completed merged unplanned duplicate outdated rejected)))
|
||
|
|
`[:select :distinct topic:*
|
||
|
|
:from [(as ,type topic)]
|
||
|
|
,@(pcase type
|
||
|
|
((and 'discussion (guard category))
|
||
|
|
`[:join discussion-category :on (= discussion-category:name ,category)])
|
||
|
|
((and (or 'issue 'pullreq) (guard milestone))
|
||
|
|
`[:join milestone :on (= milestone:title ,milestone)]))
|
||
|
|
,@(pcase (and labels type)
|
||
|
|
('discussion
|
||
|
|
[:join discussion-label :on (= discussion-label:discussion topic:id)
|
||
|
|
:join label :on (= label:id discussion-label:id)])
|
||
|
|
('issue
|
||
|
|
[:join issue-label :on (= issue-label:issue topic:id)
|
||
|
|
:join label :on (= label:id issue-label:id)])
|
||
|
|
('pullreq
|
||
|
|
[:join pullreq-label :on (= pullreq-label:pullreq topic:id)
|
||
|
|
:join label :on (= label:id pullreq-label:id)]))
|
||
|
|
,@(pcase (and marks type)
|
||
|
|
('discussion
|
||
|
|
[:join discussion-mark :on (= discussion-mark:discussion topic:id)
|
||
|
|
:join mark :on (= mark:id discussion-mark:id)])
|
||
|
|
('issue
|
||
|
|
[:join issue-mark :on (= issue-mark:issue topic:id)
|
||
|
|
:join mark :on (= mark:id issue-mark:id)])
|
||
|
|
('pullreq
|
||
|
|
[:join pullreq-mark :on (= pullreq-mark:pullreq topic:id)
|
||
|
|
:join mark :on (= mark:id pullreq-mark:id)]))
|
||
|
|
,@(pcase (and assignee type)
|
||
|
|
('issue
|
||
|
|
[:join issue-assignee :on (= issue-assignee:issue topic:id)
|
||
|
|
:join assignee :on (= assignee:id issue-assignee:id)])
|
||
|
|
('pullreq
|
||
|
|
[:join pullreq-assignee :on (= pullreq-assignee:pullreq topic:id)
|
||
|
|
:join assignee :on (= assignee:id pullreq-assignee:id)]))
|
||
|
|
,@(and reviewer
|
||
|
|
[:join (as pullreq-review-request r) :on (= r:pullreq topic:id)
|
||
|
|
:join assignee :on (= assignee:id r:id)])
|
||
|
|
:where
|
||
|
|
(and
|
||
|
|
,@(and (not global) repo `((= topic:repository ,(oref repo id))))
|
||
|
|
,@(cond
|
||
|
|
((and active state status)
|
||
|
|
`((or (in topic:state ,(vconcat (ensure-list state)))
|
||
|
|
(in topic:status ,(vconcat (ensure-list status))))))
|
||
|
|
(`(,@(and state `((in topic:state ,(vconcat (ensure-list state)))))
|
||
|
|
,@(and status `((in topic:status ,(vconcat (ensure-list status))))))))
|
||
|
|
,@(pcase type
|
||
|
|
((and 'discussion (guard category))
|
||
|
|
'((= topic:category discussion-category:id)))
|
||
|
|
((and (or 'issue 'pullreq) (guard milestone))
|
||
|
|
'((= topic:milestone milestone:id))))
|
||
|
|
,@(and labels `((or ,@(mapcar (##`(= label:name ,%)) labels))))
|
||
|
|
,@(and marks `((or ,@(mapcar (##`(= mark:name ,%)) marks))))
|
||
|
|
,@(and saved '((= topic:saved-p 't)))
|
||
|
|
,@(and author `((= topic:author ,author)))
|
||
|
|
,@(and assignee (memq type '(issue pullreq))
|
||
|
|
`((= assignee:login ,assignee)))
|
||
|
|
,@(and reviewer (eq type 'pullreq)
|
||
|
|
`((= assignee:login ,reviewer))))
|
||
|
|
:order-by [,(pcase order
|
||
|
|
('newest '(desc topic:number))
|
||
|
|
('oldest '(asc topic:number))
|
||
|
|
('recently-updated '(desc topic:updated))
|
||
|
|
('anciently-updated '(asc topic:updated)))]
|
||
|
|
,@(and limit `(:limit ,limit))]))
|
||
|
|
|
||
|
|
;;; Read
|
||
|
|
|
||
|
|
(defun forge-read-topic (prompt)
|
||
|
|
"Read an active topic with completion using PROMPT.
|
||
|
|
|
||
|
|
Open, unread and pending topics are considered active.
|
||
|
|
Default to the current topic, even if it isn't active.
|
||
|
|
|
||
|
|
\\<forge-read-topic-minibuffer-map>While completion is in \
|
||
|
|
progress, \\[forge-read-topic-lift-limit] lifts the limit, extending
|
||
|
|
the completion candidates to include all topics.
|
||
|
|
|
||
|
|
If `forge-limit-topic-choices' is nil, then all candidates
|
||
|
|
can be selected from the start."
|
||
|
|
(forge--read-topic prompt
|
||
|
|
#'forge-current-topic
|
||
|
|
(forge--topics-spec :type 'topic :active t)
|
||
|
|
(forge--topics-spec :type 'topic :active nil
|
||
|
|
:state nil :limit nil)))
|
||
|
|
|
||
|
|
(defun forge--read-topic (prompt current active all)
|
||
|
|
(let* ((current (funcall current))
|
||
|
|
(repo (forge-get-repository (or current :tracked)))
|
||
|
|
(default (and current (forge--format-topic-line current)))
|
||
|
|
(alist (forge--topic-collection
|
||
|
|
(forge--list-topics (if forge-limit-topic-choices active all)
|
||
|
|
repo)))
|
||
|
|
(choices (mapcar #'car alist))
|
||
|
|
(choices (cond ((and forge-limit-topic-choices
|
||
|
|
default
|
||
|
|
(not (member default choices)))
|
||
|
|
(push (cons default (oref current id)) alist)
|
||
|
|
(cons default choices))
|
||
|
|
(choices)))
|
||
|
|
(choice
|
||
|
|
(if forge-limit-topic-choices
|
||
|
|
(minibuffer-with-setup-hook
|
||
|
|
(lambda ()
|
||
|
|
(use-local-map (make-composed-keymap
|
||
|
|
forge-read-topic-minibuffer-map
|
||
|
|
(current-local-map))))
|
||
|
|
(magit-completing-read
|
||
|
|
(substitute-command-keys
|
||
|
|
(format "%s (\\<%s>\\[%s] for all)" prompt
|
||
|
|
'forge-read-topic-minibuffer-map
|
||
|
|
'forge-read-topic-lift-limit))
|
||
|
|
(completion-table-dynamic
|
||
|
|
(let (all-choices)
|
||
|
|
(lambda (_string)
|
||
|
|
;; For other frameworks, the minibuffer is current
|
||
|
|
;; when this function is called, but for Helm we
|
||
|
|
;; have to make it so. Starting with Helm commit
|
||
|
|
;; 5e7c8498, this isn't necessary anymore. See
|
||
|
|
;; https://github.com/emacs-helm/helm/issues/2744.
|
||
|
|
(with-selected-window (minibuffer-window)
|
||
|
|
(cond
|
||
|
|
(all-choices)
|
||
|
|
(forge-limit-topic-choices choices)
|
||
|
|
(t
|
||
|
|
(setq alist (forge--topic-collection
|
||
|
|
(forge--list-topics all repo)))
|
||
|
|
(setq all-choices (mapcar #'car alist))))))))
|
||
|
|
nil t nil nil default))
|
||
|
|
(magit-completing-read prompt choices nil t nil nil default))))
|
||
|
|
(cdr (assoc choice alist))))
|
||
|
|
|
||
|
|
(defun forge--topic-collection (topics)
|
||
|
|
(mapcar (##cons (forge--format-topic-line %)
|
||
|
|
(oref % id))
|
||
|
|
topics))
|
||
|
|
|
||
|
|
(defvar-keymap forge-read-topic-minibuffer-map
|
||
|
|
"+" #'forge-read-topic-lift-limit)
|
||
|
|
|
||
|
|
(defun forge-read-topic-lift-limit ()
|
||
|
|
"No longer limit completion candidates to active topics."
|
||
|
|
(interactive)
|
||
|
|
(when (and (minibufferp)
|
||
|
|
forge-limit-topic-choices)
|
||
|
|
(setq-local forge-limit-topic-choices nil)
|
||
|
|
(cond
|
||
|
|
((and (bound-and-true-p vertico-mode)
|
||
|
|
(boundp 'vertico--input)
|
||
|
|
(fboundp 'vertico--exhibit))
|
||
|
|
(setq vertico--input t)
|
||
|
|
(vertico--exhibit))
|
||
|
|
((and (bound-and-true-p helm-mode)
|
||
|
|
(fboundp 'helm-force-update))
|
||
|
|
(helm-force-update))
|
||
|
|
((minibuffer-completion-help (minibuffer--completion-prompt-end)
|
||
|
|
(point-max))))
|
||
|
|
(forge-read-topic--remove-prompt-hint)))
|
||
|
|
|
||
|
|
(defun forge-read-topic--remove-prompt-hint ()
|
||
|
|
(when (minibufferp)
|
||
|
|
(save-excursion
|
||
|
|
(goto-char (point-min))
|
||
|
|
(when (re-search-forward " (.+? for all)" (minibuffer-prompt-end) t)
|
||
|
|
(let ((inhibit-read-only t))
|
||
|
|
(replace-match ""))))))
|
||
|
|
|
||
|
|
(defun forge-topic-completion-at-point ()
|
||
|
|
(let ((bol (line-beginning-position))
|
||
|
|
repo)
|
||
|
|
(and (looking-back "[!#][0-9]*" bol)
|
||
|
|
(or (not bug-reference-prog-mode)
|
||
|
|
(nth 8 (syntax-ppss))) ; inside comment or string
|
||
|
|
(setq repo (forge-get-repository :tracked))
|
||
|
|
(looking-back (if (forge--childp repo 'forge-gitlab-repository)
|
||
|
|
"\\(?3:[!#]\\)\\(?2:[0-9]*\\)"
|
||
|
|
"#\\(?2:[0-9]*\\)")
|
||
|
|
bol)
|
||
|
|
(list (match-beginning 2)
|
||
|
|
(match-end 0)
|
||
|
|
(mapcar (lambda (row)
|
||
|
|
(propertize (number-to-string (car row))
|
||
|
|
:title (format " %s" (cadr row))))
|
||
|
|
(if (forge--childp repo 'forge-gitlab-repository)
|
||
|
|
(forge-sql [:select [number title]
|
||
|
|
:from $i1
|
||
|
|
:where (= repository $s2)
|
||
|
|
:order-by [(desc updated)]]
|
||
|
|
(if (equal (match-string 3) "#")
|
||
|
|
'issue
|
||
|
|
'pullreq)
|
||
|
|
(oref repo id))
|
||
|
|
(forge-sql [:select [number title updated]
|
||
|
|
:from discussion
|
||
|
|
:where (= repository $s1)
|
||
|
|
:union
|
||
|
|
:select [number title updated]
|
||
|
|
:from issue
|
||
|
|
:where (= repository $s1)
|
||
|
|
:union
|
||
|
|
:select [number title updated]
|
||
|
|
:from pullreq
|
||
|
|
:where (= repository $s1)
|
||
|
|
:order-by [(desc updated)]]
|
||
|
|
(oref repo id))))
|
||
|
|
:annotation-function (##get-text-property 0 :title %)))))
|
||
|
|
|
||
|
|
(defun forge-read-topic-title (topic)
|
||
|
|
(read-string "Title: " (oref topic title)))
|
||
|
|
|
||
|
|
(defun forge-read-topic-milestone (&optional topic)
|
||
|
|
(magit-completing-read
|
||
|
|
"Milestone"
|
||
|
|
(cons ""
|
||
|
|
(mapcar #'caddr
|
||
|
|
(oref (forge-get-repository (or topic :tracked)) milestones)))
|
||
|
|
nil t
|
||
|
|
(and topic (forge--format-topic-milestone topic))))
|
||
|
|
|
||
|
|
(defun forge-read-topic-labels (&optional obj)
|
||
|
|
(let ((crm-separator ","))
|
||
|
|
(magit-completing-read-multiple
|
||
|
|
"Labels: "
|
||
|
|
(forge--format-labels (forge-get-repository (or obj :tracked)))
|
||
|
|
nil t
|
||
|
|
(and (cl-typep obj 'forge-topic)
|
||
|
|
(forge--format-labels obj crm-separator)))))
|
||
|
|
|
||
|
|
(defun forge-read-topic-marks (&optional obj)
|
||
|
|
(let ((crm-separator ","))
|
||
|
|
(magit-completing-read-multiple
|
||
|
|
"Marks: " (forge--format-marks) nil t
|
||
|
|
(and (cl-typep obj 'forge-topic)
|
||
|
|
(forge--format-marks obj crm-separator)))))
|
||
|
|
|
||
|
|
(defun forge-read-topic-assignees (&optional topic)
|
||
|
|
(let* ((repo (forge-get-repository (or topic :tracked)))
|
||
|
|
(value (and topic (oref topic assignees)))
|
||
|
|
(choices (mapcar #'cadr (oref repo assignees)))
|
||
|
|
(crm-separator ","))
|
||
|
|
(magit-completing-read-multiple
|
||
|
|
"Assignees: " choices nil
|
||
|
|
(if (forge--childp repo 'forge-gitlab-repository)
|
||
|
|
t ; Selecting something else would fail later on.
|
||
|
|
'confirm)
|
||
|
|
(mapconcat #'cadr value ","))))
|
||
|
|
|
||
|
|
(defun forge-read-topic-review-requests (&optional topic)
|
||
|
|
(let* ((repo (forge-get-repository (or topic :tracked)))
|
||
|
|
(value (and topic (oref topic review-requests)))
|
||
|
|
(choices (nconc (mapcar #'cadr (oref repo assignees))
|
||
|
|
(oref repo teams)))
|
||
|
|
(crm-separator ","))
|
||
|
|
(magit-completing-read-multiple
|
||
|
|
"Request review from: " choices nil
|
||
|
|
'confirm
|
||
|
|
(mapconcat #'cadr value ","))))
|
||
|
|
|
||
|
|
;;; Format
|
||
|
|
|
||
|
|
(cl-defmethod forge--format ((topic forge-topic) slot &optional spec)
|
||
|
|
(forge--format (forge-get-repository topic) slot
|
||
|
|
`(,@spec (?i . ,(oref topic number)))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-line (topic &optional width)
|
||
|
|
(concat
|
||
|
|
(and (or (and (derived-mode-p 'forge-notifications-mode)
|
||
|
|
(eq forge-notifications-display-style 'flat))
|
||
|
|
(and (derived-mode-p 'forge-topics-mode)
|
||
|
|
(oref forge--buffer-topics-spec global)
|
||
|
|
(not (oref forge--buffer-topics-spec grouped))))
|
||
|
|
(concat (truncate-string-to-width
|
||
|
|
(oref (forge-get-repository topic) slug)
|
||
|
|
forge-topic-repository-slug-width
|
||
|
|
nil ?\s t)
|
||
|
|
" "))
|
||
|
|
(string-pad (forge--format-topic-slug topic) (or width 5))
|
||
|
|
" "
|
||
|
|
(forge--format-topic-title topic)))
|
||
|
|
|
||
|
|
(defun forge--format-topic-slug (topic)
|
||
|
|
(with-slots (slug state status saved-p) topic
|
||
|
|
(magit--propertize-face
|
||
|
|
slug
|
||
|
|
`(,@(and saved-p '(forge-topic-slug-saved))
|
||
|
|
,@(and (eq status 'unread) '(forge-topic-slug-unread))
|
||
|
|
,(pcase state
|
||
|
|
('open 'forge-topic-slug-open)
|
||
|
|
((or 'completed 'merged) 'forge-topic-slug-completed)
|
||
|
|
((or 'unplanned 'outdated 'duplicate 'rejected)
|
||
|
|
'forge-topic-slug-expunged))))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-refs (topic)
|
||
|
|
(pcase-let
|
||
|
|
(((eieio cross-repo-p base-repo base-ref head-repo head-ref) topic)
|
||
|
|
(separator (magit--propertize-face ":" 'magit-dimmed))
|
||
|
|
(deleted (magit--propertize-face "(deleted)" 'magit-dimmed)))
|
||
|
|
(concat (if cross-repo-p
|
||
|
|
(concat base-repo separator base-ref)
|
||
|
|
base-ref)
|
||
|
|
(magit--propertize-face "..." 'magit-dimmed)
|
||
|
|
(if cross-repo-p
|
||
|
|
(if (and head-repo head-ref)
|
||
|
|
(concat head-repo separator head-ref)
|
||
|
|
deleted)
|
||
|
|
(or head-ref deleted)))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-draft (topic)
|
||
|
|
(if (oref topic draft-p)
|
||
|
|
(magit--propertize-face "yes" 'bold)
|
||
|
|
(magit--propertize-face "no" 'magit-dimmed)))
|
||
|
|
|
||
|
|
(defun forge--format-topic-saved (topic)
|
||
|
|
(if (oref topic saved-p)
|
||
|
|
(magit--propertize-face "yes" 'bold)
|
||
|
|
(magit--propertize-face "no" 'magit-dimmed)))
|
||
|
|
|
||
|
|
(defun forge--format-topic-title (topic)
|
||
|
|
(with-temp-buffer
|
||
|
|
(save-excursion
|
||
|
|
(with-slots (title status state) topic
|
||
|
|
(insert
|
||
|
|
(magit--propertize-face
|
||
|
|
title
|
||
|
|
`(,@(and (forge-pullreq-p topic)
|
||
|
|
(oref topic draft-p)
|
||
|
|
'(forge-pullreq-draft))
|
||
|
|
,(pcase status
|
||
|
|
('unread 'forge-topic-unread)
|
||
|
|
('pending 'forge-topic-pending)
|
||
|
|
('done 'forge-topic-done))
|
||
|
|
,(pcase (list (eieio-object-class topic) state)
|
||
|
|
(`(forge-discussion open) 'forge-discussion-open)
|
||
|
|
(`(forge-discussion completed) 'forge-discussion-completed)
|
||
|
|
(`(forge-discussion outdated) 'forge-discussion-expunged)
|
||
|
|
(`(forge-discussion duplicate) 'forge-discussion-expunged)
|
||
|
|
(`(forge-issue open) 'forge-issue-open)
|
||
|
|
(`(forge-issue completed) 'forge-issue-completed)
|
||
|
|
(`(forge-issue unplanned) 'forge-issue-expunged)
|
||
|
|
(`(forge-issue duplicate) 'forge-issue-expunged)
|
||
|
|
(`(forge-pullreq open) 'forge-pullreq-open)
|
||
|
|
(`(forge-pullreq merged) 'forge-pullreq-merged)
|
||
|
|
(`(forge-pullreq rejected) 'forge-pullreq-rejected)))))))
|
||
|
|
(run-hook-wrapped 'forge-topic-wash-title-hook
|
||
|
|
(##prog1 nil (save-excursion (funcall %))))
|
||
|
|
(buffer-string)))
|
||
|
|
|
||
|
|
(defun forge--format-topic-category (topic)
|
||
|
|
(and-let* ((id (oref topic category))
|
||
|
|
(str (forge-sql1 [:select [name]
|
||
|
|
:from discussion-category
|
||
|
|
:where (= id $s1)]
|
||
|
|
id)))
|
||
|
|
(magit--propertize-face str 'forge-topic-label)))
|
||
|
|
|
||
|
|
(defun forge--format-topic-milestone (topic)
|
||
|
|
(and-let* ((id (oref topic milestone))
|
||
|
|
(str (forge-sql1 [:select [title]
|
||
|
|
:from milestone
|
||
|
|
:where (= id $s1)]
|
||
|
|
id)))
|
||
|
|
(magit--propertize-face str 'forge-topic-label)))
|
||
|
|
|
||
|
|
(defun forge--format-labels (&optional arg concat)
|
||
|
|
(and-let*
|
||
|
|
((local t)
|
||
|
|
(labels (cond
|
||
|
|
((eieio-object-p arg)
|
||
|
|
(oref arg labels))
|
||
|
|
((forge-buffer-repository)
|
||
|
|
(forge-sql-cdr `[:select label:* :from label :where
|
||
|
|
,(if arg
|
||
|
|
'(and (= repository $s1)
|
||
|
|
(in name $v2))
|
||
|
|
'(= repository $s1))
|
||
|
|
:order-by [(asc name)]]
|
||
|
|
forge-buffer-repository
|
||
|
|
(vconcat arg)))
|
||
|
|
(t
|
||
|
|
(setq local nil)
|
||
|
|
(forge-sql `[:select :distinct name :from label
|
||
|
|
,@(and arg '(:where (in name $v1)))
|
||
|
|
:order-by [(asc name)]]
|
||
|
|
(vconcat arg)))))
|
||
|
|
(format (if local
|
||
|
|
(pcase-lambda (`(,_id ,name ,color ,_description))
|
||
|
|
(let* ((background (forge--sanitize-color color))
|
||
|
|
(foreground (readable-foreground-color background)))
|
||
|
|
(magit--propertize-face
|
||
|
|
name `(( :background ,background
|
||
|
|
:foreground ,foreground)
|
||
|
|
forge-topic-label))))
|
||
|
|
(pcase-lambda (`(,name))
|
||
|
|
(magit--propertize-face name 'forge-topic-label)))))
|
||
|
|
(if concat
|
||
|
|
(mapconcat format labels (if (stringp concat) concat " "))
|
||
|
|
(mapcar format labels))))
|
||
|
|
|
||
|
|
(defun forge--format-marks (&optional arg concat)
|
||
|
|
(and-let ((marks (if (forge-topic--eieio-childp arg)
|
||
|
|
(oref arg marks)
|
||
|
|
;; Unlike labels, marks are not repo-specific.
|
||
|
|
(when (forge-repository-p arg) (setq arg nil))
|
||
|
|
(forge-sql-cdr `[:select * :from mark
|
||
|
|
,@(and arg '(:where (in name $v1)))
|
||
|
|
:order-by [(asc name)]]
|
||
|
|
(vconcat arg))))
|
||
|
|
(format (pcase-lambda (`(,_id ,name ,face ,_description))
|
||
|
|
(magit--propertize-face
|
||
|
|
name (list face 'forge-topic-label)))))
|
||
|
|
(if concat
|
||
|
|
(mapconcat format marks (if (stringp concat) concat " "))
|
||
|
|
(mapcar format marks))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-state (topic)
|
||
|
|
(with-slots (state) topic
|
||
|
|
(magit--propertize-face
|
||
|
|
(symbol-name state)
|
||
|
|
(pcase (list (if (forge-issue-p topic) 'issue 'pullreq) state)
|
||
|
|
('(discussion open) 'forge-discussion-open)
|
||
|
|
('(discussion completed) 'forge-discussion-completed)
|
||
|
|
('(discussion outdated) 'forge-discussion-expunged)
|
||
|
|
('(discussion duplicate) 'forge-discussion-expunged)
|
||
|
|
('(issue open) 'forge-issue-open)
|
||
|
|
('(issue completed) 'forge-issue-completed)
|
||
|
|
('(issue unplanned) 'forge-issue-expunged)
|
||
|
|
('(issue duplicate) 'forge-issue-expunged)
|
||
|
|
('(pullreq open) 'forge-pullreq-open)
|
||
|
|
('(pullreq merged) 'forge-pullreq-merged)
|
||
|
|
('(pullreq closed) 'forge-pullreq-rejected)))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-status (topic)
|
||
|
|
(with-slots (status) topic
|
||
|
|
(magit--propertize-face
|
||
|
|
(symbol-name status)
|
||
|
|
(pcase status
|
||
|
|
('unread 'forge-topic-unread)
|
||
|
|
('pending 'forge-topic-pending)
|
||
|
|
('done 'forge-topic-done)))))
|
||
|
|
|
||
|
|
(defun forge--format-topic-assignees (arg)
|
||
|
|
(and-let ((assignees
|
||
|
|
(cond ((eieio-object-p arg)
|
||
|
|
(oref arg assignees))
|
||
|
|
((forge-buffer-repository)
|
||
|
|
(forge-sql-cdr [:select * :from assignee
|
||
|
|
:where
|
||
|
|
(and (= repository $s1)
|
||
|
|
(in login $v2))
|
||
|
|
:order-by [(asc login)]]
|
||
|
|
forge-buffer-repository
|
||
|
|
(vconcat arg))))))
|
||
|
|
(mapconcat #'forge--format-person assignees ", ")))
|
||
|
|
|
||
|
|
(defun forge--format-topic-review-requests (topic)
|
||
|
|
(and$ (oref topic review-requests)
|
||
|
|
(mapconcat #'forge--format-person $ ", ")))
|
||
|
|
|
||
|
|
(defun forge--format-person (person)
|
||
|
|
(pcase-let* ((`(,_id ,login ,name) person)
|
||
|
|
(avatar (forge--format-avatar login)))
|
||
|
|
(propertize (if name
|
||
|
|
(format "%s%s (@%s)" avatar name login)
|
||
|
|
(format "%s@%s" avatar login))
|
||
|
|
'face 'transient-value)))
|
||
|
|
|
||
|
|
(defun forge--format-avatar (person)
|
||
|
|
(if forge-format-avatar-function
|
||
|
|
(funcall forge-format-avatar-function person)
|
||
|
|
""))
|
||
|
|
|
||
|
|
(defun forge--format-boolean (slot name &optional obj)
|
||
|
|
;; Booleans are formatted differently in transients and headers.
|
||
|
|
;; Use this to format the (complete) description of suffix commands.
|
||
|
|
(let ((obj (or obj (forge-current-topic))))
|
||
|
|
(if (and obj (slot-exists-p obj slot))
|
||
|
|
(format (propertize "[%s]" 'face 'transient-delimiter)
|
||
|
|
(propertize name 'face
|
||
|
|
(if (eieio-oref obj slot)
|
||
|
|
'transient-value
|
||
|
|
'transient-inactive-value)))
|
||
|
|
(format "[%s]" name))))
|
||
|
|
|
||
|
|
;;; Insert
|
||
|
|
|
||
|
|
(defun forge-insert-topics (type heading prepare)
|
||
|
|
"Insert a list of topics, according to PREPARE.
|
||
|
|
|
||
|
|
This function is not intended to be added to section hooks directly.
|
||
|
|
Instead create a function, which calls this function, and add that
|
||
|
|
wrapper to the mode's section hook.
|
||
|
|
|
||
|
|
PREPARE is a function which takes one arguments the repository object,
|
||
|
|
and must return a filter object of type `forge--topics-spec' or nil.
|
||
|
|
Insert no topics if PREPARE returns nil, or if the current repository
|
||
|
|
isn't tracked or Forge hasn't been fully setup yet (in the latter two
|
||
|
|
cases don't even call PREPARE).
|
||
|
|
|
||
|
|
The filter object can be created either using `forge--topics-spec' or
|
||
|
|
by `clone'ing the object returned by `forge--init-buffer-topics-spec',
|
||
|
|
to share some settings with other topic lists in the same buffer.
|
||
|
|
See `forge--topics-spec' for the valid slots and their values.
|
||
|
|
|
||
|
|
HEADING is used as the heading of the list section and TYPE is used as
|
||
|
|
its type. TYPE should be a symbol of the form `SUBSET-KIND', where KIND
|
||
|
|
is one of `topics', `issues' or `pullreqs', and SUBSET should describe
|
||
|
|
what subset of KIND is being listed.
|
||
|
|
|
||
|
|
For example, to insert a list of issues assigned to you use something
|
||
|
|
like:
|
||
|
|
|
||
|
|
(defun my-forge-insert-assigned-issues ()
|
||
|
|
\"Insert a list of issues that are assigned to me.\"
|
||
|
|
(forge-insert-topics \\='assigned-issues \"Assigned issues\"
|
||
|
|
(lambda (repo)
|
||
|
|
(and-let* ((me (ghub--username repo)))
|
||
|
|
(forge--topics-spec :type \\='issue :active t
|
||
|
|
:assignee me)))))
|
||
|
|
|
||
|
|
(magit-add-section-hook \\='magit-status-sections-hook
|
||
|
|
#\\='my-forge-insert-assigned-issues
|
||
|
|
#\\='forge-insert-issues)
|
||
|
|
|
||
|
|
Grep Forge for more examples.
|
||
|
|
|
||
|
|
Alternatively you can use `forge-topics-setup-buffer' to list a set
|
||
|
|
of topics in a dedicated buffer."
|
||
|
|
(declare (indent defun))
|
||
|
|
(when-let* ((_(forge-db t))
|
||
|
|
(repo (forge-get-repository :tracked?))
|
||
|
|
(spec (funcall prepare repo)))
|
||
|
|
(forge--insert-topics type heading (forge--list-topics spec repo))))
|
||
|
|
|
||
|
|
(defun forge--insert-topics (type heading topics)
|
||
|
|
(when topics
|
||
|
|
(let ((width (apply #'max (mapcar (##length (oref % slug)) topics))))
|
||
|
|
(magit-insert-section ((eval type) heading t)
|
||
|
|
(magit-insert-heading
|
||
|
|
(concat (magit--propertize-face (concat heading " ")
|
||
|
|
'magit-section-heading)
|
||
|
|
(magit--propertize-face (format "(%s)" (length topics))
|
||
|
|
'magit-section-child-count)))
|
||
|
|
(magit-insert-section-body
|
||
|
|
(dolist (topic topics)
|
||
|
|
(forge--insert-topic topic width))
|
||
|
|
(insert ?\n)
|
||
|
|
(magit-make-margin-overlay nil t))))))
|
||
|
|
|
||
|
|
(defun forge--insert-topic (topic &optional width)
|
||
|
|
(magit-insert-section ((eval (oref topic closql-table)) topic t)
|
||
|
|
(insert (forge--format-topic-line topic (or width 5)))
|
||
|
|
(forge--insert-topic-marks topic t)
|
||
|
|
(forge--insert-topic-labels topic t)
|
||
|
|
(insert "\n")
|
||
|
|
(magit-log-format-author-margin
|
||
|
|
(oref topic author)
|
||
|
|
(format-time-string "%s" (parse-iso8601-time-string (oref topic created))))
|
||
|
|
(when (and (slot-exists-p topic 'merged)
|
||
|
|
(not (oref topic merged)))
|
||
|
|
(magit-insert-heading)
|
||
|
|
(magit-insert-section-body
|
||
|
|
(forge--insert-pullreq-commits topic)))))
|
||
|
|
|
||
|
|
(defun forge--insert-topic-labels (topic &optional separate)
|
||
|
|
(and-let ((labels (oref topic labels)))
|
||
|
|
(prog1 t
|
||
|
|
(pcase-dolist (`(,_id ,name ,color ,description) labels)
|
||
|
|
(let* ((background (forge--sanitize-color color))
|
||
|
|
(foreground (readable-foreground-color background)))
|
||
|
|
(if separate (insert " ") (setq separate t))
|
||
|
|
(insert name)
|
||
|
|
(let ((o (make-overlay (- (point) (length name)) (point))))
|
||
|
|
(overlay-put o 'priority 2)
|
||
|
|
(overlay-put o 'evaporate t)
|
||
|
|
(overlay-put o 'font-lock-face
|
||
|
|
`(( :background ,background
|
||
|
|
:foreground ,foreground)
|
||
|
|
forge-topic-label))
|
||
|
|
(when description
|
||
|
|
(overlay-put o 'help-echo description))))))))
|
||
|
|
|
||
|
|
(defun forge--insert-topic-marks (topic &optional separate)
|
||
|
|
(and-let ((marks (oref topic marks)))
|
||
|
|
(prog1 t
|
||
|
|
(pcase-dolist (`(,_id ,name ,face ,description) marks)
|
||
|
|
(if separate (insert " ") (setq separate t))
|
||
|
|
(insert name)
|
||
|
|
(let ((o (make-overlay (- (point) (length name)) (point))))
|
||
|
|
(overlay-put o 'priority 2)
|
||
|
|
(overlay-put o 'evaporate t)
|
||
|
|
(overlay-put o 'font-lock-face (list face 'forge-topic-label))
|
||
|
|
(when description
|
||
|
|
(overlay-put o 'help-echo description)))))))
|
||
|
|
|
||
|
|
;;; Modes
|
||
|
|
|
||
|
|
(defvar-keymap forge-common-map
|
||
|
|
:doc "Parent keymap of many of Forge's keymaps.
|
||
|
|
Keymaps that use this keymap as the/a parent keymap, remap the
|
||
|
|
place-holder commands `forge--list-menu' and/or `forge--item-menu'
|
||
|
|
to the appropriate menu command. To change the keys bound to menu
|
||
|
|
commands in all Forge keymaps, one only has to change them here."
|
||
|
|
"C-c C-c" #'forge--list-menu
|
||
|
|
"C-c RET" #'forge--item-menu
|
||
|
|
"C-<return>" #'forge--item-menu)
|
||
|
|
|
||
|
|
(defun forge--list-menu ()
|
||
|
|
"Place-holder menu command. See `forge-common-map'."
|
||
|
|
(interactive)
|
||
|
|
(message "No list menu available here"))
|
||
|
|
(put 'forge--list-menu 'completion-predicate #'ignore)
|
||
|
|
|
||
|
|
(defun forge--item-menu ()
|
||
|
|
"Place-holder menu command. See `forge-common-map'."
|
||
|
|
(interactive)
|
||
|
|
(message "No item menu available here"))
|
||
|
|
(put 'forge--item-menu 'completion-predicate #'ignore)
|
||
|
|
|
||
|
|
(defvar-keymap forge-post-section-map
|
||
|
|
"<remap> <magit-edit-thing>" #'forge-edit-post
|
||
|
|
"C-c C-k" #'forge-delete-comment)
|
||
|
|
|
||
|
|
(defvar-keymap forge-topic-mode-map
|
||
|
|
:parent (make-composed-keymap forge-common-map magit-mode-map)
|
||
|
|
"<remap> <magit-visit-thing>" #'markdown-follow-link-at-point
|
||
|
|
"<mouse-2>" #'markdown-follow-link-at-point
|
||
|
|
"<remap> <forge--item-menu>" #'forge-topic-menu
|
||
|
|
"<remap> <forge--list-menu>" #'forge-topic-menu
|
||
|
|
"C-c C-n" #'forge-create-post
|
||
|
|
"C-c C-r" #'forge-create-post
|
||
|
|
"G" #'forge-pull-this-topic)
|
||
|
|
|
||
|
|
(define-derived-mode forge-topic-mode magit-mode "Topic"
|
||
|
|
"Parent major mode of `forge-{issue,pullreq}-mode'.
|
||
|
|
This mode itself is never used directly."
|
||
|
|
:interactive nil
|
||
|
|
(face-remap-add-relative 'header-line 'forge-topic-header-line)
|
||
|
|
(setq-local markdown-translate-filename-function
|
||
|
|
#'forge--markdown-translate-filename-function))
|
||
|
|
|
||
|
|
(defvar-keymap forge-discussion-mode-map :parent forge-topic-mode-map)
|
||
|
|
(define-derived-mode forge-discussion-mode forge-topic-mode "Discussion"
|
||
|
|
"Mode for looking at a Forge discussion.")
|
||
|
|
(defalias 'forge-discussion-setup-buffer #'forge-topic-setup-buffer)
|
||
|
|
(defalias 'forge-discussion-refresh-buffer #'forge-topic-refresh-buffer)
|
||
|
|
(defvar forge-discussion-headers-hook
|
||
|
|
'(forge-insert-topic-state
|
||
|
|
forge-insert-topic-status
|
||
|
|
forge-insert-topic-category
|
||
|
|
forge-insert-topic-labels
|
||
|
|
forge-insert-topic-marks))
|
||
|
|
|
||
|
|
(defvar-keymap forge-issue-mode-map :parent forge-topic-mode-map)
|
||
|
|
(define-derived-mode forge-issue-mode forge-topic-mode "Issue"
|
||
|
|
"Major mode for looking at a Forge issue."
|
||
|
|
:interactive nil)
|
||
|
|
(defalias 'forge-issue-setup-buffer #'forge-topic-setup-buffer)
|
||
|
|
(defalias 'forge-issue-refresh-buffer #'forge-topic-refresh-buffer)
|
||
|
|
(defvar forge-issue-headers-hook
|
||
|
|
'(forge-insert-topic-state
|
||
|
|
forge-insert-topic-status
|
||
|
|
forge-insert-topic-milestone
|
||
|
|
forge-insert-topic-labels
|
||
|
|
forge-insert-topic-marks
|
||
|
|
forge-insert-topic-assignees))
|
||
|
|
|
||
|
|
(defvar-keymap forge-pullreq-mode-map :parent forge-topic-mode-map)
|
||
|
|
(define-derived-mode forge-pullreq-mode forge-topic-mode "Pull-request"
|
||
|
|
"Major mode for looking at a Forge pull-request."
|
||
|
|
:interactive nil)
|
||
|
|
(defalias 'forge-pullreq-setup-buffer #'forge-topic-setup-buffer)
|
||
|
|
(defalias 'forge-pullreq-refresh-buffer #'forge-topic-refresh-buffer)
|
||
|
|
(defvar forge-pullreq-headers-hook
|
||
|
|
'(forge-insert-topic-state
|
||
|
|
forge-insert-topic-draft
|
||
|
|
forge-insert-topic-status
|
||
|
|
forge-insert-topic-saved
|
||
|
|
forge-insert-topic-refs
|
||
|
|
forge-insert-topic-milestone
|
||
|
|
forge-insert-topic-labels
|
||
|
|
forge-insert-topic-marks
|
||
|
|
forge-insert-topic-assignees
|
||
|
|
forge-insert-topic-review-requests))
|
||
|
|
|
||
|
|
(defvar-local forge-buffer-topic nil)
|
||
|
|
|
||
|
|
(defun forge-topic-setup-buffer (topic)
|
||
|
|
(let* ((repo (forge-get-repository topic))
|
||
|
|
(name (format "*forge: %s %s*" (oref repo slug) (oref topic slug)))
|
||
|
|
(magit-generate-buffer-name-function (lambda (_mode _value) name))
|
||
|
|
(mode (pcase-exhaustive (eieio-object-class topic)
|
||
|
|
('forge-discussion #'forge-discussion-mode)
|
||
|
|
('forge-issue #'forge-issue-mode)
|
||
|
|
('forge-pullreq #'forge-pullreq-mode)))
|
||
|
|
(buffer (magit-setup-buffer mode t
|
||
|
|
:buffer name
|
||
|
|
:directory (or (forge-get-worktree repo) "/")
|
||
|
|
(forge-buffer-topic topic))))
|
||
|
|
(forge-topic-mark-read topic)
|
||
|
|
buffer))
|
||
|
|
|
||
|
|
(defun forge-topic-refresh-buffer ()
|
||
|
|
(let ((topic (closql-reload forge-buffer-topic)))
|
||
|
|
(setq forge-buffer-topic topic)
|
||
|
|
(magit-set-header-line-format (forge--format-topic-line topic))
|
||
|
|
(magit-insert-section (topicbuf)
|
||
|
|
(magit-insert-headers
|
||
|
|
(pcase major-mode
|
||
|
|
('forge-discussion-mode 'forge-discussion-headers-hook)
|
||
|
|
('forge-issue-mode 'forge-issue-headers-hook)
|
||
|
|
('forge-pullreq-mode 'forge-pullreq-headers-hook)))
|
||
|
|
(when (forge-pullreq-p topic)
|
||
|
|
(magit-insert-section (pullreq topic)
|
||
|
|
(magit-insert-heading "Commits")
|
||
|
|
(forge--insert-pullreq-commits topic t)))
|
||
|
|
(when-let ((note (oref topic note)))
|
||
|
|
(magit-insert-section (note)
|
||
|
|
(magit-insert-heading "Note")
|
||
|
|
(insert (forge--fontify-markdown note) "\n\n")))
|
||
|
|
(forge-insert-post topic nil)
|
||
|
|
(dolist (post (oref topic posts))
|
||
|
|
(forge-insert-post post topic))
|
||
|
|
(when (and (display-images-p)
|
||
|
|
(fboundp 'markdown-display-inline-images))
|
||
|
|
(let ((markdown-display-remote-images t))
|
||
|
|
(markdown-display-inline-images))))))
|
||
|
|
|
||
|
|
(defun forge-insert-post (post topic)
|
||
|
|
(magit-insert-section (post post)
|
||
|
|
(forge-insert-post-heading post topic)
|
||
|
|
(forge-insert-post-content post)
|
||
|
|
(when (forge-discussion-p topic)
|
||
|
|
(dolist (reply (oref post replies))
|
||
|
|
(magit-insert-section (post reply)
|
||
|
|
(forge-insert-post-heading reply topic)
|
||
|
|
(forge-insert-post-content reply))))))
|
||
|
|
|
||
|
|
(defun forge-insert-post-heading (post topic)
|
||
|
|
(oset magit-insert-section--current
|
||
|
|
heading-highlight-face
|
||
|
|
'magit-diff-hunk-heading-highlight)
|
||
|
|
(let* ((author (oref post author))
|
||
|
|
(created (oref post created))
|
||
|
|
(heading
|
||
|
|
(format-spec
|
||
|
|
forge-post-heading-format
|
||
|
|
`((?a . ,(propertize (concat (forge--format-avatar author)
|
||
|
|
(or author "(ghost)"))
|
||
|
|
'font-lock-face 'forge-post-author))
|
||
|
|
(?c . ,(propertize created 'font-lock-face 'forge-post-date))
|
||
|
|
(?C . ,(propertize (apply #'format "%s %s ago"
|
||
|
|
(magit--age
|
||
|
|
(float-time
|
||
|
|
(date-to-time created))))
|
||
|
|
'font-lock-face 'forge-post-date))))))
|
||
|
|
(when (forge-discussion-reply-p post)
|
||
|
|
(setq heading (concat " " heading)))
|
||
|
|
(font-lock-append-text-property
|
||
|
|
0 (length heading)
|
||
|
|
'font-lock-face (cond-let*
|
||
|
|
([_(forge-discussion-p topic)]
|
||
|
|
[answer (oref topic answer)]
|
||
|
|
[_(equal (oref post their-id)
|
||
|
|
(forge--their-id answer))]
|
||
|
|
'forge-discussion-answer-heading)
|
||
|
|
((forge-discussion-reply-p post)
|
||
|
|
'(magit-dimmed magit-diff-hunk-heading))
|
||
|
|
('magit-diff-hunk-heading))
|
||
|
|
heading)
|
||
|
|
(magit-insert-heading heading)))
|
||
|
|
|
||
|
|
(defun forge-insert-post-content (post)
|
||
|
|
(insert (forge--fontify-markdown
|
||
|
|
(oref post body)
|
||
|
|
(and (forge-discussion-reply-p post) 4)))
|
||
|
|
(insert "\n\n"))
|
||
|
|
|
||
|
|
(cl-defmethod magit-buffer-value (&context (major-mode forge-topic-mode))
|
||
|
|
(oref forge-buffer-topic slug))
|
||
|
|
|
||
|
|
;;; Bookmarks
|
||
|
|
|
||
|
|
(cl-defmethod magit-bookmark-name
|
||
|
|
(&context (major-mode forge-topic-mode))
|
||
|
|
(concat (oref (forge-get-repository forge-buffer-topic) slug)
|
||
|
|
(oref forge-buffer-topic slug)))
|
||
|
|
|
||
|
|
(cl-defmethod magit-bookmark-get-value
|
||
|
|
(bookmark &context (major-mode forge-topic-mode))
|
||
|
|
(bookmark-prop-set bookmark 'forge-topic (oref forge-buffer-topic id)))
|
||
|
|
|
||
|
|
(cl-defmethod magit-bookmark-get-buffer-create
|
||
|
|
(bookmark (_mode (derived-mode forge-topic-mode)))
|
||
|
|
(let ((magit-display-buffer-function #'identity)
|
||
|
|
(magit-display-buffer-noselect t))
|
||
|
|
(forge-topic-setup-buffer
|
||
|
|
(forge-get-topic (bookmark-prop-get bookmark 'forge-topic)))))
|
||
|
|
|
||
|
|
(put 'forge-discussion-mode 'magit-bookmark-variables t)
|
||
|
|
(put 'forge-issue-mode 'magit-bookmark-variables t)
|
||
|
|
(put 'forge-pullreq-mode 'magit-bookmark-variables t)
|
||
|
|
|
||
|
|
;;; Headers
|
||
|
|
|
||
|
|
(cl-defmacro forge--define-topic-header
|
||
|
|
(name &key insert format (command nil command?))
|
||
|
|
(declare (indent defun))
|
||
|
|
(let ((fun (intern (format "forge-insert-topic-%s" name)))
|
||
|
|
(map (intern (format "forge-topic-%s-section-map" name)))
|
||
|
|
(cmd (intern (format "forge-topic-set-%s" name))))
|
||
|
|
`(progn
|
||
|
|
(cl-defun ,fun (&optional (topic forge-buffer-topic))
|
||
|
|
(magit-insert-section (,(intern (format "topic-%s" name)))
|
||
|
|
(insert ,(capitalize (string-pad (format "%s: " name) 11)))
|
||
|
|
,(cond
|
||
|
|
(insert
|
||
|
|
`(unless (funcall ,insert topic)
|
||
|
|
(insert (magit--propertize-face "none" 'magit-dimmed))))
|
||
|
|
(format
|
||
|
|
`(insert (or (funcall ,format topic)
|
||
|
|
(magit--propertize-face "none" 'magit-dimmed)))))
|
||
|
|
(insert ?\n)))
|
||
|
|
,@(and (if command? command t)
|
||
|
|
`((defvar-keymap ,map "<remap> <magit-edit-thing>"
|
||
|
|
,(or command `(function ,cmd)))
|
||
|
|
(put ',map 'definition-name ',name)))
|
||
|
|
(put ',fun 'definition-name ',name))))
|
||
|
|
|
||
|
|
(forge--define-topic-header refs
|
||
|
|
:command nil
|
||
|
|
:format #'forge--format-topic-refs)
|
||
|
|
|
||
|
|
(forge--define-topic-header draft
|
||
|
|
:command #'forge-topic-toggle-draft
|
||
|
|
:format #'forge--format-topic-draft)
|
||
|
|
|
||
|
|
(forge--define-topic-header saved
|
||
|
|
:command #'forge-topic-toggle-saved
|
||
|
|
:format #'forge--format-topic-saved)
|
||
|
|
|
||
|
|
(forge--define-topic-header state
|
||
|
|
:command #'forge-topic-state-menu
|
||
|
|
:format #'forge--format-topic-state)
|
||
|
|
|
||
|
|
(forge--define-topic-header status
|
||
|
|
:command #'forge-topic-status-menu
|
||
|
|
:format #'forge--format-topic-status)
|
||
|
|
|
||
|
|
(forge--define-topic-header category
|
||
|
|
:format #'forge--format-topic-category)
|
||
|
|
|
||
|
|
(forge--define-topic-header milestone
|
||
|
|
:format #'forge--format-topic-milestone)
|
||
|
|
|
||
|
|
(forge--define-topic-header labels
|
||
|
|
:insert #'forge--insert-topic-labels)
|
||
|
|
|
||
|
|
(forge--define-topic-header marks
|
||
|
|
:insert #'forge--insert-topic-marks)
|
||
|
|
|
||
|
|
(forge--define-topic-header assignees
|
||
|
|
:format #'forge--format-topic-assignees)
|
||
|
|
|
||
|
|
(forge--define-topic-header review-requests
|
||
|
|
:format #'forge--format-topic-review-requests)
|
||
|
|
|
||
|
|
;;; Commands
|
||
|
|
;;;; Groups
|
||
|
|
|
||
|
|
(defvar forge--show-topic-legend t)
|
||
|
|
|
||
|
|
(transient-define-group forge--lists-group
|
||
|
|
["List"
|
||
|
|
("l r" "repositories" forge-list-repositories)
|
||
|
|
("l n" "notifications" forge-list-notifications)
|
||
|
|
("l g" "global topics" forge-list-global-topics)
|
||
|
|
("l t" "topics" forge-list-topics)
|
||
|
|
""
|
||
|
|
( :info "Batch" :format "%d" :face transient-heading
|
||
|
|
:if (##memq (oref transient--prefix command)
|
||
|
|
'(forge-topic-menu forge-topics-menu)))
|
||
|
|
( "C" "done with closed" forge-mark-completed-topics-as-done
|
||
|
|
:if (##memq (oref transient--prefix command)
|
||
|
|
'(forge-topic-menu forge-topics-menu)))])
|
||
|
|
|
||
|
|
(transient-define-group forge--topic-menus-group
|
||
|
|
["Menu"
|
||
|
|
("m s" "edit" forge-topic-menu)
|
||
|
|
("m f" "filter" forge-topics-menu)
|
||
|
|
("m f" "filter" forge-notifications-menu)
|
||
|
|
("m f" "filter" forge-repositories-menu)
|
||
|
|
("m d" "dispatch" forge-dispatch)
|
||
|
|
("m c" "configure" forge-configure)
|
||
|
|
""""])
|
||
|
|
|
||
|
|
(transient-define-group forge--topic-set-state-group
|
||
|
|
[:description (##if forge--show-topic-legend "Set public state" "Set state")
|
||
|
|
("o" forge-topic-state-set-open)
|
||
|
|
("c" forge-chatter-state-set-completed)
|
||
|
|
("U" forge-issue-state-set-unplanned)
|
||
|
|
("O" forge-discussion-state-set-outdated)
|
||
|
|
("D" forge-chatter-state-set-duplicate)
|
||
|
|
("M" forge-pullreq-state-set-merged)
|
||
|
|
("R" forge-pullreq-state-set-rejected)])
|
||
|
|
|
||
|
|
(transient-define-group forge--topic-set-status-group
|
||
|
|
[:description (##if forge--show-topic-legend "Set private status" "Set status")
|
||
|
|
("u" forge-topic-status-set-unread)
|
||
|
|
("p" forge-topic-status-set-pending)
|
||
|
|
("d" forge-topic-status-set-done)])
|
||
|
|
|
||
|
|
(transient-define-group forge--topic-legend-group
|
||
|
|
["Legend" :if-non-nil forge--show-topic-legend
|
||
|
|
(:info* (##propertize "open discussion" 'face 'forge-discussion-open))
|
||
|
|
(:info* (##propertize "completed discussion" 'face 'forge-discussion-completed))
|
||
|
|
(:info* (##propertize "expunged discussion" 'face 'forge-discussion-expunged))]
|
||
|
|
["" :if-non-nil forge--show-topic-legend
|
||
|
|
(:info* (##propertize "open issue" 'face 'forge-issue-open))
|
||
|
|
(:info* (##propertize "completed issue" 'face 'forge-issue-completed))
|
||
|
|
(:info* (##propertize "expunged issue" 'face 'forge-issue-expunged))]
|
||
|
|
["" :if-non-nil forge--show-topic-legend
|
||
|
|
(:info* (##propertize "open pullreq" 'face 'forge-pullreq-open))
|
||
|
|
(:info* (##propertize "merged pullreq" 'face 'forge-pullreq-merged))
|
||
|
|
(:info* (##propertize "rejected pullreq" 'face 'forge-pullreq-rejected))]
|
||
|
|
["" :if-non-nil forge--show-topic-legend
|
||
|
|
(:info* (##propertize "unread" 'face 'forge-topic-unread))
|
||
|
|
(:info* (##propertize "pending" 'face 'forge-topic-pending))
|
||
|
|
(:info* (##propertize "done" 'face 'forge-topic-done))]
|
||
|
|
["" :if-non-nil forge--show-topic-legend
|
||
|
|
(:info* (##propertize "draft" 'face 'forge-pullreq-draft))])
|
||
|
|
|
||
|
|
(transient-define-suffix forge-toggle-topic-legend ()
|
||
|
|
"Toggle whether to show legend for faces used in topic menus and lists."
|
||
|
|
:description (##if forge--show-topic-legend "hide legend" "show legend")
|
||
|
|
:transient t
|
||
|
|
(interactive)
|
||
|
|
(customize-set-variable 'forge--show-topic-legend
|
||
|
|
(not forge--show-topic-legend)))
|
||
|
|
|
||
|
|
(defconst forge--topic-menus-column-widths '(21 21 21 21))
|
||
|
|
|
||
|
|
;;;; Menus
|
||
|
|
|
||
|
|
;;;###autoload(autoload 'forge-topic-menu "forge-topic" nil t)
|
||
|
|
(transient-define-prefix forge-topic-menu (&optional visit)
|
||
|
|
"Show menu for the topic at point.
|
||
|
|
With prefix argument VISIT, also visit the topic."
|
||
|
|
:transient-suffix t
|
||
|
|
:transient-non-suffix #'transient--do-call
|
||
|
|
:transient-switch-frame nil
|
||
|
|
:refresh-suffixes t
|
||
|
|
:environment #'forge--menu-environment
|
||
|
|
:column-widths forge--topic-menus-column-widths
|
||
|
|
[:hide always ("q" forge-menu-quit-list)]
|
||
|
|
[forge--topic-menus-group
|
||
|
|
forge--topic-set-state-group
|
||
|
|
forge--topic-set-status-group
|
||
|
|
["Actions"
|
||
|
|
("/f" forge-pull-this-topic)
|
||
|
|
("/b" forge-browse-this-topic)
|
||
|
|
("/r" "respond" forge-create-post)
|
||
|
|
("/c" forge-checkout-this-pullreq)
|
||
|
|
("/A" forge-approve-pullreq)
|
||
|
|
("/R" forge-request-changes)]]
|
||
|
|
[forge--lists-group
|
||
|
|
["Set "
|
||
|
|
("-c" forge-topic-set-category)
|
||
|
|
("-m" forge-topic-set-milestone)
|
||
|
|
("-l" forge-topic-set-labels)
|
||
|
|
("-x" forge-topic-set-marks)
|
||
|
|
("-a" forge-topic-set-assignees)
|
||
|
|
("-r" forge-topic-set-review-requests)
|
||
|
|
("-n" forge-edit-topic-note)
|
||
|
|
("-t" forge-topic-set-title)]
|
||
|
|
["Set"
|
||
|
|
("-s" forge-topic-toggle-saved)
|
||
|
|
("-d" forge-topic-toggle-draft)
|
||
|
|
("-A" forge-discussion-set-answer)
|
||
|
|
"""Display"
|
||
|
|
("-H" forge-toggle-topic-legend)]]
|
||
|
|
[forge--topic-legend-group]
|
||
|
|
(interactive (list current-prefix-arg))
|
||
|
|
(when visit
|
||
|
|
(forge-topic-setup-buffer (forge-topic-at-point)))
|
||
|
|
(transient-setup 'forge-topic-menu))
|
||
|
|
|
||
|
|
(transient-augment-suffix forge-topic-menu
|
||
|
|
:transient #'transient--do-replace
|
||
|
|
:inapt-if (lambda () (or (derived-mode-p 'forge-repository-list-mode)
|
||
|
|
(eq (oref transient--prefix command) 'forge-topic-menu)))
|
||
|
|
:inapt-face (lambda () (if (derived-mode-p 'forge-repository-list-mode)
|
||
|
|
'transient-inapt-suffix
|
||
|
|
'forge-suffix-active)))
|
||
|
|
|
||
|
|
;;;###autoload(autoload 'forge-topic-state-menu "forge-topic" nil t)
|
||
|
|
(transient-define-prefix forge-topic-state-menu ()
|
||
|
|
"Set state of the current topic."
|
||
|
|
:environment #'forge--menu-environment
|
||
|
|
[forge--topic-set-state-group])
|
||
|
|
|
||
|
|
;;;###autoload(autoload 'forge-topic-status-menu "forge-topic" nil t)
|
||
|
|
(transient-define-prefix forge-topic-status-menu ()
|
||
|
|
"Set status of the current topic."
|
||
|
|
:environment #'forge--menu-environment
|
||
|
|
[forge--topic-set-status-group])
|
||
|
|
|
||
|
|
(defun forge--menu-environment (fn)
|
||
|
|
(let ((magit--refresh-cache (list (cons 0 0))))
|
||
|
|
(funcall fn)))
|
||
|
|
|
||
|
|
;;;; State
|
||
|
|
|
||
|
|
(defclass forge--topic-set-state-command (transient-suffix)
|
||
|
|
((state :initarg :state)
|
||
|
|
(getter :initarg :getter)
|
||
|
|
(definition
|
||
|
|
:initform (lambda ()
|
||
|
|
(interactive)
|
||
|
|
(with-slots (getter state) (transient-suffix-object)
|
||
|
|
(let ((topic (funcall getter t)))
|
||
|
|
(forge--set-topic-state (forge-get-repository topic)
|
||
|
|
topic state)))))
|
||
|
|
(description
|
||
|
|
:initform (lambda (obj)
|
||
|
|
(symbol-name (oref obj state))))
|
||
|
|
(inapt-if
|
||
|
|
:initform (lambda ()
|
||
|
|
(or (forge-region-topics)
|
||
|
|
(with-slots (getter state) (transient-suffix-object)
|
||
|
|
(if-let ((topic (funcall getter)))
|
||
|
|
;; Once a pull-request is merged,
|
||
|
|
;; its state cannot be changed anymore.
|
||
|
|
(memq (oref topic state) (list state 'merged))
|
||
|
|
t)))))
|
||
|
|
(inapt-face
|
||
|
|
:initform (lambda (obj)
|
||
|
|
(with-slots (getter state) (transient-suffix-object)
|
||
|
|
(if (and-let ((_(not (forge-region-topics)))
|
||
|
|
(topic (funcall getter)))
|
||
|
|
(eq (oref topic state) state))
|
||
|
|
'forge-suffix-active
|
||
|
|
'transient-inapt-suffix))))))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-state-set-open ()
|
||
|
|
"Set the state of the current topic to `open'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'open
|
||
|
|
:getter #'forge-current-topic)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-chatter-state-set-completed ()
|
||
|
|
"Set the state of the current discussion or issue to `completed'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'completed
|
||
|
|
:getter #'forge-current-chatter
|
||
|
|
:if #'forge-current-chatter)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-issue-state-set-unplanned ()
|
||
|
|
"Set the state of the current issue to `unplanned'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'unplanned
|
||
|
|
:getter #'forge-current-issue
|
||
|
|
:if #'forge-current-issue)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-chatter-state-set-duplicate ()
|
||
|
|
"Set the state of the current discussion or issue to `duplicate'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'duplicate
|
||
|
|
:getter #'forge-current-chatter
|
||
|
|
:if #'forge-current-chatter
|
||
|
|
(interactive)
|
||
|
|
(with-slots (getter state) (transient-suffix-object)
|
||
|
|
(let ((topic (funcall getter t)))
|
||
|
|
(if (forge-issue-p topic)
|
||
|
|
(message
|
||
|
|
"The API does not yet support closing an issue as a duplicate")
|
||
|
|
(forge--set-topic-state (forge-get-repository topic)
|
||
|
|
topic state)))))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-discussion-state-set-outdated ()
|
||
|
|
"Set the state of the current discussion to `outdated'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'outdated
|
||
|
|
:getter #'forge-current-discussion
|
||
|
|
:if #'forge-current-discussion)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-pullreq-state-set-merged ()
|
||
|
|
"Merge the current pull-request into its target.
|
||
|
|
Prompt the user to either use the API to perform the merge or use Git.
|
||
|
|
I recommend you only use the API if your organization enforces that
|
||
|
|
inferior process."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'merged
|
||
|
|
:getter #'forge-current-pullreq
|
||
|
|
:if #'forge-current-pullreq
|
||
|
|
:transient nil
|
||
|
|
(interactive)
|
||
|
|
(let ((pullreq (forge-current-pullreq)))
|
||
|
|
(if (magit-read-char-case (format "Merge #%s " (oref pullreq number)) t
|
||
|
|
(?g "using [g]it (recommended)" t)
|
||
|
|
(?a "using [a]pi" nil))
|
||
|
|
(let ((branch (or (forge--pullreq-branch-active pullreq)
|
||
|
|
(forge--branch-pullreq pullreq))))
|
||
|
|
(if-let ((upstream (magit-get-local-upstream-branch branch)))
|
||
|
|
(if (zerop (magit-call-git "checkout" upstream))
|
||
|
|
(magit--merge-absorb
|
||
|
|
branch (magit-merge-arguments)
|
||
|
|
;; Users might be surprised that we
|
||
|
|
;; aren't done yet, so drop a hint.
|
||
|
|
"Inspect the result, and if satisfied push")
|
||
|
|
(user-error "Could not checkout %S" upstream))
|
||
|
|
(user-error "No upstream configured for %S" branch)))
|
||
|
|
(forge-merge pullreq (forge-select-merge-method)))))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-pullreq-state-set-rejected ()
|
||
|
|
"Set the state of the current pull-request to `rejected'."
|
||
|
|
:class 'forge--topic-set-state-command
|
||
|
|
:state 'rejected
|
||
|
|
:getter #'forge-current-pullreq
|
||
|
|
:if #'forge-current-pullreq)
|
||
|
|
|
||
|
|
;;;; Status
|
||
|
|
|
||
|
|
(defclass forge--topic-set-status-command (transient-suffix)
|
||
|
|
((status :initarg :status)
|
||
|
|
(definition
|
||
|
|
:initform (lambda ()
|
||
|
|
(interactive)
|
||
|
|
(with-slots (status) (transient-suffix-object)
|
||
|
|
(if-let ((topics (forge-region-topics)))
|
||
|
|
(dolist (topic topics)
|
||
|
|
(oset topic status status))
|
||
|
|
(oset (forge-current-topic t) status status)))
|
||
|
|
(forge-refresh-buffer)))
|
||
|
|
(description
|
||
|
|
:initform (lambda (obj)
|
||
|
|
(symbol-name (oref obj status))))
|
||
|
|
(inapt-if
|
||
|
|
:initform (lambda ()
|
||
|
|
(and (not (forge-region-topics))
|
||
|
|
(if-let ((topic (forge-current-topic)))
|
||
|
|
(eq (oref topic status)
|
||
|
|
(oref (transient-suffix-object) status))
|
||
|
|
t))))
|
||
|
|
(inapt-face
|
||
|
|
:initform (lambda ()
|
||
|
|
(if (forge-current-topic)
|
||
|
|
'forge-suffix-active
|
||
|
|
'transient-inapt-suffix)))))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-status-set-unread ()
|
||
|
|
"Set the notification status of the current topic to `unread'."
|
||
|
|
:class 'forge--topic-set-status-command :status 'unread)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-status-set-pending ()
|
||
|
|
"Set the notification status of the current topic to `pending'."
|
||
|
|
:class 'forge--topic-set-status-command :status 'pending)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-status-set-done ()
|
||
|
|
"Set the notification status of the current topic to `done'."
|
||
|
|
:class 'forge--topic-set-status-command :status 'done)
|
||
|
|
|
||
|
|
(defun forge-mark-completed-topics-as-done ()
|
||
|
|
"Mark completed topics of the current repository as done.
|
||
|
|
Change the private status to \"done\" for topics whose private status is
|
||
|
|
\"unread\" or \"pending\" and whose public state is \"completed\".
|
||
|
|
Whether this affects all such topics or only all such topics of a
|
||
|
|
certain type (discussion, issue or pull-request), depends on the
|
||
|
|
context."
|
||
|
|
(interactive)
|
||
|
|
(let* ((type (forge-current-topic-type))
|
||
|
|
(desc (if (eq type 'pullreq) 'pull-request type))
|
||
|
|
(topics (forge--list-topics
|
||
|
|
(forge--topics-spec :type type
|
||
|
|
:active nil
|
||
|
|
:state 'closed
|
||
|
|
:status 'inbox)
|
||
|
|
(forge-get-repository :tracked))))
|
||
|
|
(cond ((not topics)
|
||
|
|
(message "No completed %s that could be marked as done" desc))
|
||
|
|
((magit-confirm t
|
||
|
|
"Mark \"%s\" as done"
|
||
|
|
(format "Mark %%d %ss as done" desc)
|
||
|
|
nil
|
||
|
|
(mapcar #'forge--format-topic-line topics))
|
||
|
|
(dolist (topic topics)
|
||
|
|
(oset topic status 'done))
|
||
|
|
(forge-refresh-buffer)))))
|
||
|
|
|
||
|
|
;;;; Set
|
||
|
|
|
||
|
|
(defclass forge--topic-set-slot-command (transient-suffix)
|
||
|
|
((slot :initarg :slot)
|
||
|
|
(setter)
|
||
|
|
(reader :initarg :reader)
|
||
|
|
(formatter :initarg :formatter)
|
||
|
|
(definition
|
||
|
|
:initform (lambda (value)
|
||
|
|
(interactive
|
||
|
|
(list (funcall (oref (transient-suffix-object) reader)
|
||
|
|
(forge-current-topic t))))
|
||
|
|
(let ((topic (forge-current-topic t)))
|
||
|
|
(funcall (oref (transient-suffix-object) setter)
|
||
|
|
(forge-get-repository topic)
|
||
|
|
topic value))))
|
||
|
|
(description
|
||
|
|
:initform (lambda (obj)
|
||
|
|
(with-slots (slot inapt-if-not) obj
|
||
|
|
(if-let* ((topic (if inapt-if-not
|
||
|
|
(funcall inapt-if-not)
|
||
|
|
(forge-current-topic)))
|
||
|
|
(value (funcall (oref obj formatter) topic)))
|
||
|
|
(format "%s %s" slot value)
|
||
|
|
(format "%s" slot)))))))
|
||
|
|
|
||
|
|
(cl-defmethod initialize-instance :after
|
||
|
|
((obj forge--topic-set-slot-command) &optional _slots)
|
||
|
|
(with-slots (slot) obj
|
||
|
|
(let ((name (symbol-name slot)))
|
||
|
|
(cond ((slot-boundp obj 'reader))
|
||
|
|
((string-suffix-p "-p" name)
|
||
|
|
(setq name (substring name 0 -2))
|
||
|
|
(oset obj reader (##not (eieio-oref % slot))))
|
||
|
|
((oset obj reader (intern (format "forge-read-topic-%s" name)))))
|
||
|
|
(oset obj setter (intern (format "forge--set-topic-%s" name)))
|
||
|
|
(unless (slot-boundp obj 'formatter)
|
||
|
|
(oset obj formatter (intern (format "forge--format-topic-%s" name)))))))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-title (title)
|
||
|
|
"Edit the TITLE of the current topic."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'title
|
||
|
|
:inapt-if-not #'forge-current-topic
|
||
|
|
:formatter (lambda (topic)
|
||
|
|
(propertize (truncate-string-to-width
|
||
|
|
(forge--format-topic-title topic) 34 nil ?\s t)
|
||
|
|
'face 'font-lock-string-face)))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-category (category)
|
||
|
|
"Edit the CATEGORY of the current discussion."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'category
|
||
|
|
:inapt-if-not #'forge-current-discussion)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-milestone (milestone)
|
||
|
|
"Edit what MILESTONE the current topic belongs to."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'milestone
|
||
|
|
:inapt-if-not (##or (forge-current-issue) (forge-current-pullreq)))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-labels (labels)
|
||
|
|
"Edit the LABELS of the current topic."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'labels
|
||
|
|
:inapt-if-not #'forge-current-topic
|
||
|
|
:formatter (##forge--format-labels % t))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-marks (marks)
|
||
|
|
"Edit the MARKS of the current topic."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'marks
|
||
|
|
:inapt-if-not #'forge-current-topic
|
||
|
|
:formatter (##forge--format-marks % t))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-assignees (assignees)
|
||
|
|
"Edit the ASSIGNEES of the current topic."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'assignees
|
||
|
|
:inapt-if-not (##or (forge-current-issue) (forge-current-pullreq)))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-set-review-requests (review-requests)
|
||
|
|
"Edit the REVIEW-REQUESTS of the current pull-request."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'review-requests
|
||
|
|
:inapt-if-not #'forge-current-pullreq)
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-toggle-draft (draft)
|
||
|
|
"Toggle whether the current pull-request is a draft."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'draft-p
|
||
|
|
:inapt-if-not #'forge-current-pullreq
|
||
|
|
:description (##forge--format-boolean 'draft-p "draft"))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-topic-toggle-saved ()
|
||
|
|
"Toggle whether this topic is marked as saved."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'saved-p
|
||
|
|
:inapt-if-not #'forge-current-topic
|
||
|
|
:description (##forge--format-boolean 'saved-p "saved")
|
||
|
|
;; Set only locally because Github's API does not support this.
|
||
|
|
(interactive)
|
||
|
|
(let ((topic (forge-current-topic t)))
|
||
|
|
(oset topic saved-p (not (oref topic saved-p))))
|
||
|
|
(forge-refresh-buffer))
|
||
|
|
|
||
|
|
(transient-define-suffix forge-discussion-set-answer (post)
|
||
|
|
"Mark the post at point as the answer to the current question.
|
||
|
|
When point is on the answer, then unmark it and mark no other."
|
||
|
|
:class 'forge--topic-set-slot-command :slot 'answer
|
||
|
|
:inapt-if-not (lambda ()
|
||
|
|
(and-let* ((discussion (forge-current-discussion))
|
||
|
|
(category (oref discussion category)))
|
||
|
|
(forge-sql1 [:select answerable-p
|
||
|
|
:from discussion-category
|
||
|
|
:where (= id $s1)]
|
||
|
|
category)))
|
||
|
|
:description (##forge--format-boolean 'answer "answered")
|
||
|
|
:reader #'forge--select-discussion-answer)
|
||
|
|
|
||
|
|
;;; Color Utilities
|
||
|
|
|
||
|
|
(defun forge--sanitize-color (color)
|
||
|
|
(cond ((color-values color) color)
|
||
|
|
;; Discard alpha information.
|
||
|
|
((string-match-p "\\`#.\\{4\\}\\'" color) (substring color 0 3))
|
||
|
|
((string-match-p "\\`#.\\{8\\}\\'" color) (substring color 0 6))
|
||
|
|
(t "#000000"))) ; Use fallback instead of invalid color.
|
||
|
|
|
||
|
|
;;; Markdown Utilities
|
||
|
|
|
||
|
|
(defun forge--fontify-markdown (text &optional indent)
|
||
|
|
(with-temp-buffer
|
||
|
|
(delay-mode-hooks
|
||
|
|
(gfm-mode))
|
||
|
|
(insert text)
|
||
|
|
(font-lock-ensure)
|
||
|
|
(when forge-post-fill-region
|
||
|
|
(when indent
|
||
|
|
(setq fill-column (- fill-column indent)))
|
||
|
|
(fill-region (point-min) (point-max)))
|
||
|
|
(when indent
|
||
|
|
(indent-rigidly (point-min) (point-max) indent))
|
||
|
|
(let* ((string (buffer-string))
|
||
|
|
(beg 0)
|
||
|
|
(end (length string)))
|
||
|
|
(while (< beg end)
|
||
|
|
(let ((pos (next-single-property-change beg 'face string end))
|
||
|
|
(val (get-text-property beg 'face string)))
|
||
|
|
(put-text-property beg pos 'font-lock-face val string)
|
||
|
|
(remove-list-of-text-properties beg pos '(face) string)
|
||
|
|
(setq beg pos)))
|
||
|
|
string)))
|
||
|
|
|
||
|
|
(defun forge--markdown-translate-filename-function (file)
|
||
|
|
(if (string-match-p "\\`https?://" file)
|
||
|
|
file
|
||
|
|
(let ((host (oref (forge-get-repository :tracked) forge))) ;aka webhost
|
||
|
|
(concat (if (member host ghub-insecure-hosts) "http://" "https://")
|
||
|
|
host
|
||
|
|
(and (not (string-prefix-p "/" file)) "/")
|
||
|
|
file))))
|
||
|
|
|
||
|
|
;;; Templates
|
||
|
|
|
||
|
|
(defun forge--topic-template (repo class)
|
||
|
|
(unless repo
|
||
|
|
(setq repo (forge-get-repository :tracked)))
|
||
|
|
(let* ((templates (forge--topic-templates repo class))
|
||
|
|
(template
|
||
|
|
(if (cdr templates)
|
||
|
|
(let ((c (magit-completing-read
|
||
|
|
(pcase class
|
||
|
|
('forge-issue "Select issue template")
|
||
|
|
('forge-pullreq "Select pull-request template"))
|
||
|
|
(mapcar (##alist-get 'prompt %) templates)
|
||
|
|
nil t)))
|
||
|
|
(seq-find (##equal (alist-get 'prompt %) c) templates))
|
||
|
|
(car templates))))
|
||
|
|
(if-let ((url (alist-get 'url template)))
|
||
|
|
(if (string-match (forge--format repo "\
|
||
|
|
\\`https://%h/[^/]+/[^/]+/discussions\\(?:/categories/\\(.+\\)\\)?")
|
||
|
|
url)
|
||
|
|
`((type . forge-discussion)
|
||
|
|
(category . ,(or (match-string 1 url)
|
||
|
|
(forge-read-topic-category
|
||
|
|
nil "Category for new discussion"))))
|
||
|
|
`((type . redirect) ,@template))
|
||
|
|
`((type . ,class) ,@template))))
|
||
|
|
|
||
|
|
(defun forge--topic-templates (repo class)
|
||
|
|
(mapcan (lambda (file)
|
||
|
|
(with-temp-buffer
|
||
|
|
(magit-git-insert "cat-file" "-p" file)
|
||
|
|
(if (equal (file-name-nondirectory file) "config.yml")
|
||
|
|
(forge--topic-parse-template-config)
|
||
|
|
(list (forge--topic-parse-template (file-name-base file))))))
|
||
|
|
(forge--topic-template-files repo class)))
|
||
|
|
|
||
|
|
(cl-defgeneric forge--topic-template-files (repo class))
|
||
|
|
|
||
|
|
(defun forge--topic-template-files-1 (repo suffix &rest paths)
|
||
|
|
(setq suffix (ensure-list suffix))
|
||
|
|
(let ((branch (forge--get-default-branch repo)))
|
||
|
|
(seq-keep (if suffix
|
||
|
|
(##and (member (file-name-extension %) suffix)
|
||
|
|
(concat branch ":" %))
|
||
|
|
(##concat branch ":" %))
|
||
|
|
(magit-git-items "ls-tree" "-z"
|
||
|
|
"--full-tree" "--name-only"
|
||
|
|
(and suffix "-r")
|
||
|
|
branch "--" paths))))
|
||
|
|
|
||
|
|
(defun forge--topic-parse-template-config ()
|
||
|
|
(let-alist (yaml-parse-string (buffer-str)
|
||
|
|
:object-type 'alist
|
||
|
|
:sequence-type 'list)
|
||
|
|
(nconc
|
||
|
|
(and (not (eq .blank_issues_enabled :false)) ;unset means true
|
||
|
|
`(((prompt . ,(concat (propertize "Blank issue" 'face 'bold)
|
||
|
|
" — Create a new issue from scratch")))))
|
||
|
|
(mapcar (lambda (link)
|
||
|
|
`(,@link
|
||
|
|
(prompt . ,(let-alist link
|
||
|
|
(concat (propertize .name 'face 'bold)
|
||
|
|
" — " .about)))))
|
||
|
|
.contact_links))))
|
||
|
|
|
||
|
|
(defun forge--topic-parse-template (name)
|
||
|
|
(goto-char (point-min))
|
||
|
|
(skip-chars-forward "\s\t\n\r")
|
||
|
|
(if-let ((beg (and (looking-at "^---[\s\t]*$")
|
||
|
|
(point)))
|
||
|
|
(end (and (zerop (forward-line))
|
||
|
|
(re-search-forward "^---[\s\t]*$" nil t)
|
||
|
|
(match-beginning 0)))
|
||
|
|
(repoid (oref (forge-get-repository :tracked) id)))
|
||
|
|
(let-alist (yaml-parse-string (buffer-str beg end)
|
||
|
|
:object-type 'alist
|
||
|
|
:sequence-type 'list
|
||
|
|
:null-object nil
|
||
|
|
:false-object nil)
|
||
|
|
(when (stringp .name)
|
||
|
|
(setq name .name))
|
||
|
|
(setq name (propertize .name 'face 'bold))
|
||
|
|
`((prompt . ,(if .about (format "%s — %s" name .about) name))
|
||
|
|
(title . ,(and .title
|
||
|
|
(stringp .title)
|
||
|
|
(string-trim .title)))
|
||
|
|
(text . ,(string-trim (buffer-str (point))))
|
||
|
|
;; Prevent ad hock creation or previously unknown labels.
|
||
|
|
(labels . ,(cl-intersection
|
||
|
|
(ensure-list .labels)
|
||
|
|
(forge-sql-car [:select name :from label
|
||
|
|
:where (= repository $s1)]
|
||
|
|
repoid)
|
||
|
|
:test #'equal))
|
||
|
|
;; Server errors on invalid assignees.
|
||
|
|
(assignees . ,(cl-intersection
|
||
|
|
(ensure-list .assignees)
|
||
|
|
(forge-sql-car [:select login :from assignee
|
||
|
|
:where (= repository $s1)]
|
||
|
|
repoid)
|
||
|
|
:test #'equal))
|
||
|
|
(draft . ,(and (booleanp .draft) .draft))))
|
||
|
|
`((prompt . ,(propertize name 'face 'bold))
|
||
|
|
(text . ,(string-trim (buffer-str))))))
|
||
|
|
|
||
|
|
;;; Bug-Reference
|
||
|
|
|
||
|
|
(defvar forge-bug-reference-remote-files t
|
||
|
|
"Whether forge may enable `bug-reference-mode' in remote files.
|
||
|
|
See also `forge-bug-reference-setup'.")
|
||
|
|
|
||
|
|
(defun forge-bug-reference-setup ()
|
||
|
|
"Setup `bug-reference' in the current buffer.
|
||
|
|
If forge data has been fetched for the current repository, then
|
||
|
|
enable `bug-reference-mode' or `bug-reference-prog-mode' and
|
||
|
|
modify `bug-reference-bug-regexp' if appropriate."
|
||
|
|
(unless (or bug-reference-url-format
|
||
|
|
(not (forge-db t))
|
||
|
|
(and buffer-file-name
|
||
|
|
(not forge-bug-reference-remote-files)
|
||
|
|
(file-remote-p buffer-file-name))
|
||
|
|
;; TODO Allow use in these modes again.
|
||
|
|
(derived-mode-p 'forge-topics-mode 'forge-notifications-mode))
|
||
|
|
(magit--with-safe-default-directory nil
|
||
|
|
(when-let ((repo (forge-get-repository :tracked?)))
|
||
|
|
(when (derived-mode-p 'magit-status-mode
|
||
|
|
'forge-notifications-mode)
|
||
|
|
(setq-local
|
||
|
|
bug-reference-auto-setup-functions
|
||
|
|
(let ((hook bug-reference-auto-setup-functions))
|
||
|
|
(list (lambda ()
|
||
|
|
;; HOOK is not allowed to be a lexical var:
|
||
|
|
;; (run-hook-with-args-until-success 'hook)
|
||
|
|
(catch 'success
|
||
|
|
(dolist (f hook)
|
||
|
|
(when (funcall f)
|
||
|
|
(setq bug-reference-bug-regexp
|
||
|
|
(concat "." bug-reference-bug-regexp))
|
||
|
|
(throw 'success t)))))))))
|
||
|
|
(if (derived-mode-p 'prog-mode)
|
||
|
|
(bug-reference-prog-mode 1)
|
||
|
|
(bug-reference-mode 1))
|
||
|
|
(add-hook 'completion-at-point-functions
|
||
|
|
#'forge-topic-completion-at-point nil t)))))
|
||
|
|
|
||
|
|
(unless noninteractive
|
||
|
|
(dolist (hook forge-bug-reference-hooks)
|
||
|
|
(add-hook hook #'forge-bug-reference-setup t)))
|
||
|
|
|
||
|
|
;;; _
|
||
|
|
;; Local Variables:
|
||
|
|
;; read-symbol-shorthands: (
|
||
|
|
;; ("and$" . "cond-let--and$")
|
||
|
|
;; ("thread$" . "cond-let--thread$")
|
||
|
|
;; ("when$" . "cond-let--when$")
|
||
|
|
;; ("and-let*" . "cond-let--and-let*")
|
||
|
|
;; ("and-let" . "cond-let--and-let")
|
||
|
|
;; ("if-let*" . "cond-let--if-let*")
|
||
|
|
;; ("if-let" . "cond-let--if-let")
|
||
|
|
;; ("when-let*" . "cond-let--when-let*")
|
||
|
|
;; ("when-let" . "cond-let--when-let")
|
||
|
|
;; ("while-let*" . "cond-let--while-let*")
|
||
|
|
;; ("while-let" . "cond-let--while-let")
|
||
|
|
;; ("buffer-string" . "buffer-string")
|
||
|
|
;; ("buffer-str" . "forge--buffer-substring-no-properties")
|
||
|
|
;; ("partial" . "llama--left-apply-partially"))
|
||
|
|
;; End:
|
||
|
|
(provide 'forge-topic)
|
||
|
|
;;; forge-topic.el ends here
|