diff --git a/functions/src/default.ts b/functions/src/default.ts index 0e95e6e..2fd3127 100644 --- a/functions/src/default.ts +++ b/functions/src/default.ts @@ -84,6 +84,7 @@ Please help to unblock it by resolving these conflicts. Thanks!`, If you can't get the PR to a green state due to flakes or broken master, please try rebasing to master and/or restarting the CI job. If that fails and you believe that the issue is not due to your change, please contact the caretaker and ask for help.` }, + // triage for issues triage: { // set to true to disable disabled: true, @@ -95,12 +96,27 @@ If you can't get the PR to a green state due to flakes or broken master, please l1TriageLabels: [["comp: *"]], // arrays of labels that determine if an issue has been fully triaged l2TriageLabels: [["type: bug/fix", "severity*", "freq*", "comp: *"], ["type: feature", "comp: *"], ["type: refactor", "comp: *"], ["type: RFC / Discussion / question", "comp: *"]] + }, + + // triage for PRs + triagePR: { + // set to true to disable + disabled: true, + // number of the milestone to apply when the PR has not been triaged yet + needsTriageMilestone: 83, + // number of the milestone to apply when the PR is triaged + defaultMilestone: 82, + // arrays of labels that determine if a PR has been triaged by the caretaker + l1TriageLabels: [["comp: *"]], + // arrays of labels that determine if a PR has been fully triaged + l2TriageLabels: [["type: *", "effort*", "risk*", "comp: *"]] } }; export interface AppConfig { merge: MergeConfig; triage: TriageConfig; + triagePR: TriagePRConfig; size: SizeConfig; } @@ -143,6 +159,14 @@ export interface TriageConfig { triagedLabels?: string[][]; } +export interface TriagePRConfig { + disabled: boolean; + needsTriageMilestone: number; + defaultMilestone: number; + l1TriageLabels: string[][]; + l2TriageLabels: string[][]; +} + export interface SizeConfig { disabled: boolean; maxSizeIncrease: number | string; diff --git a/functions/src/index.ts b/functions/src/index.ts index a616ea8..507427a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -83,9 +83,9 @@ exports.init = https.onRequest(async (request: Request, response: Response) => { }); /** - * Manually trigger init for triage issues, you shouldn't need to use that unless you clean the database + * Manually trigger init to triage issues, you shouldn't need to use that unless you clean the database */ -exports.initIssues = https.onRequest(async (request: Request, response: Response) => { +exports.initTriage = https.onRequest(async (request: Request, response: Response) => { try { await tasks.triageTask.manualInit().catch(err => { console.error(err); @@ -102,6 +102,26 @@ exports.initIssues = https.onRequest(async (request: Request, response: Response } }); +/** + * Manually trigger init to triage PR, you shouldn't need to use that unless you clean the database + */ +exports.initTriagePR = https.onRequest(async (request: Request, response: Response) => { + try { + await tasks.triagePRTask.manualInit().catch(err => { + console.error(err); + }); + response.send({ + statusCode: 200, + body: JSON.stringify({ + message: 'Init triage PRs function started' + }) + }); + } catch(err) { + console.error(err); + response.sendStatus(500); + } +}); + /** * Init the PRs of a repository, triggered by an insertion in the "repositories" table */ diff --git a/functions/src/plugins/triage.ts b/functions/src/plugins/triage.ts index 01fcb0a..2c1c3ac 100644 --- a/functions/src/plugins/triage.ts +++ b/functions/src/plugins/triage.ts @@ -13,6 +13,7 @@ export class TriageTask extends Task { this.dispatch([ 'issues.labeled', 'issues.unlabeled', + 'issues.demilestoned', 'issues.milestoned', 'issues.opened' ], this.checkTriage.bind(this)); @@ -71,29 +72,31 @@ export class TriageTask extends Task { } async checkTriage(context: Context): Promise { - const issue: Github.IssuesGetResponse = context.payload.issue; - const config = await this.getConfig(context); - if(config.disabled) { - return; - } - const {owner, repo} = context.repo(); - // getting labels from Github because we might be adding multiple labels at once - const labels = await getGhLabels(context.github, owner, repo, issue.number); - const isL1Triaged = this.isTriaged(config.l1TriageLabels, getLabelsNames(labels)); - if(!isL1Triaged) { - if(issue.milestone) { - await this.setMilestone(null, context.github, owner, repo, issue); + if(!context.payload.pull_request && !context.payload.issue.pull_request) { + const issue: Github.IssuesGetResponse = context.payload.issue; + const config = await this.getConfig(context); + if(config.disabled) { + return; } - } else if(!issue.milestone || issue.milestone.number === config.defaultMilestone || issue.milestone.number === config.needsTriageMilestone) { - const isL2Triaged = this.isTriaged(config.l2TriageLabels || config.triagedLabels, getLabelsNames(labels)); - if(isL2Triaged) { - if(!issue.milestone || issue.milestone.number !== config.defaultMilestone) { - await this.setMilestone(config.defaultMilestone, context.github, owner, repo, issue); + const {owner, repo} = context.repo(); + // getting labels from Github because we might be adding multiple labels at once + const labels = await getGhLabels(context.github, owner, repo, issue.number); + const isL1Triaged = this.isTriaged(config.l1TriageLabels, getLabelsNames(labels)); + if(!isL1Triaged) { + if(issue.milestone) { + await this.setMilestone(null, context.github, owner, repo, issue); } - } else { - // if it's not triaged, set the "needsTriage" milestone - if(!issue.milestone || issue.milestone.number !== config.needsTriageMilestone) { - await this.setMilestone(config.needsTriageMilestone, context.github, owner, repo, issue); + } else if(!issue.milestone || issue.milestone.number === config.defaultMilestone || issue.milestone.number === config.needsTriageMilestone) { + const isL2Triaged = this.isTriaged(config.l2TriageLabels || config.triagedLabels, getLabelsNames(labels)); + if(isL2Triaged) { + if(!issue.milestone || issue.milestone.number !== config.defaultMilestone) { + await this.setMilestone(config.defaultMilestone, context.github, owner, repo, issue); + } + } else { + // if it's not triaged, set the "needsTriage" milestone + if(!issue.milestone || issue.milestone.number !== config.needsTriageMilestone) { + await this.setMilestone(config.needsTriageMilestone, context.github, owner, repo, issue); + } } } } diff --git a/functions/src/plugins/triagePR.ts b/functions/src/plugins/triagePR.ts new file mode 100644 index 0000000..e5d0713 --- /dev/null +++ b/functions/src/plugins/triagePR.ts @@ -0,0 +1,131 @@ +import {Application, Context} from "probot"; +import {Task} from "./task"; +import {CONFIG_FILE} from "./merge"; +import {AdminConfig, AppConfig, appConfig, TriageConfig} from "../default"; +import {getGhLabels, getLabelsNames, matchAllOfAny} from "./common"; +import Github from '@octokit/rest'; + +export class TriagePRTask extends Task { + constructor(robot: Application, db: FirebaseFirestore.Firestore) { + super(robot, db); + + // PRs are issues for github + this.dispatch([ + 'pull_request.labeled', + 'pull_request.unlabeled', + 'issues.demilestoned', + 'issues.milestoned', + 'issues.opened' + ], this.checkTriage.bind(this)); + } + + async manualInit(): Promise { + this.log('init triage PR'); + const adminConfig = await this.admin.doc('config').get(); + if(adminConfig.exists && (adminConfig.data()).allowInit) { + const github = await this.robot.auth(); + const installations = await github.paginate(github.apps.getInstallations({}), pages => pages.data); + await Promise.all(installations.map(async installation => { + const authGithub = await this.robot.auth(installation.id); + const repositories = await authGithub.apps.getInstallationRepositories({}); + await Promise.all(repositories.data.repositories.map(async (repository: Github.AppsGetInstallationRepositoriesResponseRepositoriesItem) => { + const context = new Context({payload: {repository}}, authGithub, this.robot.log); + const config = await this.getConfig(context); + if(config.disabled) { + return; + } + const {owner, repo} = context.repo(); + const issues = await authGithub.paginate(authGithub.issues.getForRepo({ + owner, + repo, + state: 'open', + per_page: 100 + }), page => page.data); + + issues.forEach(async (issue: Github.IssuesGetForRepoResponseItem) => { + // We only want the PRs, not the issues + if(issue.pull_request) { + const isL1Triaged = this.isTriaged(config.l1TriageLabels, issue.labels.map((label: Github.IssuesGetForRepoResponseItemLabelsItem) => label.name)); + if(!isL1Triaged) { + if(issue.milestone) { + await this.setMilestone(null, context.github, owner, repo, issue); + } + } else if(!issue.milestone || issue.milestone.number === config.defaultMilestone || issue.milestone.number === config.needsTriageMilestone) { + const isL2Triaged = this.isTriaged(config.l2TriageLabels || config.triagedLabels, issue.labels.map((label: Github.IssuesGetForRepoResponseItemLabelsItem) => label.name)); + if(isL2Triaged) { + if(!issue.milestone || issue.milestone.number !== config.defaultMilestone) { + await this.setMilestone(config.defaultMilestone, context.github, owner, repo, issue); + } + } else { + // if it's not triaged, set the "needsTriage" milestone + if(!issue.milestone || issue.milestone.number !== config.needsTriageMilestone) { + await this.setMilestone(config.needsTriageMilestone, context.github, owner, repo, issue); + } + } + } + } + }); + })); + })); + } else { + this.logError(`Manual init is disabled: the value of allowInit is set to false in the admin config database`); + } + } + + async checkTriage(context: Context): Promise { + if((context.payload.issue && context.payload.issue.pull_request) || context.payload.pull_request) { + const PR: Github.PullRequestsGetResponse | Github.IssuesGetResponse = context.payload.pull_request || context.payload.issue; + const config = await this.getConfig(context); + if(config.disabled) { + return; + } + const {owner, repo} = context.repo(); + // getting labels from Github because we might be adding multiple labels at once + const labels = await getGhLabels(context.github, owner, repo, PR.number); + const isL1Triaged = this.isTriaged(config.l1TriageLabels, getLabelsNames(labels)); + if(!isL1Triaged) { + if(PR.milestone) { + await this.setMilestone(null, context.github, owner, repo, PR); + } + } else if(!PR.milestone || PR.milestone.number === config.defaultMilestone || PR.milestone.number === config.needsTriageMilestone) { + const isL2Triaged = this.isTriaged(config.l2TriageLabels || config.triagedLabels, getLabelsNames(labels)); + if(isL2Triaged) { + if(!PR.milestone || PR.milestone.number !== config.defaultMilestone) { + await this.setMilestone(config.defaultMilestone, context.github, owner, repo, PR); + } + } else { + // if it's not triaged, set the "needsTriage" milestone + if(!PR.milestone || PR.milestone.number !== config.needsTriageMilestone) { + await this.setMilestone(config.needsTriageMilestone, context.github, owner, repo, PR); + } + } + } + } + } + + setMilestone(milestoneNumber: number | null, github: Github, owner: string, repo: string, PR: Github.PullRequestsGetResponse|Github.IssuesGetForRepoResponseItem): Promise> { + if(milestoneNumber) { + this.log(`Adding milestone ${milestoneNumber} to PR ${PR.html_url}`); + } else { + this.log(`Removing milestone from PR ${PR.html_url}`); + } + return github.issues.edit({owner, repo, number: PR.number, milestone: milestoneNumber}).catch(err => { + throw err; + }); + } + + isTriaged(triagedLabels: string[][], currentLabels: string[]): boolean { + return matchAllOfAny(currentLabels, triagedLabels); + } + + /** + * Gets the config for the merge plugin from Github or uses default if necessary + */ + async getConfig(context: Context): Promise { + const repositoryConfig = await context.config(CONFIG_FILE, appConfig); + const config = repositoryConfig.triagePR; + config.defaultMilestone = parseInt(config.defaultMilestone, 10); + config.needsTriageMilestone = parseInt(config.needsTriageMilestone, 10); + return config; + } +} diff --git a/functions/src/util.ts b/functions/src/util.ts index 1b60383..09d5603 100644 --- a/functions/src/util.ts +++ b/functions/src/util.ts @@ -3,6 +3,7 @@ import {CommonTask} from "./plugins/common"; import {MergeTask} from "./plugins/merge"; import {TriageTask} from "./plugins/triage"; import {SizeTask} from "./plugins/size"; +import {TriagePRTask} from "./plugins/triagePR"; class Stream { constructor(private store: FirebaseFirestore.Firestore) { @@ -93,6 +94,7 @@ export interface Tasks { commonTask: CommonTask; mergeTask: MergeTask; triageTask: TriageTask; + triagePRTask: TriagePRTask; sizeTask: SizeTask; } @@ -102,6 +104,7 @@ export function registerTasks(robot: Application, store: FirebaseFirestore.Fires commonTask: new CommonTask(robot, store), mergeTask: new MergeTask(robot, store), triageTask: new TriageTask(robot, store), + triagePRTask: new TriagePRTask(robot, store), sizeTask: new SizeTask(robot, store), }; } diff --git a/test/fixtures/angular-robot.yml b/test/fixtures/angular-robot.yml index b2560b5..cbf7c5c 100644 --- a/test/fixtures/angular-robot.yml +++ b/test/fixtures/angular-robot.yml @@ -100,7 +100,7 @@ merge: \n \nIf you can't get the PR to a green state due to flakes or broken master, please try rebasing to master and/or restarting the CI job. If that fails and you believe that the issue is not due to your change, please contact the caretaker and ask for help." -# options for the triage plugin +# options for the triage issues plugin triage: # set to true to disable disabled: false @@ -128,3 +128,23 @@ triage: - - "type: RFC / Discussion / question" - "comp: *" + +# options for the triage PR plugin +triagePR: + # set to true to disable + disabled: false + # number of the milestone to apply when the PR has not been triaged yet + needsTriageMilestone: 83, + # number of the milestone to apply when the PR is triaged + defaultMilestone: 82, + # arrays of labels that determine if a PR has been triaged by the caretaker + l1TriageLabels: + - + - "comp: *" + # arrays of labels that determine if a PR has been fully triaged + l2TriageLabels: + - + - "type: *" + - "effort*" + - "risk*" + - "comp: *"