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); },