diff --git a/packages/project-management-automation/lib/add-milestone.js b/packages/project-management-automation/lib/add-milestone.js index 1716a8bea4f024..35c60f2ffdc274 100644 --- a/packages/project-management-automation/lib/add-milestone.js +++ b/packages/project-management-automation/lib/add-milestone.js @@ -7,14 +7,13 @@ const getAssociatedPullRequest = require( './get-associated-pull-request' ); /** @typedef {import('@octokit/rest').HookError} HookError */ /** @typedef {import('@actions/github').GitHub} GitHub */ /** @typedef {import('@octokit/webhooks').WebhookPayloadPush} WebhookPayloadPush */ +/** @typedef {import('@octokit/rest').IssuesListMilestonesForRepoResponseItem} OktokitIssuesListMilestonesForRepoResponseItem */ -// Milestone due dates are calculated from a known due date: -// 6.3, which was due on August 12 2019. -const REFERENCE_MAJOR = 6; -const REFERENCE_MINOR = 3; -const REFERENCE_DATE = '2019-08-12'; - -// Releases are every 14 days. +/** + * Number of expected days elapsed between releases. + * + * @type {number} + */ const DAYS_PER_RELEASE = 14; /** @@ -34,6 +33,45 @@ const isDuplicateValidationError = ( error ) => Array.isArray( error.errors ) && error.errors.some( ( { code } ) => code === 'already_exists' ); +/** + * Returns a promise resolving to a milestone by a given title, if exists. + * + * @param {GitHub} octokit Initialized Octokit REST client. + * @param {string} owner Repository owner. + * @param {string} repo Repository name. + * @param {string} title Milestone title. + * + * @return {Promise} Promise resolving to milestone, if exists. + */ +async function getMilestoneByTitle( octokit, owner, repo, title ) { + /** @type {Partial} */ + const params = { + state: 'all', + sort: 'due_on', + direction: 'desc', + }; + + const options = octokit.issues.listMilestonesForRepo.endpoint.merge( { + owner, + repo, + ...params, + } ); + + /** + * @type {AsyncIterableIterator>} + */ + const responses = octokit.paginate.iterator( options ); + + for await ( const response of responses ) { + const milestones = response.data; + for ( const milestone of milestones ) { + if ( milestone.title === title ) { + return milestone; + } + } + } +} + /** * Assigns the correct milestone to PRs once merged. * @@ -53,16 +91,14 @@ async function addMilestone( payload, octokit ) { } debug( 'add-milestone: Fetching current milestone' ); + const owner = payload.repository.owner.login; + const repo = payload.repository.name; const { - data: { milestone }, - } = await octokit.issues.get( { - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: prNumber, - } ); + data: { milestone: pullMilestone }, + } = await octokit.issues.get( { owner, repo, issue_number: prNumber } ); - if ( milestone ) { + if ( pullMilestone ) { debug( 'add-milestone: Pull request already has a milestone. Aborting' ); @@ -74,8 +110,8 @@ async function addMilestone( payload, octokit ) { const { data: { content, encoding }, } = await octokit.repos.getContents( { - owner: payload.repository.owner.login, - repo: payload.repository.name, + owner, + repo, path: 'package.json', } ); @@ -87,6 +123,20 @@ async function addMilestone( payload, octokit ) { debug( `add-milestone: Current plugin version is ${ major }.${ minor }` ); + const lastTitle = `Gutenberg ${ major }.${ minor }`; + const lastMilestone = await getMilestoneByTitle( + octokit, + owner, + repo, + lastTitle + ); + + if ( ! lastMilestone ) { + throw new Error( + 'Could not find milestone for current version: ' + lastTitle + ); + } + if ( minor === 9 ) { major += 1; minor = 0; @@ -94,13 +144,9 @@ async function addMilestone( payload, octokit ) { minor += 1; } - const numVersionsElapsed = - ( major - REFERENCE_MAJOR ) * 10 + ( minor - REFERENCE_MINOR ); - const numDaysElapsed = numVersionsElapsed * DAYS_PER_RELEASE; - // Using UTC for the calculation ensures it's not affected by daylight savings. - const dueDate = new Date( REFERENCE_DATE ); - dueDate.setUTCDate( dueDate.getUTCDate() + numDaysElapsed ); + const dueDate = new Date( lastMilestone.due_on ); + dueDate.setUTCDate( dueDate.getUTCDate() + DAYS_PER_RELEASE ); debug( `add-milestone: Creating 'Gutenberg ${ major }.${ minor }' milestone, due on ${ dueDate.toISOString() }` @@ -108,8 +154,8 @@ async function addMilestone( payload, octokit ) { try { await octokit.issues.createMilestone( { - owner: payload.repository.owner.login, - repo: payload.repository.name, + owner, + repo, title: `Gutenberg ${ major }.${ minor }`, due_on: dueDate.toISOString(), } ); @@ -127,24 +173,28 @@ async function addMilestone( payload, octokit ) { debug( 'add-milestone: Fetching all milestones' ); - const { data: milestones } = await octokit.issues.listMilestonesForRepo( { - owner: payload.repository.owner.login, - repo: payload.repository.name, - } ); + const title = `Gutenberg ${ major }.${ minor }`; - const [ { number } ] = milestones.filter( - ( { title } ) => title === `Gutenberg ${ major }.${ minor }` + const milestone = await getMilestoneByTitle( + octokit, + payload.repository.owner.login, + payload.repository.name, + title ); + if ( ! milestone ) { + throw new Error( 'Could not rediscover milestone by title: ' + title ); + } + debug( - `add-milestone: Adding issue #${ prNumber } to milestone #${ number }` + `add-milestone: Adding issue #${ prNumber } to milestone #${ milestone.number }` ); await octokit.issues.update( { - owner: payload.repository.owner.login, - repo: payload.repository.name, + owner, + repo, issue_number: prNumber, - milestone: number, + milestone: milestone.number, } ); } diff --git a/packages/project-management-automation/lib/test/add-milestone.js b/packages/project-management-automation/lib/test/add-milestone.js index 13ecd80aa9b0a2..4d5c96ca4c293c 100644 --- a/packages/project-management-automation/lib/test/add-milestone.js +++ b/packages/project-management-automation/lib/test/add-milestone.js @@ -9,10 +9,17 @@ describe( 'addMilestone', () => { ref: 'refs/heads/not-master', }; const octokit = { + paginate: { + iterator: jest.fn(), + }, issues: { get: jest.fn(), createMilestone: jest.fn(), - listMilestonesForRepo: jest.fn(), + listMilestonesForRepo: { + endpoint: { + merge: jest.fn(), + }, + }, update: jest.fn(), }, repos: { @@ -24,7 +31,9 @@ describe( 'addMilestone', () => { expect( octokit.issues.get ).not.toHaveBeenCalled(); expect( octokit.issues.createMilestone ).not.toHaveBeenCalled(); - expect( octokit.issues.listMilestonesForRepo ).not.toHaveBeenCalled(); + expect( + octokit.issues.listMilestonesForRepo.endpoint.merge + ).not.toHaveBeenCalled(); expect( octokit.issues.update ).not.toHaveBeenCalled(); expect( octokit.repos.getContents ).not.toHaveBeenCalled(); } ); @@ -41,6 +50,9 @@ describe( 'addMilestone', () => { }, }; const octokit = { + paginate: { + iterator: jest.fn(), + }, issues: { get: jest.fn( () => Promise.resolve( { @@ -83,6 +95,29 @@ describe( 'addMilestone', () => { }, }; const octokit = { + paginate: { + iterator: jest.fn().mockReturnValue( [ + Promise.resolve( { + data: [ + { + title: 'Gutenberg 6.2', + number: 10, + due_on: '2019-07-29T00:00:00.000Z', + }, + { + title: 'Gutenberg 6.3', + number: 11, + due_on: '2019-08-12T00:00:00.000Z', + }, + { + title: 'Gutenberg 6.4', + number: 12, + due_on: '2019-08-26T00:00:00.000Z', + }, + ], + } ), + ] ), + }, issues: { get: jest.fn( () => Promise.resolve( { @@ -92,15 +127,11 @@ describe( 'addMilestone', () => { } ) ), createMilestone: jest.fn(), - listMilestonesForRepo: jest.fn( () => - Promise.resolve( { - data: [ - { title: 'Gutenberg 6.2', number: 10 }, - { title: 'Gutenberg 6.3', number: 11 }, - { title: 'Gutenberg 6.4', number: 12 }, - ], - } ) - ), + listMilestonesForRepo: { + endpoint: { + merge: jest.fn(), + }, + }, update: jest.fn(), }, repos: { @@ -137,10 +168,6 @@ describe( 'addMilestone', () => { title: 'Gutenberg 6.4', due_on: '2019-08-26T00:00:00.000Z', } ); - expect( octokit.issues.listMilestonesForRepo ).toHaveBeenCalledWith( { - owner: 'WordPress', - repo: 'gutenberg', - } ); expect( octokit.issues.update ).toHaveBeenCalledWith( { owner: 'WordPress', repo: 'gutenberg', @@ -161,6 +188,29 @@ describe( 'addMilestone', () => { }, }; const octokit = { + paginate: { + iterator: jest.fn().mockReturnValue( [ + Promise.resolve( { + data: [ + { + title: 'Gutenberg 6.8', + number: 10, + due_on: '2019-10-21T00:00:00.000Z', + }, + { + title: 'Gutenberg 6.9', + number: 11, + due_on: '2019-11-04T00:00:00.000Z', + }, + { + title: 'Gutenberg 7.0', + number: 12, + due_on: '2019-11-18T00:00:00.000Z', + }, + ], + } ), + ] ), + }, issues: { get: jest.fn( () => Promise.resolve( { @@ -170,15 +220,11 @@ describe( 'addMilestone', () => { } ) ), createMilestone: jest.fn(), - listMilestonesForRepo: jest.fn( () => - Promise.resolve( { - data: [ - { title: 'Gutenberg 6.8', number: 10 }, - { title: 'Gutenberg 6.9', number: 11 }, - { title: 'Gutenberg 7.0', number: 12 }, - ], - } ) - ), + listMilestonesForRepo: { + endpoint: { + merge: jest.fn(), + }, + }, update: jest.fn(), }, repos: { @@ -215,10 +261,6 @@ describe( 'addMilestone', () => { title: 'Gutenberg 7.0', due_on: '2019-11-18T00:00:00.000Z', } ); - expect( octokit.issues.listMilestonesForRepo ).toHaveBeenCalledWith( { - owner: 'WordPress', - repo: 'gutenberg', - } ); expect( octokit.issues.update ).toHaveBeenCalledWith( { owner: 'WordPress', repo: 'gutenberg',