// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. const exec = require('child_process').exec; const https = require('https'); const inquirer = require('inquirer'); const fs = require('fs'); const request = require('request'); // This lib is deprecated, but it was the only way I found to make upload files work. const DevConfig = require('./dev-config'); const Git = require('./git'); const Url = require('./url'); const Utils = require('./utils'); const apiVersion = 2; /** * Class to interact with Jira. */ class Jira { /** * Ask the password to the user. * * @return Promise resolved with the password. */ async askPassword() { const data = await inquirer.prompt([ { type: 'password', name: 'password', message: `Please enter the password for the username ${this.username}.`, }, ]); return data.password; } /** * Ask the user the tracker data. * * @return Promise resolved with the data, rejected if cannot get. */ async askTrackerData() { const data = await inquirer.prompt([ { type: 'input', name: 'url', message: 'Please enter the tracker URL.', default: 'https://tracker.moodle.org/', }, { type: 'input', name: 'username', message: 'Please enter your tracker username.', }, ]); DevConfig.save({ 'tracker.url': data.url, 'tracker.username': data.username, }); return data; } /** * Build URL to perform requests to Jira. * * @param uri URI to add the the Jira URL. * @return URL. */ buildRequestUrl(uri) { return Utils.concatenatePaths([this.url, this.uri, '/rest/api/', apiVersion, uri]); } /** * Delete an attachment. * * @param attachmentId Attachment ID. * @return Promise resolved when done. */ async deleteAttachment(attachmentId) { const response = await this.request(`attachment/${attachmentId}`, 'DELETE'); if (response.status != 204) { throw new Error('Could not delete the attachment'); } } /** * Load the issue info from jira server using a REST API call. * * @param key Key to identify the issue. E.g. MOBILE-1234. * @param fields Fields to get. * @return Promise resolved with the issue data. */ async getIssue(key, fields) { fields = fields || '*all,-comment'; await this.init(); // Initialize data if needed. const response = await this.request(`issue/${key}`, 'GET', {'fields': fields, 'expand': 'names'}); if (response.status == 404) { throw new Error('Issue could not be found.'); } else if (response.status != 200) { throw new Error('The tracker is not available.') } const issue = response.data; issue.named = {}; // Populate the named fields in a separate key. Allows us to easily find them without knowing the field ID. const nameList = issue.names || {}; for (const fieldKey in issue.fields) { if (nameList[fieldKey]) { issue.named[nameList[fieldKey]] = issue.fields[fieldKey]; } } return issue } /** * Load the version info from the jira server using a rest api call. * * @return Promise resolved when done. */ async getServerInfo() { const response = await this.request('serverInfo'); if (response.status != 200) { throw new Error(`Unexpected response code: ${response.status}`, response); } this.version = response.data; } /** * Get tracker data to push an issue. * * @return Promise resolved with the data. */ async getTrackerData() { // Check dev-config file first. let data = this.getTrackerDataFromDevConfig(); if (data) { console.log('Using tracker data from dev-config file'); return data; } // Try to use mdk now. try { data = await this.getTrackerDataFromMdk(); console.log('Using tracker data from mdk'); return data; } catch (error) { // MDK not available or not configured. Ask for the data. const trackerData = await this.askTrackerData(); trackerData.fromInput = true; return trackerData; } } /** * Get tracker data from dev config file. * * @return Data, undefined if cannot get. */ getTrackerDataFromDevConfig() { const url = DevConfig.get('tracker.url'); const username = DevConfig.get('tracker.username'); if (url && username) { return { url, username, }; } } /** * Get tracker URL and username from mdk. * * @return Promise resolved with the data, rejected if cannot get. */ getTrackerDataFromMdk() { return new Promise((resolve, reject) => { exec('mdk config show tracker.url', (err, url) => { if (!url) { reject(err || 'URL not found.'); return; } exec('mdk config show tracker.username', (error, username) => { if (username) { resolve({ url: url.replace('\n', ''), username: username.replace('\n', ''), }); } else { reject(error || 'Username not found.'); } }); }); }); } /** * Initialize some data. * * @return Promise resolved when done. */ async init() { if (this.initialized) { // Already initialized. return; } // Get tracker URL and username. let trackerData = await this.getTrackerData(); this.url = trackerData.url; this.username = trackerData.username; const parsed = Url.parse(this.url); this.ssl = parsed.protocol == 'https'; this.host = parsed.domain; this.uri = parsed.path; // Get the password. const keytar = require('keytar'); this.password = await keytar.getPassword('mdk-jira-password', this.username); // Use same service name as mdk. if (!this.password) { // Ask the user. this.password = await this.askPassword(); } while (!this.initialized) { try { await this.getServerInfo(); this.initialized = true; keytar.setPassword('mdk-jira-password', this.username, this.password); } catch (error) { console.log('Error connecting to the server. Please make sure you entered the data correctly.', error); if (trackerData.fromInput) { // User entered the data manually, ask him again. trackerData = await this.askTrackerData(); this.url = trackerData.url; this.username = trackerData.username; } this.password = await this.askPassword(); } } } /** * Check if a certain issue could be a security issue. * * @param key Key to identify the issue. E.g. MOBILE-1234. * @return Promise resolved with boolean: whether it's a security issue. */ async isSecurityIssue(key) { const issue = await this.getIssue(key, 'security'); return issue.fields && !!issue.fields.security; } /** * Sends a request to the server and returns the data. * * @param uri URI to add the the Jira URL. * @param method Method to use. Defaults to 'GET'. * @param params Params to send as GET params (in the URL). * @param data JSON string with the data to send as POST/PUT params. * @param headers Headers to send. * @return Promise resolved with the result. */ request(uri, method, params, data, headers) { uri = uri || ''; method = (method || 'GET').toUpperCase(); data = data || ''; params = params || {}; headers = headers || {}; headers['Content-Type'] = 'application/json'; return new Promise((resolve, reject) => { // Build the request URL. const url = Url.addParamsToUrl(this.buildRequestUrl(uri), params); // Initialize the request. const options = { method: method, auth: `${this.username}:${this.password}`, headers: headers, }; const buildRequest = https.request(url, options); // Add data. if (data) { buildRequest.write(data); } // Treat response. buildRequest.on('response', (response) => { // Read the result. let result = ''; response.on('data', (chunk) => { result += chunk; }); response.on('end', () => { try { result = JSON.parse(result); } catch (error) { // Leave it as text. } resolve({ status: response.statusCode, data: result, }); }); }); buildRequest.on('error', (e) => { reject(e); }); // Send the request. buildRequest.end(); }); } /** * Sets a set of fields for a certain issue in Jira. * * @param issueId Key to identify the issue. E.g. MOBILE-1234. * @param updates Object with the fields to update. * @return Promise resolved when done. */ async setCustomFields(issueId, updates) { const issue = await this.getIssue(issueId); const update = {'fields': {}}; // Detect which fields have changed. for (const updateName in updates) { const updateValue = updates[updateName]; const remoteValue = issue.named[updateName]; if (!remoteValue || remoteValue != updateValue) { // Map the label of the field with the field code. let fieldKey; for (const id in issue.names) { if (issue.names[id] == updateName) { fieldKey = id; break; } } if (!fieldKey) { throw new Error(`Could not find the field named ${updateName}.`); } update.fields[fieldKey] = updateValue; } } if (!Object.keys(update.fields).length) { // No fields to update. console.log('No updates required.') return; } const response = await this.request(`issue/${key}`, 'PUT', null, JSON.stringify(update)); if (response.status != 204) { throw new Error(`Issue was not updated: ${response.status}`, response.data); } console.log('Issue updated successfully.'); } /** * Upload a new attachment to an issue. * * @param key Key to identify the issue. E.g. MOBILE-1234. * @param filePath Path to the file to upload. * @return Promise resolved when done. */ async upload(key, filePath) { const uri = `issue/${key}/attachments`; const headers = { 'X-Atlassian-Token': 'nocheck', } const response = await this.uploadFile(uri, 'file', filePath, headers); if (response.status != 200) { throw new Error('Could not upload file to Jira issue'); } console.log('File successfully uploaded.') } /** * Upload a file to Jira. * * @param uri URI to add the the Jira URL. * @param fieldName Name of the form field where to put the file. * @param filePath Path to the file. * @param headers Headers. * @return Promise resolved with the result. */ async uploadFile(uri, fieldName, filePath, headers) { uri = uri || ''; headers = headers || {}; headers['Content-Type'] = 'multipart/form-data'; return new Promise((resolve) => { // Add the file to the form data. const formData = {}; formData[fieldName] = { value: fs.createReadStream(filePath), options: { filename: filePath.substr(filePath.lastIndexOf('/') + 1), contentType: 'multipart/form-data', }, }; // Perform the request. const options = { url: this.buildRequestUrl(uri), method: 'POST', headers: headers, auth: { user: this.username, pass: this.password, }, formData: formData, }; request(options, (_err, httpResponse, body) => { resolve({ status: httpResponse.statusCode, data: body, }); }); }); } } module.exports = new Jira();