diff --git a/jest.config.js b/jest.config.js index 09342e14720..da5ff59a47d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,8 @@ module.exports = { "roots": [ "/public/app", "/public/test", - "/packages" + "/packages", + "/scripts", ], "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$", "moduleFileExtensions": [ diff --git a/scripts/cli/index.ts b/scripts/cli/index.ts index 722fc2cac26..dec2a05c424 100644 --- a/scripts/cli/index.ts +++ b/scripts/cli/index.ts @@ -6,6 +6,7 @@ import { buildTask } from './tasks/grafanaui.build'; import { releaseTask } from './tasks/grafanaui.release'; import { changelogTask } from './tasks/changelog'; import { cherryPickTask } from './tasks/cherrypick'; +import { closeMilestoneTask } from './tasks/closeMilestone'; import { precommitTask } from './tasks/precommit'; import { searchTestDataSetupTask } from './tasks/searchTestDataSetup'; @@ -66,6 +67,21 @@ program await execTask(cherryPickTask)({}); }); +program + .command('close-milestone') + .option('-m, --milestone ', 'Specify milestone') + .description('Helps ends a milestone by removing the cherry-pick label and closing it') + .action(async cmd => { + if (!cmd.milestone) { + console.log('Please specify milestone, example: -m '); + return; + } + + await execTask(closeMilestoneTask)({ + milestone: cmd.milestone, + }); + }); + program .command('precommit') .description('Executes checks') diff --git a/scripts/cli/tasks/changelog.ts b/scripts/cli/tasks/changelog.ts index 2b419d32a6f..e2cb9da7e55 100644 --- a/scripts/cli/tasks/changelog.ts +++ b/scripts/cli/tasks/changelog.ts @@ -1,18 +1,14 @@ -import axios from 'axios'; import _ from 'lodash'; import { Task, TaskRunner } from './task'; - -const githubGrafanaUrl = 'https://github.com/grafana/grafana'; +import GithubClient from '../utils/githubClient'; interface ChangelogOptions { milestone: string; } const changelogTaskRunner: TaskRunner = async ({ milestone }) => { - const client = axios.create({ - baseURL: 'https://api.github.com/repos/grafana/grafana', - timeout: 10000, - }); + const githubClient = new GithubClient(); + const client = githubClient.client; if (!/^\d+$/.test(milestone)) { console.log('Use milestone number not title, find number in milestone url'); @@ -45,13 +41,20 @@ const changelogTaskRunner: TaskRunner = async ({ milestone }) const notBugs = _.sortBy(issues.filter(item => !bugs.find(bug => bug === item)), 'title'); - let markdown = '### Features / Enhancements\n'; + let markdown = ''; + + if (notBugs.length > 0) { + markdown = '### Features / Enhancements\n'; + } for (const item of notBugs) { markdown += getMarkdownLineForIssue(item); } - markdown += '\n### Bug Fixes\n'; + if (bugs.length > 0) { + markdown += '\n### Bug Fixes\n'; + } + for (const item of bugs) { markdown += getMarkdownLineForIssue(item); } @@ -60,6 +63,7 @@ const changelogTaskRunner: TaskRunner = async ({ milestone }) }; function getMarkdownLineForIssue(item: any) { + const githubGrafanaUrl = 'https://github.com/grafana/grafana'; let markdown = ''; const title = item.title.replace(/^([^:]*)/, (match, g1) => { return `**${g1}**`; diff --git a/scripts/cli/tasks/cherrypick.ts b/scripts/cli/tasks/cherrypick.ts index ac92f223a7e..3e5a7addf42 100644 --- a/scripts/cli/tasks/cherrypick.ts +++ b/scripts/cli/tasks/cherrypick.ts @@ -1,17 +1,11 @@ import { Task, TaskRunner } from './task'; -import axios from 'axios'; +import GithubClient from '../utils/githubClient'; interface CherryPickOptions {} const cherryPickRunner: TaskRunner = async () => { - let client = axios.create({ - baseURL: 'https://api.github.com/repos/grafana/grafana', - timeout: 10000, - // auth: { - // username: '', - // password: '', - // }, - }); + const githubClient = new GithubClient(); + const client = githubClient.client; const res = await client.get('/issues', { params: { diff --git a/scripts/cli/tasks/closeMilestone.ts b/scripts/cli/tasks/closeMilestone.ts new file mode 100644 index 00000000000..9873863dc25 --- /dev/null +++ b/scripts/cli/tasks/closeMilestone.ts @@ -0,0 +1,75 @@ +import { Task, TaskRunner } from './task'; +import GithubClient from '../utils/githubClient'; + +interface CloseMilestoneOptions { + milestone: string; +} + +const closeMilestoneTaskRunner: TaskRunner = async ({ milestone }) => { + const githubClient = new GithubClient(true); + + const cherryPickLabel = 'cherry-pick needed'; + const client = githubClient.client; + + if (!/^\d+$/.test(milestone)) { + console.log('Use milestone number not title, find number in milestone url'); + return; + } + + const milestoneRes = await client.get(`/milestones/${milestone}`, {}); + + const milestoneState = milestoneRes.data.state; + + if (milestoneState === 'closed') { + console.log('milestone already closed. βœ…'); + return; + } + + console.log('fetching issues/PRs of the milestone ⏬'); + + // Get all the issues/PRs with the label cherry-pick + // Every pull request is actually an issue + const issuesRes = await client.get('/issues', { + params: { + state: 'closed', + labels: cherryPickLabel, + per_page: 100, + milestone: milestone, + }, + }); + + if (issuesRes.data.length < 1) { + console.log('no issues to remove label from'); + } else { + console.log(`found ${issuesRes.data.length} issues to remove the cherry-pick label from πŸ”Ž`); + } + + for (const issue of issuesRes.data) { + // the reason for using stdout.write is for achieving 'action -> result' on + // the same line + process.stdout.write(`πŸ”§removing label from issue #${issue.number} πŸ—‘...`); + const resDelete = await client.delete(`/issues/${issue.number}/labels/${cherryPickLabel}`, {}); + if (resDelete.status === 200) { + process.stdout.write('done βœ…\n'); + } else { + console.log('failed ❌'); + } + } + + console.log(`cleaned up ${issuesRes.data.length} issues/prs ⚑️`); + + const resClose = await client.patch(`/milestones/${milestone}`, { + state: 'closed', + }); + + if (resClose.status === 200) { + console.log('milestone closed πŸ™Œ'); + } else { + console.log('failed to close the milestone, response:'); + console.log(resClose); + } +}; + +export const closeMilestoneTask = new Task(); +closeMilestoneTask.setName('Close Milestone generator task'); +closeMilestoneTask.setRunner(closeMilestoneTaskRunner); diff --git a/scripts/cli/utils/githubClient.test.ts b/scripts/cli/utils/githubClient.test.ts new file mode 100644 index 00000000000..95c67cb3111 --- /dev/null +++ b/scripts/cli/utils/githubClient.test.ts @@ -0,0 +1,66 @@ +import GithubClient from './githubClient'; + +const fakeClient = jest.fn(); + +beforeEach(() => { + delete process.env.GITHUB_USERNAME; + delete process.env.GITHUB_ACCESS_TOKEN; +}); + +afterEach(() => { + delete process.env.GITHUB_USERNAME; + delete process.env.GITHUB_ACCESS_TOKEN; +}); + +describe('GithubClient', () => { + it('should initialise a GithubClient', () => { + const github = new GithubClient(); + expect(github).toBeInstanceOf(GithubClient); + }); + + describe('#client', () => { + it('it should contain a client', () => { + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient(); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana', + timeout: 10000, + }); + expect(client).toEqual(fakeClient); + }); + + describe('when the credentials are required', () => { + it('should create the client when the credentials are defined', () => { + const username = 'grafana'; + const token = 'averysecureaccesstoken'; + + process.env.GITHUB_USERNAME = username; + process.env.GITHUB_ACCESS_TOKEN = token; + + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient(true); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana', + timeout: 10000, + auth: { username, password: token }, + }); + + expect(client).toEqual(fakeClient); + }); + + describe('when the credentials are not defined', () => { + it('should throw an error', () => { + expect(() => { + new GithubClient(true); + }).toThrow(/operation needs a GITHUB_USERNAME and GITHUB_ACCESS_TOKEN environment variables/); + }); + }); + }); + }); +}); diff --git a/scripts/cli/utils/githubClient.ts b/scripts/cli/utils/githubClient.ts new file mode 100644 index 00000000000..a3eff8c532b --- /dev/null +++ b/scripts/cli/utils/githubClient.ts @@ -0,0 +1,41 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; + +const baseURL = 'https://api.github.com/repos/grafana/grafana'; + +// Encapsulates the creation of a client for the Github API +// +// Two key things: +// 1. You can specify whenever you want the credentials to be required or not when imported. +// 2. If the the credentials are available as part of the environment, even if +// they're not required - the library will use them. This allows us to overcome +// any API rate limiting imposed without authentication. + +class GithubClient { + client: AxiosInstance; + + constructor(required = false) { + const username = process.env.GITHUB_USERNAME; + const token = process.env.GITHUB_ACCESS_TOKEN; + + const clientConfig: AxiosRequestConfig = { + baseURL: baseURL, + timeout: 10000, + }; + + if (required && !username && !token) { + throw new Error('operation needs a GITHUB_USERNAME and GITHUB_ACCESS_TOKEN environment variables'); + } + + if (username && token) { + clientConfig.auth = { username: username, password: token }; + } + + this.client = this.createClient(clientConfig); + } + + private createClient(clientConfig: AxiosRequestConfig) { + return axios.create(clientConfig); + } +} + +export default GithubClient;