diff --git a/.github/scripts/assign_backport_reviewer.js b/.github/scripts/assign_backport_reviewer.js new file mode 100644 index 0000000000000..4c5add0253f00 --- /dev/null +++ b/.github/scripts/assign_backport_reviewer.js @@ -0,0 +1,172 @@ +const normalize = (value) => value.replace(/^@/, '').trim(); + +const isHumanReviewer = (login) => + Boolean(login) && login !== 'kibanamachine' && !login.endsWith('[bot]'); + +const getOriginalPrNumber = ({ title, body }) => { + const sourcePullRequestIndex = body.indexOf('"sourcePullRequest"'); + const originalPrNumberFromMetadata = + sourcePullRequestIndex === -1 + ? undefined + : body + .slice(sourcePullRequestIndex) + .match(/"number"\s*:\s*(\d+)/)?.[1]; + const originalPrNumberFromTitle = title.match(/\(#(\d+)\)\s*$/)?.[1]; + + return Number(originalPrNumberFromMetadata ?? originalPrNumberFromTitle); +}; + +const getReviews = async ({ github, owner, repo, originalPrNumber }) => { + const reviewHistory = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviews(first: 100) { + nodes { + state + submittedAt + author { + login + } + onBehalfOf(first: 10) { + nodes { + slug + organization { + login + } + } + } + } + } + } + } + }`, + { + owner, + repo, + number: originalPrNumber, + } + ); + + return (reviewHistory.repository.pullRequest?.reviews?.nodes ?? []) + .filter((review) => Boolean(review.submittedAt)) + .sort((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)); +}; + +const getReviewedTeams = (reviews) => { + const reviewedTeams = []; + + for (const review of reviews) { + for (const team of review.onBehalfOf.nodes ?? []) { + const combinedSlug = `${team.organization.login}/${team.slug}`; + if (!reviewedTeams.includes(combinedSlug)) { + reviewedTeams.push(combinedSlug); + } + } + } + + return reviewedTeams; +}; + +const getMatchedTeams = async ({ github, core, prAuthor, reviewedTeams }) => { + try { + const result = await github.graphql( + `query($login: String!) { + organization(login: "elastic") { + teams(first: 100, userLogins: [$login]) { + nodes { combinedSlug } + } + } + }`, + { login: prAuthor } + ); + const authorTeams = new Set( + result.organization.teams.nodes.map((team) => normalize(team.combinedSlug)) + ); + + return reviewedTeams.filter((team) => authorTeams.has(team)); + } catch (error) { + core.warning(`Failed to query author's org teams: ${error.message}`); + return []; + } +}; + +const getFallbackReviewer = ({ reviews, prAuthor }) => { + const fallbackReview = reviews.find((review) => { + const login = review.author?.login; + + return ( + review.state === 'APPROVED' && + isHumanReviewer(login) && + login !== prAuthor && + Boolean(review.submittedAt) + ); + }); + + return fallbackReview?.author.login; +}; + +const requestReviewers = async ({ github, pullRequestId, teamReviewers, reviewer }) => { + await github.graphql( + `mutation RequestReviewsByLogin( + $pullRequestId: ID! + $userLogins: [String!] + $teamSlugs: [String!] + ) { + requestReviewsByLogin( + input: { + pullRequestId: $pullRequestId + userLogins: $userLogins + teamSlugs: $teamSlugs + union: false + } + ) { + clientMutationId + } + }`, + { + pullRequestId, + userLogins: reviewer ? [reviewer] : [], + teamSlugs: teamReviewers, + } + ); +}; + +module.exports = async ({ github, context, core }) => { + const { owner, repo } = context.repo; + const pullRequest = context.payload.pull_request; + const prAuthor = pullRequest.user.login; + const title = pullRequest.title ?? ''; + const body = pullRequest.body ?? ''; + const originalPrNumber = getOriginalPrNumber({ title, body }); + + if (!Number.isInteger(originalPrNumber)) { + core.info('No source pull request number found'); + return; + } + + const reviews = await getReviews({ github, owner, repo, originalPrNumber }); + const reviewedTeams = getReviewedTeams(reviews); + const teamReviewers = await getMatchedTeams({ + github, + core, + prAuthor, + reviewedTeams, + }); + const reviewer = + teamReviewers.length > 0 ? undefined : getFallbackReviewer({ reviews, prAuthor }); + + if (teamReviewers.length === 0 && !reviewer) { + core.info('No team reviewers or fallback reviewer found'); + return; + } + + core.info(`Updating reviewers: teamReviewers=${teamReviewers}, reviewer=${reviewer}`); + + await requestReviewers({ + github, + pullRequestId: pullRequest.node_id, + teamReviewers, + reviewer, + }); +}; diff --git a/.github/workflows/assign-backport-reviewer.yml b/.github/workflows/assign-backport-reviewer.yml new file mode 100644 index 0000000000000..8d5f9c6acfd40 --- /dev/null +++ b/.github/workflows/assign-backport-reviewer.yml @@ -0,0 +1,34 @@ +name: Assign manual backport reviewer + +on: + pull_request_target: + branches: + - '8.19' + - '9.*' + - '[1-9][0-9]*.*' + types: + - opened + +jobs: + assign-reviewer: + name: Find reviewer for manual backport + runs-on: ubuntu-latest + if: | + github.event.pull_request.user.login != 'kibanamachine' && + contains(github.event.pull_request.labels.*.name, 'backport') + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github + sparse-checkout-cone-mode: true + fetch-depth: 1 + + - name: Assign reviewers + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ secrets.KIBANAMACHINE_TOKEN }} + script: | + const assignBackportReviewer = require('./.github/scripts/assign_backport_reviewer'); + await assignBackportReviewer({ github, context, core });