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
taskboy/adapters/jira.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""jira cloud operations as permission-aware in-process mcp tools (JIR-001..010).
|
|
2
|
+
|
|
3
|
+
a dedicated service-account api token (no delete permissions in jira's own scheme — JIR-006/007)
|
|
4
|
+
never reaches the model. created issues always carry the `taskboy` + `agent-task-{id}` labels and
|
|
5
|
+
a task footer (JIR-008); creation checks the artifacts table and a jql label search first (JIR-009).
|
|
6
|
+
projects and issue types are validated against config (JIR-010).
|
|
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.redact import redactor
|
|
15
|
+
from taskboy.store import Store
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("taskboy.jira")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JiraAdapter:
|
|
21
|
+
def __init__(self, store: Store, task: Task, site: str, email: str, api_token: str, projects: list[str], issue_types: list[str], on_milestone=None, story_points_field: str = "", bot_name: str = "Agent"):
|
|
22
|
+
self.store = store
|
|
23
|
+
self.task = task
|
|
24
|
+
self.site = site.rstrip("/")
|
|
25
|
+
self.email = email
|
|
26
|
+
self.api_token = api_token
|
|
27
|
+
self.projects = projects
|
|
28
|
+
self.issue_types = issue_types
|
|
29
|
+
self.on_milestone = on_milestone
|
|
30
|
+
self.story_points_field = story_points_field
|
|
31
|
+
self.bot_name = bot_name
|
|
32
|
+
|
|
33
|
+
# -- tools -------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
async def search_issues(self, args: dict) -> dict:
|
|
36
|
+
data = await self._request("GET", "/rest/api/3/search/jql", params={"jql": str(args.get("jql", "")), "maxResults": min(int(args.get("max_results", 10)), 20), "fields": "summary,status,issuetype,assignee"})
|
|
37
|
+
lines = []
|
|
38
|
+
for issue in data.get("issues") or []:
|
|
39
|
+
fields = issue.get("fields") or {}
|
|
40
|
+
lines.append(f"{issue.get('key')}: {str(fields.get('summary', ''))[:120]} [{(fields.get('status') or {}).get('name')}] ({(fields.get('issuetype') or {}).get('name')})")
|
|
41
|
+
return _text("\n".join(lines) or "no issues matched")
|
|
42
|
+
|
|
43
|
+
async def get_issue(self, args: dict) -> dict:
|
|
44
|
+
key = str(args.get("key", "")).strip().upper()
|
|
45
|
+
data = await self._request("GET", f"/rest/api/3/issue/{key}", params={"fields": "summary,status,issuetype,assignee,labels,description,priority"})
|
|
46
|
+
fields = data.get("fields") or {}
|
|
47
|
+
summary = {
|
|
48
|
+
"key": data.get("key"),
|
|
49
|
+
"summary": fields.get("summary"),
|
|
50
|
+
"status": (fields.get("status") or {}).get("name"),
|
|
51
|
+
"type": (fields.get("issuetype") or {}).get("name"),
|
|
52
|
+
"assignee": (fields.get("assignee") or {}).get("displayName"),
|
|
53
|
+
"labels": fields.get("labels"),
|
|
54
|
+
"priority": (fields.get("priority") or {}).get("name"),
|
|
55
|
+
"description": _adf_to_text(fields.get("description"))[:2000],
|
|
56
|
+
}
|
|
57
|
+
return _text(json.dumps(summary, ensure_ascii=False))
|
|
58
|
+
|
|
59
|
+
async def search_users(self, args: dict) -> dict:
|
|
60
|
+
users = await self._request("GET", "/rest/api/3/user/search", params={"query": str(args.get("query", "")), "maxResults": 10})
|
|
61
|
+
lines = [f"{user.get('accountId')}: {user.get('displayName')} ({user.get('emailAddress') or 'hidden'})" for user in users]
|
|
62
|
+
return _text("\n".join(lines) or "no users matched")
|
|
63
|
+
|
|
64
|
+
async def list_boards(self, args: dict) -> dict:
|
|
65
|
+
params: dict = {"maxResults": 20}
|
|
66
|
+
project = str(args.get("project") or "").strip().upper()
|
|
67
|
+
if project:
|
|
68
|
+
params["projectKeyOrId"] = project
|
|
69
|
+
data = await self._request("GET", "/rest/agile/1.0/board", params=params)
|
|
70
|
+
lines = [f"{board.get('id')}: {board.get('name')} ({board.get('type')})" for board in data.get("values") or []]
|
|
71
|
+
return _text("\n".join(lines) or "no boards matched")
|
|
72
|
+
|
|
73
|
+
async def list_sprints(self, args: dict) -> dict:
|
|
74
|
+
board_id = int(args.get("board_id") or 0)
|
|
75
|
+
data = await self._request("GET", f"/rest/agile/1.0/board/{board_id}/sprint", params={"state": "active,future", "maxResults": 20})
|
|
76
|
+
lines = [f"{sprint.get('id')}: {sprint.get('name')} [{sprint.get('state')}]" for sprint in data.get("values") or []]
|
|
77
|
+
return _text("\n".join(lines) or "no active or future sprints matched")
|
|
78
|
+
|
|
79
|
+
async def create_issue(self, args: dict) -> dict:
|
|
80
|
+
project = str(args.get("project", "")).strip().upper()
|
|
81
|
+
issue_type = str(args.get("issue_type", "")).strip()
|
|
82
|
+
summary = str(args.get("summary", "")).strip()
|
|
83
|
+
if self.projects and project not in self.projects:
|
|
84
|
+
return _error(f"project {project!r} is not on the approved list {self.projects}")
|
|
85
|
+
if self.issue_types and issue_type not in self.issue_types:
|
|
86
|
+
return _error(f"issue type {issue_type!r} is not permitted; use one of {self.issue_types}")
|
|
87
|
+
if not summary:
|
|
88
|
+
return _error("summary is required")
|
|
89
|
+
|
|
90
|
+
# idempotency layer 1: this task already created an issue with this summary (JIR-009, ORC-012)
|
|
91
|
+
for artifact in self.store.artifacts_for(self.task.task_id):
|
|
92
|
+
if artifact["kind"] == "jira_issue":
|
|
93
|
+
return _text(f"this task already created {artifact['external_id']} ({artifact['url']}) — comment on or update it instead of creating another")
|
|
94
|
+
# idempotency layer 2: jql on the task label catches the crash-after-create window
|
|
95
|
+
existing = await self._request("GET", "/rest/api/3/search/jql", params={"jql": f'labels = "agent-task-{self.task.task_id}"', "maxResults": 1, "fields": "summary"})
|
|
96
|
+
if existing.get("issues"):
|
|
97
|
+
issue = existing["issues"][0]
|
|
98
|
+
self._record_issue(issue["key"])
|
|
99
|
+
return _text(f"an issue already exists for this task: {issue['key']} — update it instead of creating another")
|
|
100
|
+
|
|
101
|
+
description = str(args.get("description", "")).rstrip()
|
|
102
|
+
footer = f"Created by {self.bot_name} (taskboy) for Slack task {self.task.task_id} in <#{self.task.slack_channel_id}>."
|
|
103
|
+
fields: dict = {
|
|
104
|
+
"project": {"key": project},
|
|
105
|
+
"issuetype": {"name": issue_type},
|
|
106
|
+
"summary": summary,
|
|
107
|
+
"description": _adf(f"{description}\n\n{footer}" if description else footer),
|
|
108
|
+
"labels": ["taskboy", f"agent-task-{self.task.task_id}"], # JIR-008
|
|
109
|
+
}
|
|
110
|
+
assignee = str(args.get("assignee_account_id") or "").strip()
|
|
111
|
+
parent_key = str(args.get("parent_key") or "").strip()
|
|
112
|
+
story_points = float(args.get("story_points") or 0)
|
|
113
|
+
if assignee:
|
|
114
|
+
fields["assignee"] = {"accountId": assignee}
|
|
115
|
+
if parent_key:
|
|
116
|
+
fields["parent"] = {"key": parent_key.upper()}
|
|
117
|
+
points_note = ""
|
|
118
|
+
if story_points > 0:
|
|
119
|
+
if self.story_points_field:
|
|
120
|
+
fields[self.story_points_field] = story_points
|
|
121
|
+
else:
|
|
122
|
+
points_note = " — story points not set — configure jira.story_points_field"
|
|
123
|
+
payload = {"fields": fields}
|
|
124
|
+
created = await self._request("POST", "/rest/api/3/issue", payload)
|
|
125
|
+
key = str(created["key"])
|
|
126
|
+
self._record_issue(key)
|
|
127
|
+
if self.on_milestone:
|
|
128
|
+
await self.on_milestone(f"Created Jira issue {key}")
|
|
129
|
+
return _text(f"created {key}: {self.site}/browse/{key}{points_note}")
|
|
130
|
+
|
|
131
|
+
async def add_comment(self, args: dict) -> dict:
|
|
132
|
+
key = str(args.get("key", "")).strip().upper()
|
|
133
|
+
comment = await self._request("POST", f"/rest/api/3/issue/{key}/comment", {"body": _adf(str(args.get("body", "")))})
|
|
134
|
+
self.store.add_artifact(self.task.task_id, "jira_comment", f"{key}/comment/{comment.get('id')}", f"{self.site}/browse/{key}")
|
|
135
|
+
return _text(f"commented on {key}")
|
|
136
|
+
|
|
137
|
+
async def assign_issue(self, args: dict) -> dict:
|
|
138
|
+
key = str(args.get("key", "")).strip().upper()
|
|
139
|
+
account_id = str(args.get("account_id", "")).strip()
|
|
140
|
+
await self._request("PUT", f"/rest/api/3/issue/{key}/assignee", {"accountId": account_id})
|
|
141
|
+
self.store.add_event(self.task.task_id, "tool_call", {"jira_assignment": f"{key} -> {account_id}"}, tool_name="mcp__jira__assign_issue", is_write=True)
|
|
142
|
+
return _text(f"assigned {key} to account {account_id}")
|
|
143
|
+
|
|
144
|
+
async def move_to_sprint(self, args: dict) -> dict:
|
|
145
|
+
key = str(args.get("key", "")).strip().upper()
|
|
146
|
+
sprint_id = int(args.get("sprint_id") or 0)
|
|
147
|
+
await self._request("POST", f"/rest/agile/1.0/sprint/{sprint_id}/issue", {"issues": [key]})
|
|
148
|
+
self.store.add_event(self.task.task_id, "tool_call", {"jira_sprint": f"{key} -> {sprint_id}"}, tool_name="mcp__jira__move_to_sprint", is_write=True)
|
|
149
|
+
return _text(f"moved {key} to sprint {sprint_id}")
|
|
150
|
+
|
|
151
|
+
async def set_epic(self, args: dict) -> dict:
|
|
152
|
+
key = str(args.get("key", "")).strip().upper()
|
|
153
|
+
epic_key = str(args.get("epic_key", "")).strip().upper()
|
|
154
|
+
await self._request("PUT", f"/rest/api/3/issue/{key}", {"fields": {"parent": {"key": epic_key}}})
|
|
155
|
+
self.store.add_event(self.task.task_id, "tool_call", {"jira_epic": f"{key} -> {epic_key}"}, tool_name="mcp__jira__set_epic", is_write=True)
|
|
156
|
+
return _text(f"set {epic_key} as the epic for {key}")
|
|
157
|
+
|
|
158
|
+
async def set_story_points(self, args: dict) -> dict:
|
|
159
|
+
if not self.story_points_field:
|
|
160
|
+
return _error("story points field is not configured — set jira.story_points_field")
|
|
161
|
+
key = str(args.get("key", "")).strip().upper()
|
|
162
|
+
points = float(args.get("points") or 0)
|
|
163
|
+
await self._request("PUT", f"/rest/api/3/issue/{key}", {"fields": {self.story_points_field: points}})
|
|
164
|
+
self.store.add_event(self.task.task_id, "tool_call", {"jira_story_points": f"{key} -> {points}"}, tool_name="mcp__jira__set_story_points", is_write=True)
|
|
165
|
+
return _text(f"set {key} story points to {points:g}")
|
|
166
|
+
|
|
167
|
+
async def transition_issue(self, args: dict) -> dict:
|
|
168
|
+
key = str(args.get("key", "")).strip().upper()
|
|
169
|
+
wanted = str(args.get("transition", "")).strip().lower()
|
|
170
|
+
data = await self._request("GET", f"/rest/api/3/issue/{key}/transitions")
|
|
171
|
+
match = next((t for t in data.get("transitions") or [] if str(t.get("name", "")).lower() == wanted), None)
|
|
172
|
+
if match is None:
|
|
173
|
+
names = [t.get("name") for t in data.get("transitions") or []]
|
|
174
|
+
return _error(f"transition {wanted!r} is not available for {key}; available: {names}")
|
|
175
|
+
await self._request("POST", f"/rest/api/3/issue/{key}/transitions", {"transition": {"id": match["id"]}})
|
|
176
|
+
self.store.add_event(self.task.task_id, "tool_call", {"jira_transition": f"{key} -> {match.get('name')}"}, tool_name="mcp__jira__transition_issue", is_write=True)
|
|
177
|
+
return _text(f"transitioned {key} to {match.get('name')}")
|
|
178
|
+
|
|
179
|
+
async def link_pr(self, args: dict) -> dict:
|
|
180
|
+
"""attach a github pull request to a jira issue as a remote link (JIR-005, GIT-013)."""
|
|
181
|
+
key = str(args.get("key", "")).strip().upper()
|
|
182
|
+
url = str(args.get("pr_url", "")).strip()
|
|
183
|
+
if not url.startswith("https://github.com/"):
|
|
184
|
+
return _error("pr_url must be a github pull request url")
|
|
185
|
+
await self._request("POST", f"/rest/api/3/issue/{key}/remotelink", {"object": {"url": url, "title": str(args.get("title", "")) or url}})
|
|
186
|
+
self.store.add_artifact(self.task.task_id, "jira_link", f"{key} -> {url}", url)
|
|
187
|
+
return _text(f"linked {url} to {key}")
|
|
188
|
+
|
|
189
|
+
# -- plumbing ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _record_issue(self, key: str) -> None:
|
|
192
|
+
self.store.add_artifact(self.task.task_id, "jira_issue", key, f"{self.site}/browse/{key}")
|
|
193
|
+
|
|
194
|
+
async def _request(self, method: str, path: str, payload: dict | None = None, params: dict | None = None) -> dict:
|
|
195
|
+
"""the http seam — patched in unit tests. basic auth with the service-account token (TOL-007)."""
|
|
196
|
+
import aiohttp
|
|
197
|
+
|
|
198
|
+
auth = aiohttp.BasicAuth(self.email, self.api_token)
|
|
199
|
+
async with aiohttp.ClientSession(auth=auth) as session:
|
|
200
|
+
async with session.request(method, self.site + path, json=payload, params=params, headers={"Accept": "application/json"}) as response:
|
|
201
|
+
if response.status >= 300:
|
|
202
|
+
body = redactor.redact(await response.text())[:300]
|
|
203
|
+
raise RuntimeError(f"jira api {method} {path} failed: {response.status} — {body}")
|
|
204
|
+
return await response.json() if response.status != 204 else {}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _adf(text: str) -> dict:
|
|
208
|
+
"""minimal atlassian document format: one paragraph per text block."""
|
|
209
|
+
paragraphs = [{"type": "paragraph", "content": [{"type": "text", "text": block}]} for block in text.split("\n\n") if block.strip()]
|
|
210
|
+
return {"type": "doc", "version": 1, "content": paragraphs or [{"type": "paragraph", "content": [{"type": "text", "text": " "}]}]}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _adf_to_text(node) -> str:
|
|
214
|
+
"""flatten adf to plain text for bounded model context (TOL-006)."""
|
|
215
|
+
if node is None:
|
|
216
|
+
return ""
|
|
217
|
+
if isinstance(node, dict):
|
|
218
|
+
own = node.get("text", "")
|
|
219
|
+
children = "".join(_adf_to_text(child) for child in node.get("content") or [])
|
|
220
|
+
suffix = "\n" if node.get("type") in ("paragraph", "heading") else ""
|
|
221
|
+
return f"{own}{children}{suffix}"
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def build_jira_server(adapter: JiraAdapter):
|
|
226
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
227
|
+
|
|
228
|
+
tools = [
|
|
229
|
+
tool("search_issues", "Search Jira issues with JQL.", {"jql": str, "max_results": int})(wrap(adapter.search_issues, logger)),
|
|
230
|
+
tool("search_users", "Search Jira users and return account ids for assignment.", {"query": str})(wrap(adapter.search_users, logger)),
|
|
231
|
+
tool("get_issue", "Get one Jira issue (summary, status, description, labels).", {"key": str})(wrap(adapter.get_issue, logger)),
|
|
232
|
+
tool(
|
|
233
|
+
"list_boards",
|
|
234
|
+
"List Jira boards, optionally for one project.",
|
|
235
|
+
{"type": "object", "properties": {"project": {"type": "string"}}, "required": []},
|
|
236
|
+
)(wrap(adapter.list_boards, logger)),
|
|
237
|
+
tool("list_sprints", "List active and future sprints on a Jira board.", {"board_id": int})(wrap(adapter.list_sprints, logger)),
|
|
238
|
+
tool(
|
|
239
|
+
"create_issue",
|
|
240
|
+
"Create a Jira issue in an approved project. Search for duplicates first.",
|
|
241
|
+
{
|
|
242
|
+
"type": "object",
|
|
243
|
+
"properties": {
|
|
244
|
+
"project": {"type": "string"},
|
|
245
|
+
"issue_type": {"type": "string"},
|
|
246
|
+
"summary": {"type": "string"},
|
|
247
|
+
"description": {"type": "string"},
|
|
248
|
+
"assignee_account_id": {"type": "string"},
|
|
249
|
+
"parent_key": {"type": "string"},
|
|
250
|
+
"story_points": {"type": "number"},
|
|
251
|
+
},
|
|
252
|
+
"required": ["project", "issue_type", "summary", "description"],
|
|
253
|
+
},
|
|
254
|
+
)(wrap(adapter.create_issue, logger)),
|
|
255
|
+
tool("add_comment", "Add a comment to a Jira issue.", {"key": str, "body": str})(wrap(adapter.add_comment, logger)),
|
|
256
|
+
tool("assign_issue", "Assign a Jira issue using an account id from search_users.", {"key": str, "account_id": str})(wrap(adapter.assign_issue, logger)),
|
|
257
|
+
tool("transition_issue", "Move a Jira issue through its workflow (e.g. 'In Progress', 'Done').", {"key": str, "transition": str})(wrap(adapter.transition_issue, logger)),
|
|
258
|
+
tool("move_to_sprint", "Move a Jira issue into a sprint.", {"key": str, "sprint_id": int})(wrap(adapter.move_to_sprint, logger)),
|
|
259
|
+
tool("set_epic", "Set the epic parent for a Jira story.", {"key": str, "epic_key": str})(wrap(adapter.set_epic, logger)),
|
|
260
|
+
tool("set_story_points", "Set the story point estimate on a Jira issue.", {"key": str, "points": float})(wrap(adapter.set_story_points, logger)),
|
|
261
|
+
tool("link_pr", "Attach a GitHub pull request link to a Jira issue.", {"key": str, "pr_url": str, "title": str})(wrap(adapter.link_pr, logger)),
|
|
262
|
+
]
|
|
263
|
+
return create_sdk_mcp_server(name="jira", version="1.0.0", tools=tools)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""sentry diagnostics as read-only in-process mcp tools (SEN-001..006).
|
|
2
|
+
|
|
3
|
+
strictly read-only in v1 (SEN-003): the adapter implements no write operations at all.
|
|
4
|
+
event payloads are aggressively trimmed before entering model context (TOL-006, SEN-005).
|
|
5
|
+
every access is attributable via the tool-call audit trail (SEN-006).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from taskboy.adapters._util import _error, _text, wrap
|
|
12
|
+
from taskboy.models import Task
|
|
13
|
+
from taskboy.redact import redactor
|
|
14
|
+
from taskboy.store import Store
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("taskboy.sentry")
|
|
17
|
+
|
|
18
|
+
SENTRY_API = "https://sentry.io/api/0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SentryAdapter:
|
|
22
|
+
def __init__(self, store: Store, task: Task, organization: str, token: str, projects: list[str]):
|
|
23
|
+
self.store = store
|
|
24
|
+
self.task = task
|
|
25
|
+
self.organization = organization
|
|
26
|
+
self.token = token
|
|
27
|
+
self.projects = projects
|
|
28
|
+
|
|
29
|
+
async def list_issues(self, args: dict) -> dict:
|
|
30
|
+
project = str(args.get("project", "")).strip()
|
|
31
|
+
if self.projects and project not in self.projects:
|
|
32
|
+
return _error(f"project {project!r} is not on the approved list {self.projects}")
|
|
33
|
+
params = {"query": str(args.get("query", "is:unresolved")), "limit": str(min(int(args.get("limit", 10)), 20)), "sort": "freq"}
|
|
34
|
+
issues = await self._request(f"/projects/{self.organization}/{project}/issues/", params)
|
|
35
|
+
lines = [f"{i.get('shortId')} [{i.get('id')}]: {str(i.get('title', ''))[:120]} | {i.get('count')} events, {i.get('userCount')} users | last {i.get('lastSeen')}" for i in issues[:20]]
|
|
36
|
+
return _text("\n".join(lines) or "no issues matched")
|
|
37
|
+
|
|
38
|
+
async def get_issue(self, args: dict) -> dict:
|
|
39
|
+
issue = await self._request(f"/issues/{str(args.get('issue_id', '')).strip()}/", {})
|
|
40
|
+
summary = {key: issue.get(key) for key in ("shortId", "title", "culprit", "level", "status", "count", "userCount", "firstSeen", "lastSeen", "permalink")}
|
|
41
|
+
summary["project"] = (issue.get("project") or {}).get("slug")
|
|
42
|
+
return _text(json.dumps(summary, ensure_ascii=False))
|
|
43
|
+
|
|
44
|
+
async def get_latest_event(self, args: dict) -> dict:
|
|
45
|
+
"""the stack trace, trimmed to what an investigation needs (SEN-002, SEN-005)."""
|
|
46
|
+
event = await self._request(f"/issues/{str(args.get('issue_id', '')).strip()}/events/latest/", {})
|
|
47
|
+
lines = [f"title: {event.get('title')}", f"message: {str(event.get('message', ''))[:300]}"]
|
|
48
|
+
for entry in event.get("entries") or []:
|
|
49
|
+
if entry.get("type") != "exception":
|
|
50
|
+
continue
|
|
51
|
+
for value in (entry.get("data") or {}).get("values") or []:
|
|
52
|
+
lines.append(f"exception: {value.get('type')}: {str(value.get('value', ''))[:200]}")
|
|
53
|
+
frames = ((value.get("stacktrace") or {}).get("frames") or [])[-12:]
|
|
54
|
+
for frame in frames:
|
|
55
|
+
marker = " (in app)" if frame.get("inApp") else ""
|
|
56
|
+
lines.append(f" {frame.get('filename')}:{frame.get('lineNo')} in {frame.get('function')}{marker}")
|
|
57
|
+
tags = {tag.get("key"): tag.get("value") for tag in (event.get("tags") or [])[:12]}
|
|
58
|
+
lines.append(f"tags: {json.dumps(tags, ensure_ascii=False)}")
|
|
59
|
+
return _text("\n".join(lines))
|
|
60
|
+
|
|
61
|
+
async def _request(self, path: str, params: dict):
|
|
62
|
+
"""the http seam — patched in unit tests."""
|
|
63
|
+
import aiohttp
|
|
64
|
+
|
|
65
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
66
|
+
async with aiohttp.ClientSession(headers=headers) as session:
|
|
67
|
+
async with session.get(SENTRY_API + path, params=params) as response:
|
|
68
|
+
if response.status >= 300:
|
|
69
|
+
body = redactor.redact(await response.text())[:300]
|
|
70
|
+
raise RuntimeError(f"sentry api GET {path} failed: {response.status} — {body}")
|
|
71
|
+
return await response.json()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_sentry_server(adapter: SentryAdapter):
|
|
75
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
76
|
+
|
|
77
|
+
tools = [
|
|
78
|
+
tool("list_issues", "List Sentry issues in an approved project (query uses Sentry search syntax).", {"project": str, "query": str, "limit": int})(wrap(adapter.list_issues, logger)),
|
|
79
|
+
tool("get_issue", "Get one Sentry issue's summary by numeric id.", {"issue_id": str})(wrap(adapter.get_issue, logger)),
|
|
80
|
+
tool("get_latest_event", "Get the latest event for a Sentry issue: exception, stack trace, tags.", {"issue_id": str})(wrap(adapter.get_latest_event, logger)),
|
|
81
|
+
]
|
|
82
|
+
return create_sdk_mcp_server(name="sentry", version="1.0.0", tools=tools)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""task-channel-scoped Slack history as a read-only in-process mcp tool."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from taskboy.adapters._util import _error, _text, wrap
|
|
10
|
+
from taskboy.models import Task
|
|
11
|
+
from taskboy.mrkdwn import to_mrkdwn
|
|
12
|
+
from taskboy.redact import redactor
|
|
13
|
+
from taskboy.slack_users import cached_user_profile
|
|
14
|
+
from taskboy.store import Store
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("taskboy.slack_history")
|
|
17
|
+
|
|
18
|
+
HISTORY_MAX_LIMIT = 50
|
|
19
|
+
HISTORY_DEFAULT_LIMIT = 20
|
|
20
|
+
FILE_MAX_BYTES = 20 * 1024 * 1024
|
|
21
|
+
INLINE_TEXT_MAX_BYTES = 50 * 1024
|
|
22
|
+
INLINE_TEXT_MAX_CHARS = 3000
|
|
23
|
+
TEXT_FILETYPES = {"csv", "html", "javascript", "json", "log", "markdown", "python", "shell", "text", "xml", "yaml"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SlackHistoryAdapter:
|
|
27
|
+
def __init__(self, store: Store, task: Task, client, allowed_channels: list[str] | None = None, files_dir: str | Path | None = None):
|
|
28
|
+
self.store = store
|
|
29
|
+
self.task = task
|
|
30
|
+
self.client = client
|
|
31
|
+
self.allowed_channels = allowed_channels or []
|
|
32
|
+
self.files_dir = Path(files_dir) if files_dir is not None else None
|
|
33
|
+
|
|
34
|
+
async def channel_history(self, args: dict) -> dict:
|
|
35
|
+
oldest = str(args.get("oldest") or "") or None
|
|
36
|
+
latest = str(args.get("latest") or "") or None
|
|
37
|
+
cursor = str(args.get("cursor") or "") or None
|
|
38
|
+
limit = min(max(int(args.get("limit", HISTORY_DEFAULT_LIMIT)), 1), HISTORY_MAX_LIMIT)
|
|
39
|
+
self.store.add_event(self.task.task_id, "tool_call", {"oldest": oldest, "latest": latest, "limit": limit, "cursor": cursor}, tool_name="mcp__slack__channel_history", is_write=False)
|
|
40
|
+
data = await self._fetch(oldest, latest, limit, cursor)
|
|
41
|
+
lines = []
|
|
42
|
+
for message in reversed(data.get("messages") or []):
|
|
43
|
+
content = _message_content(message)
|
|
44
|
+
if not content:
|
|
45
|
+
continue
|
|
46
|
+
lines.append(f"[{_iso(message.get('ts'))}] <@{message.get('user', '')}>: {content}")
|
|
47
|
+
next_cursor = str((data.get("response_metadata") or {}).get("next_cursor") or "")
|
|
48
|
+
if next_cursor:
|
|
49
|
+
lines.append(f"more: pass cursor={next_cursor}")
|
|
50
|
+
return _text("\n".join(lines) or "no messages matched")
|
|
51
|
+
|
|
52
|
+
async def thread_replies(self, args: dict) -> dict:
|
|
53
|
+
channel = str(args.get("channel") or self.task.slack_channel_id)
|
|
54
|
+
if channel != self.task.slack_channel_id and channel not in self.allowed_channels:
|
|
55
|
+
return _error(f"channel {channel!r} is not allowed for this task")
|
|
56
|
+
thread_ts = str(args.get("thread_ts") or "")
|
|
57
|
+
if not thread_ts:
|
|
58
|
+
return _error("thread_ts is required")
|
|
59
|
+
limit = min(max(int(args.get("limit", HISTORY_DEFAULT_LIMIT)), 1), HISTORY_MAX_LIMIT)
|
|
60
|
+
self.store.add_event(self.task.task_id, "tool_call", {"channel": channel, "thread_ts": thread_ts, "limit": limit}, tool_name="mcp__slack__thread_replies", is_write=False)
|
|
61
|
+
data = await self.client.conversations_replies(channel=channel, ts=thread_ts, limit=limit)
|
|
62
|
+
lines = []
|
|
63
|
+
for message in data.get("messages") or []:
|
|
64
|
+
content = _message_content(message)
|
|
65
|
+
if content:
|
|
66
|
+
lines.append(f"[{_iso(message.get('ts'))}] <@{message.get('user', '')}>: {content}")
|
|
67
|
+
return _text("\n".join(lines) or "no messages matched")
|
|
68
|
+
|
|
69
|
+
async def get_file(self, args: dict) -> dict:
|
|
70
|
+
file_id = str(args.get("file_id") or "").strip()
|
|
71
|
+
if not file_id:
|
|
72
|
+
return _error("file_id is required")
|
|
73
|
+
self.store.add_event(self.task.task_id, "tool_call", {"file_id": file_id}, tool_name="mcp__slack__get_file", is_write=False)
|
|
74
|
+
if self.files_dir is None:
|
|
75
|
+
return _error("file downloads are not available for this task")
|
|
76
|
+
data = await self.client.files_info(file=file_id)
|
|
77
|
+
file = data.get("file") or {}
|
|
78
|
+
shared_channels = {str(channel) for channel in (file.get("channels") or []) + (file.get("groups") or []) + (file.get("ims") or [])}
|
|
79
|
+
readable_channels = {self.task.slack_channel_id, *self.allowed_channels}
|
|
80
|
+
if not shared_channels.intersection(readable_channels):
|
|
81
|
+
return _error(f"file {file_id} is not shared in a channel this task may read")
|
|
82
|
+
size = int(file.get("size") or 0)
|
|
83
|
+
if size > FILE_MAX_BYTES:
|
|
84
|
+
return _error(f"file {file_id} is larger than the 20 MB download limit")
|
|
85
|
+
url = str(file.get("url_private_download") or file.get("url_private") or "")
|
|
86
|
+
if not url:
|
|
87
|
+
return _error(f"file {file_id} has no download url")
|
|
88
|
+
content = await self._download_file(url)
|
|
89
|
+
if len(content) > FILE_MAX_BYTES:
|
|
90
|
+
return _error(f"file {file_id} is larger than the 20 MB download limit")
|
|
91
|
+
name = str(file.get("name") or file.get("title") or "file")
|
|
92
|
+
safe_name = re.sub(r"[/\\\\\x00]+", "_", name).strip() or "file"
|
|
93
|
+
self.files_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
destination = self.files_dir / f"{file_id}-{safe_name}"
|
|
95
|
+
destination.write_bytes(content)
|
|
96
|
+
relative_path = f"{self.files_dir.name}/{destination.name}"
|
|
97
|
+
mimetype = str(file.get("mimetype") or "application/octet-stream")
|
|
98
|
+
result = f"saved {relative_path} (name={name}, type={mimetype}, size={len(content)})"
|
|
99
|
+
filetype = str(file.get("filetype") or "").lower()
|
|
100
|
+
if len(content) <= INLINE_TEXT_MAX_BYTES and (mimetype.startswith("text/") or filetype in TEXT_FILETYPES):
|
|
101
|
+
result += f"\n\ncontent:\n{content.decode('utf-8', errors='replace')[:INLINE_TEXT_MAX_CHARS]}"
|
|
102
|
+
return _text(result)
|
|
103
|
+
|
|
104
|
+
async def user_info(self, args: dict) -> dict:
|
|
105
|
+
user_id = str(args.get("user") or "")
|
|
106
|
+
if not user_id:
|
|
107
|
+
return _error("user is required")
|
|
108
|
+
self.store.add_event(self.task.task_id, "tool_call", {"user": user_id}, tool_name="mcp__slack__user_info", is_write=False)
|
|
109
|
+
profile = self.store.get_slack_user(user_id)
|
|
110
|
+
try:
|
|
111
|
+
profile = await cached_user_profile(self.store, self.client, user_id)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
self.store.add_error("slack", type(e).__name__, str(e), task_id=self.task.task_id, context={"operation": "users_info", "user_id": user_id})
|
|
114
|
+
if profile is None:
|
|
115
|
+
raise
|
|
116
|
+
return _text(json.dumps(profile, sort_keys=True))
|
|
117
|
+
|
|
118
|
+
async def send_dm(self, args: dict) -> dict:
|
|
119
|
+
user_id = str(args.get("user") or "").strip()
|
|
120
|
+
message = str(args.get("message") or "").strip()
|
|
121
|
+
if not user_id:
|
|
122
|
+
return _error("user is required")
|
|
123
|
+
if not message:
|
|
124
|
+
return _error("message is required")
|
|
125
|
+
self.store.add_event(self.task.task_id, "tool_call", {"user": user_id}, tool_name="mcp__slack__send_dm", is_write=True)
|
|
126
|
+
try:
|
|
127
|
+
opened = await self.client.conversations_open(users=user_id)
|
|
128
|
+
channel_id = str((opened.get("channel") or {}).get("id") or "")
|
|
129
|
+
if not channel_id:
|
|
130
|
+
raise RuntimeError("slack did not return a dm channel")
|
|
131
|
+
await self.client.chat_postMessage(channel=channel_id, text=redactor.redact(to_mrkdwn(message)))
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self.store.add_error("slack", type(e).__name__, str(e), task_id=self.task.task_id, context={"operation": "send_dm", "user_id": user_id})
|
|
134
|
+
return _error(str(e))
|
|
135
|
+
return _text(f"sent a direct message to <@{user_id}>")
|
|
136
|
+
|
|
137
|
+
async def _fetch(self, oldest, latest, limit, cursor) -> dict:
|
|
138
|
+
kwargs = {"channel": self.task.slack_channel_id, "limit": limit}
|
|
139
|
+
if oldest:
|
|
140
|
+
kwargs["oldest"] = oldest
|
|
141
|
+
if latest:
|
|
142
|
+
kwargs["latest"] = latest
|
|
143
|
+
if cursor:
|
|
144
|
+
kwargs["cursor"] = cursor
|
|
145
|
+
return await self.client.conversations_history(**kwargs)
|
|
146
|
+
|
|
147
|
+
async def _download_file(self, url: str) -> bytes:
|
|
148
|
+
import aiohttp
|
|
149
|
+
|
|
150
|
+
headers = {"Authorization": f"Bearer {self.client.token}"}
|
|
151
|
+
async with aiohttp.ClientSession() as session:
|
|
152
|
+
async with session.get(url, headers=headers) as response:
|
|
153
|
+
if response.status >= 300:
|
|
154
|
+
raise RuntimeError(redactor.redact(f"slack file download failed: {response.status}"))
|
|
155
|
+
return await response.read()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _iso(value) -> str:
|
|
159
|
+
try:
|
|
160
|
+
return datetime.fromtimestamp(float(value), timezone.utc).isoformat()
|
|
161
|
+
except (TypeError, ValueError, OSError):
|
|
162
|
+
return str(value or "")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _message_content(message: dict) -> str:
|
|
166
|
+
parts = []
|
|
167
|
+
text = str(message.get("text") or "").strip()
|
|
168
|
+
if text:
|
|
169
|
+
parts.append(text)
|
|
170
|
+
for file in message.get("files") or []:
|
|
171
|
+
parts.append(f"(file: {file.get('name')} id={file.get('id')} type={file.get('mimetype')})")
|
|
172
|
+
return " ".join(parts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_slack_server(adapter: SlackHistoryAdapter):
|
|
176
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
177
|
+
|
|
178
|
+
channel_history = tool(
|
|
179
|
+
"channel_history",
|
|
180
|
+
"Read only the Slack channel this task originated in. Thread replies appear only as parents; page older history with cursor or oldest.",
|
|
181
|
+
{"oldest": str, "latest": str, "limit": int, "cursor": str},
|
|
182
|
+
)(wrap(adapter.channel_history, logger))
|
|
183
|
+
thread_replies = tool(
|
|
184
|
+
"thread_replies",
|
|
185
|
+
"Read one Slack thread in the originating channel or another configured allowed channel.",
|
|
186
|
+
{
|
|
187
|
+
"type": "object",
|
|
188
|
+
"properties": {"channel": {"type": "string"}, "thread_ts": {"type": "string"}, "limit": {"type": "integer"}},
|
|
189
|
+
"required": ["thread_ts"],
|
|
190
|
+
},
|
|
191
|
+
)(wrap(adapter.thread_replies, logger))
|
|
192
|
+
user_info = tool("user_info", "Look up a Slack user's cached profile, refreshing it when stale.", {"user": str})(wrap(adapter.user_info, logger))
|
|
193
|
+
get_file = tool(
|
|
194
|
+
"get_file",
|
|
195
|
+
"Download a file attached to a Slack message in an allowed channel into the workspace (slack_files/) so it can be inspected. Pass the file id shown in channel_history/thread_replies output.",
|
|
196
|
+
{"file_id": str},
|
|
197
|
+
)(wrap(adapter.get_file, logger))
|
|
198
|
+
send_dm = tool(
|
|
199
|
+
"send_dm",
|
|
200
|
+
"Send a Slack direct message only when the task genuinely calls for contacting that user directly.",
|
|
201
|
+
{"user": str, "message": str},
|
|
202
|
+
)(wrap(adapter.send_dm, logger))
|
|
203
|
+
tools = [channel_history, thread_replies, user_info, get_file, send_dm]
|
|
204
|
+
return create_sdk_mcp_server(name="slack", version="1.0.0", tools=tools)
|
taskboy/assets.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""locations of the assets shipped inside the installed package (wheel or editable checkout).
|
|
2
|
+
|
|
3
|
+
templates/ holds the seed material the wizard and operators copy out (config example,
|
|
4
|
+
personalities, skill templates, slack manifest); deploy/ holds the host runtime files
|
|
5
|
+
(systemd units, env example, git credential helper); ui_dist/ is the built dashboard.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
PACKAGE_ROOT = Path(str(files("taskboy")))
|
|
13
|
+
TEMPLATES_ROOT = PACKAGE_ROOT / "templates"
|
|
14
|
+
DEPLOY_ROOT = PACKAGE_ROOT / "deploy"
|
|
15
|
+
UI_DIST = PACKAGE_ROOT / "ui_dist"
|
|
16
|
+
GIT_CRED_HELPER = DEPLOY_ROOT / "git-cred-helper.py"
|
|
17
|
+
|
|
18
|
+
EXTRACTABLE = {"templates": TEMPLATES_ROOT, "deploy": DEPLOY_ROOT}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract(name: str, destination: str) -> Path:
|
|
22
|
+
"""copy a packaged asset tree (templates | deploy) into destination and return the new path."""
|
|
23
|
+
source = EXTRACTABLE[name]
|
|
24
|
+
target = Path(destination) / name
|
|
25
|
+
# pip byte-compiles installed .py files (the git credential helper); don't ship the cache
|
|
26
|
+
shutil.copytree(source, target, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
27
|
+
return target
|