1+ import os
2+ import anthropic
3+ from github import Github , Auth
4+
5+ # Setup
6+
7+ gh = Github (auth = Auth .Token (os .environ ["GITHUB_TOKEN" ]))
8+ repo = gh .get_repo (os .environ ["REPO_NAME" ])
9+ pr = repo .get_pull (int (os .environ ["PR_NUMBER" ]))
10+ author = os .environ ["AUTHOR_USERNAME" ]
11+ client = anthropic .Anthropic ()
12+
13+ # Tools
14+
15+ TOOLS = [
16+ {
17+ "name" : "post_comment" ,
18+ "description" : (
19+ "Post a comment on the PR. Use this to welcome a first-time contributor, "
20+ "ask for a clearer description, request an issue link, or flag non-compliance "
21+ "with CONTRIBUTING.md. Combine multiple concerns into a single comment where "
22+ "possible rather than posting several separate ones."
23+ ),
24+ "input_schema" : {
25+ "type" : "object" ,
26+ "properties" : {
27+ "body" : {"type" : "string" , "description" : "The comment text (markdown supported)." }
28+ },
29+ "required" : ["body" ],
30+ },
31+ },
32+ ]
33+
34+ # System prompt
35+
36+ SYSTEM_PROMPT = """You are a PR review assistant for an open-source GitHub repository.
37+ Given a newly opened PR, its author's contribution history, and the repository's CONTRIBUTING.md,
38+ you must check the following — in this order:
39+
40+ 1. FIRST CONTRIBUTION: If this is the author's first contribution to the repo, welcome them warmly.
41+ Acknowledge their effort and point them to any relevant getting-started resources in CONTRIBUTING.md.
42+
43+ 2. DESCRIPTION CLARITY: If the PR description is missing, too vague, or doesn't explain what
44+ the change does and why, ask for a clearer description.
45+
46+ 3. LINKED ISSUE: Check whether the description contains a linked issue using keywords like
47+ "Fixes #N", "Closes #N", "Resolves #N", or "Related to #N". If no issue is linked,
48+ ask the author to either link an existing issue or create a new one.
49+
50+ 4. CONTRIBUTING.md COMPLIANCE: Check whether the PR description follows the structure or
51+ requirements defined in CONTRIBUTING.md. If it doesn't comply, quote the relevant section
52+ and point out specifically what needs to change.
53+
54+ Important rules:
55+ - If multiple concerns apply, combine them into a single comment — never post more than one.
56+ - If everything looks good, stay silent. Do not post a comment just to say things look fine.
57+ - Be warm and constructive, never demanding. Remember this may be someone's first open-source contribution.
58+ - When referencing CONTRIBUTING.md requirements, be specific — quote or paraphrase the rule,
59+ don't just say "please read the contributing guide".
60+ - Most importantly, be as succint as possible."""
61+
62+ # GitHub helpers
63+
64+ def get_contributing_md () -> str :
65+ """Fetches CONTRIBUTING.md from the repo root, or returns a notice if absent."""
66+ try :
67+ contents = repo .get_contents ("CONTRIBUTING.md" )
68+ return contents .decoded_content .decode ("utf-8" )
69+ except Exception :
70+ return "(No CONTRIBUTING.md found in this repository.)"
71+
72+
73+ def is_first_contribution () -> bool :
74+ """Returns True if the author has no previously merged PRs in this repo."""
75+ first_contribution_list = ['FIRST_TIMER' , 'FIRST_TIME_CONTRIBUTOR' , 'NONE' ]
76+ if os .environ ["AUTHOR_ASSOCIATION" ] in first_contribution_list :
77+ return True
78+ return False
79+
80+
81+ def post_comment (body : str ) -> str :
82+ pr .create_issue_comment (body )
83+ return "Comment posted."
84+
85+ # Tool dispatch
86+
87+ def handle_tool_call (name : str , inputs : dict ) -> str :
88+ if name == "post_comment" :
89+ result = post_comment (inputs ["body" ])
90+ else :
91+ result = f"Unknown tool: { name } "
92+
93+ print (f"[tool] { name } : { result } " )
94+ return result
95+
96+ # Agentic loop
97+
98+ def build_initial_message () -> str :
99+ first_contribution = is_first_contribution ()
100+ contributing_md = get_contributing_md ()
101+
102+ return (
103+ f"Please review this newly opened PR:\n \n "
104+ f"Title: { os .environ ['PR_TITLE' ]} \n "
105+ f"Author: { author } ({ 'first-time contributor' if first_contribution else 'returning contributor' } )\n "
106+ f"Description:\n { os .environ .get ('PR_BODY' ) or '(no description provided)' } \n \n "
107+ f"---\n "
108+ f"CONTRIBUTING.md contents:\n \n "
109+ f"{ contributing_md } "
110+ )
111+
112+
113+ def run_pr_review_agent ():
114+ messages = [{"role" : "user" , "content" : build_initial_message ()}]
115+
116+ while True :
117+ response = client .messages .create (
118+ model = "claude-sonnet-4-20250514" ,
119+ max_tokens = 1024 ,
120+ system = SYSTEM_PROMPT ,
121+ tools = TOOLS ,
122+ messages = messages ,
123+ )
124+
125+ for block in response .content :
126+ if block .type == "text" and block .text :
127+ print (f"[agent] { block .text } " )
128+
129+ messages .append ({"role" : "assistant" , "content" : response .content })
130+
131+ if response .stop_reason == "end_turn" :
132+ break
133+
134+ tool_results = []
135+ for block in response .content :
136+ if block .type != "tool_use" :
137+ continue
138+ result = handle_tool_call (block .name , block .input )
139+ tool_results .append ({
140+ "type" : "tool_result" ,
141+ "tool_use_id" : block .id ,
142+ "content" : result ,
143+ })
144+
145+ messages .append ({"role" : "user" , "content" : tool_results })
146+
147+
148+ if __name__ == "__main__" :
149+ run_pr_review_agent ()
0 commit comments