From d03d5582197def92ad72e113815a3c4836da1330 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 24 May 2026 16:43:00 -0500 Subject: feat: render issue headings with the identifier prefix and title case I added two display-only heading tweaks, each a defcustom defaulting on. pearl-show-identifier-in-heading prefixes the title with the Linear identifier (** TODO [#B] SE-401: Fix the Bug). pearl-title-case-headings renders the title in smart title case, keeping minor words lowercase unless first or last and leaving words that already carry an uppercase letter (acronyms, identifiers) untouched. Both stay display-only. The LINEAR-TITLE-SHA256 hash covers the rendered (cased, un-prefixed) title, and the title-sync reader strips the identifier prefix before hashing, so an unedited heading is a no-op and neither the casing nor the prefix ever pushes to Linear. A render-then-read round-trip test locks that invariant. --- pearl.el | 99 ++++++++++++++++++++++++++++++---- tests/test-pearl-format.el | 17 +++--- tests/test-pearl-heading.el | 117 +++++++++++++++++++++++++++++++++++++++++ tests/test-pearl-org-write.el | 4 +- tests/test-pearl-title-sync.el | 23 ++++++++ 5 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 tests/test-pearl-heading.el 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 + +;; 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 . + +;;; 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 () -- cgit v1.2.3