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