Skip to content

Commit d3a3beb

Browse files
committed
ci: validate issue and PR metadata
1 parent caba0ff commit d3a3beb

10 files changed

Lines changed: 939 additions & 1 deletion

‎.asf.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ github:
3737
enabled_merge_buttons:
3838
merge: false
3939
squash: true
40+
squash_commit_message: PR_TITLE
4041
rebase: false
4142
rulesets:
4243
- name: "Branch Protection"

‎.github/PULL_REQUEST_TEMPLATE.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ Linked issue: close #xxx
99

1010
### Tests
1111

12-
<!-- List UT and IT cases to verify this change -->
12+
<!--
13+
Describe tests added or updated and what they cover; explain if no test changes are needed.
14+
Omit execution commands, results, and reasons tests were not run locally.
15+
-->
1316

1417
### API and Format
1518

‎.github/workflows/build_and_test.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
- name: Run packaging argument tests
4747
shell: bash
4848
run: ci/scripts/test_packaging_args.sh
49+
- name: Run issue and PR metadata validation tests
50+
run: python3 -m unittest discover -s ci/scripts -p 'test_check_*.py' -v
4951
- name: Run asan_symbolize tests
5052
shell: bash
5153
run: ci/scripts/test_asan_symbolize.sh
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. 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,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
name: Issue Template Check
19+
20+
on:
21+
issues:
22+
types: [opened, edited, reopened]
23+
24+
concurrency:
25+
group: ${{ github.repository }}-issue-template-${{ github.event.issue.number }}
26+
cancel-in-progress: false
27+
28+
permissions:
29+
contents: read
30+
issues: write
31+
32+
jobs:
33+
issue-template:
34+
name: Check issue template
35+
runs-on: ubuntu-24.04
36+
timeout-minutes: 5
37+
steps:
38+
- name: Checkout validation script
39+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
40+
with:
41+
persist-credentials: false
42+
- name: Check issue and update template reminder
43+
env:
44+
GITHUB_TOKEN: ${{ github.token }}
45+
run: python3 ci/scripts/check_issue_template.py

‎.github/workflows/pr_metadata.yaml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. 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,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
name: PR Metadata Check
19+
20+
on:
21+
pull_request:
22+
types: [opened, edited, reopened, synchronize]
23+
24+
concurrency:
25+
group: ${{ github.repository }}-${{ github.event.pull_request.number }}-${{ github.workflow }}
26+
cancel-in-progress: true
27+
28+
permissions:
29+
contents: read
30+
31+
jobs:
32+
metadata:
33+
name: PR ${{ matrix.check }}
34+
runs-on: ubuntu-24.04
35+
timeout-minutes: 5
36+
strategy:
37+
fail-fast: false
38+
matrix:
39+
check: [title, description]
40+
steps:
41+
- name: Checkout validation script
42+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
43+
with:
44+
persist-credentials: false
45+
- name: Check PR metadata
46+
run: python3 ci/scripts/check_pr_metadata.py ${{ matrix.check }}

‎CONTRIBUTING.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ Use the [Conventional Commits](https://www.conventionalcommits.org/) format for
3737
<type>(<optional-scope>): <description>
3838
```
3939

40+
Use one of these lowercase types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`,
41+
`refactor`, `revert`, `style`, or `test`. The scope is optional. For a breaking change,
42+
add `!` immediately before the colon, for example `feat(api)!: remove a deprecated interface`.
43+
The PR title check enforces this type list and format.
44+
4045
Examples:
4146

4247
```text

‎ci/scripts/check_issue_template.py‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
19+
"""Check issue forms and maintain a single bot reminder for missing information."""
20+
21+
import json
22+
import os
23+
import re
24+
import sys
25+
import urllib.error
26+
import urllib.request
27+
from typing import List
28+
29+
from check_pr_metadata import parse_sections
30+
31+
32+
SEARCH_SECTION = "Search before asking"
33+
SEARCH_CONFIRMATION = (
34+
"I searched in the [issues](https://github.com/apache/paimon-cpp/issues) "
35+
"and found nothing similar."
36+
)
37+
BUG_FIELDS = (
38+
"Paimon-cpp version", "Minimal reproduce step", "What doesn't meet your expectations?"
39+
)
40+
FEATURE_FIELDS = ("Motivation",)
41+
OPTIONAL_FIELDS = ("Solution", "Anything else?", "Are you willing to submit a PR?")
42+
ISSUE_SECTIONS = (SEARCH_SECTION,) + BUG_FIELDS + FEATURE_FIELDS + OPTIONAL_FIELDS
43+
NOTICE_MARKER = "<!-- paimon-cpp-issue-template-check -->"
44+
BOT_LOGIN = "github-actions[bot]"
45+
46+
47+
def check_issue_description(body: str) -> List[str]:
48+
sections, headings = parse_sections(body, ISSUE_SECTIONS)
49+
search_sections, _ = parse_sections(body, (SEARCH_SECTION,), include_fenced_code=False)
50+
search_checked = any(
51+
re.fullmatch(r"\s*[-*+]\s+\[[xX]\]\s+" + re.escape(SEARCH_CONFIRMATION) + r"\s*", line)
52+
for line in search_sections.get(SEARCH_SECTION, [])
53+
)
54+
candidates = []
55+
for fields in (BUG_FIELDS, FEATURE_FIELDS):
56+
if not any(field in sections for field in fields):
57+
continue
58+
errors = []
59+
if headings.count(SEARCH_SECTION) != 1 or not search_checked:
60+
errors.append("Complete 'Search before asking' and check the search confirmation box.")
61+
for field in fields:
62+
if headings.count(field) != 1:
63+
errors.append("Include exactly one '### " + field + "' section.")
64+
continue
65+
content = "\n".join(sections[field]).strip()
66+
if not re.search(r"\w", content) or content.strip("*_ .!").lower() in (
67+
"no response", "todo", "tbd", "na", "n/a"
68+
):
69+
errors.append("Fill in '" + field + "' with the requested information.")
70+
candidates.append(errors)
71+
if not candidates:
72+
return ["Use the Bug report or Feature template and fill in its required fields."]
73+
return min(candidates, key=len)
74+
75+
76+
def github_request(method, path, payload=None):
77+
data = None if payload is None else json.dumps(payload).encode("utf-8")
78+
request = urllib.request.Request(
79+
os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + path,
80+
data=data,
81+
headers={
82+
"Accept": "application/vnd.github+json",
83+
"Authorization": "Bearer " + os.environ["GITHUB_TOKEN"],
84+
"Content-Type": "application/json",
85+
"X-GitHub-Api-Version": "2022-11-28",
86+
},
87+
method=method,
88+
)
89+
with urllib.request.urlopen(request, timeout=30) as response:
90+
return json.load(response)
91+
92+
93+
def find_notice(api, issue_path):
94+
page = 1
95+
while True:
96+
comments = api("GET", issue_path + "/comments?per_page=100&page=" + str(page))
97+
for comment in comments:
98+
user = comment.get("user") or {}
99+
if (
100+
user.get("login") == BOT_LOGIN
101+
and user.get("type") == "Bot"
102+
and (comment.get("body") or "").startswith(NOTICE_MARKER)
103+
):
104+
return comment
105+
if len(comments) < 100:
106+
return None
107+
page += 1
108+
109+
110+
def update_notice(repository, issue_number, api, server_url="https://github.com"):
111+
issue_path = "/repos/" + repository + "/issues/" + str(issue_number)
112+
# Read the current body so queued events and reruns cannot restore an outdated reminder.
113+
issue = api("GET", issue_path)
114+
if "pull_request" in issue or issue.get("state") != "open":
115+
return
116+
errors = check_issue_description(issue.get("body") or "")
117+
notice = find_notice(api, issue_path)
118+
if errors:
119+
new_issue_url = server_url.rstrip("/") + "/" + repository + "/issues/new?template="
120+
message = (
121+
NOTICE_MARKER + "\n\n"
122+
"Thanks for opening this issue. Please use one of our issue templates:\n\n"
123+
"- [Bug report](" + new_issue_url + "bug_report.yaml)\n"
124+
"- [Feature request](" + new_issue_url + "feature.yaml)\n\n"
125+
"You can edit this issue to include the template sections and required information. "
126+
"There is no need to open a duplicate issue.\n\n"
127+
"Please address the following:\n\n"
128+
+ "\n".join("- " + error for error in errors)
129+
)
130+
elif notice:
131+
message = NOTICE_MARKER + "\n\nThanks for updating this issue to follow our issue template."
132+
else:
133+
return
134+
if notice:
135+
if notice["body"] != message:
136+
api("PATCH", "/repos/" + repository + "/issues/comments/" + str(notice["id"]),
137+
{"body": message})
138+
print("Updated the issue template reminder.")
139+
else:
140+
api("POST", issue_path + "/comments", {"body": message})
141+
print("Posted the issue template reminder.")
142+
143+
144+
def main() -> int:
145+
try:
146+
with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as source:
147+
event = json.load(source)
148+
issue_number = event["issue"]["number"]
149+
if not isinstance(issue_number, int) or issue_number <= 0:
150+
raise ValueError("Invalid issue number")
151+
update_notice(
152+
os.environ["GITHUB_REPOSITORY"], issue_number, github_request,
153+
os.environ.get("GITHUB_SERVER_URL", "https://github.com"),
154+
)
155+
except urllib.error.HTTPError as error:
156+
print("GitHub API request failed with HTTP " + str(error.code) + ".", file=sys.stderr)
157+
return 1
158+
except (OSError, KeyError, TypeError, ValueError) as error:
159+
print("Cannot check issue template: " + str(error), file=sys.stderr)
160+
return 1
161+
return 0
162+
163+
164+
if __name__ == "__main__":
165+
sys.exit(main())

0 commit comments

Comments
 (0)