Skip to content

Commit 8ac9291

Browse files
LilithHafnerDilumAluthgeadienes
authored
CI: Automatically assign a committer to PRs not opened by committers (#58303)
This is the first step in implementing @StefanKarpinski's [state machine](https://discourse.julialang.org/t/suggestion-to-slightly-improve-julia-development/50916/82), as prototyped here: https://github.com/LilithHafner/AutomationTesting/. The goal is to make sure all PRs have a committer tracking them, but without adding much cognitive load to committers. This alone will be IMO slightly helpful but not super impactful. Some next steps that will increase impact are - tracking when responsibility for continued progress lies with the PR author vs with the committer - gentle reminders to the person responsible, with an option to de-assign self and/or close the PR - automatically close PR after a long period of inactivity while the PR author is responsible for progress - automatically re-assign a new committer after a long period of inactivity while the committer is responsible We chose to use random assignment to an op-in pool of reviewers at https://github.com/JuliaLang/pr-assignment/blob/main/users.txt because - The github suggested reviewer option is not available via API - Using git blame (or "suggested reviewer") will have a tendency to assign the most busy people to the most PRs which is not good - Not all committers will be willing to triage pull requests --------- Co-authored-by: Dilum Aluthge <dilum@aluthge.com> Co-authored-by: Andy Dienes <51664769+adienes@users.noreply.github.com>
1 parent 953903b commit 8ac9291

File tree

2 files changed

+207
-0
lines changed

2 files changed

+207
-0
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CODEOWNERS @JuliaLang/github-actions
44

55
/.github/workflows/rerun_failed.yml @DilumAluthge
66
/.github/workflows/statuses.yml @DilumAluthge
7+
/.github/workflows/PrAssignee.yml @LilithHafner @DilumAluthge
78
/base/special/ @oscardssmith
89
/base/sort.jl @LilithHafner
910
/test/sorting.jl @LilithHafner

.github/workflows/PrAssignee.yml

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
name: PR Assignee
2+
on:
3+
# Important security note: Do NOT use `actions/checkout`
4+
# or any other method for checking out the pull request's source code.
5+
# This is because the pull request's source code is untrusted, but the
6+
# GITHUB_TOKEN has write permissions (because of the `on: pull_request_target` event).
7+
#
8+
# Quoting from the GitHub Docs:
9+
# > For workflows that are triggered by the pull_request_target event, the GITHUB_TOKEN is granted
10+
# > read/write repository permission unless the permissions key is specified and the workflow can access secrets,
11+
# > even when it is triggered from a fork.
12+
# >
13+
# > Although the workflow runs in the context of the base of the pull request,
14+
# > you should make sure that you do not check out, build, or run untrusted code from the pull request with this event.
15+
#
16+
# Source: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request_target
17+
#
18+
# See also: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
19+
pull_request_target:
20+
types: [opened, reopened, ready_for_review]
21+
22+
# Permissions for the `GITHUB_TOKEN`:
23+
permissions:
24+
pull-requests: write # Needed in order to assign a user as the PR assignee
25+
26+
jobs:
27+
pr-assignee:
28+
runs-on: ubuntu-latest
29+
if: ${{ github.event.pull_request.draft != true }}
30+
steps:
31+
# Important security note: As discussed above, do NOT use `actions/checkout`
32+
# or any other method for checking out the pull request's source code.
33+
# This is because the pull request's source code is untrusted, but the
34+
# GITHUB_TOKEN has write permissions (because of the `on: pull_request_target` event).
35+
- name: Add Assignee
36+
# We pin all third-party actions to a full length commit SHA
37+
# https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions
38+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
39+
with:
40+
retries: 5 # retry GitHub API requests up to 5 times, with exponential backoff
41+
retry-exempt-status-codes: 403
42+
script: |
43+
const oldPrAssignees = context.payload.pull_request.assignees
44+
.map(obj => obj.login)
45+
console.log('oldPrAssignees: ', oldPrAssignees);
46+
const prAuthor = context.payload.pull_request.user.login;
47+
48+
// Check if the PR is opened by a collaborator on the repo (aka a committer).
49+
// We use the /repos/{owner}/{repo}/collaborators/{username} endpoint to avoid
50+
// neeing org scope permissions.
51+
const isCollaboratorResponseObj = await github.request('GET /repos/{owner}/{repo}/collaborators/{username}', {
52+
owner: context.repo.owner,
53+
repo: context.repo.repo,
54+
username: prAuthor,
55+
headers: {
56+
'X-GitHub-Api-Version': '2022-11-28'
57+
}
58+
})
59+
if (isCollaboratorResponseObj.status == 204) {
60+
var isCollaborator = true;
61+
} else if (isCollaboratorResponseObj.status == 404) {
62+
var isCollaborator = false;
63+
} else {
64+
console.error('Unable to process the response from checkCollaborator');
65+
console.error('isCollaboratorResponseObj: ', isCollaboratorResponseObj);
66+
var isCollaborator = false;
67+
}
68+
69+
console.log('prAuthor: ', prAuthor);
70+
console.log('isCollaborator: ', isCollaborator);
71+
72+
// Load the list of assignable reviewers from the JuliaLang/pr-assignment repo at:
73+
// https://github.com/JuliaLang/pr-assignment/blob/main/users.txt
74+
//
75+
// NOTE to JuliaLang committers: If you want to be assigned to new PRs, please add your
76+
// GitHub username to that file.
77+
78+
// Load file contents
79+
const { data: fileContentsObj } = await github.rest.repos.getContent({
80+
owner: 'JuliaLang',
81+
repo: 'pr-assignment',
82+
path: 'users.txt',
83+
ref: 'main',
84+
});
85+
86+
const fileContentsBufferObj = Buffer.from(fileContentsObj.content, "base64");
87+
const fileContentsText = fileContentsBufferObj.toString("utf8");
88+
89+
// Find lines that match the following regex, and extract the usernames:
90+
const regex = /^@([a-zA-Z0-9\-]+)(\s*?)?(#[\S]*?)?$/;
91+
const assigneeCandidates = fileContentsText
92+
.split('\n')
93+
.map(line => line.trim())
94+
.map(line => line.match(regex))
95+
.filter(match => match !== null)
96+
.map(match => match[1]);
97+
98+
console.log('assigneeCandidates: ', assigneeCandidates);
99+
if (assigneeCandidates.length < 1) {
100+
const msg = 'ERROR: Could not find any assigneeCandidates';
101+
console.error(msg);
102+
throw new Error(msg);
103+
}
104+
105+
if (oldPrAssignees.length >= 1) {
106+
console.log('Skipping this PR, because it already has at least one assignee');
107+
return;
108+
}
109+
110+
111+
const RUNNER_DEBUG_original = process.env.RUNNER_DEBUG;
112+
console.log('RUNNER_DEBUG_original: ', RUNNER_DEBUG_original);
113+
if (RUNNER_DEBUG_original === undefined) {
114+
var thisIsActionsRunnerDebugMode = false;
115+
} else {
116+
const RUNNER_DEBUG_trimmed = RUNNER_DEBUG_original.trim().toLowerCase()
117+
if (RUNNER_DEBUG_trimmed.length < 1) {
118+
var thisIsActionsRunnerDebugMode = false;
119+
} else {
120+
var thisIsActionsRunnerDebugMode = (RUNNER_DEBUG_trimmed == 'true') || (RUNNER_DEBUG_trimmed == '1');
121+
}
122+
}
123+
console.log('thisIsActionsRunnerDebugMode: ', thisIsActionsRunnerDebugMode);
124+
125+
if (isCollaborator == true) {
126+
127+
if (thisIsActionsRunnerDebugMode) {
128+
// The PR author is a committer
129+
// But thisIsActionsRunnerDebugMode is true, so we proceed to still run the rest of the script
130+
console.log('PR is authored by JuliaLang committer, but thisIsActionsRunnerDebugMode is true, so we will still run the rest of the script: ', prAuthor);
131+
} else {
132+
// The PR author is a committer, so we skip assigning them
133+
console.log('Skipping PR authored by JuliaLang committer: ', prAuthor);
134+
console.log('Note: If you want to run the full script (even though the PR author is a committer), simply re-run this job with Actions debug logging enabled');
135+
return;
136+
}
137+
}
138+
139+
var weDidEncounterError = false;
140+
141+
// Assign random committer
142+
const selectedAssignee = assigneeCandidates[Math.floor(Math.random()*assigneeCandidates.length)]
143+
console.log('selectedAssignee: ', selectedAssignee);
144+
console.log(`Attempting to assign @${selectedAssignee} to this PR...`);
145+
await github.rest.issues.addAssignees({
146+
owner: context.repo.owner,
147+
repo: context.repo.repo,
148+
issue_number: context.payload.pull_request.number,
149+
assignees: selectedAssignee,
150+
});
151+
152+
// Add the "pr review" label
153+
const prReviewLabel = 'status: waiting for PR reviewer';
154+
console.log('Attempting to add prReviewLabel to this PR...');
155+
await github.rest.issues.addLabels({
156+
owner: context.repo.owner,
157+
repo: context.repo.repo,
158+
issue_number: context.payload.pull_request.number,
159+
labels: [prReviewLabel],
160+
});
161+
162+
// Now get the updated PR info, and see if we were successful:
163+
const updatedPrData = await github.rest.pulls.get({
164+
owner: context.repo.owner,
165+
repo: context.repo.repo,
166+
pull_number: context.payload.pull_request.number,
167+
});
168+
const newPrAssignees = updatedPrData
169+
.data
170+
.assignees
171+
.map(element => element.login)
172+
console.log('newPrAssignees: ', newPrAssignees);
173+
if (newPrAssignees.includes(selectedAssignee)) {
174+
console.log(`Successfully assigned @${selectedAssignee}`);
175+
} else {
176+
weDidEncounterError = true;
177+
console.log(`ERROR: Failed to assign @${selectedAssignee}`);
178+
}
179+
const newPrLabels = updatedPrData
180+
.data
181+
.labels
182+
.map(element => element.name)
183+
console.log('newPrLabels: ', newPrLabels);
184+
if (newPrLabels.includes(prReviewLabel)) {
185+
console.log('Successfully added prReviewLabel');
186+
} else {
187+
weDidEncounterError = true;
188+
console.log('ERROR: Failed to add add prReviewLabel');
189+
}
190+
191+
// Post a comment
192+
const commentBody = `Hello! I am a bot.\n\nThank you for your pull request!\n\nI have assigned \`@${selectedAssignee}\` to this pull request.\n\n\`@${selectedAssignee}\` can either choose to review this pull request themselves, or they can choose to find someone else to review this pull request.`
193+
console.log('Attempting to post bot comment on the PR...');
194+
await github.rest.issues.createComment({
195+
owner: context.repo.owner,
196+
repo: context.repo.repo,
197+
issue_number: context.payload.pull_request.number,
198+
body: commentBody,
199+
});
200+
201+
// Exit with error if any problems were encountered earlier
202+
if (weDidEncounterError) {
203+
const msg = 'ERROR: Encountered at least one problem while running the script';
204+
console.error(msg);
205+
throw new Error(msg);
206+
}

0 commit comments

Comments
 (0)