cicaddy-github 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """cicaddy-github: GitHub Actions plugin for cicaddy AI agent framework."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,193 @@
1
+ """Configuration for cicaddy-github (GitHub-specific extension of cicaddy)."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any
6
+
7
+ from cicaddy.config.settings import CoreSettings
8
+ from pydantic import AliasChoices, Field
9
+ from pydantic_settings import SettingsConfigDict
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Settings(CoreSettings):
15
+ """Application settings with GitHub Actions platform-specific fields.
16
+
17
+ Extends CoreSettings with GitHub-specific configuration such as
18
+ tokens, repository info, event names, and other CI variables.
19
+ """
20
+
21
+ model_config = SettingsConfigDict(extra="ignore")
22
+
23
+ # GitHub configuration (uses built-in Actions variables)
24
+ github_token: str = Field(
25
+ default="",
26
+ validation_alias=AliasChoices("GITHUB_TOKEN"),
27
+ description="GitHub API token for repository access",
28
+ )
29
+ github_repository: str = Field(
30
+ default="",
31
+ validation_alias=AliasChoices("GITHUB_REPOSITORY"),
32
+ description="Repository in owner/name format",
33
+ )
34
+ github_ref: str = Field(
35
+ default="",
36
+ validation_alias=AliasChoices("GITHUB_REF"),
37
+ description="Git ref that triggered the workflow",
38
+ )
39
+ github_event_name: str = Field(
40
+ default="",
41
+ validation_alias=AliasChoices("GITHUB_EVENT_NAME"),
42
+ description="Name of the event that triggered the workflow",
43
+ )
44
+ github_sha: str = Field(
45
+ default="",
46
+ validation_alias=AliasChoices("GITHUB_SHA"),
47
+ description="Commit SHA that triggered the workflow",
48
+ )
49
+ github_run_id: str | None = Field(
50
+ default=None,
51
+ validation_alias=AliasChoices("GITHUB_RUN_ID"),
52
+ description="Unique ID for the workflow run",
53
+ )
54
+ github_pr_number: str | None = Field(
55
+ default=None,
56
+ validation_alias=AliasChoices("GITHUB_PR_NUMBER"),
57
+ description="Pull request number",
58
+ )
59
+ post_pr_comment: bool = Field(
60
+ default=False,
61
+ validation_alias=AliasChoices("POST_PR_COMMENT"),
62
+ description="Whether to post analysis results as PR comment",
63
+ )
64
+
65
+
66
+ def load_settings() -> Settings:
67
+ """Load settings from environment variables with GitHub Actions defaults."""
68
+
69
+ # Handle MCP_SERVERS_CONFIG - default to empty array if missing
70
+ current_mcp_config = os.getenv("MCP_SERVERS_CONFIG")
71
+ if not current_mcp_config:
72
+ os.environ["MCP_SERVERS_CONFIG"] = "[]"
73
+
74
+ # Explicitly pass environment variables to work around Pydantic env reading issues
75
+ env_data: dict[str, Any] = {}
76
+
77
+ # GitHub configuration
78
+ if os.getenv("GITHUB_TOKEN"):
79
+ env_data["github_token"] = os.getenv("GITHUB_TOKEN")
80
+ if os.getenv("GITHUB_REPOSITORY"):
81
+ env_data["github_repository"] = os.getenv("GITHUB_REPOSITORY")
82
+ if os.getenv("GITHUB_REF"):
83
+ env_data["github_ref"] = os.getenv("GITHUB_REF")
84
+ if os.getenv("GITHUB_EVENT_NAME"):
85
+ env_data["github_event_name"] = os.getenv("GITHUB_EVENT_NAME")
86
+ if os.getenv("GITHUB_SHA"):
87
+ env_data["github_sha"] = os.getenv("GITHUB_SHA")
88
+ if os.getenv("GITHUB_RUN_ID"):
89
+ env_data["github_run_id"] = os.getenv("GITHUB_RUN_ID")
90
+ if os.getenv("GITHUB_PR_NUMBER"):
91
+ env_data["github_pr_number"] = os.getenv("GITHUB_PR_NUMBER")
92
+
93
+ # Post PR comment flag
94
+ post_pr = os.getenv("POST_PR_COMMENT", "").strip()
95
+ if post_pr:
96
+ env_data["post_pr_comment"] = post_pr.lower() in ("true", "1", "yes")
97
+
98
+ # AI provider configuration
99
+ if os.getenv("AI_PROVIDER"):
100
+ env_data["ai_provider"] = os.getenv("AI_PROVIDER")
101
+ if os.getenv("AI_MODEL"):
102
+ env_data["ai_model"] = os.getenv("AI_MODEL")
103
+
104
+ # AI API keys
105
+ if os.getenv("GEMINI_API_KEY"):
106
+ env_data["gemini_api_key"] = os.getenv("GEMINI_API_KEY")
107
+ if os.getenv("OPENAI_API_KEY"):
108
+ env_data["openai_api_key"] = os.getenv("OPENAI_API_KEY")
109
+ if os.getenv("ANTHROPIC_API_KEY"):
110
+ env_data["anthropic_api_key"] = os.getenv("ANTHROPIC_API_KEY")
111
+
112
+ # MCP server configuration
113
+ if os.getenv("MCP_SERVERS_CONFIG"):
114
+ env_data["mcp_servers_config"] = os.getenv("MCP_SERVERS_CONFIG")
115
+
116
+ # Slack configuration
117
+ if os.getenv("SLACK_WEBHOOK_URL"):
118
+ env_data["slack_webhook_url"] = os.getenv("SLACK_WEBHOOK_URL")
119
+
120
+ # Agent configuration
121
+ if os.getenv("AI_TASK_PROMPT"):
122
+ env_data["review_prompt"] = os.getenv("AI_TASK_PROMPT")
123
+ if os.getenv("AI_TASK_FILE"):
124
+ env_data["task_file"] = os.getenv("AI_TASK_FILE")
125
+ if os.getenv("ANALYSIS_FOCUS"):
126
+ env_data["analysis_focus"] = os.getenv("ANALYSIS_FOCUS")
127
+
128
+ # Git configuration
129
+ git_diff_context = os.getenv("GIT_DIFF_CONTEXT_LINES")
130
+ if git_diff_context:
131
+ env_data["git_diff_context_lines"] = int(git_diff_context)
132
+
133
+ # Local tools configuration
134
+ if os.getenv("ENABLE_LOCAL_TOOLS", "").strip():
135
+ env_data["enable_local_tools"] = os.getenv("ENABLE_LOCAL_TOOLS", "").lower().strip() in (
136
+ "true",
137
+ "1",
138
+ "yes",
139
+ )
140
+ if os.getenv("LOCAL_TOOLS_WORKING_DIR"):
141
+ env_data["local_tools_working_dir"] = os.getenv("LOCAL_TOOLS_WORKING_DIR")
142
+
143
+ # Execution configuration
144
+ max_exec_env = os.getenv("MAX_EXECUTION_TIME")
145
+ if max_exec_env == "":
146
+ os.environ.pop("MAX_EXECUTION_TIME", None)
147
+ elif max_exec_env:
148
+ try:
149
+ max_exec_time = int(max_exec_env)
150
+ if 60 <= max_exec_time <= 7200:
151
+ env_data["max_execution_time"] = max_exec_time
152
+ else:
153
+ logger.warning(
154
+ f"MAX_EXECUTION_TIME {max_exec_time} out of range [60, 7200], using default 600"
155
+ )
156
+ os.environ.pop("MAX_EXECUTION_TIME", None)
157
+ env_data["max_execution_time"] = 600
158
+ except ValueError:
159
+ logger.warning(f"Invalid MAX_EXECUTION_TIME value '{max_exec_env}', using default 600")
160
+ os.environ.pop("MAX_EXECUTION_TIME", None)
161
+ env_data["max_execution_time"] = 600
162
+
163
+ context_safety_env = os.getenv("CONTEXT_SAFETY_FACTOR")
164
+ if context_safety_env == "":
165
+ os.environ.pop("CONTEXT_SAFETY_FACTOR", None)
166
+ elif context_safety_env:
167
+ try:
168
+ safety_factor = float(context_safety_env)
169
+ if 0.5 <= safety_factor <= 0.97:
170
+ env_data["context_safety_factor"] = safety_factor
171
+ else:
172
+ logger.warning(
173
+ f"CONTEXT_SAFETY_FACTOR {safety_factor} out of range [0.5, 0.97], "
174
+ "using default 0.85"
175
+ )
176
+ os.environ.pop("CONTEXT_SAFETY_FACTOR", None)
177
+ env_data["context_safety_factor"] = 0.85
178
+ except ValueError:
179
+ logger.warning(
180
+ f"Invalid CONTEXT_SAFETY_FACTOR value '{context_safety_env}', using default 0.85"
181
+ )
182
+ os.environ.pop("CONTEXT_SAFETY_FACTOR", None)
183
+ env_data["context_safety_factor"] = 0.85
184
+
185
+ # Logging configuration
186
+ if os.getenv("LOG_LEVEL"):
187
+ env_data["log_level"] = os.getenv("LOG_LEVEL")
188
+
189
+ # Report configuration
190
+ if os.getenv("REPORT_TEMPLATE"):
191
+ env_data["report_template"] = os.getenv("REPORT_TEMPLATE")
192
+
193
+ return Settings(**env_data)
File without changes
@@ -0,0 +1,268 @@
1
+ """GitHub AI Agents for PR review and task execution."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from cicaddy.agent.base import BaseAIAgent
7
+ from cicaddy.tools import ToolRegistry
8
+ from cicaddy.utils.logger import get_logger
9
+
10
+ from cicaddy_github.config.settings import Settings
11
+ from cicaddy_github.github_integration.analyzer import GitHubAnalyzer
12
+ from cicaddy_github.github_integration.tools import get_all_tools
13
+ from cicaddy_github.security.leak_detector import LeakDetector
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ BOT_COMMENT_MARKER_PR_REVIEW = "<!-- cicaddy-action:pr-review -->"
18
+
19
+
20
+ class GitHubTaskAgent(BaseAIAgent):
21
+ """AI Agent for scheduled tasks and changelog generation."""
22
+
23
+ def __init__(self, settings: Settings | None = None):
24
+ super().__init__(settings)
25
+ self.leak_detector = LeakDetector()
26
+
27
+ async def _setup_local_tools(self):
28
+ """Setup local tools including git operations for changelog."""
29
+ await super()._setup_local_tools()
30
+ if self.local_tool_registry is None:
31
+ self.local_tool_registry = ToolRegistry(server_name="local")
32
+ # Register git tools as local tools
33
+ for t in get_all_tools():
34
+ self.local_tool_registry.register(t)
35
+ logger.info(f"Registered git tools: {self.local_tool_registry.list_tool_names()}")
36
+
37
+ async def _setup_platform_integration(self):
38
+ """Setup GitHub analyzer for API access."""
39
+ token = os.getenv("GITHUB_TOKEN", "")
40
+ repository = os.getenv("GITHUB_REPOSITORY", "")
41
+ working_dir = getattr(self.settings, "local_tools_working_dir", None) or "."
42
+
43
+ if token and repository:
44
+ try:
45
+ self.platform_analyzer = GitHubAnalyzer(
46
+ token=token, repository=repository, working_dir=working_dir
47
+ )
48
+ logger.info(f"GitHub analyzer initialized for {repository}")
49
+ except Exception as e:
50
+ logger.warning(f"Failed to initialize GitHub analyzer: {e}")
51
+ else:
52
+ logger.debug("GitHub analyzer not initialized (missing token or repository)")
53
+
54
+ async def get_analysis_context(self) -> dict[str, Any]:
55
+ """Get task-specific context."""
56
+ return {
57
+ "analysis_type": "task",
58
+ "repository": os.getenv("GITHUB_REPOSITORY", ""),
59
+ "ref": os.getenv("GITHUB_REF", ""),
60
+ "sha": os.getenv("GITHUB_SHA", ""),
61
+ }
62
+
63
+ def build_analysis_prompt(self, context: dict[str, Any]) -> str:
64
+ """Build analysis prompt for task execution.
65
+
66
+ Supports DSPy YAML task definitions via AI_TASK_FILE.
67
+ Falls back to inline prompt or default.
68
+ """
69
+ task_file = os.getenv("AI_TASK_FILE")
70
+ if task_file:
71
+ dspy_prompt = self.build_dspy_prompt(task_file, context)
72
+ if dspy_prompt:
73
+ return dspy_prompt
74
+
75
+ task_prompt = os.getenv("AI_TASK_PROMPT", "")
76
+ if task_prompt:
77
+ return task_prompt
78
+
79
+ return (
80
+ "Analyze the repository and provide a summary of recent changes. "
81
+ "Use the available git tools to gather information."
82
+ )
83
+
84
+ async def send_notifications(self, report: dict[str, Any], analysis_result: dict[str, Any]):
85
+ """Send notifications via Slack (sanitized)."""
86
+ # Sanitize outputs before sending
87
+ if "ai_analysis" in analysis_result:
88
+ analysis_result["ai_analysis"] = self.leak_detector.sanitize_text(
89
+ analysis_result["ai_analysis"]
90
+ )
91
+ await super().send_notifications(report, analysis_result)
92
+
93
+ def get_session_id(self) -> str:
94
+ """Get unique session ID for this task."""
95
+ run_id = os.getenv("GITHUB_RUN_ID", "unknown")
96
+ return f"task_{run_id}"
97
+
98
+
99
+ class GitHubPRAgent(BaseAIAgent):
100
+ """AI Agent specialized for pull request analysis and code review."""
101
+
102
+ def __init__(self, settings: Settings | None = None):
103
+ super().__init__(settings)
104
+ self.pr_number = settings.github_pr_number if settings else os.getenv("GITHUB_PR_NUMBER")
105
+ self.leak_detector = LeakDetector()
106
+
107
+ async def _setup_local_tools(self):
108
+ """Setup local tools for PR review."""
109
+ await super()._setup_local_tools()
110
+ if self.local_tool_registry is None:
111
+ self.local_tool_registry = ToolRegistry(server_name="local")
112
+ for t in get_all_tools():
113
+ self.local_tool_registry.register(t)
114
+
115
+ async def _setup_platform_integration(self):
116
+ """Setup GitHub analyzer for PR access."""
117
+ token = os.getenv("GITHUB_TOKEN", "")
118
+ repository = os.getenv("GITHUB_REPOSITORY", "")
119
+ working_dir = getattr(self.settings, "local_tools_working_dir", None) or "."
120
+
121
+ if token and repository:
122
+ try:
123
+ self.platform_analyzer = GitHubAnalyzer(
124
+ token=token, repository=repository, working_dir=working_dir
125
+ )
126
+ logger.info(f"GitHub analyzer initialized for {repository}")
127
+ except Exception as e:
128
+ logger.warning(f"Failed to initialize GitHub analyzer: {e}")
129
+
130
+ async def get_diff_content(self) -> str:
131
+ """Get pull request diff content."""
132
+ if not self.pr_number:
133
+ raise ValueError("No PR number provided")
134
+ if not self.platform_analyzer:
135
+ raise ValueError("GitHub analyzer not initialized")
136
+
137
+ return await self.platform_analyzer.get_pull_request_diff(
138
+ int(self.pr_number),
139
+ context_lines=getattr(self.settings, "git_diff_context_lines", 3),
140
+ )
141
+
142
+ async def get_review_context(self) -> dict[str, Any]:
143
+ """Get pull request specific context."""
144
+ if not self.pr_number:
145
+ raise ValueError("No PR number provided")
146
+ if not self.platform_analyzer:
147
+ raise ValueError("GitHub analyzer not initialized")
148
+
149
+ pr_data = await self.platform_analyzer.get_pull_request_data(int(self.pr_number))
150
+ return {
151
+ "pull_request": pr_data,
152
+ "analysis_type": "pull_request",
153
+ "pr_number": self.pr_number,
154
+ }
155
+
156
+ async def get_analysis_context(self) -> dict[str, Any]:
157
+ """Get analysis context including PR data and diff."""
158
+ context = await self.get_review_context()
159
+
160
+ # Get diff content
161
+ diff_content = await self.get_diff_content()
162
+ context["diff"] = diff_content
163
+
164
+ # Add repository info
165
+ context["repository"] = os.getenv("GITHUB_REPOSITORY", "")
166
+
167
+ return context
168
+
169
+ def build_analysis_prompt(self, context: dict[str, Any]) -> str:
170
+ """Build analysis prompt for PR code review.
171
+
172
+ Supports DSPy YAML task definitions via AI_TASK_FILE.
173
+ Falls back to built-in prompt.
174
+ """
175
+ task_file = os.getenv("AI_TASK_FILE")
176
+ if task_file:
177
+ pr_context = self._prepare_dspy_context(context)
178
+ dspy_prompt = self.build_dspy_prompt(task_file, pr_context)
179
+ if dspy_prompt:
180
+ return dspy_prompt
181
+
182
+ pr_data = context["pull_request"]
183
+ diff_content = context["diff"]
184
+
185
+ return f"""You are an AI agent performing pull request code review.
186
+
187
+ Repository: {context.get("repository", "Unknown")}
188
+ Pull Request: {pr_data["title"]}
189
+ Description: {pr_data.get("description", "No description")}
190
+ Author: {pr_data.get("author", {}).get("name", "Unknown")}
191
+ Target Branch: {pr_data.get("target_branch", "Unknown")}
192
+ Source Branch: {pr_data.get("source_branch", "Unknown")}
193
+
194
+ Code Changes:
195
+ ```diff
196
+ {diff_content}
197
+ ```
198
+
199
+ Instructions:
200
+ 1. Analyze the code changes thoroughly
201
+ 2. Identify bugs, security issues, and potential problems
202
+ 3. Suggest improvements for code quality and maintainability
203
+ 4. Provide actionable, specific feedback
204
+
205
+ Please provide your comprehensive analysis in markdown format.
206
+ """
207
+
208
+ def _prepare_dspy_context(self, context: dict[str, Any]) -> dict[str, Any]:
209
+ """Prepare context with PR-specific data for DSPy prompt building."""
210
+ pr_context = context.copy()
211
+ pr_data = context.get("pull_request", {})
212
+ pr_context["pr_title"] = pr_data.get("title", "Unknown")
213
+ pr_context["pr_description"] = pr_data.get("description", "")
214
+ pr_context["pr_author"] = pr_data.get("author", {}).get("name", "Unknown")
215
+ pr_context["target_branch"] = pr_data.get("target_branch", "Unknown")
216
+ pr_context["source_branch"] = pr_data.get("source_branch", "Unknown")
217
+ pr_context["pr_number"] = self.pr_number
218
+ pr_context["diff_content"] = context.get("diff", "")
219
+ return pr_context
220
+
221
+ async def send_notifications(self, report: dict[str, Any], analysis_result: dict[str, Any]):
222
+ """Send notifications via PR comment and Slack."""
223
+ # Sanitize outputs
224
+ if "ai_analysis" in analysis_result:
225
+ analysis_result["ai_analysis"] = self.leak_detector.sanitize_text(
226
+ analysis_result["ai_analysis"]
227
+ )
228
+
229
+ # Post PR comment if enabled (updates existing bot comment in-place)
230
+ post_comment = getattr(self.settings, "post_pr_comment", False)
231
+ if post_comment and self.platform_analyzer and self.pr_number:
232
+ try:
233
+ comment = self._format_pr_comment(analysis_result)
234
+ await self.platform_analyzer.post_pr_comment(
235
+ int(self.pr_number), comment, comment_marker=BOT_COMMENT_MARKER_PR_REVIEW
236
+ )
237
+ logger.info(f"Posted analysis to PR #{self.pr_number}")
238
+ except Exception as e:
239
+ logger.error(
240
+ f"Failed to post PR comment: {self.leak_detector.sanitize_text(str(e))}"
241
+ )
242
+ logger.debug("PR comment post traceback:", exc_info=True)
243
+
244
+ # Send Slack notification using parent class
245
+ await super().send_notifications(report, analysis_result)
246
+
247
+ def _format_pr_comment(self, analysis_result: dict[str, Any]) -> str:
248
+ """Format analysis results as a PR comment.
249
+
250
+ The hidden marker is prepended so the bot can find and update its own
251
+ comment later. No heading is injected — the AI analysis output already
252
+ contains its own structure.
253
+ """
254
+ comment = f"{BOT_COMMENT_MARKER_PR_REVIEW}\n"
255
+
256
+ if "ai_analysis" in analysis_result:
257
+ comment += analysis_result["ai_analysis"] + "\n"
258
+
259
+ comment += (
260
+ "\n<!-- cicaddy-footer -->\n---\n"
261
+ "*Generated with [cicaddy-action]"
262
+ "(https://github.com/redhat-community-ai-tools/cicaddy-action)*"
263
+ )
264
+ return comment
265
+
266
+ def get_session_id(self) -> str:
267
+ """Get unique session ID for this PR analysis."""
268
+ return f"pr_{self.pr_number or 'unknown'}"