Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,29 @@ This GitHub Action enforces consistent commit message formatting for Qualcomm pr
Create a new GitHub Actions workflow in your project, e.g. at .github/workflows/commit-check.yml

name: Commit Msg Check Action

on:
pull_request:
types: [opened, synchronize, reopened]

jobs:
check-commits:
runs-on: ubuntu-latest

steps:
- name: Run custom commit check
uses: qualcomm/commit-msg-check-action@v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0

- name: Commit Check
uses: qualcomm/commit-msg-check-action@v2.0.0
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
body-char-limit: 72
sub-char-limit: 50
check-blank-line: true
Expand Down
17 changes: 13 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
name: "My Python Commit Check"
name: "Commit Msg Check"
description: "Checks commit messages using a Python script"

inputs:
base:
Comment thread
ynancher marked this conversation as resolved.
Comment thread
ynancher marked this conversation as resolved.
description: "Base SHA for the commit range"
required: true

head:
description: "Head SHA for the commit range"
required: true

body-char-limit:
required: false
default: "72"
Expand All @@ -14,7 +22,7 @@ inputs:

check-blank-line:
required: false
default: true
default: "true"
description: "check for a blank line between commit title and body"

runs:
Expand All @@ -39,10 +47,11 @@ runs:

- name: Check commit messages
shell: bash
working-directory: ${{ github.workspace }}
run: |
python ${{ github.action_path }}/check_commits.py \
--repo "${{ github.repository }}" \
--pr-number "${{ github.event.pull_request.number }}" \
--base "${{ inputs.base }}" \
--head "${{ inputs.head }}" \
--body-limit "${{ inputs.body-char-limit }}" \
--sub-limit "${{ inputs.sub-char-limit }}" \
--check-blank-line "${{ inputs.check-blank-line }}"
188 changes: 95 additions & 93 deletions check_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,123 +3,128 @@

import os
import sys
import requests
import argparse

api_base_url = os.getenv("GITHUB_API_URL")
import subprocess


def parse_arguments():
parser = argparse.ArgumentParser(
description="Validate commit messages in a GitHub PR."
description="Validate commit messages using local Git history (no API/token)."
)
parser.add_argument(
"--base", required=True, help="Base SHA for the range (base..head)"
)
parser.add_argument(
"--head", required=True, help="Head SHA for the range (or single ref)"
)
parser.add_argument("--repo", required=True)
Comment thread
ynancher marked this conversation as resolved.
parser.add_argument("--pr-number", required=True)
Comment thread
ynancher marked this conversation as resolved.
parser.add_argument("--body-limit", type=int, default=72)
parser.add_argument("--sub-limit", type=int, default=50)
parser.add_argument("--check-blank-line", type=str, default="true")
return parser.parse_args()


def fetch_commits(args):
token = os.getenv("GITHUB_TOKEN")
if not token:
print("::error::No GITHUB_TOKEN found!")
sys.exit(1)

url = f"{api_base_url}/repos/{args.repo}/pulls/{args.pr_number}/commits"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
response = requests.get(url, headers=headers)

if response.status_code != 200:
print(
f"::error::Failed to fetch PR commits: {response.status_code} {response.text}"
def fetch_commits(base, head):
"""
Fetch commits between base..head from local repository.
Returns a list of dicts with 'sha' and 'message' keys.
"""
if not head:
print("::error::Tokenless mode requires --head (and usually --base).")
sys.exit(2)

rev_range = f"{base}..{head}" if base else head
try:
shas = (
subprocess.check_output(
["git", "rev-list", "--no-merges", rev_range], text=True
)
.strip()
.splitlines()
)
sys.exit(1)

return response.json()


def validate_commit_message(commit, sub_char_limit, body_char_limit, check_blank_line):
sha = commit["sha"]
message = commit["commit"]["message"]
lines = message.splitlines()
n = len(lines)

subject = lines[0] if n >= 1 else ""
body = [
line.strip()
for line in lines[1:]
if line.strip() and not line.lower().startswith("signed-off-by")
]
signed_off = lines[-1] if "signed-off-by" in lines[-1].lower() else ""
missing_sub_body_line = False
missing_body_sign_line = False

if check_blank_line.lower() == "true":
if n > 1 and lines[1].strip() != "":
missing_sub_body_line = True
else:
body = [
line.strip()
for line in lines[2:]
if line.strip() and not line.lower().startswith("signed-off-by")
]
if signed_off and lines[-2].strip() != "":
missing_body_sign_line = True

if not shas:
return []
output = subprocess.check_output(
["git", "show", "-s", "--format=%H%x00%B%x00"] + shas, text=True
)
commits = []
parts = output.split("\x00")
for i in range(0, len(parts) - 1, 2):
sha = parts[i].strip()
message = parts[i + 1] if i + 1 < len(parts) else ""
if sha:
commits.append({"sha": sha, "message": message})
return commits

except subprocess.CalledProcessError as e:
print(f"::error::Failed to fetch commits with git: {e}")
sys.exit(2)


def validate_subject(subject, sub_char_limit):
"""Validate the commit subject line."""
errors = []
if len(subject.strip()) == 0:
errors.append("Commit message is missing subject!")
if len(subject) > sub_char_limit:
errors.append(f"Subject exceeds {sub_char_limit} characters!")
return errors


def validate_body(lines, n, body_char_limit, check_blank_line):
"""Validate the commit body."""
errors = []

body_index = 1
if check_blank_line.lower() == "true":
if missing_sub_body_line and subject and body:
# Check for blank line after subject
if n > 1 and lines[1].strip() != "":
errors.append("Subject and body must be separated by a blank line")
if missing_body_sign_line and body and signed_off:
errors.append("Body and Signed-off-by must be separated by a blank line")
body_index = 2
body = [
line.strip()
for line in lines[body_index:n]
if line.strip() and not line.lower().startswith("signed-off-by")
]
Comment thread
ynancher marked this conversation as resolved.
if len(body) == 0:
errors.append("Commit message is missing a body!")
for line in body:
if len(line) > body_char_limit:
errors.append(f"Line exceeds {body_char_limit} characters: {line}")

return errors, body


def validate_signoff(lines, n, body, check_blank_line):
"""Validate the Signed-off-by line."""
errors = []

signed_off = lines[-1] if n > 0 and "signed-off-by" in lines[-1].lower() else ""

if check_blank_line.lower() == "true" and body and signed_off:
if n >= 2 and lines[-2].strip() != "":
errors.append("Body and Signed-off-by must be separated by a blank line")

return errors


def validate_commit_message(commit, sub_char_limit, body_char_limit, check_blank_line):
sha = commit["sha"]
message = commit["message"]
lines = message.splitlines()
n = len(lines)
subject = lines[0] if n >= 1 else ""

errors = []
subject_errors = validate_subject(subject, sub_char_limit)
body_errors, body = validate_body(lines, n, body_char_limit, check_blank_line)
signed_off_errors = validate_signoff(lines, n, body, check_blank_line)

errors.extend(subject_errors + body_errors + signed_off_errors)

return sha, errors


def add_commit_comment(repo, sha, message):
token = os.getenv("GITHUB_TOKEN")
if not token:
return
url = f"{api_base_url}/repos/{repo}/commits/{sha}/comments"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
requests.post(url, headers=headers, json={"body": message})


def set_commit_status(repo, sha, state, description):
token = os.getenv("GITHUB_TOKEN")
if not token:
return
url = f"{api_base_url}/repos/{repo}/statuses/{sha}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
data = {
"state": state,
"description": description,
"context": "commit-message-check",
}
requests.post(url, headers=headers, json=data)


def process_commits(commits, repo, sub_limit, body_limit, check_blank_line):
def process_commits(commits, sub_limit, body_limit, check_blank_line):
failed_count = 0
for commit in commits:
sha, errors = validate_commit_message(
Expand All @@ -130,20 +135,17 @@ def process_commits(commits, repo, sub_limit, body_limit, check_blank_line):
failed_count += 1
for err in errors:
print(f"::error:: {err}")
add_commit_comment(repo, sha, "\n".join(errors))
Comment thread
ynancher marked this conversation as resolved.
set_commit_status(repo, sha, "failure", "Commit message validation failed")
print("::endgroup::")
Comment thread
ynancher marked this conversation as resolved.
else:
print(f"✅ Commit {sha} passed all checks.")
set_commit_status(repo, sha, "success", "Commit message validation passed")
return failed_count


def main():
args = parse_arguments()
commits = fetch_commits(args)
commits = fetch_commits(args.base, args.head)
failed_count = process_commits(
commits, args.repo, args.sub_limit, args.body_limit, args.check_blank_line
commits, args.sub_limit, args.body_limit, args.check_blank_line
)

summary_path = os.getenv("GITHUB_STEP_SUMMARY")
Expand Down
Loading
Loading