src/yetibot/api/jira.clj
(ns yetibot.api.jira
"Interface with a configured JIRA instance. Note: there are slight differences
between server instances and JIRA Cloud, so some features may not be fully
compatible. In most cases these functions were developed against a JIRA
Enterprise instance.
To view all versions of the JIRA Rest API (and to suddelyl feel like you're
living in the 90s), visit:
https://docs.atlassian.com/software/jira/docs/api/REST/
This listing doesn't seem up to date as
https://docs.atlassian.com/software/jira/docs/api/REST/8.5.3/
exists but is not in the listing.
Safest choice might be to develop against `latest` of Server REST API:
https://docs.atlassian.com/software/jira/docs/api/REST/latest/
Hopefully someday they unify the cloud and enterprise APIs."
(:require
[yetibot.util :refer [oauth1-credentials]]
[taoensso.timbre :refer [info warn color-str]]
[clojure.spec.alpha :as s]
[yetibot.core.spec :as yspec]
[clojure.string :as string]
[clj-http.client :as client]
[oauth.client :as oauth]
[clojure.core.memoize :as memo]
[yetibot.core.config :refer [get-config]]
[clj-time
[local :refer [local-now]]
[format :refer [formatters show-formatters parse unparse]]]))
(s/def ::id ::yspec/non-blank-string)
(s/def ::key ::yspec/non-blank-string)
(s/def ::version (s/keys :req-un [::id]))
(s/def ::default (s/keys :req-un [::version]))
(s/def ::project (s/keys :req-un [::key]
:opt-un [::default]))
(s/def ::projects (s/coll-of ::project :kind vector?))
(s/def ::results ::yspec/non-blank-string)
(s/def ::max (s/keys :req-un [::results]))
(s/def ::type (s/keys :req-un [::id]))
(s/def ::issue (s/keys :req-un [::type]))
(s/def ::subtask (s/keys :req-un [::issue]))
(s/def ::default (s/keys :opt-un [::issue ::project]))
(s/def ::domain ::yspec/non-blank-string)
(s/def ::user ::yspec/non-blank-string)
(s/def ::password ::yspec/non-blank-string)
(s/def ::secret ::yspec/non-blank-string)
(s/def ::verifier ::yspec/non-blank-string)
(s/def ::token ::yspec/non-blank-string)
(s/def ::access (s/keys :req-un [::token]))
(s/def ::consumer (s/keys :req-un [::key
::secret]))
(s/def ::oauth1 (s/keys :req-un [::consumer
::verifier
::access]))
(s/def ::cloud string?)
(s/def ::config (s/keys :req-un [::domain ::user]
:opt-un [::cloud
::projects
::default
::max
::subtask
;; if password is include, basic auth is used
::password
;; if oauth is included use it instead
::oauth1]))
;; config
(def ^:dynamic *jira-project*
"Settings for the current channel, bound by yetibot.commands.jira"
nil)
(def ^:dynamic *jira-projects*
"Settings for the current channel, bound by yetibot.commands.jira"
nil)
(def jira-project-setting-key
"This key is used to store channel-specific JIRA projects"
"jira-project")
(defn channel-projects
"Retrieve the list of configured projects for a channel, given its settings"
[channel-settings]
(when-let [setting (get channel-settings jira-project-setting-key)]
(info "channel-projects" (pr-str setting))
(seq (remove string/blank? (string/split setting #",\s*")))))
(defn config [] (:value (get-config ::config [:jira])))
(defn configured? [] (config))
(defn cloud?
"Whether or not to use Cloud REST API calls:
https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-User-search"
[]
(-> (config) :cloud (= "true")))
(defn projects [] (:projects (config)))
(defn project-for-key [k] (first (filter #(= (:key %) k) (projects))))
(defn project-keys []
(set
(concat
(if *jira-project* [*jira-project*] [])
(vec *jira-projects*)
(map :key (projects)))))
(defn project-keys-str [] (string/join "," (into
(project-keys)
*jira-projects*)))
(comment
(config)
(cloud?)
(configured?)
(project-keys)
(project-keys-str))
(defn default-version-id [project-key] (-> (project-for-key project-key)
:default :version :id))
(defn default-project-key [] (or *jira-project*
(first *jira-projects*)
(-> (config) :default :project :key)
(first (project-keys))))
(defn default-project [] (project-for-key (default-project-key)))
(defn max-results []
(if-let [mr (-> (config) :max :results)]
(read-string mr)
10))
(defn sub-task-issue-type-id [] (-> (config) :subtask :issue :type :id ))
(defn default-issue-type-id [] (-> (config) :default :issue :type :id))
(defn base-uri [] (str "https://" (:domain (config))))
(defn api-uri [] (str (base-uri) "/rest/api/latest"))
(def auth (when (config)
(map (config) [:user :password])))
;; oauth 1
;; https://developer.atlassian.com/server/jira/platform/oauth/
;; This assume that you've already gone through the OAuth dance and obtained a
;; an access token
(def authorize-url (str (base-uri) "/plugins/servlet/oauth/authorize"))
(def request-token-url (str (base-uri) "/plugins/servlet/oauth/request-token"))
(def access-token-url (str (base-uri) "/plugins/servlet/oauth/access-token"))
(def oauth1 (:oauth1 (config)))
(when (configured?) (info "✅ JIRA is configured"))
(def consumer
(when oauth1
(info "✅ oauth1 is configured for JIRA API access")
(oauth/make-consumer
(-> oauth1 :consumer :key)
(-> oauth1 :consumer :secret)
request-token-url
access-token-url
authorize-url
:rsa-sha1)))
(when (and auth (every? identity auth))
(info "✅ basic auth is configured for JIRA API access"))
;; formatters
;; move to yetibot.core util if anyone else needs date parsing and formatting:
(def date-time-format (:date-hour-minute formatters))
(defn parse-and-format-date-string [date-string]
(unparse date-time-format (parse date-string)))
(defn client-opts
"For oauth1 client-opts need to be computed for every API in order to generate
an authz header, baking any query-params into the signature.
http-method should be: :GET :POST :PUT or :DELETE"
[uri http-method & [query-params]]
;; NOTE using :json-strict because of https://github.com/dakrone/clj-http/pull/507
(merge {:as :json-strict
:throw-exceptions true
:coerce :unexceptional
:throw-entire-message? true
:insecure? true}
(when query-params
{:query-params query-params})
(when (every? identity auth)
{:basic-auth auth})
(when oauth1
(let [oauth-params (-> (oauth1-credentials
consumer
(-> oauth1 :access :token)
(:verifier oauth1)
http-method
uri
query-params)
(dissoc :oauth_version)
(assoc :oauth_verifier (:verifier oauth1)))
authz-header (oauth/authorization-header oauth-params)]
{:headers {"Authorization" authz-header}}))))
(defn endpoint [& fmt-with-args]
(str (api-uri) (apply format fmt-with-args)))
(comment
(endpoint "/search"))
;; helpers
(defn http-get
[uri & [{:keys [query-params] :as opts}]]
(client/get
uri (merge opts (client-opts uri :GET query-params))))
(defn http-post
[uri & [{:keys [query-params] :as opts}]]
(client/post
uri (merge opts (client-opts uri :POST query-params))))
(defn http-put
[uri & [{:keys [query-params] :as opts}]]
(client/put
uri (merge opts (client-opts uri :PUT query-params))))
(defn http-delete
[uri & [{:keys [query-params] :as opts}]]
(client/delete
uri (merge opts (client-opts uri :DELETE query-params))))
;; formatters
(defn url-from-key [k]
(str (base-uri) "/browse/" k))
(defn format-project
[{{project-category-name :name} :projectCategory
project-key :key
project-name :name
:as project}]
(str "[" project-key "]"
(when project-category-name
(str " [" project-category-name "]"))
" "
project-name))
(defn format-issue [issue-data]
(let [fs (:fields issue-data)]
[(:summary fs)
(str "Assignee: " (-> fs :assignee :displayName))
(str "Status: " (-> fs :status :name))
(url-from-key (:key issue-data))]))
(defn format-issue-short [issue-data]
(let [fs (:fields issue-data)]
(format "[%s] [%s] [%s] [%s] %s %s"
(or (-> fs :assignee :displayName) "unassigned")
(-> fs :status :name)
(-> fs :issuetype :name)
(or (-> fs :priority :name) "Not set")
(:summary fs)
(url-from-key (:key issue-data)))))
(defn format-comment [c]
(str "💬 "
(-> c :author :displayName) " "
(parse-and-format-date-string (:created c))
": " (:body c)))
(defn format-worklog-item [w]
(str "🚧 " (-> w :author :displayName) " " (:timeSpent w) ": " (:comment w)
" [" (parse-and-format-date-string (:started w)) "]"))
(defn format-worklog-items [issue-data]
(when-let [worklog (-> issue-data :fields :worklog :worklogs)]
(map format-worklog-item worklog)))
(defn format-subtasks [{{subtasks :subtasks} :fields}]
(when subtasks
(map (fn [{st-key :key
{summary :summary} :fields}]
(str "➡️ "
"[" st-key "] " summary))
subtasks)))
(defn format-attachment-item [a]
(str "📎 "
(-> a :author :name) " "
(parse-and-format-date-string (:created a))
": " (:content a)))
(defn format-attachments [issue-data]
(when-let [attachments (-> issue-data :fields :attachment)]
(map format-attachment-item attachments)))
(defn format-issue-long
"Show the full details for an issue"
[issue-data]
(let [fs (:fields issue-data)]
(flatten
(keep identity
[(str (:key issue-data) " 🔵 " (-> fs :status :name) " 🔵 " (:summary fs))
(:description fs)
(string/join
" "
[(str "👷 " (-> fs :assignee :displayName))
(str "👮 " (-> fs :reporter :displayName))])
(string/join
" "
[(str "🔩 Component: " (->> fs :components
(map :name)
(string/join ", ")))
(str "❗️ Priority: " (-> fs :priority :name))
(str " ✅ Fix version: " (string/join ", " (map :name (:fixVersions fs))))])
(str "🕐 Created: " (parse-and-format-date-string (:created fs))
" 🕗 Updated: " (parse-and-format-date-string (:updated fs)))
(map format-comment (-> fs :comment :comments))
(format-worklog-items issue-data)
(format-subtasks issue-data)
(format-attachments issue-data)
(str "👉 " (url-from-key (:key issue-data)))]))))
;; search
(defn- projects-jql [& [project]]
(if project
(str "(project in (" project "))")
(str "(project in (" (project-keys-str) "))")))
(defn search [jql]
(info "JQL search" jql)
(http-get
(endpoint "/search")
{:query-params {:jql jql
:startAt 0
:maxResults (max-results)
:fields "summary,issuetype,status,assignee"}
:coerce :always
:throw-exceptions false}))
(defn search-in-projects [jql]
(search (str (projects-jql) " AND (" jql ")")))
(defn search-by-query [query]
(search-in-projects
(str
"(summary ~ \"" query "\" OR description ~ \"" query
"\" OR comment ~ \"" query "\")")))
(defn recent [& [project]]
(search
(str (projects-jql project) " ORDER BY updatedDate")))
(comment
(search-by-query "demo")
(projects-jql)
(projects-jql "FOO")
(search "created >= -5h")
(recent)
(recent "YETIBOT")
(str (projects-jql "YETIBOT") " ORDER BY updatedDate")
*e
)
;; users
(defn get-users [project]
(let [uri (endpoint "/user/assignable/multiProjectSearch")]
(http-get
uri
{:query-params {:projectKeys project}})))
(defn search-users
"Find a user entity matching against display name and email.
query - A query string that is matched against user attributes ( displayName,
and emailAddress) to find relevant users. The string can match the prefix of
the attribute's value. For example, query=john matches a user with a
displayName of John Smith and a user with an emailAddress of
johnson@example.com"
[query]
(http-get
(endpoint "/user/search")
{:query-params
(if (cloud?)
{:query query}
{:username query})}))
(defn resolve-user-by-query
"Given a query representing a user return the first match, if any"
[user-query]
(let [{:keys [body]} (search-users user-query)]
(first body)))
(defn user-ref
"Jira Cloud and Server take different params when referencing users. Abstract
the diff with this function"
[assignee-user]
(if (cloud?)
{:id (:accountId assignee-user)} ;; TODO verify
;; this doesn't work, apparently
;; {:key (:key assignee-user)}
(select-keys assignee-user [:name])))
;; issues
(defn issue-create-meta [] (http-get (endpoint "/issue/createmeta")))
(defn get-transitions [i]
(http-get
(endpoint "/issue/%s/transitions" i)
{:query-params {:transitionId nil}}))
(defn find-transition
"Use regex to find by partial match on transition name"
[issue-key query]
(let [p (re-pattern (str "(?i)" query))]
(first (filter #(re-find p (:name %))
(-> (get-transitions issue-key) :body :transitions)))))
(def ^:private find-resolve
(partial filter #(or
(= "Done" (:name %))
(= "Resolve Issue" (:name %)))))
;; whether resolution is present depends on the configured
;; screen. by default it's not present, so including this
;; property would trigger the error:
;; {"errorMessages":[],"errors":{"resolution":"Field 'resolution' cannot be set. It is not on the appropriate screen, or unknown."}}
;; TODO dynamically determine if `resolution` is configured
;; /shrug
;; :fields {:resolution {:name "Fixed"}}
(defn transition-issue [i transition-id & iss-comment]
(let [params (merge {:transition {:id transition-id}}
(when iss-comment
{:update {:comment [{:add {:body iss-comment}}]}}))]
(http-post
(endpoint "/issue/%s/transitions" i)
{:form-params params
:content-type :json})))
(defn resolve-issue
"Transition an issue to the resolved state. If it is unable to make that
transition nil will be returned"
[i comment]
(let [ts (:body (get-transitions i))
resolve-t (find-resolve (:transitions ts))]
(when-let [t (first resolve-t)]
(transition-issue i (:id t) comment))))
(defn post-comment [issue-key body]
(let [uri (endpoint "/issue/%s/comment" issue-key)]
(http-post uri
{:content-type :json
:form-params {:body body}})))
(defn add-worklog-item [issue-key time-spent work-description]
(let [uri (endpoint "/issue/%s/worklog" issue-key)
form-params {:timeSpent time-spent
:comment work-description}]
(http-post uri
{:content-type :json
:form-params form-params})))
(comment
;; this is HUGE - it will freez your REPL if you try it
#_(issue-create-meta)
(def iss-key (-> (recent) :body :issues first :key))
(add-worklog-item
iss-key
"2d"
"jira oauth1 nightmare")
(post-comment iss-key "will it ever end")
(get-transitions iss-key)
(->> (get-transitions iss-key)
:body
:transitions
(map :name))
(get-transitions "COM-3")
(-> iss-key
get-transitions
:body
:transitions
find-resolve)
(resolve-issue iss-key "do you even resolve")
*e)
(defn get-issue
"Fetch json for a given JIRA"
[i]
(let [uri (endpoint "/issue/%s" i)
opts {:query-params {:fields "*navigable,comment,worklog,attachment"}}]
(http-get uri opts)
#_(try
(http-get uri opts)
(catch Exception e
(info "issue not found" i)))))
(comment
(get-issue "YETIBOT-85")
*e)
(def fetch-and-format-issue-short (comp format-issue-short :body get-issue))
(defn find-project [pk]
(try
(:body (http-get (endpoint "/project/%s" pk)))
(catch Exception e
(info "unable to find project:" e)
nil)))
(comment
(find-project "YETIBOT"))
(defn priorities []
(http-get (endpoint "/priority")))
(defn find-priority-by-key [k]
(let [kp (re-pattern (str "(?i)" k))]
(first (filter #(re-find kp (:name %))
(:body (priorities))))))
(defn issue-types []
(:body (http-get (endpoint "/issuetype"))))
(defn update-issue
[issue-key {:keys [fix-version summary component-ids reporter assignee
priority-key desc timetracking]}]
(let [pri-id (if priority-key (:id (find-priority-by-key priority-key)))
params {:fields
(merge
{}
(when fix-version {:fixVersions [{:name fix-version}]})
(when summary {:summary summary})
(when assignee
(when-let [assignee-user (resolve-user-by-query assignee)]
{:assignee (user-ref assignee-user)}))
(when reporter
{:reporter (user-ref (resolve-user-by-query reporter))})
(when component-ids {:components (map #(hash-map :id %) component-ids)})
(when desc {:description desc})
(when timetracking {:timetracking timetracking})
(when pri-id {:priority {:id pri-id}}))}]
(info "update issue" (pr-str params))
(http-put
(endpoint "/issue/%s" issue-key)
{:coerce :always
:throw-exceptions false
:form-params params
:content-type :json})))
(comment
;; assign the most recent issue for the default project to a random user
(let [user "trevor"
issue (-> (recent) :body :issues second :key)]
(info "test assign" {:user user :issue issue})
(update-issue issue {:assignee user}))
(let [issue (-> (recent) :body :issues second :key)]
(http-get (endpoint "/issue/%s/editmeta" issue)))
*e
(resolve-user-by-query "trevor")
(user-ref (resolve-user-by-query "trevor hartman"))
;; update example
(http-put
(endpoint "/issue/%s" "YETIBOT-89")
{:coerce :always
:throw-exceptions false
:form-params {:fields {:assignee
(user-ref (resolve-user-by-query "trevor hartman"))}}
:content-type :json})
(search-users "y")
(search-users "trevor")
(get-users (first (project-keys)))
(priorities)
(issue-types)
*e
(update-issue
(-> (recent) :body :issues first :key)
{:desc (str (local-now))}))
;; TODO consolidate determineing project key from context (channel settings or
;; global config). currently we duplicate that logic in many places.
(defn create-issue
"This thing is a beast; thanks JIRA."
[{:keys [summary component-ids reporter assignee priority-key desc project-key
fix-version timetracking issue-type-id parent]
:or {desc "" assignee "-1"
issue-type-id (if parent (sub-task-issue-type-id)
(default-issue-type-id))
project-key (or (first *jira-projects*)
(default-project-key))}}]
(info "create-issue"
(color-str :blue {:issue-type-id issue-type-id
:project-key project-key
:parent parent
:assignee assignee
:component-ids component-ids}))
(if-let [prj (find-project project-key)]
(if-let [priority (if priority-key
(find-priority-by-key priority-key)
(first (:body (priorities))))]
(let [pri-id (:id priority)
prj-id (:id prj)
fix-version-map (if fix-version
{:name fix-version}
(when-let [dvi (default-version-id project-key)]
{:id dvi}))
_ (info "fix-version-map" fix-version-map)
params {:fields
(merge {:assignee (-> assignee resolve-user-by-query
user-ref)
:project {:id prj-id}
:summary summary
:description desc
:issuetype {:id issue-type-id}
:priority {:id pri-id}}
(when component-ids
{:components (map #(hash-map :id %)
component-ids)})
(when reporter
{:reporter (-> reporter resolve-user-by-query
user-ref)})
(when fix-version-map
{:fixVersions [fix-version-map]})
(when timetracking
{:timetracking timetracking})
(when parent
{:parent {:key parent}}))}]
(info "create issue" (pr-str params))
(http-post
(endpoint "/issue")
{:form-params params
:content-type :json}))
(warn "Could not find a priority for key " priority-key))
(warn "Could not find project" project-key)))
(comment
(create-issue {:summary "test issue creation"
})
(priorities)
(default-version-id "YETIBOT")
*e)
(defn delete-issue [issue-key]
(http-delete
(endpoint "/issue/%s" issue-key)
{:coerce :always
:content-type :json
:throw-exceptions false}))
(comment
;; delete random issue
(let [issue (-> (recent) :body :issues rand-nth :key)]
(info "deleting issue" issue)
(delete-issue issue)))
;; projects
(comment
;; list projects
(http-get
(endpoint "/project/"))
)
;; versions
(defn versions
([] (versions (default-project-key)))
([project-key]
(http-get
(endpoint "/project/%s/versions" project-key))))
(comment (versions))
;; components
(defn components [project-key]
(http-get
(endpoint "/project/%s/components" project-key)))
(def all-components
(memo/ttl #(map components (project-keys))
:ttl/threshold 3600000))
(defn find-component-like
"Match components across all projects"
[pattern-str]
(let [re (re-pattern (str "(?i)" pattern-str))]
(filter #(re-find re (:name %)) (mapcat :body (all-components)))))
(comment
(components (first (project-keys)))
(->> (project-keys)
first
components
:body
(map :description))
(find-component-like "bugs"))
;; (defn find-user-assignable-to
;; [issue-key & [user-to-search-for]]
;; (http-get
;; (endpoint "/user/assignable/search")
;; {:query-params
;; (merge {:issueKey issue-key}
;; (when user-to-search-for {}))}))
;; projects
(defn get-projects [& [query]]
(http-get
(endpoint "/project/search")
{:query-params (merge {}
(when query {:query query}))}))
(comment
(get-projects)
)
;; prime cache
;; todo: move into a start fn ;; (future (all-components))
(comment
;; scratch space for playing with JIRA api
(def username "_Yetibot_admin")
(http-get (endpoint "/user/properties"))
(endpoint "/user")
(http-get (endpoint "/user"))
*e
(def updated-name "Yetibot")
;; these don't work /shrug
(http-put (endpoint "/user/properties/displayName")
{:query-params {:username username
:value updated-name}
:throw-exceptions false})
(http-put (endpoint "/user")
{:query-params {:username username
:key "name"
:value "Yetibot"}
:throw-exceptions false}))