diff options
| -rw-r--r-- | pearl.el | 99 | ||||
| -rw-r--r-- | tests/test-pearl-format.el | 17 | ||||
| -rw-r--r-- | tests/test-pearl-heading.el | 117 | ||||
| -rw-r--r-- | tests/test-pearl-org-write.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-title-sync.el | 23 |
5 files changed, 241 insertions, 19 deletions
@@ -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 () |
