Conversion to promises and general cleanup.
[mantis2gitlab] / m2gl.js
1 #!/usr/bin/env node
2
3 var Q = require('q');
4 var FS = require('q-io/fs');
5 var util = require('util');
6 var colors = require('colors');
7 var csv = require('csv');
8 var rest = require('restler-q');
9 var async = require('async');
10 var _ = require('lodash');
11 var argv = require('optimist')
12     .demand(['i', 'c', 'g', 'p', 't', 's'])
13     .alias('i', 'input')
14     .alias('c', 'config')
15     .alias('g', 'gitlaburl')
16     .alias('p', 'project')
17     .alias('t', 'token')
18     .alias('s', 'sudo')
19     .alias('f', 'from')
20     .describe('i', 'CSV file exported from Mantis (Example: issues.csv)')
21     .describe('c', 'Configuration file (Example: config.json)')
22     .describe('g', 'GitLab URL hostname (Example: https://gitlab.com)')
23     .describe('p', 'GitLab project name including namespace (Example: mycorp/myproj)')
24     .describe('t', 'An admin user\'s private token (Example: a2r33oczFyQzq53t23Vj)')
25     .describe('s', 'The username performing the import (Example: bob)')
26     .describe('f', 'The first issue # to import (Example: 123)')
27     .argv;
28
29 var inputFile = __dirname + '/' + argv.input;
30 var configFile = __dirname + '/' + argv.config;
31 var fromIssueId = Number(argv.from||0);
32 var gitlabAPIURLBase = argv.gitlaburl + '/api/v3';
33 var gitlabProjectName = argv.project;
34 var gitlabAdminPrivateToken = argv.token;
35 var gitlabSudo = argv.sudo;
36 var config = {};
37
38 var gitLab = {};
39 var promise = getConfig()
40         .then(readMantisIssues)
41         .then(getGitLabProject)
42         .then(getGitLabProjectMembers)
43         .then(mapGitLabUserIds)
44         .then(validateMantisIssues)
45         .then(getGitLabProjectIssues)
46         .then(importGitLabIssues)
47     ;
48
49 promise.then(function() {
50   console.log(("Done!").bold.green);
51 }, function(err) {
52   console.error(err);
53 });
54
55 /**
56  * Read and parse config.json file - assigns config
57  */
58 function getConfig() {
59   log_progress("Reading configuration...");
60   return FS.read(configFile, {encoding: 'utf8'})
61       .then(function(data) {
62         var config = JSON.parse(data);
63         config.users = _.extend({
64           "": {
65             name: "Unknown",
66             gl_username: gitlabSudo
67           }
68         }, config.users);
69         return config;
70       }).then(function(cfg) {
71         config = cfg;
72       }, function() {
73         throw new Error('Cannot read config file: ' + configFile);
74       });
75 }
76
77 /**
78  * Read and parse import.csv file - assigns gitLab.mantisIssues
79  */
80 function readMantisIssues() {
81   log_progress("Reading Mantis export file...");
82   return FS.read(inputFile, {encoding: 'utf8'}).then(function(data) {
83     var rows = [];
84     var dfd = Q.defer();
85
86     csv().from(data, {delimiter: ',', escape: '"', columns: true})
87         .on('record', function(row, index) { rows.push(row) })
88         .on('end', function(error, data) {
89           dfd.resolve(rows);
90         });
91
92     return dfd.promise
93         .then(function(rows) {
94           _.forEach(rows, function(row) {
95             row.Id = Number(row.Id);
96           });
97
98           if(fromIssueId) {
99             rows = _.filter(rows, function(row) {
100               return row.Id >= fromIssueId;
101             })
102           }
103
104           return gitLab.mantisIssues = _.sortBy(rows, "Id");
105         }, function(error) {
106           throw new Error('Cannot read input file: ' + inputFile + " - " + error);
107         });
108   });
109 }
110
111 /**
112  * Fetch project info from GitLab - assigns gitLab.project
113  */
114 function getGitLabProject() {
115   log_progress("Fetching project from GitLab...");
116   var url = gitlabAPIURLBase + '/projects';
117   var data = { per_page: 100, private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
118
119   return rest.get(url, {data: data}).then(function(result) {
120
121     gitLab.project = _.find(result, { path_with_namespace : gitlabProjectName }) || null;
122
123     if (!gitLab.project) {
124       throw new Error('Cannot find GitLab project: ' + gitlabProjectName);
125     }
126
127     return gitLab.project;
128   }, function(error) {
129     throw new Error('Cannot get list of projects from gitlab: ' + url);
130   });
131 }
132
133 /**
134  * Fetch project members from GitLab - assigns gitLab.gitlabUsers
135  */
136 function getGitLabProjectMembers() {
137   log_progress("getGitLabProjectMembers");
138   var url = gitlabAPIURLBase + '/projects/' + gitLab.project.id + "/members";
139   var data = { per_page: 100, private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
140
141   return rest.get(url, {data: data}).then(function(result) {
142     return gitLab.gitlabUsers = result;
143   }, function(error) {
144     throw new Error('Cannot get list of users from gitlab: ' + url);
145   });
146 }
147
148 /**
149  * Sets config.users[].gl_id based gitLab.gitlabUsers
150  */
151 function mapGitLabUserIds() {
152   var users = config.users,
153       gitlabUsers = gitLab.gitlabUsers;
154   _.forEach(users, function(user) {
155     user.gl_id = (_.find(gitlabUsers, { id: user.gl_username }) || {}).id;
156   });
157 }
158
159 /**
160  * Ensure that Mantise user names in gitLab.mantisIssues have corresponding GitLab user mapping
161  */
162 function validateMantisIssues() {
163   log_progress("Validating Mantis Users...");
164
165   var mantisIssues = gitLab.mantisIssues;
166   var users = config.users;
167
168   var missingUsernames = [];
169
170   for (var i = 0; i < mantisIssues.length; i++) {
171     var assignee = mantisIssues[i]["Assigned To"];
172
173     if (!getUserByMantisUsername(assignee) && missingUsernames.indexOf(assignee) == -1)
174       missingUsernames.push(assignee);
175   }
176
177   for (var i = 0; i < mantisIssues.length; i++) {
178     var reporter = mantisIssues[i].Reporter;
179
180     if (!getUserByMantisUsername(reporter) && missingUsernames.indexOf(reporter) == -1)
181       missingUsernames.push(reporter);
182   }
183
184   if (missingUsernames.length > 0) {
185     for (var i = 0; i < missingUsernames.length; i++)
186       console.error('Error: Cannot map Mantis user with username: ' + missingUsernames[i]);
187
188     throw new Error("User Validation Failed");
189   }
190 }
191
192 /**
193  * Import gitLab.mantisIssues into GitLab
194  * @returns {*}
195  */
196 function importGitLabIssues() {
197   log_progress("Importing Mantis issues into GitLab from #" + fromIssueId + " ...");
198   return _.reduce(gitLab.mantisIssues, function(p, mantisIssue) {
199     return p.then(function() {
200       return importIssue(mantisIssue);
201     });
202   }, Q());
203
204 }
205
206 function importIssue(mantisIssue) {
207   var issueId = mantisIssue.Id;
208   var title = mantisIssue.Summary;
209   var description = getDescription(mantisIssue);
210   var assignee = getUserByMantisUsername(mantisIssue["Assigned To"]);
211   var milestoneId = '';
212   var labels = getLabels(mantisIssue);
213   var author = getUserByMantisUsername(mantisIssue.Reporter);
214
215   log_progress("Importing: #" + issueId + " - " + title + " ...");
216
217   var data = {
218     title: title,
219     description: description,
220     assignee_id: assignee && assignee.gl_id,
221     milestone_id: milestoneId,
222     labels: labels,
223     sudo: gitlabSudo,
224     private_token: gitlabAdminPrivateToken
225   };
226
227   return getIssue(gitLab.project.id, issueId)
228       .then(function(gitLabIssue) {
229         if (gitLabIssue) {
230           return updateIssue(gitLab.project.id, gitLabIssue.id, _.extend({
231             state_event: isClosed(mantisIssue) ? 'close' : 'reopen'
232           }, data))
233               .then(function() {
234                 console.log(("#" + issueId + ": Updated successfully.").green);
235               });
236         } else {
237           return insertSkippedIssues(issueId-1)
238               .then(function() {
239                 return insertAndCloseIssue(issueId, data, isClosed(mantisIssue));
240               });
241         }
242       });
243 }
244
245 function insertSkippedIssues(issueId) {
246   if (gitLab.gitlabIssues[issueId]) {
247     return Q();
248   }
249
250   console.warn(("Skipping Missing Mantis Issue (<= #" + issueId + ") ...").yellow);
251
252   var data = {
253     title: "Skipped Mantis Issue",
254     sudo: gitlabSudo,
255     private_token: gitlabAdminPrivateToken
256   };
257
258   return insertAndCloseIssue(issueId, data, true, getSkippedIssueData)
259       .then(function() {
260         return insertSkippedIssues(issueId);
261       });
262
263   function getSkippedIssueData(gitLabIssue) {
264     var issueId = gitLabIssue.iid;
265     var description;
266     if (config.mantisUrl) {
267       description = "[Mantis Issue " + issueId + "](" + config.mantisUrl + "/view.php?id=" + issueId + ")";
268     } else {
269       description = "Mantis Issue " + issueId;
270     }
271     return {
272       title: "Skipped Mantis Issue " + issueId,
273       description: "_Skipped " + description + "_"
274     };
275   }
276 }
277
278 function insertAndCloseIssue(issueId, data, close, custom) {
279
280   return insertIssue(gitLab.project.id, data).then(function(issue) {
281     gitLab.gitlabIssues[issue.iid] = issue;
282     if (close) {
283       return closeIssue(issue, custom && custom(issue)).then(
284           function() {
285             console.log((issueId + ': Inserted and closed successfully. #' + issue.iid).green);
286           }, function(error) {
287             console.warn((issueId + ': Inserted successfully but failed to close. #' + issue.iid).yellow);
288           });
289     }
290
291     console.log((issueId + ': Inserted successfully. #' + issue.iid).green);
292   }, function(error) {
293     console.error((issueId + ': Failed to insert.').red, error);
294   });
295 }
296
297 /**
298  * Fetch all existing project issues from GitLab - assigns gitLab.gitlabIssues
299  */
300 function getGitLabProjectIssues() {
301   return getRemainingGitLabProjectIssues(0, 100)
302       .then(function(result) {
303         log_progress("Fetched " + result.length + " GitLab issues.");
304         var issues = _.indexBy(result, 'iid');
305         return gitLab.gitlabIssues = issues;
306       });
307 }
308
309 /**
310  * Recursively fetch the remaining issues in the project
311  * @param page
312  * @param per_page
313  */
314 function getRemainingGitLabProjectIssues(page, per_page) {
315   var from = page * per_page;
316   log_progress("Fetching Project Issues from GitLab [" + (from + 1) + "-" + (from + per_page) + "]...");
317   var url = gitlabAPIURLBase + '/projects/' + gitLab.project.id + "/issues";
318   var data = {
319     page: page,
320     per_page: per_page,
321     order_by: 'id',
322     private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
323
324   return rest.get(url, {data: data}).then(function(issues) {
325     if(issues.length < per_page) {
326       return issues;
327     }
328     return getRemainingGitLabProjectIssues(page+1, per_page)
329         .then(function(remainingIssues) {
330           return issues.concat(remainingIssues);
331         });
332   }, function(error) {
333     throw new Error('Cannot get list of issues from gitlab: ' + url + " page=" + page);
334   });
335 }
336
337 function getUserByMantisUsername(username) {
338   return (username && config.users[username]) || config.users[""] || null;
339 }
340
341 function getDescription(row) {
342   var attributes = [];
343   var issueId = row.Id;
344   var value;
345   if (config.mantisUrl) {
346     attributes.push("[Mantis Issue " + issueId + "](" + config.mantisUrl + "/view.php?id=" + issueId + ")");
347   } else {
348     attributes.push("Mantis Issue " + issueId);
349   }
350
351   if (value = row.Reporter) {
352     attributes.push("Reported By: " + value);
353   }
354
355   if (value = row["Assigned To"]) {
356     attributes.push("Assigned To: " + value);
357   }
358
359   if (value = row.Created) {
360     attributes.push("Created: " + value);
361   }
362
363   if (value = row.Updated && value != row.Created) {
364     attributes.push("Updated: " + value);
365   }
366
367   var description = "_" + attributes.join(", ") + "_\n\n";
368
369   description += row.Description;
370
371   if (value = row.Info) {
372     description += "\n\n" + value;
373   }
374
375   if (value = row.Notes) {
376     description += "\n\n" + value.split("$$$$").join("\n\n")
377   }
378
379   return description;
380 }
381
382 function getLabels(row) {
383   var label;
384   var labels = (row.tags || []).slice(0);
385
386   if(label = config.category_labels[row.Category]) {
387     labels.push(label);
388   }
389
390   if(label = config.priority_labels[row.Priority]) {
391     labels.push(label);
392   }
393
394   if(label = config.severity_labels[row.Severity]) {
395     labels.push(label);
396   }
397
398   return labels.join(",");
399 }
400
401 function isClosed(row) {
402   return config.closed_statuses[row.Status];
403 }
404
405 function getIssue(projectId, issueId) {
406   return Q(gitLab.gitlabIssues[issueId]);
407   //
408   //var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues?iid=' + issueId;
409   //var data = { private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
410   //
411   //return rest.get(url, {data: data})
412   //    .then(function(issues) {
413   //      var issue = issues[0];
414   //      if(!issue) {
415   //        throw new Error("Issue not found: " + issueId);
416   //      }
417   //      return issue;
418   //    });
419 }
420
421 function insertIssue(projectId, data) {
422   var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues';
423
424   return rest.post(url, {data: data})
425       .then(null, function(error) {
426         throw new Error('Failed to insert issue into GitLab: ' + url);
427       });
428 }
429
430 function updateIssue(projectId, issueId, data) {
431   var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues/' + issueId;
432
433   return rest.put(url, {data: data})
434       .then(null, function(error) {
435         throw new Error('Failed to update issue in GitLab: ' + url + " " + JSON.stringify(error));
436       });
437 }
438
439 function closeIssue(issue, custom) {
440   var url = gitlabAPIURLBase + '/projects/' + issue.project_id + '/issues/' + issue.id;
441   var data = _.extend({
442     state_event: 'close',
443     private_token: gitlabAdminPrivateToken,
444     sudo: gitlabSudo
445   }, custom);
446
447   return rest.put(url, {data: data})
448       .then(null, function(error) {
449         throw new Error('Failed to close issue in GitLab: ' + url);
450       });
451 }
452
453
454 function log_progress(message) {
455   console.log(message.grey);
456 }