├── README.md ├── elmine-tests.el └── elmine.el /README.md: -------------------------------------------------------------------------------- 1 | # elmine 2 | 3 | `elmine` is a simple package to interact with the redmine restful 4 | webservice easily. Essentially it abstracts most API calls to the 5 | Redmine API. 6 | 7 | ## Usage 8 | 9 | To access the Redmine API you have to specifiy the redmine to access 10 | and the API key to access it with. That´s either done via setting the 11 | variables `redmine/host` and `redmine/api-key` 12 | 13 | (setq elmine/host "https://www.my-redmine.org") 14 | (setq elmine/api-key "abcdefghijklmnopqrstuvwxyz1234567890") 15 | 16 | or bind the variables `redmine-host` and `redmine-api-key` 17 | dynamically. 18 | 19 | (let ((redmine-host "https://www.my-redmine.org") 20 | (redmine-api-key "acdefghijklmnopqrstuvwxyz1234567890")) 21 | (elmine/get-issues)) 22 | 23 | ## License 24 | 25 | Copyright (C) 2012 Arthur Leonard Andersen 26 | 27 | Authors: Arthur Leonard Andersen 28 | Keywords: tools 29 | 30 | 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. 31 | 32 | 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. 33 | 34 | You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. 35 | -------------------------------------------------------------------------------- /elmine-tests.el: -------------------------------------------------------------------------------- 1 | ;; I´m currently not very confident writing tests. Especially when in 2 | ;; the need for mocking URL requests. 3 | 4 | ;; Currently this file only contains expressions that I ran against my 5 | ;; personal redmine to verify the functionality. 6 | 7 | ;; TODO: Write real tests. 8 | 9 | (elmine/api-raw "GET" "/issues/93.json" nil nil) 10 | 11 | (elmine/api-raw "POST" "/projects/personal/issues.json" 12 | "{\"issue\": {\"abc\":\"test\"}}" nil) 13 | 14 | (elmine/api-raw "DELETE" "/issues/102.json" nil nil) 15 | 16 | (elmine/api-post :issue '(:subject "It´s not blank") 17 | "/projects/personal/issues.json") 18 | 19 | (elmine/api-get :issue "/issues/93.json") 20 | 21 | (elmine/get-issues) 22 | 23 | (elmine/get-issue 105) 24 | 25 | (elmine/create-issue '(:subject "Some Title" :project_id "personal")) 26 | 27 | (elmine/create-issue :subject "Some Other Title" :project_id "personal") 28 | 29 | (elmine/update-issue '(:id 105 :subject "Changed Title 2")) 30 | 31 | (elmine/delete-issue 101) 32 | 33 | (elmine/get-issue-time-entries 4) 34 | 35 | (elmine/get-issue-relations 99) 36 | 37 | (elmine/get-projects) 38 | 39 | (elmine/get-project "personal") 40 | 41 | (elmine/create-project :name "Ein Test-Project" :identifier "test-project") 42 | 43 | (elmine/update-project '(:identifier "test-project" :name "Ein neuer Test-Projekt-Name")) 44 | 45 | (elmine/delete-project "test-project") 46 | 47 | (elmine/get-project-categories "personal") 48 | 49 | (elmine/get-project-issues "personal") 50 | 51 | (elmine/get-project-versions "personal") 52 | 53 | (elmine/get-version 16) 54 | 55 | (elmine/create-version :project_id "personal" :name "ABCDEFG") 56 | 57 | (elmine/update-version :id 18 :name "XYZ") 58 | 59 | (elmine/get-issue-statuses) 60 | 61 | (elmine/get-trackers) 62 | 63 | (elmine/get-issue-priorities) 64 | 65 | (elmine/get-time-entries) 66 | 67 | (elmine/get-time-entry 1) 68 | 69 | (elmine/create-time-entry :issue_id 93 :activity_id 1 :hours "2.0") 70 | 71 | (elmine/update-time-entry :id 5 :hours "3.0") 72 | 73 | (elmine/delete-time-entry 5) 74 | -------------------------------------------------------------------------------- /elmine.el: -------------------------------------------------------------------------------- 1 | ;;; elmine.el --- Redmine API access via elisp. 2 | 3 | ;; Copyright (c) 2012 Arthur Leonard Andersen 4 | ;; 5 | ;; Author: Arthur Andersen 6 | ;; URL: http://github.com/leoc/elmine 7 | ;; Version: 0.3.1 8 | ;; Keywords: tools 9 | ;; Package-Requires: ((s "1.10.0")) 10 | ;; 11 | ;; This file is not part of GNU Emacs. 12 | ;; 13 | ;; This program is free software; you can redistribute it and/or 14 | ;; modify it under the terms of the GNU General Public License 15 | ;; as published by the Free Software Foundation; either version 3 16 | ;; of the License, or (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU General Public License 24 | ;; along with GNU Emacs; see the file COPYING. If not, write to the 25 | ;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, 26 | ;; Boston, MA 02110-1301, USA. 27 | 28 | ;;; Commentary: 29 | 30 | ;; `elmine` provides simple means of accessing the redmine Rest API 31 | ;; programmatically. This means that you do not have interactive 32 | ;; functions but functions that give and take list representations of 33 | ;; JSON objects of the redmine API. 34 | 35 | ;;; Code: 36 | 37 | (require 'json) 38 | (require 's) 39 | 40 | (defun plist-merge (base new) 41 | "Merges two plists. The keys of the second one will overwrite the old ones." 42 | (let ((key (car new)) 43 | (val (cadr new)) 44 | (new (cddr new))) 45 | (while (and key val) 46 | (setq base (plist-put base key val)) 47 | (setq key (car new)) 48 | (setq val (cadr new)) 49 | (setq new (cddr new))) 50 | base)) 51 | 52 | (defvar elmine/host nil 53 | "The default host of the redmine.") 54 | 55 | (defvar elmine/api-key nil 56 | "The default API key for the redmine") 57 | 58 | (defun elmine/get (plist key &rest keys) 59 | "Execute `plist-get` recursively for `plist`. 60 | 61 | Example: 62 | (setq plist '(:a 3 63 | :b (:c 12 64 | :d (:e 31)))) 65 | 66 | (elmine/get plist \"a\") 67 | ;; => 3 68 | (elmine/get plist :b) 69 | ;; => (:c 12 :d (:e 31)) 70 | (elmine/get plist :b :c) 71 | ;; => 12 72 | (elmine/get plist :b :d :e) 73 | ;; => 31 74 | (elmine/get plist :b :a) 75 | ;; => nil 76 | (elmine/get plist :a :c) 77 | ;; => nil" 78 | (save-match-data 79 | (let ((ret (plist-get plist key))) 80 | (while (and keys ret) 81 | (if (listp ret) 82 | (progn 83 | (setq ret (elmine/get ret (car keys))) 84 | (setq keys (cdr keys))) 85 | (setq ret nil))) 86 | ret))) 87 | 88 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 89 | ;; HTTP functions using Emacs URL package ;; 90 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 91 | (defun elmine/make-key (string) 92 | (make-symbol (format ":%s" (s-dashed-words string)))) 93 | 94 | (defun elmine/ensure-string (object) 95 | "Return a string representation of OBJECT." 96 | (cond ((stringp object) object) 97 | ((keywordp object) (substring (format "%s" object) 1 nil)) 98 | ((symbolp object) (symbol-name object)) 99 | ((numberp object) (number-to-string object)) 100 | (t (pp-to-string object)))) 101 | 102 | (defun elmine/api-build-query-string (plist) 103 | "Builds a query string from a given plist." 104 | (if plist 105 | (let (query-pairs) 106 | (while plist 107 | (let ((key (url-hexify-string (elmine/ensure-string (car plist)))) 108 | (val (url-hexify-string (elmine/ensure-string (cadr plist))))) 109 | (setq query-pairs (cons (format "%s=%s" key val) query-pairs)) 110 | (setq plist (cddr plist)))) 111 | (concat "?" (s-join "&" query-pairs))) 112 | "")) 113 | 114 | (defun elmine/api-build-url (path params) 115 | "Creates a URL from a relative PATH, a plist of query PARAMS and 116 | the dynamically bound `redmine-api-key` and `redmine-host` variables." 117 | (let ((host (s-chop-suffix "/" redmine-host)) 118 | (query-str (elmine/api-build-query-string params))) 119 | (concat host path query-str))) 120 | 121 | (defun elmine/api-raw (method path data params) 122 | "Perform a raw HTTP request with given METHOD, a relative PATH and a 123 | plist of PARAMS for the query." 124 | (let* ((redmine-host (if (boundp 'redmine-host) 125 | redmine-host 126 | elmine/host)) 127 | (redmine-api-key (if (boundp 'redmine-api-key) 128 | redmine-api-key 129 | elmine/api-key)) 130 | (url (elmine/api-build-url path params)) 131 | (url-request-method method) 132 | (url-request-extra-headers 133 | `(("Content-Type" . "application/json") 134 | ("X-Redmine-API-Key" . ,redmine-api-key))) 135 | (url-request-data data) 136 | header-end status header body) 137 | (save-excursion 138 | (switch-to-buffer (url-retrieve-synchronously url)) 139 | (beginning-of-buffer) 140 | (setq header-end (save-excursion 141 | (if (re-search-forward "^$" nil t) 142 | (progn 143 | (forward-char) 144 | (point)) 145 | (point-max)))) 146 | (when (re-search-forward "^HTTP/\\(1\\.0\\|1\\.1\\) \\([0-9]+\\) \\([A-Za-z ]+\\)$" nil t) 147 | (setq status (plist-put status :code (string-to-number (match-string 2)))) 148 | (setq status (plist-put status :text (match-string 3)))) 149 | (while (re-search-forward "^\\([^:]+\\): \\(.*\\)" header-end t) 150 | (setq header (cons (match-string 1) (cons (match-string 2) header)))) 151 | (unless (eq header-end (point-max)) 152 | (setq body (url-unhex-string 153 | (buffer-substring header-end (point-max))))) 154 | (kill-buffer)) 155 | `(:status ,status 156 | :header ,header 157 | :body ,body))) 158 | 159 | (defun elmine/api-get (element path &rest params) 160 | "Perform an HTTP GET request and return a PLIST with the request information. 161 | It returns the " 162 | (let* ((params (if (listp (car params)) (car params) params)) 163 | (response (elmine/api-raw "GET" path nil params)) 164 | (object (elmine/api-decode (plist-get response :body))) 165 | ) 166 | (if element 167 | (plist-get object element) 168 | object))) 169 | 170 | (defun elmine/api-post (element object path &rest params) 171 | "Does an http POST request and returns response status as symbol." 172 | (let* ((params (if (listp (car params)) (car params) params)) 173 | (data (elmine/api-encode `(,element ,object))) 174 | (response (elmine/api-raw "POST" path data params)) 175 | (object (elmine/api-decode (plist-get response :body)))) 176 | object)) 177 | 178 | (defun elmine/api-put (element object path &rest params) 179 | "Does an http PUT request and returns the response status as symbol. 180 | Either :ok or :unprocessible." 181 | (let* ((params (if (listp (car params)) (car params) params)) 182 | (data (elmine/api-encode `(,element ,object))) 183 | (response (elmine/api-raw "PUT" path data params)) 184 | (object (elmine/api-decode (plist-get response :body))) 185 | (status (elmine/get response :status :code))) 186 | (cond ((eq status 200) t) 187 | ((eq status 404) 188 | (signal 'no-such-resource `(:response ,response)))))) 189 | 190 | (defun elmine/api-delete (path &rest params) 191 | "Does an http DELETE request and returns the body of the response." 192 | (let* ((params (if (listp (car params)) (car params) params)) 193 | (response (elmine/api-raw "DELETE" path nil params)) 194 | (status (elmine/get response :status :code))) 195 | (cond ((eq status 200) t) 196 | ((eq status 404) 197 | (signal 'no-such-resource `(:response ,response)))))) 198 | 199 | (defun elmine/api-get-all (element path &rest filters) 200 | "Return list of ELEMENT items retrieved from PATH limited by FILTERS. 201 | 202 | Limiting items by count can be done using `limit' in FILTERS: 203 | - If `limit' is t, return all items. 204 | - If `limit' is number, return items up to that count. 205 | - Otherwise return up to 25 items (redmine api default)." 206 | (let* ((initial-limit (plist-get filters :limit)) 207 | (initial-limit (when (or 208 | (eq t initial-limit) 209 | (numberp initial-limit)) 210 | initial-limit)) 211 | (limit (if (eq t initial-limit) 100 initial-limit)) 212 | (response-object (apply #'elmine/api-get nil path (plist-put filters :limit limit))) 213 | (offset (elmine/get response-object :offset)) 214 | (limit (elmine/get response-object :limit)) 215 | (total-count (elmine/get response-object :total_count)) 216 | (issue-list (elmine/get response-object element))) 217 | (if (and offset 218 | limit 219 | (< (+ offset limit) total-count) 220 | (or (eq t initial-limit) 221 | (and initial-limit (< (+ offset limit) initial-limit)))) 222 | (let* ((offset (+ offset limit)) 223 | (limit (if (eq t initial-limit) 224 | t 225 | (- initial-limit offset)))) 226 | (append issue-list (apply #'elmine/api-get-all element path 227 | (plist-merge 228 | filters 229 | `(:offset ,offset :limit ,limit))))) 230 | issue-list))) 231 | 232 | 233 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 234 | ;; Simple JSON decode/encode functions ;; 235 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 236 | (defun elmine/api-decode (json-string) 237 | "Parses a JSON string and returns an object. Per default JSON objects are 238 | going to be hashtables and JSON arrays are going to be lists." 239 | (if (null json-string) 240 | nil 241 | (let ((json-object-type 'plist) 242 | (json-array-type 'list)) 243 | (condition-case err 244 | (json-read-from-string json-string) 245 | (json-readtable-error 246 | (message "%s: Could not parse json-string into an object. See %s" 247 | (error-message-string err) json-string)))))) 248 | 249 | (defun elmine/api-encode (object) 250 | "Return a JSON representation from the given object." 251 | (let ((json-object-type 'plist) 252 | (json-array-type 'list)) 253 | (condition-case err 254 | (encode-coding-string (json-encode object) 'utf-8) 255 | (json-readtable-error 256 | (message "%s: Could not encode object into JSON string. See %s" 257 | (error-message-string err) object))))) 258 | 259 | 260 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 261 | ;; API functions to retrieve data from redmine ;; 262 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 263 | (defun elmine/get-issues (&rest filters) 264 | "Get a list of issues." 265 | (apply #'elmine/api-get-all :issues "/issues.json" filters)) 266 | 267 | (defun elmine/get-issue (id &rest params) 268 | "Get a specific issue via id." 269 | (elmine/api-get :issue (format "/issues/%s.json" id) params)) 270 | 271 | (defun elmine/create-issue (&rest params) 272 | "Create an issue. 273 | 274 | You can create an issue with giving each of its parameters or simply passing 275 | an issue object to this function." 276 | (let ((object (if (listp (car params)) (car params) params))) 277 | (elmine/api-post :issue object "/issues.json"))) 278 | 279 | (defun elmine/update-issue (object) 280 | "Update an issue. The object passed to this function gets updated." 281 | (let ((id (plist-get object :id))) 282 | (elmine/api-put :issue object (format "/issues/%s.json" id)))) 283 | 284 | (defun elmine/delete-issue (id) 285 | "Deletes an issue with a specific id." 286 | (elmine/api-delete (format "/issues/%s.json" id))) 287 | 288 | (defun elmine/get-issue-time-entries (issue-id &rest filters) 289 | "Gets all time entries for a specific issue." 290 | (apply #'elmine/api-get-all :time_entries 291 | (format "/issues/%s/time_entries.json" issue-id) filters)) 292 | 293 | (defun elmine/get-issue-relations (issue-id) 294 | "Get all relations for a specific issue." 295 | (apply #'elmine/api-get-all :relations 296 | (format "/issues/%s/relations.json" issue-id) nil)) 297 | 298 | (defun elmine/get-projects (&rest filters) 299 | "Get a list with projects." 300 | (apply #'elmine/api-get-all :projects "/projects.json" filters)) 301 | 302 | (defun elmine/get-project (project) 303 | "Get a specific project." 304 | (elmine/api-get :project (format "/projects/%s.json" project))) 305 | 306 | (defun elmine/create-project (&rest params) 307 | "Create a new project." 308 | (let ((object (if (listp (car params)) (car params) params))) 309 | (elmine/api-post :project object "/projects.json"))) 310 | 311 | (defun elmine/update-project (&rest params) 312 | "Update a given project." 313 | (let* ((object (if (listp (car params)) (car params) params)) 314 | (identifier (plist-get object :identifier))) 315 | (elmine/api-put :project object 316 | (format "/projects/%s.json" identifier)))) 317 | 318 | (defun elmine/delete-project (project) 319 | "Deletes a project." 320 | (elmine/api-delete (format "/projects/%s.json" project))) 321 | 322 | (defun elmine/get-project-categories (project &rest filters) 323 | "Get all categories for a project." 324 | (apply #'elmine/api-get-all :issue_categories 325 | (format "/projects/%s/issue_categories.json" project) filters)) 326 | 327 | (defun elmine/get-project-issues (project &rest filters) 328 | "Get a list of issues for a specific project." 329 | (apply #'elmine/api-get-all :issues 330 | (format "/projects/%s/issues.json" project) filters)) 331 | 332 | (defun elmine/get-project-versions (project &rest filters) 333 | "Get a list of versions for a specific project." 334 | (apply #'elmine/api-get-all :versions 335 | (format "/projects/%s/versions.json" project) filters)) 336 | 337 | (defun elmine/get-project-memberships (project &rest filters) 338 | "Get PROJECT memberships limited by FILTERS." 339 | (apply #'elmine/api-get-all :memberships 340 | (format "/projects/%s/memberships.json" project) filters)) 341 | 342 | (defun elmine/get-version (id) 343 | "Get a specific version." 344 | (elmine/api-get :version (format "/versions/%s.json" id))) 345 | 346 | (defun elmine/create-version (&rest params) 347 | "Create a new version." 348 | (let* ((object (if (listp (car params)) (car params) params)) 349 | (project (plist-get object :project_id))) 350 | (elmine/api-post :version object 351 | (format "/projects/%s/versions.json" project)))) 352 | 353 | (defun elmine/update-version (&rest params) 354 | "Update a given version." 355 | (let* ((object (if (listp (car params)) (car params) params)) 356 | (id (plist-get object :id))) 357 | (elmine/api-put :version object 358 | (format "/versions/%s.json" id)))) 359 | 360 | (defun elmine/get-issue-statuses () 361 | "Get a list of available issue statuses." 362 | (elmine/api-get-all :issue_statuses "/issue_statuses.json")) 363 | 364 | (defun elmine/get-issue-priorities (&rest params) 365 | "Get a list of issue priorities." 366 | (apply #'elmine/api-get-all :issue_priorities 367 | "/enumerations/issue_priorities.json" params)) 368 | 369 | (defun elmine/get-trackers () 370 | "Get a list of tracker names and their IDs." 371 | (elmine/api-get-all :trackers "/trackers.json")) 372 | 373 | (defun elmine/get-issue-priorities () 374 | "Get a list of issue priorities and their IDs." 375 | (elmine/api-get-all :issue_priorities "/enumerations/issue_priorities.json")) 376 | 377 | (defun elmine/get-time-entries (&rest filters) 378 | "Get a list of time entries." 379 | (apply #'elmine/api-get-all :time_entries "/time_entries.json" filters)) 380 | 381 | (defun elmine/get-time-entry (id) 382 | "Get a specific time entry." 383 | (elmine/api-get :time_entry (format "/time_entries/%s.json" id))) 384 | 385 | (defun elmine/get-time-entry-activities (&rest params) 386 | "Get a list of time entry activities." 387 | (apply #'elmine/api-get-all :time_entry_activities 388 | "/enumerations/time_entry_activities.json" params)) 389 | 390 | (defun elmine/create-time-entry (&rest params) 391 | "Create a new time entry" 392 | (let* ((object (if (listp (car params)) (car params) params))) 393 | (elmine/api-post :time_entry object "/time_entries.json"))) 394 | 395 | (defun elmine/update-time-entry (&rest params) 396 | "Update a given time entry." 397 | (let* ((object (if (listp (car params)) (car params) params)) 398 | (id (plist-get object :id))) 399 | (elmine/api-put :time_entry object (format "/time_entries/%s.json" id)))) 400 | 401 | (defun elmine/delete-time-entry (id) 402 | "Delete a specific time entry." 403 | (elmine/api-delete (format "/time_entries/%s.json" id))) 404 | 405 | (defun elmine/get-users (&rest filters) 406 | "Get a list of users limited by FILTERS." 407 | (apply #'elmine/api-get-all :users "/users.json" filters)) 408 | 409 | (defun elmine/get-user (user &rest params) 410 | "Get USER. PARAMS can be used to retrieve additional details. 411 | If USER is `current', get user whose credentials are used." 412 | (elmine/api-get :user (format "/users/%s.json" user) params)) 413 | 414 | (provide 'elmine) 415 | 416 | ;;; elmine.el ends here 417 | --------------------------------------------------------------------------------