Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/clayde/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,8 @@ def get_issue_author(g: Github, owner: str, repo: str, number: int) -> str:
def get_pr_title(g: Github, owner: str, repo: str, pr_number: int) -> str:
"""Return the title of a pull request."""
return _get_repo(g, owner, repo).get_pull(pr_number).title


def get_pull(g: Github, owner: str, repo: str, pr_number: int):
"""Return the PullRequest object for the given PR number."""
return _get_repo(g, owner, repo).get_pull(pr_number)
22 changes: 20 additions & 2 deletions src/clayde/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
get_assigned_issues,
get_pr_review_comments,
get_pr_reviews,
get_pull,
is_blocked,
is_pull_request_item,
issue_ref,
Expand All @@ -44,7 +45,7 @@
)
from clayde.safety import filter_pr_reviews, get_new_visible_comments, has_visible_content
from clayde.state import get_issue_state, load_state, save_state, update_issue_state
from clayde.tasks import pr_work, work
from clayde.tasks import work, wrap_up, pr_work
from clayde.telemetry import get_tracer, init_tracer

log = logging.getLogger("clayde.orchestrator")
Expand Down Expand Up @@ -110,9 +111,26 @@ def _handle_issue(g: Github, issue: Issue, url: str) -> None:
# Check for new visible comments since last cycle
new_comments = get_new_visible_comments(comments, last_seen_at)

# Check for merged PR — run wrap-up once, then stop processing this issue
pr_url = issue_state.get("pr_url")
if pr_url:
try:
_, _, pr_number = parse_pr_url(pr_url)
pr = get_pull(g, owner, repo, pr_number)
if pr.merged:
if not issue_state.get("merged"):
log.info("[%s] PR #%d merged — running wrap-up", label, pr_number)
update_issue_state(url, {"merged": True})
try:
wrap_up.run(url)
except Exception as e:
log.error("[%s] Wrap-up failed: %s", label, e)
return
except Exception as e:
log.warning("[%s] Failed to check PR merge status: %s", label, e)

# Check for new PR review activity
has_new_review_activity = False
pr_url = issue_state.get("pr_url")
if pr_url and last_seen_at is not None:
try:
_, _, pr_number = parse_pr_url(pr_url)
Expand Down
49 changes: 49 additions & 0 deletions src/clayde/prompts/wrap_up.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
You are Clayde, an autonomous AI agent. A pull request you opened has just been merged on GitHub.

## Context

- Repository: {{ owner }}/{{ repo }}
- Issue: #{{ number }} — {{ title }}
- PR URL: {{ pr_url }}
- Branch: {{ branch_name }}
- Issue URL: {{ issue_url }}

## Task

Run the wrap-up skill. The skill file is at `skills/wrap-up/SKILL.md` relative to your current
working directory (the knowledge base root at `{{ kb_path }}`).

Read the file first, then follow its complete workflow. The wrap-up skill invokes the reflect
skill — read `skills/reflect/SKILL.md` and follow that too. Both skills may reference the
capture skill — follow that chain as needed.

## Non-Interactive Constraints

You are running autonomously inside a container. Override any interactive step in the skills:

1. **Do NOT edit** `CLAUDE.md`, `AGENTS.md`, `settings.json`, or any system config file.
Instead, write any proposed edit as a draft inbox note (path: `inbox/{{ today }}-wrap-up-{{ topic }}.md`).
2. **Do NOT ask the user for confirmation** — decide autonomously at every step.
3. **Capture everything** — for any "should I capture?" decision, yes.
4. **CLAUDE.md / AGENTS.md proposals** — write proposed wording as a section in the inbox draft,
not directly into the file. Prefix the section with `## Proposed CLAUDE.md edit:` or similar.
5. **Skill stub candidates** — write stub to `inbox/{{ today }}-wrap-up-{{ topic }}.md` as a section,
not to `skills/`.
6. **Do NOT invoke** `fewer-permission-prompts` or any skill that edits settings files.
7. **Worklog entry** — write it directly to the daily note (`daily/{{ today }}.md`). This is a
definite action, not a proposal. Use the issue title and PR URL as the session reference.
PR link format: `[{{ owner }}/{{ repo }}#{{ number }}]({{ pr_url }})`.
8. **Freeshard detection** — check if this work touched the freeshard repo
(`{{ owner }}/{{ repo }}` or concept keywords in the issue title). If yes, run `freeshard-share-scan`
with the same non-interactive constraints: write any share candidate to `inbox/` as a section.

## Output

When all wrap-up steps are complete, your LAST output MUST be a single fenced JSON block:

```json
{"title": "<summary ≤40 chars>", "body": "<worklog entry text ≤300 chars>", "success": true}
```

Set `success` to `false` only if you could not complete the wrap-up. The `body` should match
the worklog entry you wrote to the daily note — this text will be sent as an ntfy notification.
6 changes: 6 additions & 0 deletions src/clayde/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class WorkResponse(BaseModel):
summary: str


class WrapUpResponse(BaseModel):
title: str
body: str
success: bool


def _extract_json(text: str) -> str:
"""Extract a JSON object from LLM output that may contain surrounding text.

Expand Down
95 changes: 95 additions & 0 deletions src/clayde/tasks/wrap_up.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Post-merge wrap-up task — runs wrap-up skill in KB context, notifies via ntfy."""

from __future__ import annotations

import logging
import re
from datetime import date

from clayde.claude import InvocationTimeoutError, UsageLimitError, invoke_claude
from clayde.config import get_settings
from clayde.github import parse_issue_url
from clayde.prompts import render_template
from clayde.responses import WrapUpResponse, parse_response
from clayde.state import get_issue_state
from clayde.telemetry import get_tracer
from clayde.webhook.notify import send_ntfy_sync

log = logging.getLogger("clayde.tasks.wrap_up")


def run(issue_url: str) -> None:
"""Run post-merge wrap-up: invoke wrap-up skill in KB context, notify."""
tracer = get_tracer()
with tracer.start_as_current_span("clayde.task.wrap_up") as span:
settings = get_settings()
owner, repo, number = parse_issue_url(issue_url)
issue_state = get_issue_state(issue_url)

pr_url = issue_state.get("pr_url", "")
title = (
issue_state.get("pr_title")
or issue_state.get("issue_title")
or "(unknown)"
)
branch_name = issue_state.get("branch_name", f"clayde/issue-{number}")

words = re.sub(r"[^a-z0-9\s]", "", title.lower()).split()[:3]
title_slug = "-".join(words) if words else "issue"

span.set_attribute("issue.number", number)
span.set_attribute("issue.owner", owner)
span.set_attribute("issue.repo", repo)

log.info("[%s/%s#%d] Running post-merge wrap-up", owner, repo, number)

prompt = render_template(
"wrap_up.j2",
owner=owner,
repo=repo,
number=number,
title=title,
pr_url=pr_url,
branch_name=branch_name,
issue_url=issue_url,
kb_path=settings.kb_path,
today=date.today().isoformat(),
topic=title_slug,
)

ntfy_title = f"Wrapped up: {owner}/{repo}#{number}"
ntfy_body = title
success = False

try:
result = invoke_claude(prompt, settings.kb_path)
try:
parsed = parse_response(result.output, WrapUpResponse)
ntfy_title = parsed.title
ntfy_body = parsed.body
success = parsed.success
except ValueError as e:
log.warning(
"[%s/%s#%d] Could not parse wrap-up JSON: %s", owner, repo, number, e
)
ntfy_body = f"Wrap-up complete for {owner}/{repo}#{number} (no summary)"
success = True
span.set_attribute("wrap_up.success", success)
except (UsageLimitError, InvocationTimeoutError) as e:
log.warning("[%s/%s#%d] Wrap-up invoke failed: %s", owner, repo, number, e)
ntfy_title = f"Wrap-up failed: {owner}/{repo}#{number}"
ntfy_body = str(e)[:300]
except Exception as e:
log.error("[%s/%s#%d] Wrap-up unexpected error: %s", owner, repo, number, e)
ntfy_title = f"Wrap-up error: {owner}/{repo}#{number}"
ntfy_body = str(e)[:300]

if settings.ntfy_topic:
send_ntfy_sync(
title=ntfy_title,
body=ntfy_body,
success=success,
base_url=settings.ntfy_base_url,
topic=settings.ntfy_topic,
timeout_s=settings.ntfy_timeout_s,
)
34 changes: 34 additions & 0 deletions src/clayde/webhook/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,40 @@ def _clamp_body(cls, v):
return v[:300] if isinstance(v, str) else v


def send_ntfy_sync(
*,
title: str,
body: str,
success: bool,
base_url: str,
topic: str,
timeout_s: int,
) -> None:
"""Synchronous POST to ntfy.sh. Best-effort: errors are logged, never raised."""
url = f"{base_url.rstrip('/')}/{topic}"
headers = {
"Title": _encode_header_value(title[:40]),
"Priority": "3" if success else "5",
"Tags": "white_check_mark" if success else "rotating_light",
}
tracer = get_tracer()
with tracer.start_as_current_span("clayde.pebble.notify") as span:
span.set_attribute("pebble.notify_topic", topic)
span.set_attribute("pebble.notify_title", title)
span.set_attribute("pebble.outcome_success", success)
try:
with httpx.Client(timeout=timeout_s) as client:
resp = client.post(url, content=body[:300], headers=headers)
span.set_attribute("pebble.notify_http_status", resp.status_code)
span.set_attribute("pebble.notify_ok", 200 <= resp.status_code < 300)
if resp.status_code >= 400:
log.warning("ntfy returned %d: %s", resp.status_code, resp.text[:200])
except Exception as exc:
span.set_attribute("pebble.notify_ok", False)
span.set_attribute("pebble.notify_error", type(exc).__name__)
log.warning("ntfy POST failed: %s", exc)


async def send_ntfy(
*,
title: str,
Expand Down
16 changes: 16 additions & 0 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
is_pull_request_item,
parse_issue_url,
parse_pr_url,
get_pull,
post_comment,
)

Expand Down Expand Up @@ -274,3 +275,18 @@ def test_returns_author_login(self):
g = MagicMock()
g.get_repo.return_value.get_issue.return_value.user.login = "alice"
assert get_issue_author(g, "o", "r", 1) == "alice"


class TestGetPull:
def test_returns_pull_request_object(self):
mock_pr = MagicMock()
mock_repo = MagicMock()
mock_repo.get_pull.return_value = mock_pr
g = MagicMock()
g.get_repo.return_value = mock_repo

result = get_pull(g, "owner", "repo", 42)

g.get_repo.assert_called_once_with("owner/repo")
mock_repo.get_pull.assert_called_once_with(42)
assert result is mock_pr
Loading
Loading