sugarai 3.1.1.dev4__tar.gz → 3.1.1.dev6__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.dev4/sugarai.egg-info → sugarai-3.1.1.dev6}/PKG-INFO +11 -1
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/README.md +10 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/pyproject.toml +1 -1
- sugarai-3.1.1.dev6/sugar/config/__init__.py +6 -0
- sugarai-3.1.1.dev6/sugar/config/issue_responder_config.py +143 -0
- sugarai-3.1.1.dev6/sugar/config/prompt_config.py +135 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/discovery/github_watcher.py +154 -2
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/executor/agent_sdk_executor.py +80 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/main.py +23 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/profiles/issue_responder.py +16 -1
- sugarai-3.1.1.dev6/sugar/storage/__init__.py +5 -0
- sugarai-3.1.1.dev6/sugar/storage/issue_response_manager.py +258 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6/sugarai.egg-info}/PKG-INFO +11 -1
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugarai.egg-info/SOURCES.txt +6 -0
- sugarai-3.1.1.dev6/tests/test_issue_response_integration.py +1042 -0
- sugarai-3.1.1.dev6/tests/test_prompt_config.py +359 -0
- sugarai-3.1.1.dev4/sugar/utils/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/LICENSE +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/setup.cfg +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/__version__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/agent/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/agent/base.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/agent/hooks.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/agent/subagent_manager.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/agent/tools.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/billing/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/billing/api_keys.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/billing/tiers.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/billing/usage.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/core/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/core/loop.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/discovery/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/discovery/code_quality.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/discovery/error_monitor.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/discovery/test_coverage.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/executor/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/executor/base.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/executor/claude_wrapper.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/executor/structured_request.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/integrations/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/integrations/github.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/learning/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/learning/adaptive_scheduler.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/learning/feedback_processor.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/mcp/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/mcp/server.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/orchestration/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/orchestration/agent_router.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/orchestration/task_orchestrator.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/profiles/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/profiles/base.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/profiles/default.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/coordinator.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/diff_validator.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/evidence.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/failure_handler.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/functional_verifier.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/preflight_checks.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/success_criteria.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/test_validator.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/quality_gates/truth_enforcer.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/storage/task_type_manager.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/storage/work_queue.py +0 -0
- {sugarai-3.1.1.dev4/sugar/storage → sugarai-3.1.1.dev6/sugar/utils}/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/utils/git_operations.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/utils/toon_encoder.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/workflow/__init__.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugar/workflow/orchestrator.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugarai.egg-info/dependency_links.txt +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugarai.egg-info/entry_points.txt +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugarai.egg-info/requires.txt +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/sugarai.egg-info/top_level.txt +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_agent.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_billing.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_cli.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_core_loop.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_goose_integration.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_hold_functionality.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_hooks.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_issue_responder_cli.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_orchestration.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_profiles.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_quality_gates.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_storage.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_subagent_manager.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_task_types.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_toon_encoder.py +0 -0
- {sugarai-3.1.1.dev4 → sugarai-3.1.1.dev6}/tests/test_v3_benchmarks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sugarai
|
|
3
|
-
Version: 3.1.1.
|
|
3
|
+
Version: 3.1.1.dev6
|
|
4
4
|
Summary: Sugar 🍰 - A dev team that never stops. Delegate full tasks to AI in the background.
|
|
5
5
|
Author-email: Steven Leggett <contact@roboticforce.io>
|
|
6
6
|
License: MIT
|
|
@@ -228,6 +228,16 @@ sugar issue respond 42 --post
|
|
|
228
228
|
|
|
229
229
|
The Issue Responder evaluates confidence before posting. Use `--force-post` to override the confidence check, or adjust the threshold with `--confidence-threshold`.
|
|
230
230
|
|
|
231
|
+
**Custom Prompts:** Customize Sugar's responses per-project by creating `.sugar/prompts/issue_responder.json`:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"instructions": "You are a helpful assistant for MyProject. Be friendly and professional.",
|
|
236
|
+
"guidelines": ["Always search the codebase first", "Include file paths"],
|
|
237
|
+
"constraints": ["Never share API keys", "Don't promise release dates"]
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
231
241
|
**Full documentation:** [docs/issue-responder.md](docs/issue-responder.md)
|
|
232
242
|
|
|
233
243
|
## How It Works
|
|
@@ -174,6 +174,16 @@ sugar issue respond 42 --post
|
|
|
174
174
|
|
|
175
175
|
The Issue Responder evaluates confidence before posting. Use `--force-post` to override the confidence check, or adjust the threshold with `--confidence-threshold`.
|
|
176
176
|
|
|
177
|
+
**Custom Prompts:** Customize Sugar's responses per-project by creating `.sugar/prompts/issue_responder.json`:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"instructions": "You are a helpful assistant for MyProject. Be friendly and professional.",
|
|
182
|
+
"guidelines": ["Always search the codebase first", "Include file paths"],
|
|
183
|
+
"constraints": ["Never share API keys", "Don't promise release dates"]
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
177
187
|
**Full documentation:** [docs/issue-responder.md](docs/issue-responder.md)
|
|
178
188
|
|
|
179
189
|
## How It Works
|
|
@@ -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.dev6"
|
|
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
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt configuration for Issue Responder
|
|
3
|
+
|
|
4
|
+
Loads custom prompt configuration from .sugar/prompts/issue_responder.json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PersonaConfig:
|
|
18
|
+
"""Agent persona configuration"""
|
|
19
|
+
|
|
20
|
+
role: Optional[str] = None
|
|
21
|
+
goal: Optional[str] = None
|
|
22
|
+
expertise: List[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: dict) -> "PersonaConfig":
|
|
26
|
+
"""Create PersonaConfig from a dictionary.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
data: Dictionary containing persona configuration
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
PersonaConfig instance
|
|
33
|
+
"""
|
|
34
|
+
return cls(
|
|
35
|
+
role=data.get("role"),
|
|
36
|
+
goal=data.get("goal"),
|
|
37
|
+
expertise=data.get("expertise", []),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PromptConfig:
|
|
43
|
+
"""Custom prompt configuration for Issue Responder"""
|
|
44
|
+
|
|
45
|
+
instructions: str
|
|
46
|
+
name: Optional[str] = None
|
|
47
|
+
description: Optional[str] = None
|
|
48
|
+
persona: Optional[PersonaConfig] = None
|
|
49
|
+
guidelines: List[str] = field(default_factory=list)
|
|
50
|
+
constraints: List[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict) -> "PromptConfig":
|
|
54
|
+
"""Load from dictionary"""
|
|
55
|
+
persona_data = data.get("persona")
|
|
56
|
+
persona = PersonaConfig.from_dict(persona_data) if persona_data else None
|
|
57
|
+
|
|
58
|
+
return cls(
|
|
59
|
+
instructions=data.get("instructions", ""),
|
|
60
|
+
name=data.get("name"),
|
|
61
|
+
description=data.get("description"),
|
|
62
|
+
persona=persona,
|
|
63
|
+
guidelines=data.get("guidelines", []),
|
|
64
|
+
constraints=data.get("constraints", []),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load_from_file(cls, config_path: Path = None) -> Optional["PromptConfig"]:
|
|
69
|
+
"""
|
|
70
|
+
Load from JSON file.
|
|
71
|
+
|
|
72
|
+
Default path: .sugar/prompts/issue_responder.json
|
|
73
|
+
Returns None if file doesn't exist (use defaults)
|
|
74
|
+
"""
|
|
75
|
+
if config_path is None:
|
|
76
|
+
config_path = Path(".sugar/prompts/issue_responder.json")
|
|
77
|
+
|
|
78
|
+
if not config_path.exists():
|
|
79
|
+
logger.debug(f"No custom prompt config at {config_path}")
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with open(config_path) as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
return cls.from_dict(data)
|
|
86
|
+
except json.JSONDecodeError as e:
|
|
87
|
+
logger.error(f"Invalid JSON in {config_path}: {e}")
|
|
88
|
+
return None
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Error loading prompt config: {e}")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def validate(self) -> List[str]:
|
|
94
|
+
"""Validate configuration, return list of errors"""
|
|
95
|
+
errors = []
|
|
96
|
+
if not self.instructions or not self.instructions.strip():
|
|
97
|
+
errors.append("instructions is required and cannot be empty")
|
|
98
|
+
return errors
|
|
99
|
+
|
|
100
|
+
def build_system_prompt_additions(self) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Build the system prompt additions from this config.
|
|
103
|
+
Returns a string to be added to the base system prompt.
|
|
104
|
+
"""
|
|
105
|
+
parts = []
|
|
106
|
+
|
|
107
|
+
# Add persona section
|
|
108
|
+
if self.persona:
|
|
109
|
+
persona_parts = []
|
|
110
|
+
if self.persona.role:
|
|
111
|
+
persona_parts.append(f"**Role**: {self.persona.role}")
|
|
112
|
+
if self.persona.goal:
|
|
113
|
+
persona_parts.append(f"**Goal**: {self.persona.goal}")
|
|
114
|
+
if self.persona.expertise:
|
|
115
|
+
persona_parts.append(
|
|
116
|
+
f"**Expertise**: {', '.join(self.persona.expertise)}"
|
|
117
|
+
)
|
|
118
|
+
if persona_parts:
|
|
119
|
+
parts.append("## Your Persona\n" + "\n".join(persona_parts))
|
|
120
|
+
|
|
121
|
+
# Add instructions
|
|
122
|
+
if self.instructions:
|
|
123
|
+
parts.append(f"## Project-Specific Instructions\n{self.instructions}")
|
|
124
|
+
|
|
125
|
+
# Add guidelines
|
|
126
|
+
if self.guidelines:
|
|
127
|
+
guidelines_text = "\n".join(f"- {g}" for g in self.guidelines)
|
|
128
|
+
parts.append(f"## Guidelines\n{guidelines_text}")
|
|
129
|
+
|
|
130
|
+
# Add constraints
|
|
131
|
+
if self.constraints:
|
|
132
|
+
constraints_text = "\n".join(f"- {c}" for c in self.constraints)
|
|
133
|
+
parts.append(f"## Constraints\n{constraints_text}")
|
|
134
|
+
|
|
135
|
+
return "\n\n".join(parts)
|
|
@@ -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)
|