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,564 @@
|
|
|
1
|
+
"""github pull-request operations as permission-aware in-process mcp tools (TOL-001/004/005/006/007).
|
|
2
|
+
|
|
3
|
+
credentials never reach the model or the task env: every call fetches a fresh broker token.
|
|
4
|
+
writes are idempotent (check github, and the artifacts table where github can't tell — ORC-012),
|
|
5
|
+
recorded as artifacts, and bounded before entering model context. git itself (clone/commit/
|
|
6
|
+
push) stays in Bash via the credential helper; these tools cover the api surface.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from taskboy.adapters._util import OUTPUT_LIMIT, _error, _text, wrap
|
|
14
|
+
from taskboy.models import Task
|
|
15
|
+
from taskboy.redact import redactor
|
|
16
|
+
from taskboy.store import Store
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("taskboy.github")
|
|
19
|
+
|
|
20
|
+
GITHUB_API = "https://api.github.com"
|
|
21
|
+
ALLOWED_REVIEW_EVENTS = {"COMMENT", "REQUEST_CHANGES"} # APPROVE is added per-task for the reviewing persona (issue #75)
|
|
22
|
+
TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
|
|
23
|
+
BRANCH_ALREADY_GONE = {404, 422} # github's "ref does not exist" statuses for a missing branch
|
|
24
|
+
COMMENT_PREVIEW_CHARS = 400
|
|
25
|
+
COMMENT_PREVIEW_MARKER = " …(truncated)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitHubStatusError(RuntimeError):
|
|
29
|
+
"""carries the http status (and optional Retry-After) so callers can special-case specific failures
|
|
30
|
+
(e.g. a missing ref, or a secondary rate limit) without reparsing the message string."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, status: int, message: str, retry_after: str | None = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status = status
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GitHubAdapter:
|
|
39
|
+
def __init__(self, broker, store: Store, task: Task, approved_repos: list[str], on_milestone=None, main_broker=None, reviewer_broker=None, bot_name: str = "Agent", other_bot_name: str = "Reviewer", can_approve: bool = False):
|
|
40
|
+
self.broker = broker
|
|
41
|
+
self.store = store
|
|
42
|
+
self.task = task
|
|
43
|
+
self.approved_repos = approved_repos
|
|
44
|
+
self.on_milestone = on_milestone # async (message) -> None; artifact auto-milestones (SLK-006)
|
|
45
|
+
self.main_broker = main_broker
|
|
46
|
+
self.bot_brokers = [bot_broker for bot_broker in (main_broker, reviewer_broker) if bot_broker is not None]
|
|
47
|
+
self.bot_name = bot_name
|
|
48
|
+
self.other_bot_name = other_bot_name
|
|
49
|
+
self.can_approve = can_approve
|
|
50
|
+
|
|
51
|
+
# -- tools -------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
async def create_pull_request(self, args: dict) -> dict:
|
|
54
|
+
repo, error = self._check_repo(args)
|
|
55
|
+
if error:
|
|
56
|
+
return error
|
|
57
|
+
head = str(args.get("head", "")).strip()
|
|
58
|
+
base = str(args.get("base", "")).strip() or "main"
|
|
59
|
+
if not head:
|
|
60
|
+
return _error("head branch is required")
|
|
61
|
+
|
|
62
|
+
# idempotency check: github's own open-pr-by-head state is authoritative, so a retry after
|
|
63
|
+
# close_pull_request or a crash-after-create just finds no open pr and proceeds (issue #113).
|
|
64
|
+
owner = repo.split("/", 1)[0]
|
|
65
|
+
existing = await self._request("GET", f"/repos/{repo}/pulls?head={owner}:{head}&state=open")
|
|
66
|
+
if existing:
|
|
67
|
+
pr = existing[0]
|
|
68
|
+
self._record_pr(repo, pr)
|
|
69
|
+
return _text(f"an open pull request already exists for branch {head}: {pr['html_url']} — update it instead of creating another")
|
|
70
|
+
|
|
71
|
+
body = str(args.get("body", "")).rstrip() + f"\n\n---\nRequested via Slack — taskboy task `{self.task.task_id}`."
|
|
72
|
+
pr = await self._request("POST", f"/repos/{repo}/pulls", {"title": str(args.get("title", "")), "head": head, "base": base, "body": body})
|
|
73
|
+
self._record_pr(repo, pr)
|
|
74
|
+
self.store.add_artifact(self.task.task_id, "branch", f"{repo}:{head}")
|
|
75
|
+
if self.on_milestone:
|
|
76
|
+
await self.on_milestone(f"Opened pull request {pr['html_url']}")
|
|
77
|
+
return _text(f"created pull request #{pr['number']}: {pr['html_url']}")
|
|
78
|
+
|
|
79
|
+
async def get_pull_request(self, args: dict) -> dict:
|
|
80
|
+
repo, error = self._check_repo(args)
|
|
81
|
+
if error:
|
|
82
|
+
return error
|
|
83
|
+
pr = await self._request("GET", f"/repos/{repo}/pulls/{int(args['number'])}")
|
|
84
|
+
trimmed = {key: pr.get(key) for key in ("number", "title", "state", "html_url", "user", "body", "head", "base", "mergeable_state", "changed_files", "additions", "deletions", "draft", "created_at", "updated_at")}
|
|
85
|
+
trimmed["user"] = (pr.get("user") or {}).get("login")
|
|
86
|
+
trimmed["body"] = str(pr.get("body") or "")[:2000]
|
|
87
|
+
trimmed["head"] = (pr.get("head") or {}).get("ref")
|
|
88
|
+
trimmed["head_sha"] = (pr.get("head") or {}).get("sha")
|
|
89
|
+
trimmed["base"] = (pr.get("base") or {}).get("ref")
|
|
90
|
+
trimmed["requested_reviewers"] = [(reviewer or {}).get("login") for reviewer in pr.get("requested_reviewers") or []]
|
|
91
|
+
return _text(json.dumps(trimmed))
|
|
92
|
+
|
|
93
|
+
async def list_pull_requests(self, args: dict) -> dict:
|
|
94
|
+
repo, error = self._check_repo(args)
|
|
95
|
+
if error:
|
|
96
|
+
return error
|
|
97
|
+
state = str(args.get("state") or "open").lower()
|
|
98
|
+
if state not in {"open", "closed", "all"}:
|
|
99
|
+
return _error("state must be open, closed, or all")
|
|
100
|
+
pulls = await self._request("GET", f"/repos/{repo}/pulls?state={state}&per_page=100&sort=created&direction=desc")
|
|
101
|
+
lines = []
|
|
102
|
+
for pr in pulls:
|
|
103
|
+
status = str(pr.get("state") or "") + (", draft" if pr.get("draft") else "")
|
|
104
|
+
lines.append(f"#{pr.get('number')} [{status}] {(pr.get('user') or {}).get('login')}: {pr.get('title')} (head {str((pr.get('head') or {}).get('sha') or '')[:7]}, updated {pr.get('updated_at')})")
|
|
105
|
+
return _text("\n".join(lines) or "no pull requests")
|
|
106
|
+
|
|
107
|
+
async def list_pr_files(self, args: dict) -> dict:
|
|
108
|
+
repo, error = self._check_repo(args)
|
|
109
|
+
if error:
|
|
110
|
+
return error
|
|
111
|
+
number = int(args["number"])
|
|
112
|
+
page = max(int(args.get("page") or 1), 1)
|
|
113
|
+
files = await self._request("GET", f"/repos/{repo}/pulls/{number}/files?per_page=10&page={page}")
|
|
114
|
+
headers = []
|
|
115
|
+
patches = []
|
|
116
|
+
for item in files:
|
|
117
|
+
header = f"--- {item.get('filename')} ({item.get('status')}, +{item.get('additions', 0)}/-{item.get('deletions', 0)})"
|
|
118
|
+
headers.append(header)
|
|
119
|
+
patch = str(item.get("patch") or "")
|
|
120
|
+
if len(patch) > 1500:
|
|
121
|
+
marker = "\n…(patch truncated — fetch the full diff with git)"
|
|
122
|
+
patch = patch[: 1500 - len(marker)] + marker
|
|
123
|
+
if patch:
|
|
124
|
+
patches.append(f"patch for {item.get('filename')}:\n{patch}")
|
|
125
|
+
sections = headers + (["", "patches:", *patches] if patches else [])
|
|
126
|
+
return _text("\n".join(sections) or "no files")
|
|
127
|
+
|
|
128
|
+
async def list_pr_comments(self, args: dict) -> dict:
|
|
129
|
+
repo, error = self._check_repo(args)
|
|
130
|
+
if error:
|
|
131
|
+
return error
|
|
132
|
+
number = int(args["number"])
|
|
133
|
+
offset = max(int(args.get("offset", 0) or 0), 0)
|
|
134
|
+
comment_id_raw = args.get("comment_id")
|
|
135
|
+
author = str(args.get("author") or "").strip().lower()
|
|
136
|
+
if author == "me":
|
|
137
|
+
own_login, login_error = await self._resolve_own_login()
|
|
138
|
+
if login_error or not own_login:
|
|
139
|
+
return _error(f"cannot resolve your own login for author='me' ({login_error})")
|
|
140
|
+
author = own_login
|
|
141
|
+
issue_comments = await self._paginated(f"/repos/{repo}/issues/{number}/comments")
|
|
142
|
+
review_comments = await self._paginated(f"/repos/{repo}/pulls/{number}/comments")
|
|
143
|
+
|
|
144
|
+
if comment_id_raw is not None:
|
|
145
|
+
return self._one_comment(int(comment_id_raw), issue_comments, review_comments, offset)
|
|
146
|
+
|
|
147
|
+
resolution_by_comment: dict[int, bool] = {}
|
|
148
|
+
try:
|
|
149
|
+
for thread in await self._review_threads(repo, number):
|
|
150
|
+
for comment in (thread.get("comments") or {}).get("nodes") or []:
|
|
151
|
+
comment_id = comment.get("databaseId")
|
|
152
|
+
if comment_id is not None:
|
|
153
|
+
resolution_by_comment[int(comment_id)] = bool(thread.get("isResolved"))
|
|
154
|
+
except Exception:
|
|
155
|
+
logger.debug("could not annotate review thread status for %s#%s", repo, number, exc_info=True)
|
|
156
|
+
# combine before sorting so "newest-appearing-last" holds across both kinds, not just within each
|
|
157
|
+
combined = [("issue", comment) for comment in issue_comments] + [("review", comment) for comment in review_comments]
|
|
158
|
+
combined.sort(key=lambda item: str(item[1].get("created_at") or ""))
|
|
159
|
+
lines = []
|
|
160
|
+
for kind, comment in combined:
|
|
161
|
+
comment_id_value = comment.get("id")
|
|
162
|
+
comment_resolution = resolution_by_comment.get(int(comment_id_value)) if comment_id_value is not None else None
|
|
163
|
+
comment_author = str((comment.get("user") or {}).get("login") or "")
|
|
164
|
+
if author and comment_author.lower() != author:
|
|
165
|
+
continue
|
|
166
|
+
status = ""
|
|
167
|
+
if kind == "review" and comment_resolution is not None:
|
|
168
|
+
status = " (resolved)" if comment_resolution else " (unresolved)"
|
|
169
|
+
location = self._review_comment_location(comment) if kind == "review" else ""
|
|
170
|
+
label = f"{kind}, {location}" if location else kind
|
|
171
|
+
body = str(comment.get("body", ""))
|
|
172
|
+
preview = body[:COMMENT_PREVIEW_CHARS] + (COMMENT_PREVIEW_MARKER if len(body) > COMMENT_PREVIEW_CHARS else "")
|
|
173
|
+
lines.append(f"[{comment.get('id')}] [{comment.get('created_at')}] {comment_author} ({label}): {preview}{status}")
|
|
174
|
+
if not lines:
|
|
175
|
+
total = len(issue_comments) + len(review_comments)
|
|
176
|
+
return _text("no comments" if not total else f"no comments matched the filters (there are {total} in total)")
|
|
177
|
+
# tail_fit always favors the newest lines, so offset skips from the newest end too — otherwise
|
|
178
|
+
# a caller paging with offset would keep landing back on the same tail-fitted chunk
|
|
179
|
+
selected = lines[: max(len(lines) - offset, 0)]
|
|
180
|
+
if not selected:
|
|
181
|
+
return _text(f"no comments past offset {offset} (there are {len(lines)} in total)")
|
|
182
|
+
return _text(self._tail_fit(selected, offset))
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _tail_fit(selected: list[str], offset: int) -> str:
|
|
186
|
+
"""newest lines win when the output cap bites, so the latest comments aren't the ones dropped."""
|
|
187
|
+
joined = "\n".join(selected)
|
|
188
|
+
if len(joined) <= OUTPUT_LIMIT:
|
|
189
|
+
return joined
|
|
190
|
+
reserve = 120 # room for the "earlier comments not shown" marker below
|
|
191
|
+
kept: list[str] = []
|
|
192
|
+
used = 0
|
|
193
|
+
for line in reversed(selected):
|
|
194
|
+
used += len(line) + (1 if kept else 0)
|
|
195
|
+
if used > OUTPUT_LIMIT - reserve:
|
|
196
|
+
break
|
|
197
|
+
kept.insert(0, line)
|
|
198
|
+
dropped = len(selected) - len(kept)
|
|
199
|
+
# next page must skip what offset already skipped plus what we just showed
|
|
200
|
+
next_offset = offset + len(kept)
|
|
201
|
+
marker = f"…({dropped} earlier comment(s) not shown — pass offset={next_offset} to see them, or narrow with author/comment_id)\n"
|
|
202
|
+
return marker + "\n".join(kept)
|
|
203
|
+
|
|
204
|
+
def _one_comment(self, comment_id: int, issue_comments: list[dict], review_comments: list[dict], body_offset: int) -> dict:
|
|
205
|
+
"""offset is chars into the body, not a comment index."""
|
|
206
|
+
for kind, comments in (("issue", issue_comments), ("review", review_comments)):
|
|
207
|
+
for comment in comments:
|
|
208
|
+
if comment.get("id") == comment_id:
|
|
209
|
+
author = (comment.get("user") or {}).get("login")
|
|
210
|
+
prefix = f"[{comment.get('id')}] [{comment.get('created_at')}] {author} ({kind}): "
|
|
211
|
+
body = str(comment.get("body") or "")
|
|
212
|
+
return _text(self._paged_text(prefix, body, body_offset))
|
|
213
|
+
return _error(f"comment {comment_id} was not found on this pull request (checked both issue and review comments)")
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _paged_text(prefix: str, body: str, offset: int) -> str:
|
|
217
|
+
if body and offset >= len(body):
|
|
218
|
+
return f"{prefix}(no content past offset {offset} — this comment's body is {len(body)} chars long)"
|
|
219
|
+
remaining = body[offset:]
|
|
220
|
+
available = OUTPUT_LIMIT - len(prefix)
|
|
221
|
+
if len(remaining) <= available:
|
|
222
|
+
return prefix + remaining
|
|
223
|
+
reserve = 80 # room for the continuation marker below, sized generously for its numbers
|
|
224
|
+
chunk = remaining[: max(available - reserve, 0)]
|
|
225
|
+
end = offset + len(chunk)
|
|
226
|
+
marker = f"\n…(showing chars {offset}-{end} of {len(body)} — pass offset={end} to continue)"
|
|
227
|
+
return prefix + chunk + marker
|
|
228
|
+
|
|
229
|
+
async def comment_on_pull_request(self, args: dict) -> dict:
|
|
230
|
+
repo, error = self._check_repo(args)
|
|
231
|
+
if error:
|
|
232
|
+
return error
|
|
233
|
+
number = int(args["number"])
|
|
234
|
+
comment = await self._request("POST", f"/repos/{repo}/issues/{number}/comments", {"body": str(args.get("body", ""))})
|
|
235
|
+
self.store.add_artifact(self.task.task_id, "pr_comment", f"{repo}#{number}/comment/{comment['id']}", comment.get("html_url"))
|
|
236
|
+
return _text(f"commented: {comment.get('html_url')}")
|
|
237
|
+
|
|
238
|
+
async def create_pr_review(self, args: dict) -> dict:
|
|
239
|
+
repo, error = self._check_repo(args)
|
|
240
|
+
if error:
|
|
241
|
+
return error
|
|
242
|
+
event = str(args.get("event", "COMMENT")).upper()
|
|
243
|
+
allowed_events = ALLOWED_REVIEW_EVENTS | ({"APPROVE"} if self.can_approve else set())
|
|
244
|
+
if event not in allowed_events:
|
|
245
|
+
return _error(f"review event {event!r} is not permitted; use {' or '.join(sorted(allowed_events))}")
|
|
246
|
+
number = int(args["number"])
|
|
247
|
+
# the follow-up loop only acts on REQUEST_CHANGES/APPROVE, so the reviewing persona's APPROVE and COMMENT both hinge on authorship (issue #75)
|
|
248
|
+
if self.can_approve and event in ("APPROVE", "COMMENT"):
|
|
249
|
+
main_login, lookup_error = await self._resolve_main_login()
|
|
250
|
+
if main_login is None and event == "APPROVE":
|
|
251
|
+
return _error(f"cannot verify pull request authorship — main-agent identity lookup unavailable ({lookup_error})")
|
|
252
|
+
# COMMENT fails open on a lookup blip — blocking the reviewer's reviews of human prs is worse than one unenforced comment
|
|
253
|
+
if main_login is not None:
|
|
254
|
+
pr = await self._request("GET", f"/repos/{repo}/pulls/{number}")
|
|
255
|
+
author_login = str((pr.get("user") or {}).get("login") or "").lower()
|
|
256
|
+
if event == "APPROVE" and author_login != main_login:
|
|
257
|
+
return _error(f"APPROVE is only permitted on pull requests authored by {self.other_bot_name} — it's the review loop's exit signal (issue #75), not a general human-PR approval")
|
|
258
|
+
if event == "COMMENT" and author_login == main_login:
|
|
259
|
+
return _error(f"COMMENT is not permitted on pull requests authored by {self.other_bot_name} — use REQUEST_CHANGES or APPROVE so the review loop can act on the outcome")
|
|
260
|
+
payload: dict = {"body": str(args.get("body", "")), "event": event}
|
|
261
|
+
comments_json = str(args.get("comments_json") or "").strip()
|
|
262
|
+
if comments_json:
|
|
263
|
+
try:
|
|
264
|
+
comments = json.loads(comments_json)
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
return _error("comments_json must be valid json")
|
|
267
|
+
if not isinstance(comments, list):
|
|
268
|
+
return _error("comments_json must be a list of path, line, and body objects")
|
|
269
|
+
normalized = []
|
|
270
|
+
for comment in comments:
|
|
271
|
+
if not isinstance(comment, dict) or not isinstance(comment.get("path"), str) or not isinstance(comment.get("line"), int) or not isinstance(comment.get("body"), str):
|
|
272
|
+
return _error("comments_json must be a list of path, line, and body objects")
|
|
273
|
+
normalized.append({"path": comment["path"], "line": comment["line"], "body": comment["body"], "side": str(comment.get("side") or "RIGHT")})
|
|
274
|
+
payload["comments"] = normalized
|
|
275
|
+
review = await self._request("POST", f"/repos/{repo}/pulls/{number}/reviews", payload)
|
|
276
|
+
self.store.add_artifact(self.task.task_id, "pr_comment", f"{repo}#{number}/review/{review['id']}", review.get("html_url"))
|
|
277
|
+
return _text(f"posted {event} review on {repo}#{number}")
|
|
278
|
+
|
|
279
|
+
async def reply_to_pr_comment(self, args: dict) -> dict:
|
|
280
|
+
repo, error = self._check_repo(args)
|
|
281
|
+
if error:
|
|
282
|
+
return error
|
|
283
|
+
number = int(args["number"])
|
|
284
|
+
reply = await self._request("POST", f"/repos/{repo}/pulls/{number}/comments/{int(args['comment_id'])}/replies", {"body": str(args.get("body", ""))})
|
|
285
|
+
self.store.add_artifact(self.task.task_id, "pr_comment", f"{repo}#{number}/comment/{reply['id']}", reply.get("html_url"))
|
|
286
|
+
return _text(f"replied: {reply.get('html_url')}")
|
|
287
|
+
|
|
288
|
+
async def close_pull_request(self, args: dict) -> dict:
|
|
289
|
+
repo, error = self._check_repo(args)
|
|
290
|
+
if error:
|
|
291
|
+
return error
|
|
292
|
+
number = int(args["number"])
|
|
293
|
+
pr = await self._request("GET", f"/repos/{repo}/pulls/{number}")
|
|
294
|
+
if pr.get("state") == "closed":
|
|
295
|
+
return _text(f"pull request #{number} is already closed: {pr.get('html_url')}")
|
|
296
|
+
pr = await self._request("PATCH", f"/repos/{repo}/pulls/{number}", {"state": "closed"})
|
|
297
|
+
if self.on_milestone:
|
|
298
|
+
await self.on_milestone(f"Closed pull request {pr['html_url']}")
|
|
299
|
+
return _text(f"closed pull request #{number}: {pr['html_url']}")
|
|
300
|
+
|
|
301
|
+
async def delete_branch(self, args: dict) -> dict:
|
|
302
|
+
repo, error = self._check_repo(args)
|
|
303
|
+
if error:
|
|
304
|
+
return error
|
|
305
|
+
branch = str(args.get("branch", "")).strip()
|
|
306
|
+
if branch.startswith("refs/heads/"):
|
|
307
|
+
branch = branch[len("refs/heads/") :]
|
|
308
|
+
if not branch:
|
|
309
|
+
return _error("branch is required")
|
|
310
|
+
if not branch.startswith("agent/"):
|
|
311
|
+
return _error("only agent/-prefixed branches can be deleted")
|
|
312
|
+
try:
|
|
313
|
+
await self._request("DELETE", f"/repos/{repo}/git/refs/heads/{branch}")
|
|
314
|
+
except GitHubStatusError as status_error:
|
|
315
|
+
if status_error.status not in BRANCH_ALREADY_GONE:
|
|
316
|
+
raise
|
|
317
|
+
return _text(f"branch {branch} in {repo} is already deleted")
|
|
318
|
+
return _text(f"deleted branch {branch} from {repo}")
|
|
319
|
+
|
|
320
|
+
async def create_release(self, args: dict) -> dict:
|
|
321
|
+
repo, error = self._check_repo(args)
|
|
322
|
+
if error:
|
|
323
|
+
return error
|
|
324
|
+
tag_name = str(args.get("tag_name", "")).strip()
|
|
325
|
+
if not TAG_RE.match(tag_name):
|
|
326
|
+
return _error("tag_name must look like vX.Y.Z (e.g. v1.4.0)")
|
|
327
|
+
body = str(args.get("body", "")).strip()
|
|
328
|
+
if not body:
|
|
329
|
+
return _error("body (release notes) is required")
|
|
330
|
+
# idempotency layer 1: we already published this tag's release (ORC-012)
|
|
331
|
+
for artifact in self.store.artifacts_for(self.task.task_id):
|
|
332
|
+
if artifact["kind"] == "release" and artifact["external_id"] == f"{repo}@{tag_name}":
|
|
333
|
+
return _text(f"a release for {tag_name} already exists: {artifact['url']} — nothing to do")
|
|
334
|
+
payload = {"tag_name": tag_name, "name": str(args.get("name", "")).strip() or tag_name, "body": body}
|
|
335
|
+
target_commitish = str(args.get("target_commitish", "")).strip()
|
|
336
|
+
if target_commitish:
|
|
337
|
+
payload["target_commitish"] = target_commitish
|
|
338
|
+
# idempotency layer 2: github rejects an already-cut tag with 422; treat that as done rather than surfacing a raw api error (crash-after-create window)
|
|
339
|
+
try:
|
|
340
|
+
release = await self._request("POST", f"/repos/{repo}/releases", payload)
|
|
341
|
+
except GitHubStatusError as exc:
|
|
342
|
+
if exc.status == 422:
|
|
343
|
+
return _text(f"a release for {tag_name} already exists on {repo} — nothing to do")
|
|
344
|
+
raise
|
|
345
|
+
self.store.add_artifact(self.task.task_id, "release", f"{repo}@{tag_name}", release.get("html_url"))
|
|
346
|
+
if self.on_milestone:
|
|
347
|
+
await self.on_milestone(f"Published release {tag_name}: {release['html_url']}")
|
|
348
|
+
return _text(f"created release {tag_name}: {release['html_url']}")
|
|
349
|
+
|
|
350
|
+
async def resolve_pr_thread(self, args: dict) -> dict:
|
|
351
|
+
repo, error = self._check_repo(args)
|
|
352
|
+
if error:
|
|
353
|
+
return error
|
|
354
|
+
known_logins, unresolved = await self._resolve_bot_logins()
|
|
355
|
+
detail = f" ({'; '.join(unresolved)})" if unresolved else ""
|
|
356
|
+
if not known_logins:
|
|
357
|
+
return _error(f"cannot verify thread authorship — resolution unavailable for this task{detail}")
|
|
358
|
+
number = int(args["number"])
|
|
359
|
+
comment_id = int(args["comment_id"])
|
|
360
|
+
thread = next(
|
|
361
|
+
(thread for thread in await self._review_threads(repo, number) if any(comment.get("databaseId") == comment_id for comment in ((thread.get("comments") or {}).get("nodes") or []))),
|
|
362
|
+
None,
|
|
363
|
+
)
|
|
364
|
+
if thread is None:
|
|
365
|
+
return _error("comment id was not found in this pull request's review threads; pass a review-comment id from list_pr_comments")
|
|
366
|
+
comments = (thread.get("comments") or {}).get("nodes") or []
|
|
367
|
+
root_login = str((((comments[0] if comments else {}).get("author") or {}).get("login")) or "")
|
|
368
|
+
if root_login.lower() not in known_logins:
|
|
369
|
+
# a partly-degraded identity lookup can still refuse one of our own threads, so name the failure here too
|
|
370
|
+
return _error(f"only review threads started by {self.bot_name} or {self.other_bot_name} can be resolved{detail}")
|
|
371
|
+
if thread.get("isResolved"):
|
|
372
|
+
return _text(f"review thread for comment {comment_id} on {repo}#{number} is already resolved")
|
|
373
|
+
data = await self._graphql(
|
|
374
|
+
"""mutation ResolveReviewThread($id: ID!) {
|
|
375
|
+
resolveReviewThread(input: {threadId: $id}) { thread { isResolved } }
|
|
376
|
+
}""",
|
|
377
|
+
{"id": thread["id"]},
|
|
378
|
+
)
|
|
379
|
+
if not (((data.get("resolveReviewThread") or {}).get("thread") or {}).get("isResolved")):
|
|
380
|
+
raise RuntimeError("github did not confirm that the review thread was resolved")
|
|
381
|
+
return _text(f"resolved review thread for comment {comment_id} on {repo}#{number}")
|
|
382
|
+
|
|
383
|
+
# -- plumbing ------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
async def _resolve_main_login(self) -> tuple[str | None, str | None]:
|
|
386
|
+
"""the main agent's login, resolved lazily like `_resolve_bot_logins` (#72) — used to gate APPROVE to agent-authored PRs."""
|
|
387
|
+
if self.main_broker is None:
|
|
388
|
+
return None, "no main-agent broker configured for this task"
|
|
389
|
+
return await self._resolve_login_for(self.main_broker)
|
|
390
|
+
|
|
391
|
+
async def _resolve_own_login(self) -> tuple[str | None, str | None]:
|
|
392
|
+
"""the acting persona's own login — `self.broker` is whichever bot is running this task — for `author: "me"` in `list_pr_comments`."""
|
|
393
|
+
return await self._resolve_login_for(self.broker)
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
async def _resolve_login_for(broker) -> tuple[str | None, str | None]:
|
|
397
|
+
"""shared by `_resolve_main_login` and `_resolve_own_login`, which differ only in which broker they read."""
|
|
398
|
+
try:
|
|
399
|
+
return f"{await broker.app_slug()}[bot]".lower(), None
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
return None, str(exc)
|
|
402
|
+
|
|
403
|
+
async def _resolve_bot_logins(self) -> tuple[set[str], list[str]]:
|
|
404
|
+
"""logins for either persona, resolved lazily because a build-time snapshot can be permanently incomplete (#72)."""
|
|
405
|
+
logins: set[str] = set()
|
|
406
|
+
errors: list[str] = []
|
|
407
|
+
for broker in self.bot_brokers:
|
|
408
|
+
try:
|
|
409
|
+
# the bare app slug, which is how graphql reports a bot author — REST's "{slug}[bot]" shape is not used here
|
|
410
|
+
logins.add((await broker.app_slug()).lower())
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
errors.append(str(exc))
|
|
413
|
+
return logins, errors
|
|
414
|
+
|
|
415
|
+
def _review_comment_location(self, comment: dict) -> str:
|
|
416
|
+
path = comment["path"]
|
|
417
|
+
# line goes null once the diff position goes stale, hence the original_line fallback
|
|
418
|
+
line = comment.get("line") or comment.get("original_line")
|
|
419
|
+
location = f"{path}:{line}" if line else path
|
|
420
|
+
in_reply_to = comment.get("in_reply_to_id")
|
|
421
|
+
return f"{location}, reply-to {in_reply_to}" if in_reply_to is not None else location
|
|
422
|
+
|
|
423
|
+
def _check_repo(self, args: dict) -> tuple[str, dict | None]:
|
|
424
|
+
repo = str(args.get("repo", "")).strip()
|
|
425
|
+
if repo not in self.approved_repos:
|
|
426
|
+
return repo, _error(f"repository {repo!r} is not on the approved list {self.approved_repos}")
|
|
427
|
+
return repo, None
|
|
428
|
+
|
|
429
|
+
def _record_pr(self, repo: str, pr: dict) -> None:
|
|
430
|
+
self.store.add_artifact(self.task.task_id, "pull_request", f"{repo}#{pr['number']}", pr.get("html_url"))
|
|
431
|
+
|
|
432
|
+
async def _paginated(self, path: str) -> list[dict]:
|
|
433
|
+
"""github defaults to 30 items/page; page through at 100 until a short page ends the walk."""
|
|
434
|
+
items: list[dict] = []
|
|
435
|
+
page = 1
|
|
436
|
+
while True:
|
|
437
|
+
batch = await self._request("GET", f"{path}?per_page=100&page={page}") or []
|
|
438
|
+
items.extend(batch)
|
|
439
|
+
if len(batch) < 100:
|
|
440
|
+
return items
|
|
441
|
+
page += 1
|
|
442
|
+
|
|
443
|
+
async def _request(self, method: str, path: str, payload: dict | None = None):
|
|
444
|
+
"""the http seam — patched in unit tests. token fetched per call, never stored (TOL-007)."""
|
|
445
|
+
import aiohttp
|
|
446
|
+
|
|
447
|
+
token = await self.broker.token_for_task(self.task.task_id)
|
|
448
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
|
|
449
|
+
async with aiohttp.ClientSession() as session:
|
|
450
|
+
async with session.request(method, GITHUB_API + path, json=payload, headers=headers) as response:
|
|
451
|
+
if response.status >= 300:
|
|
452
|
+
body = redactor.redact(await response.text())[:300]
|
|
453
|
+
retry_after = response.headers.get("Retry-After")
|
|
454
|
+
raise GitHubStatusError(response.status, f"github api {method} {path} failed: {response.status} — {body}", retry_after=retry_after)
|
|
455
|
+
if response.status == 204:
|
|
456
|
+
return None
|
|
457
|
+
return await response.json()
|
|
458
|
+
|
|
459
|
+
async def _graphql(self, query: str, variables: dict) -> dict:
|
|
460
|
+
"""the graphql http seam — patched separately in unit tests."""
|
|
461
|
+
import aiohttp
|
|
462
|
+
|
|
463
|
+
token = await self.broker.token_for_task(self.task.task_id)
|
|
464
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
|
|
465
|
+
async with aiohttp.ClientSession() as session:
|
|
466
|
+
async with session.post(GITHUB_API + "/graphql", json={"query": query, "variables": variables}, headers=headers) as response:
|
|
467
|
+
if response.status >= 300:
|
|
468
|
+
raise RuntimeError(redactor.redact(f"github graphql failed: {response.status}"))
|
|
469
|
+
payload = await response.json()
|
|
470
|
+
if payload.get("errors"):
|
|
471
|
+
raise RuntimeError(redactor.redact(f"github graphql failed: {json.dumps(payload['errors'])}"))
|
|
472
|
+
return payload.get("data") or {}
|
|
473
|
+
|
|
474
|
+
async def _review_threads(self, repo: str, number: int) -> list[dict]:
|
|
475
|
+
owner, name = repo.split("/", 1)
|
|
476
|
+
nodes: list[dict] = []
|
|
477
|
+
cursor = None
|
|
478
|
+
while True:
|
|
479
|
+
data = await self._graphql(
|
|
480
|
+
"""query ReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
481
|
+
repository(owner: $owner, name: $name) {
|
|
482
|
+
pullRequest(number: $number) {
|
|
483
|
+
reviewThreads(first: 100, after: $after) {
|
|
484
|
+
nodes {
|
|
485
|
+
id
|
|
486
|
+
isResolved
|
|
487
|
+
comments(first: 100) { nodes { databaseId author { login } } }
|
|
488
|
+
}
|
|
489
|
+
pageInfo { hasNextPage endCursor }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}""",
|
|
494
|
+
{"owner": owner, "name": name, "number": number, "after": cursor},
|
|
495
|
+
)
|
|
496
|
+
review_threads = (((data.get("repository") or {}).get("pullRequest") or {}).get("reviewThreads")) or {}
|
|
497
|
+
nodes.extend(review_threads.get("nodes") or [])
|
|
498
|
+
page_info = review_threads.get("pageInfo") or {}
|
|
499
|
+
cursor = page_info.get("endCursor")
|
|
500
|
+
if not page_info.get("hasNextPage") or not cursor:
|
|
501
|
+
return nodes
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def build_github_server(adapter: GitHubAdapter):
|
|
505
|
+
"""expose the adapter as mcp tools; names become mcp__github__<name> in allowed_tools."""
|
|
506
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
507
|
+
|
|
508
|
+
tools = [
|
|
509
|
+
tool("create_pull_request", "Create a pull request. Include a Summary, Testing performed, and Known limitations in the body.", {"repo": str, "title": str, "head": str, "base": str, "body": str})(wrap(adapter.create_pull_request, logger)),
|
|
510
|
+
tool("get_pull_request", "Get one pull request's metadata.", {"repo": str, "number": int})(wrap(adapter.get_pull_request, logger)),
|
|
511
|
+
tool(
|
|
512
|
+
"list_pull_requests",
|
|
513
|
+
"List pull requests in a repository. State defaults to open and may be open, closed, or all.",
|
|
514
|
+
{"type": "object", "properties": {"repo": {"type": "string"}, "state": {"type": "string"}}, "required": ["repo"]},
|
|
515
|
+
)(wrap(adapter.list_pull_requests, logger)),
|
|
516
|
+
tool(
|
|
517
|
+
"list_pr_files",
|
|
518
|
+
"List changed files and bounded patches for a pull request, 10 per page. Fetch full diffs with git.",
|
|
519
|
+
{"type": "object", "properties": {"repo": {"type": "string"}, "number": {"type": "integer"}, "page": {"type": "integer"}}, "required": ["repo", "number"]},
|
|
520
|
+
)(wrap(adapter.list_pr_files, logger)),
|
|
521
|
+
tool(
|
|
522
|
+
"list_pr_comments",
|
|
523
|
+
f"List comments and review comments on a pull request, newest-appearing-last, bodies previewed at {COMMENT_PREVIEW_CHARS} chars. Optional author filter narrows the list and combines with offset; pass comment_id instead to page that one comment's body.",
|
|
524
|
+
{
|
|
525
|
+
"type": "object",
|
|
526
|
+
"properties": {
|
|
527
|
+
"repo": {"type": "string"},
|
|
528
|
+
"number": {"type": "integer"},
|
|
529
|
+
"offset": {"type": "integer", "description": "if truncated, skip this many of the most-recently-shown comments (the truncation marker gives the right value); with comment_id, this is characters into that body to resume from instead"},
|
|
530
|
+
"author": {"type": "string", "description": 'login, exact match; pass "me" for the acting persona\'s own login'},
|
|
531
|
+
"comment_id": {"type": "integer", "description": "page this one comment's body (issue or review) instead of a filtered list; ignores author"},
|
|
532
|
+
},
|
|
533
|
+
"required": ["repo", "number"],
|
|
534
|
+
},
|
|
535
|
+
)(wrap(adapter.list_pr_comments, logger)),
|
|
536
|
+
tool("comment_on_pull_request", "Post a comment on a pull request.", {"repo": str, "number": int, "body": str})(wrap(adapter.comment_on_pull_request, logger)),
|
|
537
|
+
tool(
|
|
538
|
+
"create_pr_review",
|
|
539
|
+
"Post a review (COMMENT or REQUEST_CHANGES, and APPROVE for the reviewing persona) on a pull request, optionally with inline comments_json.",
|
|
540
|
+
{
|
|
541
|
+
"type": "object",
|
|
542
|
+
"properties": {"repo": {"type": "string"}, "number": {"type": "integer"}, "body": {"type": "string"}, "event": {"type": "string"}, "comments_json": {"type": "string"}},
|
|
543
|
+
"required": ["repo", "number", "body", "event"],
|
|
544
|
+
},
|
|
545
|
+
)(wrap(adapter.create_pr_review, logger)),
|
|
546
|
+
tool("reply_to_pr_comment", "Reply to a specific pull request review comment.", {"repo": str, "number": int, "comment_id": int, "body": str})(wrap(adapter.reply_to_pr_comment, logger)),
|
|
547
|
+
tool(
|
|
548
|
+
"resolve_pr_thread",
|
|
549
|
+
f"Resolve a pull request review thread, identified by any review comment id in it. Only threads started by {adapter.bot_name} or {adapter.other_bot_name} can be resolved; use it once the thread's finding is verifiably fixed.",
|
|
550
|
+
{"repo": str, "number": int, "comment_id": int},
|
|
551
|
+
)(wrap(adapter.resolve_pr_thread, logger)),
|
|
552
|
+
tool("close_pull_request", "Close a pull request without merging it. Idempotent — already-closed pull requests are reported, not re-closed.", {"repo": str, "number": int})(wrap(adapter.close_pull_request, logger)),
|
|
553
|
+
tool("delete_branch", "Delete a branch. Only agent/-prefixed branches can be deleted; protected and default branches are refused.", {"repo": str, "branch": str})(wrap(adapter.delete_branch, logger)),
|
|
554
|
+
tool(
|
|
555
|
+
"create_release",
|
|
556
|
+
"Create a GitHub release with a new tag (vX.Y.Z) and release notes. The tag is created from target_commitish (default branch when omitted); name defaults to the tag.",
|
|
557
|
+
{
|
|
558
|
+
"type": "object",
|
|
559
|
+
"properties": {"repo": {"type": "string"}, "tag_name": {"type": "string"}, "body": {"type": "string"}, "name": {"type": "string"}, "target_commitish": {"type": "string"}},
|
|
560
|
+
"required": ["repo", "tag_name", "body"],
|
|
561
|
+
},
|
|
562
|
+
)(wrap(adapter.create_release, logger)),
|
|
563
|
+
]
|
|
564
|
+
return create_sdk_mcp_server(name="github", version="1.0.0", tools=tools)
|