├── .eslintrc.js ├── .gitignore ├── README.md ├── app ├── index.html ├── index.js ├── main.js └── public │ └── style.css ├── bin ├── download_img.sh ├── install.sh └── update.sh ├── icon.png ├── info.plist ├── lib ├── action.js ├── alfred-exec.js ├── alfred-log.js ├── assign.js ├── background.js ├── comment.js ├── create.js ├── daemon.js ├── extendedMenu.js ├── issues.js ├── jira │ ├── assign.js │ ├── auth.js │ ├── cache.js │ ├── comment.js │ ├── config.js │ ├── create.js │ ├── grab-images.js │ ├── index.js │ ├── issuetypes.js │ ├── keychain.js │ ├── list.js │ ├── projects.js │ ├── search.js │ ├── transitions.js │ ├── users.js │ ├── watch.js │ └── worklog.js ├── listTickets.js ├── scriptfilter.js ├── search.js ├── settings.js ├── status.js ├── workflow.js └── worklog.js ├── package-lock.json ├── package.json └── resources ├── demo.gif └── icons ├── assigned.png ├── back.png ├── bookmark.png ├── comment.png ├── config.png ├── default.png ├── delete.png ├── description.png ├── done.png ├── edit.png ├── good.png ├── in progress.png ├── inbox.png ├── label.png ├── labeloutline.png ├── login.png ├── play.png ├── priority.png ├── restart.png ├── search.png ├── stop.png ├── title.png ├── to do.png ├── unwatch.png ├── update.png ├── warning.png ├── watch.png └── watched.png /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es6: true, 5 | }, 6 | rules: { 7 | 'semi': 'error', 8 | 'quotes': ['error', 'single'], 9 | 'curly': 'error', 10 | 'comma-dangle': ['error', 'always-multiline'], 11 | 'no-multi-spaces': 'error', 12 | 'no-whitespace-before-property': 'error', 13 | 'brace-style': ['error', 'stroustrup'], 14 | 'indent': ['error', 2], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | resources/project_icons 2 | resources/user_icons 3 | resources/priority_icons 4 | node_modules 5 | npm-debug* 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alfred-Jira 2 | This is a workflow for Alfred 2 (or Alfred 3) that can be used to interact with [JIRA](http://www.atlassian.com/jira). 3 | ![Alt text](https://github.com/steyep/alfred-jira/raw/master/resources/demo.gif) 4 | ## Installation 5 | * [Install Node.js](https://nodejs.org/en/download/package-manager/) (`>=6.0.0`). 6 | * Clone/fork this repo 7 | * Run the build script `npm run build` 8 | 9 | ## Features 10 | * Quickly create new issues. 11 | * **Project** and **Issue Type** fields are required and must be defined before submitted the summary. 12 | * The summary can contain multiple periods (`.`) but the last character in the summary *must* be a period in order to submit the new issue. This is a precaution used to prevent premature submission of new issues. 13 | * Default values for "Assignee," "Project," and "Issue Type" are configurable in the settings pane to make issue creation even easier. 14 | * **Alfred 3** users can `cmd + enter` when submitting the issue to open the newly created issue in the browser. 15 | * Create "bookmarks" of custom JQL search queries that allow the user to quickly return a list of issues that meet the defined criteria. 16 | * By default, `alfred-jira` has two bookmarks for **issues assigned to you** and **issues that you are watching**. 17 | * Easily filter your bookmark results by *any* string in the issue (including `status`, `reporter`, etc). 18 | * `jira` followed by the search string will search all of your bookmark results 19 | * The same can be done within a bookmark menu but will limit the search to the menu you are currently on. 20 | * Wildcards and Regular Expressions are valid search strings 21 | * Search JIRA 22 | * Returns a list of JIRA issues that contain the search string in the **summary**, **description**, or **comments** 23 | * Allows for advanced searching using JQL 24 | * Ordered in descending order by priority and then by issue name. 25 | * Assign an issue 26 | * Presents a list of assignable users for a given issue 27 | * Transition the status of an issue 28 | * Presents a list of available transitions for a given issue 29 | * When a *transition* is selected, a browser window will open allowing you to set a resolution, assignee, comment, et cetera before actually submitting the change. 30 | * Quickly add a comment to an issue 31 | * Within **Alfred**, simply select "Add a comment" for a given issue (by tabbing/pressing enter) and type the comment. Pressing `enter` will POST the comment to the issue. 32 | * View an issue's priority 33 | * Watch/Unwatch an issue 34 | * Effortlessly track time-spent on an issue 35 | * When viewing an issue's details in **Alfred**, you can start/stop progress on a given issue 36 | * **Starting** progress will move the issue to the main menu for quick access and begin automatically tracking time. 37 | * **Stopping** progress will log the time spent on an issue to JIRA as well as the exact time/date you began working on the issue. 38 | * Open issue in a web browser 39 | 40 | ## Keywords 41 | * `jira` starts the workflow and allows the user to navigate through the menus/search issues 42 | * `jiraopen` – Short cut for opening an issue in the browser. The issue key must be given as a parameter: 43 | * `jiraopen ABC-123` 44 | * `jiraclear` – Clears the progress timer for an issue ***without*** logging the time to JIRA. 45 | 46 | ## Settings 47 | #### Projects & Statuses 48 | After installing the workflow and logging in, the workflow will default to include all projects and statuses available in your JIRA instance. It is recommended that you open the settings pane and configuring the workflow to show only the projects and issue statuses that you are interested in. For example: disabling `Done` and `Closed` issue statuses. 49 | 50 | This can be easily done by selecting `Edit Settings` from the `Settings` menu and clicking the buttons associated with the statuses you wish to disable/enable. 51 | #### Minimum Log Time 52 | When logging time to issues, you may wish to set a minimum amount of time to log. A minimum can be set by adding the desired amount of time to the "**minimum time to log**" field of the settings pane. The format for time is the same as in JIRA: `.5 h` and `30m` both log a minimum of `30 minutes` to issues you work on. 53 | #### Rounding Log Time 54 | It is possible to define an increment with which to round your log time by defining "**Round time to the nearest increment**" in the settings pane. Doing so will round your time spent on an issue to the nearest `increment` defined. For example: with `15 mins` increments, `1 hour 51 minutes` spent on an issue would be logged as `2 hours`. Note that the format of the value is the same as described in the *Minimum Log Time* section. 55 | #### Searching 56 | By default, the workflow is set up to perform a *basic* search. Meaning, from the search option, Alfred will return a list of issues that contain the string(s) you typed in their summary, description, or comment fields. If you would prefer more control over the search, you can enable [JQL searching](https://confluence.atlassian.com/jirasoftwarecloud/advanced-searching-764478330.html) by ticking the "**Advanced Search (JQL)**" under the **Settings** header of the settings pane. 57 | #### Customizing 58 | By default, all items associated with a specific issue will be returned when viewing an issue's details. You can specify which items are returned so that the information that is pertinent to you is easily accessible. Enable/disable menu items from the settings pane. 59 | 60 | ## Optional 61 | You can download the image resources associated with the workflow's enabled projects, users, and priority levels via the buttons at the bottom of the settings pane under the **Optional** header. 62 | 63 | ## Security 64 | In order to authenticate against the JIRA API, your username/password will be required. They will be saved in **Keychain Access** under the name `alfred-jira`. Additionally, a configuration file will be created at `~/.alfred-jira`. Both can be removed by selecting **Logout** from the settings pane. 65 | 66 | ## Performance 67 | For better performance, some information is persisted in `~/.alfred-jira`: 68 | 69 | * The list of users will persist for 7 days 70 | * The list of available transitions will persist for 45 seconds 71 | * Update status will persist for 24 hours (unless an update **is** available – in which case the workflow stops checking for updates). 72 | 73 | You also have the ability to enable *background cacheing* by selecting "**Refresh workflow cache in the background**" in the settings pane. Once you specify a time interval and save the settings, the app will create a LaunchAgent file (`$HOME/Library/LaunchAgents/com.alfred-jira.helper.plist`) that will keep your issues synced with the server so that you don't experience any lag when navigating your bookmark queries. The LaunchAgent only runs when it can connect to your Jira instance and is disabled when your credentials are invalid to prevent an accidental account lockout. 74 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ appName }} 4 | 5 | 6 | 7 | 8 |
9 | 10 | 25 | 26 | 27 |
28 | 29 | 30 | 34 |
35 | 36 | 37 |
38 | 82 |
83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 |
92 |

Settings

93 |
94 |
95 |
    96 |
  • 97 | 98 | 99 | 100 |
  • 101 |
  • 102 | 103 | 104 |
  • 105 |
  • 106 | 107 | 108 |
  • 109 |
  • 110 | 111 | 112 |
  • 113 |
  • 114 | 115 | 116 |
  • 117 |
  • 118 | 121 | 122 | 134 |
  • 135 |
  • 136 | 137 |
    138 |
    {{ daemonLog }}
    139 |
    140 |
  • 141 |
142 |
143 | 144 | 145 |
146 |
147 |

Issue Creation

148 |
149 |
150 |

By configuring the values below, users may define default values to use when creating Jira issues from Alfred.

151 |
    152 |
  • 153 | 154 | 157 |
  • 158 |
  • 159 | 160 | 164 |
  • 165 |
  • 166 | 167 | 171 |
  • 172 |
173 |
174 | 175 | 176 |
177 |
178 |

Bookmarks

179 |
180 |
181 |

182 | Bookmarks are saved JQL queries that display as soon as you type jira into Alfred. You can edit the default queries or create custom ones to better fit your needs. 183 |

184 |
185 |
    186 |
  • 187 | 188 | 189 | 193 |
    194 | 195 |
    196 |
  • 197 |
  • 198 | 202 | 203 |
  • 204 |
205 |
    206 |
    Order by
    207 |
    208 |
      209 |
    • 210 | 216 |
    • 217 |
    • 218 | 219 | 220 |
    • 221 |
    • 222 | 223 |
    • 224 |
    225 |
    226 |
    227 |  Add another order field 228 |
    229 |
    230 |
    231 |
232 |
    233 |
  • 234 | 238 | 239 |
  • 240 |
  • 241 | 242 | 243 |
  • 244 |
  • 245 | 246 | 247 |
  • 248 |
  • 249 | 256 | 266 |
  • 267 |
268 |
269 |
270 |
271 |
272 |
273 | 274 |
275 |
276 | {{ bookmark.name }} 277 |
278 |
279 | 280 |
281 |
282 | 283 |
284 |
285 | 286 |
287 |
288 |
289 |
290 | 291 | 292 | 308 | 309 | 310 |
311 |
312 |

Enabled Projects

313 | 314 |
315 |
316 |
    317 |
  • 318 |
    319 | 323 |
    324 |
  • 325 |
326 |
327 | 328 | 329 |
330 |
331 |

Enabled Issue Statuses

332 | 333 | 334 |
335 |
336 |
    337 |
  • 338 |
    339 | 343 |
    344 |
  • 345 |
346 |
347 | 348 | 349 |
350 |
351 |

Optional

352 |
353 |
354 |
    355 |
  • 356 | 361 | 366 | 371 |
  • 372 |
373 |
374 |
375 |
376 |
377 | 378 | 379 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const config = require('../lib/jira/config'); 2 | const keychain = require('../lib/jira/keychain'); 3 | const cfgFile = config.cfgPath + config.cfgFile; 4 | const fs = require('fs'); 5 | const daemon = require('../lib/daemon'); 6 | const sh = require('child_process'); 7 | const { ipcRenderer, remote } = require('electron'); 8 | 9 | 10 | Object.prototype.Get = function(key) { 11 | return key.split('.') 12 | .reduce(function (s,p) { 13 | return typeof s == 'undefined' || typeof s === null ? s : s[p]; 14 | }, this); 15 | }; 16 | 17 | require('angular'); 18 | 19 | const getData = () => { 20 | let data = {}; 21 | if (fs.existsSync(cfgFile)) { 22 | data = JSON.parse(fs.readFileSync(cfgFile, 'utf-8')); 23 | } 24 | return data; 25 | }; 26 | 27 | const ValidateOptions = (obj) => { 28 | ['available_projects', 'enabled_menu_items', 'available_issues_statuses'].forEach(key => { 29 | let options = obj[key][0]; 30 | if (options && options.name && options.enabled !== undefined) { 31 | return; 32 | } 33 | ipcRenderer.send('get-option', key); 34 | }); 35 | }; 36 | 37 | const loginOnly = remote.getGlobal('login-only'); 38 | const appName = remote.getGlobal('app-name'); 39 | const version = remote.getGlobal('version'); 40 | const icon = remote.getGlobal('icon'); 41 | 42 | let app = angular.module('alfred-jira', []); 43 | 44 | app.controller('ctrl', ['$scope', '$timeout', '$element', '$location', '$anchorScroll', ($scope, $timeout, $element, $location, $anchorScroll) => { 45 | 46 | // Cancel login when esc is pressed. 47 | $element.bind('keydown keypress', function (event) { 48 | if (event.key === 'Escape' || event.which === 27) { 49 | $timeout($scope.cancelLogin, 0); 50 | } 51 | }); 52 | 53 | let data = getData(); 54 | 55 | $scope.showLogin = loginOnly; 56 | $scope.appName = appName; 57 | $scope.version = version; 58 | $scope.icon = icon; 59 | 60 | $scope.data = data; 61 | $scope.options = data.options || { 62 | available_projects: [], 63 | enabled_menu_items: [], 64 | available_issues_statuses: [], 65 | create_issue_defaults: {}, 66 | }; 67 | ValidateOptions($scope.options); 68 | 69 | let protocol = ($scope.data.url || '').match(/http:\/\//); 70 | if ($scope.ssl === undefined) { 71 | $scope.ssl = !protocol; 72 | } 73 | 74 | const removeProtocol = url => (url || '').replace(/\s+|https?:\/\//gi, ''); 75 | $scope.loginData = { 76 | user: $scope.data.user, 77 | url: $scope.data.url, 78 | }; 79 | 80 | $scope.$watch('loginData.url', 81 | () => $scope.loginData.url = removeProtocol($scope.loginData.url)); 82 | 83 | $scope.login = () => { 84 | let user = $scope.loginData.user; 85 | let pass = $scope.loginData.password; 86 | let protocol = $scope.ssl ? 'https://' : 'http://'; 87 | $scope.data.url = protocol + $scope.loginData.url.replace(/(.)\/*$/, '$1/'); 88 | 89 | if (user && pass && $scope.data.url) { 90 | let token = new Buffer(user + ':' + pass).toString('base64'); 91 | keychain.save(token); 92 | delete $scope.loginData.password; 93 | if (loginOnly) { 94 | ipcRenderer.send('credentials-saved', { 95 | url: $scope.data.url, 96 | user: user, 97 | }); 98 | } 99 | $scope.showLogin = false; 100 | } 101 | }; 102 | 103 | $scope.cancelLogin = () => { 104 | if (loginOnly) { 105 | ipcRenderer.send('close'); 106 | } 107 | let data = getData(); 108 | $scope.loginData.user = data.user; 109 | $scope.loginData.url = data.url; 110 | delete $scope.loginData.password; 111 | $scope.showLogin = false; 112 | }; 113 | 114 | $scope.save = () => { 115 | $scope.data.url = $scope.data.url.replace(/(.)\/*$/, '$1/'); 116 | 117 | if ($scope.data.bookmarks) { 118 | $scope.data.bookmarks = $scope.data.bookmarks 119 | .map((bookmark, index) => { 120 | let dest = config.cfgPath + 'bookmark-' + index + '.png'; 121 | if (bookmark.icon && bookmark.icon != dest && !/resources/.test(bookmark.icon)) { 122 | fs.renameSync(bookmark.icon, dest); 123 | bookmark.icon = dest; 124 | } 125 | return bookmark; 126 | }); 127 | } 128 | 129 | fs.writeFile(cfgFile, JSON.stringify($scope.data, null, 2), err => { 130 | 131 | // Handle background caching. 132 | let bgCache = $scope.data.options.backgroundCache; 133 | let bgInterval = $scope.data.options.backgroundCacheInterval || null; 134 | 135 | if (bgCache !== undefined && bgInterval !== null) { 136 | let daemonStatus; 137 | if (bgCache && $scope.data.url) { 138 | daemonStatus = daemon.load($scope.data.url, (bgInterval * 60)) ? 139 | 'Background process running every ' + getTime(bgInterval * 60000) : 140 | 'Failed to load background process'; 141 | $timeout(() => $scope.daemonRunning = true, 0); 142 | } 143 | else { 144 | daemonStatus = daemon.unload() ? 145 | 'Background process stopped' : 146 | 'Failed to unload background process'; 147 | $timeout(() => $scope.daemonRunning = false, 0); 148 | } 149 | alert(daemonStatus); 150 | } 151 | 152 | let text = document.body.getElementsByClassName('save')[0].lastChild.textContent; 153 | document.body.getElementsByClassName('save')[0].lastChild.textContent = 'SAVED!'; 154 | setTimeout(() => { 155 | document.body.getElementsByClassName('save')[0].lastChild.textContent = text; 156 | }, 1000); 157 | }); 158 | }; 159 | 160 | $scope.clearCache = () => ipcRenderer.send('clearCache'); 161 | 162 | $scope.logout = function() { 163 | ipcRenderer.send('logout'); 164 | window.onbeforeunload = undefined; 165 | }; 166 | 167 | $scope.inProgress = {}; 168 | 169 | $scope.download = type => { 170 | ipcRenderer.send('download-imgs', type); 171 | $timeout(() => $scope.inProgress[type] = true, 0); 172 | }; 173 | 174 | $scope.sortFields = pos => { 175 | return [ 176 | 'Assignee', 177 | 'Created', 178 | 'DueDate', 179 | 'IssueType', 180 | 'Key', 181 | 'Priority', 182 | 'Reporter', 183 | 'Resolution', 184 | 'Status', 185 | 'Updated', 186 | ].filter(ele => { 187 | return ele == pos || !$scope.selectedBookmark.sort.map(s => s.name).includes(ele); 188 | }); 189 | }; 190 | 191 | const getTime = mil => { 192 | s = mil/1000; 193 | m = s/60; 194 | h = m/60; 195 | d = h/24; 196 | return [d,h,m,s].map((time, index) => { 197 | time = Math.floor(time); 198 | if (index) { 199 | time %= index === 1 ? 24 : 60; 200 | } 201 | return time ? time + ' ' + ['days','hours','minutes','seconds'][index] : 0; 202 | }).filter(Boolean).join(' '); 203 | }; 204 | 205 | if (!$scope.data.bookmarks) { 206 | $scope.data.bookmarks = config.bookmarks; 207 | } 208 | 209 | // Default to 15 minute cache time. 210 | class Bookmark { 211 | constructor(obj) { 212 | obj = obj || {}; 213 | this.name = obj.name || null; 214 | this.query = obj.query || null; 215 | this.cache = obj.cache || 900000; 216 | this.sort = obj.sort || [{ name: 'Updated', desc: true }]; 217 | this.limitStatuses = obj.limitStatuses !== false; 218 | this.limitProjects = obj.limitProjects !== false; 219 | this.icon = obj.icon || null; 220 | } 221 | } 222 | 223 | $scope.getBookmarkIcon = index => { 224 | if (index === undefined) { 225 | index = $scope.data.bookmarks.length; 226 | } 227 | ipcRenderer.send('get-bookmark-icon', index); 228 | }; 229 | 230 | $scope.bookmarkIcon = fileName => { 231 | if (fileName && fs.existsSync(fileName)) { 232 | return fileName; 233 | } 234 | return '../resources/icons/bookmark.png'; 235 | }; 236 | 237 | $scope.editBookmark = (bookmark, index) => { 238 | $scope.bookmarkInEdit = true; 239 | $scope.selectedBookmarkIndex = index; 240 | $scope.selectedBookmark = new Bookmark(bookmark); 241 | $scope.selectedIcon = $scope.selectedBookmark.icon; 242 | $scope.cacheConversion = getTime($scope.selectedBookmark.cache); 243 | $location.hash('bookmark-form'); 244 | $anchorScroll(); 245 | }; 246 | 247 | $scope.addBookmark = bookmark => { 248 | if ($scope.selectedIcon != bookmark.icon) { 249 | bookmark.icon = $scope.selectedIcon; 250 | } 251 | if ($scope.selectedBookmarkIndex !== undefined) { 252 | $scope.data.bookmarks[$scope.selectedBookmarkIndex] = bookmark; 253 | delete $scope.selectedBookmarkIndex; 254 | } 255 | else { 256 | $scope.data.bookmarks.push(bookmark); 257 | } 258 | delete $scope.selectedIcon; 259 | $scope.selectedBookmark = new Bookmark(); 260 | $scope.bookmarkInEdit = false; 261 | }; 262 | 263 | $scope.copyBookmark = bookmark => { 264 | let copy = new Bookmark(bookmark); 265 | copy.name = `Copy of ${copy.name}`; 266 | $scope.selectedIcon = bookmark.icon; 267 | $scope.addBookmark(copy); 268 | }; 269 | 270 | $scope.deleteBookmark = index => $scope.data.bookmarks.splice(index,1); 271 | 272 | $scope.testBookmark = bookmark => { 273 | $scope.inProgress.testConfig = true; 274 | $scope.testSuccessful = false; 275 | ipcRenderer.send('test-bookmark', bookmark); 276 | }; 277 | 278 | $scope.selectedBookmark = $scope.selectedBookmark || new Bookmark(); 279 | 280 | $scope.selectAllLabel = category => !$scope.options[category].every(opt => opt.enabled) ? 'Select All' : 'Deselect All'; 281 | 282 | $scope.selectAll = category => { 283 | let enabled = $scope.selectAllLabel(category) == 'Select All'; 284 | 285 | $scope.options[category].map(opt => { 286 | opt.enabled = enabled; 287 | return opt; 288 | }); 289 | }; 290 | 291 | $scope.checkDaemonStatus = () => { 292 | $scope.inProgress.daemon = 0; 293 | if (daemon.status() === '0') { 294 | $scope.daemonStatus = true; 295 | $timeout(() => $scope.daemonStatus = 0, 3000); 296 | } 297 | else { 298 | $scope.daemonStatus = false; 299 | $timeout(() => $scope.daemonStatus = 0, 3000); 300 | } 301 | $scope.inProgress.daemon = false; 302 | }; 303 | $scope.daemonRunning = data.Get('options.backgroundCache') === true; 304 | 305 | $scope.logFile = config.plistFileLog; 306 | if (fs.existsSync($scope.logFile)) { 307 | const updateDaemonLog = () => $timeout(() => { 308 | try { 309 | $scope.daemonLog = sh.execSync(`tail -r -n 20 ${$scope.logFile}`).toString(); 310 | } 311 | catch(e) { 312 | $scope.daemonLog = `Caught exception when trying to read from ${$scope.logFile}:\n ${e}`; 313 | } 314 | }, 0); 315 | fs.watch($scope.logFile, updateDaemonLog); 316 | updateDaemonLog(); 317 | } 318 | 319 | // Assign new issues to current user unless otherwise specified. 320 | if ($scope.Get('options.create_issue_defaults') === undefined) { 321 | $scope.options.create_issue_defaults = { 322 | assignee: $scope.data.user, 323 | }; 324 | } 325 | 326 | $scope.$watch('selectedBookmark.cache', 327 | val => $scope.cacheConversion = getTime(val)); 328 | 329 | $scope.$watch('options.backgroundCacheInterval', 330 | val => $scope.backgroundCacheIntervalConversion = getTime(val * 60000)); 331 | 332 | $scope.$watch('selectedBookmark.query', val => { 333 | $scope.testSuccessful = false; 334 | $scope.selectedBookmark.hideSort = /order.+by/i.test(val); 335 | }); 336 | 337 | // Prompt user to save before closing. 338 | let promptUser = loginOnly; // Only ask once. 339 | window.onbeforeunload = e => { 340 | if (!angular.equals($scope.data, getData()) && !promptUser++) { 341 | e.returnValue = true; 342 | ipcRenderer.send('save-changes'); 343 | } 344 | else { 345 | return undefined; 346 | } 347 | }; 348 | 349 | ipcRenderer.send('get-users'); 350 | 351 | ipcRenderer.on('set-users', (channel, users) => { 352 | $timeout(()=> $scope.users = users.map(user => { 353 | return { 354 | name: user.name, 355 | username: user.username, 356 | }; 357 | }), 0); 358 | }); 359 | 360 | ipcRenderer.send('get-issuetypes'); 361 | 362 | ipcRenderer.on('set-issuetypes', (channel, issuetypes) => { 363 | $timeout(()=> $scope.issuetypes = issuetypes.map(issuetype => issuetype.name), 0); 364 | }); 365 | 366 | ipcRenderer.on('set-option', (channel, key, data) => { 367 | data = data.map(opt => { 368 | opt.enabled = $scope.options[key].includes(opt.name); 369 | return opt; 370 | }); 371 | $timeout(() => $scope.options[key] = data, 0); 372 | }); 373 | 374 | ipcRenderer.on('close-client', (channel, res) => { 375 | // user canceled the close. 376 | if (res === 2) { 377 | promptUser = 0; 378 | return; 379 | } 380 | if (res === 0) { 381 | $scope.save(); 382 | } 383 | ipcRenderer.send('close'); 384 | }); 385 | 386 | ipcRenderer.on('download-complete', (channel, type) => { 387 | $timeout(() => $scope.inProgress[type] = false, 0); 388 | new Notification(appName, { 389 | body: `Finished downloading icons: ${type}`, 390 | icon: icon, 391 | }); 392 | }); 393 | 394 | ipcRenderer.on('set-bookmark-icon', (channel, fileName) => { 395 | $timeout(() => { 396 | $scope.selectedIcon = fileName; 397 | }, 0); 398 | }); 399 | 400 | ipcRenderer.on('bookmark-validation', (channel, result) => { 401 | $timeout(() => { 402 | $scope.inProgress.testConfig = false; 403 | $scope.testSuccessful = result === true; 404 | if (!$scope.testSuccessful) { 405 | alert(`Bookmark Config Invalid:\n\n${result}`); 406 | } 407 | }, 0); 408 | }); 409 | }]); 410 | -------------------------------------------------------------------------------- /app/main.js: -------------------------------------------------------------------------------- 1 | const {app, BrowserWindow, ipcMain, dialog} = require('electron'); 2 | const fs = require('fs'); 3 | const sh = require('child_process'); 4 | const path = require('path'); 5 | const jira = require('../lib/jira'); 6 | const config = require('../lib/jira/config'); 7 | const Extras = require('../lib/jira/grab-images'); 8 | const cwd = process.cwd(); 9 | 10 | let win = null; 11 | const icon = `${cwd}/icon.png`; 12 | const npmPackage = fs.readFileSync(`${cwd}/package.json`, 'utf-8'); 13 | const appDetails = JSON.parse(npmPackage); 14 | const appName = appDetails.name.replace(/([a-z])([a-z]+)/g, (a,b,c) => b.toUpperCase() + c); 15 | const version = appDetails.version; 16 | const loginOnly = process.argv[2] == 'login'; 17 | const update = process.argv[2] == 'update'; 18 | const tmp = { 19 | dir: process.env.TMPDIR || config.cfgPath, 20 | prefix: 'bookmark', 21 | }; 22 | 23 | global['login-only'] = loginOnly; 24 | global['app-name'] = appName; 25 | global['version'] = version; 26 | global['icon'] = icon; 27 | 28 | const shouldQuit = app.makeSingleInstance(function(commandLine, workingDirectory) { 29 | if (win) { 30 | if (win.isMinimized()) { 31 | win.restore(); 32 | } 33 | win.focus(); 34 | } 35 | }); 36 | 37 | if (shouldQuit) { 38 | app.quit(); 39 | return; 40 | } 41 | 42 | // Prevent launching the app from CLI when not logged-in. 43 | if (!jira.checkConfig() && !loginOnly) { 44 | console.log('You need to authenticate through the workflow'); 45 | return app.quit(); 46 | } 47 | 48 | app.on('ready', function(){ 49 | win = new BrowserWindow({ 50 | width: 1045, 51 | height: 680, 52 | minWidth: 820, 53 | minHeight: 440, 54 | show: false, 55 | titleBarStyle: 'hidden', 56 | }); 57 | win.loadURL(`file://${__dirname}/index.html`); 58 | // win.webContents.openDevTools(); 59 | 60 | // Open links in browser (not Electron) 61 | win.webContents.on('new-window', (event, requestedURL) => { 62 | event.preventDefault(); 63 | sh.exec('open ' + requestedURL, err => { 64 | if (err) { 65 | throw err; 66 | } 67 | }); 68 | }); 69 | 70 | win.once('ready-to-show', () => { 71 | if (update) { 72 | win.focus(); 73 | dialog.showMessageBox({ 74 | type: 'question', 75 | message: 'Your workflow has been updated!', 76 | detail: 'In order for the changes to take effect, you may need to restart Alfred.', 77 | title: app.getName(), 78 | icon: icon, 79 | buttons: ['Restart','Later'], 80 | cancelId: 1, 81 | }, res => { 82 | if (res === 0) { 83 | process.stdout.write(res.toString()); 84 | } 85 | app.quit(); 86 | }); 87 | } 88 | else { 89 | win.show(); 90 | } 91 | }); 92 | 93 | app.on('before-quit', () => { 94 | // Clean up after ourselves. 95 | sh.execSync(`find ${tmp.dir} -maxdepth 1 -type f -name '${tmp.prefix}*png' -delete`); 96 | }); 97 | }); 98 | 99 | app.setName(appName); 100 | app.dock.setIcon(icon); 101 | 102 | ipcMain.on('get-option', (event, option) => { 103 | if (!loginOnly) { 104 | if (option == 'enabled_menu_items') { 105 | event.sender.send('set-option', option, config.menuItems); 106 | } 107 | else { 108 | let method; 109 | switch(option){ 110 | case 'available_issues_statuses': 111 | method = jira.getStatuses.bind(jira); 112 | break; 113 | case 'available_projects': 114 | method = jira.getProjects.bind(jira); 115 | break; 116 | } 117 | method().then(data => { 118 | if (data) { 119 | event.sender.send('set-option', option, data); 120 | } 121 | }).catch(console.error); 122 | } 123 | } 124 | }); 125 | 126 | ipcMain.on('close', app.quit); 127 | 128 | ipcMain.on('save-changes', (event, cb) => { 129 | let window = BrowserWindow.fromWebContents(event.sender); 130 | dialog.showMessageBox(window, { 131 | type: 'question', 132 | message: 'Save changes before you quit?', 133 | title: app.getName(), 134 | icon: icon, 135 | buttons: ['Yes','No','Cancel'], 136 | cancelId: 2, 137 | }, res => { 138 | event.sender.send('close-client', res); 139 | }); 140 | }); 141 | 142 | ipcMain.on('credentials-saved', (event, response) => { 143 | process.stderr.write(JSON.stringify(response)); 144 | app.quit(); 145 | }); 146 | 147 | ipcMain.on('logout', event => { 148 | let window = BrowserWindow.fromWebContents(event.sender); 149 | dialog.showMessageBox(window, { 150 | type: 'warning', 151 | message: `This will remove all settings associated with ${app.getName()}`, 152 | detail: 'Are you sure you want to continue?', 153 | title: app.getName(), 154 | icon: icon, 155 | buttons: ['OK','Cancel'], 156 | cancelId: 1, 157 | }, res => { 158 | if (res === 0) { 159 | jira.clearSettings(); 160 | event.sender.send('close-client', 1); 161 | } 162 | }); 163 | }); 164 | 165 | ipcMain.on('clearCache', event => { 166 | jira.clearCache().then(res => { 167 | dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender), { 168 | type: 'info', 169 | message: 'Cache cleared!', 170 | title: app.getName(), 171 | icon: icon, 172 | }); 173 | }); 174 | }); 175 | 176 | ipcMain.on('download-imgs', (event, type) => { 177 | Extras(type, () => { 178 | event.sender.send('download-complete', type); 179 | }); 180 | }); 181 | 182 | ipcMain.on('get-bookmark-icon', (event, index) => { 183 | let window = BrowserWindow.fromWebContents(event.sender); 184 | dialog.showOpenDialog(window, { 185 | title: app.getName(), 186 | properties: ['openFile'], 187 | filters: [{ name: 'Images', extensions: ['jpg', 'png', 'gif'] }], 188 | }, res => { 189 | if (res) { 190 | res = res[0]; 191 | let parsedPath = path.parse(res); 192 | let dest = path.normalize(path.join(tmp.dir, parsedPath.base)); 193 | let tmpFile = path.normalize(path.join(tmp.dir + tmp.prefix)) + new Date().getTime() + '.png'; 194 | sh.exec(`qlmanage -t -s 48 -o "${tmp.dir}" "${res}" 1>&2 195 | test -f "${dest}.png" && mv "${dest}.png" "${tmpFile}"`, 196 | err => { 197 | if (err) { 198 | throw err; 199 | } 200 | event.sender.send('set-bookmark-icon', tmpFile); 201 | }); 202 | } 203 | }); 204 | }); 205 | 206 | ipcMain.on('test-bookmark', (event, bookmark) => { 207 | jira.testBookmark(bookmark) 208 | .then(() => event.sender.send('bookmark-validation', true)) 209 | .catch(err => event.sender.send('bookmark-validation', err)); 210 | }); 211 | 212 | ipcMain.on('get-users', event => { 213 | if (!loginOnly) { 214 | jira.getUsers().then(users => { 215 | event.sender.send('set-users', users); 216 | }); 217 | } 218 | }); 219 | 220 | ipcMain.on('get-issuetypes', event => { 221 | if (!loginOnly) { 222 | jira.getIssueTypes().then(issuetypes => { 223 | event.sender.send('set-issuetypes', issuetypes); 224 | }); 225 | } 226 | }); 227 | -------------------------------------------------------------------------------- /app/public/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:400,700'); 2 | 3 | html, body { 4 | height: 100%; 5 | margin: 0; 6 | font-family: 'Open Sans', sans-serif; 7 | } 8 | 9 | body { 10 | -webkit-user-select: none; 11 | -webkit-app-region: drag; 12 | background: #efefef; 13 | animation: fadein 0.5s; 14 | color: #6A6A6A; 15 | } 16 | 17 | @keyframes fadein { 18 | from { 19 | opacity: 0; 20 | } 21 | to { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | ul { 27 | padding: 0; 28 | } 29 | 30 | li { 31 | list-style-type: none; 32 | } 33 | button { 34 | outline: none; 35 | } 36 | 37 | code { 38 | background: #e4e4e4; 39 | padding: 4px; 40 | color: #1d89ff; 41 | } 42 | 43 | a { 44 | text-decoration: none; 45 | font-weight: 700; 46 | color: #889aa7; 47 | } 48 | a:hover { 49 | color: #4ba8d8; 50 | } 51 | 52 | li > input, select, textarea, .bookmark-icon-button { 53 | background-color: #E8E8E8; 54 | border: #CDCDCD solid 1px; 55 | border-radius: 4px; 56 | } 57 | 58 | li > input, select, textarea { 59 | outline: none; 60 | padding: 10px; 61 | font-size: 15px; 62 | } 63 | 64 | li > input:hover:enabled, 65 | li > input:focus, 66 | textarea:hover, 67 | textarea:focus, 68 | select:hover, 69 | select:focus, 70 | .bookmark-icon-button:hover { 71 | border: #4ba8d8 1px solid; 72 | } 73 | 74 | hr { 75 | margin: 17px 0 18px; 76 | height: 0; 77 | clear: both; 78 | border: solid #E8E8E8; 79 | border-width: 1px 0 0; 80 | border-bottom: 1px solid #F1F1F1; 81 | border-top: 1px solid #D7D7D7; 82 | } 83 | 84 | h3 { 85 | padding: 0; 86 | margin: 0; 87 | color: #4ba8d8; 88 | } 89 | 90 | .section-title { 91 | display: flex; 92 | justify-content: space-between; 93 | } 94 | 95 | .section-title > input { 96 | flex: 1; 97 | text-align: right; 98 | margin-left: 20px; 99 | background-color: #efefef; 100 | border: none; 101 | border-radius: 0; 102 | font-family: 'FontAwesome', 'Open Sans', sans-serif; 103 | } 104 | 105 | .section-title > input:focus { 106 | outline: none; 107 | border-bottom: #4ba8d8 solid 1px; 108 | } 109 | 110 | .logo { 111 | margin: 20px 0; 112 | background-size: contain; 113 | background-repeat: no-repeat; 114 | background-position: right; 115 | height: 100px; 116 | width: 260px; 117 | } 118 | 119 | .logo > h2 { 120 | padding: 20px 100px 0px 20px; 121 | color: #4ba8d8; 122 | margin: 0; 123 | } 124 | 125 | .logo > p { 126 | padding:0 100px 10px 20px; 127 | color: #fff; 128 | margin: 0; 129 | font-size: small; 130 | } 131 | 132 | .disabled { 133 | color: #ccc; 134 | } 135 | 136 | section { 137 | padding-top:30px; 138 | margin-bottom: 85px; 139 | } 140 | 141 | .container { 142 | margin: 0; 143 | padding: 0; 144 | display: flex; 145 | min-height: 100%; 146 | } 147 | 148 | nav { 149 | min-width: 210px; 150 | background: #222222; 151 | margin-right: 40px; 152 | } 153 | 154 | .menu a { 155 | cursor: default; 156 | text-decoration: none; 157 | padding-right: 20px; 158 | } 159 | .nav-container { 160 | position: fixed; 161 | } 162 | .menu { 163 | width: inherit; 164 | cursor: default; 165 | padding-right: 50px; 166 | } 167 | .menu ul { 168 | display: flex; 169 | flex-direction: column; 170 | justify-content: center; 171 | margin: auto 0; 172 | padding: 0; 173 | } 174 | 175 | .menu li { 176 | flex: 1; 177 | display: flex; 178 | flex-direction: row; 179 | align-items: center; 180 | color:#555e63; 181 | text-transform: uppercase; 182 | letter-spacing: .09em; 183 | padding: 10px 0 10px 10px; 184 | } 185 | 186 | .menu li:hover { 187 | color: #79a7bf; 188 | background-color: #2E2E2E; 189 | } 190 | 191 | .menu i { 192 | padding-right: 10px; 193 | } 194 | 195 | .flex-column { 196 | max-width: 900px; 197 | padding: 20px; 198 | margin: 0 auto; 199 | box-sizing: border-box; 200 | flex: 1; 201 | flex-direction: column; 202 | } 203 | 204 | .login { 205 | margin: auto; 206 | } 207 | 208 | /* General Forms */ 209 | .flex-outer li, 210 | .flex-inner { 211 | display: flex; 212 | flex-wrap: wrap; 213 | align-items: center; 214 | } 215 | 216 | .sort-label, 217 | .flex-outer > li > label { 218 | margin-right: 20px; 219 | } 220 | 221 | .flex-outer > li > label + *, 222 | .flex-inner { 223 | flex: 1 0 220px; 224 | } 225 | 226 | .login > li > label { 227 | min-width: 120px; 228 | } 229 | 230 | .sort-wrapper > li:not(:last-child), 231 | .flex-outer > li:not(:last-child) { 232 | margin-bottom: 20px; 233 | } 234 | 235 | .flex-outer li button { 236 | margin-right: 20px; 237 | padding: 8px 16px; 238 | border: none; 239 | background: #333; 240 | color: #f2f2f2; 241 | text-transform: uppercase; 242 | letter-spacing: .09em; 243 | border-radius: 2px; 244 | flex: 1 0 150px; 245 | } 246 | 247 | .flex-outer li button:last-of-type { 248 | margin-right: 0; 249 | } 250 | 251 | .flex-outer li button.inline-button { 252 | border-radius: 4px; 253 | margin-left: 20px; 254 | background: #383838; 255 | padding: 10px; 256 | max-width: 92px; 257 | } 258 | 259 | .flex-outer li button.disabled { 260 | background: #ccc; 261 | cursor: not-allowed; 262 | } 263 | 264 | .flex-outer li.optional { 265 | align-items: stretch; 266 | } 267 | 268 | .flex-inner { 269 | flex: 1; 270 | justify-content: space-between; 271 | } 272 | 273 | .InputAddOn { 274 | display: flex; 275 | border: #CDCDCD solid 1px; 276 | border-radius: 4px; 277 | } 278 | 279 | .InputAddOn:hover { 280 | border: #4ba8d8 1px solid; 281 | } 282 | 283 | .addon-selected { 284 | border: #4ba8d8 1px solid; 285 | } 286 | 287 | /* Login styles */ 288 | .login-header { 289 | display: flex; 290 | } 291 | .login-header .logo { 292 | margin: 0 10px 0 0; 293 | height: inherit; 294 | background-position: inherit; 295 | width: 65px; 296 | } 297 | .login-header > h1 { 298 | margin: 0; 299 | font-size: 3em; 300 | } 301 | 302 | input.InputAddOn-field { 303 | flex: 1; 304 | outline: none; 305 | padding: 10px; 306 | background-color: #E8E8E8; 307 | border: 0px; 308 | border-top-right-radius: 4px; 309 | border-bottom-right-radius: 4px; 310 | font-size: 15px; 311 | } 312 | 313 | .InputAddOn-item { 314 | outline: none; 315 | padding: 10px; 316 | background-color: #CDCDCD; 317 | border-bottom-left-radius: 4px; 318 | border-top-left-radius: 4px; 319 | font-size: 15px; 320 | cursor: default; 321 | } 322 | 323 | .InputAddOn-item:hover { 324 | background-color: #889aa7; 325 | color: #fff; 326 | } 327 | 328 | .has-error { 329 | border-color: red !important; 330 | background-color: #f9d7d7 !important; 331 | } 332 | 333 | /* checkbox to button */ 334 | #ck-button { 335 | margin:4px; 336 | background-color:#EFEFEF; 337 | border-radius:3px; 338 | border:1px solid #D0D0D0; 339 | overflow:auto; 340 | float:left; 341 | } 342 | 343 | #ck-button label span { 344 | text-align:center; 345 | padding:8px; 346 | display:block; 347 | } 348 | 349 | #ck-button input:checked + span { 350 | background-color: #889aa7; 351 | color:#fff; 352 | } 353 | 354 | /* Sorting Issues */ 355 | select[id^="sort-"] { 356 | flex: 1; 357 | align-self: stretch; 358 | } 359 | 360 | .sort-function { 361 | flex: 1; 362 | max-width: 40px; 363 | justify-content: center; 364 | } 365 | 366 | li.sort-selection { 367 | flex: 1; 368 | display: flex; 369 | height: 40px; 370 | } 371 | 372 | .sort-list:not(:last-of-type) { 373 | margin-bottom: 20px; 374 | } 375 | 376 | .glow:hover { 377 | color: #4ba8d8; 378 | cursor: default; 379 | } 380 | 381 | .remove:hover { 382 | color: red; 383 | } 384 | 385 | /* Bookmarks */ 386 | .table-container { 387 | display: flex; 388 | flex-direction: column; 389 | justify-content: space-between; 390 | align-content: flex-end; 391 | } 392 | 393 | .table-row { 394 | display: flex; 395 | align-items: center; 396 | } 397 | .table-row:nth-child(odd) { 398 | background-color: #e8e8e8; 399 | } 400 | 401 | .table-column { 402 | flex: 1 0 40px; 403 | max-width: 20px; 404 | margin: 10px; 405 | } 406 | 407 | .bookmark-title { 408 | flex: 1; 409 | margin: 10px; 410 | } 411 | 412 | .help { 413 | font-size: small; 414 | color: #ccc; 415 | margin: 0; 416 | } 417 | 418 | .sort-label { 419 | align-self: flex-start; 420 | } 421 | 422 | .flex-outer.sort-wrapper { 423 | flex: 1; 424 | } 425 | .sort-wrapper ul.flex-outer button { 426 | background: #889aa7; 427 | border-radius: 5px; 428 | max-width: 300px; 429 | } 430 | .sort-wrapper ul.flex-outer button:hover { 431 | background: #4ba8d8; 432 | } 433 | .bookmark-form { 434 | padding: 10px; 435 | margin-bottom: 20px; 436 | } 437 | 438 | .center-separator { 439 | display: flex; 440 | } 441 | .center-separator, 442 | .center-separator > i { 443 | line-height: 2.8rem; 444 | } 445 | .center-separator:after, 446 | .center-separator:before { 447 | content: ''; 448 | flex: 1; 449 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 48.5%, #d6d6d6 48.5%, #ffffff 51.5%, rgba(255, 255, 255, 0) 51.5%) 450 | } 451 | .center-separator:after { 452 | margin-left: 10px; 453 | } 454 | .center-separator:before { 455 | margin-right: 10px; 456 | } 457 | 458 | .bookmark-icon { 459 | max-width: 38px; 460 | } 461 | .bookmark-icon-button { 462 | margin-left: 10px; 463 | } 464 | .success { 465 | background: #65bb65 !important; 466 | } 467 | .failed { 468 | background: #BB4D4B !important; 469 | } 470 | 471 | 472 | #select-all { 473 | align-self: flex-end; 474 | font-size: smaller; 475 | margin-left: 10px; 476 | cursor: pointer; 477 | color: #ccc; 478 | } 479 | #select-all:hover { 480 | color: #383838; 481 | } 482 | 483 | .embedded { 484 | background: #fff; 485 | overflow: scroll; 486 | height: 150px; 487 | } 488 | .embedded > * { 489 | padding: 0 1em; 490 | } 491 | .report { 492 | -webkit-app-region: no-drag; 493 | -webkit-user-select: initial; 494 | } 495 | 496 | -------------------------------------------------------------------------------- /bin/download_img.sh: -------------------------------------------------------------------------------- 1 | auth=$(security 2>&1 >/dev/null find-generic-password -s alfred-jira -g | sed -E 's/^password: "(.+)"$/\1/') 2 | [[ "$auth" == "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." ]] && { echo "Could not find auth-token"; exit 1; } 3 | 4 | curl -H "Authorization: Basic $auth" --create-dirs $@ 5 | 6 | for param in $@; do 7 | test ! -f $param && continue 8 | 9 | icon="$param" 10 | extension=$(file -b --mime-type $icon | sed -E 's_.*/([^\+]*).*_\1_') 11 | file_name="$icon.$extension" 12 | mv $icon $file_name 13 | 14 | if [[ "$extension" != "png" ]]; then 15 | qlmanage -t -s 48 -o $(dirname $icon) $icon.$extension 1>&2 16 | file_name="$icon.png" 17 | rm -f $icon.$extension 18 | mv $icon.$extension.png $file_name 19 | fi 20 | done 21 | 22 | exit 0 23 | -------------------------------------------------------------------------------- /bin/install.sh: -------------------------------------------------------------------------------- 1 | pushd $(dirname "$0") > /dev/null 2 | repo="$(dirname "$PWD")" 3 | popd > /dev/null 4 | 5 | hash npm &> /dev/null && npm install || { 6 | echo 7 | echo "Error: npm is required to install this workflow." 8 | echo "https://docs.npmjs.com/getting-started/installing-node" 9 | echo 10 | exit 1 11 | } 12 | 13 | echo "Looking for Alfred Preferences..." 14 | paths=("$1" "$HOME/Dropbox" "$HOME/Library/Mobile Documents/com~apple~CloudDocs" "$HOME/Google Drive" "$HOME/Library/Application Support/Alfred" "$HOME/Library/Application Support/Alfred 3" "$HOME/Library/Application Support/Alfred 2") 15 | 16 | for i in "${paths[@]}"; do 17 | d=${i// /\ } 18 | [[ ! -d $d ]] && continue 19 | path="$(find "$d" -path "*.alfredpreferences/workflows" | head -n 1)" 20 | if [[ -n "$path" ]]; then 21 | echo "Found preferences at: $(dirname "$path")" 22 | break 23 | fi 24 | done 25 | 26 | while true; do 27 | [[ -n "$path" ]] && break 28 | 29 | if [[ ! "$path" ]]; then 30 | echo "Enter the path to your \"Alfred.alfredpreferences\" file:" 31 | read directory 32 | eval directory=$directory 33 | fi 34 | 35 | if [[ ! -d "$directory" ]]; then 36 | echo "Unable to read \"$directory\"" 37 | directory= 38 | fi 39 | 40 | if [[ "$directory" ]]; then 41 | path="$(find $directory -path "*.alfredpreferences/workflows" 2>/dev/null)" 42 | if [[ -n "$path" ]]; then 43 | echo "Found preferences at: $(dirname "$path")" 44 | break 45 | else 46 | echo "Could not locate \"Alfred.alfredpreferences\" at \"$directory\"" 47 | path= 48 | fi 49 | fi 50 | done 51 | 52 | link="$path/_jira" 53 | if [[ -L "$link" ]]; then 54 | echo "Workflow already installed at \"$link\"." 55 | echo "Re-install? Y/n" 56 | read res 57 | [[ "$res" != "Y" ]] && exit 0 58 | rm "$link" 59 | fi 60 | 61 | if [[ -d "$link" ]]; then 62 | echo 63 | echo "ERROR: Non-symlinked directory found at:" 64 | echo "\"$link\"" 65 | echo "Remove previously installed Alfred-Jira workflows before installing" 66 | exit 1 67 | fi 68 | 69 | ln -s "$repo" "$link" 70 | echo "Installation complete!" 71 | exit 0 72 | -------------------------------------------------------------------------------- /bin/update.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | upstream="$(git rev-parse --abbrev-ref --symbolic-full-name @{u})" 3 | [[ -z "$upstream" ]] && upstream="origin/master" 4 | 5 | git reset --hard $upstream && npm install 6 | echo "Workflow updated" 7 | IFS=$'\n' 8 | restart="$(npm run -s electron update)" 9 | [[ ! "$restart" ]] && exit 0; 10 | 11 | alfred=() 12 | pids=() 13 | for process in $(ps ax -o pid,comm | grep Alfred | grep -v grep); do 14 | app=${process#* } 15 | pid=${process%% *} 16 | pids+=($pid) 17 | alfred+=(${app/.app*/.app}) 18 | done 19 | ( kill -9 ${pids[@]} && open $alfred ) & 20 | exit 0 21 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/icon.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | jira.steyep 7 | category 8 | Tools 9 | connections 10 | 11 | 7D4D4235-7FE2-4F9B-939A-DF9CC1AD2F56 12 | 13 | 14 | destinationuid 15 | 94C00EEF-2B8E-45D8-97AE-E8C175634DB1 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | 21 | 22 | 8EB4D1DF-1C75-4EBD-A15C-F355B43B0B57 23 | 24 | 25 | destinationuid 26 | 7D4D4235-7FE2-4F9B-939A-DF9CC1AD2F56 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | 32 | 33 | 34 | createdby 35 | Stephen Pennell 36 | description 37 | Interact with Jira 38 | disabled 39 | 40 | name 41 | jira 42 | objects 43 | 44 | 45 | config 46 | 47 | argumenttype 48 | 1 49 | escaping 50 | 64 51 | keyword 52 | jira 53 | queuedelaycustom 54 | 3 55 | queuedelayimmediatelyinitially 56 | 57 | queuedelaymode 58 | 0 59 | queuemode 60 | 1 61 | script 62 | /usr/local/bin/node ./lib/scriptfilter.js "{query}" 63 | title 64 | Alfred Jira Workflow 65 | type 66 | 0 67 | withspace 68 | 69 | 70 | type 71 | alfred.workflow.input.scriptfilter 72 | uid 73 | 8EB4D1DF-1C75-4EBD-A15C-F355B43B0B57 74 | version 75 | 0 76 | 77 | 78 | config 79 | 80 | lastpathcomponent 81 | 82 | onlyshowifquerypopulated 83 | 84 | output 85 | 0 86 | removeextension 87 | 88 | sticky 89 | 90 | text 91 | {query} 92 | title 93 | Alfred-Jira 94 | 95 | type 96 | alfred.workflow.output.notification 97 | uid 98 | 94C00EEF-2B8E-45D8-97AE-E8C175634DB1 99 | version 100 | 0 101 | 102 | 103 | config 104 | 105 | concurrently 106 | 107 | escaping 108 | 39 109 | script 110 | /usr/local/bin/node ./lib/action.js {query} 111 | type 112 | 0 113 | 114 | type 115 | alfred.workflow.action.script 116 | uid 117 | 7D4D4235-7FE2-4F9B-939A-DF9CC1AD2F56 118 | version 119 | 0 120 | 121 | 122 | readme 123 | 124 | uidata 125 | 126 | 7D4D4235-7FE2-4F9B-939A-DF9CC1AD2F56 127 | 128 | ypos 129 | 10 130 | 131 | 8EB4D1DF-1C75-4EBD-A15C-F355B43B0B57 132 | 133 | ypos 134 | 10 135 | 136 | 94C00EEF-2B8E-45D8-97AE-E8C175634DB1 137 | 138 | ypos 139 | 10 140 | 141 | 142 | webaddress 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /lib/action.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const sh = require('./alfred-exec'); 3 | const config = require('./jira/config'); 4 | const log = require('./alfred-log'); 5 | const fs = require('fs'); 6 | const cfgFile = config.cfgPath + config.cfgFile; 7 | 8 | let args = process.argv.slice(2)[0].split(' '); 9 | let query = args.shift(); 10 | const bookmarks = () => Jira.getBookmarks().map((s,p) => `bookmark-${p}`); 11 | const openURL = url => url ? sh.exec(`open ${url}`) : false; 12 | const openIssue = key => { 13 | if (key && Jira.checkConfig()) { 14 | let data = JSON.parse(fs.readFileSync(cfgFile, 'utf-8')); 15 | openURL(`${data.url}browse/${key}`); 16 | } 17 | }; 18 | 19 | switch(query) { 20 | case 'update': 21 | Jira.refreshCache(); 22 | sh.exec('sh ./bin/update.sh'); 23 | break; 24 | case 'editSettings': 25 | Jira.editSettings(); 26 | break; 27 | case 'clearCache': 28 | Jira.clearCache(); 29 | break; 30 | case 'clearProgress': 31 | Jira.clearProgress(args[0]); 32 | break; 33 | case 'login': 34 | Jira.login(); 35 | break; 36 | case 'openURL': 37 | openURL(( args[0] || '')); 38 | break; 39 | case 'openIssue': 40 | openIssue(args[0] || ''); 41 | break; 42 | case 'assign': 43 | let [ticket, user] = args; 44 | Jira.assign(ticket, user) 45 | .then(res => { 46 | if (res) { 47 | Jira.refreshCache(...bookmarks(), 'in-progress'); 48 | console.log(res); 49 | } 50 | }) 51 | .catch(console.log); 52 | break; 53 | case 'toggleWatch': 54 | let [issueId, currentState] = args; 55 | Jira.toggleWatch(issueId, currentState === 'true') 56 | .then(res => { 57 | console.log(res); 58 | Jira.refreshCache(...bookmarks(), 'in-progress'); 59 | }) 60 | .catch(console.log); 61 | break; 62 | case 'startProgress': 63 | Jira.startProgress(args[0]); 64 | break; 65 | case 'stopProgress': 66 | Jira.stopProgress(args[0]) 67 | .then(console.log) 68 | .catch(console.log); 69 | break; 70 | case 'comment': 71 | let issue = args.shift(); 72 | let comment = args.join(' '); 73 | Jira.comment(issue, comment) 74 | .then(console.log) 75 | .catch(console.log); 76 | break; 77 | case 'transition': 78 | let [ticketId, action] = args; 79 | Jira.transition(ticketId, action); 80 | Jira.clearCache(...bookmarks(), 'in-progress'); 81 | break; 82 | case 'create-issue': 83 | case 'create-issue-open': 84 | Jira.createIssue().then(res => { 85 | console.log(`Created issue: ${res}`); 86 | if (query == 'create-issue-open') { 87 | openIssue(res); 88 | } 89 | Jira.clearCache(...bookmarks(), 'in-progress'); 90 | }).catch(err => { 91 | log(err); 92 | console.log(err); 93 | }); 94 | break; 95 | } -------------------------------------------------------------------------------- /lib/alfred-exec.js: -------------------------------------------------------------------------------- 1 | const child = require('child_process'); 2 | let shell = process.env.SHELL; 3 | let escChr = str => str.replace(/(["\$])/g, '\\$1'); 4 | let escSpace = str => str.replace(/ /g, '\\ '); 5 | const PATH = 'eval $(echo "$(/usr/libexec/path_helper -s)" | awk -F\';\' \'{ print $1 }\');'; 6 | 7 | module.exports = { 8 | 'exec': (cmd, options, callback) => { 9 | cmd = escChr(`${PATH} ${cmd}`); 10 | return child.exec(`${shell} -c "${cmd}"`, options, callback); 11 | }, 12 | 'execSync': (cmd, options) => { 13 | cmd = escChr(`${PATH} ${cmd}`); 14 | return child.execSync(`${shell} -c "${cmd}"`, options); 15 | }, 16 | 'spawn': (cmd, args, options) => { 17 | args = args.map(escSpace); 18 | return child.spawn(shell, ['-c', `${PATH} ${cmd} ${args.join(' ')}`], options); 19 | }, 20 | }; -------------------------------------------------------------------------------- /lib/alfred-log.js: -------------------------------------------------------------------------------- 1 | const isDebug = () => process.env.alfred_debug || /^2/.test(process.env.alfred_version); 2 | 3 | module.exports = (...args) => process.env.alfred_workflow_name ? 4 | (isDebug() ? console.error.apply(this, args) : null) : 5 | console.log.apply(this, args); 6 | -------------------------------------------------------------------------------- /lib/assign.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const Workflow = require('./workflow'); 3 | const fs = require('fs'); 4 | 5 | let wf = new Workflow(); 6 | 7 | module.exports = (query) => { 8 | query = query.split(wf._sep).map(s => s.trim()); 9 | let search = query.pop() || ''; 10 | let context = query.pop() || ''; 11 | let data = wf.storage.get(context + '-assign'); 12 | 13 | if (!data) { 14 | return wf.actionHandler.handle('mainMenu', query); 15 | } 16 | 17 | let ticket = data._key.replace('-assign', ''); 18 | let currentUser = data.currentAssignee; 19 | wf.default({ 20 | title: 'No user found matching: "' + search + '"', 21 | valid: false, 22 | autocomplete: wf.path(...query, context), 23 | }); 24 | 25 | Jira.getUsers().then( users => { 26 | wf.addItems( 27 | users 28 | .filter(s => s.name != currentUser && new RegExp(search, 'i').test(s.name.trim())) 29 | .map( user => { 30 | return { 31 | title: user.name, 32 | valid: true, 33 | userIcon: user.name.replace(/[^a-z0-9]/gi,'_') + '.png', 34 | autocomplete: wf.path(...query, context) + user.name, 35 | arg: ['assign', ticket, user.username].join(' '), 36 | }; 37 | })); 38 | wf.feedback(); 39 | }).catch(wf.error.bind(wf)); 40 | }; -------------------------------------------------------------------------------- /lib/background.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const settings = require('./settings'); 3 | const sh = require('child_process'); 4 | const fs = require('fs'); 5 | const config = require('./jira/config'); 6 | const log = require('./alfred-log'); 7 | const moment = require('moment'); 8 | 9 | const pid = config.cfgPath + 'bg.pid'; 10 | 11 | if (!fs.existsSync(pid)) { 12 | fs.writeFileSync(pid, process.pid); 13 | 14 | Jira.getAllBookmarks().catch(console.error); 15 | Jira.getUsers().catch(console.error); 16 | settings.checkUpdates().catch(console.error); 17 | 18 | process.on('exit', code => { 19 | fs.unlinkSync(pid); 20 | if (code === 0) { 21 | let cacheFile = config.cfgPath + config.cacheFile; 22 | if (fs.existsSync(cacheFile)) { 23 | cacheFile = require(cacheFile); 24 | let refreshed = Object.keys(cacheFile); 25 | log(`Refreshed ${refreshed.length} caches`); 26 | } 27 | else { 28 | console.error('Unabled to refresh cache.'); 29 | } 30 | } 31 | process.exit(code); 32 | }); 33 | } 34 | else { 35 | // Delete the file if it hasn't been modified in 60 seconds 36 | fs.stat(pid, (err, stat) => { 37 | if (err) { 38 | return log(err); 39 | } 40 | let lastUpdated = new Date(stat.ctime).getTime(); 41 | let now = new Date().getTime(); 42 | if (now - lastUpdated >= 60*1000) { 43 | // If the process is still running, kill it. 44 | sh.exec('ps -p $(cat ' + pid + ') && kill -9 $(cat ' + pid + ')', () => { 45 | fs.unlinkSync(pid); 46 | }); 47 | } 48 | }); 49 | } -------------------------------------------------------------------------------- /lib/comment.js: -------------------------------------------------------------------------------- 1 | var Jira = require('./jira'); 2 | var Workflow = require('./workflow'); 3 | var wf = new Workflow(); 4 | 5 | module.exports = query => { 6 | 7 | // Query assignable users 8 | Jira.getUsers() 9 | .then(users => { 10 | let alfredContent = query; 11 | query = query.split(wf._sep).map(s => s.trim()); 12 | let comment = query.pop() || ''; 13 | let context = query.pop() || ''; 14 | let data = wf.storage.get(context + '-comment'); 15 | let key = data._key.replace('-comment', ''); 16 | 17 | // Default script filter result 18 | wf.default({ 19 | title: 'Begin typing a comment for: ' + key, 20 | icon: 'comment.png', 21 | valid: false, 22 | }); 23 | 24 | // Return @mention suggestions 25 | if (/@/.test(comment) && users.length) { 26 | let name = comment.split('@').pop(); 27 | wf.addItems(users 28 | .filter(user => new RegExp('^' + name, 'i').test(user.name)) 29 | .map(user => { 30 | return { 31 | title: user.name, 32 | valid: false, 33 | userIcon: user.name.replace(/[^a-z0-9]/gi,'_') + '.png', 34 | autocomplete: alfredContent.replace(/@\w*$/, `[~${user.username}]`), 35 | }; 36 | })); 37 | } 38 | 39 | // Return a preview of the comment string that will be sent to JIRA 40 | // Pressing enter will POST comment to JIRA 41 | if (comment.trim()) { 42 | wf.addItem({ 43 | title: comment.replace(/'/g, '\\\''), 44 | subtitle: key, 45 | valid: true, 46 | icon: 'comment.png', 47 | arg: ['comment', key, comment.replace(/'/g, '\\\'')].join(' '), 48 | }); 49 | } 50 | 51 | // Return the workflow feedback 52 | wf.feedback(); 53 | 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /lib/create.js: -------------------------------------------------------------------------------- 1 | var Jira = require('./jira'); 2 | var Workflow = require('./workflow'); 3 | var log = require('./alfred-log'); 4 | var config = require('./jira/config'); 5 | var wf = new Workflow(); 6 | let storage = wf.storage; 7 | 8 | let options = Jira.getOptions(); 9 | 10 | let projects = options.available_projects || []; 11 | 12 | let defaults = options.create_issue_defaults || { assignee: config.user }; 13 | 14 | const feedback = (items, data) => { 15 | wf.addItems(items); 16 | wf.feedback(); 17 | storage.set('create-config', data); 18 | }; 19 | 20 | const Trim = str => str.trim(); 21 | 22 | module.exports = query => { 23 | let context = query.split(wf._sep); 24 | let search = context.pop() || ''; 25 | 26 | context = context.map(Trim).filter(String); 27 | 28 | let issueConfig = storage.get('create-config') || { 29 | summary: '', 30 | assignee: defaults.assignee || '', 31 | project: defaults.project || '', 32 | issuetype: defaults.issuetype || '', 33 | }; 34 | 35 | for (var key of ['project', 'assignee', 'issuetype']) { 36 | let projectIndex = context.findIndex(s => s.trim() == key); 37 | if (projectIndex > -1 && projectIndex < context.length - 2) { 38 | // Set Project 39 | issueConfig[key] = context[projectIndex + 1]; 40 | // Remove it from the path. 41 | context.splice(projectIndex, 2); 42 | } 43 | } 44 | 45 | switch((context[context.length-1] || '').trim()) { 46 | case 'project': 47 | projects = projects 48 | .filter(s => new RegExp(search, 'i').test(s)) 49 | .map(project => ({ 50 | title: project, 51 | valid: false, 52 | autocomplete: wf.path(...context, project, 'create') + issueConfig.summary, 53 | projectIcon: project + '.png', 54 | })); 55 | 56 | feedback(projects, issueConfig); 57 | break; 58 | 59 | case 'assignee': 60 | Jira.getUsers().then(users => { 61 | users = users 62 | .filter(s => new RegExp(search, 'i').test(s.name)) 63 | .sort((a,b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) 64 | .map(user => ({ 65 | title: user.name, 66 | valid: false, 67 | userIcon: user.name.replace(/[^a-z0-9]/gi,'_') + '.png', 68 | autocomplete: wf.path(...context, user.username, 'create') + issueConfig.summary, 69 | })); 70 | 71 | feedback(users, issueConfig); 72 | }); 73 | break; 74 | 75 | case 'issuetype': 76 | Jira.getIssueTypes(issueConfig.project).then(types => { 77 | types = types 78 | .filter(s => new RegExp(search, 'i').test(s.name)) 79 | .map(type => ({ 80 | title: type.name, 81 | valid: false, 82 | autocomplete: wf.path(...context, type.name, 'create') + issueConfig.summary, 83 | icon: type.name == issueConfig.issuetype ? 'label.png' : 'labeloutline.png', 84 | })); 85 | 86 | feedback(types, issueConfig); 87 | }); 88 | break; 89 | 90 | default: 91 | Jira.getUsers().then(users => { 92 | issueConfig.summary = search.trim(); 93 | 94 | if (!issueConfig.assignee) { 95 | issueConfig.assignee = config.user; 96 | } 97 | 98 | let assignee = (users.find(user => user.username == issueConfig.assignee) || { name: '' }).name; 99 | 100 | let menu = [{ 101 | title: `Summary: ${issueConfig.summary}`, 102 | valid: false, 103 | autocomplete: wf.path('create') + issueConfig.summary, 104 | icon: 'title.png', 105 | },{ 106 | title: `Assignee: ${assignee}`, 107 | valid: false, 108 | autocomplete: wf.path('create', 'assignee'), 109 | userIcon: assignee.replace(/[^a-z0-9]/gi,'_') + '.png', 110 | },{ 111 | title: `Project: ${issueConfig.project}`, 112 | valid: false, 113 | projectIcon: `${issueConfig.project}.png`, 114 | autocomplete: wf.path('create', 'project'), 115 | },{ 116 | title: `Issue Type: ${issueConfig.issuetype}`, 117 | valid: false, 118 | autocomplete: wf.path('create', 'issuetype'), 119 | icon: issueConfig.issuetype ? 'label.png' : 'labeloutline.png', 120 | }]; 121 | 122 | // Show submit if all requirements have been met. 123 | if (issueConfig.summary && issueConfig.project && issueConfig.issuetype) { 124 | menu.unshift({ 125 | title: 'Submit', 126 | valid: true, 127 | arg: 'create-issue', 128 | icon: 'good.png', 129 | subtitle: 'Press enter to create issue.', 130 | cmdMod: { 131 | subtitle: 'Press enter to create & open issue.', 132 | arg: 'create-issue-open', 133 | }, 134 | }); 135 | } 136 | 137 | feedback(menu, issueConfig); 138 | }); 139 | 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /lib/daemon.js: -------------------------------------------------------------------------------- 1 | const config = require('./jira/config'); 2 | const sh = require('./alfred-exec'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const plistFile = config.plistFile; 6 | const plistFileLog = config.plistFileLog; 7 | const plistFileErr = config.plistFileErr; 8 | const plist = path.parse(plistFile); 9 | 10 | 11 | const DaemonDefinition = (uri, interval) => ( 12 | ` 13 | 15 | 16 | 17 | Label 18 | ${plist.name} 19 | ProgramArguments 20 | 21 | /bin/sh 22 | -c 23 | 24 | echo "[$(date +"%b %d %Y %H:%M")] $(/usr/bin/curl -LSs "${uri}" 2>&1 >/dev/null && /usr/local/bin/node ${path.resolve(__dirname, 'background.js')})" 25 | 26 | 27 | RunAtLoad 28 | 29 | KeepAlive 30 | 31 | NetworkState 32 | 33 | 34 | StartInterval 35 | ${interval} 36 | StandardErrorPath 37 | ${plistFileErr} 38 | StandardOutPath 39 | ${plistFileLog} 40 | WorkingDirectory 41 | ${path.resolve(__dirname, '..')} 42 | 43 | `); 44 | 45 | const BackgroundProcess = { 46 | status: () => sh.execSync(`launchctl list | grep ${plist.name} | awk '{ print $2 }'`).toString().replace(/[^\d]/g, '') || undefined, 47 | load: function (uri, interval) { 48 | if (this.unload()) { 49 | let definition = DaemonDefinition(uri, interval); 50 | fs.writeFileSync(plistFile, definition, 'utf8'); 51 | sh.execSync(`launchctl load ${plistFile}`); 52 | return true; 53 | } 54 | return false; 55 | }, 56 | unload: function() { 57 | if (this.status() !== undefined) { 58 | sh.execSync(`launchctl unload ${plistFile}`); 59 | } 60 | if (fs.existsSync(plistFile)) { 61 | fs.unlinkSync(plistFile); 62 | } 63 | if (fs.existsSync(plistFileLog)) { 64 | fs.unlinkSync(plistFileLog); 65 | } 66 | if (fs.existsSync(plistFileErr)) { 67 | fs.unlinkSync(plistFileErr); 68 | } 69 | return true; 70 | }, 71 | }; 72 | 73 | module.exports = BackgroundProcess; 74 | -------------------------------------------------------------------------------- /lib/extendedMenu.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Jira = require('./jira'); 3 | const Workflow = require('./workflow'); 4 | const config = require('./jira/config'); 5 | const cfgFile = config.cfgPath + config.cfgFile; 6 | 7 | let wf = new Workflow(); 8 | let actions = wf.actionHandler; 9 | 10 | const reserved = { 11 | 'clear': 'clear-inProgress', 12 | 'open': 'open-in-browser', 13 | }; 14 | 15 | actions.onAction('open-in-browser', params => { 16 | if (Jira.checkConfig()) { 17 | let data = JSON.parse(fs.readFileSync(cfgFile, 'utf-8')); 18 | let issue = params[0] || ''; 19 | wf.addItem({ 20 | title: 'Open in a browser', 21 | subtitle: issue ? `Open ${issue} in browser` : 'Enter an issue key to open.', 22 | valid: issue !== '', 23 | autocomplete: 'open ' + wf._sep + issue.toUpperCase(), 24 | arg: `openURL ${data.url}browse/${issue}`, 25 | }); 26 | wf.feedback(); 27 | } 28 | }); 29 | 30 | actions.onAction('clear-inProgress', () => { 31 | wf.default({ 32 | title: 'No issues in progress', 33 | valid: false, 34 | autocomplete: '', 35 | }); 36 | let progress = Jira.listInProgress(); 37 | if (progress) { 38 | wf.addItems(progress 39 | .map(issue => { 40 | return { 41 | title: `Clear progress on ${issue.id}`, 42 | subtitle: issue.runTime, 43 | valid: true, 44 | autocomplete: 'clear', 45 | projectIcon: issue.id.replace(/-.*$/, '') + '.png', 46 | arg: `clearProgress ${issue.id}`, 47 | }; 48 | })); 49 | } 50 | wf.feedback(); 51 | }); 52 | 53 | module.exports = { 54 | reserved: query => { 55 | let [context,...params] = query 56 | .split(new RegExp('[\\s' + wf._sep + ']+', '')) 57 | .map(s => s.trim()) 58 | .filter(String); 59 | let extension = Object.keys(reserved) 60 | .find(key => new RegExp('^' + key + '$', 'i').test(context)); 61 | if (!extension || /^\s/.test(query)) { 62 | return false; 63 | } 64 | 65 | actions.handle(reserved[extension], params); 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /lib/issues.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const Workflow = require('./workflow'); 3 | const config = require('./jira/config'); 4 | 5 | let wf = new Workflow(); 6 | let enabledItems = config.menuItems; 7 | let options = Jira.getOptions(); 8 | 9 | if (options['enabled_menu_items']) { 10 | enabledItems = options['enabled_menu_items']; 11 | } 12 | 13 | module.exports = { 14 | 15 | format: function (issues) { 16 | let self = this; 17 | issues = issues 18 | .map(issue => ({ 19 | title: issue.Key, 20 | subtitle: issue.Summary, 21 | valid: false, 22 | autocomplete: wf.path('tickets', issue.Key), 23 | data: self.getTicket(issue), 24 | projectIcon: issue.Key.replace(/-.*$/, '') + '.png', 25 | cmdMod: { 26 | subtitle: 'Open issue in browser', 27 | arg: `openIssue ${issue.Key}`, 28 | }, 29 | })); 30 | 31 | return issues; 32 | }, 33 | 34 | getTicket: function (ticket) { 35 | ticket = ticket || {}; 36 | let menu = []; 37 | 38 | // Summary 39 | if (ticket.Summary) { 40 | let url = ticket.URL || null; 41 | menu.push({ 42 | title: ticket.Summary, 43 | valid: url !== null, 44 | arg:'openURL ' + url, 45 | icon: 'title.png', 46 | wfId: 'summary', 47 | }); 48 | } 49 | 50 | // Description 51 | if (ticket.Description) { 52 | menu.push({ 53 | title: ticket.Description, 54 | valid: false, 55 | icon: 'description.png', 56 | wfId: 'description', 57 | }); 58 | } 59 | 60 | // Progress 61 | let progress = Jira.getProgress(ticket.Key); 62 | let startProgress = progress === false; 63 | menu.push({ 64 | title: startProgress ? 'Start Progress' : `Stop Progress (${progress})`, 65 | arg: startProgress ? `startProgress ${ticket.Key}` : `stopProgress ${ticket.Key}`, 66 | valid: true, 67 | icon: startProgress ? 'play.png' : 'stop.png', 68 | wfId: 'progress', 69 | cmdMod: startProgress ? null : { 70 | subtitle: 'Stop progress without logging time', 71 | arg: `clearProgress ${ticket.Key}`, 72 | }, 73 | }); 74 | 75 | // Assignee 76 | let assignee = ticket.Assignee || 'Unassigned'; 77 | menu.push({ 78 | title: 'Assigned: ' + assignee, 79 | valid: false, 80 | userIcon: assignee.replace(/[^a-z0-9]/gi,'_') + '.png', 81 | data: { 82 | '_key': ticket.Key + '-assign', 83 | currentAssignee: assignee, 84 | }, 85 | autocomplete: wf.path('assign', ticket.Key), 86 | wfId: 'assignee', 87 | }); 88 | 89 | // Status 90 | if (ticket.Status) { 91 | menu.push({ 92 | title: 'Status: ' + ticket.Status, 93 | valid: false, 94 | icon: ticket.StatCategory + '.png', 95 | data: { 96 | '_key': ticket.Key + '-status', 97 | }, 98 | autocomplete: wf.path('status', ticket.Key), 99 | wfId: 'status', 100 | }); 101 | } 102 | 103 | // Comment 104 | menu.push({ 105 | title: 'Add a comment', 106 | valid: false, 107 | icon: 'comment.png', 108 | data: { 109 | '_key': ticket.Key + '-comment', 110 | }, 111 | autocomplete: wf.path('comment', ticket.Key), 112 | wfId: 'comment', 113 | }); 114 | 115 | // Watch 116 | if (ticket.Watching !== undefined) { 117 | menu.push({ 118 | title: ticket.Watching ? 'Stop watching this issue' : 'Start watching this issue', 119 | valid: true, 120 | icon: ticket.Watching ? 'watch.png' : 'unwatch.png', 121 | arg: `toggleWatch ${ticket.Key} ${ticket.Watching}`, 122 | wfId: 'watch', 123 | }); 124 | } 125 | 126 | // Priority 127 | if (ticket.Priority) { 128 | menu.push({ 129 | title: ticket.Priority, 130 | valid: false, 131 | priorityIcon: ticket.Priority + '.png', 132 | wfId: 'priority', 133 | }); 134 | } 135 | 136 | return menu.filter(issue => enabledItems.includes(issue.wfId)); 137 | }, 138 | 139 | }; -------------------------------------------------------------------------------- /lib/jira/assign.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | const cache = require('./cache'); 5 | 6 | // Cache list of users for a week (7 days) 7 | let users = cache.get('users', 7*24*60*60*1000); 8 | 9 | module.exports = { 10 | to: function(ticket, assignee) { 11 | let query = 'rest/api/2/issue/' + ticket + '/assignee'; 12 | return new Promise((resolve, reject) => { 13 | request 14 | .put(config.url + query, {name: assignee}, config.req) 15 | .then( res => { 16 | if (res.status != 204) { 17 | reject(new Error(res.statusText)); 18 | } 19 | resolve(ticket + ' assigned to: ' + assignee); 20 | }) 21 | .catch(res => { 22 | let err = res.response; 23 | if (!err) { 24 | reject('Unable to receive response from: ' + 25 | config.url + ' ' + '\n' + res); 26 | } 27 | if (err.status === 401) { 28 | auth.logout(); 29 | } 30 | if (err.status === 403) { 31 | console.log('Too many failed login attempts: \n%s', 32 | err.headers['x-authentication-denied-reason']); 33 | } 34 | reject(err.status + ': ' + err.statusText); 35 | }); 36 | }); 37 | }, 38 | }; -------------------------------------------------------------------------------- /lib/jira/auth.js: -------------------------------------------------------------------------------- 1 | const sh = require('../alfred-exec'); 2 | const fs = require('fs'); 3 | const keychain = require('./keychain'); 4 | const config = require('./config'); 5 | const cache = require('./cache'); 6 | const log = require('../alfred-log'); 7 | const daemon = require('../daemon'); 8 | 9 | let Auth = { 10 | cfgPath: config.cfgPath || null, 11 | cfgFile: config.cfgFile || null, 12 | cacheFile: config.cacheFile || null, 13 | fullPath: config.cfgPath + config.cfgFile || null, 14 | menuItems: config.menuItems, 15 | 16 | 'login': function() { 17 | let self = this; 18 | sh.exec('npm run electron login', function(err, stderr, stdout) { 19 | if (err) { 20 | throw err; 21 | } 22 | 23 | let token = keychain.find(); 24 | let result = null; 25 | // Parse stdout for a potential JSON string by capturing anything 26 | // between opening and closing braces ({}). This is done to avoid 27 | // matching Electron errors that may by included in the output. 28 | let jsonString = (stdout.match(/\{.+\}/g) || []).pop(); 29 | 30 | try { 31 | result = JSON.parse(jsonString); 32 | } 33 | catch (e) { 34 | log('Unable to parse JSON response from stdout', e); 35 | } 36 | 37 | if (result && token) { 38 | config.user = result.user; 39 | config.url = result.url.replace(/(.)\/*$/,'$1/'); 40 | config.req.headers['Authorization'] = 'Basic ' + token; 41 | self.getProjects().then(projectList => { 42 | config.projects = projectList; 43 | self.getStatuses().then(statusList => { 44 | config.statuses = statusList; 45 | self.saveConfig(); 46 | }); 47 | }).catch(err => { 48 | keychain.delete(); 49 | console.log(err); 50 | }); 51 | } 52 | else { 53 | console.log('Login canceled'); 54 | } 55 | }); 56 | }, 57 | 58 | 'checkConfig': function() { 59 | config.token = keychain.find(); 60 | config.req = { 61 | headers: { 62 | 'Content-Type': 'application/json', 63 | 'Authorization': 'Basic ' + config.token, 64 | }, 65 | }; 66 | 67 | if (fs.existsSync(this.fullPath)) { 68 | let configJSON = JSON.parse(fs.readFileSync(this.fullPath, 'utf-8')); 69 | 70 | config.user = configJSON.user; 71 | config.url = configJSON.url; 72 | config.options = configJSON.options; 73 | 74 | if (configJSON.bookmarks) { 75 | config.bookmarks = configJSON.bookmarks; 76 | } 77 | 78 | if (configJSON.sort) { 79 | config.sort = configJSON.sort; 80 | } 81 | 82 | ['enabled_menu_items', 'available_projects', 'available_issues_statuses'].forEach(option => { 83 | if (config.options[option]) { 84 | config.options[option] = config.options[option] 85 | .filter(opt => opt.enabled !== false) 86 | .map(opt => opt.name || opt); 87 | } 88 | }); 89 | 90 | return (config.url && config.user && config.options && config.token); 91 | } 92 | else { 93 | return false; 94 | } 95 | }, 96 | 97 | 'setConfig': function () { 98 | let self = this; 99 | return new Promise(function (resolve, reject) { 100 | if (self.checkConfig()) { 101 | loadedSettings = sh.execSync('md5 -q ' + self.fullPath).toString(); 102 | cachedSettings = cache.get('configFile'); 103 | 104 | if (cachedSettings != loadedSettings) { 105 | if (cachedSettings) { 106 | cache.clear().then(() => cache.set('configFile', loadedSettings)); 107 | } 108 | else { 109 | cache.set('configFile', loadedSettings); 110 | } 111 | } 112 | resolve(true); 113 | } 114 | else { 115 | if (!fs.existsSync(self.cfgPath)) { 116 | fs.mkdirSync(self.cfgPath); 117 | } 118 | daemon.unload(); 119 | self.login(); 120 | } 121 | }); 122 | }, 123 | 124 | 'logout': function() { 125 | if (this.checkConfig()) { 126 | daemon.unload(); 127 | delete config.token; 128 | delete config.req; 129 | delete config.user; 130 | fs.writeFileSync(this.fullPath, JSON.stringify(config, null, 2)); 131 | keychain.delete(); 132 | log('Logged out.'); 133 | } 134 | else { 135 | log('Not logged in.'); 136 | } 137 | }, 138 | 139 | 'clearConfig': function() { 140 | if (fs.existsSync(this.cfgPath)) { 141 | daemon.unload(); 142 | fs.readdirSync(this.cfgPath).forEach(file => fs.unlinkSync(this.cfgPath + file)); 143 | fs.rmdirSync(this.cfgPath); 144 | if (keychain.delete()) { 145 | console.log('Configuration deleted successfully!'); 146 | } 147 | } 148 | }, 149 | 150 | 'getProjects': function() { 151 | const Projects = require('./projects'); 152 | return Projects.getProjects(); 153 | }, 154 | 155 | 'getStatuses': function() { 156 | const Projects = require('./projects'); 157 | return Projects.getStatuses(); 158 | }, 159 | 160 | 'saveConfig': function() { 161 | let self = this; 162 | let configFile = { 163 | url: config.url, 164 | user: config.user, 165 | options: { 166 | minimum_log_time: '0 seconds', 167 | enabled_menu_items: config.menuItems, 168 | available_projects: config.projects, 169 | available_issues_statuses: config.statuses, 170 | }, 171 | }; 172 | fs.writeFileSync(self.fullPath, JSON.stringify(configFile, null, 2)); 173 | let hash = sh.execSync('md5 -q ' + self.fullPath).toString(); 174 | cache.set('configFile', hash); 175 | console.log('Information stored!'); 176 | }, 177 | }; 178 | 179 | module.exports = Auth; 180 | -------------------------------------------------------------------------------- /lib/jira/cache.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const config = require('./config'); 3 | 4 | if (!fs.existsSync(config.cfgPath)) { 5 | fs.mkdirSync(config.cfgPath); 6 | } 7 | 8 | let cacheFile = config.cfgPath + config.cacheFile; 9 | let exists = fs.existsSync(cacheFile); 10 | let cache = exists ? JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) : {}; 11 | const persist = () => fs.writeFileSync(cacheFile, JSON.stringify(cache)); 12 | 13 | module.exports = { 14 | 'set': (id, val) => { 15 | cache = fs.existsSync(cacheFile) ? JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) : {}; 16 | cache[id] = { value: val, time: Date.now() }; 17 | persist(); 18 | }, 19 | 20 | 'get': (id, expire) => { 21 | let exists = cache[id] !== undefined, 22 | cacheData = cache[id] || {}, 23 | cachedAt = cacheData.time || 0, 24 | expiresAt = cachedAt + expire, 25 | expired = Date.now() > expiresAt; 26 | return exists && (!expired || !expire) ? cacheData.value : undefined; 27 | }, 28 | 29 | 'lastChecked': (id) => { 30 | let exists = cache[id] !== undefined, 31 | cacheData = cache[id] || {}, 32 | cachedAt = cacheData.time || 0, 33 | time = cachedAt ? cachedAt : Date.now(); 34 | return new Date(time).toLocaleString(); 35 | }, 36 | 37 | 'clear': (ids) => { 38 | return new Promise((resolve, reject) => { 39 | ids = ids || []; 40 | if (typeof ids !== 'object') { 41 | ids = [ids]; 42 | } 43 | if (ids.length) { 44 | for (let id of ids) { 45 | if (cache[id]) { 46 | delete cache[id]; 47 | } 48 | } 49 | persist(); 50 | return resolve(true); 51 | } 52 | else { 53 | if (fs.existsSync(cacheFile)) { 54 | fs.unlinkSync(cacheFile); 55 | return resolve(true); 56 | } 57 | } 58 | }); 59 | }, 60 | }; -------------------------------------------------------------------------------- /lib/jira/comment.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | 5 | module.exports = function (ticket, comment) { 6 | return new Promise((resolve, reject) => { 7 | request 8 | .post( config.url + 'rest/api/2/issue/' + ticket + '/comment', { body: comment }, config.req) 9 | .then(res => { 10 | if (res.status != 201) { 11 | reject(new Error(res.statusText)); 12 | } 13 | resolve('Comment added to ' + ticket); 14 | }) 15 | .catch(reject); 16 | }); 17 | }; -------------------------------------------------------------------------------- /lib/jira/config.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; 2 | const iconPath = process.cwd() + '/resources/icons/'; 3 | const cfgPath = process.env.HOME + '/.alfred-jira/'; 4 | module.exports = { 5 | cfgPath: cfgPath, 6 | cfgFile: 'config.json', 7 | cacheFile: 'cache.json', 8 | iconPath: iconPath, 9 | userIconPath: './resources/user_icons/', 10 | projectIconPath: './resources/project_icons/', 11 | priorityIconPath: './resources/priority_icons/', 12 | plistFile: process.env.HOME + '/Library/LaunchAgents/com.alfred-jira.helper.plist', 13 | plistFileLog: cfgPath + 'alfred-jira.log', 14 | plistFileErr: cfgPath + 'alfred-jira_err.log', 15 | menuItems: [ 16 | { name: 'summary', enabled: true }, 17 | { name: 'description', enabled: true }, 18 | { name: 'progress', enabled: true }, 19 | { name: 'assignee', enabled: true }, 20 | { name: 'status', enabled: true }, 21 | { name: 'comment', enabled: true }, 22 | { name: 'watch', enabled: true }, 23 | { name: 'priority', enabled: true }, 24 | ], 25 | url: '', 26 | user: '', 27 | projects: [], 28 | options: {}, 29 | sort: [ 30 | { name: 'Priority', desc: true }, 31 | { name: 'Key', desc: true }, 32 | ], 33 | 'bookmarks': [ 34 | { 35 | name: 'My Tickets', 36 | query: 'assignee=currentUser()', 37 | cache: 900000, 38 | sort: [ 39 | { name: 'Priority', desc: true }, 40 | { name: 'Key', desc: true }, 41 | ], 42 | limitProjects: true, 43 | limitStatuses: true, 44 | icon: iconPath + 'inbox.png', 45 | }, 46 | { 47 | name: 'Watched Issues', 48 | query: 'issueKey IN watchedIssues()', 49 | cache: 900000, 50 | sort: [ 51 | { name: 'Priority', desc: true }, 52 | { name: 'Key', desc: true }, 53 | ], 54 | limitProjects: true, 55 | limitStatuses: true, 56 | icon: iconPath + 'watched.png', 57 | }, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /lib/jira/create.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | const storage = require('node-persist'); 5 | const log = require('../alfred-log'); 6 | 7 | storage.initSync({ 8 | dir: config.cfgPath, 9 | }); 10 | 11 | const createIssue = fields => { 12 | // POST the query to JIRA to create the issue. 13 | return new Promise((resolve, reject) => { 14 | request.post(config.url + 'rest/api/2/issue', { fields }, config.req) 15 | .then(res => { 16 | if (res.status === 201) { 17 | resolve(res.data.key); 18 | } 19 | }) 20 | .catch(err => { 21 | err = err.response; 22 | if (err.status === 400) { 23 | let errors = err.data.errors; 24 | // Issue creation can fail if we tried to set a field that Jira isn't expecting. 25 | // We can check the errors to see if that's what happened, remove the unexpected 26 | // field(s) and then try to create the issue again. 27 | for (let key in errors) { 28 | let message = errors[key]; 29 | if (new RegExp(`Field '${key}' cannot be set.`, '').test(message) && fields[key]) { 30 | delete fields[key]; 31 | } 32 | else { 33 | // The request failed for some other reason. 34 | return reject(errors); 35 | } 36 | } 37 | // Try again. 38 | resolve(createIssue(fields)); 39 | } 40 | }); 41 | }); 42 | }; 43 | 44 | module.exports = function () { 45 | let issueConfig = storage.getItemSync('create-config'); 46 | let errors = []; 47 | return new Promise((resolve, reject) => { 48 | if (!issueConfig) { 49 | reject('Unable to create issue: no issue defined.'); 50 | } 51 | 52 | // Required fields. 53 | let required = ['project', 'summary', 'issuetype']; 54 | 55 | for (let key of required) { 56 | if (issueConfig[key]) { 57 | continue; 58 | } 59 | errors.push(`Missing required field: "${key}"`); 60 | } 61 | if (errors.length) { 62 | reject(errors.join('\n')); 63 | } 64 | 65 | let assignee = issueConfig.assignee ? { name: issueConfig.assignee } : null; 66 | let summary = issueConfig.summary; 67 | let description = issueConfig.description || null; 68 | let reporter = { name: config.user }; 69 | let project = { key: issueConfig.project }; 70 | let issuetype = { name: issueConfig.issuetype }; 71 | 72 | resolve(createIssue({ assignee, summary, reporter, project, issuetype })); 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /lib/jira/grab-images.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | const sh = require('child_process'); 5 | const users = require('./users'); 6 | const issueTypes = require('./issuetypes'); 7 | 8 | const download = (opts, cb) => { 9 | opts = opts 10 | .filter(opt => opt.file && opt.address) 11 | .map(opt => { 12 | return [ 13 | '-o', 14 | opt.path + opt.file.replace(/[^a-z0-9]/gi, '_'), 15 | opt.address, 16 | ]; 17 | }); 18 | let dl = sh.spawn('sh', ['./bin/download_img.sh'].concat(...opts)); 19 | // dl.stdout.on('data', data => process.stdout.write(data.toString())) 20 | // dl.stderr.on('data', data => process.stdout.write(data.toString())) 21 | dl.on('close', cb); 22 | }; 23 | 24 | const DownloadImages = { 25 | projects: (callback) => { 26 | request 27 | .get(config.url + 'rest/api/2/project', config.req) 28 | .then(res => { 29 | let params = res.data.map(s => { 30 | return { 31 | path: config.projectIconPath, 32 | file: s.key, 33 | address: s['avatarUrls']['48x48'], 34 | }; 35 | }); 36 | download(params, callback); 37 | }) 38 | .catch(console.error); 39 | }, 40 | 41 | users: (callback) => { 42 | users.getUsers() 43 | .then(res => { 44 | let params = res.map(s => { 45 | return { 46 | path: config.userIconPath, 47 | file: s.name, 48 | address: s['avatarUrls']['48x48'], 49 | }; 50 | }); 51 | download(params, callback); 52 | }) 53 | .catch(console.error); 54 | }, 55 | 56 | priorities: (callback) => { 57 | request 58 | .get(config.url + 'rest/api/2/priority', config.req) 59 | .then(res => { 60 | let params = res.data.map(s => { 61 | return { 62 | path: config.priorityIconPath, 63 | file: s.name, 64 | address: s.iconUrl, 65 | }; 66 | }); 67 | download(params, callback); 68 | }) 69 | .catch(console.error); 70 | }, 71 | }; 72 | 73 | module.exports = (action, callback) => { 74 | auth.setConfig() 75 | .then(() => { 76 | DownloadImages[action](callback); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /lib/jira/index.js: -------------------------------------------------------------------------------- 1 | const sh = require('../alfred-exec'); 2 | const config = require('./config'); 3 | const cache = require('./cache'); 4 | const auth = require('./auth'); 5 | const ls = require('./list'); 6 | const status = require('./transitions'); 7 | const users = require('./users'); 8 | const assign = require('./assign'); 9 | const Comment = require('./comment'); 10 | const watch = require('./watch'); 11 | const worklog = require('./worklog'); 12 | const log = require('../alfred-log'); 13 | const create = require('./create'); 14 | const issueTypes = require('./issuetypes'); 15 | const search = require('./search'); 16 | 17 | Jira = { 18 | 'auth': () => auth.setConfig(), 19 | 20 | 'checkConfig': () => auth.checkConfig(), 21 | 22 | 'getOptions': function() { 23 | if (this.checkConfig()) { 24 | return config.options; 25 | } 26 | else { 27 | log('Unable to get options. auth.checkConfig did not pass.'); 28 | return []; 29 | } 30 | }, 31 | 32 | 'getBookmarks': function() { 33 | if (this.checkConfig()) { 34 | if (config.bookmarks && typeof config.bookmarks == 'object') { 35 | return config.bookmarks; 36 | } 37 | } 38 | return []; 39 | }, 40 | 41 | 'getAllBookmarks': function() { 42 | let self = this; 43 | return Promise.all(self.getBookmarks() 44 | .map((bookmark, index) => 45 | self.listAll(`bookmark-${index}`))); 46 | }, 47 | 48 | 'getUsers': function() { 49 | let self = this; 50 | return new Promise((resolve, reject) => { 51 | self.auth() 52 | .then(users.getUsers.bind(users)) 53 | .then(resolve) 54 | .catch(reject); 55 | }); 56 | }, 57 | 58 | 'getIssueTypes': function(project) { 59 | let self = this; 60 | return new Promise((resolve, reject) => { 61 | self.auth() 62 | .then(() => { 63 | if (project) { 64 | return issueTypes.getIssueTypesByProject(project); 65 | } 66 | return issueTypes.getIssueTypes(); 67 | }) 68 | .then(resolve) 69 | .catch(reject); 70 | }); 71 | }, 72 | 73 | 'login': () => auth.setConfig().then(auth.login.bind(auth)), 74 | 75 | 'testBookmark': function(bookmarkConfig) { 76 | let self = this; 77 | return new Promise((resolve, reject) => { 78 | self.auth() 79 | .then(() => ls.showAll(bookmarkConfig)) 80 | .then(resolve) 81 | .catch(res => { 82 | reject(res.response.data.errorMessages); 83 | }); 84 | }); 85 | }, 86 | 87 | 'listAll': function(bookmarkConfig) { 88 | let self = this; 89 | return new Promise((resolve, reject) => { 90 | self.auth() 91 | .then(() => { 92 | return ls.showAll(bookmarkConfig); 93 | }) 94 | .then(resolve) 95 | .catch(res => { 96 | let err = res.response; 97 | log(err); 98 | if (!err) { 99 | reject('Unable to receive response from: ' + 100 | config.url + ' ' + '\n' + res); 101 | } 102 | if (err.status === 401) { 103 | auth.logout(); 104 | } 105 | if (err.status === 403) { 106 | log('Too many failed login attempts: \n%s', 107 | err.headers['x-authentication-denied-reason']); 108 | } 109 | reject(err.status + ': ' + err.statusText); 110 | }); 111 | }); 112 | }, 113 | 114 | 'search': function(query) { 115 | let self = this; 116 | return new Promise((resolve, reject) => { 117 | self.auth() 118 | .then(() => resolve(search.search(query))) 119 | .catch(reject); 120 | }); 121 | }, 122 | 123 | 'status': function(ticketId) { 124 | let self = this; 125 | return new Promise((resolve, reject) => { 126 | self.auth() 127 | .then(() => { 128 | return resolve(status.availableTransitions(ticketId)); 129 | }) 130 | .catch(reject); 131 | }); 132 | }, 133 | 134 | 'transition': function(ticketId, action, token) { 135 | let self = this; 136 | return new Promise((resolve, reject) => { 137 | self.auth() 138 | .then(() => { 139 | return resolve(status.transition(ticketId, action, token)); 140 | }) 141 | .catch(reject); 142 | }); 143 | }, 144 | 145 | 'createIssue': function() { 146 | let self = this; 147 | return new Promise((resolve, reject) => { 148 | self.auth() 149 | .then(() => { 150 | return resolve(create()); 151 | }) 152 | .catch(reject); 153 | }); 154 | }, 155 | 156 | 'assign': function(ticket, assignee) { 157 | let self = this; 158 | return new Promise((resolve, reject) => { 159 | if (!ticket) { 160 | return reject('No ticket specified'); 161 | } 162 | self.auth() 163 | .then(() => { 164 | if (assignee) { 165 | assign.to(ticket, assignee) 166 | .then(resolve) 167 | .catch(reject); 168 | } 169 | else { 170 | return reject('No assignee specified'); 171 | } 172 | }); 173 | }); 174 | }, 175 | 176 | 'comment': function(ticket, comment) { 177 | let self = this; 178 | return new Promise((resolve, reject) => { 179 | if (!ticket || !comment) { 180 | return reject('Requires a ticket & comment.'); 181 | } 182 | self.auth() 183 | .then(() => resolve(Comment(ticket, comment))) 184 | .catch(reject); 185 | }); 186 | }, 187 | 188 | 'toggleWatch': function(ticket, currentState) { 189 | let self = this; 190 | return new Promise((resolve, reject) => { 191 | if (!ticket || currentState === undefined) { 192 | return reject('Requires a ticket & the current watched status'); 193 | } 194 | self.auth() 195 | .then(() => 196 | resolve(watch[['start', 'stop'][+currentState]](ticket))) 197 | .catch(reject); 198 | }); 199 | }, 200 | 201 | 'startProgress': function(issue) { 202 | return worklog.start(issue); 203 | }, 204 | 205 | 'stopProgress': function(issue) { 206 | let self = this; 207 | return new Promise((resolve,reject) => { 208 | self.auth() 209 | .then(() => { 210 | worklog.stop(issue).then(resolve).catch(reject); 211 | }); 212 | }); 213 | }, 214 | 215 | 'clearProgress': function(issue) { 216 | return worklog.clearProgress(issue); 217 | }, 218 | 219 | 'getProgress': function(issue) { 220 | return worklog.getProgress(issue); 221 | }, 222 | 223 | 'listInProgress': function() { 224 | return worklog.inProgress(); 225 | }, 226 | 227 | 'inProgressInfo': function(issue) { 228 | let self = this; 229 | return new Promise((resolve, reject) => { 230 | self.auth() 231 | .then(() => { 232 | worklog.inProgressInfo(issue).then(resolve).catch(reject); 233 | }); 234 | }); 235 | }, 236 | 237 | 'clearSettings': function() { 238 | return auth.clearConfig(); 239 | }, 240 | 241 | 'clearCache': function() { 242 | let args = [...arguments]; 243 | return cache.clear(args); 244 | }, 245 | 246 | 'refreshCache': function() { 247 | let self = this; 248 | self.clearCache.apply(self, arguments) 249 | .then(self.fetchData); 250 | }, 251 | 252 | 'editSettings': function() { 253 | this.auth() 254 | .then(() => { 255 | sh.spawn('npm', ['run', 'electron'], 256 | { detached: true, stdio: 'ignore' }).unref(); 257 | }); 258 | }, 259 | 260 | 'getStatuses': function() { 261 | let self = this; 262 | return new Promise((resolve, reject) => { 263 | self.auth() 264 | .then(auth.getStatuses.bind(auth)) 265 | .then(resolve) 266 | .catch(reject); 267 | }); 268 | }, 269 | 270 | 'getProjects': function() { 271 | let self = this; 272 | return new Promise((resolve, reject) => { 273 | self.auth() 274 | .then(auth.getProjects.bind(auth)) 275 | .then(resolve) 276 | .catch(reject); 277 | }); 278 | }, 279 | 280 | 'fetchData': function() { 281 | sh.spawn('node', ['./lib/background.js'], 282 | { detached: true, stdio: 'ignore'}).unref(); 283 | }, 284 | }; 285 | 286 | module.exports = Jira; -------------------------------------------------------------------------------- /lib/jira/issuetypes.js: -------------------------------------------------------------------------------- 1 | var config = require('./config'); 2 | var auth = require('./auth'); 3 | let request = require('axios'); 4 | let cache = require('./cache'); 5 | 6 | module.exports = { 7 | getIssueTypes: function() { 8 | let self = this; 9 | return new Promise((resolve, reject) => { 10 | // Cache list of issuetypes for a week (7 days) 11 | let issuetypes = cache.get('issuetypes', 7*24*60*60*1000); 12 | if (issuetypes) { 13 | return resolve(issuetypes); 14 | } 15 | 16 | request 17 | .get(config.url + 'rest/api/2/issuetype', config.req) 18 | .then(res => { 19 | let types = res.data 20 | .filter(type => !type.subtask) 21 | .map(type => ({ 22 | name: type.name, 23 | icon: type.iconUrl, 24 | })); 25 | cache.set('issuetypes', types); 26 | resolve(types); 27 | }) 28 | .catch(reject); 29 | }); 30 | }, 31 | 32 | getIssueTypesByProject: function (project) { 33 | let self = this; 34 | return new Promise((resolve, reject) => { 35 | 36 | // Cache list of issuetypes for the project for 5 mins 37 | let issuetypes = cache.get(`issuetypes-${project}`, 5*60*1000); 38 | if (issuetypes) { 39 | return resolve(issuetypes); 40 | } 41 | 42 | let req = Object.assign({}, config.req); 43 | req.params = { 44 | projectKeys: project, 45 | }; 46 | 47 | request 48 | .get(config.url + 'rest/api/2/issue/createmeta', req) 49 | .then(res => { 50 | let types = res.data.projects[0].issuetypes 51 | .filter(type => !type.subtask) 52 | .map(type => ({ 53 | name: type.name, 54 | icon: type.iconUrl, 55 | })); 56 | resolve(types); 57 | cache.set(`issuetypes-${project}`, types); 58 | }) 59 | .catch(reject); 60 | }); 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /lib/jira/keychain.js: -------------------------------------------------------------------------------- 1 | const sh = require('child_process'); 2 | let key = 'alfred-jira'; 3 | let user = process.env.USER; 4 | 5 | let Keychain = { 6 | 'find': function() { 7 | try{ 8 | let token = sh.execSync('security 2>&1 >/dev/null find-generic-password -s ' + key + ' -g').toString(); 9 | if (/^password/.test(token)) { 10 | token = token.split('"')[1]; 11 | return token; 12 | } 13 | else { 14 | console.error('Unable to retrieve password'); 15 | return false; 16 | } 17 | } 18 | catch(e) { 19 | return false; 20 | } 21 | }, 22 | 23 | 'save': function(token) { 24 | sh.exec('security add-generic-password -a '+user+' -s '+key+' -w "'+token+'" -U ', function(err, stderr, stdout) { 25 | if (err) { 26 | throw err; 27 | } 28 | console.log(stdout); 29 | }); 30 | }, 31 | 32 | 'delete': function() { 33 | sh.exec('security delete-generic-password -s ' + key, function(err, stderr, stdout) { 34 | if (err) { 35 | console.error('No credentials found for ' + key); 36 | } 37 | console.error('Credentials erased!'); 38 | }); 39 | }, 40 | }; 41 | 42 | module.exports = Keychain; -------------------------------------------------------------------------------- /lib/jira/list.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const search = require('./search'); 3 | const auth = require('./auth'); 4 | const request = require('axios'); 5 | const cache = require('./cache'); 6 | 7 | const AvailableStatuses = () => ' AND status in ("' + config.options.available_issues_statuses.map(decodeURI).join('","') + '")'; 8 | const SearchInProjects = () => { 9 | let options = config.options || {}, 10 | projects = options.available_projects || []; 11 | if (!projects.length) { 12 | return ''; 13 | } 14 | return ' AND project in ("' + projects.join('","') + '")'; 15 | }; 16 | 17 | function IssueRequest(bookmark) { 18 | let query = (bookmark.query || '').trim(); 19 | if (bookmark.sort) { 20 | query += ' order by ' + bookmark.sort.map(s => s.name + ' ' + (s.desc ? 'DESC' : 'ASC')).join(', '); 21 | } 22 | 23 | [query, sort] = query.split(/[\s\+]*order[\s\+]+by[\s\+]*/i); 24 | 25 | if (bookmark.limitProjects !== false) { 26 | query += SearchInProjects(); 27 | } 28 | if (bookmark.limitStatuses !== false) { 29 | query += AvailableStatuses(); 30 | } 31 | query = [query, sort].filter(Boolean).join(' order by '); 32 | 33 | this.query = query; 34 | var cache = bookmark.cache || 0; 35 | this.cacheId = bookmark.cacheId; 36 | this.setCache = cache > 0; 37 | if (this.setCache) { 38 | this.cacheLimit = bookmark.cache; 39 | } 40 | } 41 | 42 | let list = { 43 | 44 | getIssues: function(params) { 45 | let self = this; 46 | return new Promise((resolve, reject) => { 47 | if (params.setCache){ 48 | let cachedData = cache.get(params.cacheId, params.cacheLimit); 49 | if (cachedData) { 50 | return resolve(cachedData); 51 | } 52 | } 53 | 54 | search.getIssues(params.query) 55 | .then(issues => { 56 | if (params.setCache) { 57 | cache.set(params.cacheId, issues); 58 | } 59 | resolve(issues); 60 | }) 61 | .catch(reject); 62 | }); 63 | }, 64 | 65 | showAll: function (bookmarkConfig) { 66 | if (typeof bookmarkConfig == 'object') { 67 | bookmarkConfig.cache = 0; 68 | } 69 | else { 70 | let id = bookmarkConfig; 71 | let index = bookmarkConfig.replace(/[^\d]/g, ''); 72 | bookmarkConfig = config.bookmarks[index]; 73 | bookmarkConfig.cacheId = id; 74 | } 75 | return this.getIssues(new IssueRequest(bookmarkConfig)); 76 | }, 77 | 78 | }; 79 | 80 | module.exports = list; 81 | -------------------------------------------------------------------------------- /lib/jira/projects.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const request = require('axios'); 3 | 4 | module.exports = { 5 | 'getStatuses': () => { 6 | return new Promise((resolve, reject) => { 7 | request 8 | .get(config.url + 'rest/api/2/status', config.req) 9 | .then(res => { 10 | if (res.status !== 200) { 11 | reject(new Error(res.statusText)); 12 | } 13 | let statuses = res.data.map(status => { 14 | return { 15 | name: status.name, 16 | enabled: true, 17 | }; 18 | }); 19 | resolve(statuses); 20 | }) 21 | .catch(res => { 22 | let err = res.response; 23 | if (!err) { 24 | reject('Unable to receive response from: ' + 25 | config.url + ' ' + '\n' + res); 26 | } 27 | if (err.status === 403) { 28 | console.log('Too many failed login attempts: \n%s', 29 | err.headers['x-authentication-denied-reason']); 30 | } 31 | reject(err.status + ': ' + err.statusText); 32 | }); 33 | }); 34 | }, 35 | 36 | 'getProjects': () => { 37 | return new Promise((resolve, reject) => { 38 | request 39 | .get(config.url + 'rest/api/2/project', config.req) 40 | .then(res => { 41 | if (res.status !== 200) { 42 | reject(new Error(res.statusText)); 43 | } 44 | let projects = res.data.map( project => { 45 | return { 46 | name: project.key, 47 | enabled: true, 48 | }; 49 | } ); 50 | resolve(projects); 51 | }) 52 | .catch(res => { 53 | let err = res.response; 54 | if (!err) { 55 | reject('Unable to receive response from: ' + 56 | config.url + ' ' + '\n' + res); 57 | } 58 | if (err.status === 403) { 59 | console.log('Too many failed login attempts: \n%s', 60 | err.headers['x-authentication-denied-reason']); 61 | } 62 | reject(err.status + ': ' + err.statusText); 63 | }); 64 | }); 65 | }, 66 | }; -------------------------------------------------------------------------------- /lib/jira/search.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | const log = require('../alfred-log'); 5 | 6 | module.exports = { 7 | getIssues: function(query) { 8 | let self = this; 9 | return new Promise((resolve, reject) => { 10 | 11 | // Escape escape characters (\) 12 | query = query.replace(/\\/g, '\\\\'); 13 | 14 | query = encodeURIComponent(query); 15 | 16 | request 17 | .get(config.url + 'rest/api/2/search?jql=' + query, config.req) 18 | .then(function(res) { 19 | 20 | let issues = res.data.issues; 21 | 22 | let table = []; 23 | for (let i =0; i < issues.length; i++) { 24 | let issue = issues[i], 25 | fields = issue.fields; 26 | if (!fields.priority) { 27 | fields.priority = { name: '' }; 28 | } 29 | 30 | if (!fields.status) { 31 | fields.status = { name: '' }; 32 | } 33 | 34 | if (!fields.assignee) { 35 | fields.assignee = { displayName: '' }; 36 | } 37 | 38 | if (!fields.status.statusCategory) { 39 | fields.status.statusCategory = { name: '' }; 40 | } 41 | 42 | if (!fields.watches) { 43 | fields.watches = { isWatching: null }; 44 | } 45 | 46 | table.push({ 47 | 'Key': issue.key, 48 | 'Priority': fields.priority.name, 49 | 'Summary': fields.summary, 50 | 'Description': fields.description, 51 | 'Status': fields.status.name, 52 | 'StatCategory': fields.status.statusCategory.name, 53 | 'Assignee': fields.assignee.displayName, 54 | 'URL': config.url + 'browse/' + issue.key, 55 | 'Watching': fields.watches.isWatching, 56 | }); 57 | } 58 | 59 | resolve(table); 60 | }) 61 | .catch(reject); 62 | }); 63 | }, 64 | 65 | findIssue: function(ticket){ 66 | query = `issueKey="${ticket}"`; 67 | return this.getIssues(query); 68 | }, 69 | 70 | search: function (query) { 71 | let options = config.options || {}; 72 | let basicSearch = !options.advancedSearch; 73 | 74 | if (/^\w+-\d+$/.test(query.trim())) { 75 | return this.findIssue(query); 76 | } 77 | 78 | if (basicSearch) { 79 | let ignoredWords = ['a', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'if', 'in', 'into', 'is', 'it', 'no', 'not', 'of', 'on', 'or', 's', 'such', 't', 'that', 'the', 'their', 'then', 'there', 'these', 'they', 'this', 'to', 'was', 'will', 'with']; 80 | ignoredWords = ignoredWords.filter(word => new RegExp('\\b' + word + '\\b', 'i').test(query)); 81 | if (ignoredWords.length) { 82 | log('Warning: Your query contains the following reserved word(s). Unfortunately, they will be ignored by JIRA:\n' 83 | + '"' + ignoredWords.join(', ') + '"\n' 84 | + 'For more information: https://confluence.atlassian.com/jirasoftwareserver075/search-syntax-for-text-fields-935562918.html#Searchsyntaxfortextfields-reserved'); 85 | } 86 | 87 | query = `summary ~ "${query}*"` 88 | + ` OR description ~ "${query}*"` 89 | + ` OR comment ~ "${query}*"`; 90 | } 91 | return this.getIssues(query); 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /lib/jira/transitions.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | const cache = require('./cache'); 5 | 6 | let cacheLimit = 45*1000; // 45 seconds 7 | 8 | let Transitions = { 9 | availableTransitions: function(ticketId) { 10 | let self = this; 11 | return new Promise((resolve, reject) => { 12 | let query = 'rest/api/2/issue/' + ticketId + '/transitions'; 13 | if (!ticketId) { 14 | return reject('No ticket supplied'); 15 | } 16 | let transitions = cache.get('transitions-' + ticketId, cacheLimit); 17 | if (transitions) { 18 | return resolve(transitions); 19 | } 20 | request 21 | .get(config.url + query, config.req) 22 | .then(function(res) { 23 | if (res.status != 200) { 24 | reject(new Error(res.statusText)); 25 | } 26 | let transitions = res.data.transitions || []; 27 | 28 | if (!transitions.length) { 29 | return reject('No transitions found for ' + ticketId); 30 | } 31 | 32 | transitions = transitions.map( status => { 33 | let to = status.to || { statusCategory: { name: 'done' }}; 34 | let category = to.statusCategory; 35 | return { 36 | ticketId: ticketId, 37 | action: status.id, 38 | name: status.name, 39 | category: category.name, 40 | }; 41 | }); 42 | cache.set('transitions-' + ticketId, transitions); 43 | resolve(transitions); 44 | }).catch(reject); 45 | }); 46 | }, 47 | 48 | transition: (ticketId, action, token) => { 49 | const sh = require('child_process'); 50 | if (!ticketId || !action) { 51 | return console.error('Must supply ticketId and action'); 52 | } 53 | let address = config.url + 'secure/CommentAssignIssue!default.jspa?' 54 | + 'key=' + ticketId 55 | + '&action=' + action; 56 | sh.exec('open "' + address + '"'); 57 | }, 58 | }; 59 | 60 | module.exports = Transitions; -------------------------------------------------------------------------------- /lib/jira/users.js: -------------------------------------------------------------------------------- 1 | var config = require('./config'); 2 | var auth = require('./auth'); 3 | let request = require('axios'); 4 | let cache = require('./cache'); 5 | 6 | module.exports = { 7 | getUsers: function() { 8 | let self = this; 9 | return new Promise((resolve, reject) => { 10 | // Cache list of users for a week (7 days) 11 | let users = cache.get('users', 7*24*60*60*1000); 12 | if (users) { 13 | return resolve(users); 14 | } 15 | let projects = self.getAvailableProjects(); 16 | let query = 'rest/api/2/user/assignable/multiProjectSearch'; 17 | let req = Object.assign({}, config.req); 18 | req.params = { 19 | username: '', 20 | projectKeys: projects, 21 | startAt: 0, 22 | maxResults: 1000, 23 | }; 24 | if (!projects) { 25 | return reject('No available projects found'); 26 | } 27 | request 28 | .get(config.url + query, req) 29 | .then(res => { 30 | let seen = {}; 31 | let users = res.data 32 | .filter(s => seen.hasOwnProperty(s.name) ? false : (seen[s.name] = true)) 33 | .map( user => { 34 | return { 35 | username: user.name, 36 | name: user.displayName, 37 | avatarUrls: user.avatarUrls, 38 | }; 39 | }); 40 | cache.set('users', users); 41 | resolve(users); 42 | }) 43 | .catch(reject); 44 | }); 45 | }, 46 | 47 | getAvailableProjects: () => { 48 | let options = config.options || {}, 49 | projects = options.available_projects || []; 50 | return (projects.length) ? projects.join(',') : false; 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /lib/jira/watch.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const request = require('axios'); 4 | 5 | module.exports = { 6 | start: issue => { 7 | return new Promise((resolve, reject) => { 8 | if (!config.user) { 9 | return reject('User not found'); 10 | } 11 | config.req.data = config.user; 12 | request 13 | .post(config.url + 'rest/api/2/issue/' + issue + '/watchers', '"' + config.user + '"', config.req) 14 | .then(res => { 15 | if (res.status != 204) { 16 | reject(new Error(res.statusText)); 17 | } 18 | return resolve('Started watching ' + issue); 19 | }) 20 | .catch(res => { 21 | let err = res.response; 22 | if (!err) { 23 | reject('Unable to receive response from: ' + 24 | config.url + ' ' + '\n' + res); 25 | } 26 | if (err.status === 401) { 27 | auth.logout(); 28 | } 29 | if (err.status === 404) { 30 | reject('Issue ' + issue + ' does not exist'); 31 | } 32 | reject(err.status + ': ' + err.statusText); 33 | }); 34 | }); 35 | }, 36 | 37 | stop: issue => { 38 | return new Promise((resolve, reject) => { 39 | if (!config.user) { 40 | return reject('User not found'); 41 | } 42 | config.req.params = { username: config.user }; 43 | request 44 | .delete(config.url + 'rest/api/2/issue/' + issue + '/watchers', config.req) 45 | .then(res => { 46 | if (res.status != 204) { 47 | reject(new Error(res.statusText)); 48 | } 49 | return resolve('Stopped watching ' + issue); 50 | }) 51 | .catch(res => { 52 | let err = res.response; 53 | if (!err) { 54 | reject('Unable to receive response from: ' + 55 | config.url + ' ' + '\n' + res); 56 | } 57 | if (err.status === 401) { 58 | auth.logout(); 59 | } 60 | if (err.status === 404) { 61 | reject('Issue ' + issue + ' does not exist'); 62 | } 63 | reject(err.status + ': ' + err.statusText); 64 | }); 65 | }); 66 | }, 67 | }; -------------------------------------------------------------------------------- /lib/jira/worklog.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const auth = require('./auth'); 3 | const cache = require('./cache'); 4 | const search = require('./search'); 5 | const request = require('axios'); 6 | const moment = require('moment'); 7 | const fs = require('fs'); 8 | 9 | if (!fs.existsSync(config.cfgPath)) { 10 | fs.mkdirSync(config.cfgPath); 11 | } 12 | 13 | let inProgressStorage = config.cfgPath + 'inProgress.json'; 14 | let inProgress = fs.existsSync(inProgressStorage) ? 15 | JSON.parse(fs.readFileSync(inProgressStorage, 'utf-8')) : {}; 16 | let cacheId = 'in-progress'; 17 | let cacheLimit = 15*60*1000; // 15 minutes 18 | 19 | const toSeconds = input => { 20 | input = input.match(/[\d\.]+[^\w]*\w/g) || 0; 21 | if (input.length) { 22 | input = input.map( s => { 23 | let unit = s.slice(-1); 24 | let time = +s.replace(/[^\d\.]/g,''); 25 | if (/m/i.test(unit)) { 26 | time *= 60; 27 | } 28 | if (/h/i.test(unit)) { 29 | time *= 60 * 60; 30 | } 31 | if (/d/i.test(unit)) { 32 | time *= 24 * 60 * 60; 33 | } 34 | return time; 35 | }).reduce((a,b) => a + b); 36 | } 37 | return input; 38 | }; 39 | 40 | module.exports = { 41 | 'roundLogTime': time => { 42 | let inc = config.options['log_time_increments']; 43 | if (!inc) { 44 | return time; 45 | } 46 | inc = toSeconds(inc) || 1; 47 | return Math.ceil(time/inc) * inc; 48 | }, 49 | 50 | 'minLogTime': () => { 51 | let minTime = config.options['minimum_log_time'] || '0'; 52 | return toSeconds(minTime); 53 | }, 54 | 55 | 'timeRunning': ticket => { 56 | ticket = inProgress[ticket] || null; 57 | if (!ticket) { 58 | return false; 59 | } 60 | else { 61 | let start = ticket.start; 62 | let now = new Date(); 63 | let runTime = Math.round((now - start) / 1000); 64 | return runTime; 65 | } 66 | }, 67 | 68 | 'getProgress': function(ticket) { 69 | let seconds = this.timeRunning(ticket); 70 | if (seconds === false) { 71 | return seconds; 72 | } 73 | else { 74 | let res = []; 75 | let elapsed = { 76 | 'day': Math.floor(seconds/24/60/60), 77 | 'hour': Math.floor(seconds/60/60) % 24, 78 | 'min': Math.floor(seconds/60) % 60, 79 | }; 80 | for (let i in elapsed) { 81 | if (time = elapsed[i]) { 82 | let unit = time == 1 ? i : i + 's'; 83 | res.push(time, unit); 84 | } 85 | } 86 | return res.length ? res.join(' ') : seconds + ' secs'; 87 | } 88 | }, 89 | 90 | 'start': function (ticket) { 91 | let curr = Object.keys(inProgress).filter(key => key != ticket); 92 | if (curr.length) { 93 | return console.log('%s still in progress', curr.join(', ')); 94 | } 95 | inProgress[ticket] = inProgress[ticket] || { 'start': Date.now() }; 96 | fs.writeFileSync(inProgressStorage, JSON.stringify(inProgress)); 97 | console.log('Started working on: ' + ticket); 98 | }, 99 | 100 | 'clearProgress': function (ticket) { 101 | if (inProgress[ticket]) { 102 | delete inProgress[ticket]; 103 | fs.writeFileSync(inProgressStorage, JSON.stringify(inProgress)); 104 | } 105 | }, 106 | 107 | 'stop': function(ticket) { 108 | let self = this; 109 | return new Promise((resolve,reject) => { 110 | let seconds = self.timeRunning(ticket); 111 | let min = self.minLogTime(); 112 | if (seconds < min) { 113 | seconds = min; 114 | } 115 | seconds = self.roundLogTime(seconds); 116 | if (inProgress[ticket] && seconds) { 117 | let date = moment(inProgress[ticket]['start']).format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); 118 | let data = { 119 | 'timeSpentSeconds': seconds, 120 | 'comment': '', 121 | 'started': date, 122 | }; 123 | request 124 | .post(config.url + 'rest/api/2/issue/' + ticket + '/worklog', data, config.req) 125 | .then(function(res) { 126 | if (res.status === 201) { 127 | delete inProgress[ticket]; 128 | fs.writeFileSync(inProgressStorage, JSON.stringify(inProgress)); 129 | return resolve('Logged ' + res.data.timeSpent + ' to ' + ticket); 130 | } 131 | }) 132 | .catch(res => { 133 | console.error(res); 134 | return reject('Unable to log progress of ' + ticket); 135 | }); 136 | } 137 | else { 138 | return reject('Unable to determine progress of ' + ticket); 139 | } 140 | }); 141 | }, 142 | 143 | 'inProgressInfo': function(ticket) { 144 | return new Promise((resolve, reject) => { 145 | let cached = cache.get(cacheId, cacheLimit) || {}; 146 | if (cached[ticket]) { 147 | return resolve(cached[ticket]); 148 | } 149 | search.findIssue(ticket) 150 | .then(res => { 151 | cached[ticket] = res[0]; 152 | cache.set(cacheId, cached); 153 | resolve(res[0]); 154 | }).catch(reject); 155 | }); 156 | }, 157 | 158 | 'inProgress': function() { 159 | let result = []; 160 | for (let ticket in inProgress) { 161 | result.push({ 162 | 'id': ticket, 163 | 'start': inProgress[ticket]['start'], 164 | 'runTime': this.getProgress(ticket), 165 | }); 166 | } 167 | return result; 168 | }, 169 | }; -------------------------------------------------------------------------------- /lib/listTickets.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const Workflow = require('./workflow'); 3 | const issues = require('./issues'); 4 | let wf = new Workflow(); 5 | 6 | module.exports = { 7 | formatTickets: function (tickets, query, workflow) { 8 | let self = this; 9 | 10 | let items = issues 11 | .format(tickets) 12 | .filter(s => new RegExp(':"[^"]*' + query + '[^"]*"', 'i').test(JSON.stringify(s).replace(/\\"/g,''))); 13 | 14 | workflow.addItems(items); 15 | 16 | return workflow.feedback(); 17 | }, 18 | 19 | myTickets: function (bmConfig, query) { 20 | let self = this; 21 | Jira.listAll(bmConfig).then( tickets => { 22 | self.formatTickets(tickets, query, wf); 23 | }).catch(wf.error.bind(wf)); 24 | }, 25 | 26 | users: query => { 27 | Jira.getUsers().then(users => { 28 | wf.addItems( 29 | users 30 | .filter(s => new RegExp(query,'i').test(s.name)) 31 | .map( user => { 32 | return { 33 | title: user.name, 34 | valid: false, 35 | }; 36 | })); 37 | return wf.feedback(); 38 | }); 39 | }, 40 | 41 | menu: function (query) { 42 | query = query.split(wf._sep).map(s => s.trim()); 43 | if (query.length < 3) { 44 | return wf.actionHandler.handle('mainMenu', query); 45 | } 46 | var search = query.pop() || ''; 47 | var context = query.pop() || ''; 48 | wf.default({ 49 | title: search.trim() ? 'No tickets found matching: ' + search : 'Your ticket queue is empty', 50 | valid: false, 51 | icon: 'inbox.png', 52 | }); 53 | if (/bookmark/.test(context)) { 54 | this.myTickets(context, search); 55 | } 56 | else { 57 | wf.addItems(wf.storage.get(context).filter(s => new RegExp(search, 'i').test(s.wfId))); 58 | wf.feedback(); 59 | } 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /lib/scriptfilter.js: -------------------------------------------------------------------------------- 1 | 2 | // Install npm dependencies if they're missing. 3 | process.on('uncaughtException', function (error) { 4 | const sh = require('./alfred-exec'); 5 | const log = require('./alfred-log'); 6 | log(error); 7 | if (error.code === 'MODULE_NOT_FOUND') { 8 | let feedback = '%s'; 9 | console.log(feedback, 'Installing npm dependencies...'); 10 | sh.spawn('npm', ['install'], { detached: true, stdio: 'ignore' }).unref(); 11 | } 12 | }); 13 | 14 | const Jira = require('./jira'); 15 | const Workflow = require('./workflow'); 16 | const list = require('./listTickets'); 17 | const settings = require('./settings'); 18 | const assign = require('./assign'); 19 | const comment = require('./comment'); 20 | const status = require('./status'); 21 | const worklog = require('./worklog'); 22 | const extendedMenu = require('./extendedMenu'); 23 | const create = require('./create'); 24 | const search = require('./search'); 25 | 26 | let wf = new Workflow(); 27 | let actions = wf.actionHandler; 28 | let query = process.argv.slice(2)[0]; 29 | 30 | actions.onAction('tickets', list.menu.bind(list)); 31 | actions.onAction('settings', settings.menu.bind(settings)); 32 | actions.onAction('search', search); 33 | actions.onAction('assign', assign); 34 | actions.onAction('comment', comment); 35 | actions.onAction('status', status); 36 | actions.onAction('in-progress', worklog.inProgress.bind(worklog)); 37 | actions.onAction('create', create); 38 | 39 | actions.onAction('mainMenu', query => { 40 | query = query.trim(); 41 | let search_item = { title: 'Search Jira', valid: false, autocomplete: wf.path('search', query), icon: 'search.png' }; 42 | if (Jira.checkConfig()) { 43 | // Kick off background process 44 | Jira.fetchData(); 45 | wf.default(search_item); 46 | 47 | // Include "in-progress" tickets for easy access 48 | let inProgress = Jira.listInProgress(); 49 | if (inProgress) { 50 | wf.addItems(inProgress 51 | .map(issue => { 52 | return { 53 | title: issue.id + ' (In Progress)', 54 | subtitle: issue.runTime, 55 | valid: false, 56 | autocomplete: wf.path('in-progress', issue.id), 57 | projectIcon: issue.id.replace(/-.*$/, '') + '.png', 58 | cmdMod: { 59 | subtitle: 'Stop progress without logging time', 60 | arg: `clearProgress ${issue.id}`, 61 | }, 62 | altMod: { 63 | subtitle: 'Open issue in browser', 64 | arg: `openIssue ${issue.id}`, 65 | }, 66 | }; 67 | })); 68 | } 69 | 70 | let bookmarks = Jira.getBookmarks().map((s,p) => { 71 | return { 72 | title: s.name, 73 | valid: false, 74 | autocomplete: wf.path(`bookmark-${p} `), 75 | bookmarkIcon: s.icon || 'bookmark.png', 76 | }; 77 | }); 78 | 79 | let createIssue = { 80 | title: 'Create a New Issue', 81 | icon: 'edit.png', 82 | valid: false, 83 | autocomplete: wf.path('create'), 84 | }; 85 | 86 | wf.addItems(bookmarks.concat([ 87 | createIssue, 88 | search_item, 89 | { title: 'Settings', valid: false, autocomplete: wf.path('settings'), icon: 'config.png' }, 90 | ])); 91 | 92 | // Before performing a search, 93 | // check the user's bookmarks for the query 94 | if (query) { 95 | wf.items = []; 96 | Jira.getAllBookmarks() 97 | .then(vals => { 98 | let result = {}; 99 | [].concat(...vals).forEach( s => result[s.Key] = s); 100 | list.formatTickets(Object.values(result), query, wf); 101 | }).catch( err => wf.error(err, 'Check the syntax of your bookmarks.')); 102 | } 103 | else { 104 | wf.feedback(); 105 | wf.storage.clear(); 106 | } 107 | } 108 | else { 109 | wf.addItem({ 110 | title: 'Login', 111 | valid: true, 112 | arg: 'login', 113 | icon: 'login.png', 114 | }); 115 | wf.feedback(); 116 | } 117 | }); 118 | 119 | switch(true) { 120 | case extendedMenu.reserved(query): 121 | break; 122 | case /tickets/.test(query): 123 | case /bookmark-\d+/.test(query): 124 | actions.handle('tickets', query); 125 | break; 126 | case /search/.test(query): 127 | actions.handle('search', query); 128 | break; 129 | case /settings/.test(query): 130 | actions.handle('settings', query); 131 | break; 132 | case /create/.test(query): 133 | actions.handle('create', query); 134 | break; 135 | case /assign/.test(query): 136 | actions.handle('assign', query); 137 | break; 138 | case /comment/.test(query): 139 | actions.handle('comment', query); 140 | break; 141 | case /status/.test(query): 142 | actions.handle('status', query); 143 | break; 144 | case /in-progress/.test(query): 145 | actions.handle('in-progress', query); 146 | break; 147 | default: 148 | actions.handle('mainMenu', query); 149 | }; 150 | -------------------------------------------------------------------------------- /lib/search.js: -------------------------------------------------------------------------------- 1 | var Jira = require('./jira'); 2 | var Workflow = require('./workflow'); 3 | var log = require('./alfred-log'); 4 | var issues = require('./issues'); 5 | 6 | var wf = new Workflow(); 7 | var actions = wf.actionHandler; 8 | let storage = wf.storage; 9 | const Trim = str => str ? str.trim() : ''; 10 | 11 | const Search = { 12 | context: null, 13 | search: '', 14 | items: [], 15 | default: null, 16 | 17 | feedback: function () { 18 | wf.default(this.default); 19 | wf.addItems(this.items); 20 | wf.feedback(); 21 | }, 22 | 23 | mainMenu: function() { 24 | let items = [{ 25 | title: `Search Jira for: "${this.search}"`, 26 | autocomplete: wf.path('search', this.search), 27 | valid: false, 28 | icon: 'search.png', 29 | }]; 30 | 31 | this.items = items; 32 | this.feedback(); 33 | }, 34 | 35 | searchJira: function() { 36 | let self = this; 37 | Jira.search(self.context) 38 | .then(res => { 39 | storage.set('search-' + self.context, issues.format(res)); 40 | self.searchResults(); 41 | }); 42 | }, 43 | 44 | searchResults: function() { 45 | let autocomplete = this.search ? wf.path('search', this.context) : wf.path('search'); 46 | this.default = { 47 | title: `No issues found for "${this.context}"`, 48 | valid: false, 49 | autocomplete: autocomplete, 50 | icon: 'warning.png', 51 | }; 52 | 53 | let cache = storage.get('search-' + this.context); 54 | 55 | if (!cache) { 56 | return this.searchJira(); 57 | } 58 | 59 | this.items = cache.filter(result => 60 | new RegExp(':"[^"]*' + this.search + '[^"]*"', 'i') 61 | .test(JSON.stringify(result).replace(/\\"/g,'')) 62 | ); 63 | 64 | this.feedback(); 65 | }, 66 | 67 | run: function (query) { 68 | query = query.split(wf._sep); 69 | 70 | // Allows user to search for issues containing "login problem" 71 | // by typing "jira search login problem" in Alfred. 72 | if (query.length == 1) { 73 | query = query[0].match(/(\bsearch\b)(.*)/) || []; 74 | } 75 | 76 | let [context, search] = query.map(Trim).slice(-2); 77 | 78 | this.context = context || ''; 79 | this.search = search || ''; 80 | 81 | // If there is not context just pass control back to the mainMenu. 82 | if (!this.context) { 83 | return actions.handle('mainMenu', query.join(wf._sep)); 84 | } 85 | 86 | switch (this.context) { 87 | case 'search': 88 | actions.onAction('main', this.mainMenu.bind(this)); 89 | actions.handle('main'); 90 | break; 91 | 92 | default: 93 | actions.onAction('searchJira', this.searchResults.bind(this)); 94 | actions.handle('searchJira'); 95 | } 96 | }, 97 | 98 | }; 99 | 100 | module.exports = function (query) { 101 | Search.run(query); 102 | }; -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const sh = require('./alfred-exec'); 3 | const Jira = require('./jira'); 4 | const cache = require('./jira/cache'); 5 | const Workflow = require('./workflow'); 6 | 7 | let wf = new Workflow(); 8 | 9 | function validateRepo() { 10 | let gitDir = process.cwd() + '/.git', 11 | exists = fs.existsSync(gitDir); 12 | if (exists) { 13 | let pushUrl = sh.execSync('grep -A1 \'"origin"\' "' + gitDir 14 | + '/config" | tail -n 1 | awk \'{ print $3 }\'').toString(); 15 | 16 | if (!/steyep/.test(pushUrl)) { 17 | sh.execSync('git remote set-url origin git@github.com:steyep/alfred-jira.git'); 18 | sh.execSync('git remote set-url --push origin ' + pushUrl); 19 | } 20 | } 21 | else { 22 | return false; 23 | } 24 | return true; 25 | } 26 | 27 | module.exports = { 28 | checkUpdates: function() { 29 | return new Promise((resolve, reject) => { 30 | if (!validateRepo()) { 31 | wf.error('Unable to check for updates', 'No git repo found', true); 32 | reject('Unable to validate git repo'); 33 | } 34 | // If we already know an update is available, 35 | // or if the cache hasn't expired since we last checked, 36 | // don't check again. 37 | let cacheLimit = 24*60*60*1000; // 1 day 38 | let updates = cache.get('update'); 39 | if (cache.get('update', cacheLimit) === undefined && !updates) { 40 | try { 41 | sh.execSync('git fetch origin'); 42 | } 43 | catch (e) { 44 | if (/Permission denied \(publickey\)/.test(e)) { 45 | // Use https instead. 46 | sh.execSync('git remote set-url origin https://github.com/steyep/alfred-jira.git'); 47 | } 48 | } 49 | } 50 | let status = sh.execSync('git status').toString(); 51 | updates = !/up-to-date/.test(status); 52 | cache.set('update', updates); 53 | resolve(updates); 54 | }); 55 | }, 56 | 57 | menu: function(query) { 58 | this.checkUpdates().then(updatesAvailable => { 59 | wf.addItems([{ 60 | title: updatesAvailable ? 'Update available' : 'Workflow is up-to-date', 61 | subtitle: 'Last checked: ' + cache.lastChecked('update'), 62 | valid: updatesAvailable, 63 | arg: 'update', 64 | icon: updatesAvailable ? 'update.png' : 'good.png', 65 | }, 66 | { title: 'Edit Settings', icon: 'edit.png', valid: true, arg: 'editSettings' }, 67 | { title: 'Clear cache', icon: 'delete.png', valid: true, arg: 'clearCache' }, 68 | ]); 69 | wf.feedback(); 70 | }) 71 | .catch(() => { 72 | wf.error('Unable to check for updates', 'No git repo found', true); 73 | wf.feedback(); 74 | }); 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /lib/status.js: -------------------------------------------------------------------------------- 1 | var Jira = require('./jira'); 2 | var Workflow = require('./workflow'); 3 | var wf = new Workflow(); 4 | 5 | module.exports = query => { 6 | query = query.split(wf._sep).map(s => s.trim()); 7 | if (query.length < 3) { 8 | return wf.actionHandler.handle('mainMenu', query); 9 | } 10 | let search = query.pop() || ''; 11 | let context = query.pop() || ''; 12 | let data = wf.storage.get(context + '-status'); 13 | let key = data._key.replace('-status', ''); 14 | 15 | Jira.status(key).then( transitions => { 16 | wf.addItems(transitions 17 | .filter( s => new RegExp(search, 'i').test(s.name)) 18 | .map( transition => { 19 | return { 20 | title: transition.name, 21 | valid: true, 22 | autocomplete: wf.path(...query, context) + transition.name, 23 | icon: transition.category + '.png', 24 | arg: ['transition', transition.ticketId, transition.action, transition.token].join(' '), 25 | }; 26 | })); 27 | wf.feedback(); 28 | }) 29 | .catch(wf.error.bind(wf)); 30 | }; -------------------------------------------------------------------------------- /lib/workflow.js: -------------------------------------------------------------------------------- 1 | const builder = require('xmlbuilder'); 2 | const fs = require('fs'); 3 | const config = require('./jira/config'); 4 | const log = require('./alfred-log'); 5 | const alfredVersion = parseInt(process.env.alfred_version); 6 | 7 | let ActionHandler = (() => { 8 | const events = require('events'); 9 | const eventEmitter = new events.EventEmitter(); 10 | return { 11 | onAction: (action, handler) => { 12 | if (!handler) { 13 | return; 14 | } 15 | eventEmitter.on('action-' + action, handler); 16 | }, 17 | 18 | handle: (action, query) => { 19 | eventEmitter.emit('action-' + action, query); 20 | }, 21 | }; 22 | })(); 23 | 24 | let Storage = (function(){ 25 | const storage = require('node-persist'); 26 | storage.initSync({ 27 | dir: config.cfgPath, 28 | }); 29 | 30 | return { 31 | set: (key, value) => { 32 | return storage.setItemSync(key, value); 33 | }, 34 | get: function(key) { 35 | let data = storage.getItemSync(key); 36 | return data; 37 | }, 38 | clear: () => storage.clear(), 39 | }; 40 | })(); 41 | 42 | let Icon = (icon, def) => { 43 | return fs.existsSync(icon) ? icon : def; 44 | }; 45 | 46 | function Item(settings) { 47 | if (!settings || typeof settings != 'object') { 48 | let settings = {}; 49 | } 50 | 51 | let icon = settings.icon ? Icon(config.iconPath + settings.icon, null) : null; 52 | icon = settings.projectIcon ? Icon(config.projectIconPath + settings.projectIcon, config.iconPath + 'default.png') : icon; 53 | icon = settings.userIcon ? Icon(config.userIconPath + settings.userIcon, config.iconPath + 'assigned.png') : icon; 54 | icon = settings.priorityIcon ? Icon(config.priorityIconPath + settings.priorityIcon, config.iconPath + 'priority.png') : icon; 55 | icon = settings.bookmarkIcon ? Icon(settings.bookmarkIcon, config.iconPath + 'bookmark.png') : icon; 56 | 57 | let mods = []; 58 | if (alfredVersion > 2) { 59 | for (let modKey of ['ctrl','alt','cmd','fn','shift']) { 60 | let modConfig = settings[`${modKey}Mod`]; 61 | if (modConfig && typeof modConfig == 'object') { 62 | let mod = { 63 | '@key': modKey, 64 | '@valid': modConfig.valid !== false ? 'yes' : 'no', 65 | }; 66 | if (modConfig.subtitle) { 67 | mod['@subtitle'] = modConfig.subtitle; 68 | } 69 | if (modConfig.arg) { 70 | mod['@arg'] = modConfig.arg; 71 | } 72 | mods.push(mod); 73 | } 74 | } 75 | } 76 | 77 | this.item = { 78 | '@valid': settings.valid !== false ? 'yes' : 'no', 79 | '@autocomplete': settings.autocomplete !== undefined ? settings.autocomplete : null, 80 | '@arg': settings.arg || null, 81 | '@uid': settings.uid || null, 82 | 'title': settings.title || null, 83 | 'subtitle': settings.subtitle || null, 84 | 'wfId': settings.wfId || null, 85 | 'icon': icon, 86 | 'mod': mods.length ? mods : null, 87 | }; 88 | if (!fs.existsSync(this.item.icon)) { 89 | this.item.icon = null; 90 | } 91 | let item = this.item; 92 | for (let i in item) { 93 | if (item[i] === null) { 94 | delete item[i]; 95 | } 96 | } 97 | } 98 | 99 | let Workflow = function() { 100 | return { 101 | items: [], 102 | defaultItem: null, 103 | actionHandler: ActionHandler, 104 | storage: Storage, 105 | _sep: '► ', 106 | enableBack: true, 107 | 108 | 'path': function (...args) { 109 | let sep = this._sep; 110 | args = args 111 | .join(sep) 112 | .split(sep) 113 | .filter(String) 114 | .map(s => `${s} `); 115 | let path = ['', ...args, ''].join(sep); 116 | return path; 117 | }, 118 | 119 | 'addItem': function(settings){ 120 | settings = settings || {}; 121 | let key = settings.title || null, 122 | data = settings.data || null; 123 | if (data && typeof data === 'object') { 124 | key = data['_key'] || key; 125 | } 126 | if (key && data) { 127 | this.storage.set(key, data); 128 | } 129 | this.items.push(new Item(settings)); 130 | }, 131 | 132 | 'addItems': function(array) { 133 | for (let item in array) { 134 | this.addItem(array[item]); 135 | } 136 | }, 137 | 138 | 'default': function(settings){ 139 | if (settings) { 140 | this.defaultItem = new Item(settings); 141 | } 142 | }, 143 | 144 | 'error': function(title, subtitle, suppressFeedback) { 145 | this.addItem({ 146 | title: title, 147 | subtitle: subtitle, 148 | valid: false, 149 | icon: 'warning', 150 | }); 151 | if (!suppressFeedback) { 152 | this.feedback(); 153 | } 154 | }, 155 | 156 | 'goBack': function() { 157 | if (this.enableBack) { 158 | let path = (process.argv[2].match(new RegExp('^.+' + this._sep, 'i')) || [''])[0]; 159 | let paths = this.storage.get('paths') || []; 160 | 161 | if (!path) { 162 | paths = []; 163 | } 164 | else { 165 | paths.push(path); 166 | } 167 | 168 | let index = paths.findIndex(s => s.replace(/ /g, '') == path.replace(/ /g, '')); 169 | if (index > -1 && index != paths.length - 1) { 170 | paths = paths.splice(0, index + 1); 171 | } 172 | 173 | this.storage.set('paths', paths); 174 | var backPath = paths[paths.length - 2]; 175 | 176 | if (path.trim()) { 177 | return new Item({ 178 | title: 'Back', 179 | autocomplete: backPath || '', 180 | valid: false, 181 | icon: 'back.png', 182 | }); 183 | } 184 | 185 | } 186 | return null; 187 | }, 188 | 189 | 'feedback': function() { 190 | if (!this.items.length && this.defaultItem) { 191 | this.items.push(this.defaultItem); 192 | } 193 | 194 | if (goback = this.goBack()) { 195 | this.items.push(goback); 196 | } 197 | 198 | let root = builder.create('items'); 199 | for (let i in this.items) { 200 | let item = root.ele(this.items[i]); 201 | } 202 | console.log(root.end({pretty:true})); 203 | }, 204 | }; 205 | }; 206 | 207 | module.exports = Workflow; 208 | -------------------------------------------------------------------------------- /lib/worklog.js: -------------------------------------------------------------------------------- 1 | const Jira = require('./jira'); 2 | const Workflow = require('./workflow'); 3 | const issues = require('./issues'); 4 | 5 | let wf = new Workflow(); 6 | 7 | module.exports = { 8 | inProgress: function(query) { 9 | query = query.split(wf._sep).map(s => s.trim()); 10 | let search = query.pop() || ''; 11 | let ticket = query.pop() || ''; 12 | let self = this; 13 | Jira.inProgressInfo(ticket).then(res => { 14 | wf.addItems( 15 | issues.getTicket(res) 16 | .filter(issue => new RegExp(search, 'i').test(issue.wfId))); 17 | wf.feedback(); 18 | }).catch(wf.error.bind(wf)); 19 | }, 20 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alfred-jira", 3 | "version": "1.0.11", 4 | "description": "An Alfred workflow to interact with Jira", 5 | "main": "./app/main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "sh ./bin/install.sh", 9 | "electron": "./node_modules/.bin/electron ." 10 | }, 11 | "author": "Stephen Pennell (https://github.com/steyep)", 12 | "license": "ISC", 13 | "engines": { 14 | "node": ">=6.0.0" 15 | }, 16 | "os": [ 17 | "darwin" 18 | ], 19 | "dependencies": { 20 | "angular": "1.7.9", 21 | "axios": "0.19.2", 22 | "electron": "1.8.8", 23 | "font-awesome": "4.7.0", 24 | "moment": "2.24.0", 25 | "node-persist": "1.0.1", 26 | "xmlbuilder": "8.2.2" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "steyep/alfred-jira" 31 | }, 32 | "devDependencies": { 33 | "eslint": "6.8.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/demo.gif -------------------------------------------------------------------------------- /resources/icons/assigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/assigned.png -------------------------------------------------------------------------------- /resources/icons/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/back.png -------------------------------------------------------------------------------- /resources/icons/bookmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/bookmark.png -------------------------------------------------------------------------------- /resources/icons/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/comment.png -------------------------------------------------------------------------------- /resources/icons/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/config.png -------------------------------------------------------------------------------- /resources/icons/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/default.png -------------------------------------------------------------------------------- /resources/icons/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/delete.png -------------------------------------------------------------------------------- /resources/icons/description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/description.png -------------------------------------------------------------------------------- /resources/icons/done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/done.png -------------------------------------------------------------------------------- /resources/icons/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/edit.png -------------------------------------------------------------------------------- /resources/icons/good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/good.png -------------------------------------------------------------------------------- /resources/icons/in progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/in progress.png -------------------------------------------------------------------------------- /resources/icons/inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/inbox.png -------------------------------------------------------------------------------- /resources/icons/label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/label.png -------------------------------------------------------------------------------- /resources/icons/labeloutline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/labeloutline.png -------------------------------------------------------------------------------- /resources/icons/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/login.png -------------------------------------------------------------------------------- /resources/icons/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/play.png -------------------------------------------------------------------------------- /resources/icons/priority.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/priority.png -------------------------------------------------------------------------------- /resources/icons/restart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/restart.png -------------------------------------------------------------------------------- /resources/icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/search.png -------------------------------------------------------------------------------- /resources/icons/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/stop.png -------------------------------------------------------------------------------- /resources/icons/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/title.png -------------------------------------------------------------------------------- /resources/icons/to do.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/to do.png -------------------------------------------------------------------------------- /resources/icons/unwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/unwatch.png -------------------------------------------------------------------------------- /resources/icons/update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/update.png -------------------------------------------------------------------------------- /resources/icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/warning.png -------------------------------------------------------------------------------- /resources/icons/watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/watch.png -------------------------------------------------------------------------------- /resources/icons/watched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steyep/alfred-jira/75f39fbfd36affb7a94ffc5dd1b57036db1bc552/resources/icons/watched.png --------------------------------------------------------------------------------