├── .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 | 
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 |
11 |
12 |
13 |
{{ appName }}
14 |
v. {{ version }}
15 |
16 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
34 |
35 |
36 |
37 |
83 |
84 |
85 |
86 |
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
--------------------------------------------------------------------------------