ultralytics-actions 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ultralytics-actions might be problematic. Click here for more details.

Files changed (40) hide show
  1. {ultralytics_actions-0.1.0/ultralytics_actions.egg-info → ultralytics_actions-0.1.1}/PKG-INFO +3 -1
  2. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/README.md +2 -0
  3. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/__init__.py +1 -1
  4. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/dispatch_actions.py +3 -3
  5. ultralytics_actions-0.1.1/actions/first_interaction.py +218 -0
  6. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/review_pr.py +140 -64
  7. ultralytics_actions-0.1.1/actions/summarize_pr.py +128 -0
  8. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/summarize_release.py +13 -2
  9. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/update_markdown_code_blocks.py +0 -1
  10. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/utils/__init__.py +11 -1
  11. ultralytics_actions-0.1.1/actions/utils/github_utils.py +430 -0
  12. ultralytics_actions-0.1.1/actions/utils/openai_utils.py +222 -0
  13. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_first_interaction.py +1 -19
  14. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_summarize_pr.py +2 -30
  15. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1/ultralytics_actions.egg-info}/PKG-INFO +3 -1
  16. ultralytics_actions-0.1.0/actions/first_interaction.py +0 -379
  17. ultralytics_actions-0.1.0/actions/summarize_pr.py +0 -230
  18. ultralytics_actions-0.1.0/actions/utils/github_utils.py +0 -182
  19. ultralytics_actions-0.1.0/actions/utils/openai_utils.py +0 -96
  20. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/LICENSE +0 -0
  21. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/update_file_headers.py +0 -0
  22. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/utils/common_utils.py +0 -0
  23. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/utils/version_utils.py +0 -0
  24. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/pyproject.toml +0 -0
  25. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/setup.cfg +0 -0
  26. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_cli_commands.py +0 -0
  27. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_common_utils.py +0 -0
  28. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_dispatch_actions.py +0 -0
  29. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_file_headers.py +0 -0
  30. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_github_utils.py +0 -0
  31. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_init.py +0 -0
  32. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_openai_utils.py +0 -0
  33. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_summarize_release.py +0 -0
  34. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_update_markdown_codeblocks.py +0 -0
  35. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_urls.py +0 -0
  36. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/SOURCES.txt +0 -0
  37. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
  38. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/entry_points.txt +0 -0
  39. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/requires.txt +0 -0
  40. {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultralytics-actions
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Ultralytics Actions for GitHub automation and PR management.
5
5
  Author-email: Glenn Jocher <glenn.jocher@ultralytics.com>
6
6
  Maintainer-email: Ultralytics <hello@ultralytics.com>
@@ -64,6 +64,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
64
64
  - **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
65
65
  - **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
66
66
  - **PR Summary:** Concise Pull Request summaries are generated using [OpenAI](https://openai.com/) GPT-5, improving clarity and review efficiency.
67
+ - **PR Review:** AI-powered inline code reviews identify critical bugs, security issues, and code quality concerns with suggested fixes.
67
68
  - **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
68
69
 
69
70
  ## 🛠️ How It Works
@@ -74,6 +75,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
74
75
  - **Pull Requests:**
75
76
  - Ensures contributions meet formatting standards before merging.
76
77
  - Generates a concise summary of changes using GPT-5.
78
+ - Provides AI-powered inline code reviews with suggested fixes for critical issues.
77
79
  - Applies relevant labels using GPT-5 for intelligent categorization.
78
80
  - **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
79
81
 
@@ -26,6 +26,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
26
26
  - **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
27
27
  - **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
28
28
  - **PR Summary:** Concise Pull Request summaries are generated using [OpenAI](https://openai.com/) GPT-5, improving clarity and review efficiency.
29
+ - **PR Review:** AI-powered inline code reviews identify critical bugs, security issues, and code quality concerns with suggested fixes.
29
30
  - **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
30
31
 
31
32
  ## 🛠️ How It Works
@@ -36,6 +37,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
36
37
  - **Pull Requests:**
37
38
  - Ensures contributions meet formatting standards before merging.
38
39
  - Generates a concise summary of changes using GPT-5.
40
+ - Provides AI-powered inline code reviews with suggested fixes for critical issues.
39
41
  - Applies relevant labels using GPT-5 for intelligent categorization.
40
42
  - **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
41
43
 
@@ -23,4 +23,4 @@
23
23
  # ├── test_summarize_pr.py
24
24
  # └── ...
25
25
 
26
- __version__ = "0.1.0"
26
+ __version__ = "0.1.1"
@@ -44,7 +44,7 @@ def trigger_and_get_workflow_info(event, branch: str) -> list[dict]:
44
44
  run_number = None
45
45
 
46
46
  runs_response = event.get(
47
- f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1",
47
+ f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
48
48
  )
49
49
 
50
50
  if runs_response.status_code == 200:
@@ -57,10 +57,10 @@ def trigger_and_get_workflow_info(event, branch: str) -> list[dict]:
57
57
  return results
58
58
 
59
59
 
60
- def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str) -> bool:
60
+ def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str):
61
61
  """Updates the comment with workflow information."""
62
62
  if not triggered_actions:
63
- return False
63
+ return
64
64
 
65
65
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
66
66
  summary = (
@@ -0,0 +1,218 @@
1
+ # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+
8
+ from .utils import Action, filter_labels, get_completion, get_pr_open_response, remove_html_comments
9
+
10
+ SUMMARY_START = (
11
+ "## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
12
+ )
13
+ BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
14
+
15
+
16
+ def apply_and_check_labels(event, number, node_id, issue_type, username, labels, label_descriptions):
17
+ """Normalizes, applies labels, and handles Alert label if present."""
18
+ if not labels:
19
+ print("No relevant labels found or applied.")
20
+ return
21
+
22
+ available = {k.lower(): k for k in label_descriptions}
23
+ normalized = [available.get(label.lower(), label) for label in labels if label.lower() in available]
24
+
25
+ if normalized:
26
+ print(f"Applying labels: {normalized}")
27
+ event.apply_labels(number, node_id, normalized, issue_type)
28
+ if "Alert" in normalized and not event.is_org_member(username):
29
+ event.handle_alert(number, node_id, issue_type, username, block=BLOCK_USER)
30
+
31
+
32
+ def get_event_content(event) -> tuple[int, str, str, str, str, str, str]:
33
+ """Extracts key information from GitHub event data for issues, pull requests, or discussions."""
34
+ data = event.event_data
35
+ name = event.event_name
36
+ action = data["action"]
37
+ if name == "issues":
38
+ item = data["issue"]
39
+ issue_type = "issue"
40
+ elif name in ["pull_request", "pull_request_target"]:
41
+ pr_number = data["pull_request"]["number"]
42
+ item = event.get_repo_data(f"pulls/{pr_number}")
43
+ issue_type = "pull request"
44
+ elif name == "discussion":
45
+ item = data["discussion"]
46
+ issue_type = "discussion"
47
+ else:
48
+ raise ValueError(f"Unsupported event type: {name}")
49
+
50
+ number = item["number"]
51
+ node_id = item.get("node_id") or item.get("id")
52
+ title = item["title"]
53
+ body = remove_html_comments(item.get("body", ""))
54
+ username = item["user"]["login"]
55
+ return number, node_id, title, body, username, issue_type, action
56
+
57
+
58
+ def get_relevant_labels(
59
+ issue_type: str, title: str, body: str, available_labels: dict, current_labels: list
60
+ ) -> list[str]:
61
+ """Determines relevant labels for GitHub issues/discussions using OpenAI."""
62
+ filtered_labels = filter_labels(available_labels, current_labels, is_pr=(issue_type == "pull request"))
63
+ labels_str = "\n".join(f"- {name}: {description}" for name, description in filtered_labels.items())
64
+
65
+ prompt = f"""Select the top 1-3 most relevant labels for the following GitHub {issue_type}.
66
+
67
+ INSTRUCTIONS:
68
+ 1. Review the {issue_type} title and description.
69
+ 2. Consider the available labels and their descriptions.
70
+ 3. Choose 1-3 labels that best match the {issue_type} content.
71
+ 4. Only use the "Alert" label when you have high confidence that this is an inappropriate {issue_type}.
72
+ 5. Respond ONLY with the chosen label names (no descriptions), separated by commas.
73
+ 6. If no labels are relevant, respond with 'None'.
74
+ {'7. Only use the "bug" label if the user provides a clear description of the bug, their environment with relevant package versions and a minimum reproducible example.' if issue_type == "issue" else ""}
75
+
76
+ AVAILABLE LABELS:
77
+ {labels_str}
78
+
79
+ {issue_type.upper()} TITLE:
80
+ {title}
81
+
82
+ {issue_type.upper()} DESCRIPTION:
83
+ {body[:16000]}
84
+
85
+ YOUR RESPONSE (label names only):
86
+ """
87
+ messages = [
88
+ {
89
+ "role": "system",
90
+ "content": "You are an Ultralytics AI assistant that labels GitHub issues, PRs, and discussions.",
91
+ },
92
+ {"role": "user", "content": prompt},
93
+ ]
94
+ suggested_labels = get_completion(messages, temperature=1.0)
95
+ if "none" in suggested_labels.lower():
96
+ return []
97
+
98
+ available_labels_lower = {name.lower(): name for name in filtered_labels}
99
+ return [
100
+ available_labels_lower[label.lower().strip()]
101
+ for label in suggested_labels.split(",")
102
+ if label.lower().strip() in available_labels_lower
103
+ ]
104
+
105
+
106
+ def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
107
+ """Generates a custom LLM response for GitHub issues or discussions (NOT PRs - PRs use unified call)."""
108
+ issue_discussion_response = f"""
109
+ 👋 Hello @{username}, thank you for submitting a `{event.repository}` 🚀 {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information:
110
+
111
+ 1. For bug reports:
112
+ - A clear and concise description of the bug
113
+ - A minimum reproducible example [MRE](https://docs.ultralytics.com/help/minimum-reproducible-example/) that demonstrates the issue
114
+ - Your environment details (OS, Python version, package versions)
115
+ - Expected behavior vs. actual behavior
116
+ - Any error messages or logs related to the issue
117
+
118
+ 2. For feature requests:
119
+ - A clear and concise description of the proposed feature
120
+ - The problem this feature would solve
121
+ - Any alternative solutions you've considered
122
+
123
+ 3. For questions:
124
+ - Provide as much context as possible about your question
125
+ - Include any research you've already done on the topic
126
+ - Specify which parts of the [documentation](https://docs.ultralytics.com/), if any, you've already consulted
127
+
128
+ Please make sure you've searched existing {issue_type}s to avoid duplicates. If you need to add any additional information, please comment on this {issue_type}.
129
+
130
+ Thank you for your contribution to improving our project!
131
+ """
132
+
133
+ example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response
134
+ org_name, repo_name = event.repository.split("/")
135
+
136
+ prompt = f"""Generate a customized response to the new GitHub {issue_type} below:
137
+
138
+ CONTEXT:
139
+ - Repository: {repo_name}
140
+ - Organization: {org_name}
141
+ - Repository URL: https://github.com/{event.repository}
142
+ - User: {username}
143
+
144
+ INSTRUCTIONS:
145
+ - Do not answer the question or resolve the issue directly
146
+ - Adapt the example {issue_type} response below as appropriate, keeping all badges, links and references provided
147
+ - For bug reports, specifically request a minimum reproducible example (MRE) if not provided
148
+ - INCLUDE ALL LINKS AND INSTRUCTIONS IN THE EXAMPLE BELOW, customized as appropriate
149
+ - Mention to the user that this is an automated response and that an Ultralytics engineer will also assist soon
150
+ - Do not add a sign-off or valediction like "best regards" at the end of your response
151
+ - Do not add spaces between bullet points or numbered lists
152
+ - Only link to files or URLs in the example below, do not add external links
153
+ - Use a few emojis to enliven your response
154
+
155
+ EXAMPLE {issue_type.upper()} RESPONSE:
156
+ {example}
157
+
158
+ {issue_type.upper()} TITLE:
159
+ {title}
160
+
161
+ {issue_type.upper()} DESCRIPTION:
162
+ {body[:16000]}
163
+
164
+ YOUR {issue_type.upper()} RESPONSE:
165
+ """
166
+ messages = [
167
+ {
168
+ "role": "system",
169
+ "content": f"You are an Ultralytics AI assistant responding to GitHub {issue_type}s for {org_name}.",
170
+ },
171
+ {"role": "user", "content": prompt},
172
+ ]
173
+ return get_completion(messages)
174
+
175
+
176
+ def main(*args, **kwargs):
177
+ """Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
178
+ event = Action(*args, **kwargs)
179
+ number, node_id, title, body, username, issue_type, action = get_event_content(event)
180
+ available_labels = event.get_repo_data("labels")
181
+ label_descriptions = {label["name"]: label.get("description", "") for label in available_labels}
182
+
183
+ # Use unified PR open response for new PRs (summary + labels + first comment in 1 API call)
184
+ if issue_type == "pull request" and action == "opened":
185
+ print("Processing PR open with unified API call...")
186
+ diff = event.get_pr_diff()
187
+ response = get_pr_open_response(event.repository, diff, title, body, label_descriptions)
188
+
189
+ if summary := response.get("summary"):
190
+ print("Updating PR description with summary...")
191
+ event.update_pr_description(number, SUMMARY_START + summary)
192
+
193
+ if relevant_labels := response.get("labels", []):
194
+ apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
195
+
196
+ if first_comment := response.get("first_comment"):
197
+ print("Adding first interaction comment...")
198
+ time.sleep(1) # sleep to ensure label added first
199
+ event.add_comment(number, node_id, first_comment, issue_type)
200
+ return
201
+
202
+ # Handle issues and discussions (NOT PRs)
203
+ current_labels = (
204
+ []
205
+ if issue_type == "discussion"
206
+ else [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
207
+ )
208
+
209
+ relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels)
210
+ apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
211
+
212
+ if action in {"opened", "created"}:
213
+ custom_response = get_first_interaction_response(event, issue_type, title, body, username)
214
+ event.add_comment(number, node_id, custom_response, issue_type)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -5,10 +5,28 @@ from __future__ import annotations
5
5
  import json
6
6
  import re
7
7
 
8
- from .utils import GITHUB_API_URL, Action, get_completion
8
+ from .utils import GITHUB_API_URL, Action, get_completion, remove_html_comments
9
9
 
10
10
  REVIEW_MARKER = "🔍 PR Review"
11
11
  EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
12
+ SKIP_PATTERNS = [
13
+ r"\.lock$", # Lock files
14
+ r"-lock\.(json|yaml|yml)$",
15
+ r"\.min\.(js|css)$", # Minified
16
+ r"\.bundle\.(js|css)$",
17
+ r"(^|/)dist/", # Generated/vendored directories
18
+ r"(^|/)build/",
19
+ r"(^|/)vendor/",
20
+ r"(^|/)node_modules/",
21
+ r"\.pb\.py$", # Proto generated
22
+ r"_pb2\.py$",
23
+ r"_pb2_grpc\.py$",
24
+ r"^package-lock\.json$", # Package locks
25
+ r"^yarn\.lock$",
26
+ r"^poetry\.lock$",
27
+ r"^Pipfile\.lock$",
28
+ r"\.(svg|png|jpe?g|gif)$", # Images
29
+ ]
12
30
 
13
31
 
14
32
  def parse_diff_files(diff_text: str) -> dict:
@@ -37,27 +55,36 @@ def parse_diff_files(diff_text: str) -> dict:
37
55
 
38
56
  def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_description: str) -> dict:
39
57
  """Generate comprehensive PR review with line-specific comments and overall assessment."""
40
- if not diff_text or "**ERROR" in diff_text:
41
- return {"comments": [], "summary": f"Unable to review: {diff_text if '**ERROR' in diff_text else 'diff empty'}"}
58
+ if not diff_text:
59
+ return {"comments": [], "summary": "No changes detected in diff"}
42
60
 
43
61
  diff_files = parse_diff_files(diff_text)
44
62
  if not diff_files:
45
63
  return {"comments": [], "summary": "No files with changes detected in diff"}
46
64
 
65
+ # Filter out generated/vendored files
66
+ filtered_files = {
67
+ path: lines
68
+ for path, lines in diff_files.items()
69
+ if not any(re.search(pattern, path) for pattern in SKIP_PATTERNS)
70
+ }
71
+ skipped_count = len(diff_files) - len(filtered_files)
72
+ diff_files = filtered_files
73
+
74
+ if not diff_files:
75
+ return {"comments": [], "summary": f"All {skipped_count} changed files are generated/vendored (skipped review)"}
76
+
47
77
  file_list = list(diff_files.keys())
48
- limit = round(128000 * 3.3 * 0.4)
78
+ limit = round(128000 * 3.3 * 0.5) # 3.3 characters per token for half a 256k context window
49
79
  diff_truncated = len(diff_text) > limit
50
80
  lines_changed = sum(len(lines) for lines in diff_files.values())
51
81
 
52
- valid_lines_text = "\n".join(
53
- f" {file}: {sorted(list(lines.keys())[:20])}{' ...' if len(lines) > 20 else ''}"
54
- for file, lines in list(diff_files.items())[:10]
55
- ) + ("\n ..." if len(diff_files) > 10 else "")
56
-
57
- priority_guidance = (
58
- "Prioritize the most critical/high-impact issues only"
59
- if lines_changed >= 100
60
- else "Prioritize commenting on different files/sections"
82
+ comment_guidance = (
83
+ "Provide up to 1-3 comments only if critical issues exist"
84
+ if lines_changed < 50
85
+ else "Provide up to 3-5 comments only if high-impact issues exist"
86
+ if lines_changed < 200
87
+ else "Provide up to 5-10 comments only for the most critical issues"
61
88
  )
62
89
 
63
90
  content = (
@@ -65,16 +92,28 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
65
92
  "Focus on: Code quality, style, best practices, bugs, edge cases, error handling, performance, security, documentation, test coverage\n\n"
66
93
  "FORMATTING: Use backticks for code, file names, branch names, function names, variable names, packages\n\n"
67
94
  "CRITICAL RULES:\n"
68
- f"1. Generate inline comments with recommended changes for clear bugs/security/syntax issues (up to 10)\n"
69
- "2. Each comment MUST reference a UNIQUE line number(s)\n"
70
- "3. If a section has multiple issues, combine ALL issues into ONE comment for that section\n"
71
- "4. Never create separate comments for the same line number(s)\n"
72
- f"5. {priority_guidance}\n\n"
95
+ "1. Quality over quantity: Zero comments is fine for clean code - only flag truly important issues\n"
96
+ f"2. {comment_guidance} - these are maximums, not targets\n"
97
+ "3. CRITICAL: Do not post separate comments on adjacent/nearby lines (within 10 lines). Combine all related issues into ONE comment\n"
98
+ "4. When combining issues from multiple lines, use 'start_line' (first line) and 'line' (last line) to highlight the entire range\n"
99
+ "5. Each comment must reference separate areas - no overlapping line ranges\n"
100
+ "6. Prioritize: CRITICAL bugs/security > HIGH impact issues > code quality\n"
101
+ "7. Keep comments concise, friendly, and easy to understand - avoid jargon when possible\n"
102
+ "8. DO not comment on routine changes: adding imports, adding dependencies, updating version numbers, standard refactoring\n"
103
+ "9. Trust the developer - only flag issues with clear evidence of problems, not hypothetical concerns\n\n"
104
+ "SUMMARY GUIDELINES:\n"
105
+ "- Keep summary brief, clear, and actionable - avoid overly detailed explanations\n"
106
+ "- Highlight only the most important findings\n"
107
+ "- Do NOT include file names or line numbers in the summary - inline comments already show exact locations\n"
108
+ "- Focus on what needs to be fixed, not where\n\n"
73
109
  "CODE SUGGESTIONS:\n"
74
110
  "- ONLY provide 'suggestion' field when you have high certainty the code is problematic AND sufficient context for a confident fix\n"
75
111
  "- If uncertain about the correct fix, omit 'suggestion' field and explain the concern in 'message' only\n"
76
112
  "- Suggestions must be ready-to-merge code with NO comments, placeholders, or explanations\n"
77
- "- When providing suggestions, ensure they are complete, correct, and maintain existing indentation\n"
113
+ "- Suggestions replace ONLY the single line at 'line' - for multi-line fixes, describe the change in 'message' instead\n"
114
+ "- Do NOT provide 'start_line' when including a 'suggestion' - suggestions are always single-line only\n"
115
+ "- Suggestion content must match the exact indentation of the original line\n"
116
+ "- Never include triple backticks (```) in suggestions as they break markdown formatting\n"
78
117
  "- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
79
118
  "Return JSON: "
80
119
  '{"comments": [{"file": "exact/path", "line": N, "severity": "HIGH", "message": "...", "suggestion": "..."}], "summary": "..."}\n\n'
@@ -85,16 +124,20 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
85
124
  "- When '- old' then '+ new', new line keeps SAME line number\n"
86
125
  "- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
87
126
  f"- Files changed: {len(file_list)} ({', '.join(file_list[:10])}{'...' if len(file_list) > 10 else ''})\n"
88
- f"- Total changed lines: {lines_changed}\n"
89
- f"- Diff {'truncated' if diff_truncated else 'complete'}: {len(diff_text[:limit])} chars{f' of {len(diff_text)}' if diff_truncated else ''}\n\n"
90
- f"VALID LINE NUMBERS (use ONLY these):\n{valid_lines_text}"
127
+ f"- Lines changed: {lines_changed}\n"
91
128
  )
92
129
 
93
130
  messages = [
94
131
  {"role": "system", "content": content},
95
132
  {
96
133
  "role": "user",
97
- "content": f"Review PR '{repository}':\nTitle: {pr_title}\nDescription: {pr_description[:500]}\n\nDiff:\n{diff_text[:limit]}",
134
+ "content": (
135
+ f"Review this PR in https://github.com/{repository}:\n"
136
+ f"Title: {pr_title}\n"
137
+ f"Description: {remove_html_comments(pr_description or '')[:1000]}\n\n"
138
+ f"Diff:\n{diff_text[:limit]}\n\n"
139
+ "Now review this diff according to the rules above. Return JSON with comments array and summary."
140
+ ),
98
141
  },
99
142
  ]
100
143
 
@@ -111,23 +154,45 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
111
154
  unique_comments = {}
112
155
  for c in review_data.get("comments", []):
113
156
  file_path, line_num = c.get("file"), c.get("line", 0)
114
- if file_path in diff_files and line_num in diff_files[file_path]:
115
- key = f"{file_path}:{line_num}"
116
- if key not in unique_comments:
117
- unique_comments[key] = c
118
- else:
119
- print(f"⚠️ AI duplicate for {key}: {c.get('severity')} - {c.get('message')[:60]}...")
120
- else:
157
+ start_line = c.get("start_line")
158
+
159
+ # Validate line numbers are in diff
160
+ if file_path not in diff_files or line_num not in diff_files[file_path]:
121
161
  print(f"Filtered out {file_path}:{line_num} (available: {list(diff_files.get(file_path, {}))[:10]}...)")
162
+ continue
163
+
164
+ # Validate start_line if provided - drop start_line for suggestions (single-line only)
165
+ if start_line:
166
+ if c.get("suggestion"):
167
+ print(f"Dropping start_line for {file_path}:{line_num} - suggestions must be single-line only")
168
+ c.pop("start_line", None)
169
+ elif start_line >= line_num:
170
+ print(f"Invalid start_line {start_line} >= line {line_num} for {file_path}, dropping start_line")
171
+ c.pop("start_line", None)
172
+ elif start_line not in diff_files[file_path]:
173
+ print(f"start_line {start_line} not in diff for {file_path}, dropping start_line")
174
+ c.pop("start_line", None)
175
+
176
+ # Deduplicate by line number
177
+ key = f"{file_path}:{line_num}"
178
+ if key not in unique_comments:
179
+ unique_comments[key] = c
180
+ else:
181
+ print(f"⚠️ AI duplicate for {key}: {c.get('severity')} - {c.get('message')[:60]}...")
122
182
 
123
183
  review_data.update(
124
- {"comments": list(unique_comments.values()), "diff_files": diff_files, "diff_truncated": diff_truncated}
184
+ {
185
+ "comments": list(unique_comments.values()),
186
+ "diff_files": diff_files,
187
+ "diff_truncated": diff_truncated,
188
+ "skipped_files": skipped_count,
189
+ }
125
190
  )
126
191
  print(f"Valid comments after filtering: {len(review_data['comments'])}")
127
192
  return review_data
128
193
 
129
194
  except json.JSONDecodeError as e:
130
- print(f"JSON parsing failed: {e}\nAttempted: {json_str[:500] if 'json_str' in locals() else response[:500]}...")
195
+ print(f"JSON parsing failed... {e}")
131
196
  return {"comments": [], "summary": "Review generation encountered a JSON parsing error"}
132
197
  except Exception as e:
133
198
  print(f"Review generation failed: {e}")
@@ -164,39 +229,16 @@ def dismiss_previous_reviews(event: Action) -> int:
164
229
  return review_count + 1
165
230
 
166
231
 
167
- def post_review_comments(event: Action, review_data: dict) -> None:
168
- """Post inline review comments on specific lines of the PR."""
169
- if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
170
- return
171
-
172
- url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments"
173
- diff_files = review_data.get("diff_files", {})
174
-
175
- for comment in review_data.get("comments", [])[:50]:
176
- if not (file_path := comment.get("file")) or not (line := comment.get("line", 0)):
177
- continue
178
-
179
- severity = comment.get("severity", "SUGGESTION")
180
- body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {comment.get('message', '')}"
181
-
182
- if suggestion := comment.get("suggestion"):
183
- original_line = diff_files.get(file_path, {}).get(line, "")
184
- indent = len(original_line) - len(original_line.lstrip())
185
- indented = "\n".join(" " * indent + l if l.strip() else l for l in suggestion.split("\n"))
186
- body += f"\n\n**Suggested change:**\n```suggestion\n{indented}\n```"
187
-
188
- event.post(url, json={"body": body, "commit_id": commit_sha, "path": file_path, "line": line, "side": "RIGHT"})
189
-
190
-
191
232
  def post_review_summary(event: Action, review_data: dict, review_number: int) -> None:
192
- """Post overall review summary as a PR review."""
233
+ """Post overall review summary and inline comments as a single PR review."""
193
234
  if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
194
235
  return
195
236
 
196
237
  review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
197
238
  comments = review_data.get("comments", [])
198
- max_severity = max((c.get("severity") for c in comments), default="SUGGESTION") if comments else None
199
- event_type = "APPROVE" if not comments or max_severity in ["LOW", "SUGGESTION"] else "REQUEST_CHANGES"
239
+ event_type = (
240
+ "REQUEST_CHANGES" if any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments) else "APPROVE"
241
+ )
200
242
 
201
243
  body = (
202
244
  f"## {review_title}\n\n"
@@ -205,15 +247,51 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
205
247
  )
206
248
 
207
249
  if comments:
208
- shown = min(len(comments), 50)
209
- body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (50 shown, more available)' if len(comments) > 50 else ''}\n"
250
+ shown = min(len(comments), 10)
251
+ body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (10 shown, more available)' if len(comments) > 10 else ''}\n"
210
252
 
211
253
  if review_data.get("diff_truncated"):
212
254
  body += "\n⚠️ **Large PR**: Review focused on critical issues. Some details may not be covered.\n"
213
255
 
256
+ if skipped := review_data.get("skipped_files"):
257
+ body += f"\n📋 **Skipped {skipped} file{'s' if skipped != 1 else ''}** (lock files, minified, images, etc.)\n"
258
+
259
+ # Build inline comments for the review
260
+ review_comments = []
261
+ for comment in comments[:10]:
262
+ if not (file_path := comment.get("file")) or not (line := comment.get("line", 0)):
263
+ continue
264
+
265
+ severity = comment.get("severity", "SUGGESTION")
266
+ comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {comment.get('message', '')}"
267
+
268
+ if suggestion := comment.get("suggestion"):
269
+ if "```" not in suggestion:
270
+ # Extract original line indentation and apply to suggestion
271
+ if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(line):
272
+ indent = len(original_line) - len(original_line.lstrip())
273
+ suggestion = " " * indent + suggestion.strip()
274
+ comment_body += f"\n\n**Suggested change:**\n```suggestion\n{suggestion}\n```"
275
+
276
+ # Build comment with optional start_line for multi-line context
277
+ review_comment = {"path": file_path, "line": line, "body": comment_body, "side": "RIGHT"}
278
+ if start_line := comment.get("start_line"):
279
+ if start_line < line:
280
+ review_comment["start_line"] = start_line
281
+ review_comment["start_side"] = "RIGHT"
282
+ print(f"Multi-line comment: {file_path}:{start_line}-{line}")
283
+
284
+ review_comments.append(review_comment)
285
+
286
+ # Submit review with inline comments
287
+ payload = {"commit_id": commit_sha, "body": body, "event": event_type}
288
+ if review_comments:
289
+ payload["comments"] = review_comments
290
+ print(f"Posting review with {len(review_comments)} inline comments")
291
+
214
292
  event.post(
215
293
  f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews",
216
- json={"commit_id": commit_sha, "body": body, "event": event_type},
294
+ json=payload,
217
295
  )
218
296
 
219
297
 
@@ -238,8 +316,6 @@ def main(*args, **kwargs):
238
316
  review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), event.pr.get("body", ""))
239
317
 
240
318
  post_review_summary(event, review, review_number)
241
- print(f"Posting {len(review.get('comments', []))} inline comments")
242
- post_review_comments(event, review)
243
319
  print("PR review completed")
244
320
 
245
321