477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
// (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();
|