├── .gitignore ├── README.md ├── bin └── sync-jira-github ├── lib ├── config.js ├── jira-extension.js └── syncer.js ├── package.json ├── project-example.json └── sync.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | syncer.json 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | jira-github-issue-sync 2 | ====================== 3 | 4 | Syncs jira's stories/sprints to github's milestones/issues. 5 | 6 | ### Install 7 | 8 | ``` 9 | npm install -g sync-jira-github 10 | ``` 11 | 12 | ### Run 13 | 14 | You need to create a _json_ file with github and jira confiurations. Then you can run: 15 | 16 | ``` 17 | sync-jira-github project.json 18 | ``` 19 | 20 | You have an example at [project-example.json](https://github.com/weareswat/jira-github-issue-sync/blob/master/project-example.json). 21 | -------------------------------------------------------------------------------- /bin/sync-jira-github: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('sync-jira-github') 4 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | (function config() { 2 | 3 | var path = require('path'); 4 | 5 | exports.load = function load(fileName, handler) { 6 | console.log("Loading config " + fileName + "..."); 7 | handler(require(path.join(process.cwd(), fileName))); 8 | }; 9 | 10 | })(); 11 | -------------------------------------------------------------------------------- /lib/jira-extension.js: -------------------------------------------------------------------------------- 1 | (function jiraExtenstion() { 2 | 3 | var getSprintIssues = function getSprintIssues(rapidViewId, sprintId, callback) { 4 | 5 | var options = { 6 | uri: this.makeUri('/rapid/charts/sprintreport', 'rest/greenhopper/'), 7 | method: 'GET', 8 | json: true 9 | }; 10 | 11 | options.uri = options.uri + '?rapidViewId=' + rapidViewId + '&sprintId=' + sprintId; 12 | 13 | this.request(options, function(error, response) { 14 | if( response.statusCode === 404 ) { 15 | callback('Invalid URL'); 16 | return; 17 | } 18 | 19 | if( response.statusCode !== 200 ) { 20 | callback(response.statusCode + ': Unable to connect to JIRA during sprints search'); 21 | return; 22 | } 23 | 24 | if(response.body !== null) { 25 | callback(null, response.body); 26 | } else { 27 | callback('No body given'); 28 | } 29 | }); 30 | }; 31 | 32 | exports.extend = function extend(jiraApi) { 33 | jiraApi.getSprintIssues = getSprintIssues; 34 | }; 35 | 36 | })(); 37 | -------------------------------------------------------------------------------- /lib/syncer.js: -------------------------------------------------------------------------------- 1 | (function syncer() { 2 | 3 | var JiraApi = require('jira').JiraApi; 4 | var GithubApi = require('github'); 5 | var _ = require('underscore'); 6 | var async = require('async'); 7 | var jiraExtension = require('./jira-extension.js'); 8 | var context = {}; 9 | var request = require('request'); 10 | 11 | var configApis = function configApis(config) { 12 | var apis = { jira: {} }; 13 | apis.jira.default = new JiraApi( 14 | config.jira.protocol, 15 | config.jira.host, 16 | config.jira.port, 17 | config.jira.user, 18 | config.jira.password, 19 | config.jira.defaultApi.version 20 | ); 21 | apis.jira.greenhopper = new JiraApi( 22 | config.jira.protocol, 23 | config.jira.host, 24 | config.jira.port, 25 | config.jira.user, 26 | config.jira.password, 27 | config.jira.greenhopper.version 28 | ); 29 | jiraExtension.extend(apis.jira.greenhopper); 30 | 31 | apis.github = new GithubApi({version: "3.0.0"}); 32 | apis.github.authenticate(config.github.auth); 33 | return apis; 34 | }; 35 | 36 | var errorLog = function(error) { 37 | if(error) { 38 | console.log(error); 39 | } 40 | }; 41 | 42 | var getCurrentSprint = function getCurrentSprint(callback) { 43 | context.api.jira.greenhopper.findRapidView(context.config.jira.project, function(error, rapidView) { 44 | context.rapidView = rapidView; 45 | context.api.jira.greenhopper.getLastSprintForRapidView(rapidView.id, function(error, sprint) { 46 | context.sprint = sprint; 47 | callback(sprint); 48 | }); 49 | }); 50 | }; 51 | 52 | var checkIfMilestoneExists = function checkIfMilestoneExists(sprint, callback) { 53 | var msg = _.extend({state:'open'}, context.config.github); 54 | context.api.github.issues.getAllMilestones(msg, function(error, milestones) { 55 | var milestone = _.find(milestones, function(milestone) { return milestone.title == sprint.name;}); 56 | if( milestone ) { 57 | context.milestone = milestone; 58 | console.log(' - Exists'); 59 | callback(error, true); 60 | } else { 61 | console.log(' - Not found'); 62 | callback(error, false); 63 | } 64 | }); 65 | }; 66 | 67 | var createMilestone = function createMilestone(sprint, callback) { 68 | var createMilestoneMsg = _.extend({title: sprint.name, state:'open'}, context.config.github); 69 | context.api.github.issues.createMilestone(createMilestoneMsg, function(error, result) { 70 | console.log(' - New milestone created'); 71 | context.milestone = result; 72 | callback(null); 73 | }); 74 | }; 75 | 76 | var buildMilestone = function buildMilestone(callback) { 77 | getCurrentSprint(function operateSprint(sprint) { 78 | console.log('Sprint: ' + sprint.name); 79 | checkIfMilestoneExists(sprint, function milestoneProbe(error, exists) { 80 | if(exists) { 81 | // update? 82 | callback(null); 83 | } else { 84 | createMilestone(sprint, callback); 85 | } 86 | }); 87 | }); 88 | }; 89 | 90 | var getSprintIssues = function getSprintIssues(callback) { 91 | var filter = _.extend({ 92 | //milestone: context.milestone.number, 93 | sort: 'updated', 94 | direction: 'desc', 95 | per_page: 100 96 | }, context.config.github); 97 | context.api.github.issues.repoIssues(filter, function saveGhIssues(error, issues) { 98 | context.ghIssues = issues; 99 | console.log('Got ' + issues.length + ' issues open from milestone on GH' ); 100 | callback(error, issues); 101 | }); 102 | }; 103 | 104 | var getClosedSprintIssues = function getClosedSprintIssues(callback) { 105 | var filter = _.extend({ 106 | //milestone: context.milestone.number, 107 | state: 'closed', 108 | sort: 'updated', 109 | direction: 'desc', 110 | per_page: 100 111 | }, context.config.github); 112 | context.api.github.issues.repoIssues(filter, function saveGhIssues(error, issues) { 113 | context.ghClosedIssues = issues; 114 | context.ghIssues = _.union(issues, context.ghIssues); 115 | console.log('Got ' + issues.length + ' issues closed from milestone on GH' ); 116 | callback(error, issues); 117 | }); 118 | }; 119 | 120 | var getGhIssueFor = function getGhIssue(jiraIssue) { 121 | var match = _.find(context.ghIssues, function(current) { 122 | return current.title.match("^" + jiraIssue.key); 123 | }); 124 | return match; 125 | }; 126 | 127 | var getGhUserFor = function getGhUserFor(jiraUser) { 128 | var ghuser = context.config.userMapping[jiraUser]; 129 | if(!ghuser) { 130 | throw new Error("Can't find ghuser for jiraUser:" + jiraUser); 131 | } 132 | return ghuser; 133 | }; 134 | 135 | var createGhIssue = function createGhIssue(jiraIssue, callback) { 136 | console.log('\t-Created new'); 137 | var args = _.extend({ 138 | assignee: getGhUserFor(jiraIssue.assignee), 139 | title: (jiraIssue.key + ': ' + jiraIssue.summary).toString('utf8'), 140 | milestone: context.milestone.number, 141 | labels: [jiraIssue.typeName, jiraIssue.priorityName] 142 | }); 143 | var requestArgs = { 144 | uri: 'https://api.github.com/repos/'+context.config.github.user+'/'+context.config.github.repo+'/issues', 145 | body: JSON.stringify(args), 146 | headers: { 147 | authorization: 'Basic ' + new Buffer(context.config.github.auth.username + ":" + context.config.github.auth.password, "ascii").toString("base64"), 148 | 'content-type': 'application/json' 149 | } 150 | }; 151 | request.post(requestArgs, function afterRequest(e, r, body) { 152 | callback(e); 153 | }); 154 | }; 155 | 156 | var jiraTypes = [ 157 | 'Task', 'Bug', 158 | 'Technical-Task', 'Design-Task', 159 | 'Technical Task', 'Design Task' 160 | ]; 161 | 162 | var validIssueTypeForImport = function validIssueTypeForImport(typeName) { 163 | var match = _.find(jiraTypes, function finder(jiraType) {return jiraType === typeName; }); 164 | return match !== undefined; 165 | }; 166 | 167 | var generateGithubIssue = function generateGithubIssue(issues, callback, masterCallback) { 168 | var issue = issues.pop(); 169 | console.log(' - ' + issue.typeName + ':' + issue.key ); 170 | 171 | if(validIssueTypeForImport(issue.typeName)) { 172 | var ghissue = getGhIssueFor(issue); 173 | if(ghissue) { 174 | console.log('\t- Already exists'); 175 | generateGithubIssues(issues, null, masterCallback); 176 | } else { 177 | createGhIssue(issue, function(error) { 178 | generateGithubIssues(issues, null, masterCallback); 179 | }); 180 | } 181 | } else { 182 | console.log('\t- Ignored'); 183 | generateGithubIssues(issues, null, masterCallback); 184 | } 185 | }; 186 | 187 | var generateGithubIssues = function generateGithubIssues(issues, callback, masterCallback) { 188 | if(_.isEmpty(issues) ) { 189 | masterCallback(null); 190 | } else { 191 | generateGithubIssue(issues, generateGithubIssues, masterCallback); 192 | } 193 | }; 194 | 195 | var addJiraSubtasks = function addJiraSubtasks(issue, callback) { 196 | context.api.jira.default.findIssue(issue.key, function getIssue(error, completeIssue) { 197 | _.each(completeIssue.fields.subtasks, function(subtask) { 198 | subtask.typeName = subtask.fields.issuetype.name; 199 | subtask.summary = subtask.fields.summary; 200 | subtask.priorityName = subtask.fields.priority.name; 201 | subtask.assignee = issue.assignee; 202 | }); 203 | context.subIssues = _.union(context.subIssues, completeIssue.fields.subtasks); 204 | callback(error, completeIssue); 205 | }); 206 | }; 207 | 208 | var createJiraTasksOnGithub = function createJiraTasksOnGithub(callback) { 209 | context.api.jira.greenhopper.getSprintIssues(context.rapidView.id, context.sprint.id, function(error, result) { 210 | errorLog(error); 211 | var masterIssues = _.union(result.contents.completedIssues, result.contents.incompletedIssues); 212 | context.subIssues = []; 213 | 214 | async.each(masterIssues, addJiraSubtasks, function completed(err) { 215 | context.jiraOpenIssues = _.union(result.contents.incompletedIssues, context.subIssues); 216 | var issues = _.union(result.contents.incompletedIssues, context.subIssues); // clone 217 | console.log('Sprint issues: ' + context.jiraOpenIssues.length); 218 | generateGithubIssues(issues, null, callback); 219 | }); 220 | }); 221 | }; 222 | 223 | var getJiraIssueFor = function getJiraIssue(ghIssue) { 224 | return _.find(context.jiraOpenIssues, function iter(jiraIssue) { 225 | return ghIssue.title.match('^' + jiraIssue.key + ':'); 226 | }); 227 | }; 228 | 229 | var closeJiraTask = function closeJiraTask(ghIssue, callback) { 230 | var jiraIssue = getJiraIssueFor(ghIssue); 231 | if(!jiraIssue) { 232 | // already closed 233 | return; 234 | } 235 | var msg = { 236 | "transition": { 237 | "id": "51" 238 | } 239 | }; 240 | context.api.jira.default.transitionIssue(jiraIssue.key, msg, function(error) { 241 | console.log(' - ' + ghIssue.number + ' -> ' + ghIssue.title); 242 | if(error) { 243 | console.log('\t * ' + error); 244 | } else { 245 | console.log('\t Closed'); 246 | } 247 | callback(null); 248 | }); 249 | }; 250 | 251 | var closeJiraTasks = function closeJiraTasks(callback) { 252 | async.each(context.ghClosedIssues, closeJiraTask, callback); 253 | }; 254 | 255 | exports.process = function process(config) { 256 | context.config = config; 257 | context.api = configApis(config); 258 | async.series([ 259 | buildMilestone, 260 | getSprintIssues, 261 | getClosedSprintIssues, 262 | createJiraTasksOnGithub, 263 | closeJiraTasks 264 | ], errorLog); 265 | }; 266 | 267 | })(); 268 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "SWAT ", 3 | "name": "sync-jira-github", 4 | "description": "Sync jira's stories/sprints to github's milestones/issues", 5 | "version": "0.1.1", 6 | "homepage": "https://github.com/weareswat/jira-github-issue-sync", 7 | "preferGlobal": "true", 8 | "main" : "./sync.js", 9 | 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:weareswat/jira-github-issue-sync.git" 13 | }, 14 | 15 | "dependencies": { 16 | "underscore": "1.4.2", 17 | "request": "2.14.x", 18 | "utf8": "1.0.0", 19 | "async": "0.2.6", 20 | "github": "0.1.x", 21 | "jira": "0.2.x" 22 | }, 23 | 24 | "engines": { 25 | "node": "0.8.x", 26 | "npm": "1.1.x" 27 | }, 28 | 29 | "bin": { 30 | "sync-jira-github": "./bin/sync-jira-github" 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /project-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "jira": { 3 | "protocol": "https", 4 | "host": "weareswat.atlassian.net", 5 | "port": "", 6 | "user": "user", 7 | "password": "pass", 8 | "defaultApi": { 9 | "version": "2" 10 | }, 11 | "greenhopper": { 12 | "version": "1.0" 13 | }, 14 | "project": "Project name" 15 | }, 16 | "github": { 17 | "user": "user", 18 | "repo": "repo-name", 19 | "auth": { 20 | "type": "basic", 21 | "username": "user", 22 | "password": "pass" 23 | } 24 | }, 25 | "userMapping": { 26 | "jira-username": "github-username" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sync.js: -------------------------------------------------------------------------------- 1 | var config = require('./lib/config.js') 2 | var syncer = require('./lib/syncer.js'); 3 | 4 | config.load(process.argv[2], syncer.process); 5 | --------------------------------------------------------------------------------