aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el99
-rw-r--r--tests/test-pearl-format.el17
-rw-r--r--tests/test-pearl-heading.el117
-rw-r--r--tests/test-pearl-org-write.el4
-rw-r--r--tests/test-pearl-title-sync.el23
5 files changed, 241 insertions, 19 deletions
diff --git a/pearl.el b/pearl.el
index a1f6c9b..d0f21f4 100644
--- a/pearl.el
+++ b/pearl.el
@@ -185,6 +185,82 @@ leave the buffer expanded after updates."
:type 'boolean
:group 'pearl)
+(defcustom pearl-title-case-headings t
+ "When non-nil, render issue titles in the heading in smart title case.
+Linear stores titles however they were typed (often sentence case); this
+capitalizes each word for a tidier outline, keeping minor words (articles,
+short conjunctions and prepositions) lowercase unless they are first or last,
+and leaving words that already contain an uppercase letter (acronyms like API,
+identifiers, camelCase) untouched. Display-only: the title's provenance hash
+is taken over the rendered form, so an unedited title is still a no-op on sync
+and is never rewritten on Linear. Set to nil to render titles verbatim."
+ :type 'boolean
+ :group 'pearl)
+
+(defcustom pearl-show-identifier-in-heading t
+ "When non-nil, prefix the issue heading title with the Linear identifier.
+For example `** TODO [#B] SE-401: Fix the bug'. Display-only: title sync
+strips the `IDENT: ' prefix before hashing and pushing, so the identifier
+never leaks into the title on Linear. Set to nil to omit the prefix."
+ :type 'boolean
+ :group 'pearl)
+
+(defconst pearl--title-case-minor-words
+ '("a" "an" "and" "as" "at" "but" "by" "for" "if" "in" "nor" "of" "on" "or"
+ "per" "the" "to" "vs" "via")
+ "Words kept lowercase by `pearl--title-case' unless first or last.")
+
+(defun pearl--title-case (title)
+ "Return TITLE in smart title case.
+Each word is capitalized except minor words (see
+`pearl--title-case-minor-words') in non-edge positions; a word that already
+contains an uppercase letter is left as-is so acronyms and identifiers survive.
+Internal whitespace is normalized to single spaces."
+ (let* ((words (split-string title))
+ (last (1- (length words)))
+ (case-fold-search nil)) ; so [[:upper:]] is genuinely upper-only
+ (mapconcat
+ (lambda (cell)
+ (let ((i (car cell)) (w (cdr cell)))
+ (cond
+ ((string-match-p "[[:upper:]]" w) w)
+ ((and (/= i 0) (/= i last)
+ (member (downcase w) pearl--title-case-minor-words))
+ (downcase w))
+ ;; upcase only the first char (not `capitalize', which treats an
+ ;; apostrophe/hyphen as a word break: "don't" -> "Don'T")
+ ((string-empty-p w) w)
+ (t (concat (upcase (substring w 0 1)) (substring w 1))))))
+ (let ((i -1)) (mapcar (lambda (w) (cons (cl-incf i) w)) words))
+ " ")))
+
+(defun pearl--heading-title (issue)
+ "Return the displayed heading title for ISSUE (the form the renderer writes).
+Brackets are stripped (they break Org parsing); title case is applied when
+`pearl-title-case-headings' is non-nil. This is the bare title without the
+identifier prefix -- the form the title provenance hash is taken over."
+ (let ((stripped (replace-regexp-in-string
+ "\\[\\|\\]" "" (or (plist-get issue :title) ""))))
+ (if pearl-title-case-headings (pearl--title-case stripped) stripped)))
+
+(defun pearl--heading-with-identifier (display-title identifier)
+ "Prefix DISPLAY-TITLE with \"IDENTIFIER: \" when prefixing is enabled.
+Returns DISPLAY-TITLE unchanged when `pearl-show-identifier-in-heading' is nil
+or IDENTIFIER is empty."
+ (if (and pearl-show-identifier-in-heading
+ identifier (not (string-empty-p identifier)))
+ (format "%s: %s" identifier display-title)
+ display-title))
+
+(defun pearl--strip-identifier-prefix (heading identifier)
+ "Strip a leading \"IDENTIFIER: \" prefix from HEADING when present.
+Returns HEADING unchanged when IDENTIFIER is empty or absent from the front."
+ (let ((prefix (and identifier (not (string-empty-p identifier))
+ (concat identifier ": "))))
+ (if (and prefix (string-prefix-p prefix heading))
+ (substring heading (length prefix))
+ heading)))
+
(defun pearl--hide-all-drawers ()
"Collapse every property drawer in the current buffer, across Org versions."
(cond ((fboundp 'org-fold-hide-drawer-all) (org-fold-hide-drawer-all))
@@ -1708,21 +1784,23 @@ description renders as the entry body. `LINEAR-DESC-SHA256' (the markdown) and
conflict gates; `LINEAR-DESC-ORG-SHA256' hashes the rendered Org body so a
later refresh can tell a real local edit from a lossy md->org round-trip
without re-deriving the markdown. `LINEAR-TITLE-SHA256' is the title's hash
-(over the rendered, bracket-stripped title)."
- (let* ((title (or (plist-get issue :title) ""))
- (description (or (plist-get issue :description) ""))
+(over the rendered title -- bracket-stripped and title-cased, without the
+identifier prefix -- so an unedited heading is a no-op on title sync)."
+ (let* ((description (or (plist-get issue :description) ""))
(state (plist-get issue :state))
(team (plist-get issue :team))
(project (plist-get issue :project))
(assignee (plist-get issue :assignee))
(todo (pearl--map-linear-state-to-org (plist-get state :name)))
(priority (pearl--map-linear-priority-to-org (plist-get issue :priority)))
- (sanitized-title (replace-regexp-in-string "\\[\\|\\]" "" title))
+ (display-title (pearl--heading-title issue))
+ (heading-title (pearl--heading-with-identifier
+ display-title (plist-get issue :identifier)))
(label-names (mapconcat (lambda (l) (or (plist-get l :name) ""))
(plist-get issue :labels) ", "))
(body-org (pearl--md-to-org description)))
(concat
- (format "** %s %s %s\n" todo priority sanitized-title)
+ (format "** %s %s %s\n" todo priority heading-title)
":PROPERTIES:\n"
(format ":LINEAR-ID: %s\n" (or (plist-get issue :id) ""))
(format ":LINEAR-IDENTIFIER: %s\n" (or (plist-get issue :identifier) ""))
@@ -1741,7 +1819,7 @@ without re-deriving the markdown. `LINEAR-TITLE-SHA256' is the title's hash
(format ":LINEAR-DESC-SHA256: %s\n" (secure-hash 'sha256 description))
(format ":LINEAR-DESC-ORG-SHA256: %s\n" (secure-hash 'sha256 (string-trim body-org)))
(format ":LINEAR-DESC-UPDATED-AT: %s\n" (or (plist-get issue :updated-at) ""))
- (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 sanitized-title))
+ (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 display-title))
":END:\n"
(if (string-empty-p body-org) "" (concat body-org "\n"))
(pearl--format-comments (plist-get issue :comments)))))
@@ -2041,11 +2119,14 @@ CALLBACK is called with a plist (:success BOOL :updated-at STR)."
(defun pearl--issue-title-at-point ()
"Return the title of the Linear issue heading at point.
-Strips the TODO keyword, priority cookie, and tags, leaving the bare title
-text (which is the bracket-stripped form the renderer wrote)."
+Strips the TODO keyword, priority cookie, and tags, then strips the
+`IDENT: ' identifier prefix the renderer may have added, leaving the bare
+title text (the form the renderer hashed into `LINEAR-TITLE-SHA256')."
(save-excursion
(org-back-to-heading t)
- (org-get-heading t t t t)))
+ (pearl--strip-identifier-prefix
+ (org-get-heading t t t t)
+ (org-entry-get nil "LINEAR-IDENTIFIER"))))
(defun pearl--goto-heading-or-error (&optional message)
"Move point to the enclosing heading, or signal a `user-error'.
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
index 7310413..1e9fa53 100644
--- a/tests/test-pearl-format.el
+++ b/tests/test-pearl-format.el
@@ -56,7 +56,7 @@
"A full issue renders the heading and the namespaced LINEAR-* drawer."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-full))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
(should (string-match-p "^:LINEAR-ID: +uuid-1$" out))
(should (string-match-p "^:LINEAR-IDENTIFIER: +ENG-42$" out))
(should (string-match-p "^:LINEAR-STATE-NAME: +In Progress$" out))
@@ -80,7 +80,7 @@
"Null/missing optional fields render as empty values, and the body is empty."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-bare))))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out))
(should (string-match-p "^:LINEAR-PROJECT-NAME: +$" out))
(should (string-match-p "^:LINEAR-ASSIGNEE-NAME: +$" out))
(should (string-match-p "^:LINEAR-LABELS: +\\[\\]$" out))
@@ -93,11 +93,12 @@
(let ((out (pearl--format-issue-as-org-entry
'(:id "u" :identifier "ENG-1" :title "Fix [URGENT] bug"
:priority 1 :state (:name "Todo")))))
- (should (string-match-p "^\\*\\* TODO \\[#A\\] Fix URGENT bug$" out))
- ;; the title provenance hash is of the stripped (rendered) title, so a
- ;; later no-op title sync matches the heading and never clobbers brackets
+ (should (string-match-p "^\\*\\* TODO \\[#A\\] ENG-1: Fix URGENT Bug$" out))
+ ;; the title provenance hash is of the displayed (stripped + cased) title
+ ;; without the identifier prefix, so a later no-op title sync matches the
+ ;; heading and never clobbers brackets or pushes the prefix
(should (string-match-p
- (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT bug"))
+ (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT Bug"))
out)))))
;;; build-org-content
@@ -147,8 +148,8 @@ sort together (org-sort on the parent) instead of being orphan headings."
(test-pearl--with-default-mapping
(let ((out (pearl--build-org-content
(list (test-pearl--norm-full) (test-pearl--norm-bare)))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out)))))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out)))))
;;; --restore-page-visibility
diff --git a/tests/test-pearl-heading.el b/tests/test-pearl-heading.el
new file mode 100644
index 0000000..2675518
--- /dev/null
+++ b/tests/test-pearl-heading.el
@@ -0,0 +1,117 @@
+;;; test-pearl-heading.el --- Tests for heading title rendering -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program 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 program 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 program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the heading title transforms: smart title case
+;; (`pearl--title-case'), the identifier prefix (`pearl--heading-with-identifier'
+;; / `pearl--strip-identifier-prefix'), and the way `--format-issue-as-org-entry'
+;; renders them while keeping `LINEAR-TITLE-SHA256' over the bare displayed
+;; title so an unedited heading is a no-op on title sync.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; --title-case
+
+(ert-deftest test-pearl-title-case-capitalizes-significant-words ()
+ "Significant words are capitalized; minor words mid-title stay lowercase."
+ (should (string= "Fix the Refresh Bug" (pearl--title-case "fix the refresh bug")))
+ (should (string= "A Tale of Two Cities" (pearl--title-case "a tale of two cities"))))
+
+(ert-deftest test-pearl-title-case-edges-capitalize-minor-words ()
+ "A minor word that is first or last is still capitalized."
+ (should (string= "Of Mice and Men" (pearl--title-case "of mice and men")))
+ (should (string= "What Is It For" (pearl--title-case "what is it for"))))
+
+(ert-deftest test-pearl-title-case-preserves-existing-uppercase ()
+ "A word that already has an uppercase letter (acronym, identifier) is left as-is."
+ (should (string= "API Rate Limits" (pearl--title-case "API rate limits")))
+ (should (string= "GraphQL and You" (pearl--title-case "GraphQL and you"))))
+
+(ert-deftest test-pearl-title-case-boundaries ()
+ "Empty, single-word, and extra-whitespace inputs behave."
+ (should (string= "" (pearl--title-case "")))
+ (should (string= "Bug" (pearl--title-case "bug")))
+ (should (string= "Fix the Bug" (pearl--title-case "fix the bug"))))
+
+(ert-deftest test-pearl-title-case-leaves-inner-punctuation-alone ()
+ "Only the first letter is upcased, so an apostrophe or hyphen mid-word is intact."
+ (should (string= "Don't Panic" (pearl--title-case "don't panic")))
+ (should (string= "Re-run the Task" (pearl--title-case "re-run the task"))))
+
+;;; --heading-with-identifier / --strip-identifier-prefix
+
+(ert-deftest test-pearl-heading-identifier-prefix-roundtrips ()
+ "Adding then stripping the identifier prefix is the identity."
+ (let ((pearl-show-identifier-in-heading t))
+ (let ((h (pearl--heading-with-identifier "Fix the Bug" "SE-401")))
+ (should (string= "SE-401: Fix the Bug" h))
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix h "SE-401"))))))
+
+(ert-deftest test-pearl-heading-identifier-prefix-disabled ()
+ "With prefixing off, the title is returned unchanged."
+ (let ((pearl-show-identifier-in-heading nil))
+ (should (string= "Fix the Bug"
+ (pearl--heading-with-identifier "Fix the Bug" "SE-401")))))
+
+(ert-deftest test-pearl-strip-identifier-prefix-empty-or-absent ()
+ "Stripping is a no-op when the identifier is empty or not at the front."
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix "Fix the Bug" "")))
+ (should (string= "Fix the Bug" (pearl--strip-identifier-prefix "Fix the Bug" nil)))
+ ;; an identifier that only appears mid-title is not stripped
+ (should (string= "see SE-9: later"
+ (pearl--strip-identifier-prefix "see SE-9: later" "SE-9"))))
+
+;;; rendering on / off
+
+(ert-deftest test-pearl-format-heading-defaults-prefix-and-title-case ()
+ "By default the heading carries the identifier prefix and a title-cased title."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] SE-401: Fix the Refresh Bug$" out)))))
+
+(ert-deftest test-pearl-format-heading-both-toggles-off-renders-verbatim ()
+ "With both toggles off, the heading is the bracket-stripped raw title, no prefix."
+ (let ((pearl-show-identifier-in-heading nil)
+ (pearl-title-case-headings nil))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] fix the refresh bug$" out)))))
+
+(ert-deftest test-pearl-format-title-hash-is-over-displayed-title-no-prefix ()
+ "The title hash is over the displayed (cased) title without the identifier prefix.
+That is what makes a fetch + unedited heading a no-op on title sync."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))))
+ (should (string-match-p
+ (format "^:LINEAR-TITLE-SHA256: +%s$"
+ (secure-hash 'sha256 "Fix the Refresh Bug"))
+ out)))))
+
+(provide 'test-pearl-heading)
+;;; test-pearl-heading.el ends here
diff --git a/tests/test-pearl-org-write.el b/tests/test-pearl-org-write.el
index bd06ef0..6491b54 100644
--- a/tests/test-pearl-org-write.el
+++ b/tests/test-pearl-org-write.el
@@ -55,7 +55,7 @@ The state mapping is bound so rendering is deterministic."
(let ((content (with-temp-buffer (insert-file-contents tmp) (buffer-string))))
(should (string-match-p "#\\+title: Linear" content))
(should (string-match-p "#\\+LINEAR-SOURCE: " content))
- (should (string-match-p "\\*\\* TODO \\[#C\\] T" content)))))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] ENG-1: T" content)))))
(ert-deftest test-pearl-update-org-clean-buffer-replaces-contents ()
"A clean visiting buffer is replaced in place and saved."
@@ -67,7 +67,7 @@ The state mapping is bound so rendering is deterministic."
(pearl--update-org-from-issues test-pearl--sample-issues)
(with-current-buffer buf
(should-not (buffer-modified-p))
- (should (string-match-p "\\*\\* TODO \\[#C\\] T" (buffer-string)))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] ENG-1: T" (buffer-string)))
(should-not (string-match-p "old content" (buffer-string)))))))
(ert-deftest test-pearl-update-org-dirty-buffer-not-overwritten ()
diff --git a/tests/test-pearl-title-sync.el b/tests/test-pearl-title-sync.el
index 512794c..fc84a15 100644
--- a/tests/test-pearl-title-sync.el
+++ b/tests/test-pearl-title-sync.el
@@ -53,6 +53,29 @@
"*** TODO [#B] My issue title :tag:\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
(should (string= "My issue title" (pearl--issue-title-at-point)))))
+(ert-deftest test-pearl-issue-title-strips-identifier-prefix ()
+ "The extractor strips the rendered `IDENT: ' prefix using the drawer identifier."
+ (test-pearl--in-org
+ "*** TODO [#B] SE-401: Fix the Bug\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-IDENTIFIER: SE-401\n:END:\n"
+ (should (string= "Fix the Bug" (pearl--issue-title-at-point)))))
+
+(ert-deftest test-pearl-title-render-read-roundtrip-is-noop ()
+ "Rendering an issue then reading its heading back hashes to the stored title hash.
+This is the property that keeps a fetch + unedited heading from pushing the
+title-cased / identifier-prefixed display form to Linear."
+ (let ((pearl-show-identifier-in-heading t)
+ (pearl-title-case-headings t))
+ (test-pearl--in-org
+ (pearl--format-issue-as-org-entry
+ '(:id "a" :identifier "SE-401" :title "fix the refresh bug"
+ :priority 2 :state (:name "Todo")))
+ (goto-char (point-min))
+ (re-search-forward "SE-401")
+ (let ((stored (org-entry-get nil "LINEAR-TITLE-SHA256"))
+ (read-back (pearl--issue-title-at-point)))
+ (should (string= "Fix the Refresh Bug" read-back))
+ (should (string= stored (secure-hash 'sha256 read-back)))))))
+
;;; network helpers
(ert-deftest test-pearl-fetch-issue-title-parses-payload ()