@@ -87,6 +87,38 @@ def _reconfigure_io() -> None:
8787JOB_SUMMARY_ENABLED = env_flag ("JOB_SUMMARY" )
8888PR_COMMENTS_ENABLED = env_flag ("PR_COMMENTS" )
8989PR_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)``.
416472Commit = 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+
17851948def 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