The version we used to actually import
[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   var created = mantisIssue.Created.replace(" ", "T") + "Z";
220
221   log_progress("Importing: #" + issueId + " - " + title + " ...");
222
223   var data = {
224     title: title,
225     description: description,
226     assignee_id: assignee && assignee.gl_id,
227     milestone_id: milestoneId,
228     labels: labels,
229     created_at: created,
230     sudo: gitlabSudo,
231     private_token: gitlabAdminPrivateToken
232   };
233
234   return getIssue(gitLab.project.id, issueId)
235       .then(function(gitLabIssue) {
236         if (gitLab.gitlabIssues[issueId]) {
237           // update
238           console.log("updating: " + data.title + " (Issueid: " + issueId + ")");
239           return updateIssue(gitLab.project.id, gitLabIssue.iid, _.extend({
240             state_event: isClosed(mantisIssue) ? 'close' : 'reopen'
241           }, data))
242               .then(function() {
243                 console.log(("#" + gitLabIssue.iid + ": Updated successfully.").green);
244               });
245         } else {
246           // insert
247           console.log("inserting: " + data.title + " (Issueid: " + issueId + ")");
248           data.iid = issueId;
249           return insertAndCloseIssue(issueId, data, isClosed(mantisIssue));
250 /*
251           return insertSkippedIssues(issueId-1)
252               .then(function() {
253                 return insertAndCloseIssue(issueId, data, isClosed(mantisIssue));
254               });
255 */
256         }
257       });
258 }
259
260 function insertSkippedIssues(issueId) {
261   if (issueId < 1 || gitLab.gitlabIssues[issueId]) {
262     return Q();
263   }
264
265   console.log(("Adding placeholder ...").yellow);
266
267   var data = {
268     title: "-- Platzhalter --",
269     description: "Diese Bugnummer war in Mantis einem anderen Projekt zugeordnet. Dieser Platzhalter sorgt dafuer, dass die Bugnummern in Mantis mit denen in Gitlab uebereinstimmen.",
270     sudo: gitlabSudo,
271     private_token: gitlabAdminPrivateToken
272   };
273
274   return insertAndCloseIssue(issueId, data, true)
275       .then(function() {
276         return insertSkippedIssues(issueId);
277       });
278 }
279
280 function insertAndCloseIssue(issueId, data, close, custom) {
281
282   return insertIssue(gitLab.project.id, data).then(function(issue) {
283     gitLab.gitlabIssues[issue.iid] = issue;
284     if (close) {
285       return closeIssue(issue, custom && custom(issue)).then(
286           function() {
287             console.log((issue.iid + ': Inserted and closed successfully. #' + issue.iid).green);
288           }, function(error) {
289             console.warn((issue.iid + ': Inserted successfully but failed to close. #' + issue.iid).yellow);
290           });
291     }
292
293     console.log((issue.iid + ': Inserted successfully. #' + issue.iid).green);
294   }, function(error) {
295     console.error((issue.iid + ': Failed to insert.').red, error);
296   });
297 }
298
299 /**
300  * Fetch all existing project issues from GitLab - assigns gitLab.gitlabIssues
301  */
302 function getGitLabProjectIssues() {
303   return getRemainingGitLabProjectIssues(0, 100)
304       .then(function(result) {
305         log_progress("Fetched " + result.length + " GitLab issues.");
306         var issues = _.indexBy(result, 'iid');
307         return gitLab.gitlabIssues = issues;
308       });
309 }
310
311 /**
312  * Recursively fetch the remaining issues in the project
313  * @param page
314  * @param per_page
315  */
316 function getRemainingGitLabProjectIssues(page, per_page) {
317   var from = page * per_page;
318   log_progress("Fetching Project Issues from GitLab [" + (from + 1) + "-" + (from + per_page) + "]...");
319   var url = gitlabAPIURLBase + '/projects/' + gitLab.project.id + "/issues";
320   var data = {
321     page: page,
322     per_page: per_page,
323     private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
324
325   return rest.get(url, {data: data})
326   .fail(function(err) {
327     console.log(err);
328   })
329   .then(function(issues) {
330     if(issues.length < per_page) {
331       return issues;
332     }
333     return getRemainingGitLabProjectIssues(page+1, per_page)
334         .then(function(remainingIssues) {
335           return issues.concat(remainingIssues);
336         });
337   }, function(error) {
338     throw new Error('Cannot get list of issues from gitlab: ' + url + " page=" + page);
339   });
340 }
341
342 function getUserByMantisUsername(username) {
343   return (username && config.users[username]) || config.users[""] || null;
344 }
345
346 function getDescription(row) {
347   var attributes = [];
348   var issueId = row.Id;
349   var value;
350   if (config.mantisUrl) {
351     attributes.push("[Mantis Issue " + issueId + "](" + config.mantisUrl + "/view.php?id=" + issueId + ")");
352   } else {
353     attributes.push("Mantis Issue " + issueId);
354   }
355
356   if (value = row.Reporter) {
357     attributes.push("Reported By: " + value);
358   }
359
360 /* omit ...
361   if (value = row["Assigned To"]) {
362     attributes.push("Assigned To: " + value);
363   }
364 */
365
366   if (value = row.Created) {
367     attributes.push("Created: " + value);
368   }
369
370   if (value = row.Updated && value != row.Created) {
371     attributes.push("Updated: " + value);
372   }
373
374   var description = "_" + attributes.join(", ") + "_\n\n";
375
376   description += row.Description;
377
378   if (value = row.Info) {
379     description += "\n\n" + value;
380   }
381
382   if (value = row.Notes) {
383     description += "\n\n" + value.split("$$$$").join("\n\n")
384   }
385
386   if (value = row.Attachments) {
387     description += "\n\n" + value.split("$$$$").join("\n\n")
388   }
389
390   description = description.replace(/\\n/g, "\n");
391   description = description.replace(/\\t/g, "  ");
392   description = description.replace(/``/g, '"');
393   description = description.replace(/''/g, '"');
394   description = description.replace(/\n *----/g, "\n>>>");
395
396   Object.keys(config.users).forEach(function(muser) {
397     if (muser != "") {
398         var gluser = config.users[muser].gl_username;
399         var re = new RegExp(muser, "g");
400         description = description.replace(re, "@" + gluser);
401     }
402   });
403
404   return description;
405 }
406
407 function getLabels(row) {
408   var label;
409   var labels = (row.tags || []).slice(0);
410
411   if(label = config.category_labels[row.Category]) {
412     labels.push(label);
413   }
414
415   if(label = config.priority_labels[row.Priority]) {
416     labels.push(label);
417   }
418
419   if(label = config.severity_labels[row.Severity]) {
420     labels.push(label);
421   }
422
423   return labels.join(",");
424 }
425
426 function isClosed(row) {
427   return config.closed_statuses[row.Status];
428 }
429
430 function getIssue(projectId, issueId) {
431   return Q(gitLab.gitlabIssues[issueId]);
432   //
433   //var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues?iid=' + issueId;
434   //var data = { private_token: gitlabAdminPrivateToken, sudo: gitlabSudo };
435   //
436   //return rest.get(url, {data: data})
437   //    .then(function(issues) {
438   //      var issue = issues[0];
439   //      if(!issue) {
440   //        throw new Error("Issue not found: " + issueId);
441   //      }
442   //      return issue;
443   //    });
444 }
445
446 function insertIssue(projectId, data) {
447   var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues';
448
449   return rest.post(url, {data: data})
450       .then(null, function(error) {
451         throw new Error('Failed to insert issue into GitLab: ' + url);
452       });
453 }
454
455 function updateIssue(projectId, issueIId, data) {
456   var url = gitlabAPIURLBase + '/projects/' + projectId + '/issues/' + issueIId;
457
458   return rest.put(url, {data: data})
459       .then(null, function(error) {
460         throw new Error('Failed to update issue in GitLab: ' + url + " " + JSON.stringify(error));
461       });
462 }
463
464 function closeIssue(issue, custom) {
465   var url = gitlabAPIURLBase + '/projects/' + issue.project_id + '/issues/' + issue.iid;
466   var data = _.extend({
467     state_event: 'close',
468     private_token: gitlabAdminPrivateToken,
469     sudo: gitlabSudo
470   }, custom);
471
472   return rest.put(url, {data: data})
473       .then(null, function(error) {
474         throw new Error('Failed to close issue in GitLab: ' + url);
475       });
476 }
477
478
479 function log_progress(message) {
480   console.log(message.grey);
481 }