diff --git a/.wiby.json b/.wiby.json index cec7b10..515fba3 100644 --- a/.wiby.json +++ b/.wiby.json @@ -1,13 +1,16 @@ { "dependents": [ { - "repository": "https://github.com/wiby-test/partial" + "repository": "https://github.com/wiby-test/partial", + "pullRequest": false }, { - "repository": "git://github.com/wiby-test/fail" + "repository": "git://github.com/wiby-test/fail", + "pullRequest": false }, { - "repository": "git+https://github.com/wiby-test/pass" + "repository": "git+https://github.com/wiby-test/pass", + "pullRequest": true } ] } diff --git a/bin/commands/close-pr.js b/bin/commands/close-pr.js new file mode 100644 index 0000000..e036211 --- /dev/null +++ b/bin/commands/close-pr.js @@ -0,0 +1,29 @@ +'use strict' + +const wiby = require('../..') + +exports.desc = 'Use this command to close the PRs raised against your dependents. wiby will go off to the dependent’s repo and close the PRs raised that trigger jobs `package.json` pointing to your latest version (with the new changes) triggering the dependent’s CI to run.' + +exports.builder = (yargs) => yargs + .option('dependent', { + desc: 'URL of a dependent', + type: 'string', + conflicts: 'config' + }) + .option('config', { + desc: 'Path to the configuration file. By default it will try to load the configuration from the first file it finds in the current working directory: `.wiby.json`, `.wiby.js`', + type: 'string' + }) + +exports.handler = async (params) => { + const config = params.dependent + ? { + dependents: [{ repository: params.dependent, pullRequest: true }] + } + : wiby.validate({ config: params.config }) + + const result = await wiby.closePR(config) + // TODO, something more like the result process output + const output = `${result.length} PRs closed` + console.log(output) +} diff --git a/bin/commands/result.js b/bin/commands/result.js index 01f7fc1..513bf73 100644 --- a/bin/commands/result.js +++ b/bin/commands/result.js @@ -17,7 +17,9 @@ exports.builder = (yargs) => yargs exports.handler = async (params) => { const config = params.dependent - ? { dependents: [{ repository: params.dependent }] } + ? { + dependents: [{ repository: params.dependent }] + } : wiby.validate({ config: params.config }) const result = await wiby.result(config) diff --git a/bin/commands/test.js b/bin/commands/test.js index b274622..bdb0d88 100644 --- a/bin/commands/test.js +++ b/bin/commands/test.js @@ -10,6 +10,12 @@ exports.builder = (yargs) => yargs type: 'string', conflicts: 'config' }) + .option('pull-request', { + desc: 'Raise a draft PR in addition to creating a branch', + alias: 'pr', + type: 'boolean', + conflicts: 'config' + }) .option('config', { desc: 'Path to the configuration file. By default it will try to load the configuration from the first file it finds in the current working directory: `.wiby.json`, `.wiby.js`', type: 'string' @@ -17,7 +23,9 @@ exports.builder = (yargs) => yargs exports.handler = (params) => { const config = params.dependent - ? { dependents: [{ repository: params.dependent }] } + ? { + dependents: [{ repository: params.dependent, pullRequest: !!params['pull-request'] }] + } : wiby.validate({ config: params.config }) return wiby.test(config) diff --git a/lib/closePR.js b/lib/closePR.js new file mode 100644 index 0000000..6239d4e --- /dev/null +++ b/lib/closePR.js @@ -0,0 +1,49 @@ +'use strict' + +const github = require('../lib/github') +const logger = require('./logger') +const context = require('./context') +const gitURLParse = require('git-url-parse') + +const debug = logger('wiby:closepr') + +module.exports = async ({ dependents }) => { + const closedPrs = [] + for (const { repository: url, pullRequest } of dependents) { + if (pullRequest) { + const dependentPkgInfo = gitURLParse(url) + const parentBranchName = await context.getParentBranchName() + const branch = await context.getTestingBranchName(parentBranchName) + const resp = await github.getChecks(dependentPkgInfo.owner, dependentPkgInfo.name, branch) + const closedPR = await closeDependencyPR(dependentPkgInfo.owner, dependentPkgInfo.name, branch, resp.data.check_runs) + if (closedPR) { + closedPrs.push(closedPR) + } + } + } + return closedPrs +} + +const closeDependencyPR = module.exports.closeDependencyPR = async function closeDependencyPR (owner, repo, branch, checkRuns) { + if (!checkRuns) { + return + } + // TODO, in reality multiple checks could exist and they may not all have passed + const prsToClose = checkRuns.reduce((acc, check) => { + if (check.status === 'completed' && + check.conclusion === 'success' && + check.pull_requests && + check.pull_requests.length !== 0) { + check.pull_requests.forEach((pr) => { + if (pr.head.ref === branch) { + acc.push({ + number: pr.number + }) + } + }) + } + return acc + }, []) + debug(`Dependent module: ${JSON.stringify(prsToClose, null, 2)}`) + return await Promise.all(prsToClose.map((pr) => github.closePR(owner, repo, pr.number))) +} diff --git a/lib/config.js b/lib/config.js index 5489c0d..e247785 100644 --- a/lib/config.js +++ b/lib/config.js @@ -13,7 +13,8 @@ const dependentSchema = joi.object({ 'git', 'git+https' ] - }) + }), + pullRequest: joi.boolean().optional() }).unknown(false) exports.schema = joi.object({ diff --git a/lib/github.js b/lib/github.js index 0eb78f4..36827de 100644 --- a/lib/github.js +++ b/lib/github.js @@ -42,6 +42,15 @@ module.exports.getWibyBranches = async function (owner, repo) { return edges.map(({ node: { branchName } }) => branchName) } +module.exports.getDefaultBranch = async function (owner, repo) { + const resp = await graphqlWithAuth({ + query: queries.getDefaultBranch, + owner: owner, + repo: repo + }) + return resp.repository.defaultBranchRef.name +} + module.exports.getShas = async function getShas (owner, repo) { const resp = await octokit.repos.listCommits({ owner, @@ -136,3 +145,24 @@ module.exports.getCommitStatusesForRef = async function getCommitStatusesForRef ref: branch }) } + +module.exports.createPR = async function createPR (owner, repo, title, head, base, draft, body) { + return octokit.pulls.create({ + owner, + repo, + title, + head, + base, + draft, + body + }) +} + +module.exports.closePR = async function closePR (owner, repo, pullNumber) { + return octokit.rest.pulls.update({ + owner, + repo, + pull_number: pullNumber, + state: 'closed' + }) +} diff --git a/lib/graphql/getDefaultBranch.graphql b/lib/graphql/getDefaultBranch.graphql new file mode 100644 index 0000000..e16d200 --- /dev/null +++ b/lib/graphql/getDefaultBranch.graphql @@ -0,0 +1,7 @@ +query getExistingRepoBranches($owner: String!, $repo: String!) { + repository(name: $repo, owner: $owner) { + defaultBranchRef { + name + } + } +} diff --git a/lib/graphql/index.js b/lib/graphql/index.js index b6d4973..f1efe78 100644 --- a/lib/graphql/index.js +++ b/lib/graphql/index.js @@ -6,3 +6,5 @@ const path = require('path') exports.getPackageJson = fs.readFileSync(path.join(__dirname, 'getPackageJson.graphql')).toString() exports.getWibyBranches = fs.readFileSync(path.join(__dirname, 'getWibyBranches.graphql')).toString() + +exports.getDefaultBranch = fs.readFileSync(path.join(__dirname, 'getDefaultBranch.graphql')).toString() diff --git a/lib/index.js b/lib/index.js index 6c2a341..670bb6b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,3 +7,5 @@ exports.test = require('./test') exports.result = require('./result') exports.validate = require('./config').validate + +exports.closePR = require('./closePR') diff --git a/lib/test.js b/lib/test.js index e00f594..8d320cd 100644 --- a/lib/test.js +++ b/lib/test.js @@ -21,7 +21,7 @@ module.exports = async function ({ dependents }) { const parentDependencyLink = await context.getDependencyLink(parentRepositoryInfo.owner, parentRepositoryInfo.name, parentBranchName) debug('Commit URL to test:', parentDependencyLink) - for (const { repository: url } of dependents) { + for (const { repository: url, pullRequest } of dependents) { const dependentRepositoryInfo = gitURLParse(url) const dependentPkgJson = await github.getPackageJson(dependentRepositoryInfo.owner, dependentRepositoryInfo.name) debug(`Dependent module: ${dependentRepositoryInfo.owner}/${dependentRepositoryInfo.name}`) @@ -32,6 +32,9 @@ module.exports = async function ({ dependents }) { const patchedPackageJSON = applyPatch(parentDependencyLink, parentPkgJSON.name, dependentPkgJson, parentPkgJSON.name) await pushPatch(patchedPackageJSON, dependentRepositoryInfo.owner, dependentRepositoryInfo.name, parentPkgJSON.name, parentBranchName) + if (pullRequest) { + await createPR(dependentRepositoryInfo.owner, dependentRepositoryInfo.name, parentBranchName, parentDependencyLink) + } } } @@ -58,3 +61,28 @@ async function pushPatch (dependentPkgJson, dependentOwner, dependentRepo, paren await github.createBranch(dependentOwner, dependentRepo, commitSha, branch) debug(`Changes pushed to https://github.com/${dependentOwner}/${dependentRepo}/blob/${branch}/package.json`) } + +const createPR = module.exports.createPR = async function createPR (dependentOwner, dependentRepo, parentBranchName, parentDependencyLink) { + const title = `Wiby changes to ${parentDependencyLink}` + const body = `Wiby has raised this PR to kick off automated jobs. + You are dependent upon ${parentDependencyLink} + ` + const head = context.getTestingBranchName(parentBranchName) + const draft = true + /* + from the conversation on wiby PR 93 https://github.com/pkgjs/wiby/pull/93#discussion_r615362448 + it was seen that the raising of a PR from head to main was in general ok but in the case where the + dependency feature branch does exist in the dependent then maybe detection and handle would offer a + better experience. + */ + const preExistingOnDependent = await github.getBranch(dependentOwner, dependentRepo, parentBranchName) + let result + if (preExistingOnDependent) { + result = await github.createPR(dependentOwner, dependentRepo, title, head, parentBranchName, draft, body) + } else { + const defaultBranch = await github.getDefaultBranch(dependentOwner, dependentRepo) + result = await github.createPR(dependentOwner, dependentRepo, title, head, defaultBranch, draft, body) + } + debug(`PR raised upon ${result.data.html_url}`) + return result +} diff --git a/test/cli/closePR.js b/test/cli/closePR.js new file mode 100644 index 0000000..02ce01b --- /dev/null +++ b/test/cli/closePR.js @@ -0,0 +1,43 @@ +'use strict' +const tap = require('tap') +const gitFixture = require('../fixtures/git') +const childProcess = require('child_process') +const nock = require('nock') +const path = require('path') + +const wibyCommand = path.join(__dirname, '..', '..', 'bin', 'wiby') +const fixturesPath = path.resolve(path.join(__dirname, '..', 'fixtures')) + +tap.test('closePRs command', async (t) => { + t.beforeEach(async () => { + nock.disableNetConnect() + gitFixture.init() + }) + + t.test('close-pr should fail if config and dependent provided', async (t) => { + try { + childProcess.execSync(`${wibyCommand} close-pr --config=.wiby.json --dependent="https://github.com/wiby-test/fakeRepo"`) + tap.fail() + } catch (err) { + tap.equal(true, err.message.includes('Arguments dependent and config are mutually exclusive')) + } + }) + t.test('close-pr should call and close the PR on command with dependent argument', async (t) => { + const result = childProcess.execSync(`${wibyCommand} close-pr --dependent="https://github.com/wiby-test/fakeRepo"`, { + env: { + ...process.env, + NODE_OPTIONS: `-r ${fixturesPath}/http/close-pr-command-positive.js` + } + }).toString() + t.match(result, '1 PRs closed\n') + }) + t.test('close-pr should call and close the PR on command using wiby.json settings', async (t) => { + const result = childProcess.execSync(`${wibyCommand} close-pr`, { + env: { + ...process.env, + NODE_OPTIONS: `-r ${fixturesPath}/http/close-pr-command-positive.js` + } + }).toString() + t.match(result, '1 PRs closed\n') + }) +}) diff --git a/test/closePR.js b/test/closePR.js new file mode 100644 index 0000000..60ce13b --- /dev/null +++ b/test/closePR.js @@ -0,0 +1,131 @@ +const nock = require('nock') +const tap = require('tap') +const context = require('../lib/context') +const gitFixture = require('./fixtures/git') +const closePR = require('../lib/closePR') + +tap.test('close PR', (t) => { + t.test('checks on internal call to github', (t) => { + const owner = 'pkgjs' + const repo = 'wiby' + const branch = 'wiby-main' + t.plan(6) + t.test('no checkruns returns', async (t) => { + const result = await closePR.closeDependencyPR(owner, repo, branch, null) + t.equal(result, undefined) + }) + t.test('status not completed returns empty array', async (t) => { + const result = await closePR.closeDependencyPR(owner, repo, branch, [{ status: 'queued' }]) + t.equal(result.length, 0) + }) + t.test('conclusion not a success returns empty array', async (t) => { + const result = await closePR.closeDependencyPR(owner, repo, branch, [{ status: 'queued', conclusion: 'failure' }]) + t.equal(result.length, 0) + }) + t.test('no pull requests returns empty array', async (t) => { + const result = await closePR.closeDependencyPR(owner, repo, branch, [{ + status: 'completed', + conclusion: 'success' + }]) + t.equal(result.length, 0) + }) + t.test('empty pull requests returns empty array', async (t) => { + const result = await closePR.closeDependencyPR(owner, repo, branch, [{ + status: 'completed', + conclusion: 'success', + pull_requests: [] + }]) + t.equal(result.length, 0) + }) + t.test('pull requests with numbers returns values', async (t) => { + nock('https://api.github.com') + // get package json + .patch('/repos/pkgjs/wiby/pulls/1') + .reply(200, { + data: {} + }) + const result = await closePR.closeDependencyPR(owner, repo, branch, [{ + status: 'completed', + conclusion: 'success', + pull_requests: [{ + number: 1, + head: { + ref: branch + } + }, { + number: 2, + head: { + ref: 'any-other-branch' + } + }] + }]) + t.equal(result.length, 1) + }) + }) + t.test('closePR Cli function tests', (t) => { + t.plan(2) + t.test('closePR should not close PRs', async (t) => { + // nock setup + nock('https://api.github.com') + .get(/repos.*check-runs/) + .reply(200, { + data: {} + }) + const result = await closePR({ + dependents: [ + { + repository: 'https://github.com/wiby-test/fakeRepo', + pullRequest: true + } + ] + }) + + t.equal(result.length, 0) + }) + t.test('closePR should close a single PR', async (t) => { + gitFixture.init() + const branch = context.getTestingBranchName(await context.getParentBranchName()) + // nock setup + nock('https://api.github.com') + .get(/repos.*check-runs/) + .reply(200, { + check_runs: [ + { + status: 'completed', + conclusion: 'success', + pull_requests: [{ + number: 1, + head: { + ref: branch + } + }, { + number: 2, + head: { + ref: 'any-other-branch' + } + }] + } + ] + }) + .patch(/repos.*pulls\/1/) + .reply(200, { + state: 'closed', + title: 'wiby test pr' + }) + const result = await closePR({ + dependents: [ + { + repository: 'https://github.com/wiby-test/fakeRepo1' + }, + { + repository: 'https://github.com/wiby-test/fakeRepo2', + pullRequest: true + } + ] + }) + + t.equal(result.length, 1) + }) + }) + t.end() +}) diff --git a/test/config.js b/test/config.js index 34208d7..32a2f0e 100644 --- a/test/config.js +++ b/test/config.js @@ -30,13 +30,16 @@ tap.test('config validation', async (tap) => { tap.strictSame(normalizedConfig, { dependents: [ { - repository: 'https://github.com/wiby-test/partial' + repository: 'https://github.com/wiby-test/partial', + pullRequest: false }, { - repository: 'git://github.com/wiby-test/fail' + repository: 'git://github.com/wiby-test/fail', + pullRequest: false }, { - repository: 'git+https://github.com/wiby-test/pass' + repository: 'git+https://github.com/wiby-test/pass', + pullRequest: true } ] }) diff --git a/test/fixtures/http/close-pr-command-positive.js b/test/fixtures/http/close-pr-command-positive.js new file mode 100644 index 0000000..6ce7b06 --- /dev/null +++ b/test/fixtures/http/close-pr-command-positive.js @@ -0,0 +1,51 @@ +'use strict' + +/* + Mocks of HTTP calls for close-pr command + */ +const nock = require('nock') + +nock.disableNetConnect() + +function nockRepo (nockInstance, owner, repo, branch) { + return nockInstance + // /repos/{owner}/{repo}/commits/{ref}/check-runs + .get(`/repos/${owner}/${repo}/commits/${branch}/check-runs`) + .reply(200, { + check_runs: [ + { + status: 'completed', + conclusion: 'success', + pull_requests: [{ + number: 1, + head: { + ref: branch + } + }, { + status: 'completed', + conclusion: 'success', + number: 2, + head: { + ref: 'any-other-branch' + } + }] + } + ] + }) + // /repos/{owner}/{repo}/pulls/{pull_number} + .patch(`/repos/${owner}/${repo}/pulls/1`) + .reply(200, { + state: 'closed', + title: 'wiby test pr' + }) +} + +function buildNock () { + let nockInstance = nock('https://api.github.com') + + nockInstance = nockRepo(nockInstance, 'wiby-test', 'fakeRepo', 'wiby-running-unit-tests') + nockInstance = nockRepo(nockInstance, 'wiby-test', 'pass', 'wiby-running-unit-tests') + return nockInstance +} + +buildNock() diff --git a/test/fixtures/http/test-command-positive.js b/test/fixtures/http/test-command-positive.js index 23f6740..6ca4f01 100644 --- a/test/fixtures/http/test-command-positive.js +++ b/test/fixtures/http/test-command-positive.js @@ -10,7 +10,7 @@ nock.disableNetConnect() function nockPkgjsWiby (nockInstance) { return nockInstance // get package json - .post('/graphql') + .post('/graphql', body => !!body.query.match(/HEAD/g)) .times(3) .reply(200, { data: { @@ -25,6 +25,17 @@ function nockPkgjsWiby (nockInstance) { } } }) + .post(/graphql/, body => !!body.query.match(/defaultBranchRef/g)) + .times(3) + .reply(200, { + data: { + repository: { + defaultBranchRef: { + name: 'main' + } + } + } + }) // get commit sha .get('/repos/pkgjs/wiby/commits?per_page=1') .reply(200, [ @@ -37,6 +48,8 @@ function nockPkgjsWiby (nockInstance) { } } ]) + .get('/repos/wiby-test/pass/branches/wiby-running-unit-tests') + .reply(404, {}) } function nockRepo (nockInstance, repo) { @@ -71,6 +84,15 @@ function nockRepo (nockInstance, repo) { // create branch in dependent .post(`/repos/wiby-test/${repo}/git/refs`) .reply(200, {}) + // create PR when requested + .post(`/repos/wiby-test/${repo}/pulls`) + .reply(201, { + html_url: 'https://github.com/pkgjs/wiby/pull/1' + }) + .get('/repos/wiby-test/pass/branches/running-unit-tests') + .reply(200, { + name: 'running-unit-tests' + }) } function buildNock () { diff --git a/test/test.js b/test/test.js index ebfb676..40b9176 100644 --- a/test/test.js +++ b/test/test.js @@ -97,4 +97,50 @@ tap.test('wiby.test()', async (tap) => { ) tap.end() }) + tap.test('Create PR', (tap) => { + tap.plan(2) + const htmlURL = `https://github.com/${CONFIG.PKG_ORG}/${CONFIG.DEP_REPO}/pull/1` + const dependentOwner = 'pkgjs' + const dependentRepo = 'wiby' + const parentBranchName = 'main' + tap.beforeEach(() => { + nock('https://api.github.com') + .post('/repos/pkgjs/wiby/pulls') + .reply(201, { + html_url: htmlURL + }) + .post(/graphql/) + .reply(200, { + data: { + repository: { + defaultBranchRef: { + name: 'main' + } + } + } + }) + }) + tap.test('test create PR when dependency feature branch does not exist in dependent repo', (t) => { + nock('https://api.github.com') + .get(/repos/) + .reply(404, {}) + wiby.test.createPR(dependentOwner, dependentRepo, parentBranchName) + .then((result) => { + t.equal(result.data.html_url, htmlURL) + t.end() + }) + }) + tap.test('test create PR when dependency feature branch exists in dependent repo', (t) => { + nock('https://api.github.com') + .get(/repos/) + .reply(200, { + name: parentBranchName + }) + wiby.test.createPR(dependentOwner, dependentRepo, parentBranchName) + .then((result) => { + t.equal(result.data.html_url, htmlURL) + t.end() + }) + }) + }) })