Skip to content

Commit acac90f

Browse files
authored
Run lint checks on PRs, posting any warnings as comments directly on the files (#508)
This script performs a number of steps: 1. Get the PR's diff to find the list of affected files and lines in the PR. 2. Run cpplint on all files in the PR. For each lint warning, check if it falls within the range of lines affected by the diff. Omit that warning if it doesn't fall in the affected lines. 3. Delete any prior lint warning comments posted by previous runs (this takes a moment as they must be deleted one by one, due to GitHub API limitations) 4. Post all the lint warnings that fall within the range of the PR's diff (this is done all at once), on the PR's most recent commit. It will ignore any lint warnings that occur outside the PR's diff, and it will only run cpplint on files that are added/modified in the PR in the first place. There is also an exclusion list to exclude sets of files (via regex). Any other errors from cpplint (e.g. "I don't know how to parse this file") are simply ignored. Also, the whole process will be skipped if the PR has the 'no-lint' label.
1 parent 3a2c07b commit acac90f

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-0
lines changed

.github/workflows/lint.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Lint
2+
3+
on:
4+
pull_request:
5+
types: [opened,synchronize]
6+
7+
jobs:
8+
lint_warnings_check_and_comment:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v2
12+
with:
13+
repository: cpplint/cpplint
14+
ref: "1.5.5"
15+
path: cpplint
16+
- uses: actions/checkout@v2
17+
with:
18+
submodules: false
19+
path: firebase
20+
21+
- name: Setup python
22+
uses: actions/setup-python@v2
23+
with:
24+
python-version: 3.7
25+
- name: Install prerequisites
26+
run: |
27+
python3 -m pip install unidiff
28+
- name: Run cpplint
29+
shell: bash
30+
run: |
31+
set -e
32+
cd firebase
33+
python3 scripts/gha/lint_commenter.py -l ../cpplint/cpplint.py -p ${{ github.event.pull_request.number }} -t ${{ github.token }} -A

scripts/gha/lint_commenter.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2021 Google
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Run a code linter and post the results as GitHub comments.
17+
18+
This script allows you to easily trigger a workflow on GitHub using an access
19+
token. It uses the GitHub REST API documented here:
20+
https://docs.github.com/en/rest/reference/actions#create-a-workflow-dispatch-event
21+
22+
Usage:
23+
python lint_commenter.py -t github_token -p pr_number
24+
[-r git_repo_url] [-C curl_command] [-l lint_command]
25+
26+
If -r is unspecified, uses the current repo.
27+
"""
28+
29+
import argparse
30+
from html import escape
31+
import json
32+
import os
33+
import re
34+
import subprocess
35+
import time
36+
from unidiff import PatchSet
37+
import urllib.parse
38+
39+
# Put any lint warnings you want to fully ignore into this list.
40+
IGNORE_LINT_WARNINGS = [
41+
'build/header_guard',
42+
'readability/casting',
43+
'whitespace/line_length'
44+
]
45+
# Exclude files within the following paths (specified as regexes)
46+
EXCLUDE_PATH_REGEX = [
47+
r'^analytics/ios_headers/'
48+
]
49+
# The linter gives every error a confidence score.
50+
# 1 = It's most likely not really an issue.
51+
# 5 = It's definitely an issue.
52+
# Set this to 1 to show all warnings, or higher to show fewer warnings.
53+
MINIMUM_CONFIDENCE = 1
54+
LABEL_TO_SKIP_LINT = 'no-lint'
55+
CPPLINT_FILTER = '-'+',-'.join(IGNORE_LINT_WARNINGS)
56+
LINT_COMMENT_HEADER = '⚠️ Lint warning: `'
57+
LINT_COMMENT_FOOTER = '`'
58+
HIDDEN_COMMENT_TAG = '<hidden value="cpplint-file-comment"></hidden>'
59+
60+
def main():
61+
# This script performs a number of steps:
62+
#
63+
# 1. Get the PR's diff to find the list of affected files and lines in the PR.
64+
#
65+
# 2. Run lint on all files in the PR. For each lint warning, check if it falls
66+
# within the range of lines affected by the diff. Omit that warning if it
67+
# doesn't fall in the affected lines.
68+
#
69+
# 3. Delete any prior lint warning comments posted by previous runs.
70+
#
71+
# 4. Post any lint warnings that fall within the range of the PR's diff.
72+
73+
args = parse_cmdline_args()
74+
if args.repo is None:
75+
args.repo=subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode('utf-8').rstrip('\n').lower()
76+
if args.verbose:
77+
print('autodetected repo: %s' % args.repo)
78+
if not args.repo.startswith('https://github.com/'):
79+
print('Error, only https://github.com/ repositories are allowed.')
80+
exit(2)
81+
(repo_owner, repo_name) = re.match(r'https://github\.com/([^/]+)/([^/.]+)', args.repo).groups()
82+
83+
# Get the head commit for the pull request.
84+
# GET /repos/{owner}/{repo}/pulls/{pull_number}
85+
request_url = 'https://api.github.com/repos/%s/%s/pulls/%s' % (repo_owner, repo_name, args.pr_number)
86+
header = 'Accept: application/vnd.github.VERSION.json'
87+
pr_data = json.loads(subprocess.check_output(
88+
[args.curl,
89+
'-s', '-X', 'GET',
90+
'-H', 'Accept: application/vnd.github.v3+json',
91+
'-H', 'Authorization: token %s' % args.token,
92+
request_url
93+
] + ([] if not args.verbose else ['-v'])).decode('utf-8').rstrip('\n'))
94+
95+
commit_sha = pr_data['head']['sha']
96+
if args.verbose:
97+
print('Commit sha:', commit_sha)
98+
99+
skip_lint = False
100+
if 'labels' in pr_data:
101+
for label in pr_data['labels']:
102+
if label['name'] == LABEL_TO_SKIP_LINT:
103+
skip_lint = True
104+
break
105+
if skip_lint:
106+
print('PR #%s has "%s" label, skipping lint checks' % (args.pr_number, LABEL_TO_SKIP_LINT))
107+
exit(0)
108+
109+
# Get the diff for the pull request.
110+
# GET /repos/{owner}/{repo}/pulls/{pull_number}
111+
request_url = 'https://api.github.com/repos/%s/%s/pulls/%s' % (repo_owner, repo_name, args.pr_number)
112+
header = 'Accept: application/vnd.github.VERSION.diff'
113+
114+
if args.verbose:
115+
print('request_url: %s' % request_url)
116+
117+
pr_diff = subprocess.check_output(
118+
[args.curl,
119+
'-s', '-o', '-', '-w', '\nHTTP status %{http_code}\n',
120+
'-X', 'GET',
121+
'-H', header,
122+
'-H', 'Authorization: token %s' % args.token,
123+
request_url
124+
] + ([] if not args.verbose else ['-v'])).decode('utf-8')
125+
# Parse the diff to determine the whether each source line is touched.
126+
# Only lint lines that refer to parts of files that are diffed will be shown.
127+
# Information on what this means here:
128+
# https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request
129+
valid_lines = {}
130+
file_list = []
131+
pr_patch = PatchSet(pr_diff)
132+
for pr_patch_file in pr_patch:
133+
# Skip files that only remove code.
134+
if pr_patch_file.removed and not pr_patch_file.added:
135+
continue
136+
# Skip files that match an EXCLUDE_PATH_REGEX
137+
excluded = False
138+
for exclude_regex in EXCLUDE_PATH_REGEX:
139+
if re.search(exclude_regex, pr_patch_file.path):
140+
excluded = True
141+
break
142+
if excluded: continue
143+
file_list.append(pr_patch_file.path)
144+
valid_lines[pr_patch_file.path] = set()
145+
for hunk in pr_patch_file:
146+
if hunk.target_length > 0:
147+
for line_number in range(
148+
hunk.target_start,
149+
hunk.target_start + hunk.target_length):
150+
# This line is modified by the diff, add it to the valid set of lines.
151+
valid_lines[pr_patch_file.path].add(line_number)
152+
153+
# Now we also have a list of files in repo.
154+
try:
155+
lint_results=subprocess.check_output([
156+
args.lint_command,
157+
'--output=emacs',
158+
('--filter=%s' % CPPLINT_FILTER),
159+
('--repository=..')
160+
] + file_list, stderr=subprocess.STDOUT).decode('utf-8').split('\n')
161+
except subprocess.CalledProcessError as e:
162+
# Nothing to do if there is an exception.
163+
lint_results=e.output.decode('utf-8').split('\n')
164+
165+
all_comments = []
166+
for line in lint_results:
167+
# Match an output line from the linter, in this format:
168+
# path/to/file:line#: Lint message goes here [lint type] [confidence#]
169+
m = re.match(r'([^:]+):([0-9]+): *(.*[^ ]) +\[([^]]+)\] \[(\d+)\]$', line)
170+
if m:
171+
all_comments.append({
172+
'filename': m.group(1),
173+
'line': int(m.group(2)),
174+
'text': m.group(3),
175+
'type': m.group(4),
176+
'confidence': int(m.group(5)),
177+
'original_line': line})
178+
179+
pr_comments = []
180+
for comment in all_comments:
181+
if comment['filename'] in valid_lines:
182+
if (comment['line'] in valid_lines[comment['filename']] and
183+
comment['confidence'] >= MINIMUM_CONFIDENCE):
184+
pr_comments.append(comment)
185+
if args.verbose:
186+
print('Got %d relevant lint comments' % len(pr_comments))
187+
188+
# Next, get all existing review comments that we posted on the PR and delete them.
189+
comments_to_delete = []
190+
page = 1
191+
per_page=100
192+
keep_reading = True
193+
while keep_reading:
194+
if args.verbose:
195+
print('Read page %d of comments' % page)
196+
request_url = 'https://api.github.com/repos/%s/%s/pulls/%s/comments?per_page=%d&page=%d' % (repo_owner, repo_name, args.pr_number, per_page, page)
197+
comments = json.loads(subprocess.check_output([args.curl,
198+
'-s', '-X', 'GET',
199+
'-H', 'Accept: application/vnd.github.v3+json',
200+
'-H', 'Authorization: token %s' % args.token,
201+
request_url]).decode('utf-8').rstrip('\n'))
202+
for comment in comments:
203+
if HIDDEN_COMMENT_TAG in comment['body']:
204+
comments_to_delete.append(comment['id'])
205+
page = page + 1
206+
if len(comments) < per_page:
207+
# Stop once we're read less than a full page of comments.
208+
keep_reading = False
209+
if comments_to_delete:
210+
print('Delete previous lint comments:', comments_to_delete)
211+
for comment_id in comments_to_delete:
212+
# Delete all of these comments.
213+
# DELETE /repos/{owner}/{repo}/pulls/{pull_number}/comments
214+
request_url = 'https://api.github.com/repos/%s/%s/pulls/comments/%d' % (repo_owner, repo_name, comment_id)
215+
delete_output = subprocess.check_output([args.curl,
216+
'-s', '-X', 'DELETE',
217+
'-H', 'Accept: application/vnd.github.v3+json',
218+
'-H', 'Authorization: token %s' % args.token,
219+
request_url]).decode('utf-8').rstrip('\n')
220+
if len(pr_comments) > 0:
221+
comments_to_send = []
222+
for pr_comment in pr_comments:
223+
# Post each comment.
224+
# POST /repos/{owner}/{repo}/pulls/{pull_number}/comments
225+
request_url = 'https://api.github.com/repos/%s/%s/pulls/%s/reviews' % (repo_owner, repo_name, args.pr_number)
226+
comments_to_send.append({
227+
'body': (
228+
LINT_COMMENT_HEADER +
229+
pr_comment['text'] +
230+
LINT_COMMENT_FOOTER +
231+
HIDDEN_COMMENT_TAG +
232+
'<hidden value=%s></hidden>' % json.dumps(pr_comment['original_line'])
233+
),
234+
'path': pr_comment['filename'],
235+
'line': pr_comment['line'],
236+
})
237+
print(pr_comment['original_line'])
238+
239+
request_body = {
240+
'commit_id': commit_sha,
241+
'event': 'COMMENT',
242+
'comments': comments_to_send
243+
}
244+
json_text = json.dumps(request_body)
245+
run_output = json.loads(subprocess.check_output([args.curl,
246+
'-s', '-X', 'POST',
247+
'-H', 'Accept: application/vnd.github.v3+json',
248+
'-H', 'Authorization: token %s' % args.token,
249+
request_url, '-d', json_text]
250+
+ ([] if not args.verbose else ['-v'])).decode('utf-8').rstrip('\n'))
251+
if 'message' in run_output and 'errors' in run_output:
252+
print('%s error when posting comments:\n%s' %
253+
(run_output['message'], '\n'.join(run_output['errors'])))
254+
if args.in_github_action:
255+
print('::error ::%s error when posting comments:%0A%s' %
256+
(run_output['message'], '%0A'.join(run_output['errors'])))
257+
exit(1)
258+
else:
259+
print('Posted %d lint warnings successfully' % len(pr_comments))
260+
261+
if args.in_github_action:
262+
# Also post a GitHub log comment.
263+
lines = ['Found %d lint warnings:' % len(pr_comments)]
264+
for comment in pr_comments:
265+
lines.append(comment['original_line'])
266+
print('::warning ::%s' % '%0A'.join(lines))
267+
else:
268+
print('No lint warnings found.')
269+
exit(0)
270+
271+
def parse_cmdline_args():
272+
parser = argparse.ArgumentParser(description='Run cpplint on code and add results as PR comments.')
273+
parser.add_argument('-t', '--token', required=True, help='GitHub access token')
274+
parser.add_argument('-p', '--pr_number', required=True, help='Pull request number')
275+
parser.add_argument('-l', '--lint_command', help='Lint command to run', default='cpplint.py')
276+
parser.add_argument('-r', '--repo', metavar='URL', help='GitHub repo of the pull request, default is current repo')
277+
parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose mode')
278+
parser.add_argument('-C', '--curl', default='curl', metavar='COMMAND', help='Curl command to use for making request')
279+
parser.add_argument('-A', '--in_github_action', action='store_true', help='Enable special logging for GitHub actions')
280+
args = parser.parse_args()
281+
return args
282+
283+
284+
if __name__ == '__main__':
285+
main()

0 commit comments

Comments
 (0)