Skip to content

Commit 9ba2dbc

Browse files
authored
Merge pull request #82 from ClaydeCode/clayde/issue-81
Fix #81: treat assigned PRs as first-class work items
2 parents 871dc2d + eea3a09 commit 9ba2dbc

9 files changed

Lines changed: 780 additions & 11 deletions

File tree

src/clayde/github.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,24 @@ def get_default_branch(g: Github, owner: str, repo: str) -> str:
5757

5858

5959
def get_assigned_issues(g: Github) -> list:
60-
"""Return all open issues assigned to the authenticated user."""
60+
"""Return all open issues AND PRs assigned to the authenticated user.
61+
62+
GitHub models PRs as issues — each item that is a PR will have
63+
``html_url`` containing ``/pull/``. Use ``is_pull_request_item()`` to
64+
distinguish them.
65+
"""
6166
try:
6267
return list(g.get_user().get_issues(filter="assigned", state="open"))
6368
except GithubException as e:
6469
log.error("Failed to fetch assigned issues: %s", e)
6570
return []
6671

6772

73+
def is_pull_request_item(item) -> bool:
74+
"""Return True if an item from get_assigned_issues() is a pull request."""
75+
return "/pull/" in item.html_url
76+
77+
6878
def find_open_pr(g: Github, owner: str, repo: str, branch_name: str) -> str | None:
6979
"""Return the HTML URL of an open PR for the given branch, or None."""
7080
pulls = list(_get_repo(g, owner, repo).get_pulls(

src/clayde/orchestrator.py

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@
3737
get_pr_review_comments,
3838
get_pr_reviews,
3939
is_blocked,
40+
is_pull_request_item,
4041
issue_ref,
4142
parse_issue_url,
4243
parse_pr_url,
4344
)
44-
from clayde.safety import get_new_visible_comments, has_visible_content
45+
from clayde.safety import filter_pr_reviews, get_new_visible_comments, has_visible_content
4546
from clayde.state import get_issue_state, load_state, save_state, update_issue_state
46-
from clayde.tasks import work
47+
from clayde.tasks import pr_work, work
4748
from clayde.telemetry import get_tracer, init_tracer
4849

4950
log = logging.getLogger("clayde.orchestrator")
@@ -173,6 +174,95 @@ def _handle_issue(g: Github, issue: Issue, url: str) -> None:
173174
log.info("[%s] Cycle complete", label)
174175

175176

177+
def _handle_standalone_pr(g: Github, url: str) -> None:
178+
"""Handle an assigned PR that has no parent tracked issue.
179+
180+
Checks for new review activity from whitelisted users and invokes Claude
181+
to address it. State is keyed by the PR url.
182+
"""
183+
tracer = get_tracer()
184+
with tracer.start_as_current_span("clayde.handle_standalone_pr", attributes={"pr.url": url}) as span:
185+
owner, repo, pr_number = parse_pr_url(url)
186+
ref = issue_ref(owner, repo, pr_number)
187+
188+
pr_state = get_issue_state(url)
189+
in_progress = pr_state.get("in_progress", False)
190+
last_seen_at = _parse_timestamp(pr_state.get("last_seen_at"))
191+
192+
# Check for new review activity from whitelisted users
193+
has_new_review_activity = False
194+
try:
195+
reviews = get_pr_reviews(g, owner, repo, pr_number)
196+
review_comments = get_pr_review_comments(g, owner, repo, pr_number)
197+
github_username = get_settings().github_username
198+
visible_reviews = filter_pr_reviews(reviews, github_username)
199+
200+
if last_seen_at is not None:
201+
new_reviews = [r for r in visible_reviews if r.submitted_at > last_seen_at]
202+
else:
203+
new_reviews = list(visible_reviews)
204+
205+
if new_reviews:
206+
new_review_ids = {r.id for r in new_reviews}
207+
has_inline = any(
208+
rc.pull_request_review_id in new_review_ids
209+
for rc in review_comments
210+
)
211+
has_bodies = any(r.body and r.body.strip() for r in new_reviews)
212+
if has_inline or has_bodies:
213+
has_new_review_activity = True
214+
else:
215+
# Pure approval with no comments — update timestamp only
216+
log.info("[%s] Pure PR approval — updating last_seen_at", ref)
217+
update_issue_state(url, {"last_seen_at": _now_utc()})
218+
span.set_attribute("pr.skip_reason", "pure_approval")
219+
return
220+
except Exception as e:
221+
log.warning("[%s] Failed to check PR reviews: %s", ref, e)
222+
223+
should_invoke = in_progress or has_new_review_activity
224+
225+
if not should_invoke:
226+
log.info("[%s] No new review activity — skipping", ref)
227+
span.set_attribute("pr.skip_reason", "no_new_activity")
228+
return
229+
230+
# Mark in_progress before invoking Claude
231+
update_issue_state(url, {"in_progress": True, "owner": owner, "repo": repo, "number": pr_number})
232+
233+
log.info("[%s] New review activity — invoking PR work task", ref)
234+
try:
235+
pr_work.run(url)
236+
except (UsageLimitError, InvocationTimeoutError) as e:
237+
log.warning("[%s] Usage/timeout limit — will retry next cycle: %s", ref, e)
238+
span.set_attribute("pr.status", "retry")
239+
return
240+
except Exception as e:
241+
log.error("[%s] ERROR in PR work task: %s", ref, e)
242+
span.set_status(StatusCode.ERROR, str(e))
243+
span.record_exception(e)
244+
update_issue_state(url, {"in_progress": False})
245+
return
246+
247+
update_issue_state(url, {"in_progress": False, "last_seen_at": _now_utc()})
248+
span.set_attribute("pr.status", "completed")
249+
log.info("[%s] PR cycle complete", ref)
250+
251+
252+
def _is_pr_tracked_as_issue(pr_url: str, issues_state: dict) -> bool:
253+
"""Return True if pr_url is already tracked as the child PR of a known issue.
254+
255+
When Clayde opens a PR while working on an issue, the PR URL is stored
256+
under the *issue* state entry as ``pr_url``. In that case the issue
257+
handler owns review activity for the PR, so the standalone-PR handler
258+
should skip it.
259+
"""
260+
return any(
261+
ist.get("pr_url") == pr_url
262+
for ist in issues_state.values()
263+
)
264+
265+
176266
def _prune_closed_issues(g: Github, issues_state: dict) -> None:
177267
"""Remove closed issues from state to prevent stale entries accumulating."""
178268
to_prune = []
@@ -244,17 +334,26 @@ def main():
244334
issues_state = load_state().get("issues", {})
245335

246336
if not assigned:
247-
log.info("No assigned issues. Going back to sleep.")
337+
log.info("No assigned work items. Going back to sleep.")
248338
provider.force_flush()
249339
return
250340

251-
processed = 0
252-
for issue in assigned:
253-
url = issue.html_url
254-
processed += 1
255-
_handle_issue(g, issue, url)
256-
257-
tick_span.set_attribute("issues.processed", processed)
341+
issues_count = 0
342+
prs_count = 0
343+
for item in assigned:
344+
url = item.html_url
345+
if is_pull_request_item(item):
346+
# Only handle PRs that are NOT already tracked as children of a
347+
# known issue (those are handled via _handle_issue).
348+
if not _is_pr_tracked_as_issue(url, issues_state):
349+
prs_count += 1
350+
_handle_standalone_pr(g, url)
351+
else:
352+
issues_count += 1
353+
_handle_issue(g, item, url)
354+
355+
tick_span.set_attribute("issues.processed", issues_count)
356+
tick_span.set_attribute("prs.processed", prs_count)
258357

259358
provider.force_flush()
260359

src/clayde/prompts/pr_work.j2

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
You are Clayde, an autonomous software agent. You have been assigned to pull request #{{ number }}.
2+
3+
PR: {{ owner }}/{{ repo }}#{{ number }}: {{ title }}
4+
PR URL: {{ pr_url }}
5+
PR BRANCH: {{ branch_name }}
6+
7+
PR DESCRIPTION:
8+
{{ body }}
9+
10+
PR REVIEWS:
11+
{{ review_text }}
12+
13+
REPOSITORY ON DISK: {{ repo_path }}
14+
15+
---
16+
17+
Address the review feedback on this pull request. The PR is already open on branch `{{ branch_name }}`.
18+
19+
Steps:
20+
1. Ensure you are on branch `{{ branch_name }}` (check out or pull it as needed).
21+
2. Read the review comments carefully and implement all requested changes.
22+
3. Run the test suite to confirm nothing is broken.
23+
4. Commit your changes with a clear message.
24+
5. Push: git push origin {{ branch_name }}
25+
26+
Do NOT open a new pull request — the existing PR {{ pr_url }} will automatically update when you push to `{{ branch_name }}`.
27+
28+
After completing your work, provide a short summary of what you did.
29+
30+
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
31+
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
32+
Your entire response must be parseable by json.loads().
33+
34+
{"summary": "<short summary of actions taken>"}

src/clayde/safety.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ def get_new_visible_comments(comments: list, last_seen_at: datetime | None) -> l
5757
]
5858

5959

60+
def filter_pr_reviews(reviews: list, github_username: str) -> list:
61+
"""Return only PR reviews from whitelisted users, excluding the bot's own.
62+
63+
A review is visible if the reviewer is in the whitelist and is not the
64+
authenticated bot account.
65+
"""
66+
whitelist = get_settings().whitelisted_users_list
67+
return [
68+
r for r in reviews
69+
if r.user.login in whitelist and r.user.login != github_username
70+
]
71+
72+
6073
def has_visible_content(issue, comments: list) -> bool:
6174
"""Return True if there is any visible content (issue body or comments).
6275

src/clayde/tasks/pr_work.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""PR work task — address review comments on a standalone assigned PR.
2+
3+
A "standalone PR" is a pull request assigned to Clayde that has no originating
4+
issue in the plan → implement → PR lifecycle. State is keyed by the PR URL
5+
rather than an issue URL.
6+
"""
7+
8+
import logging
9+
10+
from clayde.claude import format_cost_line, invoke_claude
11+
from clayde.config import get_github_client, get_settings
12+
from clayde.git import ensure_repo
13+
from clayde.github import (
14+
get_default_branch,
15+
get_pr_review_comments,
16+
get_pr_reviews,
17+
issue_ref,
18+
parse_pr_url,
19+
post_comment,
20+
)
21+
from clayde.prompts import render_template
22+
from clayde.responses import WorkResponse, parse_response
23+
from clayde.safety import filter_pr_reviews
24+
from clayde.state import get_issue_state, update_issue_state
25+
from clayde.telemetry import get_tracer
26+
27+
log = logging.getLogger("clayde.tasks.pr_work")
28+
29+
30+
def _format_reviews(reviews: list, review_comments: list) -> str:
31+
"""Format PR reviews and inline comments into text for the prompt."""
32+
parts = []
33+
for review in reviews:
34+
header = f"Review by @{review.user.login} (state: {review.state}):"
35+
inline = [rc for rc in review_comments if rc.pull_request_review_id == review.id]
36+
has_body = review.body and review.body.strip()
37+
if has_body or inline:
38+
parts.append(f"{header}\n{review.body}" if has_body else header)
39+
for rc in inline:
40+
file_info = f" File: {rc.path}"
41+
if hasattr(rc, "line") and rc.line:
42+
file_info += f", line {rc.line}"
43+
parts.append(f"{file_info}\n {rc.body}")
44+
return "\n---\n".join(parts) or "(none)"
45+
46+
47+
def run(pr_url: str) -> None:
48+
"""Fetch PR context and invoke Claude to address review comments.
49+
50+
State is keyed by *pr_url* (not an issue URL). Raises UsageLimitError or
51+
InvocationTimeoutError on rate/timeout limits so the orchestrator can
52+
leave in_progress=True for automatic retry.
53+
"""
54+
tracer = get_tracer()
55+
with tracer.start_as_current_span("clayde.task.pr_work") as span:
56+
g = get_github_client()
57+
owner, repo, pr_number = parse_pr_url(pr_url)
58+
ref = issue_ref(owner, repo, pr_number)
59+
span.set_attribute("pr.number", pr_number)
60+
span.set_attribute("pr.owner", owner)
61+
span.set_attribute("pr.repo", repo)
62+
63+
repo_obj = g.get_repo(f"{owner}/{repo}")
64+
pr = repo_obj.get_pull(pr_number)
65+
title = pr.title
66+
body = pr.body or "(empty)"
67+
branch_name = pr.head.ref
68+
default_branch = get_default_branch(g, owner, repo)
69+
70+
# Fetch and whitelist-filter reviews
71+
settings = get_settings()
72+
github_username = settings.github_username
73+
reviews = get_pr_reviews(g, owner, repo, pr_number)
74+
review_comments = get_pr_review_comments(g, owner, repo, pr_number)
75+
visible_reviews = filter_pr_reviews(reviews, github_username)
76+
review_text = _format_reviews(visible_reviews, review_comments)
77+
78+
# Persist metadata before invoking Claude
79+
update_issue_state(pr_url, {
80+
"owner": owner,
81+
"repo": repo,
82+
"number": pr_number,
83+
"pr_title": title,
84+
"branch_name": branch_name,
85+
"is_standalone_pr": True,
86+
})
87+
88+
repo_path = ensure_repo(owner, repo, default_branch)
89+
90+
prompt = render_template(
91+
"pr_work.j2",
92+
number=pr_number,
93+
title=title,
94+
owner=owner,
95+
repo=repo,
96+
body=body,
97+
review_text=review_text,
98+
repo_path=repo_path,
99+
branch_name=branch_name,
100+
pr_url=pr_url,
101+
default_branch=default_branch,
102+
)
103+
104+
log.info("[%s: %s] Invoking Claude for PR review", ref, title)
105+
106+
# UsageLimitError/InvocationTimeoutError propagate to the orchestrator
107+
result = invoke_claude(prompt, repo_path)
108+
109+
span.set_attribute("pr_work.output_length", len(result.output or ""))
110+
111+
# Parse summary (best-effort; fall back to raw output snippet)
112+
summary = None
113+
try:
114+
parsed = parse_response(result.output, WorkResponse)
115+
summary = parsed.summary
116+
except ValueError:
117+
log.warning("[%s: %s] Failed to parse PR work response JSON — using raw output",
118+
ref, title)
119+
summary = (result.output or "").strip()[:500] or None
120+
121+
if summary:
122+
post_comment(g, owner, repo, pr_number,
123+
f"{summary}{format_cost_line(result.cost_eur)}")
124+
125+
log.info("[%s: %s] PR work complete", ref, title)

tests/test_github.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
get_pr_review_comments,
2121
get_pr_reviews,
2222
is_blocked,
23+
is_pull_request_item,
2324
parse_issue_url,
2425
parse_pr_url,
2526
post_comment,
@@ -54,6 +55,23 @@ def test_invalid_url_raises(self):
5455
parse_pr_url("https://github.com/alice/repo/issues/1")
5556

5657

58+
class TestIsPullRequestItem:
59+
def test_returns_true_for_pr_url(self):
60+
item = MagicMock()
61+
item.html_url = "https://github.com/o/r/pull/80"
62+
assert is_pull_request_item(item) is True
63+
64+
def test_returns_false_for_issue_url(self):
65+
item = MagicMock()
66+
item.html_url = "https://github.com/o/r/issues/1"
67+
assert is_pull_request_item(item) is False
68+
69+
def test_returns_false_for_arbitrary_url(self):
70+
item = MagicMock()
71+
item.html_url = "https://github.com/o/r"
72+
assert is_pull_request_item(item) is False
73+
74+
5775
class TestFetchIssue:
5876
def test_calls_correct_api(self):
5977
g = MagicMock()

0 commit comments

Comments
 (0)