about summary refs log tree commit diff
path: root/users/glittershark/org-clubhouse/org-clubhouse.el
diff options
context:
space:
mode:
Diffstat (limited to 'users/glittershark/org-clubhouse/org-clubhouse.el')
-rw-r--r--users/glittershark/org-clubhouse/org-clubhouse.el1241
1 files changed, 0 insertions, 1241 deletions
diff --git a/users/glittershark/org-clubhouse/org-clubhouse.el b/users/glittershark/org-clubhouse/org-clubhouse.el
deleted file mode 100644
index e6e29b5751..0000000000
--- a/users/glittershark/org-clubhouse/org-clubhouse.el
+++ /dev/null
@@ -1,1241 +0,0 @@
-;;; org-clubhouse.el --- Simple, unopinionated integration between org-mode and
-;;; Clubhouse
-
-;;; Copyright (C) 2018 Off Market Data, Inc. DBA Urbint
-;;; Permission is hereby granted, free of charge, to any person obtaining a copy
-;;; of this software and associated documentation files (the "Software"), to
-;;; deal in the Software without restriction, including without limitation the
-;;; rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-;;; sell copies of the Software, and to permit persons to whom the Software is
-;;; furnished to do so, subject to the following conditions:
-
-;;; The above copyright notice and this permission notice shall be included in
-;;; all copies or substantial portions of the Software.
-
-;;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-;;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-;;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-;;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-;;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-;;; FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-;;; IN THE SOFTWARE.
-
-;;; Commentary:
-;;; org-clubhouse provides simple, unopinionated integration between Emacs's
-;;; org-mode and the Clubhouse issue tracker
-;;;
-;;; To configure org-clubhouse, create an authorization token in Cluhbouse's
-;;; settings, then place the following configuration somewhere private:
-;;;
-;;;   (setq org-clubhouse-auth-token "<auth_token>"
-;;;         org-clubhouse-team-name  "<team-name>")
-;;;
-
-;;; Code:
-
-(require 'cl-macs)
-(require 'dash)
-(require 'dash-functional)
-(require 's)
-(require 'org)
-(require 'org-element)
-(require 'subr-x)
-(require 'ivy)
-(require 'json)
-
-;;;
-;;; Configuration
-;;;
-
-(defvar org-clubhouse-auth-token nil
-  "Authorization token for the Clubhouse API.")
-
-(defvar org-clubhouse-username nil
-  "Username for the current Clubhouse user.
-
-Unfortunately, the Clubhouse API doesn't seem to provide this via the API given
-an API token, so we need to configure this for
-`org-clubhouse-claim-story-on-status-updates' to work")
-
-(defvar org-clubhouse-team-name nil
-  "Team name to use in links to Clubhouse.
-ie https://app.clubhouse.io/<TEAM_NAME>/stories")
-
-(defvar org-clubhouse-project-ids nil
-  "Specific list of project IDs to synchronize with clubhouse.
-If unset all projects will be synchronized")
-
-(defvar org-clubhouse-workflow-name "Default")
-
-(defvar org-clubhouse-default-story-type nil
-  "Sets the default story type. If set to 'nil', it will interactively prompt
-the user each and every time a new story is created. If set to 'feature',
-'bug', or 'chore', that value will be used as the default and the user will
-not be prompted")
-
-(defvar org-clubhouse-state-alist
-  '(("LATER"  . "Unscheduled")
-    ("[ ]"    . "Ready for Development")
-    ("TODO"   . "Ready for Development")
-    ("OPEN"   . "Ready for Development")
-    ("ACTIVE" . "In Development")
-    ("PR"     . "Review")
-    ("DONE"   . "Merged")
-    ("[X]"    . "Merged")
-    ("CLOSED" . "Merged"))
-  "Alist mapping org-mode todo keywords to their corresponding states in
-  Clubhouse. In `org-clubhouse-mode', moving headlines to these todo keywords
-  will update to the corresponding status in Clubhouse")
-
-(defvar org-clubhouse-story-types
-  '(("feature" . "Feature")
-    ("bug"     . "Bug")
-    ("chore"   . "Chore")))
-
-(defvar org-clubhouse-default-story-types
-  '(("feature" . "Feature")
-    ("bug"     . "Bug")
-    ("chore"   . "Chore")
-    ("prompt"  . "**Prompt each time (do not set a default story type)**")))
-
-(defvar org-clubhouse-default-state "Proposed"
-  "Default state to create all new stories in.")
-
-(defvar org-clubhouse-claim-story-on-status-update 't
-  "Controls the assignee behavior of stories on status update.
-
-If set to 't, will mark the current user as the owner of any clubhouse
-stories on any update to the status.
-
-If set to nil, will never automatically update the assignee of clubhouse
-stories.
-
-If set to a list of todo-state's, will mark the current user as the owner of
-clubhouse stories whenever updating the status to one of those todo states.")
-
-(defvar org-clubhouse-create-stories-with-labels nil
-  "Controls the way org-clubhouse creates stories with labels based on org tags.
-
-If set to 't, will create labels for all org tags on headlines when stories are
-created.
-
-If set to 'existing, will set labels on created stories only if the label
-already exists in clubhouse
-
-If set to nil, will never create stories with labels")
-
-;;;
-;;; Utilities
-;;;
-
-(defmacro comment (&rest _)
-  "Comment out one or more s-expressions."
-  nil)
-
-(defun ->list (vec) (append vec nil))
-
-(defun reject-archived (item-list)
-  (-reject (lambda (item) (equal :json-true (alist-get 'archived item))) item-list))
-
-(defun alist->plist (key-map alist)
-  (->> key-map
-       (-map (lambda (key-pair)
-               (let ((alist-key (car key-pair))
-                     (plist-key (cdr key-pair)))
-                 (list plist-key (alist-get alist-key alist)))))
-       (-flatten-n 1)))
-
-(defun alist-get-equal (key alist)
-  "Like `alist-get', but uses `equal' instead of `eq' for comparing keys"
-  (->> alist
-       (-find (lambda (pair) (equal key (car pair))))
-       (cdr)))
-
-(defun invert-alist (alist)
-  "Invert the keys and values of ALIST."
-  (-map (lambda (cell) (cons (cdr cell) (car cell))) alist))
-
-(comment
-
- (alist->plist
-  '((foo . :foo)
-    (bar . :something))
-
-  '((foo . "foo") (bar . "bar") (ignored . "ignoreme!")))
- ;; => (:foo "foo" :something "bar")
-
- )
-
-(defun find-match-in-alist (target alist)
-  (->> alist
-       (-find (lambda (key-value)
-                   (string-equal (cdr key-value) target)))
-       car))
-
-(defun org-clubhouse-collect-headlines (beg end)
-  "Collects the headline at point or the headlines in a region. Returns a list."
-  (if (and beg end)
-      (org-clubhouse-get-headlines-in-region beg end)
-    (list (org-element-find-headline))))
-
-
-(defun org-clubhouse-get-headlines-in-region (beg end)
-  "Collects the headlines from BEG to END"
-  (save-excursion
-    ;; This beg/end clean up pulled from `reverse-region`.
-    ;; it expands the region to include the full lines from the selected region.
-
-    ;; put beg at the start of a line and end and the end of one --
-    ;; the largest possible region which fits this criteria
-    (goto-char beg)
-    (or (bolp) (forward-line 1))
-    (setq beg (point))
-    (goto-char end)
-    ;; the test for bolp is for those times when end is on an empty line;
-    ;; it is probably not the case that the line should be included in the
-    ;; reversal; it isn't difficult to add it afterward.
-    (or (and (eolp) (not (bolp))) (progn (forward-line -1) (end-of-line)))
-    (setq end (point-marker))
-
-    ;; move to the beginning
-    (goto-char beg)
-    ;; walk by line until past end
-    (let ((headlines '())
-          (before-end 't))
-      (while before-end
-        (add-to-list 'headlines (org-element-find-headline))
-        (let ((before (point)))
-          (org-forward-heading-same-level 1)
-          (setq before-end (and (not (eq before (point))) (< (point) end)))))
-      (reverse headlines))))
-
-;;;
-;;; Org-element interaction
-;;;
-
-;; (defun org-element-find-headline ()
-;;   (let ((current-elt (org-element-at-point)))
-;;     (if (equal 'headline (car current-elt))
-;;         current-elt
-;;       (let* ((elt-attrs (cadr current-elt))
-;;              (parent (plist-get elt-attrs :post-affiliated)))
-;;         (goto-char parent)
-;;         (org-element-find-headline)))))
-
-(defun org-element-find-headline ()
-  (save-mark-and-excursion
-    (when (not (outline-on-heading-p)) (org-back-to-heading))
-    (let ((current-elt (org-element-at-point)))
-      (when (equal 'headline (car current-elt))
-        (cadr current-elt)))))
-
-(defun org-element-extract-clubhouse-id (elt &optional property)
-  (when-let* ((clubhouse-id-link (plist-get elt (or property :CLUBHOUSE-ID))))
-    (cond
-     ((string-match
-       (rx "[[" (one-or-more anything) "]"
-           "[" (group (one-or-more digit)) "]]")
-       clubhouse-id-link)
-      (string-to-number (match-string 1 clubhouse-id-link)))
-     ((string-match-p
-       (rx buffer-start
-           (one-or-more digit)
-           buffer-end)
-       clubhouse-id-link)
-      (string-to-number clubhouse-id-link)))))
-
-(comment
- (let ((strn "[[https://app.clubhouse.io/example/story/2330][2330]]"))
-   (string-match
-    (rx "[[" (one-or-more anything) "]"
-        "[" (group (one-or-more digit)) "]]")
-    strn)
-   (string-to-number (match-string 1 strn)))
- )
-
-(defun org-element-clubhouse-id ()
-  (org-element-extract-clubhouse-id
-   (org-element-find-headline)))
-
-(defun org-clubhouse-clocked-in-story-id ()
-  "Return the clubhouse story-id of the currently clocked-in org entry, if any."
-  (save-mark-and-excursion
-    (save-current-buffer
-      (when (org-clocking-p)
-        (set-buffer (marker-buffer org-clock-marker))
-        (save-restriction
-          (when (or (< org-clock-marker (point-min))
-                    (> org-clock-marker (point-max)))
-            (widen))
-          (goto-char org-clock-marker)
-          (org-element-clubhouse-id))))))
-
-(comment
- (org-clubhouse-clocked-in-story-id)
- )
-
-(defun org-element-and-children-at-point ()
-  (let* ((elt (org-element-find-headline))
-         (contents-begin (or (plist-get elt :contents-begin)
-                             (plist-get elt :begin)))
-         (end   (plist-get elt :end))
-         (level (plist-get elt :level))
-         (children '()))
-    (save-excursion
-      (goto-char (+ contents-begin (length (plist-get elt :title))))
-      (while (< (point) end)
-        (let* ((next-elt (org-element-at-point))
-               (elt-type (car next-elt))
-               (elt      (cadr next-elt)))
-          (when (and (eql 'headline elt-type)
-                     (eql (+ 1 level) (plist-get elt :level)))
-            (push elt children))
-          (goto-char (plist-get elt :end)))))
-    (append elt `(:children ,(reverse children)))))
-
-(defun +org-element-contents (elt)
-  (if-let ((begin (plist-get (cadr elt) :contents-begin))
-           (end (plist-get (cadr elt) :contents-end)))
-      (buffer-substring-no-properties begin end)
-    ""))
-
-(defun org-clubhouse-find-description-drawer ()
-  "Try to find a DESCRIPTION drawer in the current element."
-  (let ((elt (org-element-at-point)))
-    (cl-case (car elt)
-      ('drawer (+org-element-contents elt))
-      ('headline
-       (when-let ((drawer-pos (string-match
-                               ":DESCRIPTION:"
-                               (+org-element-contents elt))))
-         (save-excursion
-           (goto-char (+ (plist-get (cadr elt) :contents-begin)
-                         drawer-pos))
-           (org-clubhouse-find-description-drawer)))))))
-
-(defun org-clubhouse--labels-for-elt (elt)
-  "Return the Clubhouse labels based on the tags of ELT and the user's config."
-  (unless (eq nil org-clubhouse-create-stories-with-labels)
-    (let ((tags (org-get-tags (plist-get elt :contents-begin))))
-      (-map (lambda (l) `((name . ,l)))
-            (cl-case org-clubhouse-create-stories-with-labels
-              ('t tags)
-              ('existing (-filter (lambda (tag) (-some (lambda (l)
-                                                    (string-equal tag (cdr l)))
-                                                  (org-clubhouse-labels)))
-                                  tags)))))))
-
-;;;
-;;; API integration
-;;;
-
-(defvar org-clubhouse-base-url* "https://api.clubhouse.io/api/v3")
-
-(defun org-clubhouse-auth-url (url &optional params)
- (concat url
-         "?"
-         (url-build-query-string
-          (cons `("token" ,org-clubhouse-auth-token) params))))
-
-(defun org-clubhouse-baseify-url (url)
- (if (s-starts-with? org-clubhouse-base-url* url) url
-   (concat org-clubhouse-base-url*
-           (if (s-starts-with? "/" url) url
-             (concat "/" url)))))
-
-(cl-defun org-clubhouse-request (method url &key data (params '()))
- (message "%s %s %s" method url (prin1-to-string data))
- (let* ((url-request-method method)
-        (url-request-extra-headers
-         '(("Content-Type" . "application/json")))
-        (url-request-data data)
-        (buf))
-
-   (setq url (-> url
-                 org-clubhouse-baseify-url
-                 (org-clubhouse-auth-url params)))
-
-   (setq buf (url-retrieve-synchronously url))
-
-   (with-current-buffer buf
-     (goto-char url-http-end-of-headers)
-     (prog1 (json-read) (kill-buffer)))))
-
-(cl-defun to-id-name-pairs
-    (seq &optional (id-attr 'id) (name-attr 'name))
-  (->> seq
-       ->list
-       (-map (lambda (resource)
-          (cons (alist-get id-attr   resource)
-                (alist-get name-attr resource))))))
-
-(cl-defun org-clubhouse-fetch-as-id-name-pairs
-    (resource &optional
-              (id-attr 'id)
-              (name-attr 'name))
-  "Returns the given resource from clubhouse as (id . name) pairs"
-  (let ((resp-json (org-clubhouse-request "GET" resource)))
-    (-> resp-json
-        ->list
-        reject-archived
-        (to-id-name-pairs id-attr name-attr))))
-
-(defun org-clubhouse-get-story
-    (clubhouse-id)
-  (org-clubhouse-request "GET" (format "/stories/%s" clubhouse-id)))
-
-(defun org-clubhouse-link-to-story (story-id)
-  (format "https://app.clubhouse.io/%s/story/%d"
-          org-clubhouse-team-name
-          story-id))
-
-(defun org-clubhouse-link-to-epic (epic-id)
-  (format "https://app.clubhouse.io/%s/epic/%d"
-          org-clubhouse-team-name
-          epic-id))
-
-(defun org-clubhouse-link-to-milestone (milestone-id)
-  (format "https://app.clubhouse.io/%s/milestone/%d"
-          org-clubhouse-team-name
-          milestone-id))
-
-(defun org-clubhouse-link-to-project (project-id)
-  (format "https://app.clubhouse.io/%s/project/%d"
-          org-clubhouse-team-name
-          project-id))
-
-;;;
-;;; Caching
-;;;
-
-(comment
- (defcache org-clubhouse-projects
-   (org-sync-clubhouse-fetch-as-id-name-pairs "projectx"))
-
- (clear-org-clubhouse-projects-cache)
- (clear-org-clubhouse-cache)
- )
-
-(defvar org-clubhouse-cache-clear-functions ())
-
-(defmacro defcache (name &optional docstring &rest body)
-  (let* ((doc (when docstring (list docstring)))
-         (cache-var-name (intern (concat (symbol-name name)
-                                         "-cache")))
-         (clear-cache-function-name
-          (intern (concat "clear-" (symbol-name cache-var-name)))))
-    `(progn
-       (defvar ,cache-var-name :no-cache)
-       (defun ,name ()
-         ,@doc
-         (when (equal :no-cache ,cache-var-name)
-           (setq ,cache-var-name (progn ,@body)))
-         ,cache-var-name)
-       (defun ,clear-cache-function-name ()
-         (interactive)
-         (setq ,cache-var-name :no-cache))
-
-       (push (quote ,clear-cache-function-name)
-             org-clubhouse-cache-clear-functions))))
-
-(defun org-clubhouse-clear-cache ()
-  (interactive)
-  (-map #'funcall org-clubhouse-cache-clear-functions))
-
-;;;
-;;; API resource functions
-;;;
-
-(defcache org-clubhouse-projects
-  "Returns projects as (project-id . name)"
-  (org-clubhouse-fetch-as-id-name-pairs "projects"))
-
-(defcache org-clubhouse-epics
-  "Returns epics as (epic-id . name)"
-  (org-clubhouse-fetch-as-id-name-pairs "epics"))
-
-(defcache org-clubhouse-milestones
-  "Returns milestone-id . name)"
-  (org-clubhouse-fetch-as-id-name-pairs "milestones"))
-
-(defcache org-clubhouse-workflow-states
-  "Returns worflow states as (name . id) pairs"
-  (let* ((resp-json (org-clubhouse-request "GET" "workflows"))
-         (workflows (->list resp-json))
-         ;; just assume it exists, for now
-         (workflow  (-find (lambda (workflow)
-                             (equal org-clubhouse-workflow-name
-                                    (alist-get 'name workflow)))
-                           workflows))
-         (states    (->list (alist-get 'states workflow))))
-    (to-id-name-pairs states
-                      'name
-                      'id)))
-
-(defcache org-clubhouse-labels
-  "Returns labels as (label-id . name)"
-  (org-clubhouse-fetch-as-id-name-pairs "labels"))
-
-(defcache org-clubhouse-whoami
-  "Returns the ID of the logged in user"
-  (->> (org-clubhouse-request
-        "GET"
-        "/members")
-       ->list
-       (find-if (lambda (m)
-                  (->> m
-                       (alist-get 'profile)
-                       (alist-get 'mention_name)
-                       (equal org-clubhouse-username))))
-       (alist-get 'id)))
-
-(defcache org-clubhouse-iterations
-  "Returns iterations as (iteration-id . name)"
-  (org-clubhouse-fetch-as-id-name-pairs "iterations"))
-
-(defun org-clubhouse-stories-in-project (project-id)
-  "Return the stories in the given PROJECT-ID as org headlines."
-  (let ((resp-json (org-clubhouse-request "GET" (format "/projects/%d/stories" project-id))))
-    (->> resp-json ->list reject-archived
-         (-reject (lambda (story) (equal :json-true (alist-get 'completed story))))
-         (-map (lambda (story)
-                 (cons
-                  (cons 'status
-                        (cond
-                         ((equal :json-true (alist-get 'started story))
-                          'started)
-                         ((equal :json-true (alist-get 'completed story))
-                          'completed)
-                         ('t
-                          'open)))
-                  story)))
-         (-map (-partial #'alist->plist
-                         '((name . :title)
-                           (id . :id)
-                           (status . :status)))))))
-
-(defun org-clubhouse-workflow-state-id-to-todo-keyword (workflow-state-id)
-  "Convert the named clubhouse WORKFLOW-STATE-ID to an org todo keyword."
-  (let* ((state-name (alist-get-equal
-                      workflow-state-id
-                      (invert-alist (org-clubhouse-workflow-states))))
-         (inv-state-name-alist
-          (-map (lambda (cell) (cons (cdr cell) (car cell)))
-                org-clubhouse-state-alist)))
-    (or (alist-get-equal state-name inv-state-name-alist)
-        (if state-name (s-upcase state-name) "UNKNOWN"))))
-
-;;;
-;;; Prompting
-;;;
-
-(defun org-clubhouse-prompt-for-project (cb)
-  (ivy-read
-   "Select a project: "
-   (-map #'cdr (org-clubhouse-projects))
-   :require-match t
-   :history 'org-clubhouse-project-history
-   :action (lambda (selected)
-             (let ((project-id
-                    (find-match-in-alist selected (org-clubhouse-projects))))
-               (funcall cb project-id)))))
-
-(defun org-clubhouse-prompt-for-epic (cb)
-  "Prompt the user for an epic using ivy and call CB with its ID."
-  (ivy-read
-   "Select an epic: "
-   (-map #'cdr (append '((nil . "No Epic")) (org-clubhouse-epics)))
-   :history 'org-clubhouse-epic-history
-   :action (lambda (selected)
-             (let ((epic-id
-                    (find-match-in-alist selected (org-clubhouse-epics))))
-               (funcall cb epic-id)))))
-
-(defun org-clubhouse-prompt-for-milestone (cb)
-  "Prompt the user for a milestone using ivy and call CB with its ID."
-  (ivy-read
-   "Select a milestone: "
-   (-map #'cdr (append '((nil . "No Milestone")) (org-clubhouse-milestones)))
-   :require-match t
-   :history 'org-clubhouse-milestone-history
-   :action (lambda (selected)
-             (let ((milestone-id
-                    (find-match-in-alist selected (org-clubhouse-milestones))))
-               (funcall cb milestone-id)))))
-
-(defun org-clubhouse-prompt-for-story-type (cb)
-  (ivy-read
-   "Select a story type: "
-   (-map #'cdr org-clubhouse-story-types)
-   :history 'org-clubhouse-story-history
-   :action (lambda (selected)
-             (let ((story-type
-                    (find-match-in-alist selected org-clubhouse-story-types)))
-               (funcall cb story-type)))))
-
-(defun org-clubhouse-prompt-for-default-story-type ()
-  (interactive)
-  (ivy-read
-   "Select a default story type: "
-   (-map #'cdr org-clubhouse-default-story-types)
-   :history 'org-clubhouse-default-story-history
-   :action (lambda (selected)
-             (let ((story-type
-                    (find-match-in-alist selected org-clubhouse-default-story-types)))
-                  (if (string-equal story-type "prompt")
-                      (setq org-clubhouse-default-story-type nil)
-                      (setq org-clubhouse-default-story-type story-type))))))
-
-;;;
-;;; Epic creation
-;;;
-
-(cl-defun org-clubhouse-create-epic-internal
-    (title &key milestone-id)
-  (cl-assert (and (stringp title)
-                  (or (null milestone-id)
-                      (integerp milestone-id))))
-  (org-clubhouse-request
-   "POST"
-   "epics"
-   :data
-   (json-encode
-    `((name . ,title)
-      (milestone_id . ,milestone-id)))))
-
-(defun org-clubhouse-populate-created-epic (elt epic)
-  (let ((elt-start  (plist-get elt :begin))
-        (epic-id    (alist-get 'id epic))
-        (milestone-id (alist-get 'milestone_id epic)))
-    (save-excursion
-      (goto-char elt-start)
-
-      (org-set-property "clubhouse-epic-id"
-                        (org-link-make-string
-                         (org-clubhouse-link-to-epic epic-id)
-                         (number-to-string epic-id)))
-
-      (when milestone-id
-        (org-set-property "clubhouse-milestone"
-                          (org-link-make-string
-                           (org-clubhouse-link-to-milestone milestone-id)
-                           (alist-get milestone-id (org-clubhouse-milestones))))))))
-
-(defun org-clubhouse-create-epic (&optional beg end)
-  "Creates a clubhouse epic using selected headlines.
-Will pull the title from the headline at point, or create epics for all the
-headlines in the selected region.
-
-All epics are added to the same milestone, as selected via a prompt.
-If the epics already have a CLUBHOUSE-EPIC-ID, they are filtered and ignored."
-  (interactive
-   (when (use-region-p)
-     (list (region-beginning region-end))))
-
-  (let* ((elts (org-clubhouse-collect-headlines beg end))
-         (elts (-remove (lambda (elt) (plist-get elt :CLUBHOUSE-EPIC-ID)) elts)))
-    (org-clubhouse-prompt-for-milestone
-     (lambda (milestone-id)
-       (dolist (elt elts)
-         (let* ((title (plist-get elt :title))
-                (epic  (org-clubhouse-create-epic-internal
-                        title
-                        :milestone-id milestone-id)))
-           (org-clubhouse-populate-created-epic elt epic))
-         elts)))))
-
-;;;
-;;; Story creation
-;;;
-
-(defun org-clubhouse-default-state-id ()
-  (alist-get-equal org-clubhouse-default-state (org-clubhouse-workflow-states)))
-
-(cl-defun org-clubhouse-create-story-internal
-    (title &key project-id epic-id story-type description labels)
-  (cl-assert (and (stringp title)
-               (integerp project-id)
-               (or (null epic-id) (integerp epic-id))
-               (or (null description) (stringp description))))
-  (let ((workflow-state-id (org-clubhouse-default-state-id))
-        (params `((name . ,title)
-                  (project_id . ,project-id)
-                  (epic_id . ,epic-id)
-                  (story_type . ,story-type)
-                  (description . ,(or description ""))
-                  (labels . ,labels))))
-
-    (when workflow-state-id
-      (push `(workflow_state_id . ,workflow-state-id) params))
-
-    (org-clubhouse-request
-     "POST"
-     "stories"
-     :data
-     (json-encode params))))
-
-(cl-defun org-clubhouse-populate-created-story (elt story &key extra-properties)
-  (let ((elt-start  (plist-get elt :begin))
-        (story-id   (alist-get 'id story))
-        (epic-id    (alist-get 'epic_id story))
-        (project-id (alist-get 'project_id story))
-        (story-type (alist-get 'story_type story)))
-
-    (save-excursion
-      (goto-char elt-start)
-
-      (org-set-property "clubhouse-id"
-                        (org-link-make-string
-                         (org-clubhouse-link-to-story story-id)
-                         (number-to-string story-id)))
-      (when epic-id
-        (org-set-property "clubhouse-epic"
-                          (org-link-make-string
-                           (org-clubhouse-link-to-epic epic-id)
-                           (alist-get epic-id (org-clubhouse-epics)))))
-
-      (org-set-property "clubhouse-project"
-                        (org-link-make-string
-                         (org-clubhouse-link-to-project project-id)
-                         (alist-get project-id (org-clubhouse-projects))))
-
-      (org-set-property "story-type"
-                        (alist-get-equal story-type org-clubhouse-story-types))
-
-      (dolist (extra-prop extra-properties)
-        (org-set-property (car extra-prop)
-                          (alist-get (cdr extra-prop) story)))
-
-      (org-todo "TODO"))))
-
-(defun org-clubhouse-create-story (&optional beg end &key then)
-  "Creates a clubhouse story using selected headlines.
-
-Will pull the title from the headline at point,
-or create cards for all the headlines in the selected region.
-
-All stories are added to the same project and epic, as selected via two prompts.
-If the stories already have a CLUBHOUSE-ID, they are filtered and ignored."
-  (interactive
-   (when (use-region-p)
-     (list (region-beginning) (region-end))))
-
-  (let* ((elts     (org-clubhouse-collect-headlines beg end))
-         (new-elts (-remove (lambda (elt) (plist-get elt :CLUBHOUSE-ID)) elts)))
-    (org-clubhouse-prompt-for-project
-     (lambda (project-id)
-       (when project-id
-         (org-clubhouse-prompt-for-epic
-          (lambda (epic-id)
-            (let ((create-story
-                   (lambda (story-type)
-                     (-map
-                      (lambda (elt)
-                        (let* ((title (plist-get elt :title))
-                               (description
-                                (save-mark-and-excursion
-                                  (goto-char (plist-get elt :begin))
-                                  (org-clubhouse-find-description-drawer)))
-                               (labels (org-clubhouse--labels-for-elt elt))
-                               (story (org-clubhouse-create-story-internal
-                                       title
-                                       :project-id project-id
-                                       :epic-id epic-id
-                                       :story-type story-type
-                                       :description description
-                                       :labels labels)))
-                          (org-clubhouse-populate-created-story elt story)
-                          (when (functionp then)
-                            (funcall then story))))
-                      new-elts))))
-              (if org-clubhouse-default-story-type
-                  (funcall create-story org-clubhouse-default-story-type)
-                (org-clubhouse-prompt-for-story-type create-story))))))))))
-
-(defun org-clubhouse-create-story-with-task-list (&optional beg end)
-  "Creates a clubhouse story using the selected headline, making all direct
-children of that headline into tasks in the task list of the story."
-  (interactive
-   (when (use-region-p)
-     (list (region-beginning) (region-end))))
-
-  (let* ((elt (org-element-and-children-at-point)))
-    (org-clubhouse-create-story nil nil
-     :then (lambda (story)
-             (pp story)
-             (org-clubhouse-push-task-list
-              (alist-get 'id story)
-              (plist-get elt :children))))))
-
-;;;
-;;; Task creation
-;;;
-
-(cl-defun org-clubhouse-create-task (title &key story-id)
-  (cl-assert (and (stringp title)
-               (integerp story-id)))
-  (org-clubhouse-request
-   "POST"
-   (format "/stories/%d/tasks" story-id)
-   :data (json-encode `((description . ,title)))))
-
-(defun org-clubhouse-push-task-list (&optional parent-clubhouse-id child-elts)
-  "Writes each child of the element at point as a task list item.
-
-When called as (org-clubhouse-push-task-list PARENT-CLUBHOUSE-ID CHILD-ELTS),
-allows manually passing a clubhouse ID and list of org-element plists to write"
-  (interactive)
-  (let* ((elt (org-element-and-children-at-point))
-         (parent-clubhouse-id (or parent-clubhouse-id
-                                  (org-element-extract-clubhouse-id elt)))
-         (child-elts (or child-elts (plist-get elt :children)))
-         (story (org-clubhouse-get-story parent-clubhouse-id))
-         (existing-tasks (alist-get 'tasks story))
-         (task-exists
-          (lambda (task-name)
-            (cl-some (lambda (task)
-                    (string-equal task-name (alist-get 'description task)))
-                  existing-tasks)))
-         (elts-with-starts
-          (-map (lambda (e) (cons (set-marker (make-marker)
-                                         (plist-get e :begin))
-                             e))
-                child-elts)))
-    (dolist (child-elt-and-start elts-with-starts)
-      (let* ((start (car child-elt-and-start))
-             (child-elt (cdr child-elt-and-start))
-             (task-name (plist-get child-elt :title)))
-        (unless (funcall task-exists task-name)
-          (let ((task (org-clubhouse-create-task
-                       task-name
-                       :story-id parent-clubhouse-id)))
-            (org-clubhouse-populate-created-task child-elt task start)))))))
-
-(defun org-clubhouse-populate-created-task (elt task &optional begin)
-  (let ((elt-start (or begin (plist-get elt :begin)))
-        (task-id   (alist-get 'id task))
-        (story-id  (alist-get 'story_id task)))
-
-    (save-excursion
-      (goto-char elt-start)
-
-      (org-set-property "clubhouse-task-id" (format "%d" task-id))
-
-      (org-set-property "clubhouse-story-id"
-                        (org-link-make-string
-                         (org-clubhouse-link-to-story story-id)
-                         (number-to-string story-id)))
-
-      (org-todo "TODO"))))
-
-;;;
-;;; Task Updates
-;;;
-
-(cl-defun org-clubhouse-update-task-internal
-    (story-id task-id &rest attrs)
-  (cl-assert (and (integerp story-id)
-                  (integerp task-id)
-                  (listp attrs)))
-  (org-clubhouse-request
-   "PUT"
-   (format "stories/%d/tasks/%d" story-id task-id)
-   :data
-   (json-encode attrs)))
-
-;;;
-;;; Story updates
-;;;
-
-(cl-defun org-clubhouse-update-story-internal
-    (story-id &rest attrs)
-  (cl-assert (and (integerp story-id)
-               (listp attrs)))
-  (org-clubhouse-request
-   "PUT"
-   (format "stories/%d" story-id)
-   :data
-   (json-encode attrs)))
-
-(cl-defun org-clubhouse-update-story-at-point (&rest attrs)
-  (when-let* ((clubhouse-id (org-element-clubhouse-id)))
-    (apply
-     #'org-clubhouse-update-story-internal
-     (cons clubhouse-id attrs))
-    t))
-
-(defun org-clubhouse-update-story-title ()
-  "Update the title of the Clubhouse story linked to the current headline.
-
-Update the title of the story linked to the current headline with the text of
-the headline."
-  (interactive)
-
-  (let* ((elt (org-element-find-headline))
-         (title (plist-get elt :title))
-         (clubhouse-id (org-element-clubhouse-id)))
-    (and
-     (org-clubhouse-update-story-at-point
-      clubhouse-id
-      :name title)
-     (message "Successfully updated story title to \"%s\""
-              title))))
-
-(defun org-clubhouse-update-status ()
-  "Update the status of the Clubhouse story linked to the current element.
-
-Update the status of the Clubhouse story linked to the current element with the
-entry in `org-clubhouse-state-alist' corresponding to the todo-keyword of the
-element."
-  (interactive)
-  (let* ((elt (org-element-find-headline))
-         (todo-keyword (-> elt
-                           (plist-get :todo-keyword)
-                           (substring-no-properties)))
-
-         (clubhouse-id (org-element-extract-clubhouse-id elt))
-         (task-id (plist-get elt :CLUBHOUSE-TASK-ID)))
-    (cond
-     (clubhouse-id
-      (let* ((todo-keyword (-> elt
-                               (plist-get :todo-keyword)
-                               (substring-no-properties))))
-        (when-let* ((clubhouse-workflow-state
-                     (alist-get-equal todo-keyword org-clubhouse-state-alist))
-                    (workflow-state-id
-                     (alist-get-equal clubhouse-workflow-state
-                                      (org-clubhouse-workflow-states))))
-          (let ((update-assignee?
-                 (if (or (eq 't org-clubhouse-claim-story-on-status-update)
-                         (member todo-keyword
-                                 org-clubhouse-claim-story-on-status-update))
-                     (if org-clubhouse-username
-                         't
-                       (warn "Not claiming story since `org-clubhouse-username'
-                       is not set")
-                       nil))))
-
-            (if update-assignee?
-                (org-clubhouse-update-story-internal
-                 clubhouse-id
-                 :workflow_state_id workflow-state-id
-                 :owner_ids (if update-assignee?
-                                (list (org-clubhouse-whoami))
-                              (list)))
-              (org-clubhouse-update-story-internal
-                 clubhouse-id
-                 :workflow_state_id workflow-state-id))
-            (message
-             (if update-assignee?
-                 "Successfully claimed story and updated clubhouse status to \"%s\""
-               "Successfully updated clubhouse status to \"%s\"")
-             clubhouse-workflow-state)))))
-
-     (task-id
-      (let ((story-id (org-element-extract-clubhouse-id
-                       elt
-                       :CLUBHOUSE-STORY-ID))
-            (done? (member todo-keyword org-done-keywords)))
-        (org-clubhouse-update-task-internal
-         story-id
-         (string-to-number task-id)
-         :complete (if done? 't :json-false))
-        (message "Successfully marked clubhouse task status as %s"
-                 (if done? "complete" "incomplete")))))))
-
-(defun org-clubhouse-update-description ()
-  "Update the description of the Clubhouse story linked to the current element.
-
-Update the status of the Clubhouse story linked to the current element with the
-contents of a drawer inside the element called DESCRIPTION, if any."
-  (interactive)
-  (when-let* ((new-description (org-clubhouse-find-description-drawer)))
-    (and
-     (org-clubhouse-update-story-at-point
-      :description new-description)
-     (message "Successfully updated story description"))))
-
-(defun org-clubhouse-update-labels ()
-  "Update the labels of the Clubhouse story linked to the current element.
-
-Will use the value of `org-clubhouse-create-stories-with-labels' to determine
-which labels to set."
-  (interactive)
-  (when-let* ((elt (org-element-find-headline))
-              (new-labels (org-clubhouse--labels-for-elt elt)))
-    (and
-     (org-clubhouse-update-story-at-point
-      :labels new-labels)
-     (message "Successfully updated story labels to :%s:"
-              (->> new-labels
-                   (-map #'cdar)
-                   (s-join ":"))))))
-
-
-;;;
-;;; Creating headlines from existing stories
-;;;
-
-(defun org-clubhouse--task-to-headline-text (level task)
-  (format "%s %s %s
-:PROPERTIES:
-:clubhouse-task-id: %s
-:clubhouse-story-id: %s
-:END:"
-          (make-string level ?*)
-          (if (equal :json-false (alist-get 'complete task))
-              "TODO" "DONE")
-          (alist-get 'description task)
-          (alist-get 'id task)
-          (let ((story-id (alist-get 'story_id task)))
-            (org-link-make-string
-             (org-clubhouse-link-to-story story-id)
-             story-id))))
-
-(defun org-clubhouse--story-to-headline-text (level story)
-  (let ((story-id (alist-get 'id story)))
-    (format
-     "%s %s %s %s
-:PROPERTIES:
-:clubhouse-id: %s
-:END:
-%s
-%s
-"
-     (make-string level ?*)
-     (org-clubhouse-workflow-state-id-to-todo-keyword
-      (alist-get 'workflow_state_id story))
-     (alist-get 'name story)
-     (if-let ((labels (->> story
-                             (alist-get 'labels)
-                             ->list
-                             (-map (apply-partially #'alist-get 'name)))))
-         (format ":%s:" (s-join ":" labels))
-       "")
-     (org-link-make-string
-      (org-clubhouse-link-to-story story-id)
-      (number-to-string story-id))
-     (let ((desc (alist-get 'description story)))
-       (if (= 0 (length desc)) ""
-         (format ":DESCRIPTION:\n%s\n:END:" desc)))
-     (if-let ((tasks (seq-sort-by
-                      (apply-partially #'alist-get 'position)
-                      #'<
-                      (or (alist-get 'tasks story)
-                          (alist-get 'tasks
-                                     (org-clubhouse-get-story story-id))))))
-         (mapconcat (apply-partially #'org-clubhouse--task-to-headline-text
-                                     (1+ level))
-                    tasks
-                    "\n")
-       ""))))
-
-(defun org-clubhouse-headline-from-my-tasks (level)
-  "Prompt my active stories and create a single `org-mode' headline at LEVEL."
-  (interactive "*nLevel: \n")
-  (if org-clubhouse-username
-      (let* ((story-list (org-clubhouse--search-stories
-                          (format "owner:%s !is:done !is:archived"
-                                  org-clubhouse-username)))
-             (stories (to-id-name-pairs story-list)))
-        (org-clubhouse-headline-from-story-id level
-                                              (find-match-in-alist
-                                               (ivy-read "Select Story: "
-                                                         (-map #'cdr stories))
-                                               stories)))
-    (warn "Can't fetch my tasks if `org-clubhouse-username' is unset")))
-
-(defun org-clubhouse-headline-from-story-id (level story-id)
-  "Create a single `org-mode' headline at LEVEL based on the given clubhouse STORY-ID."
-  (interactive "*nLevel: \nnStory ID: ")
-  (let* ((story (org-clubhouse-get-story story-id)))
-    (if (equal '((message . "Resource not found.")) story)
-        (message "Story ID not found: %d" story-id)
-      (save-mark-and-excursion
-        (insert (org-clubhouse--story-to-headline-text level story))
-        (org-align-tags)))))
-
-(defun org-clubhouse--search-stories (query)
-  (unless (string= "" query)
-    (-> (org-clubhouse-request "GET" "search/stories" :params `((query ,query)))
-        cdadr
-        (append nil)
-        reject-archived)))
-
-(defun org-clubhouse-prompt-for-iteration (cb)
-  "Prompt for iteration and call CB with that iteration"
-  (ivy-read
-   "Select an interation: "
-   (-map #'cdr (org-clubhouse-iterations))
-   :require-match t
-   :history 'org-clubhouse-iteration-history
-   :action (lambda (selected)
-             (let ((iteration-id
-                    (find-match-in-alist selected (org-clubhouse-iterations))))
-               (funcall cb iteration-id)))))
-
-(defun org-clubhouse--get-iteration (iteration-id)
-  (-> (org-clubhouse-request "GET" (format "iterations/%d/stories" iteration-id))
-      (append nil)))
-
-(defun org-clubhouse-headlines-from-iteration (level)
-  "Create `org-mode' headlines from a clubhouse iteration.
-
-Create `org-mode' headlines from all the resulting stories at headline level LEVEL."
-  (interactive "*nLevel: ")
-  (org-clubhouse-prompt-for-iteration
-   (lambda (iteration-id)
-     (let ((story-list (org-clubhouse--get-iteration iteration-id)))
-       (if (null story-list)
-           (message "Iteration id returned no stories: %d" iteration-id)
-         (let ((text (mapconcat (apply-partially
-                                 #'org-clubhouse--story-to-headline-text
-                                 level)
-                                (reject-archived story-list) "\n")))
-               (save-mark-and-excursion
-                 (insert text)
-                 (org-align-all-tags))
-             text))))))
-
-(defun org-clubhouse-headlines-from-query (level query)
-  "Create `org-mode' headlines from a clubhouse query.
-
-Submits QUERY to clubhouse, and creates `org-mode' headlines from all the
-resulting stories at headline level LEVEL."
-  (interactive
-   "*nLevel: \nMQuery: ")
-  (let* ((story-list (org-clubhouse--search-stories query)))
-    (if (null story-list)
-        (message "Query returned no stories: %s" query)
-      (let ((text (mapconcat (apply-partially
-                              #'org-clubhouse--story-to-headline-text
-                              level)
-                             (reject-archived story-list) "\n")))
-        (if (called-interactively-p)
-            (save-mark-and-excursion
-              (insert text)
-              (org-align-all-tags))
-          text)))))
-
-(defun org-clubhouse-prompt-for-story (cb)
-  "Prompt the user for a clubhouse story, then call CB with the full story."
-  (ivy-read "Story title: "
-            (lambda (search-term)
-              (let* ((stories (org-clubhouse--search-stories
-                               (if search-term (format "\"%s\"" search-term)
-                                 ""))))
-                (-map (lambda (story)
-                        (propertize (alist-get 'name story) 'story story))
-                      stories)))
-            :dynamic-collection t
-            :history 'org-clubhouse-story-prompt
-            :action (lambda (s) (funcall cb (get-text-property 0 'story s)))
-            :require-match t))
-
-(defun org-clubhouse-headline-from-story (level)
-  "Prompt for a story, and create an org headline at LEVEL from that story."
-  (interactive "*nLevel: ")
-  (org-clubhouse-prompt-for-story
-   (lambda (story)
-     (save-mark-and-excursion
-       (insert (org-clubhouse--story-to-headline-text level story))
-       (org-align-tags)))))
-
-
-(defun org-clubhouse-link ()
-  "Link the current `org-mode' headline with an existing clubhouse story."
-  (interactive)
-  (org-clubhouse-prompt-for-story
-   (lambda (story)
-     (org-clubhouse-populate-created-story
-      (org-element-find-headline)
-      story
-      :extra-properties '(("clubhouse-story-name" . name)))
-     (org-todo
-      (org-clubhouse-workflow-state-id-to-todo-keyword
-       (alist-get 'workflow_state_id story))))))
-
-(defun org-clubhouse-claim ()
-  "Assign the clubhouse story associated with the headline at point to yourself."
-  (interactive)
-  (if org-clubhouse-username
-      (and
-       (org-clubhouse-update-story-at-point
-        :owner_ids (list (org-clubhouse-whoami)))
-       (message "Successfully claimed story"))
-    (warn "Can't claim story if `org-clubhouse-username' is unset")))
-
-(defun org-clubhouse-sync-status (&optional beg end)
-  "Pull the status(es) for the story(ies) in region and update the todo state.
-
-Uses `org-clubhouse-state-alist'. Operates over stories from BEG to END"
-  (interactive
-   (when (use-region-p)
-     (list (region-beginning) (region-end))))
-  (let ((elts (-filter (lambda (e) (plist-get e :CLUBHOUSE-ID))
-                       (org-clubhouse-collect-headlines beg end))))
-    (save-mark-and-excursion
-      (dolist (e elts)
-        (goto-char (plist-get e :begin))
-        (let* ((clubhouse-id (org-element-extract-clubhouse-id e))
-               (story (org-clubhouse-get-story clubhouse-id))
-               (workflow-state-id (alist-get 'workflow_state_id story))
-               (todo-keyword (org-clubhouse-workflow-state-id-to-todo-keyword
-                              workflow-state-id)))
-          (let ((org-after-todo-state-change-hook
-                 (remove 'org-clubhouse-update-status
-                         org-after-todo-state-change-hook)))
-            (org-todo todo-keyword)))))
-    (message "Successfully synchronized status of %d stories from Clubhouse"
-             (length elts))))
-
-(cl-defun org-clubhouse-set-epic (&optional story-id epic-id cb &key beg end)
-  "Set the epic of clubhouse story STORY-ID to EPIC-ID, then call CB.
-
-When called interactively, prompt for an epic and set the story of the clubhouse
-stor{y,ies} at point or region"
-  (interactive
-   (when (use-region-p)
-     (list nil nil nil
-           :beg (region-beginning)
-           :end (region-end))))
-  (if (and story-id epic-id)
-      (progn
-        (org-clubhouse-update-story-internal
-         story-id :epic-id epic-id)
-        (when cb (funcall cb)))
-    (let ((elts (-filter (lambda (elt) (plist-get elt :CLUBHOUSE-ID))
-                         (org-clubhouse-collect-headlines beg end))))
-      (org-clubhouse-prompt-for-epic
-       (lambda (epic-id)
-         (-map
-          (lambda (elt)
-            (let ((story-id (org-element-extract-clubhouse-id elt)))
-              (org-clubhouse-set-epic
-               story-id epic-id
-               (lambda ()
-                 (org-set-property
-                  "clubhouse-epic"
-                  (org-link-make-string
-                   (org-clubhouse-link-to-epic epic-id)
-                   (alist-get epic-id (org-clubhouse-epics))))
-                 (message "Successfully set the epic on story %d to %d"
-                          story-id epic-id))))))
-         elts)))))
-
-;;;
-
-(define-minor-mode org-clubhouse-mode
-  "If enabled, updates to the todo keywords on org headlines will update the
-linked ticket in Clubhouse."
-  :group 'org
-  :lighter "Org-Clubhouse"
-  :keymap '()
-  (add-hook 'org-after-todo-state-change-hook
-            'org-clubhouse-update-status
-            nil
-            t))
-
-(provide 'org-clubhouse)
-
-;;; org-clubhouse.el ends here