Skip to content

Commit ef866a4

Browse files
committed
git-commit-checks: comment on PR with error(s)
This adds comments onto the pull request containing what caused the commit checks to fail, if any, and suggests fixes to the user. If no errors are raised, no comment is made. GitHub says there's a limit of 65536 characters on comments. If the bot's comment is over that limit, it will truncate the comment to fit, and add a message explaining where the remaining errors can be found. Unfortunately, the GitHub API doesn't seem to provide a job's unique ID for linking to a job run (this is different than an action run: ".../runs/..." vs ".../actions/runs/...", respectively), so we can't directly link to the error messages printed to the console. Additionally, to create this link, two new environment variables are used: GITHUB_RUN_ID and GITHUB_SERVER_URL. Because we need the PR object twice, check_github_pr_description() was also changed to have the PR object passed into it; the PR object is gotten with a new function, get_github_pr(). The GitHub action configuration was changed to run on pull_request_target, instead of pull_request. Here are the differences: * The target repo (aka base repo) is cloned instead of the head repo. * This means we have to add a git remote for the head repo and fetch it. * The action runs in the security context of the target repo,instead of the head repo. * This allows us to post a comment on the pull request. * This action runs even if there are merge conflicts on the PR. Similar to 5bf0b02, we restrict permissions of the action for pull_request_target. Signed-off-by: Joe Downs <joe@dwns.dev>
1 parent 94bed42 commit ef866a4

File tree

2 files changed

+98
-28
lines changed

2 files changed

+98
-28
lines changed

.github/workflows/git-commit-checks.py

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
variables that are available in the Github Action environment. Specifically:
99
1010
* GITHUB_WORKSPACE: directory where the git clone is located
11-
* GITHUB_SHA: the git commit SHA of the artificial Github PR test merge commit
1211
* GITHUB_BASE_REF: the git ref for the base branch
12+
* GITHUB_HEAD_REF: the git commit ref of the head branch
1313
* GITHUB_TOKEN: token authorizing Github API usage
1414
* GITHUB_REPOSITORY: "org/repo" name of the Github repository of this PR
1515
* GITHUB_REF: string that includes this Github PR number
16+
* GITHUB_RUN_ID: unique ID for each workflow run
17+
* GITHUB_SERVER_URL: the URL of the GitHub server
1618
17-
This script tests each git commit between (and not including) GITHUB_SHA and
19+
This script tests each git commit between (and not including) GITHUB_HEAD_REF and
1820
GITHUB_BASE_REF multiple ways:
1921
2022
1. Ensure that the committer and author do not match any bad patterns (e.g.,
@@ -50,22 +52,30 @@
5052
GOOD = "good"
5153
BAD = "bad"
5254

55+
GIT_REMOTE_PR_HEAD_NAME = "prHead"
56+
5357
NACP = "bot:notacherrypick"
5458

5559
GITHUB_WORKSPACE = os.environ.get('GITHUB_WORKSPACE')
56-
GITHUB_SHA = os.environ.get('GITHUB_SHA')
5760
GITHUB_BASE_REF = os.environ.get('GITHUB_BASE_REF')
61+
GITHUB_HEAD_REF = os.environ.get('GITHUB_HEAD_REF')
5862
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
5963
GITHUB_REPOSITORY = os.environ.get('GITHUB_REPOSITORY')
6064
GITHUB_REF = os.environ.get('GITHUB_REF')
65+
GITHUB_RUN_ID = os.environ.get('GITHUB_RUN_ID')
66+
GITHUB_SERVER_URL = os.environ.get('GITHUB_SERVER_URL')
67+
PR_NUM = os.environ.get('PR_NUM')
6168

6269
# Sanity check
6370
if (GITHUB_WORKSPACE is None or
64-
GITHUB_SHA is None or
6571
GITHUB_BASE_REF is None or
72+
GITHUB_HEAD_REF is None or
6673
GITHUB_TOKEN is None or
6774
GITHUB_REPOSITORY is None or
68-
GITHUB_REF is None):
75+
GITHUB_REF is None or
76+
GITHUB_RUN_ID is None or
77+
GITHUB_SERVER_URL is None or
78+
PR_NUM is None):
6979
print("Error: this script is designed to run as a Github Action")
7080
exit(1)
7181

@@ -85,6 +95,50 @@ def make_commit_message(repo, hash):
8595

8696
#----------------------------------------------------------------------------
8797

98+
"""
99+
Iterate through the BAD results, collect the error messages, and send a nicely
100+
formatted comment to the PR.
101+
102+
For the structure of the results dictionary, see comment for print_results()
103+
below.
104+
105+
"""
106+
def comment_on_pr(pr, results, repo):
107+
# If there are no BAD results, just return without posting a comment to the
108+
# GitHub PR.
109+
if len(results[BAD]) == 0:
110+
return
111+
112+
comment = "Hello! The Git Commit Checker CI bot found a few problems with this PR:"
113+
for hash, entry in results[BAD].items():
114+
comment += f"\n\n**{hash[:8]}: {make_commit_message(repo, hash)}**"
115+
for check_name, message in entry.items():
116+
if message is not None:
117+
comment += f"\n * *{check_name}: {message}*"
118+
comment_footer = "\n\nPlease fix these problems and, if necessary, force-push new commits back up to the PR branch. Thanks!"
119+
120+
# GitHub says that 65536 characters is the limit of comment messages, so
121+
# check if our comment is over that limit. If it is, truncate it to fit, and
122+
# add a message explaining with a link to the full error list.
123+
comment_char_limit = 65536
124+
if len(comment + comment_footer) >= comment_char_limit:
125+
run_url = f"{GITHUB_SERVER_URL}/{GITHUB_REPOSITORY}/actions/runs/{GITHUB_RUN_ID}?check_suite_focus=true"
126+
truncation_message = f"\n\n**Additional errors could not be shown...\n[Please click here for a full list of errors.]({run_url})**"
127+
# Cut the comment down so we can get the comment itself, and the new
128+
# message in.
129+
comment = comment[:(comment_char_limit - len(comment_footer + truncation_message))]
130+
# In case a newline gets split in half, remove the leftover '\' (if
131+
# there is one). (This is purely an aesthetics choice).
132+
comment = comment.rstrip("\\")
133+
comment += truncation_message
134+
135+
comment += comment_footer
136+
pr.create_issue_comment(comment)
137+
138+
return
139+
140+
#----------------------------------------------------------------------------
141+
88142
"""
89143
The results dictionary is in the following format:
90144
@@ -242,15 +296,17 @@ def _is_entirely_submodule_updates(repo, commit):
242296
#----------------------------------------------------------------------------
243297

244298
def check_all_commits(config, repo):
245-
# Get a list of commits that we'll be examining. Use the progromatic form
246-
# of "git log GITHUB_BASE_REF..GITHUB_SHA" (i.e., "git log ^GITHUB_BASE_REF
247-
# GITHUB_SHA") to do the heavy lifting to find that set of commits.
299+
# Get a list of commits that we'll be examining. Use the programmatic form
300+
# of "git log GITHUB_BASE_REF..GITHUB_HEAD_REF" (i.e., "git log
301+
# ^GITHUB_BASE_REF GITHUB_HEAD_REF") to do the heavy lifting to find that
302+
# set of commits. Because we're using pull_request_target, GITHUB_BASE_REF
303+
# is already checked out, however, we specify "origin/{GITHUB_BASE_REF}", to
304+
# disambiguate the base ref from the head ref in case of duplicate ref
305+
# names. GITHUB_HEAD_REF has never been checked out, so we specify
306+
# "{GIT_REMOTE_PR_HEAD_NAME}/{GITHUB_HEAD_REF}".
248307
git_cli = git.cmd.Git(GITHUB_WORKSPACE)
249-
hashes = git_cli.log(f"--pretty=format:%h", f"origin/{GITHUB_BASE_REF}..{GITHUB_SHA}").splitlines()
250-
251-
# The first entry in the list will be the artificial Github merge commit for
252-
# this PR. We don't want to examine this commit.
253-
del hashes[0]
308+
hashes = git_cli.log(f"--pretty=format:%h",
309+
f"origin/{GITHUB_BASE_REF}..{GIT_REMOTE_PR_HEAD_NAME}/{GITHUB_HEAD_REF}").splitlines()
254310

255311
#------------------------------------------------------------------------
256312

@@ -292,15 +348,7 @@ def check_all_commits(config, repo):
292348
If "bot:notacherrypick" is in the PR description, then disable the
293349
cherry-pick message requirement.
294350
"""
295-
def check_github_pr_description(config):
296-
g = Github(GITHUB_TOKEN)
297-
repo = g.get_repo(GITHUB_REPOSITORY)
298-
299-
# Extract the PR number from GITHUB_REF
300-
match = re.search("/(\d+)/", GITHUB_REF)
301-
pr_num = int(match.group(1))
302-
pr = repo.get_pull(pr_num)
303-
351+
def check_github_pr_description(config, pr):
304352
if pr.body and NACP in pr.body:
305353
config['cherry pick required'] = False
306354

@@ -334,11 +382,23 @@ def load_config():
334382

335383
def main():
336384
config = load_config()
337-
check_github_pr_description(config)
338385

339-
repo = git.Repo(GITHUB_WORKSPACE)
340-
results, hashes = check_all_commits(config, repo)
341-
print_results(results, repo, hashes)
386+
g = Github(GITHUB_TOKEN)
387+
github_repo = g.get_repo(GITHUB_REPOSITORY)
388+
pr_num = int(PR_NUM)
389+
pr = github_repo.get_pull(pr_num)
390+
391+
check_github_pr_description(config, pr)
392+
393+
# Because we're using pull_request_target, we cloned the base repo and
394+
# therefore have to add the head repo as a remote.
395+
local_repo = git.Repo(GITHUB_WORKSPACE)
396+
head_remote = git.remote.Remote.create(local_repo, GIT_REMOTE_PR_HEAD_NAME, pr.head.repo.clone_url)
397+
head_remote.fetch()
398+
399+
results, hashes = check_all_commits(config, local_repo)
400+
print_results(results, local_repo, hashes)
401+
comment_on_pr(pr, results, local_repo)
342402

343403
if len(results[BAD]) == 0:
344404
print("\nTest passed: everything was good!")

.github/workflows/git-commit-checks.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
name: GitHub Action CI
22

3+
# We're using pull_request_target here instead of just pull_request so that the
4+
# action runs in the context of the base of the pull request, rather than in the
5+
# context of the merge commit. For more detail about the differences, see:
6+
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
37
on:
4-
pull_request:
8+
pull_request_target:
59
# We don't need this to be run on all types of PR behavior
610
# See https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request
711
types:
812
- opened
913
- synchronize
1014
- edited
1115

16+
permissions: {} # none
17+
1218
jobs:
1319
ci:
20+
permissions:
21+
pull-requests: write
1422
name: Git commit checker
1523
runs-on: ubuntu-latest
1624
steps:
@@ -29,6 +37,8 @@ jobs:
2937
run: pip install gitpython PyGithub
3038

3139
- name: Check all git commits
32-
run: $GITHUB_WORKSPACE/.github/workflows/git-commit-checks.py
40+
run:
41+
$GITHUB_WORKSPACE/.github/workflows/git-commit-checks.py
3342
env:
3443
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44+
PR_NUM: ${{ github.event.number }}

0 commit comments

Comments
 (0)