import { info , warning , group } from "@actions/core" ; import { GitHub } from "@actions/github/lib/utils" ; import type { Context } from "@actions/github/lib/context" ; import type { EventPayloads } from "@octokit/webhooks" ; import type { PullsGetResponseData , PullsListCommitsResponseData , } from "@octokit/types" ; import * as assert from "assert" ; import promiseRetry from "promise-retry" ; import { filterBody } from "./filter-body" ; import { handleError } from "./handle-error" ; /**
 * See https://developer.github.com/v4/enum/mergestatestatus/
 */ type MergeableState = "behind" | "blocked" | "clean" | "dirty" | "draft" | "unknown" | "unstable" ; type WebhookPayload = EventPayloads . WebhookPayloadCheckRun | EventPayloads . WebhookPayloadPullRequest | EventPayloads . WebhookPayloadPullRequestReview | EventPayloads . WebhookPayloadStatus ; type Author = { email : string ; name : string ; } ; const updateableMergeableStates = [ "behind" , ] ; const potentialMergeableStates = [ "clean" , "unstable" , ] ; const getPullRequestId = ( pullRequestNumber ) => `#${ pullRequestNumber }` ; const isCandidate = ( { closed_at , labels , } , autosquashLabel ) : boolean => { if ( closed_at !== null ) { info ( "Already merged or closed" ) ; return false ; } if ( ! labels . some ( ( { name } ) => name === autosquashLabel ) ) { info ( `No ${ autosquashLabel } label` ) ; return false ; } return true ; } ; const isCandidateWithMergeableState = ( { closed_at , labels , mergeable_state : actualMergeableState , } , autosquashLabel , expectedMergeableState ) : boolean => { if ( ! isCandidate ( { closed_at , labels } , autosquashLabel ) ) { return false ; } info ( `Mergeable state is ${ actualMergeableState }` ) ; return Array . isArray ( expectedMergeableState ) ? expectedMergeableState . includes ( actualMergeableState as MergeableState ) : actualMergeableState === expectedMergeableState ; } ; const fetchPullRequest = async ( { github , owner , pullRequestNumber , repo , } ) => { info ( "Fetching pull request details" ) ; return promiseRetry ( async ( retry ) => { try { const { data : pullRequest } = await github . pulls . get ( { owner , pull_number : pullRequestNumber , repo , } ) ; assert ( pullRequest . closed_at !== null || ( pullRequest . mergeable_state as MergeableState ) !== "unknown" ) ; return pullRequest ; } catch ( error ) { info ( "Refetching details to know mergeable state" ) ; return retry ( error ) ; } } , { minTimeout : 250 } ) ; } ; const handlePullRequests = async ( { github , handle , owner , pullRequestNumbers , repo , } ) => { for ( const pullRequestNumber of pullRequestNumbers ) { await group ( `Handling ${ getPullRequestId ( pullRequestNumber ) }` , async ( ) => { const pullRequest = await fetchPullRequest ( { github , owner , pullRequestNumber , repo , } ) ; await handle ( pullRequest ) ; } ) ; } } ; const handleSearchedPullRequests = async ( { autosquashLabel , github , handle , owner , query , repo , } ) => { const fullQuery = `is:pr is:open label:"${ autosquashLabel }" repo:${ owner }/${ repo } ${ query }` ; const { data : { incomplete_results , items } , } = await github . search . issuesAndPullRequests ( { order : "asc" , q : fullQuery , sort : "created" , } ) ; if ( incomplete_results ) { warning ( `Search has incomplete results, only the first ${ items . length } items will be handled` ) ; } for ( const item of items ) { await group ( `Handling searched pull request ${ getPullRequestId ( item . number ) }` , async ( ) => { if ( isCandidate ( item , autosquashLabel ) ) { const pullRequest = await fetchPullRequest ( { github , owner , pullRequestNumber : item . number , repo , } ) ; await handle ( pullRequest ) ; } } ) ; } } ; const fetchPullRequestCoAuthors = async ( { github , owner , pullRequestCreator , pullRequestNumber , repo , } ) : Promise < Author [ ] > => { const options = github . pulls . listCommits . endpoint . merge ( { owner , pull_number : pullRequestNumber , repo , } ) ; const commits = await github . paginate ( options ) ; const authorUsernames = new Set < string > ( ) ; const coAuthors = [ ] ; commits . filter ( ( { author , parents } ) => parents . length === 1 && author !== null && author . login !== pullRequestCreator && author . type === "User" ) . forEach ( ( { author : { login : username } , commit : { author : { email , name } , } , } ) => { if ( ! authorUsernames . has ( username ) ) { authorUsernames . add ( username ) ; coAuthors . push ( { email , name } ) ; } } ) ; return coAuthors ; } ; const getSquashedCommitMessage = ( { body , coAuthors , } ) : string => { const prBody = filterBody ( body ) ; if ( coAuthors . length === 0 ) { return prBody ; } const coAuthorLines = coAuthors . map ( ( { email , name } ) => `Co-authored-by: ${ name } <${ email }>` ) ; return [ prBody , "" , ... coAuthorLines ] . join ( "\n" ) ; } ; const merge = async ( { github , owner , pullRequest : { body , head : { sha } , number : pullRequestNumber , title , user : { login : pullRequestCreator } , } , repo , } ) => { const coAuthors = await fetchPullRequestCoAuthors ( { github , owner , pullRequestCreator , pullRequestNumber , repo , } ) ; try { info ( "Attempting merge" ) ; await github . pulls . merge ( { commit_message : getSquashedCommitMessage ( { body , coAuthors } ) , commit_title : `${ title } (#${ pullRequestNumber })` , merge_method : "squash" , owner , pull_number : pullRequestNumber , repo , sha , } ) ; info ( "Merged!" ) ; } catch ( error ) { handleError ( error ) ; } } ; const update = async ( { github , owner , pullRequest : { head : { sha : expected_head_sha } , number : pull_number , } , repo , } ) => { try { info ( "Attempting update" ) ; await github . pulls . updateBranch ( { expected_head_sha , owner , pull_number , repo , } ) ; info ( "Updated!" ) ; } catch ( error ) { handleError ( error ) ; } } ; const autosquash = async ( { autosquashLabel , context , github , } ) => { const { eventName } = context ; const { repository : { name : repo , owner : { login : owner } , } , } = context . payload as WebhookPayload ; if ( eventName === "check_run" ) { const payload = context . payload as EventPayloads . WebhookPayloadCheckRun ; if ( payload . action === "completed" ) { const pullRequestNumbers = payload . check_run . pull_requests . map ( ( { number } ) => number ) ; info ( `Consider merging ${ JSON . stringify ( pullRequestNumbers . map ( ( pullRequestNumber ) => getPullRequestId ( pullRequestNumber ) ) ) }` ) ; await handlePullRequests ( { github , async handle ( pullRequest ) { if ( isCandidateWithMergeableState ( pullRequest , autosquashLabel , potentialMergeableStates ) ) { await merge ( { github , owner , pullRequest , repo , } ) ; } } , owner , pullRequestNumbers , repo , } ) ; } } else if ( eventName === "pull_request" ) { const payload = context . payload as EventPayloads . WebhookPayloadPullRequest ; if ( payload . action === "closed" && payload . pull_request . merged ) { const { pull_request : { base : { ref : base } , } , } = payload ; info ( `Update all relevant pull requests on base ${ base }` ) ; await handleSearchedPullRequests ( { autosquashLabel , github , async handle ( pullRequest ) { if ( isCandidateWithMergeableState ( pullRequest , autosquashLabel , updateableMergeableStates ) ) { await update ( { github , owner , pullRequest , repo , } ) ; } } , owner , query : `base:"${ base }"` , repo , } ) ; } else if ( payload . action === "labeled" && payload . label . name === autosquashLabel ) { info ( `Consider merging or updating ${ getPullRequestId ( payload . number ) }` ) ; const pullRequest = await fetchPullRequest ( { github , owner , pullRequestNumber : payload . number , repo , } ) ; if ( isCandidateWithMergeableState ( pullRequest , autosquashLabel , [ ... updateableMergeableStates , ... potentialMergeableStates , ] ) ) { const handle = updateableMergeableStates . includes ( pullRequest . mergeable_state as MergeableState ) ? update : merge ; await handle ( { github , owner , pullRequest , repo } ) ; } } } else if ( eventName === "pull_request_review" ) { const payload = context . payload as EventPayloads . WebhookPayloadPullRequestReview ; if ( payload . action === "submitted" && payload . review . state === "approved" ) { const pullRequestNumber = payload . pull_request . number ; info ( `Consider merging ${ getPullRequestId ( pullRequestNumber ) }` ) ; const pullRequest = await fetchPullRequest ( { github , owner , pullRequestNumber , repo , } ) ; if ( isCandidateWithMergeableState ( pullRequest , autosquashLabel , potentialMergeableStates ) ) { await merge ( { github , owner , pullRequest , repo , } ) ; } } } else if ( eventName === "status" ) { const payload = context . payload as EventPayloads . WebhookPayloadStatus ; if ( payload . state === "success" ) { info ( `Merge all pull requests on commit ${ payload . sha }` ) ; await handleSearchedPullRequests ( { autosquashLabel , github , async handle ( pullRequest ) { if ( isCandidateWithMergeableState ( pullRequest , autosquashLabel , potentialMergeableStates ) ) { const actualHeadSha = pullRequest . head . sha ; if ( actualHeadSha === payload . sha ) { await merge ( { github , owner , pullRequest , repo , } ) ; } else { info ( `Skipping since HEAD is actually ${ actualHeadSha }` ) ; } } } , owner , query : payload . sha , repo , } ) ; } } } ; export { autosquash } ;