--- /dev/null
+;;; jiralib.el -- Provide connectivity to JIRA SOAP/REST services.
+
+;; Copyright (C) 2016-2022 Matthew Carter <mNOSPAMahungry.com>
+;; Copyright (C) 2011 Bao Haojun
+;; original Copyright (C) 2009 Alex Harsanyi
+
+;; Also, used some code from jira.el, which use xml-rpc instead of soap.
+;; Thus Copyright (C) for jira.el related code:
+;; Brian Zwahr <echosaNOSPAMgmail.com>
+;; Dave Benjamin <daveNOSPAMramenlabs.com>
+
+;; Authors:
+;; Matthew Carter <mNOSPAMahungry.com>
+;; Bao Haojun <baohaojunNOSPAMgmail.com>
+;; Alex Harsanyi <AlexHarsanyiNOSPAMgmail.com>
+
+;; Maintainer: Matthew Carter <mNOSPAMahungry.com>
+;; Version: 3.0.0
+;; Homepage: https://github.com/ahungry/org-jira
+
+;; This file is not part of GNU Emacs.
+
+;; 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/> or write to the Free Software
+;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+;; 02110-1301, USA.
+
+;; Author: Alexandru Harsanyi (AlexHarsanyiNOSPAMgmail.com)
+;; Created: December, 2009
+;; Keywords: soap, web-services, jira
+;; Homepage: http://code.google.com/p/emacs-soap-client
+
+;;; Commentary:
+
+;; This file provides a programatic interface to JIRA. It provides access to
+;; JIRA from other programs, but no user level functionality.
+
+;; Jira References:
+
+;; Primary reference (on current Jira, only REST is supported):
+;; https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis
+
+;; Full API list reference:
+;; https://docs.atlassian.com/jira/REST/cloud/
+
+;; Legacy reference (unsupported and deprecated/unavailable):
+;; http://confluence.atlassian.com/display/JIRA/Creating+a+SOAP+Client
+
+;; JavaDoc for the Jira SOAP service
+;; http://docs.atlassian.com/software/jira/docs/api/rpc-jira-plugin/latest/com/atlassian/jira/rpc/soap/JiraSoapService.html
+
+;;; News:
+
+;;;; Changes since 2.6.3:
+;; - Add worklog import filter and control variable for external worklogs.
+;; - Add the worklog related endpoint/calls.
+
+;;;; Changes since 2.1.0:
+;; - Remove os_username / os_password manual http request as part of sign in process
+;; This produces sysadmin level warnings on Jira when these are used under the latest Jira.
+;; - Remove unused function jiralib-link-issue
+;; - Bring version up to match org-jira version so they can share tag
+
+;;;; Changes since 2.0.0:
+;; - Allow issue type query by project
+
+;;;; Changes since 0.0.0:
+;; - Converted many calls to async
+;; - Converted many calls to make use of caching
+
+;;; Code:
+
+(eval-when-compile (require 'cl))
+(require 'cl-seq)
+(require 'soap-client)
+(require 'request)
+(require 'json)
+(require 'url-parse)
+(require 'url-util)
+
+(defconst jiralib-version "3.0.0"
+ "Current version of jiralib.el.")
+
+(defgroup jiralib nil
+ "Jiralib customization group."
+ :group 'applications)
+
+(defgroup jiralib-faces nil
+ "Faces for displaying Jiralib information."
+ :group 'jiralib)
+
+(defcustom jiralib-use-restapi t
+ "Use restapi instead of soap."
+ :group 'jiralib
+ :type 'boolean
+ :initialize 'custom-initialize-set)
+
+(defcustom jiralib-coding-system 'utf-8
+ "Use custom coding system for Jiralib."
+ :group 'jiralib)
+
+(defcustom jiralib-host ""
+ "User customizable host name of the Jiralib server.
+
+This will be used with USERNAME to compute password from
+.authinfo file. Will be calculated from jiralib-url if not set."
+ :group 'jiralib
+ :type 'string
+ :initialize 'custom-initialize-set)
+
+(defcustom jiralib-user ""
+ "User customizable user name of the Jiralib server.
+
+This will be used with USERNAME to compute password from
+.authinfo file. Will be calculated from jiralib-url if not set."
+ :group 'jiralib
+ :type 'string
+ :initialize 'custom-initialize-set)
+
+(defface jiralib-issue-info-face
+ '((t (:foreground "black" :background "yellow4")))
+ "Base face for issue information."
+ :group 'jiralib-faces)
+
+(defface jiralib-issue-info-header-face
+ '((t (:bold t :inherit 'jiralib-issue-info-face)))
+ "Base face for issue headers."
+ :group 'jiralib-faces)
+
+(defface jiralib-issue-summary-face
+ '((t (:bold t)))
+ "Base face for issue summary."
+ :group 'jiralib-faces)
+
+(defface jiralib-comment-face
+ '((t (:background "gray23")))
+ "Base face for comments."
+ :group 'jiralib-faces)
+
+(defface jiralib-comment-header-face
+ '((t (:bold t)))
+ "Base face for comment headers."
+ :group 'jiralib-faces)
+
+(defface jiralib-link-issue-face
+ '((t (:underline t)))
+ "Face for linked issues."
+ :group 'jiralib-faces)
+
+(defface jiralib-link-project-face
+ '((t (:underline t)))
+ "Face for linked projects"
+ :group 'jiralib-faces)
+
+(defface jiralib-link-filter-face
+ '((t (:underline t)))
+ "Face for linked filters"
+ :group 'jiralib-faces)
+
+(defvar jiralib-mode-hook nil)
+(defvar jiralib-mode-map nil)
+(defvar jiralib-issue-regexp "\\<\\(?:[A-Za-z0-9]+\\)-[0-9]+\\>")
+
+(defcustom jiralib-wsdl-descriptor-url
+ ""
+ "The location for the WSDL descriptor for the JIRA service.
+This is specific to your local JIRA installation. The URL is
+typically:
+
+ http://YOUR_INSTALLATION/rpc/soap/jirasoapservice-v2?wsdl
+
+The default value works if JIRA is located at a hostname named
+'jira'."
+ :type 'string
+ :group 'jiralib)
+
+(defcustom jiralib-url
+ "http://localhost:8081/"
+ "The address of the jira host."
+ :type 'string
+ :group 'jiralib)
+
+(defcustom jiralib-agile-page-size
+ 50
+ "Page size for agile API retrieve. Limited by server property jira.search.views.default.max"
+ :type 'integer
+ :group 'jiralib)
+
+(defvar jiralib-token nil
+ "JIRA token used for authentication.")
+
+(defvar jiralib-user-login-name nil
+ "The name of the user logged into JIRA.
+This is maintained by `jiralib-login'.")
+
+(defvar jiralib-wsdl nil)
+
+(defcustom jiralib-worklog-import--filters-alist
+ (list
+ '(nil "WorklogUpdatedByCurrentUser"
+ (lambda (wl)
+ (let-alist wl
+ (when
+ (and wl
+ (string-equal
+ (downcase
+ (or jiralib-user-login-name user-login-name ""))
+ (downcase (or .updateAuthor.name
+ (car (split-string (or .updateAuthor.emailAddress "") "@"))
+ ""))))
+ wl))))
+ '(nil "WorklogAuthoredByCurrentUser"
+ (lambda (wl)
+ (let-alist wl
+ (when
+ (and wl
+ (string-equal
+ (downcase
+ (or jiralib-user-login-name user-login-name))
+ (downcase (or .author.name
+ (car (split-string (or .author.emailAddress "") "@"))))))
+ wl)))))
+ "A list of triplets: ('Global-Enable 'Descriptive-Label 'Function-Definition)
+that apply worklog predicate filters during import.
+
+Example: (list '('t \"descriptive-predicate-label\" (lambda (x) x)))"
+ :type '(repeat (list boolean string function))
+ :group 'org-jira)
+
+
+(defcustom jiralib-update-issue-fields-exclude-list nil
+ "A list of symbols to check for exclusion on updates based on matching key.
+Key names should be one of components, description, assignee, reporter, summary, issuetype."
+ :type '(set (const :tag "Exclude components" components)
+ (const :tag "Exclude description" description)
+ (const :tag "Exclude assignee" assignee)
+ (const :tag "Exclude reporter" reporter)
+ (const :tag "Exclude summary" summary)
+ (const :tag "Exclude priority" priority)
+ (const :tag "Exclude issue type" issuetype))
+ :group 'org-jira)
+
+(defun jiralib-load-wsdl ()
+ "Load the JIRA WSDL descriptor."
+ (setq jiralib-wsdl (soap-load-wsdl-from-url (if (string-equal jiralib-wsdl-descriptor-url "")
+ (concat jiralib-url "/rpc/soap/jirasoapservice-v2?wsdl")
+ jiralib-wsdl-descriptor-url))))
+
+(defun jiralib-login (username password)
+ "Login into JIRA as user USERNAME with PASSWORD.
+
+After a successful login, store the authentication token in
+`jiralib-token'."
+ ;; NOTE that we cannot rely on `jiralib-call' because `jiralib-call' relies on
+ ;; us ;-)
+ (interactive
+ (if (> 24 emacs-major-version)
+ (let ((user (read-string "Username for Jira server login? "))
+ (password (read-passwd "Password for Jira server login? ")))
+ (list user password))
+ (let ((found (nth 0 (auth-source-search :max 1
+ :host (if (string= jiralib-host "")
+ (url-host (url-generic-parse-url jiralib-url))
+ jiralib-host)
+ :user (if (string= jiralib-user "")
+ (url-user (url-generic-parse-url jiralib-url))
+ jiralib-user)
+ ;; secrets.el wouldn’t accept a number.
+ :port (list (number-to-string (url-port (url-generic-parse-url jiralib-url)))
+ (url-type (url-generic-parse-url jiralib-url)))
+ :require '(:user :secret)
+ :create t)))
+ user secret)
+ (when found
+ (setq user (plist-get found :user)
+ secret
+ (let ((sec (plist-get found :secret)))
+ (if (functionp sec)
+ (funcall sec)
+ sec)))
+ (list user secret)))))
+ (if jiralib-use-restapi
+ (setq jiralib-token `("Authorization" . , (format "Basic %s" (base64-encode-string (concat username ":" password) t))))
+ (unless jiralib-wsdl
+ (jiralib-load-wsdl))
+ (setq jiralib-token
+ (car (soap-invoke jiralib-wsdl "jirasoapservice-v2" "login" username password))))
+ (setq jiralib-user-login-name username))
+
+(defvar jiralib-complete-callback nil)
+
+(defun jiralib-call (method callback &rest params)
+ "Invoke the Jira METHOD, then CALLBACK with supplied PARAMS.
+
+This function should be used for all JIRA interface calls, as the
+method ensures the user is logged in and invokes `soap-invoke'
+with the correct service name and authentication token.
+
+All JIRA interface methods take an authentication token as the
+first argument. The authentication token is supplied by this
+function, so PARAMS should omit this parameter. For example, the
+\"getIssue\" method takes two parameters: auth and key, however,
+when invoking it through `jiralib-call', the call should be:
+
+ (jiralib-call \"getIssue\" KEY)
+
+CALLBACK should be the post processing function to run with the
+completed data from the request result, which can be accessed with:
+
+ (cl-getf data :data)
+
+as such, the CALLBACK should follow this type of form:
+
+ (cl-function
+ (lambda (&rest data &allow-other-keys)
+ (print (cl-getf data :data))))
+
+If CALLBACK is set to nil then the request will occur with sync.
+This produces a noticeable slowdown and is not recommended by
+request.el, so if at all possible, it should be avoided."
+ ;; @TODO :auth: Probably pass this all the way down, but I think
+ ;; it may be OK at the moment to just set the variable each time.
+
+ (setq jiralib-complete-callback
+ ;; Don't run with async if we don't have a login token yet.
+ (if jiralib-token callback nil))
+
+ ;; If we don't have a regex set, ensure it is set BEFORE any async
+ ;; calls are processing, or we're going to have a bad time.
+ ;; This should only end up running once per session.
+ (unless jiralib-issue-regexp
+ (let ((projects (mapcar (lambda (e) (downcase (cdr (assoc 'key e))))
+ (append (jiralib--rest-call-it
+ "/rest/api/2/project"
+ :params '((expand . "description,lead,url,projectKeys")))
+ nil)
+ )))
+ (when projects
+ (setq jiralib-issue-regexp
+ (concat "\\<" (regexp-opt projects) "-[0-9]+\\>")))))
+
+ (if (not jiralib-use-restapi)
+ (car (apply 'jiralib--call-it method params))
+ (unless jiralib-token
+ (call-interactively 'jiralib-login))
+ (cl-case (intern method)
+ ('getStatuses (jiralib--rest-call-it "/rest/api/2/status"))
+ ('getIssueTypes (jiralib--rest-call-it "/rest/api/2/issuetype"))
+ ('getSubTaskIssueTypes (jiralib--rest-call-it "/rest/api/2/issuetype"))
+ ('getIssueTypesByProject
+ (let ((response (jiralib--rest-call-it (format "/rest/api/2/project/%s" (first params)))))
+ (cl-coerce (cdr (assoc 'issueTypes response)) 'list)))
+ ('getUser (jiralib--rest-call-it "/rest/api/2/user" :params `((accountId . ,(first params)))))
+ ('getVersions (jiralib--rest-call-it (format "/rest/api/2/project/%s/versions" (first params))))
+
+ ;; Worklog calls
+ ('getWorklogs
+ (jiralib--rest-call-it (format "/rest/api/2/issue/%s/worklog" (first params))))
+
+ ('addWorklog
+ (jiralib--rest-call-it (format "/rest/api/2/issue/%s/worklog" (first params))
+ :type "POST"
+ :data (json-encode (second params))))
+
+ ('updateWorklog
+ (jiralib--rest-call-it (format "/rest/api/2/issue/%s/worklog/%s" (first params) (second params))
+ :type "PUT"
+ :data (json-encode (third params))))
+
+ ('addWorklogAndAutoAdjustRemainingEstimate
+ (jiralib--rest-call-it (format "/rest/api/2/issue/%s/worklog" (first params))
+ :type "POST"
+ :data (json-encode (second params))))
+
+ ('addComment (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/comment" (first params))
+ :type "POST"
+ :data (json-encode (second params))))
+ ('createIssue
+ ;; Creating the issue doesn't return it, a second call must be
+ ;; made to pull it in by using the self key in response.
+ (let ((response (jiralib--rest-call-it
+ "/rest/api/2/issue"
+ :type "POST"
+ :data (json-encode (first params)))))
+ (jiralib--rest-call-it (cdr (assoc 'self response)) :type "GET")
+ ))
+ ('createIssueWithParent
+ (let ((response (jiralib--rest-call-it
+ "/rest/api/2/issue"
+ :type "POST"
+ :data (json-encode (first params)))))
+ (jiralib--rest-call-it (cdr (assoc 'self response)) :type "GET")
+ ))
+ ('editComment (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/comment/%s" (first params) (second params))
+ :data (json-encode `((body . ,(third params))))
+ :type "PUT"))
+ ('getBoard (jiralib--rest-call-it (format "/rest/agile/1.0/board/%s" (first params))))
+ ('getBoards (apply 'jiralib--agile-call-it "/rest/agile/1.0/board" 'values params))
+ ('getComment (org-jira-find-value
+ (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/comment/%s" (first params) (second params)))
+ 'comments))
+ ('getComments (org-jira-find-value
+ (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/comment" (first params)))
+ 'comments))
+ ('getAttachmentsFromIssue (org-jira-find-value
+ (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s?fields=attachment" (first params)))
+ 'comments))
+ ('getComponents (jiralib--rest-call-it
+ (format "/rest/api/2/project/%s/components" (first params))))
+ ('getIssue (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s" (first params))))
+ ('getIssuesFromBoard (apply 'jiralib--agile-call-it
+ (format "rest/agile/1.0/board/%d/issue" (first params))
+ 'issues
+ (cdr params)))
+ ('getSprintsFromBoard (jiralib--rest-call-it (format "/rest/agile/1.0/board/%s/sprint" (first params))))
+ ('getIssuesFromSprint (apply 'jiralib--agile-call-it
+ (format "rest/agile/1.0/sprint/%d/issue" (first params))
+ 'issues
+ (cdr params)))
+ ('getIssuesFromJqlSearch (append (cdr ( assoc 'issues (jiralib--rest-call-it
+ "/rest/api/2/search"
+ :type "POST"
+ :data (json-encode `((jql . ,(first params))
+ (maxResults . ,(second params)))))))
+ nil))
+ ('getPriorities (jiralib--rest-call-it
+ "/rest/api/2/priority"))
+ ('getProjects (jiralib--rest-call-it "rest/api/2/project"))
+ ('getProjectsNoSchemes (append (jiralib--rest-call-it
+ "/rest/api/2/project"
+ :params '((expand . "description,lead,url,projectKeys"))) nil))
+ ('getResolutions (append (jiralib--rest-call-it
+ "/rest/api/2/resolution") nil))
+ ('getAvailableActions
+ (mapcar
+ (lambda (trans)
+ `(,(assoc 'name trans) ,(assoc 'id trans)))
+ (cdadr (jiralib--rest-call-it (format "/rest/api/2/issue/%s/transitions" (first params))))))
+ ('getFieldsForAction (org-jira-find-value (car (let ((issue (first params))
+ (action (second params)))
+ (seq-filter (lambda (trans)
+ (or (string-equal action (org-jira-find-value trans 'id))
+ (string-equal action (org-jira-find-value trans 'name))))
+ (cdadr (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/transitions" (first params))
+ :params '((expand . "transitions.fields")))))))
+ 'fields))
+ ('progressWorkflowAction (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s/transitions" (first params))
+ :type "POST"
+ :data (json-encode `(,(car (second params)) ,(car (third params))))))
+ ('getUsers
+ (jiralib--rest-call-it (format "/rest/api/2/user/assignable/search?project=%s&maxResults=10000" (first params))
+ :type "GET"))
+ ('updateIssue (jiralib--rest-call-it
+ (format "/rest/api/2/issue/%s" (first params))
+ :type "PUT"
+ :data (json-encode `((fields . ,(second params)))))))))
+
+(defun jiralib--soap-call-it (&rest args)
+ "Deprecated SOAP call endpoint. Will be removed soon.
+Pass ARGS to jiralib-call."
+ (let ((jiralib-token nil)
+ (jiralib-use-restapi nil))
+ (apply #'jiralib-call args)))
+
+(defun jiralib--json-read ()
+ "Read with json, force utf-8"
+ (decode-coding-region (point) (point-max) jiralib-coding-system)
+ (json-read))
+
+(defun jiralib--rest-call-it (api &rest args)
+ "Invoke the corresponding jira rest method API.
+Invoking COMPLETE-CALLBACK when the
+JIRALIB-COMPLETE-CALLBACK is non-nil, request finishes, and
+passing ARGS to REQUEST."
+ (unless api (error "jiralib--rest-call-it was called with a NIL api value."))
+ (setq args
+ (mapcar
+ (lambda (arg)
+ (if (stringp arg)
+ (encode-coding-string arg jiralib-coding-system)
+ arg))
+ args))
+ (append (request-response-data
+ (apply #'request (if (string-match "^http[s]*://" api) api ;; If an absolute path, use it
+ (concat (replace-regexp-in-string "/*$" "/" jiralib-url)
+ (replace-regexp-in-string "^/*" "" api)))
+ :sync (not jiralib-complete-callback)
+ :headers `(,jiralib-token ("Content-Type" . "application/json"))
+ :parser 'jiralib--json-read
+ :complete jiralib-complete-callback
+ ;; Ensure we have useful errors
+ :error
+ (lexical-let
+ ((my-api api)
+ (my-args args))
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ (print "JIRA_ERROR - see your *Messages* buffer for more details.")
+ (print "JIRA_ERROR REQUEST: ")
+ (print my-api)
+ (print my-args)
+ (print "JIRA_ERROR RESPONSE: ")
+ (print data)
+ (error "JIRA_ERROR - see your *Messages* buffer for more details.")
+ )))
+ args))
+ nil))
+
+(defun jiralib--call-it (method &rest params)
+ "Invoke the JIRA METHOD with supplied PARAMS.
+
+Internal use, returns a list of responses, of which only the
+first is normally used."
+ (when (symbolp method)
+ (setq method (symbol-name method)))
+ (unless jiralib-token
+ (call-interactively 'jiralib-login))
+ (condition-case data
+ (apply 'soap-invoke jiralib-wsdl "jirasoapservice-v2"
+ method jiralib-token params)
+ (soap-error
+ ;; If we are here, we had a token, but it expired. Re-login and try
+ ;; again.
+ (setq jiralib-token nil)
+ (call-interactively 'jiralib-login)
+ (apply 'soap-invoke jiralib-wsdl "jirasoapservice-v2"
+ method jiralib-token params))))
+
+\f
+;;;; Some utility functions
+
+(defun jiralib-make-list (data field)
+ "Map all assoc elements in DATA to the value of FIELD in that element."
+ (cl-loop for element in data
+ collect (cdr (assoc field element))))
+
+(defun jiralib-make-assoc-list (data key-field value-field)
+ "Create an association list from a SOAP structure array.
+
+DATA is a list of association lists (a SOAP array-of type)
+KEY-FIELD is the field to use as the key in the returned alist
+VALUE-FIELD is the field to use as the value in the returned alist"
+ (cl-loop for element in data
+ collect (cons (cdr (assoc key-field element))
+ (cdr (assoc value-field element)))))
+
+(defun jiralib-make-remote-field-values (fields)
+ "Transform the (KEY . VALUE) list FIELDS into a RemoteFieldValue structure.
+
+Each (KEY . VALUE) pair is transformed into
+ ((id . KEY) (values . (VALUE)))
+
+This method exists because Several JIRA methods require a
+RemoteFieldValue list, but it is easier to work with ALISTS in
+emacs-lisp"
+ (let ((remote-field-values))
+
+ ;; we accept an ALIST of field-name field-values parameter, but we need to
+ ;; construct a structure that encodes as a RemoteFieldValue which is what
+ ;; updateIssue wants
+ (dolist (field fields)
+ (let ((name (car field))
+ (value (cdr field)))
+ (when (symbolp name)
+ (setq name (symbol-name name)))
+ ;; Value must be an "array" (for which soap-client accepts lists) even
+ ;; if it is just one value
+ (unless (vectorp value)
+ (setq value (vector value)))
+ (push `((id . ,name) (values . ,value))
+ remote-field-values)))
+
+ (apply 'vector (nreverse remote-field-values))))
+
+\f
+;;;; Wrappers around JIRA methods
+
+(defun jiralib--rest-api-for-issue-key (key)
+ "Return jira rest api for issue KEY."
+ (concat "rest/api/2/issue/" key))
+
+(defun jiralib-filter-fields-by-exclude-list (exclude-list fields)
+ (cl-remove-if
+ (lambda (el) (cl-member (car el) exclude-list)) fields))
+
+(defun jiralib-update-issue (key fields &optional callback)
+ "Update the issue with id KEY with the values in FIELDS, invoking CALLBACK."
+ (let ((filtered-fields (jiralib-filter-fields-by-exclude-list
+ jiralib-update-issue-fields-exclude-list
+ fields)))
+ (jiralib-call
+ "updateIssue"
+ callback
+ key (if jiralib-use-restapi
+ filtered-fields
+ (jiralib-make-remote-field-values filtered-fields)))))
+
+(defvar jiralib-status-codes-cache nil)
+
+(defun jiralib-get-statuses ()
+ "Return an assoc list mapping a status code to its name.
+NOTE: Status codes are stored as strings, not numbers.
+
+This function will only ask JIRA for the list of codes once, then
+will cache it."
+ (unless jiralib-status-codes-cache
+ (setq jiralib-status-codes-cache
+ (jiralib-make-assoc-list (jiralib-call "getStatuses" nil) 'id 'name)))
+ jiralib-status-codes-cache)
+
+(defvar jiralib-issue-types-cache nil)
+
+(defun jiralib-get-issue-types ()
+ "Return an assoc list mapping an issue type code to its name.
+NOTE: Issue type codes are stored as strings, not numbers.
+
+This function will only ask JIRA for the list of codes once, than
+will cache it.
+
+The issue types returned via getIssueTypes are all the ones
+available to the user, but not necessarily available to the given
+project.
+
+This endpoint is essentially a master reference for when issue
+types need a name lookup when given an id.
+
+For applying issue types to a given project that is being created, see
+the #'jiralib-get-issue-types-by-project call."
+ (unless jiralib-issue-types-cache
+ (setq jiralib-issue-types-cache
+ (jiralib-make-assoc-list (jiralib-call "getIssueTypes" nil) 'id 'name)))
+ jiralib-issue-types-cache)
+
+(defvar jiralib-issue-types-by-project-cache nil "An alist of available issue types.")
+
+(defun jiralib-get-issue-types-by-project (project)
+ "Return the available issue types for PROJECT.
+
+PROJECT should be the key, such as `EX' or `DEMO'."
+ (unless (assoc project jiralib-issue-types-by-project-cache)
+ (push (cons project
+ (jiralib-make-assoc-list
+ (jiralib-call "getIssueTypesByProject" nil project)
+ 'id 'name))
+ jiralib-issue-types-by-project-cache))
+ (cdr (assoc project jiralib-issue-types-by-project-cache)))
+
+(defvar jiralib-priority-codes-cache nil)
+
+(defun jiralib-get-priorities ()
+ "Return an assoc list mapping a priority code to its name.
+NOTE: Priority codes are stored as strings, not numbers.
+
+This function will only ask JIRA for the list of codes once, than
+will cache it."
+ (unless jiralib-priority-codes-cache
+ (setq jiralib-priority-codes-cache
+ (jiralib-make-assoc-list (jiralib-call "getPriorities" nil) 'id 'name)))
+ jiralib-priority-codes-cache)
+
+(defvar jiralib-resolution-code-cache nil)
+
+(defun jiralib-get-resolutions ()
+ "Return an assoc list mapping a resolution code to its name.
+NOTE: Resolution codes are stored as strings, not numbers.
+
+This function will only ask JIRA for the list of codes once, than
+will cache it."
+ (unless jiralib-resolution-code-cache
+ (setq jiralib-resolution-code-cache
+ (jiralib-make-assoc-list (jiralib-call "getResolutions" nil) 'id 'name)))
+ jiralib-resolution-code-cache)
+
+;; NOTE: it is not such a good idea to use this, as it needs a JIRA
+;; connection to construct the regexp (the user might be prompted for a JIRA
+;; username and password).
+;;
+;; The best use of this function is to generate the regexp once-off and
+;; persist it somewhere.
+;;
+;; FIXME: Probably just deprecate/remove this, we can assert we're on
+;; an issue with a general regexp that matches the common format, vs
+;; needing to know specific user project list.
+(defun jiralib-get-issue-regexp ()
+ "Return a regexp that will match an issue id.
+
+The regexp is constructed from the project keys in the JIRA
+database. An issue is assumed to be in the format KEY-NUMBER,
+where KEY is a project key and NUMBER is the issue number."
+ (unless jiralib-issue-regexp
+ (let ((projects (mapcar (lambda (e) (downcase (cdr (assoc 'key e))))
+ (jiralib-call "getProjectsNoSchemes" nil))))
+ (when projects
+ (setq jiralib-issue-regexp
+ (concat "\\<" (regexp-opt projects) "-[0-9]+\\>")))))
+ jiralib-issue-regexp)
+
+(defun jiralib-do-jql-search (jql &optional limit callback)
+ "Run a JQL query and return the list of issues that matched.
+LIMIT is the maximum number of queries to return. Note that JIRA
+has an internal limit of how many queries to return, as such, it
+might not be possible to find *ALL* the issues that match a
+query."
+ (unless (or limit (numberp limit))
+ (setq limit 100))
+ (jiralib-call "getIssuesFromJqlSearch" callback jql limit))
+
+(defcustom jiralib-available-actions-cache-p t
+ "Set to t to enable caching for jiralib-get-available-actions.
+
+If nil, will disable caching for this endpoint.
+
+Possible side-effects:
+
+ - If the server has the project workflow updated, the cache
+saved here will be incorrect.
+
+ - If the issue is not up to date with the remote, the wrong
+cache key may be queried."
+ :type 'boolean
+ :group 'jiralib)
+
+(defvar jiralib-available-actions-cache nil "An alist of available actions.")
+
+(defun jiralib-get-available-actions (issue-key &optional status)
+ "Return the available workflow actions for ISSUE-KEY.
+This uses STATUS as the cache key.
+This runs the getAvailableActions SOAP method."
+ (if (and jiralib-available-actions-cache-p status)
+ (progn
+ (unless (assoc status jiralib-available-actions-cache)
+ (push (cons status
+ (jiralib-make-assoc-list
+ (mapcar (lambda (x)
+ (let ((namestring (cdr (car x)))
+ (id (cdr x)))
+ (cons
+ (cons 'name (org-jira-decode namestring))
+ id)))
+ (jiralib-call "getAvailableActions" nil issue-key))
+ 'id 'name))
+ jiralib-available-actions-cache))
+ (cdr (assoc status jiralib-available-actions-cache)))
+ (progn
+ (jiralib-make-assoc-list
+ (mapcar (lambda (x)
+ (let ((namestring (cdr (car x)))
+ (id (cdr x)))
+ (cons
+ (cons 'name (org-jira-decode namestring))
+ id)))
+ (jiralib-call "getAvailableActions" nil issue-key))
+ 'id 'name))))
+
+(defcustom jiralib-fields-for-action-cache-p t
+ "Set to t to enable caching for jiralib-get-fields-for-action.
+
+If nil, will disable caching for this endpoint.
+
+Possible side-effects:
+
+ - If many tasks have different workflows, you may want to disable this."
+ :type 'boolean
+ :group 'jiralib)
+
+(defvar jiralib-fields-for-action-cache nil "An alist of available fields.")
+
+(defun jiralib-get-fields-for-action-with-cache (issue-key action-id)
+ "Return the required fields to change ISSUE-KEY to ACTION-ID."
+ (if (and jiralib-fields-for-action-cache-p action-id)
+ (progn
+ (unless (assoc action-id jiralib-fields-for-action-cache)
+ (push (cons action-id
+ (jiralib-call "getFieldsForAction" nil issue-key action-id))
+ jiralib-fields-for-action-cache))
+ (cdr (assoc action-id jiralib-fields-for-action-cache)))
+ (jiralib-call "getFieldsForAction" nil issue-key action-id)))
+
+(defun jiralib-get-fields-for-action (issue-key action-id)
+ "Return the required fields to change ISSUE-KEY to ACTION-ID."
+ (if jiralib-use-restapi
+ (let ((fields (jiralib-get-fields-for-action-with-cache issue-key action-id)))
+ (mapcar (lambda (field)
+ (cons (symbol-name (car field))
+ (format "%s (required: %s)"
+ (org-jira-find-value field 'name)
+ (if (eq (org-jira-find-value field 'required) :json-false)
+ "nil"
+ "t"))))
+ fields))
+ (jiralib-make-assoc-list
+ (jiralib-get-fields-for-action-with-cache issue-key action-id)
+ 'id 'name)))
+
+(defun jiralib-progress-workflow-action (issue-key action-id params &optional callback)
+ "Progress issue with ISSUE-KEY to action ACTION-ID, and provide the needed PARAMS.
+
+When CALLBACK is present, this will run async."
+ (if jiralib-use-restapi
+ (jiralib-call "progressWorkflowAction"
+ callback issue-key `((transition (id . ,action-id)))
+ `((fields . ,params)))
+ (jiralib-call "progressWorkflowAction"
+ callback issue-key action-id (jiralib-make-remote-field-values params))))
+
+
+(defun jiralib-format-datetime (&optional datetime)
+ "Convert a mixed DATETIME format into the Jira required datetime format.
+
+This will produce a datetime string such as:
+
+ 2010-02-05T14:30:00.000+0000
+
+for being consumed in the Jira API.
+
+If DATETIME is not passed in, it will default to the current time."
+ (let* ((defaults (format-time-string "%Y-%m-%d %H:%M:%S" (current-time)))
+ (datetime (concat datetime (subseq defaults (length datetime))))
+ (parts (parse-time-string datetime)))
+ (format "%04d-%02d-%02dT%02d:%02d:%02d.000+0000"
+ (nth 5 parts)
+ (nth 4 parts)
+ (nth 3 parts)
+ (nth 2 parts)
+ (nth 1 parts)
+ (nth 0 parts))))
+
+(defvar jiralib-worklog-coming-soon-message
+ "WORKLOG FEATURES ARE NOT IMPLEMENTED YET, COMING SOON!")
+
+(defun jiralib-add-worklog-and-autoadjust-remaining-estimate (issue-key start-date time-spent comment)
+ "Log time spent on ISSUE-KEY to its worklog.
+The time worked begins at START-DATE and has a TIME-SPENT
+duration. JIRA will automatically update the remaining estimate
+by subtracting TIME-SPENT from it.
+
+START-DATE should be in the format 2010-02-05T14:30:00Z
+
+TIME-SPENT can be in one of the following formats: 10m, 120m
+hours; 10h, 120h days; 10d, 120d weeks.
+
+COMMENT will be added to this worklog."
+ (let ((formatted-start-date (jiralib-format-datetime start-date)))
+ (jiralib-call "addWorklogAndAutoAdjustRemainingEstimate"
+ nil
+ issue-key
+ ;; Expects data such as: '{"timeSpent":"1h", "started":"2017-02-21T00:00:00.000+0000", "comment":"woot!"}'
+ ;; and only that format will work (no loose formatting on the started date)
+ `((started . ,formatted-start-date)
+ (timeSpent . ,time-spent)
+ (comment . ,comment)))))
+
+\f
+;;;; Issue field accessors
+
+(defun jiralib-issue-key (issue)
+ "Return the key of ISSUE."
+ (cdr (assoc 'key issue)))
+
+(defun jiralib-issue-owner (issue)
+ "Return the owner of ISSUE."
+ (cdr (assq 'assignee issue)))
+
+(defun jiralib-issue-status (issue)
+ "Return the status of ISSUE as a status string (not as a number!)."
+ (let ((status-code (cdr (assq 'status issue))))
+ (cdr (assoc status-code (jiralib-get-statuses)))))
+
+(defun jiralib-custom-field-value (custom-field issue)
+ "Return the value of CUSTOM-FIELD for ISSUE.
+Return nil if the field is not found"
+ (catch 'found
+ (dolist (field (cdr (assq 'customFieldValues issue)))
+ (when (equal (cdr (assq 'customfieldId field)) custom-field)
+ (throw 'found (cadr (assq 'values field)))))))
+
+(defvar jiralib-current-issue nil
+ "This holds the currently selected issue.")
+
+(defvar jiralib-projects-list nil
+ "This holds a list of projects and their details.")
+
+(defvar jiralib-types nil
+ "This holds a list of issues types.")
+
+(defvar jiralib-priorities nil
+ "This holds a list of priorities.")
+
+(defvar jiralib-user-fullnames nil
+ "This holds a list of user fullnames.")
+
+(defun jiralib-get-project-name (key)
+ "Return the name of the JIRA project with id KEY."
+ (let ((projects jiralib-projects-list)
+ (name nil))
+ (dolist (project projects)
+ (if (equal (cdr (assoc 'key project)) key)
+ (setf name (cdr (assoc 'name project)))))
+ name))
+
+(defun jiralib-get-type-name (id)
+ "Return the name of the issue type with ID."
+ (let ((types jiralib-types)
+ (name nil))
+ (dolist (type types)
+ (if (equal (cdr (assoc 'id type)) id)
+ (setf name (cdr (assoc 'name type)))))
+ name))
+
+;; (defun jiralib-get-user-fullname (account-id)
+;; "Return the full name (display name) of the user with USERNAME."
+ ;; (if (assoc account-id jiralib-user-fullnames)
+ ;; (cdr (assoc account-id jiralib-user-fullnames))
+ ;; (progn
+ ;; (let ((user (jiralib-get-user account-id)))
+ ;; (setf jiralib-user-fullnames (append jiralib-user-fullnames (list (cons account-id (cdr (assoc 'fullname user))))))
+ ;; (cdr (assoc 'fullname user))))))
+
+(defun jiralib-get-user-fullname (account-id)
+ "Return the full name (displayName) of the user with ACCOUNT-ID."
+ (cl-loop for user in (jiralib-get-users nil)
+ when (rassoc account-id user)
+ return (cdr (assoc 'displayName user))))
+
+(defun jiralib-get-user-account-id (project full-name)
+ "Return the account-id (accountId) of the user with FULL-NAME (displayName) in PROJECT."
+ (cl-loop for user in (jiralib-get-users project)
+ when (rassoc full-name user)
+ return (cdr (assoc 'accountId user))))
+
+(defun jiralib-get-filter (filter-id)
+ "Return a filter given its FILTER-ID."
+ (cl-flet ((id-match (filter)
+ (equal filter-id (cdr (assoc 'id filter)))))
+ (cl-find-if 'id-match (jiralib-get-saved-filters))))
+
+(defun jiralib-get-filter-alist ()
+ "Return an association list mapping filter names to IDs."
+ (mapcar (lambda (filter)
+ (cons (cdr (assoc 'name filter))
+ (cdr (assoc 'id filter))))
+ (jiralib-get-saved-filters)))
+
+(defun jiralib-add-comment (issue-key comment &optional callback)
+ "Add to issue with ISSUE-KEY the given COMMENT, invoke CALLBACK."
+ (jiralib-call "addComment" callback issue-key `((body . ,comment))))
+
+(defun jiralib-edit-comment (issue-id comment-id comment &optional callback)
+ "Edit ISSUE-ID's comment COMMENT-ID to reflect the new COMMENT, invoke CALLBACK."
+ (if (not jiralib-use-restapi)
+ (jiralib-call "editComment" callback `((id . ,comment-id)
+ (body . ,comment)))
+ (jiralib-call "editComment" callback issue-id comment-id comment)))
+
+(defun jiralib-create-issue (issue)
+ "Create a new ISSUE in JIRALIB.
+
+ISSUE is a Hashtable object."
+ (jiralib-call "createIssue" nil issue))
+
+(defun jiralib-create-subtask (subtask)
+ "Create SUBTASK for issue with PARENT-ISSUE-ID.
+
+SUBTASK is a Hashtable object."
+ (jiralib-call "createIssueWithParent" nil subtask))
+
+(defvar jiralib-subtask-types-cache nil)
+
+(defun jiralib-get-subtask-types ()
+ "Return an assoc list mapping an issue type code to its name.
+NOTE: Issue type codes are stored as strings, not numbers.
+
+This function will only ask JIRA for the list of codes once, than
+will cache it."
+ (unless jiralib-subtask-types-cache
+ (setq jiralib-subtask-types-cache
+ (jiralib-make-assoc-list (jiralib-call "getSubTaskIssueTypes" nil) 'id 'name)))
+ jiralib-subtask-types-cache)
+
+(defun jiralib-get-comment (issue-key comment-id &optional callback)
+ "Return all comments associated with issue ISSUE-KEY, invoking CALLBACK."
+ (jiralib-call "getComment" callback issue-key comment-id))
+
+(defun jiralib-get-comments (issue-key &optional callback)
+ "Return all comments associated with issue ISSUE-KEY, invoking CALLBACK."
+ (jiralib-call "getComments" callback issue-key))
+
+(defun jiralib-get-attachments (issue-key &optional callback)
+ "Return all attachments associated with issue ISSUE-KEY, invoking CALLBACK."
+ (jiralib-call "getAttachmentsFromIssue" callback issue-key))
+
+(defun jiralib-get-worklogs (issue-key &optional callback)
+ "Return all worklogs associated with issue ISSUE-KEY, invoking CALLBACK."
+ (jiralib-call "getWorklogs" callback issue-key))
+
+(defun jiralib-add-worklog (issue-id started time-spent-seconds comment &optional callback)
+ "Add the worklog linked to ISSUE-ID.
+
+Requires STARTED (a jira datetime), TIME-SPENT-SECONDS (integer) and a COMMENT.
+CALLBACK will be invoked if passed in upon endpoint completion."
+ ;; Call will fail if 0 seconds are set as the time, so always do at least one min.
+ (setq time-spent-seconds (max 60 time-spent-seconds))
+ (let ((worklog `((started . ,started)
+ ;; @TODO :worklog: timeSpentSeconds changes into incorrect values
+ ;; in the Jira API (for instance, 89600 = 1 day, but Jira thinks 3 days...
+ ;; We should convert to a Xd Xh Xm format from our seconds ourselves.
+ (timeSpentSeconds . ,time-spent-seconds)
+ (comment . ,comment))))
+ (jiralib-call "addWorklog" callback issue-id worklog)))
+
+(defun jiralib-update-worklog (issue-id worklog-id started time-spent-seconds comment &optional callback)
+ "Update the worklog linked to ISSUE-ID and WORKLOG-ID.
+
+Requires STARTED (a jira datetime), TIME-SPENT-SECONDS (integer) and a COMMENT.
+CALLBACK will be invoked if passed in upon endpoint completion."
+ ;; Call will fail if 0 seconds are set as the time, so always do at least one min.
+ (setq time-spent-seconds (max 60 time-spent-seconds))
+ (let ((worklog `((started . ,started)
+ ;; @TODO :worklog: timeSpentSeconds changes into incorrect values
+ ;; in the Jira API (for instance, 89600 = 1 day, but Jira thinks 3 days...
+ ;; We should convert to a Xd Xh Xm format from our seconds ourselves.
+ (timeSpentSeconds . ,time-spent-seconds)
+ (comment . ,comment))))
+ (jiralib-call "updateWorklog" callback issue-id worklog-id worklog)))
+
+(defvar jiralib-components-cache nil "An alist of project components.")
+
+(defun jiralib-get-components (project-key)
+ "Return all components available in the project PROJECT-KEY."
+ (unless (assoc project-key jiralib-components-cache)
+ (push (cons project-key
+ (jiralib-make-assoc-list
+ (jiralib-call "getComponents" nil project-key) 'id 'name))
+ jiralib-components-cache))
+ (cdr (assoc project-key jiralib-components-cache)))
+
+(defun jiralib-get-issue (issue-key &optional callback)
+ "Get the issue with key ISSUE-KEY, running CALLBACK after."
+ (jiralib-call "getIssue" callback issue-key))
+
+(defun jiralib-get-issues-from-filter (filter-id)
+ "Get the issues from applying saved filter FILTER-ID."
+ (message "jiralib-get-issues-from-filter is NOT IMPLEMENTED!! Do not use!")
+ (jiralib-call "getIssuesFromFilter" nil filter-id))
+
+(defun jiralib-get-issues-from-text-search (search-terms)
+ "Find issues using free text search SEARCH-TERMS."
+ (jiralib-call "getIssuesFromTextSearch" nil search-terms))
+
+(defun jiralib-get-issues-from-text-search-with-project
+ (project-keys search-terms max-num-results)
+ "Find issues in projects PROJECT-KEYS, using free text search SEARCH-TERMS.
+
+Return no more than MAX-NUM-RESULTS."
+ (jiralib-call "getIssuesFromTextSearchWithProject"
+ nil
+ (apply 'vector project-keys) search-terms max-num-results))
+
+;; Modified by Brian Zwahr to use getProjectsNoSchemes instead of getProjects
+(defun jiralib-get-projects ()
+ "Return a list of projects available to the user."
+ (if jiralib-projects-list
+ jiralib-projects-list
+ (setq jiralib-projects-list
+ (if jiralib-use-restapi
+ (jiralib-call "getProjects" nil)
+ (jiralib-call "getProjectsNoSchemes" nil)))))
+
+(defun jiralib-get-saved-filters ()
+ "Get all saved filters available for the currently logged in user."
+ (jiralib-make-assoc-list (jiralib-call "getSavedFilters" nil) 'id 'name))
+
+(defun jiralib-get-server-info ()
+ "Return the Server information such as baseUrl, version, edition, buildDate, buildNumber."
+ (jiralib-call "getServerInfo" nil))
+
+(defun jiralib-get-sub-task-issue-types ()
+ "Return all visible subtask issue types in the system."
+ (jiralib-call "getSubTaskIssueTypes" nil))
+
+(defun jiralib-get-user (account-id)
+ "Return a user's information given their full name."
+ (cond ((eq 0 (length account-id)) nil) ;; Unassigned
+ (t (jiralib-call "getUser" nil account-id))))
+
+(defvar jiralib-users-cache nil "Cached list of users.")
+
+(defun jiralib-get-users (project-key)
+ "Return assignable users information given the PROJECT-KEY."
+ (unless jiralib-users-cache
+ (setq jiralib-users-cache
+ (jiralib-call "getUsers" nil project-key))
+ (cl-loop for (name . id) in org-jira-users do
+ (setf jiralib-users-cache (append (list (jiralib-get-user id)) jiralib-users-cache))))
+ jiralib-users-cache)
+
+(defun jiralib-get-versions (project-key)
+ "Return all versions available in project PROJECT-KEY."
+ (jiralib-call "getVersions" nil project-key))
+
+(defun jiralib-strip-cr (string)
+ "Remove carriage returns from STRING."
+ (when string (replace-regexp-in-string "\r" "" string)))
+
+(defun jiralib-worklog-import--filter-apply
+ (worklog-obj &optional predicate-fn-lst unwrap-worklog-records-fn rewrap-worklog-records-fn)
+ "Remove non-matching org-jira issue worklogs.
+
+Variables:
+ WORKLOG-OBJ is the passed in object
+ PREDICATE-FN-LST is the list of lambdas used as match predicates.
+ UNWRAP-WORKLOG-RECORDS-FN is the function used to produce the list of worklog records from within the worklog-obj
+ REWRAP-WORKLOG-RECORDS-FN is the function used to reshape the worklog records back into the form they were received in.
+
+Auxiliary Notes:
+ Only the WORKLOG-OBJ variable is required.
+ The value of PPREDICATE-FN-LST is filled from the jiralib-worklog-import--filters-alist variable by default.
+ If PREDICATE-FN-LST is empty the unmodified value of WORKLOG-OBJ is returned.
+ If PREDICATE-FN-LST contains multiple predicate functions, each predicate filters operates as a clause in an AND match. In effect, a worklog must match all predicates to be returned.
+ The variable 'jiralib-user-login-name is used by many lambda filters."
+
+ (let
+ ((unwrap-worklog-records-fn)
+ (rewrap-worklog-records-fn)
+ (predicate-fn-lst)
+ (worklogs worklog-obj)
+ (predicate-fn))
+ ;; let-body
+ (progn
+ (setq unwrap-worklog-records-fn
+ (if (and
+ (boundp 'unwrap-worklog-records-fn)
+ (functionp unwrap-worklog-records-fn))
+ unwrap-worklog-records-fn
+ (lambda (x) (cl-coerce x 'list))))
+ (setq rewrap-worklog-records-fn
+ (if (and
+ (boundp 'rewrap-worklog-records-fn)
+ (functionp rewrap-worklog-records-fn))
+ rewrap-worklog-records-fn
+ (lambda (x) (remove 'nil (cl-coerce x 'vector)))))
+ (setq predicate-fn-lst
+ (if (and (boundp 'predicate-fn-lst)
+ (not (null predicate-fn-lst))
+ (listp predicate-fn-lst))
+ predicate-fn-lst
+ (mapcar 'caddr
+ (remove 'nil
+ (mapcar (lambda (x) (unless (null (car x)) x))
+ jiralib-worklog-import--filters-alist)))))
+ ;; final condition/sanity checks before processing
+ (cond
+ ;; pass cases, don't apply filters, return unaltered worklog-obj
+ ((or (not (boundp 'predicate-fn-lst)) (not (listp predicate-fn-lst)) (null predicate-fn-lst))
+ worklog-obj)
+ ;; default-case, apply worklog filters and return only matching worklogs
+ (t
+ (setq worklogs (funcall unwrap-worklog-records-fn worklogs))
+ (while (setq predicate-fn (pop predicate-fn-lst))
+ (setq worklogs (mapcar predicate-fn worklogs)))
+ (funcall rewrap-worklog-records-fn worklogs))))))
+
+
+(defun jiralib-get-board (id &optional callback)
+ "Return details on given board"
+ (jiralib-call "getBoard" nil id))
+
+(defun jiralib-get-boards ()
+ "Return list of jira boards"
+ (jiralib-call "getBoards" nil))
+
+(defun jiralib-get-board-sprints (id)
+ "Return list of jira sprints in the specified jira board"
+ (jiralib-call "getSprintsFromBoard" nil id))
+
+(defun jiralib-get-sprint-issues (id &rest params)
+ "Return list of issues in the specified sprint"
+ (apply 'jiralib-call "getIssuesFromSprint"
+ (cl-getf params :callback) id params))
+
+(defun jiralib-get-board-issues (board-id &rest params)
+ "Return list of jira issues in the specified jira board"
+ (apply 'jiralib-call "getIssuesFromBoard"
+ (cl-getf params :callback) board-id params))
+
+(defun jiralib--agile-not-last-entry (num-entries total start-at limit)
+ "Return true if need to retrieve next page from agile api"
+ (and (> num-entries 0)
+ (or (not limit) ; not required to be set
+ (< limit 1) ; ignore invalid limit
+ (> limit start-at))
+ (or (not total) ; not always returned
+ (> total start-at))))
+
+(defun jiralib--agile-limit-page-size (page-size start-at limit)
+ (if (and limit
+ (> (+ start-at page-size) limit))
+ (- limit start-at)
+ page-size))
+
+
+(defun jiralib--agile-rest-call-it (api max-results start-at limit query-params)
+ (let ((callurl
+ (format "%s?%s" api
+ (url-build-query-string
+ (append `((maxResults ,(jiralib--agile-limit-page-size max-results start-at limit))
+ (startAt ,start-at))
+ query-params)))))
+ (jiralib--rest-call-it callurl)))
+
+(defun jiralib--agile-call-it (api values-key &rest params)
+ "Invoke Jira agile method api and retrieve the results using
+paging.
+
+If JIRALIB-COMPLETE-CALLBACK is non-nil, then the call will be
+performed asynchronously and JIRALIB-COMPLETE-CALLBACK will be
+called when all data are retrieved.
+
+If JIRALIB-COMPLETE-CALLBACK is nil, then the call will be
+performed syncronously and this function will return the
+retrieved data.
+
+API - path to called API that must start with /rest/agile/1.0.
+
+VALUES-KEY - key of the actual reply data in the reply assoc list.
+
+PARAMS - optional additional parameters.
+:limit - limit total number of retrieved entries.
+:query-params - extra query parameters in the format of url-build-query-string.
+"
+ (if jiralib-complete-callback
+ (apply 'jiralib--agile-call-async api values-key params)
+ (apply 'jiralib--agile-call-sync api values-key params)))
+
+(defun jiralib--agile-call-sync (api values-key &rest params)
+ "Syncroniously invoke Jira agile method api retrieve all the
+results using paging and return results.
+
+VALUES-KEY - key of the actual reply data in the reply assoc list.
+
+PARAMS - extra parameters (as keyword arguments), the supported parameters are:
+
+:limit - limit total number of retrieved entries.
+:query-params - extra query parameters in the format of url-build-query-string.
+"
+ (setq jiralib-complete-callback nil)
+ (let ((not-last t)
+ (start-at 0)
+ (limit (cl-getf params :limit))
+ (query-params (cl-getf params :query-params))
+ ;; maximum page size, 50 is server side maximum
+ (max-results jiralib-agile-page-size)
+ (values ()))
+ (while not-last
+ (let* ((reply-alist
+ (jiralib--agile-rest-call-it api max-results start-at limit query-params))
+ (values-array (cdr (assoc values-key reply-alist)))
+ (num-entries (length values-array))
+ (total (cdr (assq 'total reply-alist))))
+ (setf values (append values (append values-array nil)))
+ (setf start-at (+ start-at num-entries))
+ (setf not-last (jiralib--agile-not-last-entry num-entries total start-at limit))))
+ values))
+
+(defun jiralib--agile-call-async (api values-key &rest params)
+ "Asyncroniously invoke Jira agile method api,
+retrieve all the results using paging and call
+JIRALIB-COMPLETE_CALLBACK when all the data are retrieved.
+
+VALUES-KEY - key of the actual reply data in the reply assoc list.
+
+PARAMS - extra parameters (as keyword arguments), the supported parameters are:
+
+limit - limit total number of retrieved entries."
+ (lexical-let
+ ((start-at 0)
+ (limit (cl-getf params :limit))
+ (query-params (cl-getf params :query-params))
+ ;; maximum page size, 50 is server side maximum
+ (max-results jiralib-agile-page-size)
+ (values-list ())
+ (vk values-key)
+ (url api)
+ ;; save the call back to be called later after the last page
+ (complete-callback jiralib-complete-callback))
+ ;; setup new callback to be called after each page
+ (setf jiralib-complete-callback
+ (cl-function
+ (lambda (&rest data &allow-other-keys)
+ (condition-case err
+ (let* ((reply-alist (cl-getf data :data))
+ (values-array (cdr (assoc vk reply-alist)))
+ (num-entries (length values-array))
+ (total (cdr (assq 'total reply-alist))))
+ (setf values-list (append values-list (append values-array nil)))
+ (setf start-at (+ start-at num-entries))
+ (message "jiralib agile retrieve: got %d values%s%s"
+ start-at
+ (if total " of " "")
+ (if total (int-to-string total) ""))
+ (if (jiralib--agile-not-last-entry num-entries total start-at limit)
+ (jiralib--agile-rest-call-it url max-results start-at limit query-params)
+ ;; last page: call originall callback
+ (message "jiralib agile retrieve: calling callback")
+ (setf jiralib-complete-callback complete-callback)
+ (funcall jiralib-complete-callback
+ :data (list (cons vk values-list)))
+ (message "jiralib agile retrieve: all done")))
+ ('error (message (format "jiralib agile retrieve: caught error: %s" err)))))))
+ (jiralib--agile-rest-call-it api max-results start-at limit query-params)))
+
+(provide 'jiralib)
+;;; jiralib.el ends here
--- /dev/null
+;;; org-jira.el --- Syncing between Jira and Org-mode. -*- lexical-binding: t -*-
+
+;; Copyright (C) 2016-2022 Matthew Carter <mNOSPAMahungry.com>
+;; Copyright (C) 2011 Bao Haojun
+;;
+;; Authors:
+;; Matthew Carter <mNOSPAMahungry.com>
+;; Bao Haojun <baohaojunNOSPAMgmail.com>
+;;
+;; Maintainer: Matthew Carter <mNOSPAMahungry.com>
+;; URL: https://github.com/ahungry/org-jira
+;; Version: 4.3.3
+;; Keywords: ahungry jira org bug tracker
+;; Package-Requires: ((emacs "24.5") (cl-lib "0.5") (request "0.2.0") (dash "2.14.1"))
+
+;; This file is not part of GNU Emacs.
+
+;; 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/> or write to the Free Software
+;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+;; 02110-1301, USA.
+
+;;; Commentary:
+
+;; This provides an extension to org-mode for syncing issues with JIRA
+;; issue servers.
+
+;;; News:
+
+;;;; Changes in 4.4.1
+;; - Fix tag (4.3.3 was out of order - we had a 4.4.0 on repo)
+;; - Fix for some crazy scoping issue in the org-jira-get-issue-val-from-org function
+
+;;;; Changes in 4.3.3:
+;; - Address issue with assignee property being removed when Unassigned
+
+;;;; Changes in 4.3.2:
+;; - Fixes issues with org-jira-add-comment and org-jira-update-comment
+
+;;;; Changes in 4.3.1:
+;; - Fix to make custom-jql results sync worklogs properly.
+
+;;;; Changes in 4.3.0:
+;; - Allow org-jira-set-issue-reporter call to dynamically set this value.
+
+;;;; Changes in 4.1.0:
+;; - Allow custom-jql to be specified and render in special files (see: README.md).
+
+;;;; Changes in 4.0.0:
+;; - Introduce SDK type for handling records vs random alist structures.
+
+;;;; Changes since 3.1.0:
+;; - Fix how we were ruining the kill-ring with kill calls.
+
+;;;; Changes since 3.0.0:
+;; - Add new org-jira-add-comment call (C-c c c)
+
+;;;; Changes since 2.8.0:
+;; - New version 3.0.0 deprecates old filing mechanism and files
+;; all of the changes under the top level ticket header.
+;; - If you want other top level headers in the same file, this should
+;; work now, as long as they come after the main project one.
+
+;;;; Changes since 2.7.0:
+;; - Clean up multi-buffer handling, disable attachments call until
+;; - refresh is compatible with it.
+
+;;;; Changes since 2.6.3:
+;; - Insert worklog import filter in the existing org-jira-update-worklogs-for-current-issue function
+;; - Sync up org-clocks and worklogs! Set org-jira-worklog-sync-p to nil to avoid.
+
+;;;; Changes since 2.6.1:
+;; - Fix bug with getting all issues when worklog is an error trigger.
+
+;;;; Changes since 2.5.4:
+;; - Added new org-jira-refresh-issues-in-buffer call and binding
+
+;;;; Changes since 2.5.3:
+;; - Re-introduce the commit that introduced a break into Emacs 25.1.1 list/array push
+;; The commit caused updates/comment updates to fail when a blank list of components
+;; was present - it will now handle both cases (full list, empty list).
+
+;;;; Changes since 2.5.2:
+;; - Revert a commit that introduced a break into Emacs 25.1.1 list/array push
+;; The commit caused updates/comment updates to fail
+
+;;;; Changes since 2.5.1:
+;; - Only set duedate if a DEADLINE is present in the tags and predicate is t
+
+;;;; Changes since 2.5.0:
+;; - Allow overriding the org property names with new defcustom
+
+;;;; Changes since 2.4.0:
+;; - Fix many deprecation/warning issues
+;; - Fix error with allow-other-keys not being wrapped in cl-function
+
+;;;; Changes since 2.3.0:
+;; - Integration with org deadline and Jira due date fields
+
+;;;; Changes since 2.2.0:
+;; - Selecting issue type based on project key for creating issues
+
+;;;; Changes since 2.1.0:
+;; - Allow changing to unassigned user
+;; - Add new shortcut for quick assignment
+
+;;;; Changes since 2.0.0:
+;; - Change statusCategory to status value
+;; - Clean out some redundant code
+;; - Add ELPA tags in keywords
+
+;;;; Changes since 1.0.1:
+;; - Converted many calls to async
+;; - Removed minor annoyances (position resets etc.)
+
+;;; Code:
+
+(eval-when-compile (require 'cl))
+(require 'org)
+(require 'org-clock)
+(require 'cl-lib)
+(require 'url)
+(require 'ls-lisp)
+(require 'dash)
+(require 'jiralib)
+(require 'org-jira-sdk)
+
+(defconst org-jira-version "4.3.1"
+ "Current version of org-jira.el.")
+
+(defgroup org-jira nil
+ "Customization group for org-jira."
+ :tag "Org JIRA"
+ :group 'org)
+
+(defcustom org-jira-working-dir "~/.org-jira"
+ "Folder under which to store org-jira working files."
+ :group 'org-jira
+ :type 'directory)
+
+(defcustom org-jira-project-filename-alist nil
+ "Alist translating project keys to filenames.
+
+Each element has a structure:
+
+ (PROJECT-KEY . NEW-FILE-NAME)
+
+where both are strings. NEW-FILE-NAME is relative to
+`org-jira-working-dir'."
+ :group 'org-jira
+ :type '(alist :key-type (string :tag "Project key")
+ :value-type (string :tag "File name for this project")))
+
+(defcustom org-jira-default-jql
+ "assignee = currentUser() and resolution = unresolved ORDER BY
+ priority DESC, created ASC"
+ "Default jql for querying your Jira tickets."
+ :group 'org-jira
+ :type 'string)
+
+(defcustom org-jira-ignore-comment-user-list
+ '("admin")
+ "Jira usernames that should have comments ignored."
+ :group 'org-jira
+ :type '(repeat (string :tag "Jira username:")))
+
+(defcustom org-jira-reverse-comment-order nil
+ "If non-nil, order comments from most recent to least recent."
+ :group 'org-jira
+ :type 'boolean)
+
+(defcustom org-jira-done-states
+ '("Closed" "Resolved" "Done")
+ "Jira states that should be considered as DONE for `org-mode'."
+ :group 'org-jira
+ :type '(repeat (string :tag "Jira state name:")))
+
+(defcustom org-jira-users
+ '(("Full Name" . "account-id"))
+ "A list of displayName and key pairs."
+ :group 'org-jira
+ :type 'list)
+
+(defcustom org-jira-progress-issue-flow
+ '(("To Do" . "In Progress")
+ ("In Progress" . "Done"))
+ "Quickly define a common issue flow."
+ :group 'org-jira
+ :type 'list)
+
+(defcustom org-jira-property-overrides (list)
+ "An assoc list of property tag overrides.
+
+The KEY . VAL pairs should both be strings.
+
+For instance, to change the :assignee: property in the org :PROPERTIES:
+block to :WorkedBy:, you can set this as such:
+
+ (setq org-jira-property-overrides (list (cons \"assignee\" \"WorkedBy\")))
+
+or simply:
+
+ (add-to-list (quote org-jira-property-overrides)
+ (cons (\"assignee\" \"WorkedBy\")))
+
+This will work for most any of the properties, with the
+exception of ones with unique functionality, such as:
+
+ - deadline
+ - summary
+ - description"
+ :group 'org-jira
+ :type 'list)
+
+(defcustom org-jira-serv-alist nil
+ "Association list to set information for each jira server.
+Each element of the alist is a jira server name. The CAR of each
+element is a string, uniquely identifying the server. The CDR of
+each element is a well-formed property list with an even number
+of elements, alternating keys and values, specifying parameters
+for the server.
+
+ (:property value :property value ... )
+
+When a property is given a value in org-jira-serv-alist, its
+setting overrides the value of the corresponding user
+variable (if any) during syncing.
+
+Most properties are optional, but some should always be set:
+
+ :url soap url of the jira server.
+ :username username to be used.
+ :host hostname of the jira server (TODO: compute it from ~url~).
+
+All the other properties are optional. They override the global
+variables.
+
+ :password password to be used, will be prompted if missing."
+ :group 'org-jira
+ :type '(alist :value-type plist))
+
+(defcustom org-jira-use-status-as-todo nil
+ "Use the JIRA status as the TODO tag value."
+ :group 'org-jira)
+
+(defcustom org-jira-deadline-duedate-sync-p t
+ "Keep org deadline and jira duedate fields synced.
+You may wish to set this to nil if you track org deadlines in
+your buffer that you do not want to send back to your Jira
+instance."
+ :group 'org-jira
+ :type 'boolean)
+
+(defcustom org-jira-worklog-sync-p t
+ "Keep org clocks and jira worklog fields synced.
+You may wish to set this to nil if you track org clocks in
+your buffer that you do not want to send back to your Jira
+instance."
+ :group 'org-jira
+ :type 'boolean)
+
+(defcustom org-jira-download-dir "~/Downloads"
+ "Name of the default jira download library."
+ :group 'org-jira
+ :type 'string)
+
+(defcustom org-jira-download-ask-override t
+ "Ask before overriding tile."
+ :group 'org-jira
+ :type 'boolean)
+
+(defcustom org-jira-jira-status-to-org-keyword-alist nil
+ "Custom alist of jira status stored in car and `org-mode' keyword stored in cdr."
+ :group 'org-jira
+ :type '(alist :key-type string :value-type string))
+
+(defcustom org-jira-priority-to-org-priority-omit-default-priority nil
+ "Whether to omit insertion of priority when it matches the default.
+
+When set to t, will omit the insertion of the matched value from
+`org-jira-priority-to-org-priority-alist' when it matches the `org-default-priority'."
+ :group 'org-jira
+ :type 'boolean)
+
+(defcustom org-jira-priority-to-org-priority-alist nil
+ "Alist mapping jira priority keywords to `org-mode' priority cookies.
+
+A sample value might be
+ (list (cons \"High\" ?A)
+ (cons \"Medium\" ?B)
+ (cons \"Low\" ?C)).
+
+See `org-default-priority' for more info."
+ :group 'org-jira
+ :type '(alist :key-type string :value-type character))
+
+(defcustom org-jira-boards-default-limit 50
+ "Default limit for number of issues retrieved from agile boards."
+ :group 'org-jira
+ :type 'integer)
+
+;; FIXME: Issue with using this - issues are grouped under a headline incorrectly.
+(defcustom org-jira-custom-jqls
+ '(
+ (:jql " assignee = currentUser() and createdDate < '2019-01-01' order by created DESC "
+ :limit 100
+ :filename "last-years-work")
+ (:jql " assignee = currentUser() and createdDate >= '2019-01-01' order by created DESC "
+ :limit 100
+ :filename "this-years-work")
+ )
+ "A list of plists with :jql and :filename keys to run arbitrary user JQL."
+ :group 'org-jira
+ :type '(alist :value-type plist))
+
+(defcustom org-jira-download-comments t
+ "Set to nil if you don't want to update comments during issue rendering."
+ :group 'org-jira
+ :type 'boolean)
+
+(defvar org-jira-serv nil
+ "Parameters of the currently selected blog.")
+
+(defvar org-jira-serv-name nil
+ "Name of the blog, to pick from `org-jira-serv-alist'.")
+
+(defvar org-jira-projects-list nil
+ "List of jira projects.")
+
+(defvar org-jira-current-project nil
+ "Currently selected (i.e., active project).")
+
+(defvar org-jira-issues-list nil
+ "List of jira issues under the current project.")
+
+(defvar org-jira-server-rpc-url nil
+ "Jira server soap URL.")
+
+(defvar org-jira-server-userid nil
+ "Jira server user id.")
+
+(defvar org-jira-proj-id nil
+ "Jira project ID.")
+
+(defvar org-jira-logged-in nil
+ "Flag whether user is logged-in or not.")
+
+(defvar org-jira-buffer-name "*org-jira-%s*"
+ "Name of the jira buffer.")
+
+(defvar org-jira-buffer-kill-prompt t
+ "Ask before killing buffer.")
+
+(make-variable-buffer-local 'org-jira-buffer-kill-prompt)
+
+(defvar org-jira-mode-hook nil
+ "Hook to run upon entry into mode.")
+
+(defvar org-jira-issue-id-history '()
+ "Prompt history for issue id.")
+
+(defvar org-jira-fixversion-id-history '()
+ "Prompt history for fixversion id.")
+
+(defvar org-jira-verbosity 'debug)
+
+(defun org-jira-log (s) (when (eq 'debug org-jira-verbosity) (message "%s" s)))
+
+(defmacro ensure-on-issue (&rest body)
+ "Make sure we are on an issue heading, before executing BODY."
+ (declare (debug t)
+ (indent 0))
+ `(save-excursion
+ (save-restriction
+ (widen)
+ (unless (looking-at "^\\*\\* ")
+ (search-backward-regexp "^\\*\\* " nil t)) ; go to top heading
+ (let ((org-jira-id (org-jira-id)))
+ (unless (and org-jira-id (string-match (jiralib-get-issue-regexp) (downcase org-jira-id)))
+ (error "Not on an issue region!")))
+ ,@body)))
+
+(defmacro org-jira-with-callback (&rest body)
+ "Simpler way to write the data BODY callbacks."
+ (declare (debug t)
+ (indent 0))
+ `(lambda (&rest request-response)
+ (declare (ignore cb-data))
+ (let ((cb-data (cl-getf request-response :data)))
+ ,@body)))
+
+(defmacro org-jira-freeze-ui (&rest body)
+ "Freeze the UI layout for the user as much as possible."
+ (declare (debug t)
+ (indent 0))
+ `(save-excursion
+ (save-restriction
+ (widen)
+ (org-save-outline-visibility t
+ (outline-show-all)
+ ,@body))))
+
+(defvar org-jira-proj-key-override nil
+ "String. An override for the proj-key. Set to nil to restore old behavior.")
+
+;; We want some hooking system to override default-jql + this.
+(defun org-jira--get-proj-key (issue-id)
+ "Get the proper proj-key. Typically derived from ISSUE-ID."
+ (if org-jira-proj-key-override org-jira-proj-key-override
+ (replace-regexp-in-string "-.*" "" issue-id)))
+
+(defun org-jira--get-proj-key-from-issue (Issue)
+ "Get the proper proj-key from an ISSUE instance."
+ (oref Issue filename))
+
+;; TODO: Merge these 3 ensure macros (or, scrap all but ones that work on Issue)
+(defmacro ensure-on-issue-id (issue-id &rest body)
+ "Just do some work on ISSUE-ID, execute BODY."
+ (declare (debug t)
+ (indent 1))
+ (let ((issue-id-var (make-symbol "issue-id")))
+ `(let* ((,issue-id-var ,issue-id)
+ (proj-key (org-jira--get-proj-key ,issue-id-var))
+ (project-file (org-jira--get-project-file-name proj-key))
+ (project-buffer (or (find-buffer-visiting project-file)
+ (find-file project-file))))
+ (with-current-buffer project-buffer
+ (org-jira-freeze-ui
+ (let ((p (org-find-entry-with-id ,issue-id-var)))
+ (unless p (error "Issue %s not found!" ,issue-id-var))
+ (goto-char p)
+ (org-narrow-to-subtree)
+ ,@body))))))
+
+(defmacro ensure-on-issue-id-with-filename (issue-id filename &rest body)
+ "Just do some work on ISSUE-ID, execute BODY."
+ (declare (debug t)
+ (indent 1))
+ (let ((issue-id-var (make-symbol "issue-id"))
+ (filename-var (make-symbol "filename")))
+ `(let* ((,issue-id-var ,issue-id)
+ (,filename-var ,filename)
+ (proj-key ,filename-var)
+ (project-file (org-jira--get-project-file-name proj-key))
+ (project-buffer (or (find-buffer-visiting project-file)
+ (find-file project-file))))
+ (with-current-buffer project-buffer
+ (org-jira-freeze-ui
+ (let ((p (org-find-entry-with-id ,issue-id-var)))
+ (unless p (error "Issue %s not found!" ,issue-id-var))
+ (goto-char p)
+ (org-narrow-to-subtree)
+ ,@body))))))
+
+(defmacro ensure-on-issue-Issue (Issue &rest body)
+ "Just do some work on ISSUE, execute BODY."
+ (declare (debug t)
+ (indent 1))
+ (let ((Issue-var (make-symbol "Issue")))
+ `(let ((,Issue-var ,Issue))
+ (with-slots (issue-id) ,Issue-var
+ (let* ((proj-key (org-jira--get-proj-key-from-issue ,Issue-var))
+ (project-file (org-jira--get-project-file-name proj-key))
+ (project-buffer (or (find-buffer-visiting project-file)
+ (find-file project-file))))
+ (with-current-buffer project-buffer
+ (org-jira-freeze-ui
+ (let ((p (org-find-entry-with-id issue-id)))
+ (unless p (error "Issue %s not found!" issue-id))
+ (goto-char p)
+ (org-narrow-to-subtree)
+ ,@body))))))))
+
+(defmacro ensure-on-todo (&rest body)
+ "Make sure we are on an todo heading, before executing BODY."
+ (declare (debug t)
+ (indent 0))
+ `(save-excursion
+ (save-restriction
+ (let ((continue t)
+ (on-todo nil))
+ (while continue
+ (when (org-get-todo-state)
+ (setq continue nil on-todo t))
+ (unless (and continue (org-up-heading-safe))
+ (setq continue nil)))
+ (if (not on-todo)
+ (error "TODO not found")
+ (org-narrow-to-subtree)
+ ,@body)))))
+
+(defmacro ensure-on-comment (&rest body)
+ "Make sure we are on a comment heading, before executing BODY."
+ (declare (debug t)
+ (indent 0))
+ `(save-excursion
+ (org-back-to-heading)
+ (forward-thing 'whitespace)
+ (unless (looking-at "Comment:")
+ (error "Not on a comment region!"))
+ (save-restriction
+ (org-narrow-to-subtree)
+ ,@body)))
+
+(defmacro ensure-on-worklog (&rest body)
+ "Make sure we are on a worklog heading, before executing BODY."
+ (declare (debug t)
+ (indent 0))
+ `(save-excursion
+ (org-back-to-heading)
+ (forward-thing 'whitespace)
+ (unless (looking-at "Worklog:")
+ (error "Not on a worklog region!"))
+ (save-restriction
+ (org-narrow-to-subtree)
+ ,@body)))
+
+(defun org-jira--ensure-working-dir ()
+ "Ensure that the org-jira-working-dir exists"
+ (unless (file-exists-p org-jira-working-dir)
+ (error (format "org-jira directory does not exist! Run (make-directory \"%s\")" org-jira-working-dir))
+ )
+ org-jira-working-dir
+ )
+
+(defvar org-jira-entry-mode-map
+ (let ((org-jira-map (make-sparse-keymap)))
+ (define-key org-jira-map (kbd "C-c pg") 'org-jira-get-projects)
+ (define-key org-jira-map (kbd "C-c bg") 'org-jira-get-boards)
+ (define-key org-jira-map (kbd "C-c iv") 'org-jira-get-issues-by-board)
+ (define-key org-jira-map (kbd "C-c ib") 'org-jira-browse-issue)
+ (define-key org-jira-map (kbd "C-c ig") 'org-jira-get-issues)
+ (define-key org-jira-map (kbd "C-c ij") 'org-jira-get-issues-from-custom-jql)
+ (define-key org-jira-map (kbd "C-c ih") 'org-jira-get-issues-headonly)
+ ;;(define-key org-jira-map (kbd "C-c if") 'org-jira-get-issues-from-filter-headonly)
+ ;;(define-key org-jira-map (kbd "C-c iF") 'org-jira-get-issues-from-filter)
+ (define-key org-jira-map (kbd "C-c iu") 'org-jira-update-issue)
+ (define-key org-jira-map (kbd "C-c iw") 'org-jira-progress-issue)
+ (define-key org-jira-map (kbd "C-c in") 'org-jira-progress-issue-next)
+ (define-key org-jira-map (kbd "C-c ia") 'org-jira-assign-issue)
+ ;(define-key org-jira-map (kbd "C-c isr") 'org-jira-set-issue-reporter)
+ (define-key org-jira-map (kbd "C-c ir") 'org-jira-refresh-issue)
+ (define-key org-jira-map (kbd "C-c iR") 'org-jira-refresh-issues-in-buffer)
+ (define-key org-jira-map (kbd "C-c ic") 'org-jira-create-issue)
+ (define-key org-jira-map (kbd "C-c ik") 'org-jira-copy-current-issue-key)
+ (define-key org-jira-map (kbd "C-c sc") 'org-jira-create-subtask)
+ (define-key org-jira-map (kbd "C-c sg") 'org-jira-get-subtasks)
+ (define-key org-jira-map (kbd "C-c cc") 'org-jira-add-comment)
+ (define-key org-jira-map (kbd "C-c cu") 'org-jira-update-comment)
+ (define-key org-jira-map (kbd "C-c wu") 'org-jira-update-worklogs-from-org-clocks)
+ (define-key org-jira-map (kbd "C-c tj") 'org-jira-todo-to-jira)
+ (define-key org-jira-map (kbd "C-c if") 'org-jira-get-issues-by-fixversion)
+ org-jira-map))
+
+;;;###autoload
+(define-minor-mode org-jira-mode
+ "Toggle org-jira mode.
+With no argument, the mode is toggled on/off.
+Non-nil argument turns mode on.
+Nil argument turns mode off.
+
+Commands:
+\\{org-jira-entry-mode-map}
+
+Entry to this mode calls the value of `org-jira-mode-hook'."
+
+ :init-value nil
+ :lighter " jira"
+ :group 'org-jira
+ :keymap org-jira-entry-mode-map
+
+ (if org-jira-mode
+ (progn
+ (set (make-local-variable 'org-element-use-cache) nil)
+ (run-mode-hooks 'org-jira-mode-hook))
+ (progn
+ (kill-local-variable 'org-element-use-cache))))
+
+(defun org-jira-maybe-activate-mode ()
+ "Having hooks can be an expensive operation, and they are invoked each
+time the mode is started - we should only ever re-activate the mode if
+it isn't already on."
+ (unless (bound-and-true-p org-jira-mode) (org-jira-mode t)))
+
+(defun org-jira-get-project-name (proj)
+ (org-jira-find-value proj 'key))
+
+(defun org-jira-find-value (l &rest keys)
+ (let* (key exists)
+ (while (and keys (listp l))
+ (setq key (car keys))
+ (setq exists nil)
+ (mapc (lambda (item)
+ (when (equal key (car item))
+ (setq exists t)))
+ (if (and (listp l)
+ (listp (car l)))
+ l
+ nil))
+ (setq keys (cdr keys))
+ (if exists
+ (setq l (cdr (assoc key l)))
+ (setq l (or (cdr (assoc key l)) l))))
+ l))
+
+(defun org-jira--get-project-file-name (project-key)
+ "Translate PROJECT-KEY into filename."
+ (-if-let (translation (cdr (assoc project-key org-jira-project-filename-alist)))
+ (expand-file-name translation (org-jira--ensure-working-dir))
+ (expand-file-name (concat project-key ".org") (org-jira--ensure-working-dir))))
+
+(defun org-jira-get-project-lead (proj)
+ (org-jira-find-value proj 'lead 'name))
+
+;; This is mapped to accountId and not username, so we need nil not blank string.
+(defun org-jira-get-assignable-users (project-key)
+ "Get the list of assignable users for PROJECT-KEY, adding user set jira-users first."
+ (append
+ '(("Unassigned" . nil))
+ org-jira-users
+ (mapcar (lambda (user)
+ (cons (org-jira-decode (cdr (assoc 'displayName user)))
+ (org-jira-decode (cdr (assoc 'accountId user)))))
+ (jiralib-get-users project-key))))
+
+(defun org-jira-get-reporter-candidates (project-key)
+ "Get the list of assignable users for PROJECT-KEY, adding user set jira-users first."
+ (append
+ org-jira-users
+ (mapcar (lambda (user)
+ (cons (org-jira-decode (cdr (assoc 'displayName user)))
+ (org-jira-decode (cdr (assoc 'accountId user)))))
+ (jiralib-get-users project-key))))
+
+(defun org-jira-entry-put (pom property value)
+ "Similar to org-jira-entry-put, but with an optional alist of overrides.
+
+At point-or-marker POM, set PROPERTY to VALUE.
+
+Look at customizing org-jira-property-overrides if you want
+to change the property names this sets."
+ (unless (stringp property)
+ (setq property (symbol-name property)))
+ (let ((property (or (assoc-default property org-jira-property-overrides)
+ property)))
+ (org-entry-put pom property (org-jira-decode value))))
+
+(defun org-jira-kill-line ()
+ "Kill the line, without 'kill-line' side-effects of altering kill ring."
+ (interactive)
+ (delete-region (point) (line-end-position)))
+
+;; Appropriated from org.el
+(defun org-jira-org-kill-line (&optional _arg)
+ "Kill line, to tags or end of line."
+ (cond
+ ((or (not org-special-ctrl-k)
+ (bolp)
+ (not (org-at-heading-p)))
+ (when (and (get-char-property (min (point-max) (point-at-eol)) 'invisible)
+ org-ctrl-k-protect-subtree
+ (or (eq org-ctrl-k-protect-subtree 'error)
+ (not (y-or-n-p "Kill hidden subtree along with headline? "))))
+ (user-error "C-k aborted as it would kill a hidden subtree"))
+ (call-interactively
+ (if (bound-and-true-p visual-line-mode) 'kill-visual-line 'org-jira-kill-line)))
+ ((looking-at ".*?\\S-\\([ \t]+\\(:[[:alnum:]_@#%:]+:\\)\\)[ \t]*$")
+ (delete-region (point) (match-beginning 1))
+ (org-set-tags nil t))
+ (t (delete-region (point) (point-at-eol)))))
+
+;;;###autoload
+(defun org-jira-get-projects ()
+ "Get list of projects."
+ (interactive)
+ (let ((projects-file (expand-file-name "projects-list.org" (org-jira--ensure-working-dir))))
+ (or (find-buffer-visiting projects-file)
+ (find-file projects-file))
+ (org-jira-maybe-activate-mode)
+ (save-excursion
+ (let* ((oj-projs (jiralib-get-projects)))
+ (mapc (lambda (proj)
+ (let* ((proj-key (org-jira-find-value proj 'key))
+ (proj-headline (format "Project: [[file:%s.org][%s]]" proj-key proj-key)))
+ (save-restriction
+ (widen)
+ (goto-char (point-min))
+ (outline-show-all)
+ (setq p (org-find-exact-headline-in-buffer proj-headline))
+ (if (and p (>= p (point-min))
+ (<= p (point-max)))
+ (progn
+ (goto-char p)
+ (org-narrow-to-subtree)
+ (end-of-line))
+ (goto-char (point-max))
+ (unless (looking-at "^")
+ (insert "\n"))
+ (insert "* ")
+ (org-jira-insert proj-headline)
+ (org-narrow-to-subtree))
+ (org-jira-entry-put (point) "name" (org-jira-get-project-name proj))
+ (org-jira-entry-put (point) "key" (org-jira-find-value proj 'key))
+ (org-jira-entry-put (point) "lead" (org-jira-get-project-lead proj))
+ (org-jira-entry-put (point) "ID" (org-jira-find-value proj 'id))
+ (org-jira-entry-put (point) "url" (format "%s/browse/%s" (replace-regexp-in-string "/*$" "" jiralib-url) (org-jira-find-value proj 'key))))))
+ oj-projs)))))
+
+(defun org-jira-get-issue-components (issue)
+ "Return the components the ISSUE belongs to."
+ (mapconcat
+ (lambda (comp)
+ (org-jira-find-value comp 'name))
+ (org-jira-find-value issue 'fields 'components) ", "))
+
+(defun org-jira-get-issue-labels (issue)
+ "Return the labels the ISSUE belongs to."
+ (org-jira-find-value issue 'fields 'labels))
+
+(defun org-jira-decode (data)
+ "Decode text DATA.
+
+It must receive a coercion to string, as not every time will it
+be populated."
+ (decode-coding-string
+ (cl-coerce data 'string) jiralib-coding-system))
+
+(defun org-jira-insert (&rest args)
+ "Set coding to text provide by `ARGS' when insert in buffer."
+ (insert (org-jira-decode (apply #'concat args))))
+
+(defun org-jira-transform-time-format (jira-time-str)
+ "Convert JIRA-TIME-STR to format \"%Y-%m-%d %T\".
+
+Example: \"2012-01-09T08:59:15.000Z\" becomes \"2012-01-09
+16:59:15\", with the current timezone being +0800."
+ (condition-case ()
+ (format-time-string
+ "%Y-%m-%d %T"
+ (apply
+ 'encode-time
+ (parse-time-string (replace-regexp-in-string "T\\|\\.000" " " jira-time-str))))
+ (error jira-time-str)))
+
+(defun org-jira--fix-encode-time-args (arg)
+ "Fix ARG for 3 nil values at the head."
+ (cl-loop
+ for n from 0 to 2 by 1 do
+ (when (not (nth n arg))
+ (setcar (nthcdr n arg) 0)))
+ arg)
+
+(defun org-jira-time-format-to-jira (org-time-str)
+ "Convert ORG-TIME-STR back to jira time format."
+ (condition-case ()
+ (format-time-string
+ "%Y-%m-%dT%T.000Z"
+ (apply 'encode-time
+ (org-jira--fix-encode-time-args (parse-time-string org-time-str)))
+ t)
+ (error org-time-str)))
+
+(defun org-jira-get-comment-val (key comment)
+ "Return the value associated with KEY of COMMENT."
+ (org-jira-get-issue-val key comment))
+
+(defun org-jira-time-stamp-to-org-clock (time-stamp)
+ "Convert TIME-STAMP into org-clock format."
+ (format-time-string "%Y-%m-%d %a %H:%M" time-stamp))
+
+(defun org-jira-date-to-org-clock (date)
+ "Convert DATE into a time stamp and then into org-clock format.
+Expects a date in format such as: 2017-02-26T00:08:00.000-0500."
+ (org-jira-time-stamp-to-org-clock (date-to-time date)))
+
+(defun org-jira-worklogs-to-org-clocks (worklogs)
+ "Get a list of WORKLOGS and convert to org-clocks."
+ (mapcar
+ (lambda (worklog)
+ (let ((wl-start (cdr (assoc 'started worklog)))
+ (wl-time (cdr (assoc 'timeSpentSeconds worklog)))
+ (wl-end))
+ (setq wl-start (org-jira-date-to-org-clock wl-start))
+ (setq wl-end (org-jira-time-stamp-to-org-clock (time-add (date-to-time wl-start) wl-time)))
+ (list
+ wl-start
+ wl-end
+ (cdr (assoc 'comment worklog))
+ (cdr (assoc 'id worklog))
+ )
+ ))
+ worklogs)
+ )
+
+(defun org-jira-format-clock (clock-entry)
+ "Format a CLOCK-ENTRY given the (list start end).
+This format is typically generated from org-jira-worklogs-to-org-clocks call."
+ (format "CLOCK: [%s]--[%s]" (car clock-entry) (cadr clock-entry)))
+
+(defun org-jira-insert-clock (clock-entry)
+ "Insert a CLOCK-ENTRY given the (list start end).
+This format is typically generated from org-jira-worklogs-to-org-clocks call."
+ (insert (org-jira-format-clock clock-entry))
+ (org-beginning-of-line)
+ (org-ctrl-c-ctrl-c) ;; @TODO Maybe not call directly? does it matter? - used to resync the clock estimate
+ (org-end-of-line)
+ (insert "\n")
+ (insert (format " :id: %s\n" (cadddr clock-entry)))
+ (when (caddr clock-entry) (insert (replace-regexp-in-string "^\\*" "-" (format " %s\n" (org-jira-decode (caddr clock-entry)))))) ;; No comment is nil, so don't print it
+ )
+
+(defun org-jira-logbook-reset (issue-id filename &optional clocks)
+ "Find logbook for ISSUE-ID in FILENAME, delete it.
+Re-create it with CLOCKS. This is used for worklogs."
+ (interactive)
+ (let ((existing-logbook-p nil))
+ ;; See if the LOGBOOK already exists or not.
+ (ensure-on-issue-id-with-filename issue-id filename
+ (let ((drawer-name (or (org-clock-drawer-name) "LOGBOOK")))
+ (when (search-forward (format ":%s:" drawer-name) nil 1 1)
+ (setq existing-logbook-p t))))
+ (ensure-on-issue-id-with-filename issue-id filename
+ (let ((drawer-name (or (org-clock-drawer-name) "LOGBOOK")))
+ (if existing-logbook-p
+ (progn ;; If we had a logbook, drop it and re-create in a bit.
+ (search-forward (format ":%s:" drawer-name) nil 1 1)
+ (org-beginning-of-line)
+ (delete-region (point) (search-forward ":END:" nil 1 1))
+ )
+ (progn ;; Otherwise, create a new one at the end of properties list
+ (search-forward ":END:" nil 1 1)
+ (forward-line)))
+ (org-insert-drawer nil (format "%s" drawer-name)) ;; Doc says non-nil, but this requires nil
+ (mapc #'org-jira-insert-clock clocks)
+ ;; Clean up leftover newlines (we left 2 behind)
+ (dotimes (n 2)
+ (search-forward-regexp "^$" nil 1 1)
+ (delete-region (point) (min (point-max) (1+ (point)))))))))
+
+(defun org-jira-get-worklog-val (key WORKLOG)
+ "Return the value associated with KEY of WORKLOG."
+ (org-jira-get-comment-val key WORKLOG))
+
+(defun org-jira-get-issue-val (key issue)
+ "Return the value associated with key KEY of issue ISSUE."
+ (let ((tmp (or (org-jira-find-value issue 'fields key 'key) ""))) ;; For project, we need a key, not the name...
+ (unless (stringp tmp)
+ (setq tmp (org-jira-find-value issue key)))
+ (unless (stringp tmp)
+ (setq tmp (org-jira-find-value issue 'fields key 'displayName)))
+ (unless (stringp tmp)
+ (setq tmp ""))
+ (cond ((eq key 'components)
+ (org-jira-get-issue-components issue))
+ ((eq key 'labels)
+ (org-jira-get-issue-labels issue))
+ ((member key '(created updated startDate))
+ (org-jira-transform-time-format tmp))
+ ((eq key 'status)
+ (if jiralib-use-restapi
+ (org-jira-find-value issue 'fields 'status 'name)
+ (org-jira-find-value (jiralib-get-statuses) tmp)))
+ ((eq key 'resolution)
+ (if jiralib-use-restapi
+ tmp
+ (if (string= tmp "")
+ ""
+ (org-jira-find-value (jiralib-get-resolutions) tmp))))
+ ((eq key 'type)
+ (if jiralib-use-restapi
+ (org-jira-find-value issue 'fields 'issuetype 'name)
+ (org-jira-find-value (jiralib-get-issue-types) tmp)))
+ ((eq key 'priority)
+ (if jiralib-use-restapi
+ (org-jira-find-value issue 'fields 'priority 'name)
+ (org-jira-find-value (jiralib-get-priorities) tmp)))
+ ((eq key 'description)
+ (org-trim tmp))
+ (t
+ tmp))))
+
+(defvar org-jira-jql-history nil)
+
+;;;###autoload
+(defun org-jira-get-issue-list (&optional callback)
+ "Get list of issues, using jql (jira query language), invoke CALLBACK after.
+
+Default is unresolved issues assigned to current login user; with
+a prefix argument you are given the chance to enter your own
+jql."
+ (org-jira-log (format "I was called, was it with a callback? %s" (if callback "yes" "no")))
+ (let ((jql org-jira-default-jql))
+ (when current-prefix-arg
+ (setq jql (read-string "Jql: "
+ (if org-jira-jql-history
+ (car org-jira-jql-history)
+ "assignee = currentUser() and resolution = unresolved")
+ 'org-jira-jql-history
+ "assignee = currentUser() and resolution = unresolved")))
+ (list (jiralib-do-jql-search jql nil callback))))
+
+(defun org-jira-get-issue-by-id (id)
+ "Get an issue by its ID."
+ (push id org-jira-issue-id-history)
+ (let ((jql (format "id = %s" id)))
+ (jiralib-do-jql-search jql)))
+
+(defun org-jira-get-issue-by-fixversion (fixversion-id)
+ "Get an issue by its FIXVERSION-ID."
+ (push fixversion-id org-jira-fixversion-id-history)
+ (let ((jql (format "fixVersion = \"%s\"" fixversion-id)))
+ (jiralib-do-jql-search jql)))
+
+;;;###autoload
+(defun org-jira-get-summary ()
+ "Get issue summary from point and place next to issue id from jira"
+ (interactive)
+ (let ((jira-id (thing-at-point 'symbol)))
+ (unless jira-id (error "ORG_JIRA_ERROR: JIRA-ID missing in org-jira-get-summary!"))
+ (forward-symbol 1)
+ (insert (format " - %s"
+ (cdr (assoc 'summary (assoc 'fields (car (org-jira-get-issue-by-id jira-id)))))))))
+
+;;;###autoload
+(defun org-jira-get-summary-url ()
+ "Get issue summary from point and place next to issue id from jira, and make issue id a link"
+ (interactive)
+ (let ((jira-id (thing-at-point 'symbol)))
+ (insert (format "[[%s][%s]] - %s"
+ (concatenate 'string jiralib-url "browse/" jira-id) jira-id
+ (cdr (assoc 'summary (car (org-jira-get-issue-by-id jira-id))))))))
+
+;;;###autoload
+(defun org-jira-get-issues-headonly (issues)
+ "Get list of ISSUES, head only.
+
+The default behavior is to return issues assigned to you and unresolved.
+
+With a prefix argument, allow you to customize the jql. See
+`org-jira-get-issue-list'."
+
+ (interactive
+ (org-jira-get-issue-list))
+
+ (let* ((issues-file (expand-file-name "issues-headonly.org" (org-jira--ensure-working-dir)))
+ (issues-headonly-buffer (or (find-buffer-visiting issues-file)
+ (find-file issues-file))))
+ (with-current-buffer issues-headonly-buffer
+ (widen)
+ (delete-region (point-min) (point-max))
+
+ (mapc (lambda (issue)
+ (let ((issue-id (org-jira-get-issue-key issue))
+ (issue-summary (org-jira-get-issue-summary issue)))
+ (org-jira-insert (format "- [jira:%s] %s\n" issue-id issue-summary))))
+ issues))
+ (switch-to-buffer issues-headonly-buffer)))
+
+;;;###autoload
+(defun org-jira-get-issue (id)
+ "Get a JIRA issue, allowing you to enter the issue-id first."
+ (interactive (list (read-string "Issue ID: " "" 'org-jira-issue-id-history)))
+ (org-jira-get-issues (org-jira-get-issue-by-id id))
+ (let ((issue-pos (org-find-entry-with-id id)))
+ (when issue-pos
+ (goto-char issue-pos)
+ (recenter 0))))
+;;;###autoload
+(defun org-jira-get-issues-by-fixversion (fixversion)
+ "Get list of issues by FIXVERSION."
+ (interactive (list (read-string "Fixversion ID: " ""
+ 'org-jira-fixversion-id-history)))
+ (org-jira-get-issues (org-jira-get-issue-by-fixversion fixversion)))
+
+;;;###autoload
+(defun org-jira-get-issue-project (issue)
+ (org-jira-find-value issue 'fields 'project 'key))
+
+(defun org-jira-get-issue-key (issue)
+ (org-jira-find-value issue 'key))
+
+(defun org-jira-get-issue-summary (issue)
+ (org-jira-find-value issue 'fields 'summary))
+
+(defvar org-jira-get-issue-list-callback
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ "Callback for async, DATA is the response from the request call.
+
+Will send a list of org-jira-sdk-issue objects to the list printer."
+ (org-jira-log "Received data for org-jira-get-issue-list-callback.")
+ (--> data
+ (org-jira-sdk-path it '(issues))
+ (append it nil) ; convert the conses into a proper list.
+ org-jira-sdk-create-issues-from-data-list
+ org-jira-get-issues))))
+
+(defvar org-jira-get-sprint-list-callback
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ "Callback for async, DATA is the response from the request call.
+
+Will send a list of org-jira-sdk-issue objects to the list printer."
+ (org-jira-log "Received data for org-jira-get-sprint-list-callback.")
+ (--> data
+ (org-jira-sdk-path it '(sprint))
+ (append it nil) ; convert the conses into a proper list.
+ org-jira-sdk-create-issues-from-data-list
+ org-jira-get-issues))))
+
+
+;;;###autoload
+(defun org-jira-get-issues (issues)
+ "Get list of ISSUES into an org buffer.
+
+Default is get unfinished issues assigned to you, but you can
+customize jql with a prefix argument.
+See`org-jira-get-issue-list'"
+ ;; If the user doesn't provide a default, async call to build an issue list
+ ;; from the JQL style query
+ (interactive
+ (org-jira-get-issue-list org-jira-get-issue-list-callback))
+ (org-jira-log "Fetching issues...")
+ (when (> (length issues) 0)
+ (org-jira--render-issues-from-issue-list issues)))
+
+(defvar org-jira-original-default-jql nil)
+
+(defun org-jira-get-issues-from-custom-jql-callback (filename list)
+ "Generate a function that we can iterate over FILENAME and LIST with when callback finishes."
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ "Callback for async, DATA is the response from the request call.
+
+Will send a list of org-jira-sdk-issue objects to the list printer."
+ (org-jira-log "Received data for org-jira-get-issues-from-custom-jql-callback.")
+ (--> data
+ (org-jira-sdk-path it '(issues))
+ (append it nil) ; convert the conses into a proper list.
+ (org-jira-sdk-create-issues-from-data-list-with-filename filename it)
+ org-jira-get-issues)
+ (setq org-jira-proj-key-override nil)
+ (let ((next (rest list)))
+ (when next
+ (org-jira-get-issues-from-custom-jql next))))))
+
+;;;###autoload
+(defun org-jira-get-issues-from-custom-jql (&optional jql-list)
+ "Get JQL-LIST list of issues from a custom JQL and PROJ-KEY.
+
+The PROJ-KEY will act as the file name, while the JQL will be any
+valid JQL to populate a file to store PROJ-KEY results in.
+
+Please note that this is *not* concurrent or race condition
+proof. If you try to run multiple calls to this function, it
+will mangle things badly, as they rely on globals DEFAULT-JQL and
+ORG-JIRA-PROJ-KEY-OVERRIDE being set before and after running."
+ (interactive)
+ (let* ((jl (or jql-list org-jira-custom-jqls))
+ (uno (car jl))
+ (filename (cl-getf uno :filename))
+ (limit (cl-getf uno :limit))
+ (jql (replace-regexp-in-string "[\n]" " " (cl-getf uno :jql))))
+ (setq org-jira-proj-key-override filename)
+ (jiralib-do-jql-search jql limit (org-jira-get-issues-from-custom-jql-callback filename jl))))
+
+(defun org-jira--get-project-buffer (Issue)
+ (let* ((proj-key (org-jira--get-proj-key-from-issue Issue))
+ (project-file (org-jira--get-project-file-name proj-key))
+ (project-buffer (find-file-noselect project-file)))
+ project-buffer))
+
+(defun org-jira--is-top-headline? (proj-key)
+ "For PROJ-KEY, check if it is a top headline or not."
+ (let ((elem (org-element-at-point)))
+ (and (eq 'headline (car elem))
+ (equal (format "%s-Tickets" proj-key)
+ (plist-get (cadr elem) :title))
+ (= 1 (plist-get (cadr elem) :level)))))
+
+(defun org-jira--maybe-render-top-heading (proj-key)
+ "Ensure that there is a headline for PROJ-KEY at the top of the file."
+ (goto-char (point-min))
+ (let ((top-heading (format ".*%s-Tickets" proj-key))
+ (th-found? nil))
+ (while (and (not (eobp))
+ (not th-found?))
+ (beginning-of-line)
+ (when (org-jira--is-top-headline? proj-key) (setq th-found? t))
+ (re-search-forward top-heading nil 1 1))
+ (beginning-of-line)
+ (unless (looking-at top-heading)
+ (insert (format "\n* %s-Tickets\n" proj-key)))))
+
+(defun org-jira--render-issue (Issue)
+ "Render single ISSUE."
+;; (org-jira-log "Rendering issue from issue list")
+;; (org-jira-log (org-jira-sdk-dump Issue))
+ (with-slots (filename proj-key issue-id summary status priority headline id) Issue
+ (let (p)
+ (with-current-buffer (org-jira--get-project-buffer Issue)
+ (org-jira-freeze-ui
+ (org-jira-maybe-activate-mode)
+ (org-jira--maybe-render-top-heading proj-key)
+ (setq p (org-find-entry-with-id issue-id))
+ (save-restriction
+ (if (and p (>= p (point-min))
+ (<= p (point-max)))
+ (progn
+ (goto-char p)
+ (forward-thing 'whitespace)
+ (org-jira-kill-line))
+ (goto-char (point-max))
+ (unless (looking-at "^")
+ (insert "\n"))
+ (insert "** "))
+ (org-jira-insert
+ (concat (org-jira-get-org-keyword-from-status status)
+ " "
+ (org-jira-get-org-priority-cookie-from-issue priority)
+ headline))
+ (save-excursion
+ (unless (search-forward "\n" (point-max) 1)
+ (insert "\n")))
+ (org-narrow-to-subtree)
+ (save-excursion
+ (org-back-to-heading t)
+ (org-set-tags-to (replace-regexp-in-string "-" "_" issue-id)))
+ (org-jira-entry-put (point) "assignee" (or (slot-value Issue 'assignee) "Unassigned"))
+ (mapc (lambda (entry)
+ (let ((val (slot-value Issue entry)))
+ (when (and val (not (string= val "")))
+ (org-jira-entry-put (point) (symbol-name entry) val))))
+ '(filename reporter type type-id priority labels resolution status components created updated sprint))
+
+ (org-jira-entry-put (point) "ID" issue-id)
+ (org-jira-entry-put (point) "CUSTOM_ID" issue-id)
+
+ ;; Insert the duedate as a deadline if it exists
+ (when org-jira-deadline-duedate-sync-p
+ (let ((duedate (oref Issue duedate)))
+ (when (> (length duedate) 0)
+ (org-deadline nil duedate))))
+
+ (mapc
+ (lambda (heading-entry)
+ (ensure-on-issue-id-with-filename issue-id filename
+ (let* ((entry-heading
+ (concat (symbol-name heading-entry)
+ (format ": [[%s][%s]]"
+ (concat jiralib-url "/browse/" issue-id) issue-id))))
+ (setq p (org-find-exact-headline-in-buffer entry-heading))
+ (if (and p (>= p (point-min))
+ (<= p (point-max)))
+ (progn
+ (goto-char p)
+ (org-narrow-to-subtree)
+ (goto-char (point-min))
+ (forward-line 1)
+ (delete-region (point) (point-max)))
+ (if (org-goto-first-child)
+ (org-insert-heading)
+ (goto-char (point-max))
+ (org-insert-subheading t))
+ (org-jira-insert entry-heading "\n"))
+
+ ;; Insert 2 spaces of indentation so Jira markup won't cause org-markup
+ (org-jira-insert
+ (replace-regexp-in-string
+ "^" " "
+ (format "%s" (slot-value Issue heading-entry)))))))
+ '(description))
+
+ (when org-jira-download-comments
+ (org-jira-update-comments-for-issue Issue)
+
+ ;; FIXME: Re-enable when attachments are not erroring.
+ ;;(org-jira-update-attachments-for-current-issue)
+ )
+
+ ;; only sync worklog clocks when the user sets it to be so.
+ (when org-jira-worklog-sync-p
+ (org-jira-update-worklogs-for-issue issue-id filename))))))))
+
+(defun org-jira--render-issues-from-issue-list (Issues)
+ "Add the issues from ISSUES list into the org file(s).
+
+ISSUES is a list of `org-jira-sdk-issue' records."
+ ;; FIXME: Some type of loading error - the first async callback does not know about
+ ;; the issues existing as a class, so we may need to instantiate here if we have none.
+ (when (eq 0 (->> Issues (cl-remove-if-not #'org-jira-sdk-isa-issue?) length))
+ (setq Issues (org-jira-sdk-create-issues-from-data-list Issues)))
+
+ ;; First off, we never ever want to run on non-issues, so check our types early.
+ (setq Issues (cl-remove-if-not #'org-jira-sdk-isa-issue? Issues))
+ (org-jira-log (format "About to render %d issues." (length Issues)))
+
+ ;; If we have any left, we map over them.
+ (mapc 'org-jira--render-issue Issues)
+
+ ;; Prior text: "Oh, are you the culprit?" - Not sure if this caused an issue at some point.
+ ;; We want to ensure we fix broken org narrowing though, by doing org-show-all and then org-cycle.
+ (switch-to-buffer (org-jira--get-project-buffer (-last-item Issues)))
+ (org-show-all)
+ (org-cycle))
+
+;;;###autoload
+(defun org-jira-update-comment ()
+ "Update a comment for the current issue."
+ (interactive)
+ (let* ((issue-id (org-jira-get-from-org 'issue 'key)) ; Really the key
+ (filename (org-jira-filename))
+ (comment-id (org-jira-get-from-org 'comment 'id))
+ (comment (replace-regexp-in-string "^ " "" (org-jira-get-comment-body comment-id))))
+ (lexical-let ((issue-id issue-id)
+ (filename filename))
+ (let ((callback-edit
+ (cl-function
+ (lambda (&key _data &allow-other-keys)
+ (ensure-on-issue-id-with-filename
+ issue-id filename
+ (org-jira-update-comments-for-current-issue)))))
+ (callback-add
+ (cl-function
+ (lambda (&key _data &allow-other-keys)
+ (ensure-on-issue-id-with-filename
+ issue-id filename
+ ;; @TODO :optim: Has to be a better way to do this
+ ;; than delete region (like update the unmarked
+ ;; one)
+ (org-jira-delete-current-comment)
+ (org-jira-update-comments-for-current-issue))))))
+ (if comment-id
+ (jiralib-edit-comment issue-id comment-id comment callback-edit)
+ (jiralib-add-comment issue-id comment callback-add))))))
+
+(defun org-jira-add-comment (issue-id filename comment)
+ "For ISSUE-ID in FILENAME, add a new COMMENT string to the issue region."
+ (interactive
+ (let* ((issue-id (org-jira-get-from-org 'issue 'id))
+ (filename (org-jira-filename))
+ (comment (read-string (format "Comment (%s): " issue-id))))
+ (list issue-id filename comment)))
+ (lexical-let ((issue-id issue-id)
+ (filename filename))
+ (ensure-on-issue-id-with-filename issue-id filename
+ (goto-char (point-max))
+ (jiralib-add-comment
+ issue-id comment
+ (cl-function
+ (lambda (&key _data &allow-other-keys)
+ (ensure-on-issue-id-with-filename issue-id filename
+ (org-jira-update-comments-for-current-issue))))))))
+
+(defun org-jira-org-clock-to-date (org-time)
+ "Convert ORG-TIME formatted date into a plain date string."
+ (format-time-string
+ "%Y-%m-%dT%H:%M:%S.000%z"
+ (date-to-time org-time)))
+
+(defun org-jira-worklog-time-from-org-time (org-time)
+ "Take in an ORG-TIME and convert it into the portions of a worklog time.
+Expects input in format such as: [2017-04-05 Wed 01:00]--[2017-04-05 Wed 01:46] => 0:46"
+ (let ((start (replace-regexp-in-string "^\\[\\(.*?\\)\\].*" "\\1" org-time))
+ (end (replace-regexp-in-string ".*--\\[\\(.*?\\)\\].*" "\\1" org-time)))
+ `((started . ,(org-jira-org-clock-to-date start))
+ (time-spent-seconds . ,(time-to-seconds
+ (time-subtract
+ (date-to-time end)
+ (date-to-time start)))))))
+
+(defun org-jira-org-clock-to-jira-worklog (org-time clock-content)
+ "Given ORG-TIME and CLOCK-CONTENT, format a jira worklog entry."
+ (let ((lines (split-string clock-content "\n"))
+ worklog-id)
+ ;; See if we look like we have an id
+ (when (string-match ":id:" (first lines))
+ (setq worklog-id
+ (replace-regexp-in-string "^.*:id: \\([0-9]*\\)$" "\\1" (first lines)))
+ (when (> (string-to-number worklog-id) 0) ;; pop off the first id line if we found it valid
+ (setq lines (cdr lines))))
+ (setq lines (reverse (cdr (reverse lines)))) ;; drop last line
+ (let ((comment (org-trim (mapconcat 'identity lines "\n")))
+ (worklog-time (org-jira-worklog-time-from-org-time org-time)))
+ `((worklog-id . ,worklog-id)
+ (comment . ,comment)
+ (started . ,(cdr (assoc 'started worklog-time)))
+ (time-spent-seconds . ,(cdr (assoc 'time-spent-seconds worklog-time)))
+ ))))
+
+(defun org-jira-worklog-to-hashtable (issue-id)
+ "Given ISSUE-ID, return a hashtable of worklog-id -> jira worklog."
+ (let ((worklog-hashtable (make-hash-table :test 'equal)))
+ (mapc
+ (lambda (worklog)
+ (let ((worklog-id (cdr (assoc 'id worklog))))
+ (puthash worklog-id worklog worklog-hashtable)))
+ (jiralib-worklog-import--filter-apply
+ (org-jira-find-value
+ (jiralib-get-worklogs
+ issue-id)
+ 'worklogs)))
+ worklog-hashtable))
+
+;;;###autoload
+(defun org-jira-update-worklogs-from-org-clocks ()
+ "Update or add a worklog based on the org clocks."
+ (interactive)
+ (let* ((issue-id (org-jira-get-from-org 'issue 'key))
+ (filename (org-jira-filename))
+ ;; Fetch all workflogs for this issue
+ (jira-worklogs-ht (org-jira-worklog-to-hashtable issue-id)))
+ (org-jira-log (format "About to sync worklog for issue: %s in file: %s"
+ issue-id filename))
+ (ensure-on-issue-id-with-filename issue-id filename
+ (search-forward (format ":%s:" (or (org-clock-drawer-name) "LOGBOOK")) nil 1 1)
+ (org-beginning-of-line)
+ ;; (org-cycle 1)
+ (while (search-forward "CLOCK: " nil 1 1)
+ (let ((org-time (buffer-substring-no-properties (point) (point-at-eol))))
+ (forward-line)
+ ;; See where the stuff ends (what point)
+ (let (next-clock-point)
+ (save-excursion
+ (search-forward-regexp "\\(CLOCK\\|:END\\):" nil 1 1)
+ (setq next-clock-point (point)))
+ (let ((clock-content
+ (buffer-substring-no-properties (point) next-clock-point)))
+ ;; Update via jiralib call
+ (let* ((worklog (org-jira-org-clock-to-jira-worklog org-time clock-content))
+ (comment-text (cdr (assoc 'comment worklog)))
+ (comment-text (if (string= (org-trim comment-text) "") nil comment-text)))
+ (if (cdr (assoc 'worklog-id worklog))
+ ;; If there is a worklog in jira for this ID, check if the worklog has changed.
+ ;; If it has changed, update the worklog.
+ ;; If it has not changed, skip.
+ (let ((jira-worklog (gethash (cdr (assoc 'worklog-id worklog)) jira-worklogs-ht)))
+ (when (and jira-worklog
+ ;; Check if the entries are differing lengths.
+ (or (not (= (cdr (assoc 'timeSpentSeconds jira-worklog))
+ (cdr (assoc 'time-spent-seconds worklog))))
+ ;; Check if the entries start at different times.
+ (not (string= (cdr (assoc 'started jira-worklog))
+ (cdr (assoc 'started worklog))))))
+ (jiralib-update-worklog
+ issue-id
+ (cdr (assoc 'worklog-id worklog))
+ (cdr (assoc 'started worklog))
+ (cdr (assoc 'time-spent-seconds worklog))
+ comment-text
+ nil))) ; no callback - synchronous
+ ;; else
+ (jiralib-add-worklog
+ issue-id
+ (cdr (assoc 'started worklog))
+ (cdr (assoc 'time-spent-seconds worklog))
+ comment-text
+ nil) ; no callback - synchronous
+ )
+ )))))
+ (org-jira-log (format "Updating worklog from org-jira-update-worklogs-from-org-clocks call"))
+ (org-jira-update-worklogs-for-issue issue-id filename)
+ )))
+
+(defun org-jira-update-worklog ()
+ "Update a worklog for the current issue."
+ (interactive)
+ (error "Deprecated, use org-jira-update-worklogs-from-org-clocks instead!")
+ (let* ((issue-id (org-jira-get-from-org 'issue 'key))
+ (worklog-id (org-jira-get-from-org 'worklog 'id))
+ (timeSpent (org-jira-get-from-org 'worklog 'timeSpent))
+ (timeSpent (if timeSpent
+ timeSpent
+ (read-string "Input the time you spent (such as 3w 1d 2h): ")))
+ (timeSpent (replace-regexp-in-string " \\(\\sw\\)\\sw*\\(,\\|$\\)" "\\1" timeSpent))
+ (startDate (org-jira-get-from-org 'worklog 'startDate))
+ (startDate (if startDate
+ startDate
+ (org-read-date nil nil nil "Input when did you start")))
+ (startDate (org-jira-time-format-to-jira startDate))
+ (comment (replace-regexp-in-string "^ " "" (org-jira-get-worklog-comment worklog-id)))
+ (worklog `((comment . ,comment)
+ (timeSpent . ,timeSpent)
+ (timeSpentInSeconds . 10)
+ (startDate . ,startDate)))
+ (worklog (if worklog-id
+ (cons `(id . ,(replace-regexp-in-string "^worklog-" "" worklog-id)) worklog)
+ worklog)))
+ (if worklog-id
+ (jiralib-update-worklog worklog)
+ (jiralib-add-worklog-and-autoadjust-remaining-estimate issue-id startDate timeSpent comment))
+ (org-jira-delete-current-worklog)
+ (org-jira-update-worklogs-for-current-issue)))
+
+(defun org-jira-delete-current-comment ()
+ "Delete the current comment."
+ (ensure-on-comment
+ (delete-region (point-min) (point-max))))
+
+(defun org-jira-delete-current-worklog ()
+ "Delete the current worklog."
+ (ensure-on-worklog
+ (delete-region (point-min) (point-max))))
+
+;;;###autoload
+(defun org-jira-copy-current-issue-key ()
+ "Copy the current issue's key into clipboard."
+ (interactive)
+ (let ((issue-id (org-jira-get-from-org 'issue 'key)))
+ (with-temp-buffer
+ (insert issue-id)
+ (kill-region (point-min) (point-max)))))
+
+(defun org-jira-get-comment-id (comment)
+ (org-jira-find-value comment 'id))
+
+(defun org-jira-get-comment-author (comment)
+ (org-jira-find-value comment 'author 'displayName))
+
+(defun org-jira-isa-ignored-comment? (comment)
+ (member-ignore-case (oref comment author) org-jira-ignore-comment-user-list))
+
+(defun org-jira-maybe-reverse-comments (comments)
+ (if org-jira-reverse-comment-order (reverse comments) comments))
+
+(defun org-jira-extract-comments-from-data (data)
+ (->> (append data nil)
+ org-jira-sdk-create-comments-from-data-list
+ org-jira-maybe-reverse-comments
+ (cl-remove-if #'org-jira-isa-ignored-comment?)))
+
+(defun org-jira--render-comment (Issue Comment)
+ (with-slots (issue-id) Issue
+ (with-slots (comment-id author headline created updated body) Comment
+ (org-jira-log (format "Rendering a comment: %s" body))
+ (ensure-on-issue-Issue Issue
+ (setq p (org-find-entry-with-id comment-id))
+ (when (and p (>= p (point-min))
+ (<= p (point-max)))
+ (goto-char p)
+ (org-narrow-to-subtree)
+ (delete-region (point-min) (point-max)))
+ (goto-char (point-max))
+ (unless (looking-at "^")
+ (insert "\n"))
+ (insert "*** ")
+ (org-jira-insert headline "\n")
+ (org-narrow-to-subtree)
+ (org-jira-entry-put (point) "ID" comment-id)
+ (org-jira-entry-put (point) "created" created)
+ (unless (string= created updated)
+ (org-jira-entry-put (point) "updated" updated))
+ (goto-char (point-max))
+ ;; Insert 2 spaces of indentation so Jira markup won't cause org-markup
+ (org-jira-insert (replace-regexp-in-string "^" " " (or body "")))))))
+
+(defun org-jira-update-comments-for-issue (Issue)
+ "Update the comments for the specified ISSUE issue."
+ (with-slots (issue-id) Issue
+ (jiralib-get-comments
+ issue-id
+ (org-jira-with-callback
+ (org-jira-log "In the callback for org-jira-update-comments-for-issue.")
+ (-->
+ (org-jira-find-value cb-data 'comments)
+ (org-jira-extract-comments-from-data it)
+ (mapc (lambda (Comment) (org-jira--render-comment Issue Comment)) it))))))
+
+(defun org-jira-update-comments-for-current-issue ()
+ "Update comments for the current issue."
+ (org-jira-log "About to update comments for current issue.")
+ (let ((Issue (make-instance 'org-jira-sdk-issue
+ :issue-id (org-jira-get-from-org 'issue 'key)
+ :filename (org-jira-filename))))
+ (-> Issue org-jira-update-comments-for-issue)))
+
+(defun org-jira-delete-subtree ()
+ "Derived from org-cut-subtree.
+
+Like that function, without mangling the user's clipboard for the
+purpose of wiping an old subtree."
+ (let (beg end folded (beg0 (point)))
+ (org-back-to-heading t) ; take what is really there
+ (setq beg (point))
+ (skip-chars-forward " \t\r\n")
+ (save-match-data
+ (save-excursion (outline-end-of-heading)
+ (setq folded (org-invisible-p))
+ (org-end-of-subtree t t)))
+ ;; Include the end of an inlinetask
+ (when (and (featurep 'org-inlinetask)
+ (looking-at-p (concat (org-inlinetask-outline-regexp)
+ "END[ \t]*$")))
+ (end-of-line))
+ (setq end (point))
+ (goto-char beg0)
+ (when (> end beg)
+ (setq org-subtree-clip-folded folded)
+ (org-save-markers-in-region beg end)
+ (delete-region beg end))))
+
+(defun org-jira-update-attachments-for-current-issue ()
+ "Update the attachments for the current issue."
+ (when jiralib-use-restapi
+ (lexical-let ((issue-id (org-jira-get-from-org 'issue 'key)))
+ ;; Run the call
+ (jiralib-get-attachments
+ issue-id
+ (save-excursion
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ ;; First, make sure we're in the proper buffer (logic copied from org-jira-get-issues.
+ (let* ((proj-key (replace-regexp-in-string "-.*" "" issue-id))
+ (project-file (org-jira--get-project-file-name proj-key))
+ (project-buffer (or (find-buffer-visiting project-file)
+ (find-file project-file))))
+ (with-current-buffer project-buffer
+ ;; delete old attachment node
+ (ensure-on-issue
+ (if (org-goto-first-child)
+ (while (org-goto-sibling)
+ (forward-thing 'whitespace)
+ (when (looking-at "Attachments:")
+ (org-jira-delete-subtree)))))
+ (let ((attachments (org-jira-find-value data 'fields 'attachment)))
+ (when (not (zerop (length attachments)))
+ (ensure-on-issue
+ (if (org-goto-first-child)
+ (progn
+ (while (org-goto-sibling))
+ (org-insert-heading-after-current))
+ (org-insert-subheading nil))
+
+ (insert "Attachments:")
+ (mapc
+ (lambda (attachment)
+ (let ((attachment-id (org-jira-get-comment-id attachment))
+ (author (org-jira-get-comment-author attachment))
+ (created (org-jira-transform-time-format
+ (org-jira-find-value attachment 'created)))
+ (size (org-jira-find-value attachment 'size))
+ (mimeType (org-jira-find-value attachment 'mimeType))
+ (content (org-jira-find-value attachment 'content))
+ (filename (org-jira-find-value attachment 'filename)))
+ (if (looking-back "Attachments:")
+ (org-insert-subheading nil)
+ (org-insert-heading-respect-content))
+ (insert "[[" content "][" filename "]]")
+ (org-narrow-to-subtree)
+ (org-jira-entry-put (point) "ID" attachment-id)
+ (org-jira-entry-put (point) "Author" author)
+ (org-jira-entry-put (point) "Name" filename)
+ (org-jira-entry-put (point) "Created" created)
+ (org-jira-entry-put (point) "Size" (ls-lisp-format-file-size size t))
+ (org-jira-entry-put (point) "Content" content)
+ (widen)))
+ attachments)))))))))))))
+
+(defun org-jira-sort-org-clocks (clocks)
+ "Given a CLOCKS list, sort it by start date descending."
+ ;; Expects data such as this:
+
+ ;; ((\"2017-02-26 Sun 00:08\" \"2017-02-26 Sun 01:08\" \"Hi\" \"10101\")
+ ;; (\"2017-03-16 Thu 22:25\" \"2017-03-16 Thu 22:57\" \"Test\" \"10200\"))
+ (sort clocks
+ (lambda (a b)
+ (> (time-to-seconds (date-to-time (car a)))
+ (time-to-seconds (date-to-time (car b)))))))
+
+(defun org-jira-update-worklogs-for-current-issue ()
+ "Update the worklogs for the current issue."
+ (let ((issue-id (org-jira-get-from-org 'issue 'key))
+ (filename (org-jira-filename)))
+ (org-jira-update-worklogs-for-issue issue-id filename)))
+
+(defun org-jira-update-worklogs-for-issue (issue-id filename)
+ "Update the worklogs for the current ISSUE-ID located in FILENAME."
+ (org-jira-log (format "org-jira-update-worklogs-for-issue id: %s filename: %s"
+ issue-id filename))
+ ;; Run the call
+ (jiralib-get-worklogs
+ issue-id
+ (org-jira-with-callback
+ (ensure-on-issue-id-with-filename issue-id filename
+ (let ((worklogs (org-jira-find-value cb-data 'worklogs)))
+ (org-jira-log (format "org-jira-update-worklogs-for-issue cb id: %s fn: %s"
+ issue-id filename))
+ (org-jira-logbook-reset issue-id filename
+ (org-jira-sort-org-clocks (org-jira-worklogs-to-org-clocks
+ (jiralib-worklog-import--filter-apply worklogs)))))))))
+
+;;;###autoload
+(defun org-jira-unassign-issue ()
+ "Update an issue to be unassigned."
+ (interactive)
+ (let ((issue-id (org-jira-parse-issue-id))
+ (filename (org-jira-parse-issue-filename)))
+ (org-jira-update-issue-details issue-id filename :assignee nil)))
+
+;;;###autoload
+(defun org-jira-set-issue-reporter ()
+ "Update an issue's reporter interactively."
+ (interactive)
+ (let ((issue-id (org-jira-parse-issue-id))
+ (filename (org-jira-parse-issue-filename)))
+ (if issue-id
+ (let* ((project (replace-regexp-in-string "-[0-9]+" "" issue-id))
+ (jira-users (org-jira-get-reporter-candidates project)) ;; TODO, probably a better option than org-jira-get-assignable-users here
+ (user (completing-read
+ "Reporter: "
+ (append (mapcar 'car jira-users)
+ (mapcar 'cdr jira-users))))
+ (reporter (or
+ (cdr (assoc user jira-users))
+ (cdr (rassoc user jira-users)))))
+ (when (null reporter)
+ (error "No reporter found, this should probably never happen."))
+ (org-jira-update-issue-details issue-id filename :reporter (jiralib-get-user-account-id project reporter)))
+ (error "Not on an issue"))))
+
+;;;###autoload
+(defun org-jira-assign-issue ()
+ "Update an issue with interactive re-assignment."
+ (interactive)
+ (let ((issue-id (org-jira-parse-issue-id))
+ (filename (org-jira-parse-issue-filename)))
+ (if issue-id
+ (let* ((project (replace-regexp-in-string "-[0-9]+" "" issue-id))
+ (jira-users (org-jira-get-assignable-users project))
+ (user (completing-read
+ "Assignee: "
+ (append (mapcar 'car jira-users)
+ (mapcar 'cdr jira-users))))
+ (assignee (or
+ (cdr (assoc user jira-users))
+ (cdr (rassoc user jira-users)))))
+ (when (null assignee)
+ (error "No assignee found, use org-jira-unassign-issue to make the issue unassigned"))
+ (org-jira-update-issue-details issue-id filename :assignee (jiralib-get-user-account-id project assignee)))
+ (error "Not on an issue"))))
+
+;;;###autoload
+(defun org-jira-update-issue ()
+ "Update an issue."
+ (interactive)
+ (let ((issue-id (org-jira-parse-issue-id))
+ (filename (org-jira-parse-issue-filename)))
+ (if issue-id
+ (org-jira-update-issue-details issue-id filename)
+ (error "Not on an issue"))))
+
+;;;###autoload
+(defun org-jira-todo-to-jira ()
+ "Convert an ordinary todo item to a jira ticket."
+ (interactive)
+ (ensure-on-todo
+ (when (org-jira-parse-issue-id)
+ (error "Already on jira ticket"))
+ (save-excursion (org-jira-create-issue
+ (org-jira-read-project)
+ (org-jira-read-issue-type)
+ (org-get-heading t t)
+ (org-get-entry)))
+ (delete-region (point-min) (point-max))))
+
+;;;###autoload
+(defun org-jira-get-subtasks ()
+ "Get subtasks for the current issue."
+ (interactive)
+ (ensure-on-issue
+ (org-jira-get-issues-headonly (jiralib-do-jql-search (format "parent = %s" (org-jira-parse-issue-id))))))
+
+(defvar org-jira-project-read-history nil)
+(defvar org-jira-boards-read-history nil)
+(defvar org-jira-sprints-read-history nil)
+(defvar org-jira-components-read-history nil)
+(defvar org-jira-priority-read-history nil)
+(defvar org-jira-type-read-history nil)
+
+(defun org-jira-read-project ()
+ "Read project name."
+ (completing-read
+ "Project: "
+ (jiralib-make-list (jiralib-get-projects) 'key)
+ nil
+ t
+ nil
+ 'org-jira-project-read-history
+ (car org-jira-project-read-history)))
+
+(defun org-jira-read-board ()
+ "Read board name. Returns cons pair (name . integer-id)"
+ (let* ((boards-alist
+ (jiralib-make-assoc-list (jiralib-get-boards) 'name 'id))
+ (board-name
+ (completing-read "Boards: " boards-alist
+ nil t nil
+ 'org-jira-boards-read-history
+ (car org-jira-boards-read-history))))
+ (assoc board-name boards-alist)))
+
+(defun org-jira-read-sprint (board)
+ "Read sprint name. Returns cons pair (name . integer-id)"
+ (let* ((sprints-alist
+ (jiralib-make-assoc-list (append (alist-get 'values (jiralib-get-board-sprints board)) nil) 'name 'id))
+ (sprint-name
+ (completing-read "Sprints: " sprints-alist
+ nil t nil
+ 'org-jira-sprints-read-history
+ (car org-jira-sprints-read-history))))
+ (assoc sprint-name sprints-alist)))
+
+(defun org-jira-read-component (project)
+ "Read the components options for PROJECT such as EX."
+ (completing-read
+ "Components (choose Done to stop): "
+ (append '("Done") (mapcar 'cdr (jiralib-get-components project)))
+ nil
+ t
+ nil
+ 'org-jira-components-read-history
+ "Done"))
+
+;; TODO: Finish this feature - integrate into org-jira-create-issue
+(defun org-jira-read-components (project)
+ "Types: string PROJECT : string (csv of components).
+
+Get all the components for the PROJECT such as EX,
+that should be bound to an issue."
+ (let (components component)
+ (while (not (equal "Done" component))
+ (setq component (org-jira-read-component project))
+ (unless (equal "Done" component)
+ (push component components)))
+ components))
+
+(defun org-jira-read-priority ()
+ "Read priority name."
+ (completing-read
+ "Priority: "
+ (mapcar 'cdr (jiralib-get-priorities))
+ nil
+ t
+ nil
+ 'org-jira-priority-read-history
+ (car org-jira-priority-read-history)))
+
+(defun org-jira-read-issue-type (&optional project)
+ "Read issue type name. PROJECT is the optional project key."
+ (let* ((issue-types
+ (mapcar 'cdr (if project
+ (jiralib-get-issue-types-by-project project)
+ (jiralib-get-issue-types))))
+ (initial-input (when (member (car org-jira-type-read-history) issue-types)
+ org-jira-type-read-history)))
+
+ ;; TODO: The completing-read calls as such are all over the place, and always tend
+ ;; to follow this exact same call structure - we should abstract to a single fn
+ ;; that will allow calling with fewer or keyword args
+ (completing-read
+ "Type: " ; PROMPT
+ issue-types ; COLLECTION
+ nil ; PREDICATE
+ t ; REQUIRE-MATCH
+ nil ; INITIAL-INPUT
+ 'initial-input ; HIST
+ (car initial-input)))) ; DEF
+
+(defun org-jira-read-subtask-type ()
+ "Read issue type name."
+ (completing-read
+ "Type: "
+ (mapcar 'cdr (jiralib-get-subtask-types))
+ nil
+ t
+ nil
+ 'org-jira-type-read-history
+ (car org-jira-type-read-history)))
+
+(defun org-jira-get-issue-struct (project type summary description &optional parent-id)
+ "Create an issue struct for PROJECT, of TYPE, with SUMMARY and DESCRIPTION."
+ (if (or (equal project "")
+ (equal type "")
+ (equal summary ""))
+ (error "Must provide all information!"))
+ (let* ((project-components (jiralib-get-components project))
+ (jira-users (org-jira-get-assignable-users project))
+ (user (completing-read "Assignee: " (mapcar 'car jira-users)))
+ (priority (car (rassoc (org-jira-read-priority) (jiralib-get-priorities))))
+ (ticket-struct
+ `((fields
+ (project (key . ,project))
+ (parent (key . ,parent-id))
+ (issuetype (id . ,(car (rassoc type (if (and (boundp 'parent-id) parent-id)
+ (jiralib-get-subtask-types)
+ (jiralib-get-issue-types-by-project project))))))
+ (summary . ,(format "%s%s" summary
+ (if (and (boundp 'parent-id) parent-id)
+ (format " (subtask of [jira:%s])" parent-id)
+ "")))
+ (description . ,description)
+ (priority (id . ,priority))
+ ;; accountId should be nil if Unassigned, not the key slot.
+ (assignee (accountId . ,(or (cdr (assoc user jira-users)) nil)))))))
+ ticket-struct))
+
+;;;###autoload
+(defun org-jira-create-issue (project type summary description)
+ "Create an issue in PROJECT, of type TYPE, with given SUMMARY and DESCRIPTION."
+ (interactive
+ (let* ((project (org-jira-read-project))
+ (type (org-jira-read-issue-type project))
+ (summary (read-string "Summary: "))
+ (description (read-string "Description: ")))
+ (list project type summary description)))
+ (if (or (equal project "")
+ (equal type "")
+ (equal summary ""))
+ (error "Must provide all information!"))
+ (let* ((parent-id nil)
+ (ticket-struct (org-jira-get-issue-struct project type summary description)))
+ (org-jira-get-issues (list (jiralib-create-issue ticket-struct)))))
+
+;;;###autoload
+(defun org-jira-create-subtask (project type summary description)
+ "Create a subtask issue for PROJECT, of TYPE, with SUMMARY and DESCRIPTION."
+ (interactive (ensure-on-issue (list (org-jira-read-project)
+ (org-jira-read-subtask-type)
+ (read-string "Summary: ")
+ (read-string "Description: "))))
+ (if (or (equal project "")
+ (equal type "")
+ (equal summary ""))
+ (error "Must provide all information!"))
+ (let* ((parent-id (org-jira-parse-issue-id))
+ (ticket-struct (org-jira-get-issue-struct project type summary description parent-id)))
+ (org-jira-get-issues (list (jiralib-create-subtask ticket-struct)))))
+
+(defun org-jira-get-issue-val-from-org (key)
+ "Return the requested value by KEY from the current issue."
+ ;; There is some odd issue when not using any let-scoping, where myself
+ ;; and an array of users are hitting a snag circa 2023-03-01 time frame
+ ;; in which the setq portion of a when clause is being hit even when it
+ ;; evaluates to false - the bug only manifests on a first launch of Emacs - it
+ ;; doesn't occur when re-evaluating this function. However, wrapping it "fixes"
+ ;; the issue.
+ ;;
+ ;; The first link has the most troubleshooting/diagnosis around the particulars of
+ ;; this bug.
+ ;;
+ ;; See: https://github.com/ahungry/org-jira/issues/319
+ ;; See: https://github.com/ahungry/org-jira/issues/296
+ ;; See: https://github.com/ahungry/org-jira/issues/316
+ (lexical-let ((my-key key))
+ (ensure-on-issue
+ (cond ((eq my-key 'description)
+ (org-goto-first-child)
+ (forward-thing 'whitespace)
+ (if (looking-at "description: ")
+ (org-trim (org-get-entry))
+ (error "Can not find description field for this issue")))
+
+ ((eq my-key 'summary)
+ (ensure-on-issue
+ (org-get-heading t t)))
+
+ ;; org returns a time tuple, we need to convert it
+ ((eq my-key 'deadline)
+ (let ((encoded-time (org-get-deadline-time (point))))
+ (when encoded-time
+ (cl-reduce (lambda (carry segment)
+ (format "%s-%s" carry segment))
+ (reverse (cl-subseq (decode-time encoded-time) 3 6))))))
+
+ ;; default case, just grab the value in the properties block
+ (t
+ (when (symbolp my-key)
+ (setq my-key (symbol-name my-key)))
+
+ (setq my-key (or (assoc-default my-key org-jira-property-overrides)
+ my-key))
+
+ ;; This is the "impossible" to hit setq that somehow gets hit without the let
+ ;; wrapper around the function input args.
+ (when (string= my-key "key")
+ (setq my-key "ID"))
+
+ ;; The variable `org-special-properties' will mess this up
+ ;; if our search, such as 'priority' is within there, so
+ ;; don't bother with it for this (since we only ever care
+ ;; about the local properties, not any hierarchal or special
+ ;; ones).
+ (let ((org-special-properties nil))
+ (or (org-entry-get (point) my-key t)
+ "")))))))
+
+(defun org-jira-read-action (actions)
+ "Read issue workflow progress ACTIONS."
+ (let ((action (completing-read
+ "Action: "
+ (mapcar 'cdr actions)
+ nil
+ t
+ nil)))
+ (or
+ (car (rassoc action actions))
+ (user-error "You specified an empty action, the valid actions are: %s" (mapcar 'cdr actions)))))
+
+(defvar org-jira-fields-history nil)
+(defun org-jira-read-field (fields)
+ "Read (custom) FIELDS for workflow progress."
+ (let ((field-desc (completing-read
+ "More fields to set: "
+ (cons "Thanks, no more fields are *required*." (mapcar 'org-jira-decode (mapcar 'cdr fields)))
+ nil
+ t
+ nil
+ 'org-jira-fields-history))
+ field-name)
+ (setq field-name (car (rassoc field-desc fields)))
+ (if field-name
+ (intern field-name)
+ field-name)))
+
+
+(defvar org-jira-rest-fields nil
+ "Extra fields are held here for usage between two endpoints.
+Used in org-jira-read-resolution and org-jira-progress-issue calls.")
+
+(defvar org-jira-resolution-history nil)
+(defun org-jira-read-resolution ()
+ "Read issue workflow progress resolution."
+ (if (not jiralib-use-restapi)
+ (let ((resolution (completing-read
+ "Resolution: "
+ (mapcar 'cdr (jiralib-get-resolutions))
+ nil
+ t
+ nil
+ 'org-jira-resolution-history
+ (car org-jira-resolution-history))))
+ (car (rassoc resolution (jiralib-get-resolutions))))
+ (let* ((resolutions (org-jira-find-value org-jira-rest-fields 'resolution 'allowedValues))
+ (resolution-name (completing-read
+ "Resolution: "
+ (mapcar (lambda (resolution)
+ (org-jira-find-value resolution 'name))
+ resolutions))))
+ (cons 'name resolution-name))))
+
+(defun org-jira-refresh-issues-in-buffer-loose ()
+ "Iterates over all level 1-2 headings in current buffer, refreshing on issue :ID:.
+ It differs with org-jira-refresh-issues-in-buffer() in that:
+ a) It accepts current buffer and its corresponding filename, regardless of whether
+ it has been previously registered as an org-jira project file or not.
+ b) It doesn't expect a very specific structure in the org buffer, but simply goes
+ over every existing heading (level 1-2), and refreshes it IFF a valid jira ID
+ can be detected in it."
+ (interactive)
+ (save-excursion
+ (save-restriction
+ (widen)
+ (outline-show-all)
+ (outline-hide-sublevels 2)
+ (goto-char (point-min))
+
+ (while (not (eobp))
+ (progn
+ (if (org-jira-id)
+ (progn
+ (org-jira--refresh-issue (org-jira-id) (file-name-sans-extension buffer-file-name))))
+ (outline-next-visible-heading 1))))))
+
+;; TODO: Refactor to just scoop all ids from buffer, run ensure-on-issue-id on
+;; each using a map, and refresh them that way. That way we don't have to iterate
+;; on the user headings etc.
+(defun org-jira-refresh-issues-in-buffer ()
+ "Iterate across all entries in current buffer, refreshing on issue :ID:.
+Where issue-id will be something such as \"EX-22\"."
+ (interactive)
+ (save-excursion
+ (save-restriction
+ (widen)
+ (outline-show-all)
+ (outline-hide-sublevels 2)
+ (goto-char (point-min))
+ (while (and (or (looking-at "^ *$")
+ (looking-at "^#.*$")
+ (looking-at "^\\* .*"))
+ (not (eobp)))
+ (forward-line))
+ (outline-next-visible-heading 1)
+ (while (and (not (org-next-line-empty-p))
+ (not (eobp)))
+ (when (outline-on-heading-p t)
+ ;; It's possible we could be on a non-org-jira headline, but
+ ;; that should be an exceptional case and not necessitating a
+ ;; fix atm.
+ (org-jira-refresh-issue))
+ (outline-next-visible-heading 1)))))
+
+;;;###autoload
+(defun org-jira-refresh-issue ()
+ "Refresh current issue from jira to org."
+ (interactive)
+ (ensure-on-issue
+ (org-jira--refresh-issue (org-jira-id) (org-jira-filename))))
+
+(defun org-jira--refresh-issue (issue-id &optional filename)
+ "Refresh issue from jira to org using ISSUE-ID."
+ (unless filename (setq filename (replace-regexp-in-string "-[0-9]+" "" issue-id)))
+ (jiralib-get-issue
+ issue-id
+ (org-jira-with-callback
+ (org-jira-log (format "Received refresh issue data for id: %s in file: %s" issue-id filename))
+ (--> cb-data
+ list
+ (org-jira-sdk-create-issues-from-data-list-with-filename filename it)
+ org-jira--render-issues-from-issue-list))))
+
+(defun org-jira--refresh-issue-by-id (issue-id)
+ "Refresh issue from jira to org using ISSUE-ID."
+ (ensure-on-issue-id issue-id
+ (org-jira--refresh-issue issue-id)))
+
+(defvar org-jira-fields-values-history nil)
+;;;###autoload
+(defun org-jira-progress-issue ()
+ "Progress issue workflow."
+ (interactive)
+ (ensure-on-issue
+ (let* ((issue-id (org-jira-id))
+ (actions (jiralib-get-available-actions
+ issue-id
+ (org-jira-get-issue-val-from-org 'status)))
+ (action (org-jira-read-action actions))
+ (fields (jiralib-get-fields-for-action issue-id action))
+ (org-jira-rest-fields fields)
+ (field-key)
+ (custom-fields-collector nil)
+ (custom-fields
+ (progn
+ ;; delete those elements in fields, which have
+ ;; already been set in custom-fields-collector
+ (while fields
+ (setq fields
+ (cl-remove-if
+ (lambda (strstr)
+ (cl-member-if (lambda (symstr)
+ (string= (car strstr) (symbol-name (car symstr))))
+ custom-fields-collector))
+ fields))
+ (setq field-key (org-jira-read-field fields))
+ (if (not field-key)
+ (setq fields nil)
+ (setq custom-fields-collector
+ (cons
+ (funcall (if jiralib-use-restapi
+ #'list
+ #'cons)
+ field-key
+ (if (eq field-key 'resolution)
+ (org-jira-read-resolution)
+ (let ((field-value (completing-read
+ (format "Please enter %s's value: "
+ (cdr (assoc (symbol-name field-key) fields)))
+ org-jira-fields-values-history
+ nil
+ nil
+ nil
+ 'org-jira-fields-values-history)))
+ (if jiralib-use-restapi
+ (cons 'name field-value)
+ field-value))))
+ custom-fields-collector))))
+ custom-fields-collector)))
+ (jiralib-progress-workflow-action
+ issue-id
+ action
+ custom-fields
+ (cl-function
+ (lambda (&key data &allow-other-keys)
+ (org-jira-refresh-issue)))))))
+
+(defun org-jira-progress-next-action (actions current-status)
+ "Grab the user defined 'next' action from ACTIONS, given CURRENT-STATUS."
+ (let* ((next-action-name (cdr (assoc current-status org-jira-progress-issue-flow)))
+ (next-action-id (caar (cl-remove-if-not
+ (lambda (action)
+ (equal action next-action-name)) actions :key #'cdr))))
+ next-action-id))
+
+;;;###autoload
+(defun org-jira-progress-issue-next ()
+ "Progress issue workflow."
+ (interactive)
+ (ensure-on-issue
+ (let* ((issue-id (org-jira-id))
+ (filename (org-jira-filename))
+ (actions (jiralib-get-available-actions
+ issue-id
+ (org-jira-get-issue-val-from-org 'status)))
+ (action (org-jira-progress-next-action actions (org-jira-get-issue-val-from-org 'status)))
+ (fields (jiralib-get-fields-for-action issue-id action))
+ (org-jira-rest-fields fields)
+ (field-key)
+ (custom-fields-collector nil)
+ (custom-fields
+ (progn
+ ;; delete those elements in fields, which have
+ ;; already been set in custom-fields-collector
+ (while fields
+ (setq fields
+ (cl-remove-if
+ (lambda (strstr)
+ (cl-member-if (lambda (symstr)
+ (string= (car strstr) (symbol-name (car symstr))))
+ custom-fields-collector))
+ fields))
+ (setq field-key (org-jira-read-field fields))
+ (if (not field-key)
+ (setq fields nil)
+ (setq custom-fields-collector
+ (cons
+ (funcall (if jiralib-use-restapi
+ #'list
+ #'cons)
+ field-key
+ (if (eq field-key 'resolution)
+ (org-jira-read-resolution)
+ (let ((field-value (completing-read
+ (format "Please enter %s's value: "
+ (cdr (assoc (symbol-name field-key) fields)))
+ org-jira-fields-values-history
+ nil
+ nil
+ nil
+ 'org-jira-fields-values-history)))
+ (if jiralib-use-restapi
+ (cons 'name field-value)
+ field-value))))
+ custom-fields-collector))))
+ custom-fields-collector)))
+ (if action
+ (jiralib-progress-workflow-action
+ issue-id
+ action
+ custom-fields
+ (org-jira-with-callback
+ (ensure-on-issue-id-with-filename issue-id filename
+ (org-jira-refresh-issue))))
+ (error "No action defined for that step!")))))
+
+(defun org-jira-get-id-name-alist (name ids-to-names)
+ "Find the id corresponding to NAME in IDS-TO-NAMES and return an alist with id and name as keys."
+ (let ((id (car (rassoc name ids-to-names))))
+ `((id . ,id)
+ (name . ,name))))
+
+(defun org-jira-build-components-list (project-components org-issue-components)
+ "Given PROJECT-COMPONENTS, attempt to build a list.
+
+If the PROJECT-COMPONENTS are nil, this should return:
+
+ (list components []), which will translate into the JSON:
+
+ {\"components\": []}
+
+otherwise it should return:
+
+ (list components (list (cons id comp-id) (cons name item-name))),
+
+ which will translate into the JSON:
+
+{\"components\": [{\"id\": \"comp-id\", \"name\": \"item\"}]}"
+ (if (not project-components) (vector) ;; Return a blank array for JSON
+ (apply 'list
+ (cl-mapcan
+ (lambda (item)
+ (let ((comp-id (car (rassoc item project-components))))
+ (if comp-id
+ `(((id . ,comp-id)
+ (name . ,item)))
+ nil)))
+ (split-string org-issue-components ",\\s *")))))
+
+(defun org-jira-strip-priority-tags (s)
+ "Given string S, remove any priority tags in the brackets."
+ (->> s (replace-regexp-in-string "\\[#.*?\\]" "") org-trim))
+
+(defun org-jira-update-issue-details (issue-id filename &rest rest)
+ "Update the details of issue ISSUE-ID in FILENAME. REST will contain optional input."
+ (ensure-on-issue-id-with-filename issue-id filename
+ ;; Set up a bunch of values from the org content
+ (let* ((org-issue-components (org-jira-get-issue-val-from-org 'components))
+ (org-issue-labels (org-jira-get-issue-val-from-org 'labels))
+ (org-issue-description (org-trim (org-jira-get-issue-val-from-org 'description)))
+ (org-issue-priority (org-jira-get-issue-val-from-org 'priority))
+ (org-issue-type (org-jira-get-issue-val-from-org 'type))
+ (org-issue-type-id (org-jira-get-issue-val-from-org 'type-id))
+ (org-issue-assignee (cl-getf rest :assignee (org-jira-get-issue-val-from-org 'assignee)))
+ (org-issue-reporter (cl-getf rest :reporter (org-jira-get-issue-val-from-org 'reporter)))
+ (project (replace-regexp-in-string "-[0-9]+" "" issue-id))
+ (project-components (jiralib-get-components project)))
+
+ ;; Lets fire off a worklog update async with the main issue
+ ;; update, why not? This is better to fire first, because it
+ ;; doesn't auto-refresh any areas, while the end of the main
+ ;; update does a callback that reloads the worklog entries (so,
+ ;; we hope that wont occur until after this successfully syncs
+ ;; up). Only do this sync if the user defcustom defines it as such.
+ (when org-jira-worklog-sync-p
+ (org-jira-update-worklogs-from-org-clocks))
+
+ ;; Send the update to jira
+ (let ((update-fields
+ (list (cons
+ 'components
+ (or (org-jira-build-components-list
+ project-components
+ org-issue-components) []))
+ (cons 'labels (split-string org-issue-labels ",\\s *"))
+ (cons 'priority (org-jira-get-id-name-alist org-issue-priority
+ (jiralib-get-priorities)))
+ (cons 'description org-issue-description)
+ (cons 'assignee (list (cons 'id (jiralib-get-user-account-id project org-issue-assignee))))
+ (cons 'reporter (list (cons 'id (jiralib-get-user-account-id project org-issue-reporter))))
+ (cons 'summary (org-jira-strip-priority-tags (org-jira-get-issue-val-from-org 'summary)))
+ (cons 'issuetype `((id . ,org-issue-type-id)
+ (name . ,org-issue-type))))))
+
+
+ ;; If we enable duedate sync and we have a deadline present
+ (when (and org-jira-deadline-duedate-sync-p
+ (org-jira-get-issue-val-from-org 'deadline))
+ (setq update-fields
+ (append update-fields
+ (list (cons 'duedate (org-jira-get-issue-val-from-org 'deadline))))))
+
+ ;; TODO: We need some way to handle things like assignee setting
+ ;; and refreshing the proper issue in the proper buffer/filename.
+ (jiralib-update-issue
+ issue-id
+ update-fields
+ ;; This callback occurs on success
+ (org-jira-with-callback
+ (message (format "Issue '%s' updated!" issue-id))
+ (jiralib-get-issue
+ issue-id
+ (org-jira-with-callback
+ (org-jira-log "Update get issue for refresh callback hit.")
+ (-> cb-data list org-jira-get-issues))))
+ ))
+ )))
+
+
+
+(defun org-jira-parse-issue-id ()
+ "Get issue id from org text."
+ (save-excursion
+ (let ((continue t)
+ issue-id)
+ (while continue
+ (when (string-match (jiralib-get-issue-regexp)
+ (or (setq issue-id (org-entry-get (point) "ID"))
+ ""))
+ (setq continue nil))
+ (unless (and continue (org-up-heading-safe))
+ (setq continue nil)))
+ issue-id)))
+
+(defun org-jira-parse-issue-filename ()
+ "Get filename from org text."
+ (save-excursion
+ (let ((continue t)
+ filename)
+ (while continue
+ (when (setq filename (org-entry-get (point) "filename"))
+ (setq continue nil))
+ (unless (and continue (org-up-heading-safe))
+ (setq continue nil)))
+ filename)))
+
+(defun org-jira-get-from-org (type entry)
+ "Get an org property from the current item.
+
+TYPE is the type to of the current item, and can be 'issue, or 'comment.
+
+ENTRY will vary, and is the name of the property to return. If
+it is a symbol, it will be converted to string."
+ (when (symbolp entry)
+ (setq entry (symbol-name entry)))
+ (cond
+ ((eq type 'issue)
+ (org-jira-get-issue-val-from-org entry))
+ ((eq type 'comment)
+ (org-jira-get-comment-val-from-org entry))
+ ((eq type 'worklog)
+ (org-jira-get-worklog-val-from-org entry))
+ (t (error "Unknown type %s" type))))
+
+(defun org-jira-get-comment-val-from-org (entry)
+ "Get the JIRA issue field value ENTRY of the current comment item."
+ (ensure-on-comment
+ (when (symbolp entry)
+ (setq entry (symbol-name entry)))
+ (when (string= entry "id")
+ (setq entry "ID"))
+ (org-entry-get (point) entry)))
+
+(defun org-jira-get-worklog-val-from-org (entry)
+ "Get the JIRA issue field value ENTRY of the current worklog item."
+ (ensure-on-worklog
+ (when (symbolp entry)
+ (setq entry (symbol-name entry)))
+ (when (string= entry "id")
+ (setq entry "ID"))
+ (org-entry-get (point) entry)))
+
+(defun org-jira-get-comment-body (&optional comment-id)
+ "Get the comment body of the comment with id COMMENT-ID."
+ (ensure-on-comment
+ (goto-char (point-min))
+ ;; so that search for :END: won't fail
+ (org-jira-entry-put (point) "ID" comment-id)
+ (search-forward ":END:" nil 1 1)
+ (forward-line)
+ (org-trim (buffer-substring-no-properties (point) (point-max)))))
+
+(defun org-jira-get-worklog-comment (&optional worklog-id)
+ "Get the worklog comment of the worklog with id WORKLOG-ID."
+ (ensure-on-worklog
+ (goto-char (point-min))
+ ;; so that search for :END: won't fail
+ (org-jira-entry-put (point) "ID" worklog-id)
+ (search-forward ":END:" nil 1 1)
+ (forward-line)
+ (org-trim (buffer-substring-no-properties (point) (point-max)))))
+
+(defun org-jira-id ()
+ "Get the ID entry for the current heading."
+ (org-entry-get (point) "ID"))
+
+(defun org-jira-filename ()
+ "Get the ID entry for the current heading."
+ (org-jira-get-from-org 'issue 'filename))
+
+;;;###autoload
+(defun org-jira-browse-issue ()
+ "Open the current issue in external browser."
+ (interactive)
+ (ensure-on-issue
+ (browse-url (concat (replace-regexp-in-string "/*$" "" jiralib-url) "/browse/" (org-jira-id)))))
+
+(defun org-jira-url-copy-file (url newname)
+ "Similar to url-copy-file but async."
+ (lexical-let ((newname newname))
+ (url-retrieve
+ url
+ (lambda (status)
+ (let ((buffer (current-buffer))
+ (handle nil)
+ (filename (if (and (file-exists-p newname)
+ org-jira-download-ask-override)
+ (read-string "File already exists, select new name or press ENTER to override: " newname)
+ newname)))
+ (if (not buffer)
+ (error "Opening input file: No such file or directory, %s" url))
+ (with-current-buffer buffer
+ (setq handle (mm-dissect-buffer t)))
+ (mm-save-part-to-file handle filename)
+ (kill-buffer buffer)
+ (mm-destroy-parts handle))))))
+
+;;;###autoload
+(defun org-jira-download-attachment ()
+ "Download the attachment under cursor."
+ (interactive)
+ (when jiralib-use-restapi
+ (save-excursion
+ (org-up-heading-safe)
+ (org-back-to-heading)
+ (forward-thing 'whitespace)
+ (unless (looking-at "Attachments:")
+ (error "Not on a attachment region!")))
+ (let ((filename (org-entry-get (point) "Name"))
+ (url (org-entry-get (point) "Content"))
+ (url-request-extra-headers `(,jiralib-token)))
+ (org-jira-url-copy-file
+ url
+ (concat (file-name-as-directory org-jira-download-dir) filename)))))
+
+;;;###autoload
+(defun org-jira-get-issues-from-filter (filter)
+ "Get issues from the server-side stored filter named FILTER.
+
+Provide this command in case some users are not able to use
+client side jql (maybe because of JIRA server version?)."
+ (interactive
+ (list (completing-read "Filter: " (mapcar 'cdr (jiralib-get-saved-filters)))))
+ (org-jira-get-issues (jiralib-get-issues-from-filter (car (rassoc filter (jiralib-get-saved-filters))))))
+
+;;;###autoload
+(defun org-jira-get-issues-from-filter-headonly (filter)
+ "Get issues *head only* from saved filter named FILTER.
+See `org-jira-get-issues-from-filter'."
+ (interactive
+ (list (completing-read "Filter: " (mapcar 'cdr (jiralib-get-saved-filters)))))
+ (org-jira-get-issues-headonly (jiralib-get-issues-from-filter (car (rassoc filter (jiralib-get-saved-filters))))))
+
+(org-add-link-type "jira" 'org-jira-open)
+
+;; This was only added in org 9.0, not sure all org users will have
+;; that version, so keep the deprecated one from above for now.
+
+;;(org-link-set-parameters "jira" ((:follow . 'org-jira-open)))
+
+(defun org-jira-open (path)
+ "Open a Jira Link from PATH."
+ (org-jira-get-issue path))
+
+;;;###autoload
+(defun org-jira-get-issues-by-board ()
+ "Get list of ISSUES from agile board."
+ (interactive)
+ (let* ((board (org-jira-read-board))
+ (board-id (cdr board)))
+ (jiralib-get-board-issues board-id
+ :callback org-jira-get-issue-list-callback
+ :limit (org-jira-get-board-limit board-id)
+ :query-params (org-jira--make-jql-queryparams board-id))))
+
+;;;###autoload
+(defun org-jira-get-issues-by-sprint ()
+ "Get list of ISSUES from sprint."
+ (interactive)
+ (let* ((board (org-jira-read-board))
+ (board-id (cdr board))
+ (sprint (org-jira-read-sprint board-id))
+ (sprint-id (cdr sprint)))
+ (jiralib-get-sprint-issues sprint-id
+ :callback org-jira-get-issue-list-callback
+ :limit (org-jira-get-board-limit board-id)
+ :query-params (org-jira--make-jql-queryparams board-id))))
+
+(defun org-jira-get-board-limit (id)
+ "Get limit for number of retrieved issues for a board
+id - integer board id"
+ (let ((board (org-jira--get-board-from-buffer id)))
+ (if (and board (slot-boundp board 'limit))
+ (oref board limit)
+ org-jira-boards-default-limit)))
+
+(defun org-jira--make-jql-queryparams (board-id)
+ "make GET query parameters for jql, returns nil if JQL query is not set"
+ (let* ((board (org-jira--get-board-from-buffer board-id))
+ (jql (if (and board (slot-boundp board 'jql))
+ (oref board jql))))
+ (if (and jql (not (string-blank-p jql))) `((jql ,jql)))))
+
+;;;###autoload
+(defun org-jira-get-issues-by-board-headonly ()
+ "Get list of ISSUES from agile board, head only."
+ (interactive)
+ (let* ((board (org-jira-read-board))
+ (board-id (cdr board)))
+ (org-jira-get-issues-headonly
+ (jiralib-get-board-issues board-id
+ :limit (org-jira-get-board-limit board-id)
+ :query-params (org-jira--make-jql-queryparams board-id)))))
+
+
+(defun org-jira--render-boards-from-list (boards)
+ "Add the boards from list into the org file.
+
+boards - list of `org-jira-sdk-board' records."
+ (mapc 'org-jira--render-board boards))
+
+
+(defun org-jira--render-board (board)
+ "Render single board"
+ ;;(org-jira-sdk-dump board)
+ (with-slots (id name url board-type jql limit) board
+ (with-current-buffer (org-jira--get-boards-buffer)
+ (org-jira-maybe-activate-mode)
+ (org-jira-freeze-ui
+ (org-save-outline-visibility t
+ (save-restriction
+ (outline-show-all)
+ (widen)
+ (goto-char (point-min))
+ (let* ((board-headline
+ (format "Board: [[%s][%s]]" url name))
+ (headline-pos
+ (org-find-exact-headline-in-buffer board-headline (current-buffer) t))
+ (entry-exists (and headline-pos (>= headline-pos (point-min)) (<= headline-pos (point-max))))
+ (limit-value (if (slot-boundp board 'limit) (int-to-string limit) nil))
+ (jql-value (if (slot-boundp board 'jql) jql nil)))
+ (if entry-exists
+ (progn
+ (goto-char headline-pos)
+ (org-narrow-to-subtree)
+ (end-of-line))
+ (goto-char (point-max))
+ (unless (looking-at "^")
+ (insert "\n"))
+ (insert "* ")
+ (org-jira-insert board-headline)
+ (org-narrow-to-subtree))
+ (org-jira-entry-put (point) "name" name)
+ (org-jira-entry-put (point) "type" board-type)
+ (org-jira-entry-put (point) "url" url)
+ ;; do not overwrite existing user properties with empty values
+ (if (or (not entry-exists) limit-value)
+ (org-jira-entry-put (point) "limit" limit-value))
+ (if (or (not entry-exists) jql-value)
+ (org-jira-entry-put (point) "JQL" jql-value ))
+ (org-jira-entry-put (point) "ID" id))))))))
+
+(defun org-jira--get-boards-file ()
+ (expand-file-name "boards-list.org" (org-jira--ensure-working-dir)))
+
+(defun org-jira--get-boards-buffer ()
+ "Return buffer for list of agile boards. Create one if it does not exist."
+ (let* ((boards-file (org-jira--get-boards-file))
+ (existing-buffer (find-buffer-visiting boards-file)))
+ (if existing-buffer
+ existing-buffer
+ (find-file-noselect boards-file))))
+
+;;;###autoload
+(defun org-jira-get-boards ()
+ "Get list of boards and their properies."
+ (interactive)
+ (let* ((datalist (jiralib-get-boards))
+ (boards (org-jira-sdk-create-boards-from-data-list datalist)))
+ (org-jira--render-boards-from-list boards))
+ (switch-to-buffer (org-jira--get-boards-buffer)))
+
+(defun org-jira--get-board-from-buffer (id)
+ "Parse board record from org file."
+ (with-current-buffer (org-jira--get-boards-buffer)
+ (org-jira-freeze-ui
+ (let ((pos (org-find-property "ID" (int-to-string id))))
+ (if pos
+ (progn
+ (goto-char pos)
+ (apply 'org-jira-sdk-board
+ (cl-reduce
+ #'(lambda (acc entry)
+ (let* ((pname (car entry))
+ (pval (cdr entry))
+ (pair (and pval
+ (not (string-empty-p pval))
+ (cond
+ ((equal pname "ID")
+ (list :id pval))
+ ((equal pname "URL")
+ (list :url pval))
+ ((equal pname "TYPE")
+ (list :board-type pval))
+ ((equal pname "NAME")
+ (list :name pval))
+ ((equal pname "LIMIT")
+ (list :limit (string-to-number pval)))
+ ((equal pname "JQL")
+ (list :jql pval))
+ (t nil)))))
+ (if pair (append pair acc) acc)))
+ (org-entry-properties) :initial-value ()))))))))
+
+(defun org-jira-get-org-keyword-from-status (status)
+ "Gets an 'org-mode' keyword corresponding to a given jira STATUS."
+ (if org-jira-use-status-as-todo
+ (upcase (replace-regexp-in-string " " "-" status))
+ (let ((known-keyword (assoc status org-jira-jira-status-to-org-keyword-alist)))
+ (cond (known-keyword (cdr known-keyword))
+ ((member (org-jira-decode status) org-jira-done-states) "DONE")
+ ("TODO")))))
+
+(defun org-jira-get-org-priority-string (character)
+ "Return an org-priority-string based on CHARACTER and user settings."
+ (cond ((not character) "")
+ ((and org-jira-priority-to-org-priority-omit-default-priority
+ (eq character org-default-priority)) "")
+ (t (format "[#%c] " character))))
+
+(defun org-jira-get-org-priority-cookie-from-issue (priority)
+ "Get the `org-mode' [#X] PRIORITY cookie."
+ (let ((character (cdr (assoc priority org-jira-priority-to-org-priority-alist))))
+ (org-jira-get-org-priority-string character)))
+
+(provide 'org-jira)
+;;; org-jira.el ends here