diff --git a/package.json b/package.json
index 4e5a0d3..41e01f6 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
}
},
"dependencies": {
+ "axios": "^0.17.1",
"cron": "^1.3.0",
"d3-timelines": "^1.3.1",
"desktop-idle": "^1.1.1",
diff --git a/src/index.js b/src/index.js
index c9cdcd9..a94e70d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,6 +6,7 @@ import Registry from './registry';
import DB from './db';
import Env from './env';
import RedmineProvider from './remote/redmine';
+import JiraProvider from './remote/jira';
import IdleMonitor from './idle-monitor';
import Notifier from './notifier';
import Cron from './cron';
@@ -51,6 +52,22 @@ if (Env.isDebug()) {
}
})();
+const createProviderSyncCommand = (providerName, createProvider) => async (...args) => {
+ const provider = createProvider(...args);
+
+ if (!cron.exists('jira')) {
+ cron.add(
+ 'jira',
+ registry.config().get('remoteSyncCron'),
+ () => provider.synchronize().then(() => provider.report(registry.config().get('minLogTime'))),
+ true,
+ );
+ }
+
+ await provider.synchronize();
+ await provider.report(registry.config().get('minLogTime'));
+};
+
registry
.register('logger', logger)
.register('events', events)
@@ -61,21 +78,14 @@ registry
app.dock.setBadge(text);
}
})
- .register('synchronizeRedmine', async (host, apiKey) => {
- const redmine = new RedmineProvider(db, { host, apiKey });
-
- if (!cron.exists('redmine')) {
- cron.add(
- 'redmine',
- registry.config().get('remoteSyncCron'),
- () => redmine.synchronize().then(() => redmine.report(registry.config().get('minLogTime'))),
- true,
- );
- }
-
- await redmine.synchronize();
- await redmine.report(registry.config().get('minLogTime'));
- })
+ .register('synchronizeRedmine', createProviderSyncCommand(
+ 'jira',
+ (host, apiKey) => new RedmineProvider(db, { host, apiKey }),
+ ))
+ .register('synchronizeJira', createProviderSyncCommand(
+ 'jira',
+ (host, username, password) => new JiraProvider(db, { host, username, password }),
+ ))
.register('notify', Notifier.notify)
.register('openReport', async (projectId) => {
registry.config().set('lastReportProjectId', projectId);
diff --git a/src/invariant.js b/src/invariant.js
new file mode 100644
index 0000000..34ab7b9
--- /dev/null
+++ b/src/invariant.js
@@ -0,0 +1,7 @@
+export default function invariant(condition, message, ...args) {
+ if (!condition) {
+ let idx = 0;
+
+ throw new Error(message.replace(/%s/g, () => args[idx++])); // eslint-disable-line no-plusplus
+ }
+}
diff --git a/src/remote/jira.js b/src/remote/jira.js
new file mode 100644
index 0000000..6930a82
--- /dev/null
+++ b/src/remote/jira.js
@@ -0,0 +1,64 @@
+import url from 'url';
+import AbstractProvider from './abstract-provider';
+import Logger from '../logger';
+import JiraClient from './rest/client/jira-client';
+import BasicAuthProvider from './rest/auth/basic-auth-provider';
+
+export default class JiraProvider extends AbstractProvider {
+ setupClient() {
+ Logger.debug(`Connect to Jira on ${this.options.host}`);
+
+ JiraClient.updateOptions({
+ authProvider: new BasicAuthProvider(
+ this.options.username,
+ this.options.password,
+ ),
+ baseURL: this.options.host,
+ });
+ }
+
+ generateId(issue) { // eslint-disable-line
+ return `JIRA-${issue.id}`;
+ }
+
+ generateLink(issue) { // eslint-disable-line
+ return url.resolve(this.options.host, `/browse/${issue.key}`);
+ }
+
+ generateName(issue) { // eslint-disable-line
+ return `[${issue.key}] ${issue.fields.summary}`;
+ }
+
+ async synchronize() {
+ this.setupClient();
+
+ const response = await JiraClient.search({
+ jql: 'assignee=currentuser() AND status!=closed',
+ });
+
+ const itemsToLookup = response.data.issues.map(issue => ({
+ projectId: issue.key,
+ name: this.generateName(issue),
+ issueId: this.generateId(issue),
+ link: this.generateLink(issue),
+ }));
+
+ itemsToLookup.forEach((item) => {
+ const { issueId } = item;
+
+ const existingItem = this.db.findOne({ issueId });
+
+ if (!existingItem) {
+ Logger.info(`Add new Jira issue: ${item.name} #${issueId}`);
+
+ this.db.save(item);
+ }
+ });
+ }
+
+ async report(minLogTime = 0) { // eslint-disable-line
+ Logger.info(`JiraProvider::report(${minLogTime})`);
+
+ return Promise.resolve();
+ }
+}
diff --git a/src/remote/rest/auth/anon-provider.js b/src/remote/rest/auth/anon-provider.js
new file mode 100644
index 0000000..ef726ed
--- /dev/null
+++ b/src/remote/rest/auth/anon-provider.js
@@ -0,0 +1,5 @@
+export default class AnonProvider {
+ sign(requestConfig) { // eslint-disable-line
+ return requestConfig;
+ }
+}
diff --git a/src/remote/rest/auth/basic-auth-provider.js b/src/remote/rest/auth/basic-auth-provider.js
new file mode 100644
index 0000000..2139a8e
--- /dev/null
+++ b/src/remote/rest/auth/basic-auth-provider.js
@@ -0,0 +1,16 @@
+export default class BasicAuthProvider {
+ constructor(username, password) {
+ this.username = username;
+ this.password = password;
+ }
+
+ sign(requestConfig) {
+ const signedRequestConfig = Object.assign({}, requestConfig);
+
+ signedRequestConfig.headers = Object.assign(signedRequestConfig.headers || {}, {
+ Authorization: `Basic ${new Buffer(`${this.username}:${this.password}`).toString('base64')}`,
+ });
+
+ return signedRequestConfig;
+ }
+}
diff --git a/src/remote/rest/client/jira-client.js b/src/remote/rest/client/jira-client.js
new file mode 100644
index 0000000..4102729
--- /dev/null
+++ b/src/remote/rest/client/jira-client.js
@@ -0,0 +1,28 @@
+import RestClient from './rest-client';
+
+/**
+ * @callback ApiMethod
+ * @param {Object=} payload
+ * @returns {Promise<{status: Number, headers: Object, data: Object}>}
+ */
+
+/**
+ * @typedef {RestClient} JiraRestClient
+ *
+ * @property {ApiMethod} getIssue
+ * @property {ApiMethod} search
+ *
+ * @type {JiraRestClient}
+ */
+const JiraClient = new RestClient({
+ getIssue: {
+ method: 'GET',
+ path: 'rest/api/2/issue/{issueId}',
+ },
+ search: {
+ method: 'GET',
+ path: '/rest/api/2/search',
+ },
+});
+
+export default JiraClient;
diff --git a/src/remote/rest/client/rest-client.js b/src/remote/rest/client/rest-client.js
new file mode 100644
index 0000000..50bc871
--- /dev/null
+++ b/src/remote/rest/client/rest-client.js
@@ -0,0 +1,72 @@
+import axios from 'axios';
+import invariant from '../../../invariant';
+import AnonProvider from '../auth/anon-provider';
+
+export default class RestClient {
+ constructor(methodsMap = {}, options = {}) {
+ this.options = options;
+ this.options.authProvider = Object.assign(RestClient.defaults, options);
+
+ this.createApiMethods(methodsMap);
+ }
+
+ // @TODO: add options schema
+ updateOptions(newOptions) {
+ this.options = Object.assign(this.options, newOptions);
+ }
+
+ createApiMethods(methodsMap) {
+ Object.keys(methodsMap).forEach((methodName) => {
+ const methodDefinition = methodsMap[methodName];
+
+ this[methodName] = this.createApiMethod(methodDefinition);
+ });
+ }
+
+ createApiMethod(methodDefinition) {
+ return (payload = {}, overrideOptions = {}) => {
+ const options = this.options;
+ const requestConfig = Object.assign({
+ baseURL: options.baseURL,
+ method: methodDefinition.method,
+ adapter: methodDefinition.adapter,
+ responseType: 'json',
+ url: this.buildUrl(methodDefinition.path, payload),
+ [this.getParametersSendType(methodDefinition)]: payload,
+ }, overrideOptions);
+
+ invariant(options.baseURL, 'You should configure API baseURL before making any api calls');
+ invariant(options.authProvider, 'Missing authentication provider');
+
+ const signedRequestConfig = this.options.authProvider.sign(requestConfig);
+
+ return axios.request(signedRequestConfig);
+ };
+ }
+
+ buildUrl(urlTemplate, params = {}) { // eslint-disable-line
+ return urlTemplate.replace(/{\s*([^/]+)\s*}/g, (match, paramName) => {
+ invariant(
+ paramName in params,
+ 'Missing "%s" parameter. Cannot build "%s" url template',
+ paramName,
+ urlTemplate,
+ );
+
+ const paramValue = params[paramName];
+ delete params[paramName]; // eslint-disable-line no-param-reassign
+
+ return paramValue.toString();
+ });
+ }
+
+ getParametersSendType(endpointDefinition) { // eslint-disable-line
+ return ['GET', 'HEAD'].includes(endpointDefinition.method.toUpperCase()) ? 'params' : 'data';
+ }
+
+ static get defaults() {
+ return {
+ authProvider: new AnonProvider(),
+ };
+ }
+}
diff --git a/src/view/component/projects.vue b/src/view/component/projects.vue
index 20e98e4..f06cd92 100644
--- a/src/view/component/projects.vue
+++ b/src/view/component/projects.vue
@@ -44,7 +44,11 @@
bug_report
- Integrate Redmine
+ Redmine
+
+
+ bug_report
+ Jira
@@ -70,6 +74,32 @@
+
+ Jira Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+ Synchronize
+
+
+
Add a Project
@@ -109,6 +139,7 @@ import validUrl from 'valid-url';
const PROJECT_SELECTED = 'property-selected';
const REDMINE = { HOST: 'redmine_host', API_KEY: 'redmine_apiKey' };
+const JIRA = { HOST: 'jira_host', USERNAME: 'jira_username', PASSWORD: 'jira_password' };
export default {
PROJECT_SELECTED,
@@ -119,6 +150,10 @@ export default {
redmineDialog: false,
redmineHost: null,
redmineApiKey: null,
+ jiraDialog: false,
+ jiraHost: null,
+ jiraUsername: null,
+ jiraPassword: null,
newProjectDialog: false,
newProjectName: null,
newProjectLink: null,
@@ -181,6 +216,39 @@ export default {
}
}
},
+
+ // TODO: make separate component for synchronization
+ async syncJira (readConfig = false) {
+ if (readConfig) {
+ this.jiraHost = this.$registry.config().get(JIRA.HOST);
+ this.jiraUsername = this.$registry.config().get(JIRA.USERNAME);
+ this.jiraPassword = this.$registry.config().get(JIRA.PASSWORD);
+ }
+
+ this.$registry.get('logger').info('Trigger Jira synchronization');
+
+ this.$registry.config().set(this.jiraHost, JIRA.HOST);
+ this.$registry.config().set(this.jiraUsername, JIRA.USERNAME);
+ this.$registry.config().set(this.jiraPassword, JIRA.PASSWORD);
+
+ try {
+ await this.$registry.get('synchronizeJira')(this.jiraHost, this.jiraUsername, this.jiraPassword);
+ this.refresh();
+
+ this.$registry.get('notify')(
+ 'TiTime - Jira Integration.',
+ `We've successfully synchronized w/ Jira (${ this.jiraHost }).`
+ );
+ } catch (error) {
+ this.$registry.get('logger').error(error.message);
+
+ this.$registry.get('notify')(
+ 'TiTime - Jira Integration.',
+ `Failed to synchronized w/ Jira (${ this.jiraHost }). Error: ${ error.message }`
+ );
+ }
+ },
+
validUrl (val) {
return validUrl.isUri(val);
},