|
37 | 37 | get_pr_review_comments, |
38 | 38 | get_pr_reviews, |
39 | 39 | is_blocked, |
| 40 | + is_pull_request_item, |
40 | 41 | issue_ref, |
41 | 42 | parse_issue_url, |
42 | 43 | parse_pr_url, |
43 | 44 | ) |
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 |
45 | 46 | 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 |
47 | 48 | from clayde.telemetry import get_tracer, init_tracer |
48 | 49 |
|
49 | 50 | log = logging.getLogger("clayde.orchestrator") |
@@ -173,6 +174,95 @@ def _handle_issue(g: Github, issue: Issue, url: str) -> None: |
173 | 174 | log.info("[%s] Cycle complete", label) |
174 | 175 |
|
175 | 176 |
|
| 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 | + |
176 | 266 | def _prune_closed_issues(g: Github, issues_state: dict) -> None: |
177 | 267 | """Remove closed issues from state to prevent stale entries accumulating.""" |
178 | 268 | to_prune = [] |
@@ -244,17 +334,26 @@ def main(): |
244 | 334 | issues_state = load_state().get("issues", {}) |
245 | 335 |
|
246 | 336 | if not assigned: |
247 | | - log.info("No assigned issues. Going back to sleep.") |
| 337 | + log.info("No assigned work items. Going back to sleep.") |
248 | 338 | provider.force_flush() |
249 | 339 | return |
250 | 340 |
|
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) |
258 | 357 |
|
259 | 358 | provider.force_flush() |
260 | 359 |
|
|
0 commit comments