diff --git a/build/publish/GetClosedIssuesAndMergedPRs.graphql b/build/publish/GetClosedIssuesAndMergedPRs.graphql new file mode 100644 index 0000000..72d2bd5 --- /dev/null +++ b/build/publish/GetClosedIssuesAndMergedPRs.graphql @@ -0,0 +1,67 @@ +query ($owner: String!, $repo: String!, $afterIssues: String, $afterPRs: String, $includePRs: Boolean!, $includeIssues: Boolean!) { + repository(owner: $owner, name: $repo) { + ...issues @include(if: $includeIssues) + ...prs @include(if: $includePRs) + parent { + ...issues @include(if: $includeIssues) + ...prs @include(if: $includePRs) + } + } +} + +fragment issues on Repository { + issues(first: 100, after: $afterIssues, states: [CLOSED]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + url + title + timeline(first: 100) { + nodes { + ... on ReferencedEvent { + commit { + oid + } + } + ... on ClosedEvent { + closeCommit: commit { + oid + } + } + } + } + } + } +} + +fragment prs on Repository { + pullRequests(first: 100, after: $afterPRs, states: [MERGED]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + title + url + number + timeline(first: 100) { + nodes { + ... on MergedEvent { + commit { + oid + } + } + } + } + # unfortunatly seems to be buggy in the alpha of github graphql + # sometime "MERGED" PRs have a mergeCommit and sometimes not + # while they are having a "MergedEvent" timeline + # mergeCommit { + # oid + # } + } + } +} \ No newline at end of file diff --git a/build/publish/GetMasterCommits.graphql b/build/publish/GetMasterCommits.graphql new file mode 100644 index 0000000..1d2d9d4 --- /dev/null +++ b/build/publish/GetMasterCommits.graphql @@ -0,0 +1,45 @@ +query ($owner: String!, $repo: String!, $after: String, $includeLastRelease: Boolean!) { + repository(owner: $owner, name: $repo) { + ...commits + ...lastRelease @include(if:$includeLastRelease) + # for some reason releases aren't queryable on forked repos if they weren't published on the fork first + # so include the parent in case the repo doesn't have a last release + parent @include(if: $includeLastRelease) { + ...lastRelease + } + } +} + +fragment commits on Repository { + ref(qualifiedName: "refs/heads/master") { + target { + ... on Commit { + history(first: 100, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + url + messageHeadline + oid + } + } + } + } + } +} + +fragment lastRelease on Repository { + releases(last: 1) { + nodes { + description + tag { + name + target { + oid + } + } + } + } +} \ No newline at end of file diff --git a/build/publish/github-graphql.js b/build/publish/github-graphql.js new file mode 100644 index 0000000..1f32311 --- /dev/null +++ b/build/publish/github-graphql.js @@ -0,0 +1,165 @@ +var fetch = require('node-fetch'); +var fs = require('fs'); +var path = require('path'); + +var files = {}; + +function readFileForMethod(method) { + return new Promise(function (resolve, reject) { + if (files[method]) { + return setTimeout(resolve.bind(null, files[method])); + } + fs.readFile(path.resolve(__dirname, method + '.graphql'), function (err, file) { + if (err) { + return reject(err); + } + files[method] = file.toString().replace('\n', ''); + resolve(files[method]); + }); + }); +} + +function queryGraphQL (method, vars, key) { + return readFileForMethod(method).then(function (query) { + var body = JSON.stringify({query: query, variables: vars}); + return fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: 'bearer ' + key + }, + body: body + }).then(res => res.json()).then(function (result) { + if (result.errors && result.errors.length > 0) { + result.errors.forEach(console.error); + throw new Error(result.error[0]); + } + return result; + }); + }); +} + +function hasProperties(obj) { + for (var key in obj) { + return true; + } + return false; +} + +function GithubGraphQLWrapper (key, owner, repo) { + this._key = key; + this._repo = repo; + this._owner = owner; + this._commits = []; + this._issues = []; + this._mergedPRs = []; + this._closedIssues = []; + this._lastRelease = null; + this._commitAfterRef = null; + this._reachedLastIssue = false; + this._reachedLastPr = false; + this._afterPRRef = null; + this._afterIssueRef = null; +} + +GithubGraphQLWrapper.prototype = { + constructor: GithubGraphQLWrapper, + fetchLastGithubRelease: function fetchLastGithubRelease () { + return queryGraphQL('GetMasterCommits', { + repo: this._repo, + owner: this._owner, + includeLastRelease: true, + after: this._commitAfterRef + }, this._key).then(result => { + var data = result.data.repository; + var parentRelease = data.parent && data.parent.releases.nodes.length && data.parent.releases.nodes[0]; + var lastRelease = data.releases.nodes.length > 0 ? data.releases.nodes[0] : parentRelease; + var history = data.ref.target.history; + this._lastRelease = lastRelease; + this._commits = this._commits.concat(history.nodes); + this._commitAfterRef = history.pageInfo.endCursor; + return this; + }); + }, + fetchCommitsToLastRelease: function () { + return queryGraphQL('GetMasterCommits', { + repo: this._repo, + owner: this._owner, + includeLastRelease: false, + after: this._commitAfterRef + }, this._key).then(result => { + var data = result.data.repository; + var history = data.ref.target.history; + this._commitAfterRef = data.ref.target.history.pageInfo.endCursor; + this._commits = this._commits.concat(data.ref.target.history.nodes); + var commitOids = this._commits.map(c => c.oid); + if (commitOids.indexOf(this._lastRelease.tag.target.oid) == -1 && history.pageInfo.hasNextPage) { + return this.fetchCommitsToLastRelease(); + } + this._commits.splice(commitOids.indexOf(this._lastRelease.tag.target.oid)); + return this; + }); + }, + fetchPRsAndIssues: function () { + return queryGraphQL('GetClosedIssuesAndMergedPRs', { + repo: this._repo, + owner: this._owner, + includePRs: !this._reachedLastPr, + includeIssues: !this._reachedLastIssue, + afterIssues: this._afterIssueRef, + afterPRs: this._afterPRRef, + }, this._key).then(result => { + var repository = result.data.repository; + var parent = repository.parent; + var parentIssues = parent && parent.issues && parent.issues.nodes.length && parent.issues; + var localIssues = repository.issues && repository.issues.nodes.length && repository.issues; + var issues = localIssues || parentIssues; + var parentPRs = parent && parent.pullRequests && parent.pullRequests.nodes.length && parent.pullRequests; + var localPRs = repository.pullRequests && repository.pullRequests.nodes.length && repository.pullRequests; + var prs = localPRs || parentPRs; + if (issues) { + this._reachedLastIssue = !issues.pageInfo.hasNextPage; + this._afterIssueRef = issues.pageInfo.endCursor; + this._closedIssues = this._closedIssues.concat(issues.nodes); + } + + if (prs) { + this._reachedLastPr = !prs.pageInfo.hasNextPage; + this._afterPRRef = prs.pageInfo.endCursor; + this._mergedPRs = this._mergedPRs.concat(prs.nodes); + } + if (!this._reachedLastPr && !this._reachedLastIssue) { + return this.fetchPRsAndIssues(); + } + }).then(() => { + this._closedIssues = this._closedIssues.map(issue => { + issue.timeline = issue.timeline.nodes.filter(hasProperties); + return issue; + }).filter(issue => issue.timeline.length > 0); + this._mergedPRs.map(pr => { + pr.timeline = pr.timeline.nodes.filter(hasProperties); + return pr; + }).filter(pr => pr.timeline.length > 0); + return this; + }); + }, + getLastRelease: function () { + return this._lastRelease; + }, + getMergedPRs: function () { + return this._mergedPRs; + }, + getCommits: function () { + return this._commits; + }, + getClosedIssues: function () { + return this._closedIssues; + }, + getOwner: function () { + return this._owner; + }, + getRepo: function () { + return this._repo; + } +}; + +module.exports = GithubGraphQLWrapper; \ No newline at end of file diff --git a/build/tasks/publish.js b/build/tasks/publish.js index 2e71ac9..1486aa1 100644 --- a/build/tasks/publish.js +++ b/build/tasks/publish.js @@ -1,11 +1,14 @@ module.exports = function (grunt) { var Git = require('nodegit'); var npmUtils = require('npm-utils'); - var Github = require('github'); + var GithubGraphQLWrapper = require('../publish/github-graphql.js'); + var fetch = require('node-fetch'); var gitUser = process.env.GIT_USER; - var gitPassword = process.env.GIT_PASSWORD; + var gitKey = process.env.GIT_KEY; var gitEmail = process.env.GIT_EMAIL; var lastCommit; + var graphql; + function getMasterCommit (repo) { return repo.getMasterCommit(); @@ -80,13 +83,14 @@ module.exports = function (grunt) { } grunt.registerTask('publish', function() { - if (!gitUser || !gitPassword || !gitEmail) { - grunt.log.error('Missing login data for github. Make sure GIT_USER, GIT_EMAIL and GIT_PASSWORD are set in the environment.'); + if (!gitUser || !gitKey || !gitEmail) { + grunt.log.error('Missing login data for github. Make sure GIT_USER, GIT_EMAIL and GIT_KEY are set in the environment.'); return; } var done = this.async(); var repository; + graphql = new GithubGraphQLWrapper(gitKey, process.env.TRAVIS_REPO_SLUG.split('/')[0], process.env.TRAVIS_REPO_SLUG.split('/')[1]); Git.Repository.open('.') .then(function (repo) { repository = repo; @@ -109,6 +113,14 @@ module.exports = function (grunt) { .then(function (patch) { return hasVersionChange(patch, repository); }) + .then(function (versionChanged) { + if (!versionChanged) + return false; + return graphql.fetchLastGithubRelease() + .then(function () { + return graphql.getLastRelease().tag.name != grunt.config.version; + }); + }) .then(function (versionChanged) { if (versionChanged) { grunt.log.writeln('Package version has changed. Build will be published.'); @@ -119,7 +131,7 @@ module.exports = function (grunt) { }); } else { - grunt.log.writeln('Version has not changed.'); + grunt.log.writeln('Version has not changed or is already released.'); done(); return; } @@ -182,7 +194,7 @@ module.exports = function (grunt) { { callbacks: { credentials: function () { - return Git.Cred.userpassPlaintextNew(gitUser, gitPassword); + return Git.Cred.userpassPlaintextNew(gitKey, 'x-oauth-basic'); } } } @@ -192,62 +204,68 @@ module.exports = function (grunt) { }); } - function publishReleaseNotes () { - var github = new Github(); - if (!process.env.TRAVIS_REPO_SLUG) return; - var owner = process.env.TRAVIS_REPO_SLUG.split('/')[0]; - var repo = process.env.TRAVIS_REPO_SLUG.split('/')[1]; - github.authenticate({ - type: 'basic', - username: gitUser, - password: gitPassword - }); - var commitParser = new GithubCommitParser(github, owner, repo); - return github.gitdata.getTags({ - owner: owner, - repo: repo, - per_page: 1 - }).then(function (latestTag) { - var latestReleaseCommit = null; - if (latestTag[0]) { - latestReleaseCommit = latestTag[0].object.sha; - } - return commitParser.loadCommits(lastCommit, latestReleaseCommit) - .then(commitParser.loadClosedIssuesForCommits.bind(commitParser)) - .then(commitParser.loadMergedPullRequestsForCommits.bind(commitParser)); - }).then(function () { - var releaseNotes = '### Release ' + grunt.config.data.version + '\n'; - if (commitParser.closedIssues.length > 0) { - releaseNotes += '## Issues closed in this release: \n'; - commitParser.closedIssues.forEach(function (issue) { - releaseNotes += '* [[`#'+ issue.number + '`]](' + issue.html_url + ') - ' + issue.title + '\n'; + function publishRelease () { + return graphql.fetchCommitsToLastRelease() + .then(ql => ql.fetchPRsAndIssues()) + .then(function () { + var commits = graphql.getCommits(); + var prs = graphql.getMergedPRs(); + var issues = graphql.getClosedIssues(); + var commitOids = commits.map(commit => commit.oid); + var mergedPRs = prs.filter(pr => { + for (var i in pr.timeline) { + var mergeEvent = pr.timeline[i]; + if (mergeEvent.commit && commitOids.indexOf(mergeEvent.commit.oid) != -1) { + return true; + } + } + return false; }); - } - if (commitParser.mergedPullRequests.length > 0) { - releaseNotes += '## Merged pull requests in this release: \n'; - commitParser.mergedPullRequests.forEach(function (pr) { - releaseNotes += '* [[`#' + pr.number + '`]](' + pr.html_url + ') - ' + pr.title + '\n'; + var closedIssues = issues.filter(issue => { + for (var i in issue.timeline) { + var event = issue.timeline[i]; + if ( (event.commit && commitOids.indexOf(event.commit.oid) != -1) || (event.closeCommit && commitOids.indexOf(event.closeCommit.oid) != -1)) { + return true; + } + } + return false; + }); + return {closedIssues: closedIssues, mergedPRs: mergedPRs, commits: commits}; + }).then(history => { + var releaseNotes = `# ${grunt.config.data.pkg.name} release ${grunt.config.data.version}\n\n`; + releaseNotes += `## Closed issues:\n`; + history.closedIssues.forEach(issue => { + releaseNotes += `* [[\`#${issue.number}\`]](${issue.url}) - ${issue.title}\n`; + }); + + releaseNotes += `\n\n## Merged pull requests:\n`; + history.mergedPRs.forEach(pr => { + releaseNotes += `* [[\`#${pr.number}\`]](${pr.url}) - ${pr.title}\n`; + }); + + releaseNotes += `\n\n## Commits:\n`; + history.commits.forEach(commit => { + releaseNotes += `* [[\`${commit.oid.substr(0, 10)}\`]](${commit.url}) - ${commit.messageHeadline} \n`; + }); + + return releaseNotes; + }).then(releaseNotes => { + return fetch(`https://api.github.com/repos/${graphql.getOwner()}/${graphql.getRepo()}/releases`, { + method: 'POST', + headers: { + Authorization: `token ${gitKey}`, + }, + body: JSON.stringify({ + prerelease: /rc,alpha,beta/i.test(grunt.config.data.version), + name: grunt.config.data.version, + tag_name: grunt.config.data.version, + body: releaseNotes + }) }); - } - releaseNotes += '## Commits in this release: \n'; - commitParser.commits.forEach(function (commit) { - releaseNotes += '* [[`' + commit.sha.substr(0,7) + '`]](' + commit.html_url + ') - ' + commit.split('\n')[0] + '\n'; - }); - return releaseNotes; - }).then(function (releaseNotes) { - return github.repos.createRelease({ - owner: owner, - repo: repo, - tag_name: grunt.config.data.version, - name: 'Release ' + grunt.config.data.version, - body: releaseNotes }); - }) - .then(function () { - grunt.log.writeln('created github release'); - }); } + function publishNpm () { var cwd = process.cwd(); process.chdir(require('path').join(cwd, 'dist/npm')); @@ -266,7 +284,7 @@ module.exports = function (grunt) { grunt.task.requires('build-only'); var done = this.async(); commitRelease() - .then(publishReleaseNotes) + .then(publishRelease) .then(function () { grunt.log.writeln('Done publishing.'); done(); @@ -278,119 +296,3 @@ module.exports = function (grunt) { }); }; - -function GithubCommitParser (github, user, repository) { - this.github = github; - this.user = user; - this.repository = repository; - this.reset(); -} -GithubCommitParser.prototype = { - reset: function () { - this.commits = []; - this.closedIssuesEvents = []; - this.closedIssues = []; - this.commitPage = 1; - this.issueEventsPage = 1; - this.issueEvents = []; - this.mergedPullRequests = []; - this.mergedPullRequestEvents = []; - }, - loadCommits: function (startCommitSha, endCommitSha) { - var containsCommit = this._containsCommit.bind(this, endCommitSha); - var self = this; - return this.github.repos.getCommits({ - owner: this.user, - repo: this.repository, - sha: startCommitSha, - page: self.commitPage++, - per_page: 100 - }).then(function (commits) { - self.commits = self.commits.concat(commits); - return containsCommit(); - }).then(function (res) { - if (res.contains && self.commits.length % 100 === 0) { - self.commits.splice(res.index+1); - return self; - } else { - return self.loadCommits(startCommitSha, endCommitSha); - } - }); - }, - loadClosedIssuesForCommits: function () { - var self = this; - return new Promise(function (resolve) { - if (self.issueEvents.length === 0) { - resolve(self._loadIssueEvents()); - } else { - resolve(); - } - }).then(function () { - self._filterClosedIssueEvents(); - }).then(function () { - self.closedIssues = self.closedIssuesEvents.map(function (event) { - return event.issue; - }); - return self; - }); - }, - loadMergedPullRequestsForCommits: function () { - var self = this; - return new Promise(function (resolve) { - if (self.issueEvents.length === 0) { - resolve(self._loadIssueEvents()); - } else { - resolve(); - } - }).then(function () { - self._filterPullRequests(); - }).then(function () { - self.mergedPullRequests = self.mergedPullRequestEvents.filter(function (event) { - return event.issue; - }); - return self; - }); - }, - _loadIssueEvents: function () { - var self = this; - return this.github.issues.getEventsForRepo({ - owner: this.user, - repo: this.repository, - page: this.issueEventsPage++, - per_page: 100 - }).then(function (issueEvents) { - self.issueEvents = self.issueEvents.concat(issueEvents); - if (issueEvents.length < 100) { - return; - } else { - return self._loadIssueEvents(); - } - }); - }, - _filterPullRequests: function () { - var commitShas = this.commits.map(function (commit) { - return commit.sha; - }); - this.mergedPullRequestEvents = this.issueEvents.filter(function (event) { - return commitShas.indexOf(event.commit_id) != -1 && event.event == 'merged'; - }); - }, - _filterClosedIssueEvents: function () { - var commitShas = this.commits.map(function (commit) { - return commit.sha; - }); - this.closedIssuesEvents = this.issueEvents.filter(function (issueEvent) { - return commitShas.indexOf(issueEvent.commit_id) != -1 && issueEvent.event == 'closed' && !issueEvent.issue.pullRequest; - }); - }, - _containsCommit: function (commitSha) { - var commits = this.commits.map(function (commit) { - return commit.sha; - }); - if (commits.indexOf(commitSha) == -1) { - return {contains: false, index: null}; - } else { - return {contains: true, index: commits.indexOf(commitSha)}; - } - } -}; diff --git a/package.json b/package.json index b5410ad..166de95 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "escodegen": "^1.6.1", "esprima": "^3.0.0", "estraverse": "^4.1.0", - "github": "^8.1.1", "grunt": "^1.0.1", "grunt-cli": "^1.2.0", "grunt-contrib-jshint": "^1.0.0", @@ -34,7 +33,8 @@ "karma-ie-launcher": "^1.0.0", "karma-jasmine": "^1.0.2", "karma-jasmine-jquery": "^0.1.1", - "nodegit": "^0.16.0", + "node-fetch": "^1.6.3", + "nodegit": "^0.17.0", "npm-utils": "^1.11.0", "parse5": "^2.2.1" }