From 05fa5775776cc9696c16739fc915f53d28d86cec Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 27 Jul 2020 10:37:20 +0200 Subject: [PATCH] MOBILE-3501 gulp: Support uploading patches to Jira --- gulp/git.js | 33 ++++++++++++ gulp/jira.js | 128 ++++++++++++++++++++++++++++++++++++++++++---- gulp/task-push.js | 96 ++++++++++++++++++++++++++++------ package-lock.json | 85 ++++++++++++------------------ package.json | 1 + 5 files changed, 266 insertions(+), 77 deletions(-) diff --git a/gulp/git.js b/gulp/git.js index 508112d04..f3f66298a 100644 --- a/gulp/git.js +++ b/gulp/git.js @@ -13,6 +13,7 @@ // limitations under the License. const exec = require('child_process').exec; +const fs = require('fs'); const DevConfig = require('./dev-config'); const Utils = require('./utils'); @@ -21,6 +22,38 @@ const Utils = require('./utils'); */ class Git { + /** + * Create a patch. + * + * @param range Show only commits in the specified revision range. + * @param saveTo Path to the file to save the patch to. If not defined, the patch contents will be returned. + * @return Promise resolved when done. If saveTo not provided, it will return the patch contents. + */ + createPatch(range, saveTo) { + return new Promise((resolve, reject) => { + exec(`git format-patch ${range} --stdout`, (err, result) => { + if (err) { + reject(err || 'Cannot create patch.'); + return; + } + + if (!saveTo) { + resolve(result); + return; + } + + // Save it to a file. + const directory = saveTo.substring(0, saveTo.lastIndexOf('/')); + if (directory && directory != '.' && directory != '..' && !fs.existsSync(directory)) { + fs.mkdirSync(directory); + } + fs.writeFileSync(saveTo, result); + + resolve(); + }); + }); + } + /** * Get current branch. * diff --git a/gulp/jira.js b/gulp/jira.js index 110fd3c27..317fda5df 100644 --- a/gulp/jira.js +++ b/gulp/jira.js @@ -16,6 +16,8 @@ const exec = require('child_process').exec; const https = require('https'); const keytar = require('keytar'); 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'); @@ -73,6 +75,30 @@ class Jira { 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. * @@ -248,6 +274,18 @@ class Jira { } } + /** + * 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. * @@ -269,16 +307,23 @@ class Jira { return new Promise((resolve, reject) => { // Build the request URL. - let url = Utils.concatenatePaths([this.url, this.uri, '/rest/api/', apiVersion, uri]); - url = Url.addParamsToUrl(url, params); + const url = Url.addParamsToUrl(this.buildRequestUrl(uri), params); - // Perform the request. + // Initialize the request. const options = { method: method, auth: `${this.username}:${this.password}`, headers: headers, }; - const request = https.request(url, options, (response) => { + const request = https.request(url, options); + + // Add data. + if (data) { + request.write(data); + } + + // Treat response. + request.on('response', (response) => { // Read the result. let result = ''; response.on('data', (chunk) => { @@ -302,11 +347,7 @@ class Jira { reject(e); }); - // Send data. - if (data) { - request.write(data); - } - + // Send the request. request.end(); }); } @@ -359,6 +400,75 @@ class Jira { 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, reject) => { + // 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(); diff --git a/gulp/task-push.js b/gulp/task-push.js index 3ec238bba..253351916 100644 --- a/gulp/task-push.js +++ b/gulp/task-push.js @@ -24,6 +24,56 @@ const Utils = require('./utils'); */ class PushTask { + /** + * Push a patch to the tracker and remove the previous one. + * + * @param branch Branch name. + * @param branchData Parsed branch data. + * @param remote Remote used. + * @return Promise resolved when done. + */ + async pushPatch(branch, branchData, remote) { + const headCommit = await Git.getHeadCommit(branch, branchData); + + if (!headCommit) { + throw new Error('Head commit not resolved, abort pushing patch.'); + } + + // Create the patch file. + const fileName = branch + '.patch'; + const tmpPatchPath = `./tmp/${fileName}`; + + await Git.createPatch(`${headCommit}...${branch}`, tmpPatchPath); + console.log('Git patch created'); + + // Check if there is an attachment with same name in the issue. + const issue = await Jira.getIssue(branchData.issue, 'attachment'); + + let existingAttachmentId; + const attachments = (issue.fields && issue.fields.attachment) || []; + for (const i in attachments) { + if (attachments[i].filename == fileName) { + // Found an existing attachment with the same name, we keep track of it. + existingAttachmentId = attachments[i].id; + break + } + } + + // Push the patch to the tracker. + console.log(`Uploading patch ${fileName} to the tracker...`); + await Jira.upload(branchData.issue, tmpPatchPath); + + if (existingAttachmentId) { + // On success, deleting file that was there before. + try { + console.log('Deleting older patch...') + await Jira.deleteAttachment(existingAttachmentId); + } catch (error) { + console.log('Could not delete older attachment.'); + } + } + } + /** * Run the task. * @@ -46,7 +96,9 @@ class PushTask { throw new Error('Cannot push HEAD branch'); } - const keepRunning = await this.validateLastCommitMessage(branch); + // Parse the branch to get the project and issue number. + const branchData = Utils.parseBranch(branch); + const keepRunning = await this.validateLastCommitMessage(branchData); if (!keepRunning) { // Last commit not valid, stop. @@ -55,13 +107,31 @@ class PushTask { return; } - // Push the branch. - console.log(`Pushing branch ${branch} to remote ${remote}...`); - await Git.push(remote, branch, force); + if (!args.patch) { + // Check if it's a security issue to force patch mode. + try { + args.patch = await Jira.isSecurityIssue(branchData.issue); - // Update tracker info. - console.log(`Branch pushed, update tracker info...`); - await this.updateTrackerGitInfo(branch, remote); + if (args.patch) { + console.log(`${branchData.issue} appears to be a security issue, switching to patch mode...`); + } + } catch (error) { + console.log(`Could not check if ${branchData.issue} is a security issue.`); + } + } + + if (args.patch) { + // Create and upload a patch file. + await this.pushPatch(branch, branchData, remote); + } else { + // Push the branch. + console.log(`Pushing branch ${branch} to remote ${remote}...`); + await Git.push(remote, branch, force); + + // Update tracker info. + console.log(`Branch pushed, update tracker info...`); + await this.updateTrackerGitInfo(branch, branchData, remote); + } } catch (error) { console.error(error); } @@ -73,13 +143,11 @@ class PushTask { * Update git info in the tracker issue. * * @param branch Branch name. + * @param branchData Parsed branch data. * @param remote Remote used. * @return Promise resolved when done. */ - async updateTrackerGitInfo(branch, remote) { - // Parse the branch to get the project and issue number. - const branchData = Utils.parseBranch(branch); - + async updateTrackerGitInfo(branch, branchData, remote) { // Get the repository data for the project. let repositoryUrl = DevConfig.get(branchData.project + '.repositoryUrl'); let diffUrlTemplate = DevConfig.get(branchData.project + '.diffUrlTemplate', ''); @@ -134,12 +202,10 @@ class PushTask { /** * Validate last commit message comparing it with the branch name. * - * @param branch Branch name. + * @param branchData Parsed branch data. * @return True if value is ok or the user wants to continue anyway, false to stop. */ - async validateLastCommitMessage(branch) { - const branchData = Utils.parseBranch(branch); - + async validateLastCommitMessage(branchData) { const messages = await Git.messages(1); const message = messages[0]; diff --git a/package-lock.json b/package-lock.json index de972bd64..3149a5e56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2259,9 +2259,9 @@ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz", + "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==" }, "babel-code-frame": { "version": "6.26.0", @@ -5356,8 +5356,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -6179,16 +6178,6 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, "formidable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", @@ -8400,29 +8389,6 @@ "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" - }, - "dependencies": { - "ajv": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.9.1.tgz", - "integrity": "sha512-XDN92U311aINL77ieWHmqCcNlwjoP5cHXDxIxbf2MaPYuCXOHS7gHH8jktxeK5omgd52XbSTX6a4Piwd1pQmzA==", - "requires": { - "fast-deep-equal": "^2.0.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - } } }, "has": { @@ -12117,7 +12083,8 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true }, "q": { "version": "1.5.1", @@ -12500,9 +12467,9 @@ } }, "request": { - "version": "2.88.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", - "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -12511,7 +12478,7 @@ "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", - "har-validator": "~5.1.0", + "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", @@ -12521,15 +12488,20 @@ "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", - "tough-cookie": "~2.4.3", + "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" }, "dependencies": { - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } } } }, @@ -14407,12 +14379,19 @@ } }, "tough-cookie": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", - "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "requires": { - "psl": "^1.1.24", - "punycode": "^1.4.1" + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + } } }, "tree-kill": { diff --git a/package.json b/package.json index 5878c602c..68fadccc2 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "minimist": "^1.2.5", "native-run": "^1.0.0", "node-loader": "^0.6.0", + "request": "^2.88.2", "through": "^2.3.8", "typescript": "~2.6.2", "vinyl": "^2.2.0",