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