sugarai 3.1.1.dev3__tar.gz → 3.1.1.dev5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sugarai-3.1.1.dev3/sugarai.egg-info → sugarai-3.1.1.dev5}/PKG-INFO +1 -1
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/pyproject.toml +1 -1
- sugarai-3.1.1.dev5/sugar/config/__init__.py +5 -0
- sugarai-3.1.1.dev5/sugar/config/issue_responder_config.py +143 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/github_watcher.py +154 -2
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/agent_sdk_executor.py +80 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/main.py +30 -1
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/issue_responder.py +10 -6
- sugarai-3.1.1.dev5/sugar/storage/__init__.py +5 -0
- sugarai-3.1.1.dev5/sugar/storage/issue_response_manager.py +258 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5/sugarai.egg-info}/PKG-INFO +1 -1
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/SOURCES.txt +4 -0
- sugarai-3.1.1.dev5/tests/test_issue_response_integration.py +1042 -0
- sugarai-3.1.1.dev3/sugar/utils/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/LICENSE +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/README.md +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/setup.cfg +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/__version__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/base.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/hooks.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/subagent_manager.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/tools.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/api_keys.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/tiers.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/usage.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/core/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/core/loop.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/code_quality.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/error_monitor.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/test_coverage.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/base.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/claude_wrapper.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/structured_request.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/integrations/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/integrations/github.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/adaptive_scheduler.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/feedback_processor.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/mcp/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/mcp/server.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/agent_router.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/task_orchestrator.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/base.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/default.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/coordinator.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/diff_validator.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/evidence.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/failure_handler.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/functional_verifier.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/preflight_checks.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/success_criteria.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/test_validator.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/truth_enforcer.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/storage/task_type_manager.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/storage/work_queue.py +0 -0
- {sugarai-3.1.1.dev3/sugar/storage → sugarai-3.1.1.dev5/sugar/utils}/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/utils/git_operations.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/utils/toon_encoder.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/workflow/__init__.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/workflow/orchestrator.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/dependency_links.txt +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/entry_points.txt +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/requires.txt +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/top_level.txt +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_agent.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_billing.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_cli.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_core_loop.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_goose_integration.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_hold_functionality.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_hooks.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_issue_responder_cli.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_orchestration.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_profiles.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_quality_gates.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_storage.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_subagent_manager.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_task_types.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_toon_encoder.py +0 -0
- {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_v3_benchmarks.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sugarai"
|
|
7
|
-
version = "3.1.1.
|
|
7
|
+
version = "3.1.1.dev5"
|
|
8
8
|
description = "Sugar 🍰 - A dev team that never stops. Delegate full tasks to AI in the background."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Configuration loader for the Issue Responder feature."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class IssueResponderConfig:
|
|
11
|
+
"""Configuration for AI-powered automatic issue responses.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
enabled: Enable automatic issue responses
|
|
15
|
+
auto_post_threshold: Minimum confidence score (0.0-1.0) to auto-post responses
|
|
16
|
+
max_response_length: Maximum characters per response
|
|
17
|
+
response_delay_seconds: Delay before posting (0 = immediate)
|
|
18
|
+
rate_limit_per_hour: Maximum responses per hour (0 = unlimited)
|
|
19
|
+
respond_to_labels: Only respond to issues with these labels (empty = all)
|
|
20
|
+
skip_labels: Never respond to issues with these labels
|
|
21
|
+
skip_bot_issues: Skip issues created by bots
|
|
22
|
+
handle_follow_ups: How to handle follow-up comments - "ignore" | "queue" | "auto"
|
|
23
|
+
model: Claude model to use for generating responses
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
enabled: bool = False
|
|
27
|
+
auto_post_threshold: float = 0.8
|
|
28
|
+
max_response_length: int = 2000
|
|
29
|
+
response_delay_seconds: int = 0
|
|
30
|
+
rate_limit_per_hour: int = 10
|
|
31
|
+
respond_to_labels: List[str] = field(default_factory=list)
|
|
32
|
+
skip_labels: List[str] = field(
|
|
33
|
+
default_factory=lambda: ["wontfix", "duplicate", "stale"]
|
|
34
|
+
)
|
|
35
|
+
skip_bot_issues: bool = True
|
|
36
|
+
handle_follow_ups: str = "ignore" # "ignore" | "queue" | "auto"
|
|
37
|
+
model: str = "claude-sonnet-4-20250514"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict) -> "IssueResponderConfig":
|
|
41
|
+
"""Create IssueResponderConfig from a dictionary.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
data: Dictionary containing configuration values
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
IssueResponderConfig instance
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> config_data = {
|
|
51
|
+
... "enabled": True,
|
|
52
|
+
... "auto_post_threshold": 0.9,
|
|
53
|
+
... "respond_to_labels": ["question", "help-wanted"]
|
|
54
|
+
... }
|
|
55
|
+
>>> config = IssueResponderConfig.from_dict(config_data)
|
|
56
|
+
"""
|
|
57
|
+
# Filter to only include known fields
|
|
58
|
+
valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
|
59
|
+
filtered_data = {k: v for k, v in data.items() if k in valid_fields}
|
|
60
|
+
return cls(**filtered_data)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def load_from_file(cls, config_path: str) -> "IssueResponderConfig":
|
|
64
|
+
"""Load IssueResponderConfig from a YAML configuration file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_path: Path to the YAML configuration file
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
IssueResponderConfig instance with values from the file
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
FileNotFoundError: If the config file doesn't exist
|
|
74
|
+
KeyError: If the 'issue_responder' section is missing
|
|
75
|
+
yaml.YAMLError: If the YAML file is malformed
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> config = IssueResponderConfig.load_from_file(".sugar/config.yaml")
|
|
79
|
+
"""
|
|
80
|
+
config_file = Path(config_path)
|
|
81
|
+
|
|
82
|
+
if not config_file.exists():
|
|
83
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
84
|
+
|
|
85
|
+
with open(config_file, "r") as f:
|
|
86
|
+
config_data = yaml.safe_load(f)
|
|
87
|
+
|
|
88
|
+
# Navigate to the issue_responder section
|
|
89
|
+
if "sugar" not in config_data:
|
|
90
|
+
raise KeyError("Configuration file missing 'sugar' section")
|
|
91
|
+
|
|
92
|
+
if "issue_responder" not in config_data["sugar"]:
|
|
93
|
+
raise KeyError("Configuration file missing 'sugar.issue_responder' section")
|
|
94
|
+
|
|
95
|
+
issue_responder_data = config_data["sugar"]["issue_responder"]
|
|
96
|
+
return cls.from_dict(issue_responder_data)
|
|
97
|
+
|
|
98
|
+
def validate(self) -> List[str]:
|
|
99
|
+
"""Validate configuration values and return list of errors.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of validation error messages (empty if valid)
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> config = IssueResponderConfig(auto_post_threshold=1.5)
|
|
106
|
+
>>> errors = config.validate()
|
|
107
|
+
>>> if errors:
|
|
108
|
+
... print("Invalid configuration:", errors)
|
|
109
|
+
"""
|
|
110
|
+
errors = []
|
|
111
|
+
|
|
112
|
+
# Validate auto_post_threshold
|
|
113
|
+
if not 0.0 <= self.auto_post_threshold <= 1.0:
|
|
114
|
+
errors.append(
|
|
115
|
+
f"auto_post_threshold must be between 0.0 and 1.0, got {self.auto_post_threshold}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Validate max_response_length
|
|
119
|
+
if self.max_response_length <= 0:
|
|
120
|
+
errors.append(
|
|
121
|
+
f"max_response_length must be positive, got {self.max_response_length}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Validate response_delay_seconds
|
|
125
|
+
if self.response_delay_seconds < 0:
|
|
126
|
+
errors.append(
|
|
127
|
+
f"response_delay_seconds must be non-negative, got {self.response_delay_seconds}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Validate rate_limit_per_hour
|
|
131
|
+
if self.rate_limit_per_hour < 0:
|
|
132
|
+
errors.append(
|
|
133
|
+
f"rate_limit_per_hour must be non-negative, got {self.rate_limit_per_hour}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Validate handle_follow_ups
|
|
137
|
+
valid_follow_up_values = {"ignore", "queue", "auto"}
|
|
138
|
+
if self.handle_follow_ups not in valid_follow_up_values:
|
|
139
|
+
errors.append(
|
|
140
|
+
f"handle_follow_ups must be one of {valid_follow_up_values}, got '{self.handle_follow_ups}'"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return errors
|
|
@@ -11,6 +11,9 @@ import os
|
|
|
11
11
|
from datetime import datetime, timedelta, timezone
|
|
12
12
|
from typing import List, Dict, Any, Optional
|
|
13
13
|
|
|
14
|
+
from ..storage import IssueResponseManager
|
|
15
|
+
from ..config import IssueResponderConfig
|
|
16
|
+
|
|
14
17
|
# Optional PyGithub import
|
|
15
18
|
try:
|
|
16
19
|
from github import Github, GithubException
|
|
@@ -25,11 +28,20 @@ logger = logging.getLogger(__name__)
|
|
|
25
28
|
class GitHubWatcher:
|
|
26
29
|
"""Monitor GitHub repository for issues and pull requests"""
|
|
27
30
|
|
|
28
|
-
def __init__(
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
config: dict,
|
|
34
|
+
issue_responder_config: Optional[IssueResponderConfig] = None,
|
|
35
|
+
):
|
|
29
36
|
self.config = config
|
|
30
37
|
self.enabled = config.get("enabled", False)
|
|
31
38
|
self.repo_name = config.get("repo", "")
|
|
32
39
|
self.auth_method = config.get("auth_method", "auto")
|
|
40
|
+
self.issue_responder_config = issue_responder_config
|
|
41
|
+
|
|
42
|
+
# Initialize IssueResponseManager
|
|
43
|
+
db_path = config.get("db_path", ".sugar/sugar.db")
|
|
44
|
+
self.issue_response_manager = IssueResponseManager(db_path)
|
|
33
45
|
|
|
34
46
|
if not self.enabled:
|
|
35
47
|
return
|
|
@@ -156,6 +168,20 @@ class GitHubWatcher:
|
|
|
156
168
|
work_items = []
|
|
157
169
|
|
|
158
170
|
try:
|
|
171
|
+
# Initialize issue response manager
|
|
172
|
+
await self.issue_response_manager.initialize()
|
|
173
|
+
|
|
174
|
+
# Load issue responder config if not provided in constructor
|
|
175
|
+
responder_config = self.issue_responder_config
|
|
176
|
+
if responder_config is None:
|
|
177
|
+
try:
|
|
178
|
+
responder_config = IssueResponderConfig.load_from_file(
|
|
179
|
+
".sugar/config.yaml"
|
|
180
|
+
)
|
|
181
|
+
except (FileNotFoundError, KeyError):
|
|
182
|
+
# If config doesn't exist, use default (disabled)
|
|
183
|
+
responder_config = IssueResponderConfig()
|
|
184
|
+
|
|
159
185
|
gh_command = self.config.get("gh_cli", {}).get("command", "gh")
|
|
160
186
|
issue_labels = self.config.get("issue_labels", ["bug", "enhancement"])
|
|
161
187
|
|
|
@@ -163,6 +189,7 @@ class GitHubWatcher:
|
|
|
163
189
|
self._log_label_filtering_mode(issue_labels)
|
|
164
190
|
|
|
165
191
|
# Get all open issues first (we'll filter by labels after)
|
|
192
|
+
# Include author field for bot detection
|
|
166
193
|
cmd = [
|
|
167
194
|
gh_command,
|
|
168
195
|
"issue",
|
|
@@ -174,7 +201,7 @@ class GitHubWatcher:
|
|
|
174
201
|
"--limit",
|
|
175
202
|
"50", # Get more issues to filter from
|
|
176
203
|
"--json",
|
|
177
|
-
"number,title,body,labels,assignees,comments,createdAt,updatedAt,url",
|
|
204
|
+
"number,title,body,labels,assignees,comments,createdAt,updatedAt,url,author",
|
|
178
205
|
]
|
|
179
206
|
|
|
180
207
|
# Note: We don't use --label flag here because it uses AND logic
|
|
@@ -211,6 +238,49 @@ class GitHubWatcher:
|
|
|
211
238
|
)
|
|
212
239
|
|
|
213
240
|
for issue in filtered_issues:
|
|
241
|
+
# Check if issue responder is enabled and should create response work item
|
|
242
|
+
if responder_config.enabled and self._should_respond_to_issue(
|
|
243
|
+
issue, responder_config
|
|
244
|
+
):
|
|
245
|
+
# Check if we've already responded to this issue
|
|
246
|
+
has_responded = await self.issue_response_manager.has_responded(
|
|
247
|
+
self.repo_name, issue["number"], "initial"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if not has_responded:
|
|
251
|
+
# Create issue response work item
|
|
252
|
+
response_work_item = {
|
|
253
|
+
"type": "issue_response",
|
|
254
|
+
"title": f"Respond to issue #{issue['number']}: {issue['title']}",
|
|
255
|
+
"source_file": f"github://issues/{issue['number']}",
|
|
256
|
+
"priority": 2, # Medium priority
|
|
257
|
+
"context": {
|
|
258
|
+
"github_issue": {
|
|
259
|
+
"number": issue["number"],
|
|
260
|
+
"title": issue["title"],
|
|
261
|
+
"body": issue.get("body", ""),
|
|
262
|
+
"user": {
|
|
263
|
+
"login": issue.get("author", {}).get(
|
|
264
|
+
"login", "unknown"
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
"labels": [
|
|
268
|
+
label["name"]
|
|
269
|
+
for label in issue.get("labels", [])
|
|
270
|
+
],
|
|
271
|
+
"created_at": issue["createdAt"],
|
|
272
|
+
},
|
|
273
|
+
"repo": self.repo_name,
|
|
274
|
+
"response_type": "initial",
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
work_items.append(response_work_item)
|
|
278
|
+
logger.debug(
|
|
279
|
+
f"Created issue_response work item for #{issue['number']}"
|
|
280
|
+
)
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Create standard work item if not creating a response item
|
|
214
284
|
work_item = self._create_work_item_from_issue_data(issue)
|
|
215
285
|
if work_item:
|
|
216
286
|
work_items.append(work_item)
|
|
@@ -225,6 +295,20 @@ class GitHubWatcher:
|
|
|
225
295
|
work_items = []
|
|
226
296
|
|
|
227
297
|
try:
|
|
298
|
+
# Initialize issue response manager
|
|
299
|
+
await self.issue_response_manager.initialize()
|
|
300
|
+
|
|
301
|
+
# Load issue responder config if not provided in constructor
|
|
302
|
+
responder_config = self.issue_responder_config
|
|
303
|
+
if responder_config is None:
|
|
304
|
+
try:
|
|
305
|
+
responder_config = IssueResponderConfig.load_from_file(
|
|
306
|
+
".sugar/config.yaml"
|
|
307
|
+
)
|
|
308
|
+
except (FileNotFoundError, KeyError):
|
|
309
|
+
# If config doesn't exist, use default (disabled)
|
|
310
|
+
responder_config = IssueResponderConfig()
|
|
311
|
+
|
|
228
312
|
# Get recent issues
|
|
229
313
|
since = datetime.now(timezone.utc) - timedelta(days=7)
|
|
230
314
|
repo = self.github.get_repo(self.repo_name)
|
|
@@ -263,8 +347,50 @@ class GitHubWatcher:
|
|
|
263
347
|
"createdAt": issue.created_at.isoformat(),
|
|
264
348
|
"updatedAt": issue.updated_at.isoformat(),
|
|
265
349
|
"url": issue.html_url,
|
|
350
|
+
"author": {"login": issue.user.login},
|
|
266
351
|
}
|
|
267
352
|
|
|
353
|
+
# Check if issue responder is enabled and should create response work item
|
|
354
|
+
if responder_config.enabled and self._should_respond_to_issue(
|
|
355
|
+
issue_data, responder_config
|
|
356
|
+
):
|
|
357
|
+
# Check if we've already responded to this issue
|
|
358
|
+
has_responded = await self.issue_response_manager.has_responded(
|
|
359
|
+
self.repo_name, issue.number, "initial"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if not has_responded:
|
|
363
|
+
# Create issue response work item
|
|
364
|
+
response_work_item = {
|
|
365
|
+
"type": "issue_response",
|
|
366
|
+
"title": f"Respond to issue #{issue.number}: {issue.title}",
|
|
367
|
+
"source_file": f"github://issues/{issue.number}",
|
|
368
|
+
"priority": 2, # Medium priority
|
|
369
|
+
"context": {
|
|
370
|
+
"github_issue": {
|
|
371
|
+
"number": issue.number,
|
|
372
|
+
"title": issue.title,
|
|
373
|
+
"body": issue.body or "",
|
|
374
|
+
"user": {"login": issue.user.login},
|
|
375
|
+
"labels": [label.name for label in issue.labels],
|
|
376
|
+
"created_at": issue.created_at.isoformat(),
|
|
377
|
+
},
|
|
378
|
+
"repo": self.repo_name,
|
|
379
|
+
"response_type": "initial",
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
work_items.append(response_work_item)
|
|
383
|
+
logger.debug(
|
|
384
|
+
f"Created issue_response work item for #{issue.number}"
|
|
385
|
+
)
|
|
386
|
+
processed_count += 1
|
|
387
|
+
|
|
388
|
+
# Limit to 10 issues after filtering
|
|
389
|
+
if processed_count >= 10:
|
|
390
|
+
break
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
# Create standard work item if not creating a response item
|
|
268
394
|
work_item = self._create_work_item_from_issue_data(issue_data)
|
|
269
395
|
if work_item:
|
|
270
396
|
work_items.append(work_item)
|
|
@@ -552,6 +678,32 @@ class GitHubWatcher:
|
|
|
552
678
|
logger.error(f"Error using PyGithub to assign: {e}")
|
|
553
679
|
return False
|
|
554
680
|
|
|
681
|
+
def _should_respond_to_issue(
|
|
682
|
+
self, issue: dict, config: IssueResponderConfig
|
|
683
|
+
) -> bool:
|
|
684
|
+
"""Check if we should create a response work item for this issue"""
|
|
685
|
+
# Check bot issues
|
|
686
|
+
if config.skip_bot_issues:
|
|
687
|
+
author = issue.get("author", {}).get("login", "")
|
|
688
|
+
if author.endswith("[bot]") or "[bot]" in author:
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
# Check skip labels
|
|
692
|
+
issue_labels = [l.get("name", "") for l in issue.get("labels", [])]
|
|
693
|
+
for skip_label in config.skip_labels:
|
|
694
|
+
if skip_label in issue_labels:
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
# Check respond_to_labels (if not empty, issue must have one of these)
|
|
698
|
+
if config.respond_to_labels:
|
|
699
|
+
has_matching_label = any(
|
|
700
|
+
label in issue_labels for label in config.respond_to_labels
|
|
701
|
+
)
|
|
702
|
+
if not has_matching_label:
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
return True
|
|
706
|
+
|
|
555
707
|
def _should_include_issue_by_labels(
|
|
556
708
|
self, issue_labels: list, config_labels: list, original_config: list
|
|
557
709
|
) -> bool:
|
|
@@ -16,6 +16,10 @@ from typing import Any, Dict, List, Optional
|
|
|
16
16
|
|
|
17
17
|
from .base import BaseExecutor, ExecutionResult
|
|
18
18
|
from ..agent import SugarAgent, SugarAgentConfig
|
|
19
|
+
from ..storage import IssueResponseManager
|
|
20
|
+
from ..profiles import IssueResponderProfile
|
|
21
|
+
from ..config import IssueResponderConfig
|
|
22
|
+
from ..integrations import GitHubClient
|
|
19
23
|
|
|
20
24
|
logger = logging.getLogger(__name__)
|
|
21
25
|
|
|
@@ -83,6 +87,76 @@ class AgentSDKExecutor(BaseExecutor):
|
|
|
83
87
|
)
|
|
84
88
|
return self._agent
|
|
85
89
|
|
|
90
|
+
async def _execute_issue_response(self, work_item: Dict) -> Dict:
|
|
91
|
+
"""Execute an issue response task using IssueResponderProfile"""
|
|
92
|
+
# Extract issue data from work_item context
|
|
93
|
+
context = work_item.get("context", {})
|
|
94
|
+
issue_data = context.get("github_issue", {})
|
|
95
|
+
repo = context.get("repo", "")
|
|
96
|
+
|
|
97
|
+
# Load config
|
|
98
|
+
config = IssueResponderConfig.load_from_file(".sugar/config.yaml")
|
|
99
|
+
|
|
100
|
+
# Create profile with config settings
|
|
101
|
+
profile = IssueResponderProfile()
|
|
102
|
+
profile.config.settings["max_response_length"] = config.max_response_length
|
|
103
|
+
profile.config.settings["auto_post_threshold"] = config.auto_post_threshold
|
|
104
|
+
|
|
105
|
+
# Process input
|
|
106
|
+
input_data = {"issue": issue_data, "repo": repo}
|
|
107
|
+
processed = await profile.process_input(input_data)
|
|
108
|
+
|
|
109
|
+
# Execute with agent
|
|
110
|
+
agent_config = SugarAgentConfig(
|
|
111
|
+
model=config.model,
|
|
112
|
+
permission_mode="default",
|
|
113
|
+
allowed_tools=["Read", "Glob", "Grep"],
|
|
114
|
+
system_prompt_additions=profile.get_system_prompt({"repo": repo}),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
agent = SugarAgent(agent_config)
|
|
118
|
+
await agent.start_session()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
response = await agent.execute(processed["prompt"])
|
|
122
|
+
output_data = {"content": response.content}
|
|
123
|
+
result = await profile.process_output(output_data)
|
|
124
|
+
|
|
125
|
+
response_data = result.get("response", {})
|
|
126
|
+
confidence = response_data.get("confidence", 0.0)
|
|
127
|
+
content = response_data.get("content", "")
|
|
128
|
+
|
|
129
|
+
# Check if should auto-post
|
|
130
|
+
should_post = confidence >= config.auto_post_threshold
|
|
131
|
+
|
|
132
|
+
if should_post:
|
|
133
|
+
# Post to GitHub
|
|
134
|
+
client = GitHubClient(repo=repo)
|
|
135
|
+
client.post_comment(issue_data["number"], content)
|
|
136
|
+
|
|
137
|
+
# Record response
|
|
138
|
+
manager = IssueResponseManager()
|
|
139
|
+
await manager.initialize()
|
|
140
|
+
await manager.record_response(
|
|
141
|
+
repo=repo,
|
|
142
|
+
issue_number=issue_data["number"],
|
|
143
|
+
response_type="initial",
|
|
144
|
+
confidence=confidence,
|
|
145
|
+
response_content=content,
|
|
146
|
+
labels_applied=response_data.get("suggested_labels", []),
|
|
147
|
+
was_auto_posted=True,
|
|
148
|
+
work_item_id=work_item.get("id"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"success": True,
|
|
153
|
+
"posted": should_post,
|
|
154
|
+
"confidence": confidence,
|
|
155
|
+
"content": content,
|
|
156
|
+
}
|
|
157
|
+
finally:
|
|
158
|
+
await agent.end_session()
|
|
159
|
+
|
|
86
160
|
async def execute_work(self, work_item: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
161
|
"""
|
|
88
162
|
Execute a work item using the Claude Agent SDK.
|
|
@@ -93,6 +167,12 @@ class AgentSDKExecutor(BaseExecutor):
|
|
|
93
167
|
Returns:
|
|
94
168
|
Result dictionary compatible with Sugar's workflow
|
|
95
169
|
"""
|
|
170
|
+
# Check for specialized task types
|
|
171
|
+
task_type = work_item.get("type", "")
|
|
172
|
+
|
|
173
|
+
if task_type == "issue_response":
|
|
174
|
+
return await self._execute_issue_response(work_item)
|
|
175
|
+
|
|
96
176
|
if self.dry_run:
|
|
97
177
|
logger.info(f"DRY RUN: Simulating execution of {work_item.get('title')}")
|
|
98
178
|
return await self._simulate_execution(work_item)
|
|
@@ -2126,6 +2126,29 @@ sugar:
|
|
|
2126
2126
|
workflow:
|
|
2127
2127
|
# Profiles: solo (direct commits), balanced (PRs), enterprise (full governance)
|
|
2128
2128
|
profile: "solo"
|
|
2129
|
+
|
|
2130
|
+
# Issue Responder (AI-powered issue responses)
|
|
2131
|
+
issue_responder:
|
|
2132
|
+
enabled: false # Enable automatic issue responses
|
|
2133
|
+
|
|
2134
|
+
# Response behavior
|
|
2135
|
+
auto_post_threshold: 0.8 # Min confidence to auto-post (0.0-1.0)
|
|
2136
|
+
max_response_length: 2000 # Max chars per response
|
|
2137
|
+
response_delay_seconds: 0 # Delay before posting (0 = immediate)
|
|
2138
|
+
|
|
2139
|
+
# Rate limiting
|
|
2140
|
+
rate_limit_per_hour: 10 # Max responses per hour (0 = unlimited)
|
|
2141
|
+
|
|
2142
|
+
# Issue filtering
|
|
2143
|
+
respond_to_labels: [] # Empty = all issues, or ["question", "bug"]
|
|
2144
|
+
skip_labels: ["wontfix", "duplicate", "stale"]
|
|
2145
|
+
skip_bot_issues: true # Skip issues created by bots
|
|
2146
|
+
|
|
2147
|
+
# Follow-up handling
|
|
2148
|
+
handle_follow_ups: "ignore" # "ignore" | "queue" | "auto"
|
|
2149
|
+
|
|
2150
|
+
# Model selection
|
|
2151
|
+
model: "claude-sonnet-4-20250514"
|
|
2129
2152
|
"""
|
|
2130
2153
|
|
|
2131
2154
|
|
|
@@ -3203,9 +3226,14 @@ def issue_analyze(ctx, issue_number, repo, output_format):
|
|
|
3203
3226
|
@click.option(
|
|
3204
3227
|
"--dry-run", is_flag=True, help="Show what would be posted without posting"
|
|
3205
3228
|
)
|
|
3229
|
+
@click.option(
|
|
3230
|
+
"--max-length",
|
|
3231
|
+
default=2000,
|
|
3232
|
+
help="Maximum response length in characters (default: 2000)",
|
|
3233
|
+
)
|
|
3206
3234
|
@click.pass_context
|
|
3207
3235
|
def issue_respond(
|
|
3208
|
-
ctx, issue_number, repo, post, force_post, confidence_threshold, dry_run
|
|
3236
|
+
ctx, issue_number, repo, post, force_post, confidence_threshold, dry_run, max_length
|
|
3209
3237
|
):
|
|
3210
3238
|
"""Generate an AI response to a GitHub issue
|
|
3211
3239
|
|
|
@@ -3234,6 +3262,7 @@ def issue_respond(
|
|
|
3234
3262
|
|
|
3235
3263
|
# Process with IssueResponderProfile
|
|
3236
3264
|
profile = IssueResponderProfile()
|
|
3265
|
+
profile.config.settings["max_response_length"] = max_length
|
|
3237
3266
|
input_data = {"issue": issue.to_dict(), "repo": repo or ""}
|
|
3238
3267
|
processed = await profile.process_input(input_data)
|
|
3239
3268
|
|
|
@@ -232,16 +232,20 @@ Provide your response in this format:
|
|
|
232
232
|
# Build the response
|
|
233
233
|
response_content = parsed.get("response", content)
|
|
234
234
|
|
|
235
|
-
#
|
|
235
|
+
# Get signature
|
|
236
|
+
signature = ""
|
|
236
237
|
if self.config.settings.get("include_signature", True):
|
|
237
238
|
signature = self.config.settings.get("signature", "")
|
|
238
|
-
if signature and signature not in response_content:
|
|
239
|
-
response_content += signature
|
|
240
239
|
|
|
241
|
-
# Truncate if needed
|
|
240
|
+
# Truncate if needed (reserve space for signature)
|
|
242
241
|
max_length = self.config.settings.get("max_response_length", 2000)
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
content_max = max_length - len(signature) if signature else max_length
|
|
243
|
+
if len(response_content) > content_max:
|
|
244
|
+
response_content = response_content[: content_max - 3] + "..."
|
|
245
|
+
|
|
246
|
+
# Add signature after truncation
|
|
247
|
+
if signature and signature not in response_content:
|
|
248
|
+
response_content += signature
|
|
245
249
|
|
|
246
250
|
confidence = parsed.get("confidence", 0.5)
|
|
247
251
|
auto_post_threshold = self.config.settings.get("auto_post_threshold", 0.8)
|