Skip to content

Commit a399e6c

Browse files
authored
[ci] Add linter for PR title and body (apache#12367)
* [skip ci][ci] Fix Jenkinsfile (apache#12387) This got out of date after merging apache#12178 Co-authored-by: driazati <[email protected]> * Address comments Co-authored-by: driazati <[email protected]>
1 parent d54c065 commit a399e6c

File tree

4 files changed

+192
-4
lines changed

4 files changed

+192
-4
lines changed

Jenkinsfile

+21-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ci/jenkins/Prepare.groovy.j2

+20-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def should_skip_ci(pr_number) {
138138
}
139139
withCredentials([string(
140140
credentialsId: 'tvm-bot-jenkins-reader',
141-
variable: 'TOKEN',
141+
variable: 'GITHUB_TOKEN',
142142
)]) {
143143
// Exit code of 1 means run full CI (or the script had an error, so run
144144
// full CI just in case). Exit code of 0 means skip CI.
@@ -151,12 +151,31 @@ def should_skip_ci(pr_number) {
151151
return git_skip_ci_code == 0
152152
}
153153

154+
def check_pr(pr_number) {
155+
if (env.BRANCH_NAME == null || !env.BRANCH_NAME.startsWith('PR-')) {
156+
// never skip CI on build sourced from a branch
157+
return false
158+
}
159+
withCredentials([string(
160+
credentialsId: 'tvm-bot-jenkins-reader',
161+
variable: 'GITHUB_TOKEN',
162+
)]) {
163+
sh (
164+
script: "python3 ci/scripts/check_pr.py --pr ${pr_number}",
165+
label: 'Check PR title and body',
166+
)
167+
}
168+
169+
}
170+
154171
def prepare() {
155172
stage('Prepare') {
156173
node('CPU-SMALL') {
157174
ws("workspace/exec_${env.EXECUTOR_NUMBER}/tvm/prepare") {
158175
init_git()
159176

177+
check_pr(env.CHANGE_ID)
178+
160179
if (env.DETERMINE_DOCKER_IMAGES == 'yes') {
161180
sh(
162181
script: "./ci/scripts/determine_docker_images.py {% for image in images %}{{ image.name }}={% raw %}${{% endraw %}{{ image.name }}{% raw %}}{% endraw %} {% endfor %}",

ci/scripts/check_pr.py

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
import argparse
19+
import re
20+
import os
21+
import textwrap
22+
from dataclasses import dataclass
23+
from typing import Any, List, Callable
24+
25+
26+
from git_utils import GitHubRepo, parse_remote, git
27+
from cmd_utils import init_log, tags_from_title
28+
29+
30+
GITHUB_USERNAME_REGEX = re.compile(r"(@[a-zA-Z0-9-]+)", flags=re.MULTILINE)
31+
OK = object()
32+
FAIL = object()
33+
34+
35+
@dataclass
36+
class Check:
37+
# check to run, returning OK means it passed, anything else means it failed
38+
check: Callable[[str], Any]
39+
40+
# function to call to generate the error message
41+
error_fn: Callable[[Any], str]
42+
43+
44+
def non_empty(s: str):
45+
if len(s) == 0:
46+
return FAIL
47+
return OK
48+
49+
50+
def usernames(s: str):
51+
m = GITHUB_USERNAME_REGEX.findall(s)
52+
return m if m else OK
53+
54+
55+
def tags(s: str):
56+
items = tags_from_title(s)
57+
if len(items) == 0:
58+
return FAIL
59+
return OK
60+
61+
62+
def trailing_period(s: str):
63+
if s.endswith("."):
64+
return FAIL
65+
return OK
66+
67+
68+
title_checks = [
69+
Check(check=non_empty, error_fn=lambda d: "PR must have a title but title was empty"),
70+
Check(check=trailing_period, error_fn=lambda d: "PR must not end in a tailing '.'"),
71+
# TODO(driazati): enable this check once https://github.com/apache/tvm/issues/12637 is done
72+
# Check(
73+
# check=usernames,
74+
# error_fn=lambda d: f"PR title must not tag anyone but found these usernames: {d}",
75+
# ),
76+
]
77+
body_checks = [
78+
Check(check=non_empty, error_fn=lambda d: "PR must have a body but body was empty"),
79+
# TODO(driazati): enable this check once https://github.com/apache/tvm/issues/12637 is done
80+
# Check(
81+
# check=usernames,
82+
# error_fn=lambda d: f"PR body must not tag anyone but found these usernames: {d}",
83+
# ),
84+
]
85+
86+
87+
def run_checks(checks: List[Check], s: str, name: str) -> bool:
88+
print(f"Running checks for {name}")
89+
print(textwrap.indent(s, prefix=" "))
90+
passed = True
91+
print(" Checks:")
92+
for i, check in enumerate(checks):
93+
result = check.check(s)
94+
if result == OK:
95+
print(f" [{i+1}] {check.check.__name__}: PASSED")
96+
else:
97+
passed = False
98+
msg = check.error_fn(result)
99+
print(f" [{i+1}] {check.check.__name__}: FAILED: {msg}")
100+
101+
return passed
102+
103+
104+
if __name__ == "__main__":
105+
init_log()
106+
help = "Check a PR's title and body for conformance to guidelines"
107+
parser = argparse.ArgumentParser(description=help)
108+
parser.add_argument("--pr", required=True)
109+
parser.add_argument("--remote", default="origin", help="ssh remote to parse")
110+
parser.add_argument(
111+
"--pr-body", help="(testing) PR body to use instead of fetching from GitHub"
112+
)
113+
parser.add_argument(
114+
"--pr-title", help="(testing) PR title to use instead of fetching from GitHub"
115+
)
116+
args = parser.parse_args()
117+
118+
try:
119+
pr = int(args.pr)
120+
except ValueError:
121+
print(f"PR was not a number: {args.pr}")
122+
exit(0)
123+
124+
if args.pr_body:
125+
body = args.pr_body
126+
title = args.pr_title
127+
else:
128+
remote = git(["config", "--get", f"remote.{args.remote}.url"])
129+
user, repo = parse_remote(remote)
130+
131+
github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
132+
pr = github.get(f"pulls/{args.pr}")
133+
body = pr["body"]
134+
title = pr["title"]
135+
136+
body = body.strip()
137+
title = title.strip()
138+
139+
title_passed = run_checks(checks=title_checks, s=title, name="PR title")
140+
print("")
141+
body_passed = run_checks(checks=body_checks, s=body, name="PR body")
142+
143+
if title_passed and body_passed:
144+
print("All checks passed!")
145+
exit(0)
146+
else:
147+
print(
148+
"Some checks failed, please review the logs above and edit your PR on GitHub accordingly"
149+
)
150+
exit(1)

ci/scripts/git_skip_ci.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def check_pr_title():
4646
if args.pr_title:
4747
title = args.pr_title
4848
else:
49-
github = GitHubRepo(token=os.environ["TOKEN"], user=user, repo=repo)
49+
github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
5050
pr = github.get(f"pulls/{args.pr}")
5151
title = pr["title"]
5252
logging.info(f"pr title: {title}")

0 commit comments

Comments
 (0)