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