|
| 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