patchpilot 0.1.0__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.
- patchpilot/__init__.py +3 -0
- patchpilot/agent.py +90 -0
- patchpilot/analyze.py +36 -0
- patchpilot/cli.py +151 -0
- patchpilot/github.py +83 -0
- patchpilot/sandbox.py +114 -0
- patchpilot/tools.py +331 -0
- patchpilot/upgrade.py +287 -0
- patchpilot-0.1.0.dist-info/METADATA +193 -0
- patchpilot-0.1.0.dist-info/RECORD +13 -0
- patchpilot-0.1.0.dist-info/WHEEL +4 -0
- patchpilot-0.1.0.dist-info/entry_points.txt +2 -0
- patchpilot-0.1.0.dist-info/licenses/LICENSE +21 -0
patchpilot/__init__.py
ADDED
patchpilot/agent.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The agent loop: send the conversation to Claude, run the tools it asks for, repeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import anthropic
|
|
8
|
+
|
|
9
|
+
from patchpilot.tools import TOOL_FUNCTIONS, ToolError, Workspace
|
|
10
|
+
|
|
11
|
+
MODEL = "claude-opus-5-5"
|
|
12
|
+
|
|
13
|
+
# Anthropic-hosted tool: Claude fetches URLs (like changelogs) itself, no code needed on our side.
|
|
14
|
+
# It only fetches URLs that already appear in the conversation, e.g. from get_latest_version.
|
|
15
|
+
WEB_FETCH_TOOL = {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": 8}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def execute_tool(ws: Workspace, name: str, tool_input: dict) -> tuple[str, bool]:
|
|
19
|
+
"""Run one tool. Returns (result_text, is_error)."""
|
|
20
|
+
func = TOOL_FUNCTIONS.get(name)
|
|
21
|
+
if func is None:
|
|
22
|
+
return f"Unknown tool '{name}'.", True
|
|
23
|
+
try:
|
|
24
|
+
return func(ws, **tool_input), False
|
|
25
|
+
except ToolError as e:
|
|
26
|
+
return str(e), True
|
|
27
|
+
except TypeError as e:
|
|
28
|
+
return f"Invalid arguments for '{name}': {e}", True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_agent(
|
|
32
|
+
ws: Workspace,
|
|
33
|
+
system: str,
|
|
34
|
+
task: str,
|
|
35
|
+
tools: list[dict],
|
|
36
|
+
max_turns: int = 30,
|
|
37
|
+
client: anthropic.Anthropic | None = None,
|
|
38
|
+
on_tool_call: Callable[[str, dict], None] | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Run the agent until it finishes and return its final text."""
|
|
41
|
+
client = client or anthropic.Anthropic()
|
|
42
|
+
allowed = {t["name"] for t in tools if "input_schema" in t}
|
|
43
|
+
messages: list[dict] = [{"role": "user", "content": task}]
|
|
44
|
+
|
|
45
|
+
for _ in range(max_turns):
|
|
46
|
+
response = client.beta.messages.create(
|
|
47
|
+
model=MODEL,
|
|
48
|
+
max_tokens=16000,
|
|
49
|
+
system=system,
|
|
50
|
+
tools=tools,
|
|
51
|
+
messages=messages,
|
|
52
|
+
output_config={"effort": "high"},
|
|
53
|
+
# If a request is declined by a safety classifier, retry on the server's default
|
|
54
|
+
# fallback model instead of failing outright.
|
|
55
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
56
|
+
fallbacks="default",
|
|
57
|
+
)
|
|
58
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
59
|
+
|
|
60
|
+
if response.stop_reason == "refusal":
|
|
61
|
+
raise RuntimeError("Claude declined this request.")
|
|
62
|
+
if response.stop_reason == "max_tokens":
|
|
63
|
+
raise RuntimeError("Response hit max_tokens before finishing.")
|
|
64
|
+
if response.stop_reason == "pause_turn":
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
tool_uses = [b for b in response.content if b.type == "tool_use"]
|
|
68
|
+
if not tool_uses:
|
|
69
|
+
return "\n".join(b.text for b in response.content if b.type == "text")
|
|
70
|
+
|
|
71
|
+
tool_results = []
|
|
72
|
+
for block in tool_uses:
|
|
73
|
+
if on_tool_call:
|
|
74
|
+
on_tool_call(block.name, block.input)
|
|
75
|
+
if block.name in allowed:
|
|
76
|
+
result, is_error = execute_tool(ws, block.name, block.input)
|
|
77
|
+
else:
|
|
78
|
+
result, is_error = f"Tool '{block.name}' is not available here.", True
|
|
79
|
+
tool_results.append(
|
|
80
|
+
{
|
|
81
|
+
"type": "tool_result",
|
|
82
|
+
"tool_use_id": block.id,
|
|
83
|
+
"content": result,
|
|
84
|
+
"is_error": is_error,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
# All results for one turn go back in a single user message.
|
|
88
|
+
messages.append({"role": "user", "content": tool_results})
|
|
89
|
+
|
|
90
|
+
raise RuntimeError(f"Agent did not finish within {max_turns} turns.")
|
patchpilot/analyze.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""`patchpilot analyze`: a read-only report of what could be upgraded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from patchpilot.agent import run_agent
|
|
8
|
+
from patchpilot.tools import READ_TOOL_SCHEMAS, Workspace
|
|
9
|
+
|
|
10
|
+
SYSTEM_PROMPT = """\
|
|
11
|
+
You are patchpilot, an agent that keeps a Python project's dependencies up to date.
|
|
12
|
+
|
|
13
|
+
Your job right now is to produce an upgrade report. Use the tools to:
|
|
14
|
+
1. Read the project's declared dependencies.
|
|
15
|
+
2. Look up the latest version of each one on PyPI.
|
|
16
|
+
3. Run the test suite to establish a baseline.
|
|
17
|
+
|
|
18
|
+
Then finish with a concise report in Markdown:
|
|
19
|
+
- A table of dependencies: name, current specifier, latest version, and whether the
|
|
20
|
+
current specifier already allows the latest version.
|
|
21
|
+
- Which upgrades look like major-version jumps that are likely to need code changes.
|
|
22
|
+
- The baseline test result.
|
|
23
|
+
- A recommended order to upgrade in, with one line of reasoning each.
|
|
24
|
+
|
|
25
|
+
You can only read and run tests in this phase; do not suggest that you changed any files.\
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def analyze(ws: Workspace, on_tool_call: Callable[[str, dict], None] | None = None) -> str:
|
|
30
|
+
return run_agent(
|
|
31
|
+
ws,
|
|
32
|
+
system=SYSTEM_PROMPT,
|
|
33
|
+
task=f"Analyze the project at {ws.root} and write the report.",
|
|
34
|
+
tools=READ_TOOL_SCHEMAS,
|
|
35
|
+
on_tool_call=on_tool_call,
|
|
36
|
+
)
|
patchpilot/cli.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
patchpilot analyze <project> read-only upgrade report
|
|
4
|
+
patchpilot upgrade <project> <package> upgrade one package and fix what breaks
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import anthropic
|
|
16
|
+
from dotenv import find_dotenv, load_dotenv
|
|
17
|
+
|
|
18
|
+
from patchpilot.agent import MODEL
|
|
19
|
+
from patchpilot.analyze import analyze
|
|
20
|
+
from patchpilot.github import GitHubError
|
|
21
|
+
from patchpilot.sandbox import DEFAULT_IMAGE, SandboxError
|
|
22
|
+
from patchpilot.tools import Workspace
|
|
23
|
+
from patchpilot.upgrade import UpgradeError, upgrade
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_tool_call(name: str, tool_input: dict) -> None:
|
|
27
|
+
# File contents can be long; show just the path for writes.
|
|
28
|
+
shown = {k: v for k, v in tool_input.items() if k not in ("content", "old_text", "new_text")}
|
|
29
|
+
print(f" → {name} {json.dumps(shown)}", file=sys.stderr)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def explain_api_error(e: anthropic.AnthropicError) -> str:
|
|
33
|
+
"""Turn an Anthropic SDK error into one sentence the user can act on."""
|
|
34
|
+
detail = str(e)
|
|
35
|
+
if isinstance(e, anthropic.APIStatusError) and isinstance(e.body, dict):
|
|
36
|
+
detail = e.body.get("error", {}).get("message", detail)
|
|
37
|
+
|
|
38
|
+
if isinstance(e, anthropic.BadRequestError) and "credit balance" in detail:
|
|
39
|
+
return (
|
|
40
|
+
"Your Anthropic account is out of credit. Add some at "
|
|
41
|
+
"https://console.anthropic.com/settings/billing and run patchpilot again."
|
|
42
|
+
)
|
|
43
|
+
if isinstance(e, anthropic.AuthenticationError):
|
|
44
|
+
return (
|
|
45
|
+
"Your Anthropic API key was rejected. Check ANTHROPIC_API_KEY in your .env file "
|
|
46
|
+
"(keys are at https://console.anthropic.com/settings/keys)."
|
|
47
|
+
)
|
|
48
|
+
if isinstance(e, anthropic.PermissionDeniedError):
|
|
49
|
+
return f"Your API key doesn't have access to {MODEL}: {detail}"
|
|
50
|
+
if isinstance(e, anthropic.RateLimitError):
|
|
51
|
+
return "The Anthropic API is rate-limiting this key. Wait a minute and try again."
|
|
52
|
+
if isinstance(e, anthropic.APIConnectionError):
|
|
53
|
+
return "Couldn't reach the Anthropic API. Check your internet connection and try again."
|
|
54
|
+
if isinstance(e, anthropic.APIStatusError):
|
|
55
|
+
return f"The Anthropic API returned an error ({e.status_code}): {detail}"
|
|
56
|
+
# e.g. no API key configured at all
|
|
57
|
+
return f"Couldn't call the Anthropic API: {detail}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_status(message: str) -> None:
|
|
61
|
+
print(f"• {message}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog="patchpilot",
|
|
67
|
+
description="An AI agent that upgrades your dependencies and fixes what breaks.",
|
|
68
|
+
)
|
|
69
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
70
|
+
|
|
71
|
+
analyze_p = sub.add_parser("analyze", help="Report what could be upgraded (changes nothing).")
|
|
72
|
+
analyze_p.add_argument("project", type=Path, help="Path to the project.")
|
|
73
|
+
|
|
74
|
+
upgrade_p = sub.add_parser(
|
|
75
|
+
"upgrade", help="Upgrade one package, fix the code, and commit it to a new branch."
|
|
76
|
+
)
|
|
77
|
+
upgrade_p.add_argument("project", type=Path, help="Path to the project (inside a git repo).")
|
|
78
|
+
upgrade_p.add_argument("package", help="Name of the package to upgrade, e.g. pydantic.")
|
|
79
|
+
upgrade_p.add_argument(
|
|
80
|
+
"--image",
|
|
81
|
+
default=DEFAULT_IMAGE,
|
|
82
|
+
help="Docker image to run the project in; pick one matching your Python version "
|
|
83
|
+
"(default: %(default)s).",
|
|
84
|
+
)
|
|
85
|
+
upgrade_p.add_argument(
|
|
86
|
+
"--pr",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Push the branch and open a GitHub pull request (needs the gh CLI, logged in).",
|
|
89
|
+
)
|
|
90
|
+
upgrade_p.add_argument(
|
|
91
|
+
"--no-docker",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Run in a local virtualenv instead of Docker. Faster, but the project's code "
|
|
94
|
+
"runs directly on your machine.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
for p in (analyze_p, upgrade_p):
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"--test-command",
|
|
100
|
+
default="python -m pytest -q",
|
|
101
|
+
help="Command that runs the project's tests (default: %(default)s).",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
project = args.project.expanduser().resolve()
|
|
106
|
+
if not project.is_dir():
|
|
107
|
+
parser.error(f"{project} is not a directory")
|
|
108
|
+
test_command = shlex.split(args.test_command)
|
|
109
|
+
|
|
110
|
+
# Look for .env in the directory patchpilot is run from, not where it's installed.
|
|
111
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if args.command == "analyze":
|
|
115
|
+
ws = Workspace(root=project, test_command=test_command)
|
|
116
|
+
print(analyze(ws, on_tool_call=log_tool_call))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
result = upgrade(
|
|
120
|
+
project,
|
|
121
|
+
args.package,
|
|
122
|
+
test_command=test_command,
|
|
123
|
+
on_status=log_status,
|
|
124
|
+
on_tool_call=log_tool_call,
|
|
125
|
+
use_docker=not args.no_docker,
|
|
126
|
+
image=args.image,
|
|
127
|
+
open_pr=args.pr,
|
|
128
|
+
)
|
|
129
|
+
except (RuntimeError, UpgradeError, SandboxError, GitHubError) as e:
|
|
130
|
+
print(f"patchpilot: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
except anthropic.AnthropicError as e:
|
|
133
|
+
print(f"patchpilot: {explain_api_error(e)}", file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
print(result.report)
|
|
137
|
+
print(file=sys.stderr)
|
|
138
|
+
if not result.committed:
|
|
139
|
+
log_status("The agent didn't change any files, so no branch was created.")
|
|
140
|
+
return 1
|
|
141
|
+
verdict = "✅ tests pass" if result.tests_passed else "❌ tests still failing"
|
|
142
|
+
log_status(f"Committed to branch {result.branch} ({verdict})")
|
|
143
|
+
if result.pr_url:
|
|
144
|
+
log_status(f"Pull request: {result.pr_url}")
|
|
145
|
+
else:
|
|
146
|
+
log_status(f"Review it with: git diff HEAD...{result.branch}")
|
|
147
|
+
return 0 if result.tests_passed else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|
patchpilot/github.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Push the upgrade branch and open a pull request, using the GitHub CLI (`gh`).
|
|
2
|
+
|
|
3
|
+
Going through `gh` means patchpilot never handles a GitHub token itself: it uses
|
|
4
|
+
whatever account the user already logged in with (`gh auth login`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubError(Exception):
|
|
15
|
+
"""Pushing or opening the PR failed; the message is meant for the user."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(cmd: list[str], cwd: Path) -> str:
|
|
19
|
+
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
20
|
+
if proc.returncode != 0:
|
|
21
|
+
raise GitHubError(f"`{' '.join(cmd[:3])}` failed:\n{proc.stderr.strip()}")
|
|
22
|
+
return proc.stdout.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_ready(repo_root: Path, branch: str, remote: str = "origin") -> str:
|
|
26
|
+
"""Check everything a PR needs before any work starts. Returns the base branch."""
|
|
27
|
+
if shutil.which("gh") is None:
|
|
28
|
+
raise GitHubError(
|
|
29
|
+
"--pr needs the GitHub CLI. Install it (brew install gh) and run: gh auth login"
|
|
30
|
+
)
|
|
31
|
+
if subprocess.run(["gh", "auth", "status"], capture_output=True).returncode != 0:
|
|
32
|
+
raise GitHubError("You're not logged in to the GitHub CLI. Run: gh auth login")
|
|
33
|
+
|
|
34
|
+
remotes = _run(["git", "remote"], repo_root).split()
|
|
35
|
+
if remote not in remotes:
|
|
36
|
+
raise GitHubError(
|
|
37
|
+
f"This repo has no '{remote}' remote, so there's nowhere to open a PR. "
|
|
38
|
+
"Push it to GitHub first."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
base = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root)
|
|
42
|
+
if base == "HEAD":
|
|
43
|
+
raise GitHubError("You're in 'detached HEAD' state. Check out a branch first.")
|
|
44
|
+
on_remote = subprocess.run(
|
|
45
|
+
["git", "ls-remote", "--exit-code", "--heads", remote, base],
|
|
46
|
+
cwd=repo_root,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
)
|
|
49
|
+
if on_remote.returncode != 0:
|
|
50
|
+
raise GitHubError(
|
|
51
|
+
f"Branch '{base}' isn't on GitHub yet, so a PR can't target it. "
|
|
52
|
+
f"Push it first: git push -u {remote} {base}"
|
|
53
|
+
)
|
|
54
|
+
taken = subprocess.run(
|
|
55
|
+
["git", "ls-remote", "--exit-code", "--heads", remote, branch],
|
|
56
|
+
cwd=repo_root,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
)
|
|
59
|
+
if taken.returncode == 0:
|
|
60
|
+
raise GitHubError(
|
|
61
|
+
f"Branch '{branch}' already exists on GitHub (probably an earlier patchpilot PR). "
|
|
62
|
+
f"Merge or close that PR and delete the branch first."
|
|
63
|
+
)
|
|
64
|
+
return base
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def open_pull_request(
|
|
68
|
+
repo_root: Path,
|
|
69
|
+
branch: str,
|
|
70
|
+
base: str,
|
|
71
|
+
title: str,
|
|
72
|
+
body: str,
|
|
73
|
+
draft: bool,
|
|
74
|
+
remote: str = "origin",
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Push the branch and open a PR. Returns the PR's URL."""
|
|
77
|
+
_run(["git", "push", "--quiet", "--set-upstream", remote, branch], repo_root)
|
|
78
|
+
cmd = ["gh", "pr", "create", "--head", branch, "--base", base, "--title", title]
|
|
79
|
+
cmd += ["--body", body]
|
|
80
|
+
if draft:
|
|
81
|
+
cmd.append("--draft")
|
|
82
|
+
# gh prints the new PR's URL as the last line of its output.
|
|
83
|
+
return _run(cmd, repo_root).splitlines()[-1]
|
patchpilot/sandbox.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Where the project's own code runs: a Docker container (default) or a local virtualenv.
|
|
2
|
+
|
|
3
|
+
Only commands run here: installing packages and running the test suite. File reads and
|
|
4
|
+
edits happen on the host, inside the temporary worktree, and the agent loop (with the API
|
|
5
|
+
key) never enters the sandbox.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
DEFAULT_IMAGE = "python:3.13-slim"
|
|
18
|
+
CONTAINER_ROOT = "/workspace"
|
|
19
|
+
CONTAINER_VENV = "/tmp/venv"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SandboxError(Exception):
|
|
23
|
+
"""The sandbox couldn't be created; the message is meant for the user."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalSandbox:
|
|
27
|
+
"""A fresh virtualenv on the host. No isolation beyond Python packages."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, project: Path, venv_dir: Path):
|
|
30
|
+
self.project = project
|
|
31
|
+
self.venv_dir = venv_dir
|
|
32
|
+
self.python = str(venv_dir / "bin" / "python")
|
|
33
|
+
|
|
34
|
+
def __enter__(self) -> LocalSandbox:
|
|
35
|
+
subprocess.run([sys.executable, "-m", "venv", str(self.venv_dir)], check=True)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *exc) -> None:
|
|
39
|
+
shutil.rmtree(self.venv_dir, ignore_errors=True)
|
|
40
|
+
|
|
41
|
+
def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
return subprocess.run(
|
|
43
|
+
cmd, cwd=self.project, capture_output=True, text=True, timeout=timeout
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DockerSandbox:
|
|
48
|
+
"""A locked-down container that can see only the temporary worktree.
|
|
49
|
+
|
|
50
|
+
- mounts the worktree at /workspace and nothing else from the host
|
|
51
|
+
- runs as the host user (not root), with all Linux capabilities dropped
|
|
52
|
+
- caps memory, CPU and process count so runaway tests can't take down the machine
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, worktree: Path, project_rel: Path, image: str = DEFAULT_IMAGE):
|
|
56
|
+
self.worktree = worktree
|
|
57
|
+
rel = project_rel.as_posix()
|
|
58
|
+
self.workdir = CONTAINER_ROOT if rel == "." else f"{CONTAINER_ROOT}/{rel}"
|
|
59
|
+
self.image = image
|
|
60
|
+
self.name = f"patchpilot-{uuid.uuid4().hex[:12]}"
|
|
61
|
+
self.python = f"{CONTAINER_VENV}/bin/python"
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def check_available() -> None:
|
|
65
|
+
if shutil.which("docker") is None:
|
|
66
|
+
raise SandboxError(
|
|
67
|
+
"Docker isn't installed. Install Docker Desktop, or run with --no-docker "
|
|
68
|
+
"to use a local virtualenv instead (less isolated)."
|
|
69
|
+
)
|
|
70
|
+
info = subprocess.run(["docker", "info"], capture_output=True, text=True)
|
|
71
|
+
if info.returncode != 0:
|
|
72
|
+
raise SandboxError(
|
|
73
|
+
"Docker is installed but not running. Start Docker Desktop and try again."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> DockerSandbox:
|
|
77
|
+
self.check_available()
|
|
78
|
+
cmd = [
|
|
79
|
+
"docker", "run", "--detach", "--rm",
|
|
80
|
+
"--name", self.name,
|
|
81
|
+
"--volume", f"{self.worktree}:{CONTAINER_ROOT}",
|
|
82
|
+
"--workdir", self.workdir,
|
|
83
|
+
"--user", f"{os.getuid()}:{os.getgid()}",
|
|
84
|
+
"--env", "HOME=/tmp",
|
|
85
|
+
"--env", "PIP_DISABLE_PIP_VERSION_CHECK=1",
|
|
86
|
+
"--cap-drop", "ALL",
|
|
87
|
+
"--security-opt", "no-new-privileges",
|
|
88
|
+
"--memory", "2g",
|
|
89
|
+
"--cpus", "2",
|
|
90
|
+
"--pids-limit", "512",
|
|
91
|
+
self.image,
|
|
92
|
+
"sleep", "infinity",
|
|
93
|
+
] # fmt: skip
|
|
94
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
raise SandboxError(f"Couldn't start the Docker sandbox:\n{proc.stderr.strip()}")
|
|
97
|
+
venv = self.run(["python", "-m", "venv", CONTAINER_VENV], timeout=120)
|
|
98
|
+
if venv.returncode != 0:
|
|
99
|
+
self.__exit__()
|
|
100
|
+
raise SandboxError(f"Couldn't create a virtualenv in the sandbox:\n{venv.stderr}")
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *exc) -> None:
|
|
104
|
+
subprocess.run(["docker", "rm", "--force", self.name], capture_output=True)
|
|
105
|
+
|
|
106
|
+
def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
|
|
107
|
+
# `timeout` inside the container kills the command itself; killing only the
|
|
108
|
+
# `docker exec` client would leave it running in the container.
|
|
109
|
+
return subprocess.run(
|
|
110
|
+
["docker", "exec", self.name, "timeout", str(timeout), *cmd],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=timeout + 30,
|
|
114
|
+
)
|
patchpilot/tools.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Tools the agent can call, plus the JSON schemas that describe them to Claude.
|
|
2
|
+
|
|
3
|
+
Every tool works inside a single project directory (the "workspace"). Paths the
|
|
4
|
+
model sends are resolved against that directory and rejected if they escape it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import tomllib
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
|
|
19
|
+
MAX_OUTPUT_CHARS = 20_000
|
|
20
|
+
IGNORED_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache"}
|
|
21
|
+
|
|
22
|
+
# Matches "name", "name==1.2", "name[extra]>=1.0,<2", etc. Group 1 is the name, group 3 the spec.
|
|
23
|
+
REQUIREMENT_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([^;#]*)")
|
|
24
|
+
# What pip_install accepts: a name, optional extras, optional version specifier. No flags or URLs.
|
|
25
|
+
INSTALLABLE_RE = re.compile(
|
|
26
|
+
r"[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?([<>=!~]=?[0-9A-Za-z.*,<>=!~]+)?"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolError(Exception):
|
|
31
|
+
"""A tool failed in a way the model should hear about and can recover from."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Sandbox(Protocol):
|
|
35
|
+
"""Somewhere to run the project's commands (see patchpilot.sandbox)."""
|
|
36
|
+
|
|
37
|
+
python: str
|
|
38
|
+
|
|
39
|
+
def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Workspace:
|
|
44
|
+
root: Path
|
|
45
|
+
test_command: list[str]
|
|
46
|
+
# Where commands run in upgrade mode. None runs them directly in `root`, using whatever
|
|
47
|
+
# Python is on PATH (analyze mode, which never installs anything).
|
|
48
|
+
sandbox: Sandbox | None = None
|
|
49
|
+
changed_files: set[str] = field(default_factory=set)
|
|
50
|
+
|
|
51
|
+
def resolve(self, relative_path: str) -> Path:
|
|
52
|
+
path = (self.root / relative_path).resolve()
|
|
53
|
+
if not path.is_relative_to(self.root.resolve()):
|
|
54
|
+
raise ToolError(f"Path '{relative_path}' is outside the project directory.")
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
def command(self, cmd: list[str]) -> list[str]:
|
|
58
|
+
"""Point a leading `python` at the sandbox's own interpreter."""
|
|
59
|
+
if self.sandbox and cmd and cmd[0] in ("python", "python3"):
|
|
60
|
+
return [self.sandbox.python, *cmd[1:]]
|
|
61
|
+
return cmd
|
|
62
|
+
|
|
63
|
+
def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
|
|
64
|
+
cmd = self.command(cmd)
|
|
65
|
+
try:
|
|
66
|
+
if self.sandbox:
|
|
67
|
+
proc = self.sandbox.run(cmd, timeout)
|
|
68
|
+
else:
|
|
69
|
+
proc = subprocess.run(
|
|
70
|
+
cmd, cwd=self.root, capture_output=True, text=True, timeout=timeout
|
|
71
|
+
)
|
|
72
|
+
except FileNotFoundError as e:
|
|
73
|
+
raise ToolError(f"Command not found: {cmd[0]}") from e
|
|
74
|
+
except subprocess.TimeoutExpired as e:
|
|
75
|
+
raise ToolError(f"Command timed out after {timeout} seconds.") from e
|
|
76
|
+
if self.sandbox and proc.returncode == 124: # exit code of coreutils `timeout`
|
|
77
|
+
raise ToolError(f"Command timed out after {timeout} seconds.")
|
|
78
|
+
return proc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _truncate(text: str) -> str:
|
|
82
|
+
if len(text) <= MAX_OUTPUT_CHARS:
|
|
83
|
+
return text
|
|
84
|
+
# Test failures usually matter most at the end of the output, so keep the tail.
|
|
85
|
+
dropped = len(text) - MAX_OUTPUT_CHARS
|
|
86
|
+
return f"[... {dropped} characters truncated ...]\n" + text[-MAX_OUTPUT_CHARS:]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _format_result(proc: subprocess.CompletedProcess[str]) -> str:
|
|
90
|
+
status = "PASSED" if proc.returncode == 0 else f"FAILED (exit code {proc.returncode})"
|
|
91
|
+
return _truncate(f"{status}\n\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def list_files(ws: Workspace, directory: str = ".") -> str:
|
|
95
|
+
base = ws.resolve(directory)
|
|
96
|
+
if not base.is_dir():
|
|
97
|
+
raise ToolError(f"'{directory}' is not a directory.")
|
|
98
|
+
files = []
|
|
99
|
+
for path in sorted(base.rglob("*")):
|
|
100
|
+
rel = path.relative_to(ws.root.resolve())
|
|
101
|
+
if any(part in IGNORED_DIRS for part in rel.parts) or not path.is_file():
|
|
102
|
+
continue
|
|
103
|
+
files.append(str(rel))
|
|
104
|
+
return "\n".join(files) if files else "(no files)"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_file(ws: Workspace, path: str) -> str:
|
|
108
|
+
target = ws.resolve(path)
|
|
109
|
+
if not target.is_file():
|
|
110
|
+
raise ToolError(f"File '{path}' does not exist.")
|
|
111
|
+
return _truncate(target.read_text(errors="replace"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _writable(ws: Workspace, path: str) -> Path:
|
|
115
|
+
target = ws.resolve(path)
|
|
116
|
+
rel = target.relative_to(ws.root.resolve())
|
|
117
|
+
if any(part in IGNORED_DIRS for part in rel.parts):
|
|
118
|
+
raise ToolError(f"Refusing to write inside '{rel.parts[0]}'.")
|
|
119
|
+
return target
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_file(ws: Workspace, path: str, content: str) -> str:
|
|
123
|
+
target = _writable(ws, path)
|
|
124
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
target.write_text(content)
|
|
126
|
+
ws.changed_files.add(path)
|
|
127
|
+
return f"Wrote {len(content)} characters to {path}."
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def edit_file(ws: Workspace, path: str, old_text: str, new_text: str) -> str:
|
|
131
|
+
target = _writable(ws, path)
|
|
132
|
+
if not target.is_file():
|
|
133
|
+
raise ToolError(f"File '{path}' does not exist. Use write_file to create it.")
|
|
134
|
+
text = target.read_text()
|
|
135
|
+
count = text.count(old_text)
|
|
136
|
+
if count == 0:
|
|
137
|
+
raise ToolError(f"old_text was not found in {path}. Read the file and copy it exactly.")
|
|
138
|
+
if count > 1:
|
|
139
|
+
raise ToolError(f"old_text appears {count} times in {path}. Include more context.")
|
|
140
|
+
target.write_text(text.replace(old_text, new_text))
|
|
141
|
+
ws.changed_files.add(path)
|
|
142
|
+
return f"Edited {path}."
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_requirements(text: str) -> list[dict[str, str]]:
|
|
146
|
+
deps = []
|
|
147
|
+
for line in text.splitlines():
|
|
148
|
+
line = line.strip()
|
|
149
|
+
if not line or line.startswith(("#", "-")):
|
|
150
|
+
continue
|
|
151
|
+
match = REQUIREMENT_RE.match(line)
|
|
152
|
+
if match:
|
|
153
|
+
deps.append({"name": match.group(1), "spec": match.group(3).strip()})
|
|
154
|
+
return deps
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def normalize_name(name: str) -> str:
|
|
158
|
+
"""PyPI treats 'Foo_Bar', 'foo-bar' and 'foo.bar' as the same package (PEP 503)."""
|
|
159
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def collect_dependencies(root: Path) -> dict[str, list[dict[str, str]]]:
|
|
163
|
+
"""Declared dependencies from requirements*.txt and pyproject.toml, keyed by source."""
|
|
164
|
+
found: dict[str, list[dict[str, str]]] = {}
|
|
165
|
+
root = root.resolve()
|
|
166
|
+
|
|
167
|
+
for req_file in sorted(root.glob("requirements*.txt")):
|
|
168
|
+
found[req_file.name] = parse_requirements(req_file.read_text())
|
|
169
|
+
|
|
170
|
+
pyproject = root / "pyproject.toml"
|
|
171
|
+
if pyproject.is_file():
|
|
172
|
+
data = tomllib.loads(pyproject.read_text())
|
|
173
|
+
project = data.get("project", {})
|
|
174
|
+
found["pyproject.toml [project.dependencies]"] = parse_requirements(
|
|
175
|
+
"\n".join(project.get("dependencies", []))
|
|
176
|
+
)
|
|
177
|
+
for group, reqs in project.get("optional-dependencies", {}).items():
|
|
178
|
+
found[f"pyproject.toml [optional-dependencies.{group}]"] = parse_requirements(
|
|
179
|
+
"\n".join(reqs)
|
|
180
|
+
)
|
|
181
|
+
return found
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_dependencies(ws: Workspace) -> str:
|
|
185
|
+
found = collect_dependencies(ws.root)
|
|
186
|
+
if not found:
|
|
187
|
+
raise ToolError("No requirements*.txt or pyproject.toml found in the project.")
|
|
188
|
+
return json.dumps(found, indent=2)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_latest_version(ws: Workspace, package: str) -> str:
|
|
192
|
+
"""Look up the newest release of a package on PyPI."""
|
|
193
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", package):
|
|
194
|
+
raise ToolError(f"'{package}' is not a valid package name.")
|
|
195
|
+
url = f"https://pypi.org/pypi/{package}/json"
|
|
196
|
+
try:
|
|
197
|
+
with urllib.request.urlopen(url, timeout=15) as resp:
|
|
198
|
+
info = json.load(resp)["info"]
|
|
199
|
+
except urllib.error.HTTPError as e:
|
|
200
|
+
raise ToolError(f"PyPI returned {e.code} for '{package}'.") from e
|
|
201
|
+
except urllib.error.URLError as e:
|
|
202
|
+
raise ToolError(f"Could not reach PyPI: {e.reason}") from e
|
|
203
|
+
return json.dumps(
|
|
204
|
+
{
|
|
205
|
+
"name": info["name"],
|
|
206
|
+
"latest_version": info["version"],
|
|
207
|
+
"requires_python": info.get("requires_python"),
|
|
208
|
+
"project_urls": info.get("project_urls"),
|
|
209
|
+
},
|
|
210
|
+
indent=2,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def pip_install(ws: Workspace, requirement: str) -> str:
|
|
215
|
+
"""Install one requirement (e.g. 'pydantic==2.9.2') into the sandbox's environment."""
|
|
216
|
+
if ws.sandbox is None:
|
|
217
|
+
raise ToolError("Installing packages is only available in upgrade mode.")
|
|
218
|
+
if not INSTALLABLE_RE.fullmatch(requirement):
|
|
219
|
+
raise ToolError(
|
|
220
|
+
f"'{requirement}' is not allowed. Pass a single requirement like 'name==1.2.3'."
|
|
221
|
+
)
|
|
222
|
+
return _format_result(ws.run(["python", "-m", "pip", "install", requirement], timeout=600))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_tests(ws: Workspace) -> str:
|
|
226
|
+
return _format_result(ws.run(ws.test_command, timeout=600))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
TOOL_FUNCTIONS = {
|
|
230
|
+
"list_files": list_files,
|
|
231
|
+
"read_file": read_file,
|
|
232
|
+
"write_file": write_file,
|
|
233
|
+
"edit_file": edit_file,
|
|
234
|
+
"read_dependencies": read_dependencies,
|
|
235
|
+
"get_latest_version": get_latest_version,
|
|
236
|
+
"pip_install": pip_install,
|
|
237
|
+
"run_tests": run_tests,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _schema(properties: dict, required: list[str]) -> dict:
|
|
242
|
+
return {
|
|
243
|
+
"type": "object",
|
|
244
|
+
"properties": properties,
|
|
245
|
+
"required": required,
|
|
246
|
+
"additionalProperties": False,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_PATH = {"type": "string", "description": "Path relative to the project root."}
|
|
251
|
+
|
|
252
|
+
READ_TOOL_SCHEMAS = [
|
|
253
|
+
{
|
|
254
|
+
"name": "list_files",
|
|
255
|
+
"description": "List project files recursively, skipping .git, virtualenvs and caches.",
|
|
256
|
+
"input_schema": _schema(
|
|
257
|
+
{
|
|
258
|
+
"directory": {
|
|
259
|
+
"type": "string",
|
|
260
|
+
"description": "Directory relative to the project root. Defaults to '.'.",
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[],
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"name": "read_file",
|
|
268
|
+
"description": "Read a text file from the project.",
|
|
269
|
+
"input_schema": _schema({"path": _PATH}, ["path"]),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"name": "read_dependencies",
|
|
273
|
+
"description": (
|
|
274
|
+
"Return the project's declared dependencies and version specifiers from "
|
|
275
|
+
"requirements*.txt and pyproject.toml, grouped by source file."
|
|
276
|
+
),
|
|
277
|
+
"input_schema": _schema({}, []),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"name": "get_latest_version",
|
|
281
|
+
"description": (
|
|
282
|
+
"Look up the latest released version of a package on PyPI, plus its project URLs "
|
|
283
|
+
"(often including a changelog link)."
|
|
284
|
+
),
|
|
285
|
+
"input_schema": _schema(
|
|
286
|
+
{"package": {"type": "string", "description": "PyPI package name."}}, ["package"]
|
|
287
|
+
),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"name": "run_tests",
|
|
291
|
+
"description": "Run the project's test suite and return pass/fail status plus output.",
|
|
292
|
+
"input_schema": _schema({}, []),
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
WRITE_TOOL_SCHEMAS = [
|
|
297
|
+
{
|
|
298
|
+
"name": "edit_file",
|
|
299
|
+
"description": (
|
|
300
|
+
"Replace one exact occurrence of old_text with new_text in a file. old_text must "
|
|
301
|
+
"match the file exactly (including indentation) and appear only once."
|
|
302
|
+
),
|
|
303
|
+
"input_schema": _schema(
|
|
304
|
+
{
|
|
305
|
+
"path": _PATH,
|
|
306
|
+
"old_text": {"type": "string", "description": "Exact text to replace."},
|
|
307
|
+
"new_text": {"type": "string", "description": "Replacement text."},
|
|
308
|
+
},
|
|
309
|
+
["path", "old_text", "new_text"],
|
|
310
|
+
),
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"name": "write_file",
|
|
314
|
+
"description": "Create a file, or overwrite one completely. Prefer edit_file for changes.",
|
|
315
|
+
"input_schema": _schema(
|
|
316
|
+
{"path": _PATH, "content": {"type": "string", "description": "Full file content."}},
|
|
317
|
+
["path", "content"],
|
|
318
|
+
),
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"name": "pip_install",
|
|
322
|
+
"description": (
|
|
323
|
+
"Install a single requirement into the project's isolated environment, "
|
|
324
|
+
"e.g. 'pydantic==2.9.2' or 'pydantic-settings'."
|
|
325
|
+
),
|
|
326
|
+
"input_schema": _schema(
|
|
327
|
+
{"requirement": {"type": "string", "description": "One requirement specifier."}},
|
|
328
|
+
["requirement"],
|
|
329
|
+
),
|
|
330
|
+
},
|
|
331
|
+
]
|
patchpilot/upgrade.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""`patchpilot upgrade`: upgrade one package, fix what breaks, commit the result to a branch.
|
|
2
|
+
|
|
3
|
+
The user's own checkout is never modified. All work happens in a temporary git worktree
|
|
4
|
+
(a second checkout of the same repo), and the project's code only ever runs inside a
|
|
5
|
+
sandbox (see patchpilot.sandbox). Only the finished commit, on a new
|
|
6
|
+
`patchpilot/upgrade-<package>` branch, is left behind.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import tomllib
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from contextlib import ExitStack
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from patchpilot import github
|
|
22
|
+
from patchpilot.agent import WEB_FETCH_TOOL, run_agent
|
|
23
|
+
from patchpilot.sandbox import DEFAULT_IMAGE, DockerSandbox, LocalSandbox
|
|
24
|
+
from patchpilot.tools import (
|
|
25
|
+
IGNORED_DIRS,
|
|
26
|
+
READ_TOOL_SCHEMAS,
|
|
27
|
+
WRITE_TOOL_SCHEMAS,
|
|
28
|
+
Workspace,
|
|
29
|
+
collect_dependencies,
|
|
30
|
+
normalize_name,
|
|
31
|
+
run_tests,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
DEFAULT_TEST_COMMAND = ["python", "-m", "pytest", "-q"]
|
|
35
|
+
|
|
36
|
+
SYSTEM_PROMPT = """\
|
|
37
|
+
You are patchpilot, an agent that upgrades one dependency in a Python project and fixes
|
|
38
|
+
the code that breaks because of it.
|
|
39
|
+
|
|
40
|
+
You are working in an isolated copy of the project with its own virtualenv. The test
|
|
41
|
+
suite passed before you started. Your job:
|
|
42
|
+
1. Find out the latest version of the package and install it with pip_install.
|
|
43
|
+
2. Update the declared version specifier in requirements*.txt or pyproject.toml so it
|
|
44
|
+
requires the new version.
|
|
45
|
+
3. Run the tests. If they fail, work out why: read the failing code, and read the package's
|
|
46
|
+
changelog or migration guide with web_fetch (use URLs from get_latest_version).
|
|
47
|
+
4. Fix the project's code with small, targeted edits, then run the tests again. Repeat
|
|
48
|
+
until they pass.
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
- Only upgrade the requested package. If the new version moved features into a separate
|
|
52
|
+
package (for example pydantic-settings), install it and add it to the declared
|
|
53
|
+
dependencies too.
|
|
54
|
+
- Never delete, skip or weaken tests to make them pass. Change a test only when the test
|
|
55
|
+
itself uses an API that was removed, and keep what it checks the same.
|
|
56
|
+
- Keep changes minimal and in the existing code style.
|
|
57
|
+
- If you can't get the tests passing, stop and explain what's left.
|
|
58
|
+
|
|
59
|
+
When you're done, reply with only this Markdown report (it becomes the commit message and
|
|
60
|
+
the pull request description):
|
|
61
|
+
|
|
62
|
+
## Summary
|
|
63
|
+
One or two sentences: what was upgraded, from which version to which.
|
|
64
|
+
|
|
65
|
+
## Breaking changes
|
|
66
|
+
Each breaking change in the new version that affected this project, and how it was fixed.
|
|
67
|
+
|
|
68
|
+
## Files changed
|
|
69
|
+
A bullet per file with a short description of the change.
|
|
70
|
+
|
|
71
|
+
## Needs your attention
|
|
72
|
+
Anything you couldn't fix or a human should double-check. Write "Nothing." if none.\
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UpgradeError(Exception):
|
|
77
|
+
"""Upgrade could not start or finish; the message is meant for the user."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class UpgradeResult:
|
|
82
|
+
branch: str
|
|
83
|
+
tests_passed: bool
|
|
84
|
+
report: str
|
|
85
|
+
changed_files: list[str] = field(default_factory=list)
|
|
86
|
+
committed: bool = False
|
|
87
|
+
pr_url: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git(*args: str, cwd: Path) -> str:
|
|
91
|
+
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
92
|
+
if proc.returncode != 0:
|
|
93
|
+
raise UpgradeError(f"git {' '.join(args)} failed:\n{proc.stderr.strip()}")
|
|
94
|
+
return proc.stdout.strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pip(ws: Workspace, args: list[str]) -> None:
|
|
98
|
+
proc = ws.run(["python", "-m", "pip", "install", "-q", *args], timeout=900)
|
|
99
|
+
if proc.returncode != 0:
|
|
100
|
+
raise UpgradeError(f"Installing the project failed:\n{proc.stderr.strip()[-3000:]}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def install_project(ws: Workspace) -> None:
|
|
104
|
+
"""Install the project and its declared dependencies into the workspace's sandbox."""
|
|
105
|
+
project = ws.root
|
|
106
|
+
for req_file in sorted(project.glob("requirements*.txt")):
|
|
107
|
+
_pip(ws, ["-r", req_file.name])
|
|
108
|
+
|
|
109
|
+
pyproject = project / "pyproject.toml"
|
|
110
|
+
if pyproject.is_file() and "project" in tomllib.loads(pyproject.read_text()):
|
|
111
|
+
extras = tomllib.loads(pyproject.read_text())["project"].get("optional-dependencies", {})
|
|
112
|
+
target = f".[{','.join(extras)}]" if extras else "."
|
|
113
|
+
_pip(ws, ["-e", target])
|
|
114
|
+
|
|
115
|
+
if ws.test_command == DEFAULT_TEST_COMMAND:
|
|
116
|
+
if ws.run(["python", "-m", "pytest", "--version"], timeout=60).returncode != 0:
|
|
117
|
+
_pip(ws, ["pytest"])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _declares(directory: Path, package: str) -> list[str]:
|
|
121
|
+
"""Which of the directory's dependency files declare the package."""
|
|
122
|
+
wanted = normalize_name(package)
|
|
123
|
+
return [
|
|
124
|
+
source
|
|
125
|
+
for source, deps in collect_dependencies(directory).items()
|
|
126
|
+
if any(normalize_name(d["name"]) == wanted for d in deps)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_declared(project: Path, package: str, repo_root: Path) -> None:
|
|
131
|
+
"""Fail early, with a hint, if the package isn't a dependency of this project.
|
|
132
|
+
|
|
133
|
+
Otherwise the agent would run against a test suite that never exercises the package,
|
|
134
|
+
and "tests pass" would mean nothing.
|
|
135
|
+
"""
|
|
136
|
+
if _declares(project, package):
|
|
137
|
+
return
|
|
138
|
+
elsewhere = []
|
|
139
|
+
for dep_file in sorted(
|
|
140
|
+
[*repo_root.rglob("requirements*.txt"), *repo_root.rglob("pyproject.toml")]
|
|
141
|
+
):
|
|
142
|
+
rel = dep_file.relative_to(repo_root)
|
|
143
|
+
if any(part in IGNORED_DIRS for part in rel.parts) or dep_file.parent == project:
|
|
144
|
+
continue
|
|
145
|
+
if dep_file.parent not in elsewhere and _declares(dep_file.parent, package):
|
|
146
|
+
elsewhere.append(dep_file.parent)
|
|
147
|
+
|
|
148
|
+
message = f"{package} isn't a dependency of {project}."
|
|
149
|
+
if elsewhere:
|
|
150
|
+
dirs = [os.path.relpath(d) for d in elsewhere]
|
|
151
|
+
message += "\nIt's declared in: " + ", ".join(dirs)
|
|
152
|
+
message += f"\nTry: patchpilot upgrade {dirs[0]} {package}"
|
|
153
|
+
raise UpgradeError(message)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def upgrade(
|
|
157
|
+
project: Path,
|
|
158
|
+
package: str,
|
|
159
|
+
test_command: list[str] | None = None,
|
|
160
|
+
on_status: Callable[[str], None] = lambda _: None,
|
|
161
|
+
on_tool_call: Callable[[str, dict], None] | None = None,
|
|
162
|
+
use_docker: bool = True,
|
|
163
|
+
image: str = DEFAULT_IMAGE,
|
|
164
|
+
open_pr: bool = False,
|
|
165
|
+
) -> UpgradeResult:
|
|
166
|
+
test_command = test_command or DEFAULT_TEST_COMMAND
|
|
167
|
+
project = project.resolve()
|
|
168
|
+
repo_root = Path(_git("rev-parse", "--show-toplevel", cwd=project)).resolve()
|
|
169
|
+
project_rel = project.relative_to(repo_root)
|
|
170
|
+
check_declared(project, package, repo_root)
|
|
171
|
+
branch = f"patchpilot/upgrade-{package.lower()}"
|
|
172
|
+
|
|
173
|
+
exists = subprocess.run(
|
|
174
|
+
["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo_root
|
|
175
|
+
)
|
|
176
|
+
if exists.returncode == 0:
|
|
177
|
+
raise UpgradeError(
|
|
178
|
+
f"Branch '{branch}' already exists. Delete it first: git branch -D {branch}"
|
|
179
|
+
)
|
|
180
|
+
if use_docker:
|
|
181
|
+
DockerSandbox.check_available()
|
|
182
|
+
# Check GitHub before spending anything on the agent, not after.
|
|
183
|
+
base = github.check_ready(repo_root, branch) if open_pr else None
|
|
184
|
+
|
|
185
|
+
tmp = Path(tempfile.mkdtemp(prefix="patchpilot-"))
|
|
186
|
+
worktree = tmp / "repo"
|
|
187
|
+
committed = False
|
|
188
|
+
with ExitStack() as cleanup:
|
|
189
|
+
cleanup.callback(_remove_worktree, repo_root, worktree, branch, lambda: committed, tmp)
|
|
190
|
+
|
|
191
|
+
on_status(f"Creating an isolated copy of the repo on branch {branch}")
|
|
192
|
+
_git("worktree", "add", "-q", "-b", branch, str(worktree), "HEAD", cwd=repo_root)
|
|
193
|
+
work_project = worktree / project_rel
|
|
194
|
+
|
|
195
|
+
if use_docker:
|
|
196
|
+
on_status(f"Starting a Docker sandbox ({image}) and installing the project")
|
|
197
|
+
sandbox = DockerSandbox(worktree, project_rel, image)
|
|
198
|
+
else:
|
|
199
|
+
on_status("Creating a local virtualenv and installing the project (no Docker)")
|
|
200
|
+
sandbox = LocalSandbox(work_project, tmp / "venv")
|
|
201
|
+
cleanup.enter_context(sandbox)
|
|
202
|
+
|
|
203
|
+
ws = Workspace(root=work_project, test_command=test_command, sandbox=sandbox)
|
|
204
|
+
install_project(ws)
|
|
205
|
+
on_status("Running the tests before upgrading")
|
|
206
|
+
baseline = run_tests(ws)
|
|
207
|
+
if not baseline.startswith("PASSED"):
|
|
208
|
+
raise UpgradeError(
|
|
209
|
+
"The tests fail before any upgrade, so patchpilot can't tell what the upgrade "
|
|
210
|
+
f"breaks. Fix them first.\n\n{baseline[-3000:]}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
on_status(f"Agent is upgrading {package}")
|
|
214
|
+
report = run_agent(
|
|
215
|
+
ws,
|
|
216
|
+
system=SYSTEM_PROMPT,
|
|
217
|
+
task=f"Upgrade '{package}' to its latest version in the project at {work_project}.",
|
|
218
|
+
tools=[*READ_TOOL_SCHEMAS, *WRITE_TOOL_SCHEMAS, WEB_FETCH_TOOL],
|
|
219
|
+
max_turns=60,
|
|
220
|
+
on_tool_call=on_tool_call,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
on_status("Re-running the tests to verify the agent's work")
|
|
224
|
+
tests_passed = run_tests(ws).startswith("PASSED")
|
|
225
|
+
version = installed_version(ws, package)
|
|
226
|
+
result = UpgradeResult(
|
|
227
|
+
branch=branch,
|
|
228
|
+
tests_passed=tests_passed,
|
|
229
|
+
report=report,
|
|
230
|
+
changed_files=sorted(ws.changed_files),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if result.changed_files:
|
|
234
|
+
paths = [str(project_rel / p) for p in result.changed_files]
|
|
235
|
+
_git("add", "--", *paths, cwd=worktree)
|
|
236
|
+
title = f"Upgrade {package}" + (f" to {version}" if version else "")
|
|
237
|
+
title += "" if tests_passed else " (tests still failing)"
|
|
238
|
+
_git("commit", "-q", "-m", title, "-m", report, cwd=worktree)
|
|
239
|
+
result.committed = committed = True
|
|
240
|
+
|
|
241
|
+
if open_pr and result.committed:
|
|
242
|
+
on_status("Pushing the branch and opening a pull request")
|
|
243
|
+
where = f"in the Docker sandbox, {image}" if use_docker else "in a local virtualenv"
|
|
244
|
+
result.pr_url = github.open_pull_request(
|
|
245
|
+
repo_root,
|
|
246
|
+
branch,
|
|
247
|
+
base,
|
|
248
|
+
title,
|
|
249
|
+
pr_body(report, tests_passed, where),
|
|
250
|
+
draft=not tests_passed,
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def installed_version(ws: Workspace, package: str) -> str | None:
|
|
256
|
+
proc = ws.run(["python", "-m", "pip", "show", package], timeout=60)
|
|
257
|
+
for line in proc.stdout.splitlines():
|
|
258
|
+
if line.startswith("Version:"):
|
|
259
|
+
return line.split(":", 1)[1].strip()
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def pr_body(report: str, tests_passed: bool, sandbox: str) -> str:
|
|
264
|
+
verdict = (
|
|
265
|
+
"✅ The test suite passes."
|
|
266
|
+
if tests_passed
|
|
267
|
+
else "❌ The test suite still fails. This PR is a draft; see *Needs your attention*."
|
|
268
|
+
)
|
|
269
|
+
return (
|
|
270
|
+
f"{report}\n\n---\n\n"
|
|
271
|
+
f"**Verified by patchpilot:** after the agent finished, patchpilot re-ran the tests "
|
|
272
|
+
f"itself ({sandbox}). {verdict}\n\n"
|
|
273
|
+
"<sub>Opened by [patchpilot](https://github.com/ilmeedesilva/patchpilot), an AI agent "
|
|
274
|
+
"that upgrades dependencies and fixes what breaks. Review it like any other PR.</sub>"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _remove_worktree(
|
|
279
|
+
repo_root: Path, worktree: Path, branch: str, committed: Callable[[], bool], tmp: Path
|
|
280
|
+
) -> None:
|
|
281
|
+
subprocess.run(
|
|
282
|
+
["git", "worktree", "remove", "--force", str(worktree)], cwd=repo_root, capture_output=True
|
|
283
|
+
)
|
|
284
|
+
# No commit means nothing worth reviewing, so don't leave an empty branch behind.
|
|
285
|
+
if not committed():
|
|
286
|
+
subprocess.run(["git", "branch", "-D", branch], cwd=repo_root, capture_output=True)
|
|
287
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: patchpilot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An AI agent that upgrades your dependencies, fixes what breaks, and opens a PR
|
|
5
|
+
Project-URL: Homepage, https://github.com/ilmeedesilva/patchpilot
|
|
6
|
+
Project-URL: Issues, https://github.com/ilmeedesilva/patchpilot/issues
|
|
7
|
+
Project-URL: Demo, https://github.com/ilmeedesilva/patchpilot-demo
|
|
8
|
+
Author: Ilmee De Silva
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,claude,dependencies,developer-tools,llm,upgrade
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: anthropic>=2.3.0
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=9; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.17; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# patchpilot
|
|
34
|
+
|
|
35
|
+
[](https://github.com/ilmeedesilva/patchpilot/actions/workflows/ci.yml)
|
|
36
|
+

|
|
37
|
+
[](https://github.com/ilmeedesilva/patchpilot/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
**An AI agent that upgrades a dependency, fixes the code that breaks, and opens a pull request explaining what changed and why.**
|
|
40
|
+
|
|
41
|
+
Dependabot and Renovate bump version numbers. When the new version breaks your build, the fix is still on you. patchpilot does that part: it upgrades the package inside a sandbox, runs your tests, reads the failures and the changelog, edits your code until the tests pass, and hands you a reviewable PR.
|
|
42
|
+
|
|
43
|
+
<!-- TODO: demo GIF here: `patchpilot upgrade . pydantic --pr` → PR appears -->
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ patchpilot upgrade . pydantic --pr
|
|
47
|
+
• Creating an isolated copy of the repo on branch patchpilot/upgrade-pydantic
|
|
48
|
+
• Starting a Docker sandbox (python:3.13-slim) and installing the project
|
|
49
|
+
• Running the tests before upgrading
|
|
50
|
+
• Agent is upgrading pydantic
|
|
51
|
+
→ read_dependencies {}
|
|
52
|
+
→ get_latest_version {"package": "pydantic"}
|
|
53
|
+
→ pip_install {"requirement": "pydantic==2.14.1"}
|
|
54
|
+
→ run_tests {} # fails: BaseSettings moved out of pydantic
|
|
55
|
+
→ pip_install {"requirement": "pydantic-settings==2.16.2"}
|
|
56
|
+
→ write_file {"path": "app/config.py"}
|
|
57
|
+
→ write_file {"path": "app/models.py"}
|
|
58
|
+
→ run_tests {} # passes
|
|
59
|
+
• Re-running the tests to verify the agent's work
|
|
60
|
+
• Pull request: https://github.com/<you>/<repo>/pull/1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The PR description is written by the agent, and includes:
|
|
64
|
+
|
|
65
|
+
> **Breaking changes**
|
|
66
|
+
> - **`BaseSettings` moved to `pydantic-settings`**: importing it from `pydantic` now fails. Installed `pydantic-settings`, added it to `requirements.txt`, and now import from `pydantic_settings`.
|
|
67
|
+
> - **`@root_validator` without `skip_on_failure=True` is an error in v2**: replaced with `@model_validator(mode="after")`.
|
|
68
|
+
> - …
|
|
69
|
+
>
|
|
70
|
+
> **Needs your attention**
|
|
71
|
+
> - In v1 the password check ran even when another field had failed; in v2 it only runs once all fields are valid, so a bad email plus mismatched passwords now reports only the email error.
|
|
72
|
+
|
|
73
|
+
*(Trimmed output and report from a real run on [`examples/pydantic-demo`](https://github.com/ilmeedesilva/patchpilot/tree/main/examples/pydantic-demo).)*
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
You need Python 3.11+, [Docker Desktop](https://www.docker.com/products/docker-desktop/) running, and an [Anthropic API key](https://console.anthropic.com/settings/keys).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pipx install git+https://github.com/ilmeedesilva/patchpilot.git # soon: pipx install patchpilot
|
|
81
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or put it in a .env file where you run patchpilot
|
|
82
|
+
|
|
83
|
+
cd your-project # a git repo with a test suite
|
|
84
|
+
patchpilot analyze . # read-only report: what's outdated, what's risky
|
|
85
|
+
patchpilot upgrade . pydantic # upgrade + fix, committed to a new branch
|
|
86
|
+
patchpilot upgrade . pydantic --pr # ...and open a pull request
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each run uses Anthropic API credit. The amount depends on the size of the project and how much breaks.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
### `patchpilot analyze <project>`
|
|
94
|
+
|
|
95
|
+
Reads your dependency files, checks PyPI for the latest versions and runs your tests once, then prints a Markdown report. It covers what's outdated, which upgrades are major-version jumps likely to need code changes, and a suggested order. It changes nothing.
|
|
96
|
+
|
|
97
|
+
### `patchpilot upgrade <project> <package>`
|
|
98
|
+
|
|
99
|
+
1. Checks that `<package>` really is a dependency of `<project>`. If it's declared in a subfolder instead, it tells you which one.
|
|
100
|
+
2. Makes a separate checkout of your repo (a git worktree) on a new branch, `patchpilot/upgrade-<package>`. **Your working copy is never touched.**
|
|
101
|
+
3. Starts a Docker sandbox, installs your project into it, and checks that the tests pass *before* changing anything.
|
|
102
|
+
4. Lets the agent work: upgrade the package, run the tests, read the failures and changelog, edit the code, and repeat.
|
|
103
|
+
5. **Re-runs the tests itself** instead of trusting the agent's word, then commits to the branch with the agent's report as the commit message.
|
|
104
|
+
|
|
105
|
+
Review the result with `git diff HEAD...patchpilot/upgrade-<package>`, then merge it or delete it.
|
|
106
|
+
|
|
107
|
+
| Option | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `--pr` | Push the branch and open a GitHub pull request. If the tests still fail, it's opened as a **draft**. Uses the [GitHub CLI](https://cli.github.com/), so patchpilot never handles a GitHub token. |
|
|
110
|
+
| `--test-command "..."` | How to run your tests (default: `python -m pytest -q`). |
|
|
111
|
+
| `--image python:3.11-slim` | Docker image to run in. Match it to your project's Python version (default: `python:3.13-slim`). |
|
|
112
|
+
| `--no-docker` | Use a local virtualenv instead of Docker. Faster, but your project's code runs directly on your machine. |
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
```mermaid
|
|
117
|
+
flowchart LR
|
|
118
|
+
subgraph host["Your machine"]
|
|
119
|
+
CLI["patchpilot CLI"] --> Loop["Agent loop"]
|
|
120
|
+
Loop <-->|"tool calls ⇄ results"| Claude["Claude API"]
|
|
121
|
+
Loop --> Files["Read / edit files<br/>in a temporary worktree"]
|
|
122
|
+
end
|
|
123
|
+
subgraph sandbox["Docker sandbox: sees only the worktree"]
|
|
124
|
+
Pip["pip install <one package>"]
|
|
125
|
+
Tests["your test suite"]
|
|
126
|
+
end
|
|
127
|
+
Loop --> Pip
|
|
128
|
+
Loop --> Tests
|
|
129
|
+
Loop -->|"verify, commit"| Branch["patchpilot/upgrade-<pkg>"]
|
|
130
|
+
Branch -->|"--pr"| PR["GitHub pull request"]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
At the centre is a hand-written [tool-use loop](https://github.com/ilmeedesilva/patchpilot/blob/main/src/patchpilot/agent.py). patchpilot sends Claude the task and a list of tools. Claude replies with the tool it wants to call, patchpilot runs it and sends back the result, and this repeats until Claude writes its final report. Claude decides *what* to do next; patchpilot's code is the only thing that *does* anything.
|
|
134
|
+
|
|
135
|
+
| Tool | Runs where | What it does |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `read_dependencies` | host | Parses `requirements*.txt` and `pyproject.toml` |
|
|
138
|
+
| `get_latest_version` | host | Queries PyPI, including changelog URLs |
|
|
139
|
+
| `list_files`, `read_file` | host | Read-only, confined to the project folder |
|
|
140
|
+
| `edit_file`, `write_file` | host | Exact-match edits; refuses `.git/` and virtualenvs |
|
|
141
|
+
| `pip_install` | **sandbox** | One requirement at a time; flags, URLs and file paths are rejected |
|
|
142
|
+
| `run_tests` | **sandbox** | Your test command, with a timeout |
|
|
143
|
+
| `web_fetch` | Anthropic | Reads changelogs and migration guides |
|
|
144
|
+
|
|
145
|
+
### Safety
|
|
146
|
+
|
|
147
|
+
Upgrading means running code that isn't yours: your tests, and whatever a package runs when it installs. So that code runs inside a Docker container that:
|
|
148
|
+
|
|
149
|
+
- mounts **only the temporary worktree**, not your home folder, SSH keys or other projects
|
|
150
|
+
- runs as your user, not root, with all Linux capabilities dropped and `no-new-privileges`
|
|
151
|
+
- is capped at 2 CPUs, 2 GB of memory and 512 processes
|
|
152
|
+
- is deleted when the run ends, even if the run fails
|
|
153
|
+
|
|
154
|
+
The agent loop and your API key never enter the container. These properties are checked by [integration tests](https://github.com/ilmeedesilva/patchpilot/blob/main/tests/test_sandbox.py), not just described here.
|
|
155
|
+
|
|
156
|
+
### Design decisions
|
|
157
|
+
|
|
158
|
+
- **Verify, don't trust.** The agent says when it's done, but patchpilot re-runs the tests itself before committing. The ✅ in the output is patchpilot's verdict, not the model's.
|
|
159
|
+
- **Refuse to run when "tests pass" would mean nothing.** patchpilot stops if the tests already fail before the upgrade, or if the package isn't a dependency of the folder you pointed it at. Otherwise a green result could be meaningless.
|
|
160
|
+
- **Fail before spending, not after.** Docker, GitHub login, remotes and branch names are all checked before the first API call.
|
|
161
|
+
- **Leave nothing behind.** The worktree, container and virtualenv are cleaned up on success, failure or Ctrl-C. A branch only survives if it has a commit worth reviewing.
|
|
162
|
+
- **A small, explicit tool surface** instead of a general shell. It's easier to reason about what the agent can do, and easier to sandbox.
|
|
163
|
+
|
|
164
|
+
## Limitations
|
|
165
|
+
|
|
166
|
+
- Python projects only, with `requirements*.txt` or a PEP 621 `pyproject.toml` (no Poetry or uv lockfiles yet).
|
|
167
|
+
- One package per run.
|
|
168
|
+
- The fix is only as good as your tests. If they don't cover what changed, patchpilot can't catch it, though the agent flags behaviour changes it notices under **Needs your attention**.
|
|
169
|
+
- The sandbox needs network access to install packages, so code inside it can reach the internet.
|
|
170
|
+
|
|
171
|
+
## Roadmap
|
|
172
|
+
|
|
173
|
+
- [ ] Publish to PyPI (`pipx install patchpilot`)
|
|
174
|
+
- [ ] GitHub Action: run weekly and open PRs automatically
|
|
175
|
+
- [ ] Upgrade several packages in one run, ordered by risk
|
|
176
|
+
- [ ] Turn off the sandbox's network access while tests run
|
|
177
|
+
- [ ] uv and Poetry lockfile support
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
git clone git@github.com:ilmeedesilva/patchpilot.git && cd patchpilot
|
|
183
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
184
|
+
pip install -e ".[dev]"
|
|
185
|
+
pytest # Docker tests are skipped if Docker isn't running
|
|
186
|
+
ruff check . && ruff format --check .
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The test suite doesn't call the Anthropic API: the agent is replaced with a fake that makes known edits, and GitHub with a local bare repo plus a stand-in `gh`.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
[MIT](https://github.com/ilmeedesilva/patchpilot/blob/main/LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
patchpilot/__init__.py,sha256=UBmfZOHxiEFyeFNtKSjPAinbxPgDIzrwL61976nrzAM,103
|
|
2
|
+
patchpilot/agent.py,sha256=LXESN7HH-R8R2FYI_roT268ln9YiSF07VOia54yDOnw,3374
|
|
3
|
+
patchpilot/analyze.py,sha256=Asa30FY7g1lU3FJICbrmJHyhx4ZC_bk63QJaChvWKNc,1336
|
|
4
|
+
patchpilot/cli.py,sha256=zVHOI3eXtJMks6CqjJYlPkQyoaffbwe1kQJYxpghqpw,5742
|
|
5
|
+
patchpilot/github.py,sha256=jl-6PzEJOyBsAFptr4OA4xVhL12invTRx_b5r_Pcmhk,2992
|
|
6
|
+
patchpilot/sandbox.py,sha256=_-i8WAwLbn7JR4LBhKRaKi4VTVlFH91f_L2BFVNf92I,4329
|
|
7
|
+
patchpilot/tools.py,sha256=eSCXFe7nXgvopqQmlBuWRtf4eoIDUFzkwwRYSG6h7EU,11987
|
|
8
|
+
patchpilot/upgrade.py,sha256=3CJJXGJxIFWXSVxzPhKp_zJujE681ztIs6qt5aNz_vw,11262
|
|
9
|
+
patchpilot-0.1.0.dist-info/METADATA,sha256=lPF--XCDpT2hDigld0nt6t2dgbLd8Xi4FXPjxsQgSOQ,10709
|
|
10
|
+
patchpilot-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
patchpilot-0.1.0.dist-info/entry_points.txt,sha256=E7vHakGW5WsfwwJOG4zOW730gZXQnm-GX2dDZwMz-Pg,51
|
|
12
|
+
patchpilot-0.1.0.dist-info/licenses/LICENSE,sha256=pQ5TGEEuo0j5qmeDIbgM6_iu5qmD-gCwLJ6YKcOx_X4,1071
|
|
13
|
+
patchpilot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ilmee De Silva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|