evizi-kit 1.0.0

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.
Files changed (201) hide show
  1. package/README.md +506 -0
  2. package/kits/agent/.agent/skills/claude-code-subagent-creator/SKILL.md +292 -0
  3. package/kits/agent/.agent/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
  4. package/kits/agent/.agent/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
  5. package/kits/agent/.agent/skills/skill-creator/LICENSE.txt +202 -0
  6. package/kits/agent/.agent/skills/skill-creator/SKILL.md +485 -0
  7. package/kits/agent/.agent/skills/skill-creator/agents/analyzer.md +274 -0
  8. package/kits/agent/.agent/skills/skill-creator/agents/comparator.md +202 -0
  9. package/kits/agent/.agent/skills/skill-creator/agents/grader.md +223 -0
  10. package/kits/agent/.agent/skills/skill-creator/assets/eval_review.html +146 -0
  11. package/kits/agent/.agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  12. package/kits/agent/.agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  13. package/kits/agent/.agent/skills/skill-creator/references/schemas.md +430 -0
  14. package/kits/agent/.agent/skills/skill-creator/scripts/__init__.py +0 -0
  15. package/kits/agent/.agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  16. package/kits/agent/.agent/skills/skill-creator/scripts/generate_report.py +326 -0
  17. package/kits/agent/.agent/skills/skill-creator/scripts/improve_description.py +247 -0
  18. package/kits/agent/.agent/skills/skill-creator/scripts/package_skill.py +136 -0
  19. package/kits/agent/.agent/skills/skill-creator/scripts/quick_validate.py +103 -0
  20. package/kits/agent/.agent/skills/skill-creator/scripts/run_eval.py +310 -0
  21. package/kits/agent/.agent/skills/skill-creator/scripts/run_loop.py +328 -0
  22. package/kits/agent/.agent/skills/skill-creator/scripts/utils.py +47 -0
  23. package/kits/agent/manifest.json +10 -0
  24. package/kits/claude/.claude/agents/code-pusher.md +46 -0
  25. package/kits/claude/.claude/agents/feature-document-updater.md +37 -0
  26. package/kits/claude/.claude/agents/self-reviewer.md +32 -0
  27. package/kits/claude/.claude/agents/web-auto-agentic-workflow-initializer.md +42 -0
  28. package/kits/claude/.claude/agents/web-auto-assisted-fix-and-runner.md +36 -0
  29. package/kits/claude/.claude/agents/web-auto-chrome-devtools-selector-extractor.md +36 -0
  30. package/kits/claude/.claude/agents/web-auto-coder.md +33 -0
  31. package/kits/claude/.claude/agents/web-auto-fe-selector-extractor.md +31 -0
  32. package/kits/claude/.claude/agents/web-auto-fix-and-runner.md +35 -0
  33. package/kits/claude/.claude/agents/web-auto-lessons-learned-extractor.md +34 -0
  34. package/kits/claude/.claude/agents/web-auto-playwright-mcp-selector-extractor.md +37 -0
  35. package/kits/claude/.claude/agents/web-auto-source-instructions-updater.md +43 -0
  36. package/kits/claude/.claude/agents/web-auto-test-cases-generator.md +29 -0
  37. package/kits/claude/.claude/agents/web-auto-ticket-designer.md +35 -0
  38. package/kits/claude/.claude/agents/web-auto-ticket-playbook-planner.md +36 -0
  39. package/kits/claude/.claude/agents/web-auto.md +382 -0
  40. package/kits/claude/.claude/skills/claude-code-subagent-creator/SKILL.md +292 -0
  41. package/kits/claude/.claude/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
  42. package/kits/claude/.claude/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
  43. package/kits/claude/.claude/skills/skill-creator/LICENSE.txt +202 -0
  44. package/kits/claude/.claude/skills/skill-creator/SKILL.md +485 -0
  45. package/kits/claude/.claude/skills/skill-creator/agents/analyzer.md +274 -0
  46. package/kits/claude/.claude/skills/skill-creator/agents/comparator.md +202 -0
  47. package/kits/claude/.claude/skills/skill-creator/agents/grader.md +223 -0
  48. package/kits/claude/.claude/skills/skill-creator/assets/eval_review.html +146 -0
  49. package/kits/claude/.claude/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  50. package/kits/claude/.claude/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  51. package/kits/claude/.claude/skills/skill-creator/references/schemas.md +430 -0
  52. package/kits/claude/.claude/skills/skill-creator/scripts/__init__.py +0 -0
  53. package/kits/claude/.claude/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  54. package/kits/claude/.claude/skills/skill-creator/scripts/generate_report.py +326 -0
  55. package/kits/claude/.claude/skills/skill-creator/scripts/improve_description.py +247 -0
  56. package/kits/claude/.claude/skills/skill-creator/scripts/package_skill.py +136 -0
  57. package/kits/claude/.claude/skills/skill-creator/scripts/quick_validate.py +103 -0
  58. package/kits/claude/.claude/skills/skill-creator/scripts/run_eval.py +310 -0
  59. package/kits/claude/.claude/skills/skill-creator/scripts/run_loop.py +328 -0
  60. package/kits/claude/.claude/skills/skill-creator/scripts/utils.py +47 -0
  61. package/kits/claude/manifest.json +10 -0
  62. package/kits/cursor/.cursor/agents/code-pusher.agent.md +43 -0
  63. package/kits/cursor/.cursor/agents/feature-document-updater.agent.md +34 -0
  64. package/kits/cursor/.cursor/agents/self-reviewer.agent.md +29 -0
  65. package/kits/cursor/.cursor/agents/web-auto-agentic-workflow-initializer.agent.md +37 -0
  66. package/kits/cursor/.cursor/agents/web-auto-assisted-fix-and-runner.agent.md +33 -0
  67. package/kits/cursor/.cursor/agents/web-auto-chrome-devtools-selector-extractor.agent.md +31 -0
  68. package/kits/cursor/.cursor/agents/web-auto-coder.agent.md +30 -0
  69. package/kits/cursor/.cursor/agents/web-auto-fe-selector-extractor.agent.md +28 -0
  70. package/kits/cursor/.cursor/agents/web-auto-fix-and-runner.agent.md +32 -0
  71. package/kits/cursor/.cursor/agents/web-auto-lessons-learned-extractor.agent.md +31 -0
  72. package/kits/cursor/.cursor/agents/web-auto-playwright-mcp-selector-extractor.agent.md +32 -0
  73. package/kits/cursor/.cursor/agents/web-auto-source-instructions-updater.agent.md +40 -0
  74. package/kits/cursor/.cursor/agents/web-auto-test-cases-generator.agent.md +26 -0
  75. package/kits/cursor/.cursor/agents/web-auto-ticket-designer.agent.md +32 -0
  76. package/kits/cursor/.cursor/agents/web-auto-ticket-playbook-planner.agent.md +33 -0
  77. package/kits/cursor/.cursor/agents/web-auto.agent.md +379 -0
  78. package/kits/cursor/.cursor/skills/claude-code-subagent-creator/SKILL.md +292 -0
  79. package/kits/cursor/.cursor/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
  80. package/kits/cursor/.cursor/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
  81. package/kits/cursor/.cursor/skills/skill-creator/LICENSE.txt +202 -0
  82. package/kits/cursor/.cursor/skills/skill-creator/SKILL.md +485 -0
  83. package/kits/cursor/.cursor/skills/skill-creator/agents/analyzer.md +274 -0
  84. package/kits/cursor/.cursor/skills/skill-creator/agents/comparator.md +202 -0
  85. package/kits/cursor/.cursor/skills/skill-creator/agents/grader.md +223 -0
  86. package/kits/cursor/.cursor/skills/skill-creator/assets/eval_review.html +146 -0
  87. package/kits/cursor/.cursor/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  88. package/kits/cursor/.cursor/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  89. package/kits/cursor/.cursor/skills/skill-creator/references/schemas.md +430 -0
  90. package/kits/cursor/.cursor/skills/skill-creator/scripts/__init__.py +0 -0
  91. package/kits/cursor/.cursor/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  92. package/kits/cursor/.cursor/skills/skill-creator/scripts/generate_report.py +326 -0
  93. package/kits/cursor/.cursor/skills/skill-creator/scripts/improve_description.py +247 -0
  94. package/kits/cursor/.cursor/skills/skill-creator/scripts/package_skill.py +136 -0
  95. package/kits/cursor/.cursor/skills/skill-creator/scripts/quick_validate.py +103 -0
  96. package/kits/cursor/.cursor/skills/skill-creator/scripts/run_eval.py +310 -0
  97. package/kits/cursor/.cursor/skills/skill-creator/scripts/run_loop.py +328 -0
  98. package/kits/cursor/.cursor/skills/skill-creator/scripts/utils.py +47 -0
  99. package/kits/cursor/manifest.json +10 -0
  100. package/kits/github/.github/agents/code-pusher.agent.md +45 -0
  101. package/kits/github/.github/agents/feature-document-updater.agent.md +36 -0
  102. package/kits/github/.github/agents/self-reviewer.agent.md +31 -0
  103. package/kits/github/.github/agents/web-auto-agentic-workflow-initializer.agent.md +39 -0
  104. package/kits/github/.github/agents/web-auto-assisted-fix-and-runner.agent.md +35 -0
  105. package/kits/github/.github/agents/web-auto-chrome-devtools-selector-extractor.agent.md +33 -0
  106. package/kits/github/.github/agents/web-auto-coder.agent.md +32 -0
  107. package/kits/github/.github/agents/web-auto-fe-selector-extractor.agent.md +30 -0
  108. package/kits/github/.github/agents/web-auto-fix-and-runner.agent.md +34 -0
  109. package/kits/github/.github/agents/web-auto-lessons-learned-extractor.agent.md +33 -0
  110. package/kits/github/.github/agents/web-auto-playwright-mcp-selector-extractor.agent.md +34 -0
  111. package/kits/github/.github/agents/web-auto-source-instructions-updater.agent.md +42 -0
  112. package/kits/github/.github/agents/web-auto-test-cases-generator.agent.md +28 -0
  113. package/kits/github/.github/agents/web-auto-ticket-designer.agent.md +34 -0
  114. package/kits/github/.github/agents/web-auto-ticket-playbook-creator.agent.md +35 -0
  115. package/kits/github/.github/agents/web-auto.agent.md +382 -0
  116. package/kits/github/.github/skills/claude-code-subagent-creator/SKILL.md +310 -0
  117. package/kits/github/.github/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
  118. package/kits/github/.github/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +37 -0
  119. package/kits/github/.github/skills/skill-creator/LICENSE.txt +202 -0
  120. package/kits/github/.github/skills/skill-creator/SKILL.md +485 -0
  121. package/kits/github/.github/skills/skill-creator/agents/analyzer.md +274 -0
  122. package/kits/github/.github/skills/skill-creator/agents/comparator.md +202 -0
  123. package/kits/github/.github/skills/skill-creator/agents/grader.md +223 -0
  124. package/kits/github/.github/skills/skill-creator/assets/eval_review.html +146 -0
  125. package/kits/github/.github/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  126. package/kits/github/.github/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  127. package/kits/github/.github/skills/skill-creator/references/schemas.md +430 -0
  128. package/kits/github/.github/skills/skill-creator/scripts/__init__.py +0 -0
  129. package/kits/github/.github/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  130. package/kits/github/.github/skills/skill-creator/scripts/generate_report.py +326 -0
  131. package/kits/github/.github/skills/skill-creator/scripts/improve_description.py +247 -0
  132. package/kits/github/.github/skills/skill-creator/scripts/package_skill.py +136 -0
  133. package/kits/github/.github/skills/skill-creator/scripts/quick_validate.py +103 -0
  134. package/kits/github/.github/skills/skill-creator/scripts/run_eval.py +310 -0
  135. package/kits/github/.github/skills/skill-creator/scripts/run_loop.py +328 -0
  136. package/kits/github/.github/skills/skill-creator/scripts/utils.py +47 -0
  137. package/kits/github/manifest.json +10 -0
  138. package/kits/shared/docs/ai-code-review.md +440 -0
  139. package/kits/shared/docs/increase-unit-test-coverage.md +77 -0
  140. package/kits/shared/docs/pr-review-agent.md +501 -0
  141. package/kits/shared/docs/self-review-agent.md +246 -0
  142. package/kits/shared/docs/web-auto-agentic-workflow.md +506 -0
  143. package/kits/shared/manifest.json +11 -0
  144. package/kits/shared/skills/fix-automation-tests/SKILL.md +280 -0
  145. package/kits/shared/skills/fix-automation-tests/scripts/fetch_pr_changes.py +300 -0
  146. package/kits/shared/skills/fix-automation-tests/templates/impact-report.template.md +42 -0
  147. package/kits/shared/skills/increase-unit-test-coverage/SKILL.md +117 -0
  148. package/kits/shared/skills/increase-unit-test-coverage/scripts/filter_low_coverage.py +447 -0
  149. package/kits/shared/skills/pr-review/SKILL.md +200 -0
  150. package/kits/shared/skills/pr-review/references/automation.md +62 -0
  151. package/kits/shared/skills/pr-review/references/backend.md +95 -0
  152. package/kits/shared/skills/pr-review/references/frontend.md +103 -0
  153. package/kits/shared/skills/pr-review/references/mobile.md +108 -0
  154. package/kits/shared/skills/pr-review/references/output-schema.md +130 -0
  155. package/kits/shared/skills/pr-review/scripts/post-review.py +1395 -0
  156. package/kits/shared/skills/push-code/SKILL.md +176 -0
  157. package/kits/shared/skills/self-review/SKILL.md +234 -0
  158. package/kits/shared/skills/self-review/evals/evals.json +23 -0
  159. package/kits/shared/skills/self-review/references/automation.md +62 -0
  160. package/kits/shared/skills/self-review/references/backend.md +95 -0
  161. package/kits/shared/skills/self-review/references/frontend.md +103 -0
  162. package/kits/shared/skills/self-review/references/mobile.md +108 -0
  163. package/kits/shared/skills/self-review/templates/issues.template.md +72 -0
  164. package/kits/shared/skills/update-feature-document/SKILL.md +156 -0
  165. package/kits/shared/skills/update-feature-document/templates/delta.template.yaml +58 -0
  166. package/kits/shared/skills/update-feature-document/templates/feature.template.md +25 -0
  167. package/kits/shared/skills/web-auto-assisted-fix-and-run/SKILL.md +130 -0
  168. package/kits/shared/skills/web-auto-assisted-fix-and-run/references/resolve-api-error.md +108 -0
  169. package/kits/shared/skills/web-auto-assisted-fix-and-run/references/resolve-selector.md +60 -0
  170. package/kits/shared/skills/web-auto-assisted-fix-and-run/templates/issues-resolution-report-append.template.md +54 -0
  171. package/kits/shared/skills/web-auto-chrome-devtools-mcp-extract-selectors/SKILL.md +284 -0
  172. package/kits/shared/skills/web-auto-coding/SKILL.md +152 -0
  173. package/kits/shared/skills/web-auto-extract-lessons-learned/SKILL.md +168 -0
  174. package/kits/shared/skills/web-auto-extract-lessons-learned/templates/lessons-learned.template.md +115 -0
  175. package/kits/shared/skills/web-auto-fe-extract-selectors/SKILL.md +282 -0
  176. package/kits/shared/skills/web-auto-fe-extract-selectors/evals/evals.json +23 -0
  177. package/kits/shared/skills/web-auto-fix-and-run-test/SKILL.md +183 -0
  178. package/kits/shared/skills/web-auto-fix-and-run-test/templates/issues-resolution-report.template.md +77 -0
  179. package/kits/shared/skills/web-auto-generate-best-practices/SKILL.md +123 -0
  180. package/kits/shared/skills/web-auto-generate-instructions/SKILL.md +200 -0
  181. package/kits/shared/skills/web-auto-generate-instructions/evals/evals.json +23 -0
  182. package/kits/shared/skills/web-auto-generate-instructions/references/analysis-guide.md +145 -0
  183. package/kits/shared/skills/web-auto-generate-instructions/templates/web-auto-instructions.template.md +184 -0
  184. package/kits/shared/skills/web-auto-generate-project-blueprint/SKILL.md +181 -0
  185. package/kits/shared/skills/web-auto-generate-project-blueprint/evals/evals.json +57 -0
  186. package/kits/shared/skills/web-auto-generate-project-blueprint/templates/web-auto-project-blueprint.template.md +161 -0
  187. package/kits/shared/skills/web-auto-playwright-mcp-extract-selectors/SKILL.md +293 -0
  188. package/kits/shared/skills/web-auto-test-cases/SKILL.md +138 -0
  189. package/kits/shared/skills/web-auto-test-cases/evals/evals.json +129 -0
  190. package/kits/shared/skills/web-auto-test-cases/templates/test-cases.template.md +53 -0
  191. package/kits/shared/skills/web-auto-ticket-design/SKILL.md +199 -0
  192. package/kits/shared/skills/web-auto-ticket-design/templates/ticket-design.template.md +138 -0
  193. package/kits/shared/skills/web-auto-ticket-playbook/SKILL.md +218 -0
  194. package/kits/shared/skills/web-auto-ticket-playbook/evals/evals.json +23 -0
  195. package/kits/shared/skills/web-auto-ticket-playbook/templates/ticket-playbook.template.md +148 -0
  196. package/kits/shared/skills/web-auto-update-source-instructions/SKILL.md +156 -0
  197. package/kits/shared/skills/web-auto-update-source-instructions/evals/evals.json +22 -0
  198. package/kits/shared/skills/workspace-ai-nav-creator/SKILL.md +168 -0
  199. package/kits/shared/skills/workspace-ai-nav-creator/templates/agents-md.template.md +112 -0
  200. package/kits/shared/skills/workspace-ai-nav-creator/templates/claude-md.template.md +86 -0
  201. package/package.json +16 -0
@@ -0,0 +1,1395 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PR Review Comment Dispatcher
4
+
5
+ Unified dispatcher script that handles everything after generating review.json:
6
+ - Reads review.json from project root
7
+ - Reads platform from project.config.json (searches .documents-design/)\n- Resolves repo config from the repos map (e.g., repos.webAuto.{platform})
8
+ - Transforms standard review output to platform-specific API format
9
+ - Posts inline comments directly to the appropriate Git platform
10
+ - Sends Google Chat notification if enableNotification is true in config
11
+
12
+ Supported Platforms:
13
+ - GitHub (via GitHub API)
14
+ - GitLab (via GitLab API)
15
+ - Gitea (via Gitea API)
16
+ - Bitbucket (via Bitbucket API)
17
+
18
+ Configuration Options:
19
+ - enableCommentPosting (boolean, default: true): Enable/disable posting inline comments
20
+ - enableNotification (boolean, default: false): Enable/disable Google Chat notifications
21
+
22
+ Operation Modes (controlled by config + token availability):
23
+
24
+ 1. Full Mode: Posts inline comments + sends notification
25
+ - Config: enableCommentPosting=true, enableNotification=true
26
+ - Requires: Platform token configured
27
+
28
+ 2. Comment-Only Mode: Posts inline comments, no notification
29
+ - Config: enableCommentPosting=true, enableNotification=false
30
+ - Requires: Platform token configured
31
+
32
+ 3. Notification-Only Mode: Skips inline comments, only sends notification
33
+ - Config: enableCommentPosting=false, enableNotification=true
34
+ - OR: enableCommentPosting=true (but no token), enableNotification=true
35
+ - No platform token needed
36
+ - Useful for testing or when you only want notifications
37
+
38
+ All platform-specific logic is integrated into this single dispatcher script.
39
+
40
+ Usage:
41
+ python {PR_REVIEW_SKILL_PATH}/scripts/post-review.py # Post review comments
42
+ python3 {PR_REVIEW_SKILL_PATH}/scripts/post-review.py # Alternative Python 3 command
43
+ python {PR_REVIEW_SKILL_PATH}/scripts/post-review.py --dry-run # Transform only, do not post
44
+
45
+ Where {PR_REVIEW_SKILL_PATH} is the absolute path to pr-review skill folder:
46
+ - Cursor IDE: .cursor/skills/pr-review
47
+ - GitHub Copilot: .github/skills/pr-review
48
+ - Other agents: .agent/skills/pr-review (or configured path)
49
+
50
+ The script reads review.json from the project root directory.
51
+ """
52
+
53
+ import sys
54
+ import os
55
+ import json
56
+ import ssl
57
+ import urllib.request
58
+ import urllib.error
59
+ import urllib.parse
60
+ import base64
61
+ from datetime import datetime
62
+
63
+ # Default file paths
64
+ DEFAULT_REVIEW_FILE = 'review.json'
65
+ CONFIG_SEARCH_PATHS = [
66
+ '.documents-design/project.config.json',
67
+ ]
68
+
69
+ def load_json_file(file_path: str) -> dict:
70
+ """Load and parse a JSON file."""
71
+ with open(file_path, 'r', encoding='utf-8') as f:
72
+ return json.load(f)
73
+
74
+ def create_no_proxy_opener():
75
+ """Create a URL opener that bypasses proxy settings with SSL verification disabled."""
76
+ ctx = ssl.create_default_context()
77
+ ctx.check_hostname = False
78
+ ctx.verify_mode = ssl.CERT_NONE
79
+ return urllib.request.build_opener(
80
+ urllib.request.ProxyHandler({}),
81
+ urllib.request.HTTPSHandler(context=ctx)
82
+ )
83
+
84
+ def get_config() -> tuple[dict, str]:
85
+ """Load the project configuration. Returns (config, path)."""
86
+ for config_path in CONFIG_SEARCH_PATHS:
87
+ if os.path.isfile(config_path):
88
+ return load_json_file(config_path), config_path
89
+ raise FileNotFoundError(f"Configuration file not found. Searched: {CONFIG_SEARCH_PATHS}")
90
+
91
+
92
+ def resolve_repo_config(config: dict, repo_name: str) -> dict:
93
+ """Resolve repo-specific config from the repos map.
94
+
95
+ Returns a dict with platform-level fields (apiUrl) merged with
96
+ repo-level fields (owner, repo, projectId, workspace, repoSlug).
97
+ """
98
+ platform = config.get('platform', 'github')
99
+ platform_config = config.get('platformConfig', {}).get(platform, {})
100
+ repo_config = config.get('repos', {}).get(repo_name, {}).get(platform, {})
101
+ return {**platform_config, **repo_config}
102
+
103
+ def get_platform(config: dict) -> str:
104
+ """Extract platform from configuration."""
105
+ return config.get('platform', 'github')
106
+
107
+ def is_notification_enabled(config: dict) -> bool:
108
+ """Check if notifications are enabled."""
109
+ return config.get('enableNotification', False)
110
+
111
+ def is_comment_posting_enabled(config: dict) -> bool:
112
+ """Check if comment posting is enabled. Defaults to True for backward compatibility."""
113
+ return config.get('enableCommentPosting', True)
114
+
115
+ def has_platform_token(platform: str) -> bool:
116
+ """Check if the required token for the platform is available."""
117
+ token_map = {
118
+ 'github': 'GITHUB_TOKEN',
119
+ 'gitlab': 'GITLAB_TOKEN',
120
+ 'gitea': 'GITEA_TOKEN',
121
+ 'bitbucket': 'BITBUCKET_TOKEN'
122
+ }
123
+ env_var = token_map.get(platform)
124
+ return bool(env_var and os.environ.get(env_var))
125
+
126
+
127
+ # =============================================================================
128
+ # GitHub Platform Implementation
129
+ # =============================================================================
130
+
131
+ def transform_to_github_format(standard_review: dict) -> dict:
132
+ """Transform standard review output to GitHub Reviews API format."""
133
+ status_map = {
134
+ "APPROVE": "APPROVE",
135
+ "REQUEST_CHANGES": "REQUEST_CHANGES",
136
+ "COMMENT": "COMMENT"
137
+ }
138
+ event = status_map.get(standard_review.get('status', 'COMMENT'), 'COMMENT')
139
+
140
+ # Build summary body with statistics
141
+ stats = standard_review.get('statistics', {})
142
+ summary_parts = [standard_review.get('summary', 'Automated PR Review')]
143
+
144
+ if stats:
145
+ summary_parts.append(f"\n\n📊 **Review Statistics**")
146
+ summary_parts.append(f"- Files reviewed: {stats.get('filesReviewed', 0)}")
147
+ summary_parts.append(f"- Critical issues: {stats.get('criticalCount', 0)}")
148
+ summary_parts.append(f"- Warnings: {stats.get('warningCount', 0)}")
149
+ summary_parts.append(f"- Suggestions: {stats.get('suggestionCount', 0)}")
150
+
151
+ # Transform comments
152
+ github_comments = []
153
+ for comment in standard_review.get('comments', []):
154
+ body_parts = []
155
+
156
+ category = comment.get('category', 'Review')
157
+ title = comment.get('title', 'Issue')
158
+ severity = comment.get('severity', 'warning')
159
+
160
+ severity_emoji = {
161
+ 'critical': '🔴',
162
+ 'warning': '🟡',
163
+ 'suggestion': '💡'
164
+ }.get(severity, '💬')
165
+
166
+ body_parts.append(f"{severity_emoji} **[{category}] {title}**")
167
+ body_parts.append("")
168
+ body_parts.append(comment.get('body', ''))
169
+
170
+ if comment.get('recommendation'):
171
+ body_parts.append("")
172
+ body_parts.append(f"**Recommendation**: {comment.get('recommendation')}")
173
+
174
+ if comment.get('codeSnippet'):
175
+ body_parts.append("")
176
+ body_parts.append("```")
177
+ body_parts.append(comment.get('codeSnippet'))
178
+ body_parts.append("```")
179
+
180
+ github_comment = {
181
+ "path": comment.get('path'),
182
+ "line": comment.get('line'),
183
+ "side": "RIGHT",
184
+ "body": "\n".join(body_parts)
185
+ }
186
+ github_comments.append(github_comment)
187
+
188
+ return {
189
+ "event": event,
190
+ "body": "\n".join(summary_parts),
191
+ "comments": github_comments
192
+ }
193
+
194
+
195
+ def post_github_review(standard_review: dict, config: dict, dry_run: bool = False) -> dict:
196
+ """Post review to GitHub API."""
197
+ if dry_run:
198
+ github_payload = transform_to_github_format(standard_review)
199
+ return {"success": True, "dryRun": True, "platform": "github", "payload": github_payload}
200
+
201
+ repo_name = config.get('_repoName', 'webAuto')
202
+ resolved = resolve_repo_config(config, repo_name)
203
+ api_url = resolved.get('apiUrl', 'https://api.github.com')
204
+ owner = resolved.get('owner')
205
+ repo = resolved.get('repo')
206
+
207
+ if not owner or not repo:
208
+ return {
209
+ "success": False,
210
+ "platform": "github",
211
+ "error": f"Owner and repo must be specified in repos.{repo_name}.github"
212
+ }
213
+
214
+ token = os.environ.get('GITHUB_TOKEN')
215
+ if not token:
216
+ return {
217
+ "success": False,
218
+ "platform": "github",
219
+ "error": "GitHub token not found. Set GITHUB_TOKEN environment variable.",
220
+ "skipped": True
221
+ }
222
+
223
+ pr_number = standard_review.get('prNumber')
224
+ github_payload = transform_to_github_format(standard_review)
225
+ url = f"{api_url}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
226
+
227
+ headers = {
228
+ 'Content-Type': 'application/json',
229
+ 'Authorization': f'Bearer {token}',
230
+ 'Accept': 'application/vnd.github+json',
231
+ 'X-GitHub-Api-Version': '2022-11-28'
232
+ }
233
+
234
+ req = urllib.request.Request(
235
+ url,
236
+ data=json.dumps(github_payload).encode('utf-8'),
237
+ headers=headers,
238
+ method='POST'
239
+ )
240
+
241
+ try:
242
+ opener = create_no_proxy_opener()
243
+
244
+ with opener.open(req) as response:
245
+ result = json.loads(response.read().decode('utf-8'))
246
+ return {
247
+ "success": True,
248
+ "platform": "github",
249
+ "reviewId": result.get('id'),
250
+ "htmlUrl": result.get('html_url'),
251
+ "state": result.get('state'),
252
+ "commentsPosted": len(github_payload.get('comments', []))
253
+ }
254
+ except urllib.error.HTTPError as e:
255
+ error_body = e.read().decode('utf-8') if e.fp else str(e)
256
+ return {
257
+ "success": False,
258
+ "platform": "github",
259
+ "error": f"HTTP {e.code}: {e.reason}",
260
+ "details": error_body
261
+ }
262
+ except urllib.error.URLError as e:
263
+ return {
264
+ "success": False,
265
+ "platform": "github",
266
+ "error": f"Connection error: {e.reason}"
267
+ }
268
+
269
+
270
+ # =============================================================================
271
+ # GitLab Platform Implementation
272
+ # =============================================================================
273
+
274
+ def transform_comment_body(comment: dict) -> str:
275
+ """Transform a single comment to markdown format."""
276
+ body_parts = []
277
+
278
+ category = comment.get('category', 'Review')
279
+ title = comment.get('title', 'Issue')
280
+ severity = comment.get('severity', 'warning')
281
+
282
+ severity_emoji = {
283
+ 'critical': '🔴',
284
+ 'warning': '🟡',
285
+ 'suggestion': '💡'
286
+ }.get(severity, '💬')
287
+
288
+ body_parts.append(f"{severity_emoji} **[{category}] {title}**")
289
+ body_parts.append("")
290
+ body_parts.append(comment.get('body', ''))
291
+
292
+ if comment.get('recommendation'):
293
+ body_parts.append("")
294
+ body_parts.append(f"**Recommendation**: {comment.get('recommendation')}")
295
+
296
+ if comment.get('codeSnippet'):
297
+ body_parts.append("")
298
+ body_parts.append("```")
299
+ body_parts.append(comment.get('codeSnippet'))
300
+ body_parts.append("```")
301
+
302
+ return "\n".join(body_parts)
303
+
304
+
305
+ def build_summary_note(standard_review: dict) -> str:
306
+ """Build the summary note for GitLab."""
307
+ status = standard_review.get('status', 'COMMENT')
308
+ stats = standard_review.get('statistics', {})
309
+
310
+ status_emoji = {
311
+ 'APPROVE': '✅',
312
+ 'REQUEST_CHANGES': '⚠️',
313
+ 'COMMENT': '💬'
314
+ }.get(status, '💬')
315
+
316
+ summary_parts = [f"{status_emoji} **Automated MR Review**"]
317
+ summary_parts.append("")
318
+ summary_parts.append(standard_review.get('summary', ''))
319
+
320
+ if stats:
321
+ summary_parts.append("")
322
+ summary_parts.append("📊 **Statistics**")
323
+ summary_parts.append(f"| Metric | Count |")
324
+ summary_parts.append(f"|--------|-------|")
325
+ summary_parts.append(f"| Files reviewed | {stats.get('filesReviewed', 0)} |")
326
+ summary_parts.append(f"| Critical issues | {stats.get('criticalCount', 0)} |")
327
+ summary_parts.append(f"| Warnings | {stats.get('warningCount', 0)} |")
328
+ summary_parts.append(f"| Suggestions | {stats.get('suggestionCount', 0)} |")
329
+
330
+ return "\n".join(summary_parts)
331
+
332
+
333
+ def get_mr_versions(project_id: str, mr_iid: int, api_url: str, token: str) -> dict:
334
+ """Get diff versions to obtain SHA values required for inline comments."""
335
+ encoded_project_id = urllib.parse.quote(str(project_id), safe='')
336
+ url = f"{api_url}/projects/{encoded_project_id}/merge_requests/{mr_iid}/versions"
337
+
338
+ headers = {'PRIVATE-TOKEN': token}
339
+ req = urllib.request.Request(url, headers=headers, method='GET')
340
+
341
+ try:
342
+ opener = create_no_proxy_opener()
343
+
344
+ with opener.open(req) as response:
345
+ versions = json.loads(response.read().decode('utf-8'))
346
+ if versions and len(versions) > 0:
347
+ latest = versions[0]
348
+ return {
349
+ "success": True,
350
+ "head_sha": latest.get('head_commit_sha'),
351
+ "base_sha": latest.get('base_commit_sha'),
352
+ "start_sha": latest.get('start_commit_sha')
353
+ }
354
+ return {"success": False, "error": "No versions found"}
355
+ except urllib.error.HTTPError as e:
356
+ return {"success": False, "error": f"HTTP {e.code}: {e.reason}"}
357
+
358
+
359
+ def post_gitlab_note(project_id: str, mr_iid: int, body: str, api_url: str, token: str) -> dict:
360
+ """Post a general note to MR."""
361
+ encoded_project_id = urllib.parse.quote(str(project_id), safe='')
362
+ url = f"{api_url}/projects/{encoded_project_id}/merge_requests/{mr_iid}/notes"
363
+
364
+ headers = {
365
+ 'Content-Type': 'application/json',
366
+ 'PRIVATE-TOKEN': token
367
+ }
368
+
369
+ req = urllib.request.Request(
370
+ url,
371
+ data=json.dumps({"body": body}).encode('utf-8'),
372
+ headers=headers,
373
+ method='POST'
374
+ )
375
+
376
+ try:
377
+ opener = create_no_proxy_opener()
378
+
379
+ with opener.open(req) as response:
380
+ result = json.loads(response.read().decode('utf-8'))
381
+ return {"success": True, "noteId": result.get('id')}
382
+ except urllib.error.HTTPError as e:
383
+ return {"success": False, "error": f"HTTP {e.code}: {e.reason}"}
384
+
385
+
386
+ def post_gitlab_discussion(project_id: str, mr_iid: int, comment: dict,
387
+ sha_info: dict, api_url: str, token: str) -> dict:
388
+ """Post an inline discussion thread."""
389
+ encoded_project_id = urllib.parse.quote(str(project_id), safe='')
390
+ url = f"{api_url}/projects/{encoded_project_id}/merge_requests/{mr_iid}/discussions"
391
+
392
+ payload = {
393
+ "body": transform_comment_body(comment),
394
+ "position": {
395
+ "position_type": "text",
396
+ "new_path": comment.get('path'),
397
+ "new_line": comment.get('line'),
398
+ "base_sha": sha_info.get('base_sha'),
399
+ "head_sha": sha_info.get('head_sha'),
400
+ "start_sha": sha_info.get('start_sha')
401
+ }
402
+ }
403
+
404
+ headers = {
405
+ 'Content-Type': 'application/json',
406
+ 'PRIVATE-TOKEN': token
407
+ }
408
+
409
+ req = urllib.request.Request(
410
+ url,
411
+ data=json.dumps(payload).encode('utf-8'),
412
+ headers=headers,
413
+ method='POST'
414
+ )
415
+
416
+ try:
417
+ opener = create_no_proxy_opener()
418
+
419
+ with opener.open(req) as response:
420
+ result = json.loads(response.read().decode('utf-8'))
421
+ return {"success": True, "discussionId": result.get('id')}
422
+ except urllib.error.HTTPError as e:
423
+ error_body = e.read().decode('utf-8') if e.fp else str(e)
424
+ return {"success": False, "error": f"HTTP {e.code}", "details": error_body}
425
+
426
+
427
+ def approve_gitlab_mr(project_id: str, mr_iid: int, api_url: str, token: str) -> dict:
428
+ """Approve the MR."""
429
+ encoded_project_id = urllib.parse.quote(str(project_id), safe='')
430
+ url = f"{api_url}/projects/{encoded_project_id}/merge_requests/{mr_iid}/approve"
431
+
432
+ headers = {'PRIVATE-TOKEN': token}
433
+ req = urllib.request.Request(url, headers=headers, method='POST')
434
+
435
+ try:
436
+ opener = create_no_proxy_opener()
437
+
438
+ with opener.open(req) as response:
439
+ return {"success": True, "approved": True}
440
+ except urllib.error.HTTPError as e:
441
+ return {"success": False, "error": f"HTTP {e.code}: {e.reason}"}
442
+
443
+
444
+ def post_gitlab_review(standard_review: dict, config: dict, dry_run: bool = False) -> dict:
445
+ """Post review to GitLab API."""
446
+ if dry_run:
447
+ return {
448
+ "success": True,
449
+ "dryRun": True,
450
+ "platform": "gitlab",
451
+ "summary": build_summary_note(standard_review),
452
+ "comments": [transform_comment_body(c) for c in standard_review.get('comments', [])]
453
+ }
454
+
455
+ repo_name = config.get('_repoName', 'webAuto')
456
+ resolved = resolve_repo_config(config, repo_name)
457
+ api_url = resolved.get('apiUrl', 'https://gitlab.com/api/v4')
458
+ project_id = resolved.get('projectId')
459
+
460
+ if not project_id:
461
+ return {
462
+ "success": False,
463
+ "platform": "gitlab",
464
+ "error": f"projectId must be specified in repos.{repo_name}.gitlab"
465
+ }
466
+
467
+ token = os.environ.get('GITLAB_TOKEN')
468
+ if not token:
469
+ return {
470
+ "success": False,
471
+ "platform": "gitlab",
472
+ "error": "GitLab token not found. Set GITLAB_TOKEN environment variable.",
473
+ "skipped": True
474
+ }
475
+
476
+ mr_iid = standard_review.get('prNumber')
477
+ status = standard_review.get('status', 'COMMENT')
478
+
479
+ # Get SHA values
480
+ sha_info = get_mr_versions(project_id, mr_iid, api_url, token)
481
+ if not sha_info.get('success'):
482
+ return {
483
+ "success": False,
484
+ "platform": "gitlab",
485
+ "error": f"Failed to get MR versions: {sha_info.get('error')}"
486
+ }
487
+
488
+ # Post summary note
489
+ summary_body = build_summary_note(standard_review)
490
+ summary_result = post_gitlab_note(project_id, mr_iid, summary_body, api_url, token)
491
+
492
+ # Post inline comments
493
+ successful = 0
494
+ failed = []
495
+
496
+ for comment in standard_review.get('comments', []):
497
+ result = post_gitlab_discussion(project_id, mr_iid, comment, sha_info, api_url, token)
498
+ if result.get('success'):
499
+ successful += 1
500
+ else:
501
+ failed.append({
502
+ "path": comment.get('path'),
503
+ "line": comment.get('line'),
504
+ "error": result.get('error')
505
+ })
506
+
507
+ # Handle approval
508
+ approval_result = None
509
+ if status == 'APPROVE':
510
+ approval_result = approve_gitlab_mr(project_id, mr_iid, api_url, token)
511
+
512
+ return {
513
+ "success": len(failed) == 0,
514
+ "platform": "gitlab",
515
+ "mrIid": mr_iid,
516
+ "summaryPosted": summary_result.get('success', False),
517
+ "commentsPosted": successful,
518
+ "failedComments": failed if failed else None,
519
+ "approved": approval_result.get('approved') if approval_result else None
520
+ }
521
+
522
+
523
+ # =============================================================================
524
+ # Gitea Platform Implementation
525
+ # =============================================================================
526
+
527
+ def transform_to_gitea_format(standard_review: dict) -> dict:
528
+ """Transform standard review output to Gitea Reviews API format."""
529
+
530
+ # Build summary body with statistics
531
+ stats = standard_review.get('statistics', {})
532
+ summary_parts = [standard_review.get('summary', 'Automated PR Review')]
533
+
534
+ # Transform comments
535
+ gitea_comments = []
536
+ for comment in standard_review.get('comments', []):
537
+ body_parts = []
538
+
539
+ category = comment.get('category', 'Review')
540
+ title = comment.get('title', 'Issue')
541
+ severity = comment.get('severity', 'warning')
542
+
543
+ severity_emoji = {
544
+ 'critical': '🔴',
545
+ 'warning': '🟡',
546
+ 'suggestion': '💡'
547
+ }.get(severity, '💬')
548
+
549
+ body_parts.append(f"{severity_emoji} **[{category}] {title}**")
550
+ body_parts.append("")
551
+ body_parts.append(comment.get('body', ''))
552
+
553
+ if comment.get('recommendation'):
554
+ body_parts.append("")
555
+ body_parts.append(f"**Recommendation**: {comment.get('recommendation')}")
556
+
557
+ if comment.get('codeSnippet'):
558
+ body_parts.append("")
559
+ body_parts.append("```")
560
+ body_parts.append(comment.get('codeSnippet'))
561
+ body_parts.append("```")
562
+
563
+ gitea_comment = {
564
+ "path": comment.get('path'),
565
+ "new_position": comment.get('line'), # Gitea uses new_position
566
+ "old_position": 0, # 0 for new lines
567
+ "body": "\n".join(body_parts)
568
+ }
569
+ gitea_comments.append(gitea_comment)
570
+
571
+ return {
572
+ "event": 'COMMENT',
573
+ "body": "\n".join(summary_parts),
574
+ "comments": gitea_comments
575
+ }
576
+
577
+
578
+ def post_gitea_review(standard_review: dict, config: dict, dry_run: bool = False) -> dict:
579
+ """Post review to Gitea API."""
580
+ if dry_run:
581
+ gitea_payload = transform_to_gitea_format(standard_review)
582
+ return {"success": True, "dryRun": True, "platform": "gitea", "payload": gitea_payload}
583
+
584
+ repo_name = config.get('_repoName', 'webAuto')
585
+ resolved = resolve_repo_config(config, repo_name)
586
+ api_url = resolved.get('apiUrl', 'https://gitea.example.com/api/v1')
587
+ owner = resolved.get('owner')
588
+ repo = resolved.get('repo')
589
+
590
+ if not owner or not repo:
591
+ return {
592
+ "success": False,
593
+ "platform": "gitea",
594
+ "error": f"Owner and repo must be specified in repos.{repo_name}.gitea"
595
+ }
596
+
597
+ token = os.environ.get('GITEA_TOKEN')
598
+ if not token:
599
+ return {
600
+ "success": False,
601
+ "platform": "gitea",
602
+ "error": "Gitea token not found. Set GITEA_TOKEN environment variable.",
603
+ "skipped": True
604
+ }
605
+
606
+ pr_number = standard_review.get('prNumber')
607
+ gitea_payload = transform_to_gitea_format(standard_review)
608
+ url = f"{api_url}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
609
+
610
+ headers = {
611
+ 'Content-Type': 'application/json',
612
+ 'Authorization': f'token {token}',
613
+ 'Accept': 'application/json'
614
+ }
615
+
616
+ req = urllib.request.Request(
617
+ url,
618
+ data=json.dumps(gitea_payload).encode('utf-8'),
619
+ headers=headers,
620
+ method='POST'
621
+ )
622
+
623
+ try:
624
+ opener = create_no_proxy_opener()
625
+
626
+ with opener.open(req) as response:
627
+ result = json.loads(response.read().decode('utf-8'))
628
+ return {
629
+ "success": True,
630
+ "platform": "gitea",
631
+ "reviewId": result.get('id'),
632
+ "htmlUrl": result.get('html_url'),
633
+ "state": result.get('state'),
634
+ "commentsPosted": len(gitea_payload.get('comments', []))
635
+ }
636
+ except urllib.error.HTTPError as e:
637
+ error_body = e.read().decode('utf-8') if e.fp else str(e)
638
+ return {
639
+ "success": False,
640
+ "platform": "gitea",
641
+ "error": f"HTTP {e.code}: {e.reason}",
642
+ "details": error_body
643
+ }
644
+ except urllib.error.URLError as e:
645
+ return {
646
+ "success": False,
647
+ "platform": "gitea",
648
+ "error": f"Connection error: {e.reason}"
649
+ }
650
+
651
+
652
+ # =============================================================================
653
+ # Bitbucket Platform Implementation
654
+ # =============================================================================
655
+
656
+ def build_bitbucket_summary(standard_review: dict) -> dict:
657
+ """Build the summary comment payload for Bitbucket."""
658
+ status = standard_review.get('status', 'COMMENT')
659
+ stats = standard_review.get('statistics', {})
660
+
661
+ status_emoji = {
662
+ 'APPROVE': '✅',
663
+ 'REQUEST_CHANGES': '⚠️',
664
+ 'COMMENT': '💬'
665
+ }.get(status, '💬')
666
+
667
+ summary_parts = [f"{status_emoji} **Automated PR Review**"]
668
+ summary_parts.append("")
669
+ summary_parts.append(standard_review.get('summary', ''))
670
+
671
+ if stats:
672
+ summary_parts.append("")
673
+ summary_parts.append("📊 **Statistics**")
674
+ summary_parts.append(f"- Files reviewed: {stats.get('filesReviewed', 0)}")
675
+ summary_parts.append(f"- Critical issues: {stats.get('criticalCount', 0)}")
676
+ summary_parts.append(f"- Warnings: {stats.get('warningCount', 0)}")
677
+ summary_parts.append(f"- Suggestions: {stats.get('suggestionCount', 0)}")
678
+
679
+ return {
680
+ "content": {
681
+ "raw": "\n".join(summary_parts)
682
+ }
683
+ }
684
+
685
+
686
+ def transform_bitbucket_inline_comment(comment: dict) -> dict:
687
+ """Transform a standard comment to Bitbucket inline comment format."""
688
+ return {
689
+ "content": {
690
+ "raw": transform_comment_body(comment)
691
+ },
692
+ "inline": {
693
+ "path": comment.get('path'),
694
+ "to": comment.get('line') # Bitbucket uses 'to' for line number
695
+ }
696
+ }
697
+
698
+
699
+ def get_bitbucket_auth_header(platform_config: dict) -> dict:
700
+ """Get authentication header for Bitbucket API. Returns dict with success and auth_header or error."""
701
+ token = os.environ.get('BITBUCKET_TOKEN')
702
+ username = os.environ.get('BITBUCKET_USERNAME')
703
+
704
+ if token and username:
705
+ # App Password with Basic Auth
706
+ credentials = base64.b64encode(f"{username}:{token}".encode()).decode()
707
+ return {"success": True, "auth_header": f"Basic {credentials}"}
708
+ elif token:
709
+ # OAuth Bearer token
710
+ return {"success": True, "auth_header": f"Bearer {token}"}
711
+ else:
712
+ return {
713
+ "success": False,
714
+ "error": "Bitbucket credentials not found. Set BITBUCKET_TOKEN environment variable.",
715
+ "skipped": True
716
+ }
717
+
718
+
719
+ def post_bitbucket_comment(workspace: str, repo_slug: str, pr_id: int,
720
+ payload: dict, api_url: str, auth_header: str) -> dict:
721
+ """Post a comment to Bitbucket PR."""
722
+ url = f"{api_url}/repositories/{workspace}/{repo_slug}/pullrequests/{pr_id}/comments"
723
+
724
+ headers = {
725
+ 'Content-Type': 'application/json',
726
+ 'Authorization': auth_header
727
+ }
728
+
729
+ req = urllib.request.Request(
730
+ url,
731
+ data=json.dumps(payload).encode('utf-8'),
732
+ headers=headers,
733
+ method='POST'
734
+ )
735
+
736
+ try:
737
+ opener = create_no_proxy_opener()
738
+
739
+ with opener.open(req) as response:
740
+ result = json.loads(response.read().decode('utf-8'))
741
+ return {"success": True, "commentId": result.get('id')}
742
+ except urllib.error.HTTPError as e:
743
+ error_body = e.read().decode('utf-8') if e.fp else str(e)
744
+ return {"success": False, "error": f"HTTP {e.code}", "details": error_body}
745
+
746
+
747
+ def approve_bitbucket_pr(workspace: str, repo_slug: str, pr_id: int,
748
+ api_url: str, auth_header: str) -> dict:
749
+ """Approve the PR."""
750
+ url = f"{api_url}/repositories/{workspace}/{repo_slug}/pullrequests/{pr_id}/approve"
751
+
752
+ headers = {'Authorization': auth_header}
753
+ req = urllib.request.Request(url, headers=headers, method='POST')
754
+
755
+ try:
756
+ opener = create_no_proxy_opener()
757
+
758
+ with opener.open(req) as response:
759
+ return {"success": True, "approved": True}
760
+ except urllib.error.HTTPError as e:
761
+ return {"success": False, "error": f"HTTP {e.code}: {e.reason}"}
762
+
763
+
764
+ def request_bitbucket_changes(workspace: str, repo_slug: str, pr_id: int,
765
+ api_url: str, auth_header: str) -> dict:
766
+ """Request changes on the PR."""
767
+ url = f"{api_url}/repositories/{workspace}/{repo_slug}/pullrequests/{pr_id}/request-changes"
768
+
769
+ headers = {'Authorization': auth_header}
770
+ req = urllib.request.Request(url, headers=headers, method='POST')
771
+
772
+ try:
773
+ opener = create_no_proxy_opener()
774
+
775
+ with opener.open(req) as response:
776
+ return {"success": True, "changesRequested": True}
777
+ except urllib.error.HTTPError as e:
778
+ return {"success": False, "error": f"HTTP {e.code}: {e.reason}"}
779
+
780
+
781
+ def post_bitbucket_review(standard_review: dict, config: dict, dry_run: bool = False) -> dict:
782
+ """Post review to Bitbucket API."""
783
+ if dry_run:
784
+ return {
785
+ "success": True,
786
+ "dryRun": True,
787
+ "platform": "bitbucket",
788
+ "summary": build_bitbucket_summary(standard_review),
789
+ "comments": [transform_bitbucket_inline_comment(c) for c in standard_review.get('comments', [])]
790
+ }
791
+
792
+ repo_name = config.get('_repoName', 'webAuto')
793
+ resolved = resolve_repo_config(config, repo_name)
794
+ api_url = resolved.get('apiUrl', 'https://api.bitbucket.org/2.0')
795
+ workspace = resolved.get('workspace')
796
+ repo_slug = resolved.get('repoSlug')
797
+
798
+ if not workspace or not repo_slug:
799
+ return {
800
+ "success": False,
801
+ "platform": "bitbucket",
802
+ "error": f"workspace and repoSlug must be specified in repos.{repo_name}.bitbucket"
803
+ }
804
+
805
+ auth_result = get_bitbucket_auth_header(platform_config)
806
+ if not auth_result.get('success'):
807
+ return {
808
+ "success": False,
809
+ "platform": "bitbucket",
810
+ "error": auth_result.get('error'),
811
+ "skipped": True
812
+ }
813
+
814
+ auth_header = auth_result.get('auth_header')
815
+
816
+ pr_id = standard_review.get('prNumber')
817
+ status = standard_review.get('status', 'COMMENT')
818
+
819
+ # Post summary comment
820
+ summary_payload = build_bitbucket_summary(standard_review)
821
+ summary_result = post_bitbucket_comment(workspace, repo_slug, pr_id, summary_payload, api_url, auth_header)
822
+
823
+ # Post inline comments
824
+ successful = 0
825
+ failed = []
826
+
827
+ for comment in standard_review.get('comments', []):
828
+ inline_payload = transform_bitbucket_inline_comment(comment)
829
+ result = post_bitbucket_comment(workspace, repo_slug, pr_id, inline_payload, api_url, auth_header)
830
+
831
+ if result.get('success'):
832
+ successful += 1
833
+ else:
834
+ failed.append({
835
+ "path": comment.get('path'),
836
+ "line": comment.get('line'),
837
+ "error": result.get('error')
838
+ })
839
+
840
+ # Set approval status
841
+ status_result = None
842
+ if status == 'APPROVE':
843
+ status_result = approve_bitbucket_pr(workspace, repo_slug, pr_id, api_url, auth_header)
844
+ elif status == 'REQUEST_CHANGES':
845
+ status_result = request_bitbucket_changes(workspace, repo_slug, pr_id, api_url, auth_header)
846
+
847
+ return {
848
+ "success": len(failed) == 0,
849
+ "platform": "bitbucket",
850
+ "prId": pr_id,
851
+ "workspace": workspace,
852
+ "repoSlug": repo_slug,
853
+ "summaryPosted": summary_result.get('success', False),
854
+ "commentsPosted": successful,
855
+ "failedComments": failed if failed else None,
856
+ "statusSet": status_result.get('success') if status_result else None
857
+ }
858
+
859
+
860
+ # =============================================================================
861
+ # Platform Script Dispatcher
862
+ # =============================================================================
863
+
864
+ def post_to_platform(platform: str, standard_review: dict, config: dict, dry_run: bool = False) -> dict:
865
+ """
866
+ Post review to the specified platform.
867
+
868
+ Directly handles transformation and API calls for each platform.
869
+ """
870
+ platform_handlers = {
871
+ 'github': post_github_review,
872
+ 'gitea': post_gitea_review,
873
+ 'gitlab': post_gitlab_review,
874
+ 'bitbucket': post_bitbucket_review
875
+ }
876
+
877
+ if platform not in platform_handlers:
878
+ return {
879
+ "success": False,
880
+ "error": f"Unsupported platform: {platform}",
881
+ "supportedPlatforms": list(platform_handlers.keys())
882
+ }
883
+
884
+ try:
885
+ return platform_handlers[platform](standard_review, config, dry_run)
886
+ except Exception as e:
887
+ return {
888
+ "success": False,
889
+ "platform": platform,
890
+ "error": f"Exception: {str(e)}"
891
+ }
892
+
893
+
894
+ # =============================================================================
895
+ # Notification
896
+ # =============================================================================
897
+
898
+ def fetch_gitea_pr_info(pr_number: int, config: dict) -> dict:
899
+ """Fetch PR information from Gitea API."""
900
+ platform = config.get('platform', '')
901
+
902
+ if platform != 'gitea':
903
+ return {"success": False, "error": "Not a Gitea platform"}
904
+
905
+ repo_name = config.get('_repoName', 'webAuto')
906
+ resolved = resolve_repo_config(config, repo_name)
907
+ api_url = resolved.get('apiUrl', '')
908
+ owner = resolved.get('owner', '')
909
+ repo = resolved.get('repo', '')
910
+
911
+ if not api_url or not owner or not repo:
912
+ return {"success": False, "error": "Missing Gitea configuration"}
913
+
914
+ token = os.environ.get('GITEA_TOKEN')
915
+ if not token:
916
+ return {"success": False, "error": "GITEA_TOKEN not set"}
917
+
918
+ url = f"{api_url}/repos/{owner}/{repo}/pulls/{pr_number}"
919
+
920
+ headers = {
921
+ 'Authorization': f'token {token}',
922
+ 'Accept': 'application/json'
923
+ }
924
+
925
+ req = urllib.request.Request(url, headers=headers, method='GET')
926
+
927
+ try:
928
+ opener = create_no_proxy_opener()
929
+
930
+ with opener.open(req) as response:
931
+ pr_data = json.loads(response.read().decode('utf-8'))
932
+ return {
933
+ "success": True,
934
+ "data": {
935
+ "title": pr_data.get('title', ''),
936
+ "body": pr_data.get('body', ''),
937
+ "state": pr_data.get('state', ''),
938
+ "user": pr_data.get('user', {}).get('login', ''),
939
+ "created_at": pr_data.get('created_at', ''),
940
+ "updated_at": pr_data.get('updated_at', ''),
941
+ "html_url": pr_data.get('html_url', ''),
942
+ "base_branch": pr_data.get('base', {}).get('ref', ''),
943
+ "head_branch": pr_data.get('head', {}).get('ref', ''),
944
+ "mergeable": pr_data.get('mergeable', False),
945
+ "merged": pr_data.get('merged', False),
946
+ "additions": pr_data.get('additions', 0),
947
+ "deletions": pr_data.get('deletions', 0),
948
+ "changed_files": pr_data.get('changed_files', 0)
949
+ }
950
+ }
951
+ except urllib.error.HTTPError as e:
952
+ error_body = e.read().decode('utf-8') if e.fp else str(e)
953
+ return {
954
+ "success": False,
955
+ "error": f"HTTP {e.code}: {e.reason}",
956
+ "details": error_body
957
+ }
958
+ except urllib.error.URLError as e:
959
+ return {
960
+ "success": False,
961
+ "error": f"Connection error: {e.reason}"
962
+ }
963
+ except Exception as e:
964
+ return {
965
+ "success": False,
966
+ "error": f"Unexpected error: {str(e)}"
967
+ }
968
+
969
+
970
+ def generate_summary_file(standard_review: dict, config: dict, output_path: str) -> str:
971
+ """Generate a markdown summary file for notifications."""
972
+ status = standard_review.get('status', 'COMMENT')
973
+ stats = standard_review.get('statistics', {})
974
+ pr_number = standard_review.get('prNumber')
975
+
976
+ status_emoji = {
977
+ 'APPROVE': '✅',
978
+ 'REQUEST_CHANGES': '🔴',
979
+ 'COMMENT': '💬'
980
+ }.get(status, '💬')
981
+
982
+ # Fetch PR information from Gitea if available
983
+ pr_info = fetch_gitea_pr_info(pr_number, config)
984
+ pr_data = pr_info.get('data', {}) if pr_info.get('success') else {}
985
+
986
+ lines = [
987
+ f"# PR Review Report",
988
+ ""
989
+ ]
990
+
991
+ # Add PR information if available from Gitea
992
+ if pr_data:
993
+ lines.extend([
994
+ f"## Pull Request Information",
995
+ "",
996
+ f"**PR Number**: #{pr_number}",
997
+ f"**Title**: {pr_data.get('title', 'N/A')}",
998
+ f"**Author**: {pr_data.get('user', 'N/A')}",
999
+ f"**Branch**: {pr_data.get('head_branch', 'unknown')} → {pr_data.get('base_branch', 'main')}",
1000
+ f"**State**: {pr_data.get('state', 'unknown').upper()}",
1001
+ f"**Mergeable**: {'✅ Yes' if pr_data.get('mergeable') else '❌ No'}",
1002
+ f"**Changes**: +{pr_data.get('additions', 0)} -{pr_data.get('deletions', 0)} ({pr_data.get('changed_files', 0)} files)",
1003
+ f"**Created**: {pr_data.get('created_at', 'N/A')}",
1004
+ f"**Updated**: {pr_data.get('updated_at', 'N/A')}",
1005
+ ""
1006
+ ])
1007
+
1008
+ # Add PR description if available
1009
+ if pr_data.get('body'):
1010
+ lines.extend([
1011
+ "### Description",
1012
+ "",
1013
+ pr_data.get('body'),
1014
+ ""
1015
+ ])
1016
+
1017
+ # Add PR URL if available
1018
+ if pr_data.get('html_url'):
1019
+ lines.extend([
1020
+ f"**🔗 PR Link**: {pr_data.get('html_url')}",
1021
+ ""
1022
+ ])
1023
+ else:
1024
+ # Fallback to metadata from standard_review
1025
+ lines.extend([
1026
+ f"**PR Number**: #{pr_number}",
1027
+ f"**Branch**: {standard_review.get('metadata', {}).get('sourceBranch', 'unknown')} → {standard_review.get('metadata', {}).get('baseBranch', 'main')}",
1028
+ ""
1029
+ ])
1030
+
1031
+ lines.extend([
1032
+ "---",
1033
+ "",
1034
+ "## Review Status",
1035
+ "",
1036
+ f"**Status**: {status_emoji} {status}",
1037
+ f"**Files Reviewed**: {stats.get('filesReviewed', 0)}",
1038
+ "",
1039
+ "---",
1040
+ "",
1041
+ "## Review Summary",
1042
+ "",
1043
+ standard_review.get('summary', 'No summary provided.'),
1044
+ "",
1045
+ "---",
1046
+ "",
1047
+ f"## Critical Issues ({stats.get('criticalCount', 0)})",
1048
+ ""
1049
+ ])
1050
+
1051
+ critical_comments = [c for c in standard_review.get('comments', [])
1052
+ if c.get('severity') == 'critical']
1053
+
1054
+ if critical_comments:
1055
+ for i, c in enumerate(critical_comments, 1):
1056
+ lines.append(f"{i}. **[{c.get('category')}]** `{c.get('path')}:{c.get('line')}` - {c.get('title')}")
1057
+ else:
1058
+ lines.append("✅ No critical issues found.")
1059
+
1060
+ lines.extend([
1061
+ "",
1062
+ "---",
1063
+ "",
1064
+ f"## Warnings ({stats.get('warningCount', 0)})",
1065
+ ""
1066
+ ])
1067
+
1068
+ warning_comments = [c for c in standard_review.get('comments', [])
1069
+ if c.get('severity') == 'warning']
1070
+
1071
+ if warning_comments:
1072
+ for i, c in enumerate(warning_comments, 1):
1073
+ lines.append(f"{i}. **[{c.get('category')}]** `{c.get('path')}:{c.get('line')}` - {c.get('title')}")
1074
+ else:
1075
+ lines.append("✅ No warnings found.")
1076
+
1077
+ lines.extend([
1078
+ "",
1079
+ "---",
1080
+ "",
1081
+ f"*Generated by PR Review Agent on {datetime.now().isoformat()}*"
1082
+ ])
1083
+
1084
+ content = "\n".join(lines)
1085
+
1086
+ with open(output_path, 'w', encoding='utf-8') as f:
1087
+ f.write(content)
1088
+
1089
+ return output_path
1090
+
1091
+
1092
+ def send_notification(summary_file: str, config: dict, standard_review: dict) -> dict:
1093
+ """Send notification to Google Chat with PR link."""
1094
+ webhook_url = os.environ.get('GOOGLE_CHAT_WEBHOOK_URL')
1095
+
1096
+ if not webhook_url:
1097
+ return {"success": False, "error": "GOOGLE_CHAT_WEBHOOK_URL not set"}
1098
+
1099
+ if not os.path.isfile(summary_file):
1100
+ return {"success": False, "error": f"Summary file not found: {summary_file}"}
1101
+
1102
+ try:
1103
+ with open(summary_file, 'r', encoding='utf-8') as f:
1104
+ content = f.read()
1105
+ except Exception as e:
1106
+ return {"success": False, "error": f"Error reading summary file: {e}"}
1107
+
1108
+ # Extract summary information
1109
+ import re
1110
+
1111
+ # Branch
1112
+ branch_match = re.search(r'\*\*Branch\*\*:\s*(.*)', content)
1113
+ branch = branch_match.group(1).strip() if branch_match else "unknown"
1114
+
1115
+ # Critical Count
1116
+ critical_match = re.search(r'Critical Issues \(([0-9]*)\)', content)
1117
+ critical_count = int(critical_match.group(1)) if critical_match else 0
1118
+
1119
+ # Warning Count
1120
+ warning_match = re.search(r'Warnings \(([0-9]*)\)', content)
1121
+ warning_count = int(warning_match.group(1)) if warning_match else 0
1122
+
1123
+ # Suggestion Count - get from statistics in standard_review
1124
+ stats = standard_review.get('statistics', {})
1125
+ suggestion_count = stats.get('suggestionCount', 0)
1126
+
1127
+ # PR Title
1128
+ title_match = re.search(r'\*\*Title\*\*:\s*(.*)', content)
1129
+ pr_title = title_match.group(1).strip() if title_match else f"PR #{standard_review.get('prNumber', 'N/A')}"
1130
+
1131
+ # PR Author
1132
+ author_match = re.search(r'\*\*Author\*\*:\s*(.*)', content)
1133
+ author = author_match.group(1).strip() if author_match else "Unknown"
1134
+
1135
+ # Determine status
1136
+ if critical_count > 0:
1137
+ status = "Changes Required"
1138
+ status_short = "🔴"
1139
+ elif warning_count > 0:
1140
+ status = "Changes Recommended"
1141
+ status_short = "🟡"
1142
+ else:
1143
+ status = "Approved"
1144
+ status_short = "✅"
1145
+
1146
+ # Build PR URL based on platform
1147
+ pr_number = standard_review.get('prNumber')
1148
+ platform = config.get('platform', 'github')
1149
+ config_repo_name = config.get('_repoName', 'webAuto')
1150
+ resolved = resolve_repo_config(config, config_repo_name)
1151
+
1152
+ # Get repo name for header
1153
+ repo_display = resolved.get('repo', '') or resolved.get('repoSlug', '') or config_repo_name
1154
+
1155
+ pr_url = None
1156
+ if platform == 'github':
1157
+ api_url = resolved.get('apiUrl', 'https://api.github.com')
1158
+ owner = resolved.get('owner', '')
1159
+ repo = resolved.get('repo', '')
1160
+ if owner and repo:
1161
+ # Convert api.github.com to github.com
1162
+ base_url = api_url.replace('api.github.com', 'github.com').replace('/api/v3', '')
1163
+ pr_url = f"{base_url}/{owner}/{repo}/pull/{pr_number}"
1164
+ elif platform == 'gitea':
1165
+ api_url = resolved.get('apiUrl', '')
1166
+ owner = resolved.get('owner', '')
1167
+ repo = resolved.get('repo', '')
1168
+ if api_url and owner and repo:
1169
+ # Remove /api/v1 from URL
1170
+ base_url = api_url.replace('/api/v1', '')
1171
+ pr_url = f"{base_url}/{owner}/{repo}/pulls/{pr_number}"
1172
+ elif platform == 'gitlab':
1173
+ api_url = resolved.get('apiUrl', 'https://gitlab.com/api/v4')
1174
+ project_id = resolved.get('projectId', '')
1175
+ if project_id:
1176
+ # Convert api URL to web URL
1177
+ base_url = api_url.replace('/api/v4', '')
1178
+ # GitLab uses project ID in URL format
1179
+ pr_url = f"{base_url}/merge_requests/{pr_number}"
1180
+ elif platform == 'bitbucket':
1181
+ workspace = resolved.get('workspace', '')
1182
+ repo_slug = resolved.get('repoSlug', '')
1183
+ if workspace and repo_slug:
1184
+ pr_url = f"https://bitbucket.org/{workspace}/{repo_slug}/pull-requests/{pr_number}"
1185
+
1186
+ # Get comment count and unique files with comments
1187
+ comments = standard_review.get('comments', [])
1188
+ comment_count = len(comments)
1189
+ unique_files = len(set(c.get('path') for c in comments if c.get('path')))
1190
+
1191
+ # Format timestamp
1192
+ current_time = datetime.now().strftime('%m/%d/%Y %H:%M:%S')
1193
+
1194
+ # Format comment info text
1195
+ comment_text = "comment" if comment_count == 1 else "comments"
1196
+ file_text = "file" if unique_files == 1 else "files"
1197
+ review_info = f"{current_time} • {comment_count} {comment_text} found at {unique_files} {file_text}"
1198
+
1199
+ # Build widgets list
1200
+ widgets = [
1201
+ {"decoratedText": {"text": review_info}}
1202
+ ]
1203
+
1204
+ # Add PR link button right after review_info if URL was built
1205
+ if pr_url:
1206
+ widgets.append({
1207
+ "buttonList": {
1208
+ "buttons": [{
1209
+ "text": "View Pull Request",
1210
+ "onClick": {
1211
+ "openLink": {
1212
+ "url": pr_url
1213
+ }
1214
+ }
1215
+ }]
1216
+ }
1217
+ })
1218
+
1219
+ # Add remaining widgets
1220
+ widgets.extend([
1221
+ {"decoratedText": {"topLabel": "📊 Issue Summary"}},
1222
+ {"decoratedText": {"topLabel": f"🔴 Critical: {str(critical_count)}"}},
1223
+ {"decoratedText": {"topLabel": f"🟡 Warnings: {str(warning_count)}"}},
1224
+ {"decoratedText": {"topLabel": f"⚪ Suggestions: {str(suggestion_count)}"}},
1225
+ {"decoratedText": {"topLabel": f'🤖 AI Reviewer: {str(status)}'}},
1226
+ ])
1227
+
1228
+ # Build Google Chat payload with status, repo name, author, and PR title
1229
+ card_title = f"{status_short} [{repo_display}] Code Review Agent ({author}): {pr_title}"
1230
+
1231
+ payload = {
1232
+ "cardsV2": [{
1233
+ "cardId": "pr-review",
1234
+ "card": {
1235
+ "header": {"title": card_title, "subtitle": branch},
1236
+ "sections": [{
1237
+ "widgets": widgets
1238
+ }]
1239
+ }
1240
+ }]
1241
+ }
1242
+
1243
+ # Send notification
1244
+ try:
1245
+ req = urllib.request.Request(
1246
+ webhook_url,
1247
+ data=json.dumps(payload).encode('utf-8'),
1248
+ headers={'Content-Type': 'application/json'},
1249
+ method='POST'
1250
+ )
1251
+ opener = create_no_proxy_opener()
1252
+
1253
+ with opener.open(req) as response:
1254
+ if response.status == 200:
1255
+ return {"success": True, "sent": True}
1256
+ else:
1257
+ return {"success": False, "error": f"HTTP {response.status}"}
1258
+ except urllib.error.HTTPError as e:
1259
+ return {"success": False, "error": f"HTTP {e.code} - {e.reason}"}
1260
+ except urllib.error.URLError as e:
1261
+ return {"success": False, "error": f"Connection error: {e.reason}"}
1262
+ except Exception as e:
1263
+ return {"success": False, "error": f"Unexpected error: {e}"}
1264
+
1265
+
1266
+
1267
+ # =============================================================================
1268
+ # Main
1269
+ # =============================================================================
1270
+
1271
+ def main():
1272
+ import argparse
1273
+
1274
+ parser = argparse.ArgumentParser(
1275
+ description='Post PR review comments to Git platform',
1276
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1277
+ epilog="""
1278
+ Examples:
1279
+ python post-review.py # Uses review.json, default repo (webAuto)
1280
+ python post-review.py --repo webApp # Target a specific repo from repos map
1281
+ python post-review.py --dry-run # Transform only, do not post
1282
+ """
1283
+ )
1284
+ parser.add_argument('--dry-run', '-d', action='store_true',
1285
+ help='Transform and display, do not post')
1286
+ parser.add_argument('--repo', '-r', default='webAuto',
1287
+ help='Repo name from repos map in project.config.json (default: webAuto)')
1288
+
1289
+ args = parser.parse_args()
1290
+
1291
+ # Load review.json from project root
1292
+ if not os.path.isfile(DEFAULT_REVIEW_FILE):
1293
+ print(f"Error: Review file not found: {DEFAULT_REVIEW_FILE}", file=sys.stderr)
1294
+ sys.exit(1)
1295
+
1296
+ try:
1297
+ standard_review = load_json_file(DEFAULT_REVIEW_FILE)
1298
+ except json.JSONDecodeError as e:
1299
+ print(f"Error: Invalid JSON in {DEFAULT_REVIEW_FILE}: {e}", file=sys.stderr)
1300
+ sys.exit(1)
1301
+
1302
+ pr_number = standard_review.get('prNumber')
1303
+ if not pr_number:
1304
+ print("Error: prNumber is required in review.json", file=sys.stderr)
1305
+ sys.exit(1)
1306
+
1307
+ # Load config
1308
+ try:
1309
+ config, config_path = get_config()
1310
+ except FileNotFoundError as e:
1311
+ print(f"Error: {e}", file=sys.stderr)
1312
+ sys.exit(1)
1313
+
1314
+ # Attach repo name for downstream functions
1315
+ config['_repoName'] = args.repo
1316
+
1317
+ platform = get_platform(config)
1318
+ notification_enabled = is_notification_enabled(config)
1319
+ comment_posting_enabled = is_comment_posting_enabled(config)
1320
+ has_token = has_platform_token(platform)
1321
+
1322
+ # Determine operation mode
1323
+ if not comment_posting_enabled and not notification_enabled:
1324
+ # Both disabled - nothing to do
1325
+ print(f"Error: Both comment posting and notifications are disabled in config.", file=sys.stderr)
1326
+ print(f"Enable at least one: set 'enableCommentPosting' or 'enableNotification' to true.", file=sys.stderr)
1327
+ sys.exit(1)
1328
+
1329
+ # Post comments (if enabled in config and token available)
1330
+ result = {}
1331
+ should_post = comment_posting_enabled and (has_token or args.dry_run)
1332
+
1333
+ if should_post:
1334
+ result = post_to_platform(platform, standard_review, config, args.dry_run)
1335
+
1336
+ if not result.get('success') and not result.get('skipped') and not args.dry_run:
1337
+ print(f"Error: Failed to post review: {result.get('error', 'Unknown error')}", file=sys.stderr)
1338
+ # Don't exit if notifications are enabled - continue to send notification
1339
+ if not notification_enabled:
1340
+ sys.exit(1)
1341
+ else:
1342
+ # Determine skip reason
1343
+ skip_reasons = []
1344
+ if not comment_posting_enabled:
1345
+ skip_reasons.append("comment posting disabled in config")
1346
+ if comment_posting_enabled and not has_token:
1347
+ skip_reasons.append("no git token configured")
1348
+
1349
+ skip_reason = " and ".join(skip_reasons)
1350
+
1351
+ result = {
1352
+ "success": False,
1353
+ "platform": platform,
1354
+ "skipped": True,
1355
+ "reason": f"Skipping inline comments: {skip_reason}"
1356
+ }
1357
+
1358
+ if notification_enabled:
1359
+ print(f"Info: Skipping inline comments ({skip_reason}). Running in notification-only mode.", file=sys.stderr)
1360
+ else:
1361
+ print(f"Error: Cannot post comments - {skip_reason}.", file=sys.stderr)
1362
+ sys.exit(1)
1363
+
1364
+ # Send notification if enabled in config
1365
+ if notification_enabled and not args.dry_run:
1366
+ summary_path = f"/tmp/pr-{pr_number}-summary.md"
1367
+ generate_summary_file(standard_review, config, summary_path)
1368
+ notify_result = send_notification(summary_path, config, standard_review)
1369
+ result['notification'] = notify_result
1370
+
1371
+ # Cleanup: Remove review.json after successful posting or notification
1372
+ should_cleanup = False
1373
+ if not args.dry_run:
1374
+ if result.get('success'):
1375
+ # Posting was successful
1376
+ should_cleanup = True
1377
+ elif result.get('skipped') and notification_enabled:
1378
+ # Notification-only mode - cleanup if notification was successful
1379
+ notify_result = result.get('notification', {})
1380
+ should_cleanup = notify_result.get('success', False)
1381
+
1382
+ if should_cleanup:
1383
+ try:
1384
+ os.remove(DEFAULT_REVIEW_FILE)
1385
+ result['cleanup'] = {'reviewFileDeleted': True}
1386
+ except Exception as e:
1387
+ # Non-critical error, don't fail the whole process
1388
+ result['cleanup'] = {'reviewFileDeleted': False, 'error': str(e)}
1389
+
1390
+ # Output result as JSON
1391
+ print(json.dumps(result, indent=2))
1392
+
1393
+
1394
+ if __name__ == '__main__':
1395
+ main()