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.
- package/README.md +506 -0
- package/kits/agent/.agent/skills/claude-code-subagent-creator/SKILL.md +292 -0
- package/kits/agent/.agent/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
- package/kits/agent/.agent/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
- package/kits/agent/.agent/skills/skill-creator/LICENSE.txt +202 -0
- package/kits/agent/.agent/skills/skill-creator/SKILL.md +485 -0
- package/kits/agent/.agent/skills/skill-creator/agents/analyzer.md +274 -0
- package/kits/agent/.agent/skills/skill-creator/agents/comparator.md +202 -0
- package/kits/agent/.agent/skills/skill-creator/agents/grader.md +223 -0
- package/kits/agent/.agent/skills/skill-creator/assets/eval_review.html +146 -0
- package/kits/agent/.agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/kits/agent/.agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/kits/agent/.agent/skills/skill-creator/references/schemas.md +430 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/__init__.py +0 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/generate_report.py +326 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/improve_description.py +247 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/package_skill.py +136 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/run_eval.py +310 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/run_loop.py +328 -0
- package/kits/agent/.agent/skills/skill-creator/scripts/utils.py +47 -0
- package/kits/agent/manifest.json +10 -0
- package/kits/claude/.claude/agents/code-pusher.md +46 -0
- package/kits/claude/.claude/agents/feature-document-updater.md +37 -0
- package/kits/claude/.claude/agents/self-reviewer.md +32 -0
- package/kits/claude/.claude/agents/web-auto-agentic-workflow-initializer.md +42 -0
- package/kits/claude/.claude/agents/web-auto-assisted-fix-and-runner.md +36 -0
- package/kits/claude/.claude/agents/web-auto-chrome-devtools-selector-extractor.md +36 -0
- package/kits/claude/.claude/agents/web-auto-coder.md +33 -0
- package/kits/claude/.claude/agents/web-auto-fe-selector-extractor.md +31 -0
- package/kits/claude/.claude/agents/web-auto-fix-and-runner.md +35 -0
- package/kits/claude/.claude/agents/web-auto-lessons-learned-extractor.md +34 -0
- package/kits/claude/.claude/agents/web-auto-playwright-mcp-selector-extractor.md +37 -0
- package/kits/claude/.claude/agents/web-auto-source-instructions-updater.md +43 -0
- package/kits/claude/.claude/agents/web-auto-test-cases-generator.md +29 -0
- package/kits/claude/.claude/agents/web-auto-ticket-designer.md +35 -0
- package/kits/claude/.claude/agents/web-auto-ticket-playbook-planner.md +36 -0
- package/kits/claude/.claude/agents/web-auto.md +382 -0
- package/kits/claude/.claude/skills/claude-code-subagent-creator/SKILL.md +292 -0
- package/kits/claude/.claude/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
- package/kits/claude/.claude/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
- package/kits/claude/.claude/skills/skill-creator/LICENSE.txt +202 -0
- package/kits/claude/.claude/skills/skill-creator/SKILL.md +485 -0
- package/kits/claude/.claude/skills/skill-creator/agents/analyzer.md +274 -0
- package/kits/claude/.claude/skills/skill-creator/agents/comparator.md +202 -0
- package/kits/claude/.claude/skills/skill-creator/agents/grader.md +223 -0
- package/kits/claude/.claude/skills/skill-creator/assets/eval_review.html +146 -0
- package/kits/claude/.claude/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/kits/claude/.claude/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/kits/claude/.claude/skills/skill-creator/references/schemas.md +430 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/__init__.py +0 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/generate_report.py +326 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/improve_description.py +247 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/package_skill.py +136 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/run_eval.py +310 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/run_loop.py +328 -0
- package/kits/claude/.claude/skills/skill-creator/scripts/utils.py +47 -0
- package/kits/claude/manifest.json +10 -0
- package/kits/cursor/.cursor/agents/code-pusher.agent.md +43 -0
- package/kits/cursor/.cursor/agents/feature-document-updater.agent.md +34 -0
- package/kits/cursor/.cursor/agents/self-reviewer.agent.md +29 -0
- package/kits/cursor/.cursor/agents/web-auto-agentic-workflow-initializer.agent.md +37 -0
- package/kits/cursor/.cursor/agents/web-auto-assisted-fix-and-runner.agent.md +33 -0
- package/kits/cursor/.cursor/agents/web-auto-chrome-devtools-selector-extractor.agent.md +31 -0
- package/kits/cursor/.cursor/agents/web-auto-coder.agent.md +30 -0
- package/kits/cursor/.cursor/agents/web-auto-fe-selector-extractor.agent.md +28 -0
- package/kits/cursor/.cursor/agents/web-auto-fix-and-runner.agent.md +32 -0
- package/kits/cursor/.cursor/agents/web-auto-lessons-learned-extractor.agent.md +31 -0
- package/kits/cursor/.cursor/agents/web-auto-playwright-mcp-selector-extractor.agent.md +32 -0
- package/kits/cursor/.cursor/agents/web-auto-source-instructions-updater.agent.md +40 -0
- package/kits/cursor/.cursor/agents/web-auto-test-cases-generator.agent.md +26 -0
- package/kits/cursor/.cursor/agents/web-auto-ticket-designer.agent.md +32 -0
- package/kits/cursor/.cursor/agents/web-auto-ticket-playbook-planner.agent.md +33 -0
- package/kits/cursor/.cursor/agents/web-auto.agent.md +379 -0
- package/kits/cursor/.cursor/skills/claude-code-subagent-creator/SKILL.md +292 -0
- package/kits/cursor/.cursor/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
- package/kits/cursor/.cursor/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +26 -0
- package/kits/cursor/.cursor/skills/skill-creator/LICENSE.txt +202 -0
- package/kits/cursor/.cursor/skills/skill-creator/SKILL.md +485 -0
- package/kits/cursor/.cursor/skills/skill-creator/agents/analyzer.md +274 -0
- package/kits/cursor/.cursor/skills/skill-creator/agents/comparator.md +202 -0
- package/kits/cursor/.cursor/skills/skill-creator/agents/grader.md +223 -0
- package/kits/cursor/.cursor/skills/skill-creator/assets/eval_review.html +146 -0
- package/kits/cursor/.cursor/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/kits/cursor/.cursor/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/kits/cursor/.cursor/skills/skill-creator/references/schemas.md +430 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/__init__.py +0 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/generate_report.py +326 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/improve_description.py +247 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/package_skill.py +136 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/run_eval.py +310 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/run_loop.py +328 -0
- package/kits/cursor/.cursor/skills/skill-creator/scripts/utils.py +47 -0
- package/kits/cursor/manifest.json +10 -0
- package/kits/github/.github/agents/code-pusher.agent.md +45 -0
- package/kits/github/.github/agents/feature-document-updater.agent.md +36 -0
- package/kits/github/.github/agents/self-reviewer.agent.md +31 -0
- package/kits/github/.github/agents/web-auto-agentic-workflow-initializer.agent.md +39 -0
- package/kits/github/.github/agents/web-auto-assisted-fix-and-runner.agent.md +35 -0
- package/kits/github/.github/agents/web-auto-chrome-devtools-selector-extractor.agent.md +33 -0
- package/kits/github/.github/agents/web-auto-coder.agent.md +32 -0
- package/kits/github/.github/agents/web-auto-fe-selector-extractor.agent.md +30 -0
- package/kits/github/.github/agents/web-auto-fix-and-runner.agent.md +34 -0
- package/kits/github/.github/agents/web-auto-lessons-learned-extractor.agent.md +33 -0
- package/kits/github/.github/agents/web-auto-playwright-mcp-selector-extractor.agent.md +34 -0
- package/kits/github/.github/agents/web-auto-source-instructions-updater.agent.md +42 -0
- package/kits/github/.github/agents/web-auto-test-cases-generator.agent.md +28 -0
- package/kits/github/.github/agents/web-auto-ticket-designer.agent.md +34 -0
- package/kits/github/.github/agents/web-auto-ticket-playbook-creator.agent.md +35 -0
- package/kits/github/.github/agents/web-auto.agent.md +382 -0
- package/kits/github/.github/skills/claude-code-subagent-creator/SKILL.md +310 -0
- package/kits/github/.github/skills/claude-code-subagent-creator/references/claude-code-subagent-configuration.md +158 -0
- package/kits/github/.github/skills/claude-code-subagent-creator/templates/subagent-profile.template.md +37 -0
- package/kits/github/.github/skills/skill-creator/LICENSE.txt +202 -0
- package/kits/github/.github/skills/skill-creator/SKILL.md +485 -0
- package/kits/github/.github/skills/skill-creator/agents/analyzer.md +274 -0
- package/kits/github/.github/skills/skill-creator/agents/comparator.md +202 -0
- package/kits/github/.github/skills/skill-creator/agents/grader.md +223 -0
- package/kits/github/.github/skills/skill-creator/assets/eval_review.html +146 -0
- package/kits/github/.github/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/kits/github/.github/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/kits/github/.github/skills/skill-creator/references/schemas.md +430 -0
- package/kits/github/.github/skills/skill-creator/scripts/__init__.py +0 -0
- package/kits/github/.github/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/kits/github/.github/skills/skill-creator/scripts/generate_report.py +326 -0
- package/kits/github/.github/skills/skill-creator/scripts/improve_description.py +247 -0
- package/kits/github/.github/skills/skill-creator/scripts/package_skill.py +136 -0
- package/kits/github/.github/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/kits/github/.github/skills/skill-creator/scripts/run_eval.py +310 -0
- package/kits/github/.github/skills/skill-creator/scripts/run_loop.py +328 -0
- package/kits/github/.github/skills/skill-creator/scripts/utils.py +47 -0
- package/kits/github/manifest.json +10 -0
- package/kits/shared/docs/ai-code-review.md +440 -0
- package/kits/shared/docs/increase-unit-test-coverage.md +77 -0
- package/kits/shared/docs/pr-review-agent.md +501 -0
- package/kits/shared/docs/self-review-agent.md +246 -0
- package/kits/shared/docs/web-auto-agentic-workflow.md +506 -0
- package/kits/shared/manifest.json +11 -0
- package/kits/shared/skills/fix-automation-tests/SKILL.md +280 -0
- package/kits/shared/skills/fix-automation-tests/scripts/fetch_pr_changes.py +300 -0
- package/kits/shared/skills/fix-automation-tests/templates/impact-report.template.md +42 -0
- package/kits/shared/skills/increase-unit-test-coverage/SKILL.md +117 -0
- package/kits/shared/skills/increase-unit-test-coverage/scripts/filter_low_coverage.py +447 -0
- package/kits/shared/skills/pr-review/SKILL.md +200 -0
- package/kits/shared/skills/pr-review/references/automation.md +62 -0
- package/kits/shared/skills/pr-review/references/backend.md +95 -0
- package/kits/shared/skills/pr-review/references/frontend.md +103 -0
- package/kits/shared/skills/pr-review/references/mobile.md +108 -0
- package/kits/shared/skills/pr-review/references/output-schema.md +130 -0
- package/kits/shared/skills/pr-review/scripts/post-review.py +1395 -0
- package/kits/shared/skills/push-code/SKILL.md +176 -0
- package/kits/shared/skills/self-review/SKILL.md +234 -0
- package/kits/shared/skills/self-review/evals/evals.json +23 -0
- package/kits/shared/skills/self-review/references/automation.md +62 -0
- package/kits/shared/skills/self-review/references/backend.md +95 -0
- package/kits/shared/skills/self-review/references/frontend.md +103 -0
- package/kits/shared/skills/self-review/references/mobile.md +108 -0
- package/kits/shared/skills/self-review/templates/issues.template.md +72 -0
- package/kits/shared/skills/update-feature-document/SKILL.md +156 -0
- package/kits/shared/skills/update-feature-document/templates/delta.template.yaml +58 -0
- package/kits/shared/skills/update-feature-document/templates/feature.template.md +25 -0
- package/kits/shared/skills/web-auto-assisted-fix-and-run/SKILL.md +130 -0
- package/kits/shared/skills/web-auto-assisted-fix-and-run/references/resolve-api-error.md +108 -0
- package/kits/shared/skills/web-auto-assisted-fix-and-run/references/resolve-selector.md +60 -0
- package/kits/shared/skills/web-auto-assisted-fix-and-run/templates/issues-resolution-report-append.template.md +54 -0
- package/kits/shared/skills/web-auto-chrome-devtools-mcp-extract-selectors/SKILL.md +284 -0
- package/kits/shared/skills/web-auto-coding/SKILL.md +152 -0
- package/kits/shared/skills/web-auto-extract-lessons-learned/SKILL.md +168 -0
- package/kits/shared/skills/web-auto-extract-lessons-learned/templates/lessons-learned.template.md +115 -0
- package/kits/shared/skills/web-auto-fe-extract-selectors/SKILL.md +282 -0
- package/kits/shared/skills/web-auto-fe-extract-selectors/evals/evals.json +23 -0
- package/kits/shared/skills/web-auto-fix-and-run-test/SKILL.md +183 -0
- package/kits/shared/skills/web-auto-fix-and-run-test/templates/issues-resolution-report.template.md +77 -0
- package/kits/shared/skills/web-auto-generate-best-practices/SKILL.md +123 -0
- package/kits/shared/skills/web-auto-generate-instructions/SKILL.md +200 -0
- package/kits/shared/skills/web-auto-generate-instructions/evals/evals.json +23 -0
- package/kits/shared/skills/web-auto-generate-instructions/references/analysis-guide.md +145 -0
- package/kits/shared/skills/web-auto-generate-instructions/templates/web-auto-instructions.template.md +184 -0
- package/kits/shared/skills/web-auto-generate-project-blueprint/SKILL.md +181 -0
- package/kits/shared/skills/web-auto-generate-project-blueprint/evals/evals.json +57 -0
- package/kits/shared/skills/web-auto-generate-project-blueprint/templates/web-auto-project-blueprint.template.md +161 -0
- package/kits/shared/skills/web-auto-playwright-mcp-extract-selectors/SKILL.md +293 -0
- package/kits/shared/skills/web-auto-test-cases/SKILL.md +138 -0
- package/kits/shared/skills/web-auto-test-cases/evals/evals.json +129 -0
- package/kits/shared/skills/web-auto-test-cases/templates/test-cases.template.md +53 -0
- package/kits/shared/skills/web-auto-ticket-design/SKILL.md +199 -0
- package/kits/shared/skills/web-auto-ticket-design/templates/ticket-design.template.md +138 -0
- package/kits/shared/skills/web-auto-ticket-playbook/SKILL.md +218 -0
- package/kits/shared/skills/web-auto-ticket-playbook/evals/evals.json +23 -0
- package/kits/shared/skills/web-auto-ticket-playbook/templates/ticket-playbook.template.md +148 -0
- package/kits/shared/skills/web-auto-update-source-instructions/SKILL.md +156 -0
- package/kits/shared/skills/web-auto-update-source-instructions/evals/evals.json +22 -0
- package/kits/shared/skills/workspace-ai-nav-creator/SKILL.md +168 -0
- package/kits/shared/skills/workspace-ai-nav-creator/templates/agents-md.template.md +112 -0
- package/kits/shared/skills/workspace-ai-nav-creator/templates/claude-md.template.md +86 -0
- 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()
|