Skip to content

Commit c9ebd49

Browse files
authored
Automate contributing rule checks (#167)
* Add Ruff docstring style checks * Add contributing style checker * Apply contributing style rules * Run contributing style checks in automation * Check PR metadata automatically * Allow descriptive docstring summaries * Narrow Ruff docstring checks * Apply Ruff fixes to style checks * Honor return spacing exception * Enable Ruff docstring layout checks * Enable imperative docstring summaries
1 parent daf55c7 commit c9ebd49

17 files changed

Lines changed: 940 additions & 40 deletions

.githooks/commit-msg

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,19 @@ else
8484
exit 1
8585
fi
8686

87+
if command -v python3 > /dev/null; then
88+
python_cmd=python3
89+
elif command -v python > /dev/null; then
90+
python_cmd=python
91+
else
92+
echo "❌ Error: Python not found." >&2
93+
exit 1
94+
fi
95+
96+
echo "Running contributing style checks..." >&2
97+
if ! "$python_cmd" scripts/check_contributing_style.py; then
98+
echo "❌ Error: Contributing style check failed." >&2
99+
exit 1
100+
fi
101+
87102
echo "✅ Code formatting completed." >&2

.github/workflows/contributing.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Contributing
2+
on:
3+
pull_request_target:
4+
types: [opened, edited, reopened, synchronize, ready_for_review]
5+
6+
jobs:
7+
metadata:
8+
runs-on: ubuntu-latest
9+
permissions:
10+
contents: read
11+
pull-requests: read
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Check PR metadata
15+
run: python3 scripts/check_contributing_metadata.py --event "$GITHUB_EVENT_PATH"

.github/workflows/ruff.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ jobs:
55
runs-on: ubuntu-latest
66
steps:
77
- uses: actions/checkout@v4
8+
- name: Check contributing style
9+
run: python3 scripts/check_contributing_style.py
810
- uses: chartboost/ruff-action@v1
911
- uses: chartboost/ruff-action@v1
1012
with:

CONTRIBUTING.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ We follow [GitHub flow](https://docs.github.com/en/get-started/using-github/gith
5555

5656
### Branch Naming
5757

58-
Use kebab-case (lowercase letters, numbers, and hyphens) for branch names, with a maximum of 50 characters. This is enforced by the `pre-push` hook.
58+
Use kebab-case (lowercase letters, numbers, and hyphens) for branch names, with a maximum of 50 characters. This is enforced by the `pre-push` hook and the PR metadata workflow.
5959

6060
- **Valid:** `develop-visualization`, `fix-123-memory-leak`, `add-conv2d-support`
6161
- **Invalid:** `Develop_Visualization`, `fix_memory_leak`, `myBranch`
@@ -68,7 +68,7 @@ The following rules apply to both commit messages and PR titles:
6868
- **Do not** end with punctuation (`.`, `!`, `?`, etc.).
6969
- **Use imperative mood** (e.g., "Add feature" not "Added feature").
7070

71-
These rules are enforced by the `commit-msg` hook.
71+
Commit messages are enforced by the `commit-msg` hook. PR titles are enforced by the PR metadata workflow.
7272

7373
**Valid examples:**
7474

@@ -84,7 +84,7 @@ These rules are enforced by the `commit-msg` hook.
8484

8585
### Pull Request Requirements
8686

87-
Before merging a PR, you must provide the `pytest` output in the PR description to confirm that all tests pass with your latest changes. The PR template includes a section for this. See [#30](https://github.com/InfiniTensor/ninetoothed/pull/30) for a reference example.
87+
Before merging a PR, you must provide the `pytest` output in the PR description to confirm that all tests pass with your latest changes. The PR template includes a section for this, and the PR metadata workflow checks that the section is filled in. See [#30](https://github.com/InfiniTensor/ninetoothed/pull/30) for a reference example.
8888

8989
## Code Style Guide
9090

@@ -96,7 +96,19 @@ Run [Ruff](https://docs.astral.sh/ruff/) before every commit:
9696
ruff format && ruff check
9797
```
9898

99-
This is also enforced by the `commit-msg` hook.
99+
Run the project-specific contributing style checker for Python code:
100+
101+
```bash
102+
python scripts/check_contributing_style.py
103+
```
104+
105+
To apply mechanical blank-line fixes before checking:
106+
107+
```bash
108+
python scripts/check_contributing_style.py --fix
109+
```
110+
111+
Ruff and the project-specific contributing style checks are also enforced by the `commit-msg` hook.
100112

101113
### Additional Rules
102114

@@ -132,10 +144,26 @@ Run the formatter:
132144
ruff format
133145
```
134146

147+
Run the project-specific contributing style checker:
148+
149+
```bash
150+
python scripts/check_contributing_style.py
151+
```
152+
153+
Apply project-specific blank-line fixes:
154+
155+
```bash
156+
python scripts/check_contributing_style.py --fix
157+
```
158+
135159
To run a full local CI check before pushing:
136160

137161
```bash
138-
ruff format && ruff check && pytest
162+
python scripts/check_contributing_style.py --fix
163+
ruff format
164+
ruff check
165+
python scripts/check_contributing_style.py
166+
pytest
139167
```
140168

141169
## Version Release Process

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,10 @@ src = [".", "src", "tests"]
3030

3131
[tool.ruff.lint]
3232
select = ["E4", "E7", "E9", "F", "I"]
33+
extend-select = ["D2", "D3", "D4"]
34+
35+
[tool.ruff.lint.pydocstyle]
36+
convention = "pep257"
37+
38+
[tool.ruff.format]
39+
docstring-code-format = true
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python3
2+
"""Check contribution metadata rules from ``CONTRIBUTING.md``."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import re
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
12+
BRANCH_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
13+
TRAILING_PUNCTUATION = ".,;:!?"
14+
PAST_TENSE_PATTERN = re.compile(
15+
r"\b(added|fixed|changed|modified|removed|updated|implemented|created|improved)\b",
16+
re.IGNORECASE,
17+
)
18+
PYTEST_OUTPUT_PATTERN = re.compile(
19+
r"`?pytest`?\s+output\s*:\s*```[a-zA-Z0-9_-]*\s*(.*?)```",
20+
re.IGNORECASE | re.DOTALL,
21+
)
22+
23+
24+
@dataclass(frozen=True)
25+
class Metadata:
26+
title: str | None = None
27+
branch: str | None = None
28+
body: str | None = None
29+
30+
31+
def main() -> int:
32+
args = parse_args()
33+
metadata = load_metadata(args)
34+
diagnostics = check_metadata(
35+
metadata,
36+
check_title=not args.skip_title,
37+
check_branch=not args.skip_branch,
38+
check_pytest_output=not args.skip_pytest_output,
39+
)
40+
41+
for diagnostic in diagnostics:
42+
print(diagnostic)
43+
44+
return 1 if diagnostics else 0
45+
46+
47+
def parse_args() -> argparse.Namespace:
48+
parser = argparse.ArgumentParser(
49+
description="Check contribution metadata rules from CONTRIBUTING.md."
50+
)
51+
parser.add_argument("--event", type=Path, help="GitHub event JSON file.")
52+
parser.add_argument("--title", help="Commit message subject or PR title.")
53+
parser.add_argument("--branch", help="Branch name to validate.")
54+
parser.add_argument("--body", help="PR body text.")
55+
parser.add_argument("--body-file", type=Path, help="File containing the PR body.")
56+
parser.add_argument(
57+
"--commit-message-file",
58+
type=Path,
59+
help="Commit message file whose first line should be checked as a title.",
60+
)
61+
parser.add_argument("--skip-title", action="store_true", help="Skip title checks.")
62+
parser.add_argument(
63+
"--skip-branch", action="store_true", help="Skip branch checks."
64+
)
65+
parser.add_argument(
66+
"--skip-pytest-output",
67+
action="store_true",
68+
help="Skip PR body pytest output checks.",
69+
)
70+
71+
return parser.parse_args()
72+
73+
74+
def load_metadata(args: argparse.Namespace) -> Metadata:
75+
metadata = Metadata()
76+
77+
if args.event is not None:
78+
metadata = metadata_from_event(args.event)
79+
80+
title = args.title if args.title is not None else metadata.title
81+
branch = args.branch if args.branch is not None else metadata.branch
82+
body = args.body if args.body is not None else metadata.body
83+
84+
if args.body_file is not None:
85+
body = args.body_file.read_text(encoding="utf-8")
86+
87+
if args.commit_message_file is not None:
88+
title = first_line(args.commit_message_file.read_text(encoding="utf-8"))
89+
90+
return Metadata(title=title, branch=branch, body=body)
91+
92+
93+
def metadata_from_event(path: Path) -> Metadata:
94+
payload = json.loads(path.read_text(encoding="utf-8"))
95+
pull_request = payload.get("pull_request") or {}
96+
head = pull_request.get("head") or {}
97+
98+
return Metadata(
99+
title=pull_request.get("title"),
100+
branch=head.get("ref"),
101+
body=pull_request.get("body") or "",
102+
)
103+
104+
105+
def first_line(text: str) -> str:
106+
return text.splitlines()[0] if text.splitlines() else ""
107+
108+
109+
def check_metadata(
110+
metadata: Metadata,
111+
*,
112+
check_title: bool,
113+
check_branch: bool,
114+
check_pytest_output: bool,
115+
) -> list[str]:
116+
diagnostics: list[str] = []
117+
118+
if check_title:
119+
diagnostics.extend(check_title_text(metadata.title, label="title"))
120+
121+
if check_branch:
122+
diagnostics.extend(check_branch_name(metadata.branch))
123+
124+
if check_pytest_output:
125+
diagnostics.extend(check_pytest_output_block(metadata.body))
126+
127+
return diagnostics
128+
129+
130+
def check_title_text(title: str | None, *, label: str) -> list[str]:
131+
diagnostics: list[str] = []
132+
normalized = (title or "").strip()
133+
134+
if not normalized:
135+
return [f"METADATA001: The {label} cannot be empty."]
136+
137+
if not normalized[0].isupper():
138+
diagnostics.append(
139+
f"METADATA002: The {label} must start with an uppercase letter."
140+
)
141+
142+
if normalized[-1] in TRAILING_PUNCTUATION:
143+
diagnostics.append(f"METADATA003: The {label} must not end with punctuation.")
144+
145+
if PAST_TENSE_PATTERN.search(normalized):
146+
diagnostics.append(f"METADATA004: The {label} must use imperative mood.")
147+
148+
return diagnostics
149+
150+
151+
def check_branch_name(branch: str | None) -> list[str]:
152+
normalized = (branch or "").strip()
153+
154+
if not normalized:
155+
return ["METADATA005: The branch name cannot be empty."]
156+
157+
diagnostics: list[str] = []
158+
159+
if not BRANCH_PATTERN.fullmatch(normalized):
160+
diagnostics.append(
161+
"METADATA006: The branch name must use kebab-case with lowercase letters, "
162+
"numbers, and hyphens."
163+
)
164+
165+
if len(normalized) > 50:
166+
diagnostics.append(
167+
"METADATA007: The branch name must be 50 characters or shorter."
168+
)
169+
170+
return diagnostics
171+
172+
173+
def check_pytest_output_block(body: str | None) -> list[str]:
174+
normalized = body or ""
175+
match = PYTEST_OUTPUT_PATTERN.search(normalized)
176+
177+
if match is None:
178+
return [
179+
"METADATA008: The PR description must include a `pytest` output code block."
180+
]
181+
182+
if not match.group(1).strip():
183+
return ["METADATA009: The `pytest` output code block cannot be empty."]
184+
185+
return []
186+
187+
188+
if __name__ == "__main__":
189+
raise SystemExit(main())

0 commit comments

Comments
 (0)