Skip to content

Commit 3ea1f76

Browse files
authored
feat: check the PR description and label AI-written pull requests (#323)
Co-authored-by: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com>
1 parent 29f40ef commit 3ea1f76

5 files changed

Lines changed: 553 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,16 @@ runners need a few tools — see [Good to know](#good-to-know).
7979
| `author-name` | `false` | Check each commit's author name |
8080
| `author-email` | `false` | Check each commit's author email |
8181
| `pr-title` | `false` | Check the pull request title against Conventional Commits — the one that matters for squash merges. Pull request events only |
82+
| `pr-body` | `false` | Check the pull request description against the AI rules of `cchk.toml` (`ai_attribution`) only. Pull request events only |
83+
| `ai-label` | `false` | Label the pull request when an AI tool wrote any of it (a commit, the description, or an agent's app that opened it): `true` for `ai-assisted`, or the label's name. Labels are only added, never removed. Needs `pull-requests: write`; skipped on fork pull requests under `pull_request` |
8284
| `pr-comments` | `false` | Post the report as a pull request comment, edited in place on later runs. Needs `pull-requests: write`; skipped on fork pull requests |
8385
| `job-summary` | `true` | Write the report to the job summary |
8486
| `dry-run` | `false` | Report failures as warnings and always exit 0 |
8587

8688
> [!TIP]
87-
> `pull_request` does not fire when a title is edited. With `pr-title: true`,
88-
> add `types: [opened, synchronize, reopened, edited]` to re-check a renamed
89-
> pull request.
89+
> `pull_request` does not fire when a title or description is edited. With
90+
> `pr-title` or `pr-body`, add `types: [opened, synchronize, reopened, edited]`
91+
> to re-check an edited pull request.
9092

9193
## Configuration
9294

‎action.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ inputs:
3737
description: check pull request title following conventional commits
3838
required: false
3939
default: false
40+
pr-body:
41+
description: check the pull request description against the AI attribution rules (ai_attribution in cchk.toml)
42+
required: false
43+
default: false
44+
ai-label:
45+
description: label the pull request when an AI tool wrote any of it; true for the label ai-assisted, or the label's name
46+
required: false
47+
default: false
4048
outputs:
4149
result:
4250
description: Structured check results as JSON (status pass/warn/skip/fail + per-scope label, sha and checks). Consume with fromJSON(steps.<id>.outputs.result).
@@ -110,4 +118,6 @@ runs:
110118
JOB_SUMMARY: ${{ inputs.job-summary }}
111119
PR_COMMENTS: ${{ inputs.pr-comments }}
112120
PR_TITLE: ${{ inputs.pr-title }}
121+
PR_BODY: ${{ inputs.pr-body }}
122+
AI_LABEL: ${{ inputs.ai-label }}
113123
GITHUB_TOKEN: ${{ github.token }}

‎main.py‎

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,38 @@ def _reconfigure_io() -> None:
8787
JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY")
8888
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
8989
PR_TITLE_ENABLED = env_flag("PR_TITLE")
90+
PR_BODY_ENABLED = env_flag("PR_BODY")
91+
92+
#: The label ``ai-label: true`` puts on a pull request that an AI tool wrote.
93+
DEFAULT_AI_LABEL = "ai-assisted"
94+
#: The colour a label gets when the Action has to create it.
95+
AI_LABEL_COLOR = "8250df"
96+
97+
98+
def ai_label_name() -> str | None:
99+
"""The label to put on an AI-written pull request, or ``None`` for none.
100+
101+
``ai-label: true`` means the default label; any other value but
102+
``false`` is the label's own name.
103+
"""
104+
value = os.getenv("AI_LABEL", "false").strip()
105+
if not value or value.lower() == "false":
106+
return None
107+
return DEFAULT_AI_LABEL if value.lower() == "true" else value
108+
109+
110+
#: AI coding agents that open pull requests from their own GitHub App, by
111+
#: login, with the tool each one is. The same list as the GitHub App's.
112+
AI_AGENT_BOTS = {
113+
"amazon-q-developer[bot]": "Amazon Q Developer",
114+
"chatgpt-codex-connector[bot]": "OpenAI Codex",
115+
"claude[bot]": "Claude Code",
116+
"copilot-swe-agent[bot]": "GitHub Copilot",
117+
"cursor[bot]": "Cursor",
118+
"devin-ai-integration[bot]": "Devin",
119+
"factory-droid[bot]": "Factory Droid",
120+
"google-labs-jules[bot]": "Jules",
121+
}
90122

91123

92124
@dataclass
@@ -109,6 +141,8 @@ class ScopeResult:
109141
checks: list[dict[str, str]] = field(default_factory=list)
110142
raw_text: str = ""
111143
sha: str = ""
144+
#: The AI tools the CLI found in what this scope checked (``ai_tools``).
145+
ai_tools: list[str] = field(default_factory=list)
112146

113147
@property
114148
def display_label(self) -> str:
@@ -220,6 +254,8 @@ def log_env_vars():
220254
"JOB_SUMMARY",
221255
"PR_COMMENTS",
222256
"PR_TITLE",
257+
"PR_BODY",
258+
"AI_LABEL",
223259
):
224260
value = os.getenv(name, "false")
225261
print(f"::debug::{name}={value}")
@@ -412,6 +448,26 @@ def get_pr_title() -> str | None:
412448
return None
413449

414450

451+
def get_pr_body() -> str | None:
452+
"""Read the pull request description from the event payload.
453+
454+
GitHub sends ``null`` for a pull request opened with no description.
455+
"""
456+
body = get_pr_event().get("body")
457+
return body if isinstance(body, str) and body.strip() else None
458+
459+
460+
def get_pr_opener_tool() -> str | None:
461+
"""The AI tool that opened the pull request from its own app, if any."""
462+
login = (get_pr_event().get("user") or {}).get("login") or ""
463+
return AI_AGENT_BOTS.get(str(login).lower())
464+
465+
466+
#: The AI tools found in the pull request description when it was read for
467+
#: the label only (``ai-label`` without ``pr-body``), so it is no scope.
468+
PR_BODY_AI_TOOLS: list[str] = []
469+
470+
415471
#: One commit to check: ``(full sha, message)``.
416472
Commit = tuple[str, str]
417473

@@ -663,7 +719,13 @@ def check_scope(
663719
"""
664720
_rc, data, raw = run_check_json(args, input_text=input_text, env=env)
665721
if isinstance(data, dict):
666-
return ScopeResult(label=label, checks=data.get("checks", []), sha=sha)
722+
tools = data.get("ai_tools")
723+
return ScopeResult(
724+
label=label,
725+
checks=data.get("checks", []),
726+
sha=sha,
727+
ai_tools=[str(t) for t in tools] if isinstance(tools, list) else [],
728+
)
667729
return ScopeResult(label=label, raw_text=raw, sha=sha)
668730

669731

@@ -727,8 +789,11 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
727789
728790
Checks are evaluated in order:
729791
1. PR title (when ``pr-title: true`` and in a PR event)
730-
2. Individual PR commit messages (when ``message: true`` and in a PR event)
731-
3. All remaining checks (branch, author name/email, etc.)
792+
2. PR description, AI rules only (when ``pr-body: true`` and in a PR
793+
event; with only ``ai-label`` set it is read for the label, and no
794+
scope is reported)
795+
3. Individual PR commit messages (when ``message: true`` and in a PR event)
796+
4. All remaining checks (branch, author name/email, etc.)
732797
733798
Outside of a PR event all enabled checks are handed to the CLI at once.
734799
"""
@@ -741,7 +806,17 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
741806
if pr_title:
742807
results.append(check_scope("PR title", ["--message"], input_text=pr_title))
743808

744-
# ---- 2. Commit message checks -----------------------------------------
809+
# ---- 2. PR description ------------------------------------------------
810+
if (PR_BODY_ENABLED or ai_label_name()) and is_pr_event():
811+
pr_body = get_pr_body()
812+
if pr_body:
813+
scope = check_scope("PR description", ["--ai-only"], input_text=pr_body)
814+
if PR_BODY_ENABLED:
815+
results.append(scope)
816+
else:
817+
PR_BODY_AI_TOOLS[:] = scope.ai_tools
818+
819+
# ---- 3. Commit message checks -----------------------------------------
745820
if MESSAGE_ENABLED:
746821
pr_commits = get_pr_commit_messages()
747822
if pr_commits:
@@ -757,7 +832,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
757832
"Could not list the pull request's commits", "only HEAD was checked"
758833
)
759834

760-
# ---- 3. Remaining checks (branch, author, etc.) -----------------------
835+
# ---- 4. Remaining checks (branch, author, etc.) -----------------------
761836
# Outside a PR, check the HEAD commit message directly.
762837
if "--message" in args:
763838
results.append(check_scope("Commit message", ["--message"], sha=head_sha()))
@@ -1560,6 +1635,7 @@ def set_result_output(results: list[ScopeResult]) -> None:
15601635
return
15611636
payload = {
15621637
"status": overall_status(results),
1638+
"ai_tools": ai_tools_found(results),
15631639
"scopes": [
15641640
{
15651641
"label": scope.label,
@@ -1782,6 +1858,93 @@ def add_pr_comments(results: list[ScopeResult]) -> int:
17821858
return 0
17831859

17841860

1861+
def ai_tools_found(results: list[ScopeResult]) -> list[str]:
1862+
"""Every AI tool found in the pull request, sorted.
1863+
1864+
In the checked commits (messages and authors), the title, the
1865+
description, and the app that opened the pull request.
1866+
"""
1867+
tools = {tool for scope in results for tool in scope.ai_tools}
1868+
tools.update(PR_BODY_AI_TOOLS)
1869+
if is_pr_event():
1870+
opener = get_pr_opener_tool()
1871+
if opener:
1872+
tools.add(opener)
1873+
return sorted(tools)
1874+
1875+
1876+
def add_ai_label(results: list[ScopeResult]) -> None:
1877+
"""Label the pull request when an AI tool wrote any of it (``ai-label``).
1878+
1879+
The label is only ever added, never removed: a maintainer may have put
1880+
it there by hand. Best-effort, like the comment: a failure warns and
1881+
never fails the step.
1882+
"""
1883+
name = ai_label_name()
1884+
if not name or not is_pr_event():
1885+
return
1886+
tools = ai_tools_found(results)
1887+
if not tools:
1888+
print("No AI tool found in the pull request; no label added.")
1889+
return
1890+
found = ", ".join(tools)
1891+
if is_fork_pr_with_readonly_token():
1892+
print(
1893+
f"::warning::AI tools found ({found}), but the label '{name}' was "
1894+
"not added: pull requests from forked repositories get a read-only "
1895+
"GITHUB_TOKEN under the pull_request event. "
1896+
"See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md"
1897+
)
1898+
return
1899+
1900+
try:
1901+
from github import Auth, Github, GithubException # type: ignore
1902+
except ImportError as e:
1903+
print(f"::warning::Unable to add the AI label: {e}", file=sys.stderr)
1904+
return
1905+
1906+
try:
1907+
token = os.getenv("GITHUB_TOKEN")
1908+
repo_name = os.getenv("GITHUB_REPOSITORY")
1909+
if not token:
1910+
raise ValueError("GITHUB_TOKEN is not set")
1911+
if not repo_name:
1912+
raise ValueError("GITHUB_REPOSITORY is not set")
1913+
pr_number = get_pr_number()
1914+
api_url = os.getenv("GITHUB_API_URL", "https://api.github.com")
1915+
repo = Github(auth=Auth.Token(token), base_url=api_url).get_repo(repo_name)
1916+
issue = repo.get_issue(pr_number)
1917+
if any(label.name == name for label in issue.labels):
1918+
print(f"PR #{pr_number} already has the label '{name}' ({found}).")
1919+
return
1920+
try:
1921+
repo.get_label(name)
1922+
except GithubException as e:
1923+
if e.status != 404:
1924+
raise
1925+
try:
1926+
repo.create_label(
1927+
name, AI_LABEL_COLOR, "Written with the help of an AI tool"
1928+
)
1929+
except GithubException as created:
1930+
# 422: another run created it in the meantime; use that one.
1931+
if created.status != 422:
1932+
raise
1933+
issue.add_to_labels(name)
1934+
print(f"Added the label '{name}' to PR #{pr_number}: {found}.")
1935+
except GithubException as e:
1936+
hint = (
1937+
" Ensure your workflow grants 'pull-requests: write' permission."
1938+
if e.status == 403
1939+
else ""
1940+
)
1941+
print(
1942+
f"::warning::Unable to add the AI label.{hint} Error: {e}", file=sys.stderr
1943+
)
1944+
except Exception as e: # noqa: BLE001 - the label is best-effort
1945+
print(f"::warning::Unable to add the AI label: {e}", file=sys.stderr)
1946+
1947+
17851948
def log_error_and_exit(ret_code: int) -> None:
17861949
"""Exit with the given code.
17871950
@@ -1806,6 +1969,7 @@ def main():
18061969
set_result_output(results)
18071970

18081971
ret_code = max(ret_code, add_job_summary(results), add_pr_comments(results))
1972+
add_ai_label(results)
18091973

18101974
if DRY_RUN_ENABLED:
18111975
ret_code = 0

0 commit comments

Comments
 (0)