Skip to content

Commit 97e3ce9

Browse files
authored
Assign pr reviewer stratconn (#3210)
* added assign reviewer * moved to scripts * minor test * minor test * change teams * rename wf * rename wf * rename wf * rename wf * rename wf * changed to target * refactor * 2 reviewers * fix reviewers logic * fix reviewer logic from outside stratconn * pull request target * minor change * modify assign logic * rename script * get team from github based on codeowner team assignment * redundant check * redundant check
1 parent bd82d5a commit 97e3ce9

File tree

4 files changed

+219
-11
lines changed

4 files changed

+219
-11
lines changed

.github/CODEOWNERS

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,28 @@
66

77
# Actions common lib folder
88

9-
actions-shared/ @segmentio/strategic-connections-team
9+
/packages/actions-shared/ @segmentio/strategic-connections-team
1010

1111
# AJV utils
1212

13-
ajv-human-errors/ @segmentio/strategic-connections-team
13+
/packages/ajv-human-errors/ @segmentio/strategic-connections-team
1414

1515
# Browser destinations
1616

17-
browser-destinations/ @segmentio/libraries-web-team @segmentio/strategic-connections-team
17+
/packages/browser-destinations/ @segmentio/libraries-web-team @segmentio/strategic-connections-team
1818

1919
# CLI binary
2020

21-
cli/ @segmentio/strategic-connections-team
21+
/packages/cli/ @segmentio/strategic-connections-team
2222

2323
# Core actions runtime
2424

25-
core/ @segmentio/strategic-connections-team
25+
/packages/core/ @segmentio/strategic-connections-team
2626

2727
# Destination definitions and their actions
2828

29-
destination-actions/ @segmentio/strategic-connections-team
29+
/packages/destination-actions/ @segmentio/strategic-connections-team
3030

3131
# Utilities for event payload validation against an action's subscription AST.
3232

33-
destination-subscriptions/ @segmentio/strategic-connections-team
33+
/packages/destination-subscriptions/ @segmentio/strategic-connections-team

.github/workflows/label-prs.yml renamed to .github/workflows/configure-pr.yml

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
# This workflow labels PRs based on the files that were changed. It uses a custom script to this
1+
# This workflow labels PRs based on the files that were changed and also assign a reviewer based on team. It uses a custom script to this
22
# instead of actions/labeler as few of the tags are more than just file changes.
33

4-
name: Label PRs
4+
name: Configure PR
55
on:
66
pull_request_target:
7-
types: [opened, synchronize, reopened]
7+
types: [opened, synchronize, reopened, ready_for_review]
88

99
jobs:
10-
pr-labeler:
10+
configure-pr:
1111
runs-on: ubuntu-22.04
1212
permissions:
1313
contents: read
@@ -103,3 +103,30 @@ jobs:
103103
repo: context.repo.repo
104104
})
105105
}
106+
107+
- name: Get Reviewers
108+
if: github.event.action == 'ready_for_review' || (github.event.action == 'opened' && !github.event.pull_request.draft)
109+
id: get-reviewers
110+
uses: actions/github-script@v7
111+
env:
112+
labelsToAdd: '${{ steps.compute-labels.outputs.add }}'
113+
with:
114+
github-token: ${{ secrets.GH_PAT_MEMBER_AND_PULL_REQUEST_READONLY }}
115+
script: |
116+
const script = require('./scripts/github-action/get-reviewers.js')
117+
await script({github, context, core})
118+
119+
- name: Assign Reviewers
120+
if: (github.event.action == 'ready_for_review' || (github.event.action == 'opened' && !github.event.pull_request.draft)) && steps.get-reviewers.outputs.skip != 'true'
121+
uses: actions/github-script@v7
122+
env:
123+
REVIEWERS: '${{ steps.get-reviewers.outputs.reviewers }}'
124+
TEAM: '${{ steps.get-reviewers.outputs.team }}'
125+
with:
126+
script: |
127+
const script = require('./scripts/github-action/assign-reviewer.js')
128+
await script({github, context, core})
129+
130+
- name: Log Skip Reviewer Assignment Reason
131+
if: (github.event.action == 'ready_for_review' || (github.event.action == 'opened' && !github.event.pull_request.draft)) && steps.get-reviewers.outputs.skip == 'true'
132+
run: echo "Skipping reviewer assignment - ${{ steps.get-reviewers.outputs.reason }}"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// This is a github action script and can be run only from github actions. To run this script locally, you need to mock the github object and context object.
2+
module.exports = async ({ github, context, core }) => {
3+
const reviewers = process.env.REVIEWERS
4+
const team = process.env.TEAM
5+
6+
try {
7+
const reviewerList = reviewers.split(',')
8+
await github.rest.pulls.requestReviewers({
9+
owner: context.repo.owner,
10+
repo: context.repo.repo,
11+
pull_number: context.payload.pull_request.number,
12+
reviewers: reviewerList
13+
})
14+
core.info(`Assigned ${reviewerList.join(', ')} from ${team} team as reviewers`)
15+
} catch (error) {
16+
core.error(`Failed to assign ${reviewers} as reviewers: ${error.message}`)
17+
}
18+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// This is a github action script and can be run only from github actions. To run this script locally, you need to mock the github object and context object.
2+
module.exports = async ({ github, context, core }) => {
3+
const { labelsToAdd } = process.env
4+
5+
// Check if there are already reviewers assigned to the PR
6+
try {
7+
const existingReviewers = await github.rest.pulls.listRequestedReviewers({
8+
owner: context.repo.owner,
9+
repo: context.repo.repo,
10+
pull_number: context.payload.pull_request.number
11+
})
12+
13+
if (existingReviewers.data.users && existingReviewers.data.users.length > 0) {
14+
core.setOutput('skip', 'true')
15+
core.setOutput('reason', `PR already has ${existingReviewers.data.users.length} user reviewer(s) assigned: ${existingReviewers.data.users.map(u => u.login).join(', ')}`)
16+
core.info(`Skipping reviewer assignment - users already assigned: ${existingReviewers.data.users.map(u => u.login).join(', ')}`)
17+
return
18+
}
19+
} catch (error) {
20+
core.info(`Failed to check existing reviewers: ${error.message}`)
21+
core.setOutput('skip', 'true')
22+
core.setOutput('reason', `Failed to check existing reviewers: ${error.message}`)
23+
return
24+
}
25+
26+
// Check if team:external label is being added
27+
if (labelsToAdd && labelsToAdd.split(',').includes('team:external')) {
28+
core.setOutput('reviewers', 'joe-ayoub-segment')
29+
core.setOutput('team', 'external')
30+
core.setOutput('skip', 'false')
31+
core.info('Assigned joe-ayoub-segment for external contributor PR')
32+
return
33+
}
34+
35+
// Check if team:external label already exists on the PR
36+
const existingLabels = context.payload.pull_request.labels.map((label) => label.name)
37+
if (existingLabels.includes('team:external')) {
38+
core.setOutput('reviewers', 'joe-ayoub-segment')
39+
core.setOutput('team', 'external')
40+
core.setOutput('skip', 'false')
41+
core.info('Assigned joe-ayoub-segment for external contributor PR (existing label)')
42+
return
43+
}
44+
45+
// Function to get teams from existing reviewers
46+
async function getTeamFromGitHub() {
47+
try {
48+
// Get the list of requested reviewers (teams and individuals)
49+
const requestedReviewers = await github.rest.pulls.listRequestedReviewers({
50+
owner: context.repo.owner,
51+
repo: context.repo.repo,
52+
pull_number: context.payload.pull_request.number
53+
})
54+
55+
core.info(`GitHub requested reviewers: ${JSON.stringify(requestedReviewers.data)}`)
56+
57+
// Extract teams from requested reviewers
58+
const teams = []
59+
if (requestedReviewers.data.teams && requestedReviewers.data.teams.length > 0) {
60+
for (const team of requestedReviewers.data.teams) {
61+
teams.push(team.slug)
62+
}
63+
}
64+
65+
if (teams.length > 0) {
66+
// Return the first team
67+
const selectedTeam = teams[0]
68+
core.info(`Selected team from requested reviewers: ${selectedTeam}`)
69+
return selectedTeam
70+
}
71+
72+
core.info('No teams found in requested reviewers')
73+
return null
74+
75+
} catch (error) {
76+
core.info(`Failed to get teams from GitHub: ${error.message}`)
77+
return null
78+
}
79+
}
80+
81+
// Get team assignment from GitHub's CODEOWNERS evaluation
82+
const teamToAssign = await getTeamFromGitHub()
83+
84+
if (!teamToAssign) {
85+
core.setOutput('reviewers', 'joe-ayoub-segment')
86+
core.setOutput('team', 'other')
87+
core.setOutput('skip', 'false')
88+
core.info('No team assigned, defaulting to joe-ayoub-segment')
89+
return
90+
}
91+
92+
// Special handling for strategic-connections-team: only assign joe-ayoub-segment if author is not in team
93+
if (teamToAssign === 'strategic-connections-team') {
94+
// Check if PR author is a member of strategic-connections-team
95+
let isAuthorInTeam = false
96+
try {
97+
const team = await github.rest.teams.listMembersInOrg({
98+
team_slug: teamToAssign,
99+
org: context.repo.owner
100+
})
101+
const prAuthor = context.payload.pull_request.user.login
102+
isAuthorInTeam = team.data.some(member => member.login === prAuthor)
103+
core.info(`PR author ${prAuthor} is ${isAuthorInTeam ? 'in' : 'not in'} ${teamToAssign}`)
104+
} catch (error) {
105+
core.info(`Failed to check team membership: ${error.message}`)
106+
}
107+
108+
if (!isAuthorInTeam) {
109+
// PR targeting strategic-connections-team from non-team member -> assign to joe-ayoub-segment
110+
core.setOutput('reviewers', 'joe-ayoub-segment')
111+
core.setOutput('team', 'other')
112+
core.setOutput('skip', 'false')
113+
core.info(`PR targeting ${teamToAssign} from non-team member, assigned to joe-ayoub-segment`)
114+
return
115+
}
116+
// If author is in team, continue to team assignment logic below
117+
}
118+
119+
// Get team members (assign from target team regardless of author's team membership)
120+
try {
121+
const team = await github.rest.teams.listMembersInOrg({
122+
team_slug: teamToAssign,
123+
org: context.repo.owner
124+
})
125+
126+
if (team.data.length === 0) {
127+
core.setOutput('skip', 'true')
128+
core.setOutput('reason', `no team members found in ${teamToAssign}`)
129+
return
130+
}
131+
132+
// Filter out the PR author if they are in the target team (to avoid self-review)
133+
const prAuthor = context.payload.pull_request.user.login
134+
const eligibleMembers = team.data.filter(member => member.login !== prAuthor)
135+
136+
// If no eligible members after filtering author, use all team members
137+
const membersToSelectFrom = eligibleMembers.length > 0 ? eligibleMembers : team.data
138+
139+
// Get 2 consecutive members from the available team members (or all available if less than 2)
140+
const numReviewers = Math.min(2, membersToSelectFrom.length)
141+
142+
// Select a random starting position and take consecutive members
143+
const startIndex = Math.floor(Math.random() * membersToSelectFrom.length)
144+
const selectedMembers = []
145+
146+
for (let i = 0; i < numReviewers; i++) {
147+
const index = (startIndex + i) % membersToSelectFrom.length
148+
selectedMembers.push(membersToSelectFrom[index])
149+
}
150+
151+
const reviewerLogins = selectedMembers.map(member => member.login)
152+
core.setOutput('reviewers', reviewerLogins.join(','))
153+
core.setOutput('team', teamToAssign)
154+
core.setOutput('skip', 'false')
155+
156+
const authorInfo = eligibleMembers.length !== team.data.length ? ` (excluded PR author: ${prAuthor})` : ''
157+
core.info(`Selected ${reviewerLogins.join(', ')} (consecutive from index ${startIndex}) from ${membersToSelectFrom.length} available members in ${teamToAssign}${authorInfo}`)
158+
159+
} catch (error) {
160+
core.setOutput('skip', 'true')
161+
core.setOutput('reason', `failed to get ${teamToAssign} members: ${error.message}`)
162+
}
163+
}

0 commit comments

Comments
 (0)