-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[CI] GH workflow to assign reviewer for manual backport #253949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
dec9c5e
init idea
Ikuni17 b45d858
Merge remote-tracking branch 'upstream/main' into ci/assign-backport-…
Ikuni17 d4a4e5a
Merge remote-tracking branch 'upstream/main' into ci/assign-backport-…
Ikuni17 66e5780
refactor into affected co action
Ikuni17 29cf2cb
Merge branch 'main' into ci/assign-backport-reviewer
Ikuni17 8ced531
add fallback to most recent reviewer
Ikuni17 5241dcc
use single gql query
Ikuni17 f549000
Merge remote-tracking branch 'upstream/main' into ci/assign-backport-…
Ikuni17 0e63cbf
fix length with set
Ikuni17 c96dff7
fix undefined owners from affected-codeowners action
Ikuni17 7907095
fix early exit
Ikuni17 18380b1
use previous reviewers onBehalfOf instead of codeowners
Ikuni17 441947f
naming
Ikuni17 a378c30
Merge branch 'main' into ci/assign-backport-reviewer
Ikuni17 8635867
Merge branch 'main' into ci/assign-backport-reviewer
Ikuni17 39543f7
Merge branch 'main' into ci/assign-backport-reviewer
Ikuni17 8e42014
Merge remote-tracking branch 'upstream/main' into HEAD
Ikuni17 f8ff8c0
Refactor backport reviewer assignment
Ikuni17 6406f0b
Merge branch 'main' into ci/assign-backport-reviewer
Ikuni17 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.