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.
Files changed (88) hide show
  1. {sugarai-3.1.1.dev3/sugarai.egg-info → sugarai-3.1.1.dev5}/PKG-INFO +1 -1
  2. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/pyproject.toml +1 -1
  3. sugarai-3.1.1.dev5/sugar/config/__init__.py +5 -0
  4. sugarai-3.1.1.dev5/sugar/config/issue_responder_config.py +143 -0
  5. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/github_watcher.py +154 -2
  6. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/agent_sdk_executor.py +80 -0
  7. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/main.py +30 -1
  8. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/issue_responder.py +10 -6
  9. sugarai-3.1.1.dev5/sugar/storage/__init__.py +5 -0
  10. sugarai-3.1.1.dev5/sugar/storage/issue_response_manager.py +258 -0
  11. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5/sugarai.egg-info}/PKG-INFO +1 -1
  12. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/SOURCES.txt +4 -0
  13. sugarai-3.1.1.dev5/tests/test_issue_response_integration.py +1042 -0
  14. sugarai-3.1.1.dev3/sugar/utils/__init__.py +0 -0
  15. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/LICENSE +0 -0
  16. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/README.md +0 -0
  17. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/setup.cfg +0 -0
  18. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/__init__.py +0 -0
  19. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/__version__.py +0 -0
  20. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/__init__.py +0 -0
  21. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/base.py +0 -0
  22. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/hooks.py +0 -0
  23. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/subagent_manager.py +0 -0
  24. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/agent/tools.py +0 -0
  25. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/__init__.py +0 -0
  26. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/api_keys.py +0 -0
  27. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/tiers.py +0 -0
  28. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/billing/usage.py +0 -0
  29. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/core/__init__.py +0 -0
  30. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/core/loop.py +0 -0
  31. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/__init__.py +0 -0
  32. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/code_quality.py +0 -0
  33. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/error_monitor.py +0 -0
  34. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/discovery/test_coverage.py +0 -0
  35. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/__init__.py +0 -0
  36. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/base.py +0 -0
  37. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/claude_wrapper.py +0 -0
  38. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/executor/structured_request.py +0 -0
  39. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/integrations/__init__.py +0 -0
  40. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/integrations/github.py +0 -0
  41. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/__init__.py +0 -0
  42. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/adaptive_scheduler.py +0 -0
  43. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/learning/feedback_processor.py +0 -0
  44. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/mcp/__init__.py +0 -0
  45. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/mcp/server.py +0 -0
  46. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/__init__.py +0 -0
  47. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/agent_router.py +0 -0
  48. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/orchestration/task_orchestrator.py +0 -0
  49. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/__init__.py +0 -0
  50. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/base.py +0 -0
  51. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/profiles/default.py +0 -0
  52. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/__init__.py +0 -0
  53. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/coordinator.py +0 -0
  54. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/diff_validator.py +0 -0
  55. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/evidence.py +0 -0
  56. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/failure_handler.py +0 -0
  57. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/functional_verifier.py +0 -0
  58. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/preflight_checks.py +0 -0
  59. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/success_criteria.py +0 -0
  60. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/test_validator.py +0 -0
  61. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/quality_gates/truth_enforcer.py +0 -0
  62. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/storage/task_type_manager.py +0 -0
  63. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/storage/work_queue.py +0 -0
  64. {sugarai-3.1.1.dev3/sugar/storage → sugarai-3.1.1.dev5/sugar/utils}/__init__.py +0 -0
  65. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/utils/git_operations.py +0 -0
  66. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/utils/toon_encoder.py +0 -0
  67. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/workflow/__init__.py +0 -0
  68. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugar/workflow/orchestrator.py +0 -0
  69. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/dependency_links.txt +0 -0
  70. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/entry_points.txt +0 -0
  71. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/requires.txt +0 -0
  72. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/sugarai.egg-info/top_level.txt +0 -0
  73. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_agent.py +0 -0
  74. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_billing.py +0 -0
  75. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_cli.py +0 -0
  76. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_core_loop.py +0 -0
  77. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_goose_integration.py +0 -0
  78. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_hold_functionality.py +0 -0
  79. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_hooks.py +0 -0
  80. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_issue_responder_cli.py +0 -0
  81. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_orchestration.py +0 -0
  82. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_profiles.py +0 -0
  83. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_quality_gates.py +0 -0
  84. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_storage.py +0 -0
  85. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_subagent_manager.py +0 -0
  86. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_task_types.py +0 -0
  87. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/tests/test_toon_encoder.py +0 -0
  88. {sugarai-3.1.1.dev3 → sugarai-3.1.1.dev5}/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.dev3
3
+ Version: 3.1.1.dev5
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sugarai"
7
- version = "3.1.1.dev3"
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,5 @@
1
+ """Sugar configuration loaders."""
2
+
3
+ from .issue_responder_config import IssueResponderConfig
4
+
5
+ __all__ = ["IssueResponderConfig"]
@@ -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__(self, config: dict):
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
- # Add signature if configured
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
- if len(response_content) > max_length:
244
- response_content = response_content[: max_length - 3] + "..."
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)
@@ -0,0 +1,5 @@
1
+ from .work_queue import WorkQueue
2
+ from .task_type_manager import TaskTypeManager
3
+ from .issue_response_manager import IssueResponseManager
4
+
5
+ __all__ = ["WorkQueue", "TaskTypeManager", "IssueResponseManager"]