diff --git a/src/accountConfigs/christophehurpeau.ts b/src/accountConfigs/christophehurpeau.ts index c54439239..a2c8d3fcf 100644 --- a/src/accountConfigs/christophehurpeau.ts +++ b/src/accountConfigs/christophehurpeau.ts @@ -11,6 +11,7 @@ const config: Config<'dev', never> = { }, experimentalFeatures: { lintPullRequestTitleWithConventionalCommit: true, + githubAutoMerge: true, }, groups: { dev: { diff --git a/src/accountConfigs/types.ts b/src/accountConfigs/types.ts index be7a7257a..3395bac29 100644 --- a/src/accountConfigs/types.ts +++ b/src/accountConfigs/types.ts @@ -69,6 +69,7 @@ export interface LabelsConfig { interface ExperimentalFeatures { lintPullRequestTitleWithConventionalCommit?: true; + githubAutoMerge?: true; } interface WarnOnForcePushAfterReviewStarted { diff --git a/src/events/pr-handlers/actions/autoMergeIfPossible.ts b/src/events/pr-handlers/actions/autoMergeIfPossible.ts index e9f386aab..e98713daa 100644 --- a/src/events/pr-handlers/actions/autoMergeIfPossible.ts +++ b/src/events/pr-handlers/actions/autoMergeIfPossible.ts @@ -8,6 +8,7 @@ import type { PullRequestData, PullRequestFromRestEndpoint, PullRequestLabels, + PullRequestWithDecentData, } from '../utils/PullRequestData'; import type { ReviewflowPrContext } from '../utils/createPullRequestContext'; import { createMergeLockPrFromPr } from '../utils/mergeLock'; @@ -19,7 +20,7 @@ import hasLabelInPR from './utils/hasLabelInPR'; import { readPullRequestCommits } from './utils/readPullRequestCommits'; interface CreateCommitMessageOptions { - pullRequest: PullRequestFromRestEndpoint; + pullRequest: PullRequestWithDecentData; parsedBody: ParsedBody; options: Options; } @@ -127,6 +128,7 @@ export const autoMergeIfPossible = async < if (!repo) return false; if (repoContext.config.disableAutoMerge) return false; + if (repoContext.config.experimentalFeatures?.githubAutoMerge) return false; const autoMergeLabel = repoContext.labels['merge/automerge']; diff --git a/src/events/pr-handlers/actions/commentBodyEdited.ts b/src/events/pr-handlers/actions/commentBodyEdited.ts index 107b158fe..6795f8526 100644 --- a/src/events/pr-handlers/actions/commentBodyEdited.ts +++ b/src/events/pr-handlers/actions/commentBodyEdited.ts @@ -5,6 +5,10 @@ import type { PullRequestFromRestEndpoint } from '../utils/PullRequestData'; import type { ReviewflowPrContext } from '../utils/createPullRequestContext'; import { autoMergeIfPossible } from './autoMergeIfPossible'; import { editOpenedPR } from './editOpenedPR'; +import { + disableGithubAutoMerge, + enableGithubAutoMerge, +} from './enableGithubAutoMerge'; import { updateBranch } from './updateBranch'; import { updatePrCommentBodyIfNeeded } from './updatePrCommentBody'; import { updateStatusCheckFromLabels } from './updateStatusCheckFromLabels'; @@ -75,20 +79,48 @@ export const commentBodyEdited = async ( shouldHaveLabel: options.autoMerge, label: automergeLabel, onAdd: async (prLabels) => { - await autoMergeIfPossible( - pullRequest, - context, - repoContext, - reviewflowPrContext, - prLabels, - ); + if ( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) { + return ( + (await enableGithubAutoMerge( + pullRequest, + context, + repoContext, + reviewflowPrContext, + )) !== null + ); + } else { + await autoMergeIfPossible( + pullRequest, + context, + repoContext, + reviewflowPrContext, + prLabels, + ); + return true; + } }, onRemove: async () => { - await repoContext.removePrFromAutomergeQueue( - context, - pullRequest, - 'label removed', - ); + if ( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) { + return disableGithubAutoMerge( + pullRequest, + context, + repoContext, + reviewflowPrContext, + ); + } else { + await repoContext.removePrFromAutomergeQueue( + context, + pullRequest, + 'label removed', + ); + return true; + } }, }, ]), diff --git a/src/events/pr-handlers/actions/enableGithubAutoMerge.ts b/src/events/pr-handlers/actions/enableGithubAutoMerge.ts new file mode 100644 index 000000000..dcf2615d9 --- /dev/null +++ b/src/events/pr-handlers/actions/enableGithubAutoMerge.ts @@ -0,0 +1,104 @@ +import type { EventsWithRepository, RepoContext } from 'context/repoContext'; +import type { AutoMergeRequest } from '../../../utils/github/pullRequest/autoMerge'; +import { + enableGithubAutoMergeMutation, + disableGithubAutoMergeMutation, +} from '../../../utils/github/pullRequest/autoMerge'; +import type { ProbotEvent } from '../../probot-types'; +import type { PullRequestWithDecentData } from '../utils/PullRequestData'; +import type { ReviewflowPrContext } from '../utils/createPullRequestContext'; +import { createCommitMessage } from './autoMergeIfPossible'; +import { parseBody } from './utils/body/parseBody'; + +export const enableGithubAutoMerge = async < + EventName extends EventsWithRepository, +>( + pullRequest: PullRequestWithDecentData, + context: ProbotEvent, + repoContext: RepoContext, + reviewflowPrContext: ReviewflowPrContext, + login?: string, +): Promise => { + // TODO prevent merge when statuses are failing (see autoMergeIfPossible) => add in reviewflow status check + + const parsedBody = parseBody( + reviewflowPrContext.commentBody, + repoContext.config.prDefaultOptions, + ); + const options = parsedBody?.options || repoContext.config.prDefaultOptions; + + const [commitHeadline, commitBody] = createCommitMessage({ + pullRequest, + parsedBody, + options, + }); + + try { + /* Conditions: +Allow auto-merge enabled in settings. +The pull request base must have a branch protection rule with at least one requirement enabled. +The pull request must be in a state where requirements have not yet been satisfied. If the pull request can already be merged, attempting to enable auto-merge will fail. +*/ + const response = await enableGithubAutoMergeMutation(context, { + pullRequestId: pullRequest.node_id, + mergeMethod: 'SQUASH', + commitHeadline, + commitBody, + }); + return response.enablePullRequestAutoMerge.pullRequest.autoMergeRequest; + } catch (err) { + context.log.error( + 'Could not enable automerge', + context.repo({ + issue_number: pullRequest.number, + }), + err, + ); + context.octokit.issues.createComment( + context.repo({ + issue_number: pullRequest.number, + body: `${login ? `@${login} ` : ''}Could not enable automerge`, + }), + ); + } + return null; +}; + +export const disableGithubAutoMerge = async < + EventName extends EventsWithRepository, +>( + pullRequest: PullRequestWithDecentData, + context: ProbotEvent, + repoContext: RepoContext, + reviewflowPrContext: ReviewflowPrContext, + login?: string, +): Promise => { + try { + /* Conditions: +Allow auto-merge enabled in settings. +The pull request base must have a branch protection rule with at least one requirement enabled. +The pull request must be in a state where requirements have not yet been satisfied. If the pull request can already be merged, attempting to enable auto-merge will fail. +*/ + const response = await disableGithubAutoMergeMutation(context, { + pullRequestId: pullRequest.node_id, + }); + return ( + response.disablePullRequestAutoMerge.pullRequest.autoMergeRequest === null + ); + } catch (err) { + context.log.error( + 'Could not disable automerge', + context.repo({ + issue_number: pullRequest.number, + }), + err, + ); + context.octokit.issues.createComment( + context.repo({ + issue_number: pullRequest.number, + body: `${login ? `@${login} ` : ''}Could not disable automerge`, + }), + ); + return false; + } +}; diff --git a/src/events/pr-handlers/actions/utils/syncLabel.ts b/src/events/pr-handlers/actions/utils/syncLabel.ts index f0b6f8f45..43d916b1c 100644 --- a/src/events/pr-handlers/actions/utils/syncLabel.ts +++ b/src/events/pr-handlers/actions/utils/syncLabel.ts @@ -5,7 +5,10 @@ import type { ProbotEvent } from 'events/probot-types'; import type { LabelResponse } from '../../../../context/initRepoLabels'; import hasLabelInPR from './hasLabelInPR'; -type SyncLabelCallback = (prLabels: LabelResponse[]) => void | Promise; +type SyncLabelCallback = ( + prLabels: LabelResponse[], + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type +) => void | undefined | boolean | Promise; interface SyncLabelOptions { onRemove?: SyncLabelCallback; @@ -27,13 +30,25 @@ export default async function syncLabel< const response = await context.octokit.issues.removeLabel( context.issue({ name: label.name }), ); - if (onRemove) await onRemove(response.data); + if (onRemove) { + if ((await onRemove(response.data)) === false) { + await context.octokit.issues.addLabels( + context.issue({ labels: [label.name] }), + ); + } + } } if (shouldHaveLabel && !prHasLabel) { const response = await context.octokit.issues.addLabels( context.issue({ labels: [label.name] }), ); - if (onAdd) await onAdd(response.data); + if (onAdd) { + if ((await onAdd(response.data)) === false) { + await context.octokit.issues.removeLabel( + context.issue({ name: label.name }), + ); + } + } } } @@ -75,6 +90,7 @@ export async function syncLabels( onRemove, onAdd, }) => { + if (!label) return; if (prHasLabel && shouldHaveLabel === false) { labelsToRemove.push(label); if (onRemove) callbacks.push(onRemove); diff --git a/src/events/pr-handlers/autoMergeChanged.ts b/src/events/pr-handlers/autoMergeChanged.ts new file mode 100644 index 000000000..0dd7a6292 --- /dev/null +++ b/src/events/pr-handlers/autoMergeChanged.ts @@ -0,0 +1,78 @@ +import type { Probot } from 'probot'; +import type { AppContext } from '../../context/AppContext'; +import { checkIfIsThisBot } from '../../utils/github/isBotUser'; +import { updatePrCommentBodyOptions } from './actions/updatePrCommentBody'; +import { syncLabels } from './actions/utils/syncLabel'; +import { createPullRequestHandler } from './utils/createPullRequestHandler'; + +export default function autoMergeChangedHandler( + app: Probot, + appContext: AppContext, +): void { + createPullRequestHandler( + app, + appContext, + 'pull_request.auto_merge_enabled', + (payload, context, repoContext) => { + if (repoContext.shouldIgnore) return null; + + if (checkIfIsThisBot(payload.sender)) { + // ignore from this bot + return null; + } + + return payload.pull_request; + }, + async (pullRequest, context, repoContext, reviewflowPrContext) => { + const autoMergeLabel = repoContext.labels['merge/automerge']; + + await Promise.all([ + reviewflowPrContext && + (await updatePrCommentBodyOptions( + context, + repoContext, + reviewflowPrContext, + { + autoMerge: true, + }, + )), + syncLabels(pullRequest, context, [ + { + shouldHaveLabel: true, + label: autoMergeLabel, + }, + ]), + ]); + }, + ); + createPullRequestHandler( + app, + appContext, + 'pull_request.auto_merge_disabled', + (payload, context, repoContext) => { + if (repoContext.shouldIgnore) return null; + return payload.pull_request; + }, + async (pullRequest, context, repoContext, reviewflowPrContext) => { + const autoMergeLabel = repoContext.labels['merge/automerge']; + + await Promise.all([ + reviewflowPrContext && + (await updatePrCommentBodyOptions( + context, + repoContext, + reviewflowPrContext, + { + autoMerge: false, + }, + )), + syncLabels(pullRequest, context, [ + { + shouldHaveLabel: false, + label: autoMergeLabel, + }, + ]), + ]); + }, + ); +} diff --git a/src/events/pr-handlers/labelsChanged.ts b/src/events/pr-handlers/labelsChanged.ts index 84939f708..56370c6d0 100644 --- a/src/events/pr-handlers/labelsChanged.ts +++ b/src/events/pr-handlers/labelsChanged.ts @@ -2,6 +2,10 @@ import type { Probot } from 'probot'; import type { ProbotEvent } from 'events/probot-types'; import type { AppContext } from '../../context/AppContext'; import { autoMergeIfPossible } from './actions/autoMergeIfPossible'; +import { + enableGithubAutoMerge, + disableGithubAutoMerge, +} from './actions/enableGithubAutoMerge'; import { updateBranch } from './actions/updateBranch'; import { updatePrCommentBodyOptions } from './actions/updatePrCommentBody'; import { updateReviewStatus } from './actions/updateReviewStatus'; @@ -46,12 +50,16 @@ export default function labelsChanged( const fromRenovate = isFromRenovate(context.payload); const updatedPr = await fetchPr(context, pullRequest.number); + const updateBranchLabel = repoContext.labels['merge/update-branch']; + const autoMergeLabel = repoContext.labels['merge/automerge']; + const autoMergeSkipCiLabel = repoContext.labels['merge/skip-ci']; + const label = context.payload.label; + let successful = true; + if (fromRenovate) { const codeApprovedLabel = repoContext.labels['code/approved']; const codeNeedsReviewLabel = repoContext.labels['code/needs-review']; - const autoMergeLabel = repoContext.labels['merge/automerge']; - const autoMergeSkipCiLabel = repoContext.labels['merge/skip-ci']; if (context.payload.action === 'labeled') { if (codeApprovedLabel && label.id === codeApprovedLabel.id) { // const { data: reviews } = await context.octokit.pulls.listReviews( @@ -127,13 +135,33 @@ export default function labelsChanged( : repoContext.config.prDefaultOptions.autoMergeWithSkipCi, }, ); + + if ( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) { + await enableGithubAutoMerge( + pullRequest, + context, + repoContext, + reviewflowPrContext, + ); + } + } + + if ( + !( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) + ) { + await autoMergeIfPossible( + updatedPr, + context, + repoContext, + reviewflowPrContext, + ); } - await autoMergeIfPossible( - updatedPr, - context, - repoContext, - reviewflowPrContext, - ); } return; } @@ -159,46 +187,65 @@ export default function labelsChanged( reviewflowPrContext, ); - const updateBranchLabel = repoContext.labels['merge/update-branch']; - const automergeLabel = repoContext.labels['merge/automerge']; - const skipCiLabel = repoContext.labels['merge/skip-ci']; - - const option = (() => { - if (automergeLabel && label.id === automergeLabel.id) { - return 'autoMerge'; - } - if (skipCiLabel && label.id === skipCiLabel.id) { - return 'autoMergeWithSkipCi'; - } - return null; - })(); - - if (option) { - await updatePrCommentBodyOptions( - context, - repoContext, - reviewflowPrContext, - { - [option]: context.payload.action === 'labeled', - }, - ); - } // not an else if - if (automergeLabel && label.id === automergeLabel.id) { + // not an else if + if (autoMergeLabel && label.id === autoMergeLabel.id) { if (context.payload.action === 'labeled') { - await autoMergeIfPossible( - updatedPr, - context, - repoContext, - reviewflowPrContext, - ); + if ( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) { + successful = + (await enableGithubAutoMerge( + pullRequest, + context, + repoContext, + reviewflowPrContext, + context.payload.sender.login, + )) !== null; + + // if not successful, remove label + if (!successful) { + await context.octokit.issues.removeLabel( + context.issue({ name: label.name }), + ); + } + } else { + await autoMergeIfPossible( + updatedPr, + context, + repoContext, + reviewflowPrContext, + ); + } } else { - await repoContext.removePrFromAutomergeQueue( - context, - pullRequest, - 'automerge label removed', - ); + // eslint-disable-next-line no-lonely-if + if ( + repoContext.settings.allowAutoMerge && + repoContext.config.experimentalFeatures?.githubAutoMerge + ) { + successful = await disableGithubAutoMerge( + pullRequest, + context, + repoContext, + reviewflowPrContext, + context.payload.sender.login, + ); + // if not successful, add label back + if (!successful) { + await context.octokit.issues.addLabels( + context.issue({ labels: [label.name] }), + ); + } + } else { + await repoContext.removePrFromAutomergeQueue( + context, + pullRequest, + 'automerge label removed', + ); + } } } + if (updateBranchLabel && label.id === updateBranchLabel.id) { if (context.payload.action === 'labeled') { await updateBranch(updatedPr, context, context.payload.sender.login); @@ -207,6 +254,29 @@ export default function labelsChanged( ); } } + + if (successful) { + const option = (() => { + if (autoMergeLabel && label.id === autoMergeLabel.id) { + return 'autoMerge'; + } + if (autoMergeSkipCiLabel && label.id === autoMergeSkipCiLabel.id) { + return 'autoMergeWithSkipCi'; + } + return null; + })(); + + if (option) { + await updatePrCommentBodyOptions( + context, + repoContext, + reviewflowPrContext, + { + [option]: context.payload.action === 'labeled', + }, + ); + } + } }, ); } diff --git a/src/initApp.ts b/src/initApp.ts index bd3aef9dc..67b97e304 100644 --- a/src/initApp.ts +++ b/src/initApp.ts @@ -6,6 +6,7 @@ import orgMemberAddedOrRemoved from './events/account-handlers/orgMemberAddedOrR import teamChanged from './events/account-handlers/teamChanged'; import commitCommentCreated from './events/commit-handlers/commitCommentCreated'; import assignedOrUnassignedHandler from './events/pr-handlers/assignedOrUnassigned'; +import autoMergeChangedHandler from './events/pr-handlers/autoMergeChanged'; import checkrunCompleted from './events/pr-handlers/checkrunCompleted'; import checksuiteCompleted from './events/pr-handlers/checksuiteCompleted'; import closedHandler from './events/pr-handlers/closed'; @@ -63,6 +64,7 @@ export default function initApp(app: Probot, appContext: AppContext): void { reviewDismissedHandler(app, appContext); labelsChanged(app, appContext); synchronizeHandler(app, appContext); + autoMergeChangedHandler(app, appContext); /* https://developer.github.com/webhooks/event-payloads/#pull_request_review_comment */ /* https://developer.github.com/webhooks/event-payloads/#issue_comment */ diff --git a/src/utils/github/pullRequest/autoMerge.ts b/src/utils/github/pullRequest/autoMerge.ts new file mode 100644 index 000000000..1198b18b8 --- /dev/null +++ b/src/utils/github/pullRequest/autoMerge.ts @@ -0,0 +1,83 @@ +import type { Context } from 'probot'; + +export interface AutoMergeRequest { + enabledAt?: string; + enabledBy: { + login: string; + }; +} + +export interface EnablePullRequestAutoMergeParams { + pullRequestId: string; + mergeMethod: 'SQUASH'; + commitHeadline: string; + commitBody: string; +} + +export interface EnablePullRequestAutoMergeResponse { + enablePullRequestAutoMerge: { + pullRequest: { + autoMergeRequest: AutoMergeRequest; + }; + }; +} + +export const enableGithubAutoMergeMutation = ( + context: Context, + params: EnablePullRequestAutoMergeParams, +): Promise => { + return context.octokit.graphql( + `mutation EnableGithubAutoMergeMutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $commitHeadline: String!, $commitBody: String!) { + enablePullRequestAutoMerge(input: { + pullRequestId: $pullRequestId, + mergeMethod: $mergeMethod, + commitHeadline: $commitHeadline, + commitBody: $commitBody + }) { + pullRequest { + autoMergeRequest { + enabledAt + enabledBy { + login + } + } + } + } + }`, + params as any, + ); +}; + +export interface DisablePullRequestAutoMergeParams { + pullRequestId: string; +} + +export interface DisablePullRequestAutoMergeResponse { + disablePullRequestAutoMerge: { + pullRequest: { + autoMergeRequest: null; + }; + }; +} +export const disableGithubAutoMergeMutation = ( + context: Context, + params: DisablePullRequestAutoMergeParams, +): Promise => { + return context.octokit.graphql( + `mutation DisableGithubAutoMergeMutation($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { + pullRequestId: $pullRequestId + }) { + pullRequest { + autoMergeRequest { + enabledAt + enabledBy { + login + } + } + } + } + }`, + params as any, + ); +};