taskboy 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""issues as in-process mcp tools.
|
|
2
|
+
|
|
3
|
+
the discovery skill reads past tasks/feedback/errors and records proposed issues; the
|
|
4
|
+
implementation skill reads approved issues and enqueues a spec-driven PR task per one; the
|
|
5
|
+
spec2pr skill reads its assigned spec and records the outcome. these are gated to skills that opt in
|
|
6
|
+
via `internal_tools:` frontmatter, and every call is on the tool-call audit trail like any other tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from taskboy.adapters._util import _error, _text, wrap
|
|
13
|
+
from taskboy.models import Task
|
|
14
|
+
from taskboy.store import ISSUE_STATUSES, Store
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("taskboy.issues")
|
|
17
|
+
|
|
18
|
+
# tool names the runner adds to a session's allowlist when its skill opts into each capability server
|
|
19
|
+
ISSUES_TOOLS = [
|
|
20
|
+
"mcp__issues__list_task_feedback",
|
|
21
|
+
"mcp__issues__list_failed_tasks",
|
|
22
|
+
"mcp__issues__list_recent_errors",
|
|
23
|
+
"mcp__issues__record_issue",
|
|
24
|
+
"mcp__issues__list_accepted_issues",
|
|
25
|
+
"mcp__issues__list_existing_issues",
|
|
26
|
+
"mcp__issues__get_issue",
|
|
27
|
+
"mcp__issues__update_issue",
|
|
28
|
+
"mcp__issues__list_issue_comments",
|
|
29
|
+
"mcp__issues__post_issue_comment",
|
|
30
|
+
"mcp__issues__update_issue_comment",
|
|
31
|
+
"mcp__issues__delete_issue_comment",
|
|
32
|
+
"mcp__issues__resolve_issue_comment",
|
|
33
|
+
"mcp__issues__finish_issue",
|
|
34
|
+
]
|
|
35
|
+
ENQUEUE_TOOLS = ["mcp__enqueue__enqueue_spec_pr"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IssuesAdapter:
|
|
39
|
+
def __init__(self, store: Store, task: Task, approved_repos: list[str], bot_name: str = "Agent"):
|
|
40
|
+
self.store = store
|
|
41
|
+
self.task = task
|
|
42
|
+
self.approved_repos = approved_repos
|
|
43
|
+
self.bot_name = bot_name
|
|
44
|
+
|
|
45
|
+
def _audit(self, tool: str, detail: dict, is_write: bool) -> None:
|
|
46
|
+
self.store.add_event(self.task.task_id, "tool_call", detail, tool_name=f"mcp__issues__{tool}", is_write=is_write)
|
|
47
|
+
|
|
48
|
+
async def list_task_feedback(self, args: dict) -> dict:
|
|
49
|
+
limit = min(int(args.get("limit", 50) or 50), 200)
|
|
50
|
+
offset = max(int(args.get("offset", 0) or 0), 0)
|
|
51
|
+
out = []
|
|
52
|
+
for row in self.store.recent_feedback(limit, offset=offset):
|
|
53
|
+
source = self.store.get_task(row["task_id"])
|
|
54
|
+
out.append(
|
|
55
|
+
{
|
|
56
|
+
"task_id": row["task_id"],
|
|
57
|
+
"rating": row["rating"],
|
|
58
|
+
"comment": (row["comment"] or "")[:300],
|
|
59
|
+
"request": source.request_text[:300] if source else None,
|
|
60
|
+
"result": (source.result_summary or "")[:300] if source else None,
|
|
61
|
+
"state": source.state if source else None,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
self._audit("list_task_feedback", {"count": len(out), "offset": offset}, False)
|
|
65
|
+
return _text(json.dumps(out, ensure_ascii=False) if out else "no task feedback recorded yet")
|
|
66
|
+
|
|
67
|
+
async def list_failed_tasks(self, args: dict) -> dict:
|
|
68
|
+
limit = min(int(args.get("limit", 50) or 50), 200)
|
|
69
|
+
offset = max(int(args.get("offset", 0) or 0), 0)
|
|
70
|
+
query = str(args.get("query", "")).strip() or None
|
|
71
|
+
task_type = str(args.get("task_type", "")).strip() or None
|
|
72
|
+
tasks = self.store.list_tasks(state="failed", limit=limit, offset=offset, query=query, task_type=task_type) + self.store.list_tasks(state="blocked", limit=limit, offset=offset, query=query, task_type=task_type)
|
|
73
|
+
out = [{"task_id": t.task_id, "state": t.state, "task_type": t.task_type, "request": t.request_text[:300], "error": t.error, "blocked_reason": t.blocked_reason} for t in tasks]
|
|
74
|
+
self._audit("list_failed_tasks", {"count": len(out), "offset": offset, "query": query, "task_type": task_type}, False)
|
|
75
|
+
return _text(json.dumps(out, ensure_ascii=False) if out else "no failed or blocked tasks")
|
|
76
|
+
|
|
77
|
+
async def list_recent_errors(self, args: dict) -> dict:
|
|
78
|
+
limit = min(int(args.get("limit", 50) or 50), 200)
|
|
79
|
+
offset = max(int(args.get("offset", 0) or 0), 0)
|
|
80
|
+
component = str(args.get("component", "")).strip() or None
|
|
81
|
+
kind = str(args.get("kind", "")).strip() or None
|
|
82
|
+
# traceback_chars stays a distinct "not provided" vs "0" so callers can opt out of the tail entirely
|
|
83
|
+
# (0) without disturbing the default (600, byte-identical to the old hardcoded slice)
|
|
84
|
+
raw_traceback_chars = args.get("traceback_chars")
|
|
85
|
+
traceback_chars = 600 if raw_traceback_chars is None else int(raw_traceback_chars)
|
|
86
|
+
traceback_chars = min(max(traceback_chars, 0), 2000)
|
|
87
|
+
rows = self.store.recent_errors(limit, offset=offset, component=component, kind=kind)
|
|
88
|
+
# counts go first so the recurring-error signal survives even when the row detail is truncated
|
|
89
|
+
counts: dict[tuple[str, str], int] = {}
|
|
90
|
+
for r in rows:
|
|
91
|
+
key = (r["component"], r["kind"])
|
|
92
|
+
counts[key] = counts.get(key, 0) + 1
|
|
93
|
+
summary = [{"component": component_, "kind": kind_, "count": count} for (component_, kind_), count in sorted(counts.items(), key=lambda item: -item[1])]
|
|
94
|
+
# the traceback tail carries the raising frame — enough to locate the fault without flooding context
|
|
95
|
+
out = [
|
|
96
|
+
{
|
|
97
|
+
"component": r["component"],
|
|
98
|
+
"kind": r["kind"],
|
|
99
|
+
"message": (r["message"] or "")[:300],
|
|
100
|
+
"traceback_tail": ((r["traceback"] or "")[-traceback_chars:] or None) if traceback_chars else None,
|
|
101
|
+
"task_id": r["task_id"],
|
|
102
|
+
"ts": r["ts"],
|
|
103
|
+
}
|
|
104
|
+
for r in rows
|
|
105
|
+
]
|
|
106
|
+
self._audit("list_recent_errors", {"count": len(out), "offset": offset, "component": component, "kind": kind}, False)
|
|
107
|
+
return _text(json.dumps({"counts": summary, "errors": out}, ensure_ascii=False) if out else "no errors recorded")
|
|
108
|
+
|
|
109
|
+
async def record_issue(self, args: dict) -> dict:
|
|
110
|
+
repo = str(args.get("repo", "")).strip()
|
|
111
|
+
summary = str(args.get("summary", "")).strip()
|
|
112
|
+
issue_type = str(args.get("issue_type", "")).strip()
|
|
113
|
+
details = str(args.get("details", "")).strip()
|
|
114
|
+
dedupe_key = str(args.get("dedupe_key", "")).strip()
|
|
115
|
+
priority = args.get("priority", 50)
|
|
116
|
+
if not (repo and summary and issue_type and details and dedupe_key):
|
|
117
|
+
return _error("repo, summary, issue_type, details, and dedupe_key are all required")
|
|
118
|
+
if repo not in self.approved_repos:
|
|
119
|
+
return _error(f"repo must be one of {sorted(self.approved_repos)}")
|
|
120
|
+
# cheap backstop: catch a duplicate proposal under a different dedupe_key before it gets recorded.
|
|
121
|
+
# a matching dedupe_key is a refresh (handled by the store's upsert), not a duplicate.
|
|
122
|
+
normalized = summary.strip().lower()
|
|
123
|
+
for existing in self.store.list_issues():
|
|
124
|
+
if existing["repo"] == repo and existing["dedupe_key"] != dedupe_key and existing["summary"].strip().lower() == normalized:
|
|
125
|
+
# the store only upserts dedupe_key matches while the row is still proposed; past that, re-recording is a silent no-op
|
|
126
|
+
if existing["status"] == "proposed":
|
|
127
|
+
return _error(f"an issue with this summary already exists as #{existing['id']} (proposed); " f"re-record with its dedupe_key '{existing['dedupe_key']}' if you mean to refresh it, or choose a genuinely different issue")
|
|
128
|
+
return _error(f"an issue with this summary already exists as #{existing['id']} ({existing['status']}); " "it can no longer be refreshed by re-recording — choose a genuinely different issue")
|
|
129
|
+
row = self.store.record_issue(dedupe_key, repo, summary, issue_type, details, priority)
|
|
130
|
+
if row["status"] != "proposed":
|
|
131
|
+
# the store's upsert is guarded by WHERE status = 'proposed', so a dedupe_key match on an
|
|
132
|
+
# already-decided row silently wrote nothing — don't report success for a no-op
|
|
133
|
+
return _error(f"an issue with this dedupe_key already exists as #{row['id']} ({row['status']}); it can no longer be refreshed by re-recording — choose a genuinely different dedupe_key")
|
|
134
|
+
self._audit("record_issue", {"id": row["id"], "repo": repo, "dedupe_key": dedupe_key, "type": issue_type, "status": row["status"]}, True)
|
|
135
|
+
return _text(f"recorded issue #{row['id']} ({row['status']}); re-recording the same dedupe_key refreshes it while it stays proposed")
|
|
136
|
+
|
|
137
|
+
async def list_accepted_issues(self, args: dict) -> dict:
|
|
138
|
+
# only a hand-typed or retried coordinator gets here without a reservation; gate on ever-reserved so a handed-off batch can't reserve a second one
|
|
139
|
+
rows = self.store.issues_reserved_by(self.task.task_id)
|
|
140
|
+
if not self.store.has_ever_reserved(self.task.task_id):
|
|
141
|
+
rows = self.store.reserve_issues(self.task.task_id)
|
|
142
|
+
out = [{"id": r["id"], "repo": r["repo"], "summary": r["summary"], "issue_type": r["issue_type"], "details": r["details"], "priority": r["priority"]} for r in rows]
|
|
143
|
+
self._audit("list_accepted_issues", {"count": len(out)}, False)
|
|
144
|
+
return _text(json.dumps(out, ensure_ascii=False) if out else "no approved issues are waiting to be implemented")
|
|
145
|
+
|
|
146
|
+
async def list_existing_issues(self, args: dict) -> dict:
|
|
147
|
+
limit = min(int(args.get("limit", 200) or 200), 500)
|
|
148
|
+
offset = max(int(args.get("offset", 0) or 0), 0)
|
|
149
|
+
repo = str(args.get("repo", "")).strip() or None
|
|
150
|
+
status = str(args.get("status", "")).strip() or None
|
|
151
|
+
keys_only = bool(args.get("keys_only", False))
|
|
152
|
+
if repo is not None and repo not in self.approved_repos:
|
|
153
|
+
return _error(f"repo must be blank or one of {sorted(self.approved_repos)}")
|
|
154
|
+
if status is not None and status not in ISSUE_STATUSES:
|
|
155
|
+
return _error(f"status must be blank or one of {list(ISSUE_STATUSES)}")
|
|
156
|
+
rows = self.store.list_issues(status=status, limit=limit, offset=offset, repo=repo)
|
|
157
|
+
if keys_only:
|
|
158
|
+
out = [{"id": r["id"], "dedupe_key": r["dedupe_key"], "status": r["status"]} for r in rows]
|
|
159
|
+
else:
|
|
160
|
+
out = [{"id": r["id"], "dedupe_key": r["dedupe_key"], "repo": r["repo"], "summary": r["summary"], "issue_type": r["issue_type"], "status": r["status"], "priority": r["priority"]} for r in rows]
|
|
161
|
+
self._audit("list_existing_issues", {"count": len(out), "offset": offset, "status": status, "keys_only": keys_only}, False)
|
|
162
|
+
return _text(json.dumps(out, ensure_ascii=False) if out else "no issues recorded yet")
|
|
163
|
+
|
|
164
|
+
async def get_issue(self, args: dict) -> dict:
|
|
165
|
+
row = self.store.get_issue(int(args.get("id", 0) or 0))
|
|
166
|
+
if row is None:
|
|
167
|
+
return _error("issue not found")
|
|
168
|
+
keep = ("id", "repo", "summary", "issue_type", "details", "priority", "status", "spec", "pr_url")
|
|
169
|
+
self._audit("get_issue", {"id": row["id"], "status": row["status"]}, False)
|
|
170
|
+
return _text(json.dumps({key: row[key] for key in keep}, ensure_ascii=False))
|
|
171
|
+
|
|
172
|
+
async def update_issue(self, args: dict) -> dict:
|
|
173
|
+
issue_id = int(args.get("id", 0) or 0)
|
|
174
|
+
summary = str(args.get("summary", "")).strip()
|
|
175
|
+
details = str(args.get("details", "")).strip()
|
|
176
|
+
priority = args.get("priority")
|
|
177
|
+
if not issue_id or not summary or not details or not isinstance(priority, int) or isinstance(priority, bool) or not 1 <= priority <= 100:
|
|
178
|
+
return _error("id, summary, details, and priority (1-100) are required")
|
|
179
|
+
row = self.store.update_issue(issue_id, summary, details, priority)
|
|
180
|
+
if row is None:
|
|
181
|
+
return _error(f"issue #{issue_id} is not editable")
|
|
182
|
+
self._audit("update_issue", {"id": issue_id, "priority": priority}, True)
|
|
183
|
+
return _text(f"updated issue #{issue_id}")
|
|
184
|
+
|
|
185
|
+
def _threaded_comments(self, issue_id: int) -> list[dict]:
|
|
186
|
+
attachments: dict[int, list[str]] = {}
|
|
187
|
+
for attachment in self.store.list_issue_attachments(issue_id):
|
|
188
|
+
if attachment["comment_id"] is not None:
|
|
189
|
+
attachments.setdefault(int(attachment["comment_id"]), []).append(attachment["filename"])
|
|
190
|
+
roots: list[dict] = []
|
|
191
|
+
by_id: dict[int, dict] = {}
|
|
192
|
+
for row in self.store.list_issue_comments(issue_id):
|
|
193
|
+
item = {
|
|
194
|
+
"id": row["id"],
|
|
195
|
+
"parent_comment_id": row["parent_comment_id"],
|
|
196
|
+
"author": row["author"],
|
|
197
|
+
"body": row["body"],
|
|
198
|
+
"resolved": bool(row["resolved"]),
|
|
199
|
+
"deleted": row["deleted_at"] is not None,
|
|
200
|
+
"edited_at": row["edited_at"],
|
|
201
|
+
"created_at": row["created_at"],
|
|
202
|
+
"attachments": attachments.get(row["id"], []),
|
|
203
|
+
"replies": [],
|
|
204
|
+
}
|
|
205
|
+
by_id[row["id"]] = item
|
|
206
|
+
if row["parent_comment_id"] is None:
|
|
207
|
+
roots.append(item)
|
|
208
|
+
elif row["parent_comment_id"] in by_id:
|
|
209
|
+
by_id[row["parent_comment_id"]]["replies"].append(item)
|
|
210
|
+
return roots
|
|
211
|
+
|
|
212
|
+
async def list_issue_comments(self, args: dict) -> dict:
|
|
213
|
+
issue_id = int(args.get("issue_id", 0) or 0)
|
|
214
|
+
if self.store.get_issue(issue_id) is None:
|
|
215
|
+
return _error("issue not found")
|
|
216
|
+
comments = self._threaded_comments(issue_id)
|
|
217
|
+
self._audit("list_issue_comments", {"issue_id": issue_id, "count": self.store.count_issue_comments(issue_id)}, False)
|
|
218
|
+
return _text(json.dumps(comments, ensure_ascii=False))
|
|
219
|
+
|
|
220
|
+
async def post_issue_comment(self, args: dict) -> dict:
|
|
221
|
+
issue_id = int(args.get("issue_id", 0) or 0)
|
|
222
|
+
parent_id = int(args.get("parent_comment_id", 0) or 0) or None
|
|
223
|
+
body = str(args.get("body", "")).strip()
|
|
224
|
+
if not issue_id or not body:
|
|
225
|
+
return _error("issue_id and body are required")
|
|
226
|
+
try:
|
|
227
|
+
row = self.store.add_issue_comment(issue_id, "agent", body, parent_id)
|
|
228
|
+
except (LookupError, ValueError) as exc:
|
|
229
|
+
return _error(str(exc))
|
|
230
|
+
self._audit("post_issue_comment", {"issue_id": issue_id, "comment_id": row["id"], "parent_comment_id": parent_id}, True)
|
|
231
|
+
return _text(f"posted comment #{row['id']} on issue #{issue_id}")
|
|
232
|
+
|
|
233
|
+
async def update_issue_comment(self, args: dict) -> dict:
|
|
234
|
+
comment_id = int(args.get("comment_id", 0) or 0)
|
|
235
|
+
body = str(args.get("body", "")).strip()
|
|
236
|
+
if not body:
|
|
237
|
+
return _error("body is required")
|
|
238
|
+
comment = self.store.get_issue_comment(comment_id)
|
|
239
|
+
if comment is None:
|
|
240
|
+
return _error("comment not found")
|
|
241
|
+
if comment["author"] != "agent":
|
|
242
|
+
return _error("only the agent's comments can be edited")
|
|
243
|
+
if comment["deleted_at"] is not None:
|
|
244
|
+
return _error("deleted comments cannot be edited")
|
|
245
|
+
row = self.store.update_issue_comment(comment_id, body)
|
|
246
|
+
if row is None:
|
|
247
|
+
return _error("comment could not be edited")
|
|
248
|
+
self._audit("update_issue_comment", {"comment_id": comment_id}, True)
|
|
249
|
+
return _text(f"updated comment #{comment_id}")
|
|
250
|
+
|
|
251
|
+
async def delete_issue_comment(self, args: dict) -> dict:
|
|
252
|
+
comment_id = int(args.get("comment_id", 0) or 0)
|
|
253
|
+
comment = self.store.get_issue_comment(comment_id)
|
|
254
|
+
if comment is None:
|
|
255
|
+
return _error("comment not found")
|
|
256
|
+
if comment["author"] != "agent":
|
|
257
|
+
return _error("only the agent's comments can be deleted")
|
|
258
|
+
if comment["deleted_at"] is not None:
|
|
259
|
+
return _error("comment is already deleted")
|
|
260
|
+
self.store.delete_issue_comment(comment_id)
|
|
261
|
+
self._audit("delete_issue_comment", {"comment_id": comment_id}, True)
|
|
262
|
+
return _text(f"deleted comment #{comment_id}")
|
|
263
|
+
|
|
264
|
+
async def resolve_issue_comment(self, args: dict) -> dict:
|
|
265
|
+
comment_id = int(args.get("comment_id", 0) or 0)
|
|
266
|
+
row = self.store.resolve_issue_comment(comment_id, "agent")
|
|
267
|
+
if row is None:
|
|
268
|
+
return _error("comment not found")
|
|
269
|
+
self._audit("resolve_issue_comment", {"comment_id": comment_id}, True)
|
|
270
|
+
return _text(f"resolved comment #{comment_id}")
|
|
271
|
+
|
|
272
|
+
async def finish_issue(self, args: dict) -> dict:
|
|
273
|
+
issue_id = int(args.get("id", 0) or 0)
|
|
274
|
+
status = str(args.get("status", "")).strip()
|
|
275
|
+
pr_url = str(args.get("pr_url", "")).strip() or None
|
|
276
|
+
if status not in ("done", "failed"):
|
|
277
|
+
return _error("status must be 'done' or 'failed'")
|
|
278
|
+
# a PR is open but not yet merged — record it as in_review; housekeeping resolves it to done/failed
|
|
279
|
+
stored_status = "in_review" if status == "done" and pr_url else status
|
|
280
|
+
row = self.store.finish_issue(issue_id, stored_status, pr_url)
|
|
281
|
+
if row is None:
|
|
282
|
+
return _error(f"issue #{issue_id} is not in progress, so its outcome cannot be recorded")
|
|
283
|
+
self._audit("finish_issue", {"id": issue_id, "status": stored_status, "pr_url": pr_url}, True)
|
|
284
|
+
if stored_status == "in_review":
|
|
285
|
+
return _text(f"issue #{issue_id} marked in_review — it will move to done once {pr_url} merges, or failed if it's closed unmerged")
|
|
286
|
+
return _text(f"issue #{issue_id} marked {stored_status}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class EnqueueAdapter:
|
|
290
|
+
"""lets the implementation skill hand one PR off to a fresh sub-agent task (the spec2pr skill, on its own model)."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, store: Store, task: Task):
|
|
293
|
+
self.store = store
|
|
294
|
+
self.task = task
|
|
295
|
+
|
|
296
|
+
async def enqueue_spec_pr(self, args: dict) -> dict:
|
|
297
|
+
issue_id = int(args.get("id", 0) or 0)
|
|
298
|
+
spec = str(args.get("spec", "")).strip()
|
|
299
|
+
if not issue_id or not spec:
|
|
300
|
+
return _error("id (an approved issue id) and spec (the full implementation spec) are both required")
|
|
301
|
+
issue = self.store.get_issue(issue_id)
|
|
302
|
+
if issue is None:
|
|
303
|
+
return _error(f"issue #{issue_id} not found")
|
|
304
|
+
parent = self.task
|
|
305
|
+
|
|
306
|
+
# a re-call for an issue this same parent already claimed and enqueued: reply idempotently, no new claim/child
|
|
307
|
+
if issue["status"] == "in_progress" and issue["task_id"]:
|
|
308
|
+
existing_child = self.store.get_task(issue["task_id"])
|
|
309
|
+
if existing_child is not None and existing_child.parent_task_id == parent.task_id:
|
|
310
|
+
return _text(f"a PR task for issue #{issue_id} was already enqueued ({issue['task_id']})")
|
|
311
|
+
|
|
312
|
+
reserved_for_this_run = issue["status"] == "implementation_queued" and issue.get("reserved_by") == parent.task_id
|
|
313
|
+
legacy_manual_approval = issue["status"] == "approved"
|
|
314
|
+
if not (reserved_for_this_run or legacy_manual_approval):
|
|
315
|
+
return _error(f"issue #{issue_id} is {issue['status']}, not reserved for this run or approved — only a reserved or approved issue can be enqueued")
|
|
316
|
+
|
|
317
|
+
# claim before creating the child so a failed claim never leaves an orphan task behind
|
|
318
|
+
if self.store.start_issue(issue_id, None, spec) is None:
|
|
319
|
+
return _error(f"issue #{issue_id} could not be claimed (its status changed)")
|
|
320
|
+
|
|
321
|
+
# inherit the parent's slack context so replies land in the same thread; a per-issue message_ts keeps intake idempotent
|
|
322
|
+
child, created = self.store.create_task(
|
|
323
|
+
slack_team_id=parent.slack_team_id,
|
|
324
|
+
slack_channel_id=parent.slack_channel_id,
|
|
325
|
+
slack_thread_ts=parent.slack_thread_ts,
|
|
326
|
+
slack_message_ts=f"{parent.task_id}:spec:{issue_id}",
|
|
327
|
+
slack_user_id=parent.slack_user_id,
|
|
328
|
+
request_text=f"/spec2pr {issue_id}",
|
|
329
|
+
parent_task_id=parent.task_id,
|
|
330
|
+
)
|
|
331
|
+
if not created:
|
|
332
|
+
return _text(f"a PR task for issue #{issue_id} was already enqueued ({child.task_id})")
|
|
333
|
+
self.store.link_issue_task(issue_id, child.task_id)
|
|
334
|
+
self.store.add_event(parent.task_id, "issue_enqueued", {"issue_id": issue_id, "child_task_id": child.task_id}, tool_name="mcp__enqueue__enqueue_spec_pr", is_write=True)
|
|
335
|
+
return _text(f"enqueued PR task {child.task_id} for issue #{issue_id}; it runs the /spec2pr skill on its own model and reports its own outcome")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def build_issues_server(adapter: IssuesAdapter):
|
|
339
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
340
|
+
|
|
341
|
+
tools = [
|
|
342
|
+
tool(
|
|
343
|
+
"list_task_feedback",
|
|
344
|
+
"List recent requester feedback on tasks, with each task's request and result, to find where the system fell short. " "Use offset to page past the ~4000-char response cap.",
|
|
345
|
+
{
|
|
346
|
+
"type": "object",
|
|
347
|
+
"properties": {
|
|
348
|
+
"limit": {"type": "integer"},
|
|
349
|
+
"offset": {"type": "integer", "description": "skip this many rows by recency — page through a large backlog"},
|
|
350
|
+
},
|
|
351
|
+
"additionalProperties": False,
|
|
352
|
+
},
|
|
353
|
+
)(wrap(adapter.list_task_feedback, logger)),
|
|
354
|
+
tool(
|
|
355
|
+
"list_failed_tasks",
|
|
356
|
+
"List recent failed and blocked tasks with their error/blocked reason, to find recurring failure modes. " "Use offset to page past the ~4000-char response cap; task_type/query narrow further.",
|
|
357
|
+
{
|
|
358
|
+
"type": "object",
|
|
359
|
+
"properties": {
|
|
360
|
+
"limit": {"type": "integer"},
|
|
361
|
+
"offset": {"type": "integer", "description": "skip this many rows (applied per state: failed, blocked) — page through a large backlog"},
|
|
362
|
+
"task_type": {"type": "string", "description": "keep only rows with this exact task_type"},
|
|
363
|
+
"query": {"type": "string", "description": "substring match against task_id or request_text"},
|
|
364
|
+
},
|
|
365
|
+
"additionalProperties": False,
|
|
366
|
+
},
|
|
367
|
+
)(wrap(adapter.list_failed_tasks, logger)),
|
|
368
|
+
tool(
|
|
369
|
+
"list_recent_errors",
|
|
370
|
+
"List recent internal errors recorded across the system's components. Use component/kind to narrow " "and offset to page past the ~4000-char response cap; traceback_chars trims or (at 0) omits the traceback tail.",
|
|
371
|
+
{
|
|
372
|
+
"type": "object",
|
|
373
|
+
"properties": {
|
|
374
|
+
"limit": {"type": "integer"},
|
|
375
|
+
"offset": {"type": "integer", "description": "skip this many rows — page through a large backlog"},
|
|
376
|
+
"component": {"type": "string", "description": "exact component filter, e.g. 'review_poller'"},
|
|
377
|
+
"kind": {"type": "string", "description": "exact error kind filter, e.g. 'RuntimeError'"},
|
|
378
|
+
"traceback_chars": {"type": "integer", "description": "length of traceback_tail per row (default 600); 0 omits it"},
|
|
379
|
+
},
|
|
380
|
+
"additionalProperties": False,
|
|
381
|
+
},
|
|
382
|
+
)(wrap(adapter.list_recent_errors, logger)),
|
|
383
|
+
tool(
|
|
384
|
+
"record_issue",
|
|
385
|
+
"Record one repo-scoped issue. repo must be approved; dedupe_key is stable across discovery runs. issue_type may be feature_request, bug, security, reliability, performance, or another concise category.",
|
|
386
|
+
{"repo": str, "summary": str, "issue_type": str, "details": str, "dedupe_key": str, "priority": int},
|
|
387
|
+
)(wrap(adapter.record_issue, logger)),
|
|
388
|
+
tool("list_accepted_issues", "List the issues reserved for this implementation run, reserving up to 5 approved ones when none are reserved yet.", {})(wrap(adapter.list_accepted_issues, logger)),
|
|
389
|
+
tool(
|
|
390
|
+
"list_existing_issues",
|
|
391
|
+
"List existing issues so discovery can avoid recording duplicates. Check this before record_issue. " "Use status/offset to page through the full table past the ~4000-char response cap; keys_only returns " "compact id/dedupe_key/status rows so more fit per call.",
|
|
392
|
+
{
|
|
393
|
+
"type": "object",
|
|
394
|
+
"properties": {
|
|
395
|
+
"limit": {"type": "integer"},
|
|
396
|
+
"offset": {"type": "integer", "description": "skip this many rows — page through a large backlog"},
|
|
397
|
+
"repo": {"type": "string"},
|
|
398
|
+
"status": {"type": "string", "description": f"one of {list(ISSUE_STATUSES)}"},
|
|
399
|
+
"keys_only": {"type": "boolean", "description": "return only id/dedupe_key/status per row, to fit more rows under the truncation cap"},
|
|
400
|
+
},
|
|
401
|
+
"additionalProperties": False,
|
|
402
|
+
},
|
|
403
|
+
)(wrap(adapter.list_existing_issues, logger)),
|
|
404
|
+
tool("get_issue", "Get one issue by id, including its stored implementation spec.", {"id": int})(wrap(adapter.get_issue, logger)),
|
|
405
|
+
tool("update_issue", "Update an editable issue's title, markdown description, and priority after reconciling its discussion.", {"id": int, "summary": str, "details": str, "priority": int})(wrap(adapter.update_issue, logger)),
|
|
406
|
+
tool("list_issue_comments", "List an issue's threaded discussion, resolved/deleted state, and attachment filenames.", {"issue_id": int})(wrap(adapter.list_issue_comments, logger)),
|
|
407
|
+
tool(
|
|
408
|
+
"post_issue_comment",
|
|
409
|
+
f"Post a markdown comment or optional one-level reply as {adapter.bot_name}.",
|
|
410
|
+
{
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {"issue_id": {"type": "integer"}, "body": {"type": "string"}, "parent_comment_id": {"type": "integer"}},
|
|
413
|
+
"required": ["issue_id", "body"],
|
|
414
|
+
"additionalProperties": False,
|
|
415
|
+
},
|
|
416
|
+
)(wrap(adapter.post_issue_comment, logger)),
|
|
417
|
+
tool("update_issue_comment", f"Edit one of {adapter.bot_name}'s existing comments.", {"comment_id": int, "body": str})(wrap(adapter.update_issue_comment, logger)),
|
|
418
|
+
tool("delete_issue_comment", f"Soft-delete one of {adapter.bot_name}'s stale comments.", {"comment_id": int})(wrap(adapter.delete_issue_comment, logger)),
|
|
419
|
+
tool("resolve_issue_comment", "Mark an answered discussion comment resolved.", {"comment_id": int})(wrap(adapter.resolve_issue_comment, logger)),
|
|
420
|
+
tool(
|
|
421
|
+
"finish_issue",
|
|
422
|
+
"Record the outcome of an in-progress issue: status 'done' (with the pr_url — stored as in_review until the PR merges) or 'failed'.",
|
|
423
|
+
{"id": int, "status": str, "pr_url": str},
|
|
424
|
+
)(wrap(adapter.finish_issue, logger)),
|
|
425
|
+
]
|
|
426
|
+
return create_sdk_mcp_server(name="issues", version="1.0.0", tools=tools)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_enqueue_server(adapter: EnqueueAdapter):
|
|
430
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
431
|
+
|
|
432
|
+
tools = [
|
|
433
|
+
tool("enqueue_spec_pr", "Hand one approved issue off to a fresh sub-agent that opens a PR. Pass the issue id and the full implementation spec (markdown). Call once per issue.", {"id": int, "spec": str})(wrap(adapter.enqueue_spec_pr, logger)),
|
|
434
|
+
]
|
|
435
|
+
return create_sdk_mcp_server(name="enqueue", version="1.0.0", tools=tools)
|