Skip to content
Open
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ It is designed to sit **between an issue and a pull request** — with the devel
- 🧪 Automatic test-command detection and execution
- 🔁 Limited repair loop when tests fail
- 🌿 **Automatic isolated Git branch per run**
- ✅ **Explicit workspace commit API with per-commit author identity**
- 🐙 **GitHub issue import via API**
- 📦 Isolated per-run workspaces
- 🔍 Reviewable Git diffs
Expand Down Expand Up @@ -122,7 +123,23 @@ You can provide a branch name in the run request:

If omitted, PatchPilot generates a unique `patchpilot/...` branch name automatically.

**Important:** the current version creates the branch only inside PatchPilot's cloned workspace. It does **not** push the branch to GitHub yet. Remote commits and pull requests remain future roadmap items.
PatchPilot can also turn the reviewed workspace diff into a local Git commit:

```http
POST /api/commit
Content-Type: application/json

{
"workspace_id": "<run workspace id>",
"message": "fix: handle expired sessions",
"author_name": "PatchPilot",
"author_email": "patchpilot@localhost"
}
```

The endpoint stages the workspace with `git add --all`, refuses clean workspaces, creates the commit without modifying global Git identity, and returns the commit SHA and branch.

**Important:** branches and commits still exist only inside PatchPilot's cloned workspace. PatchPilot does **not** push to GitHub yet, so remote pull requests remain future roadmap work.

---

Expand Down Expand Up @@ -184,6 +201,7 @@ Use the planning stage as a fast first pass over an unfamiliar codebase before m
| `/api/run` | POST | Clone, plan, edit, test, repair, and return a diff |
| `/api/execute` | POST | Execute a command inside an existing workspace |
| `/api/workspace` | POST | Inspect workspace files and current diff |
| `/api/commit` | POST | Stage and commit the current workspace changes |
| `/api/github/issue` | GET | Fetch a GitHub issue |
| `/api/import-issue` | POST | Convert a GitHub issue into an agent task |
| `/api/openapi-summary` | GET | Return PatchPilot feature metadata |
Expand Down Expand Up @@ -412,7 +430,7 @@ The project roadmap intentionally includes a stronger sandbox for this reason.

- [x] GitHub issue import
- [x] Automatic isolated branches
- [ ] Commit changes from the agent
- [x] Commit changes from the agent
- [ ] Open pull requests automatically

### Next
Expand Down
92 changes: 92 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ class CommandRequest(BaseModel):
command: str = Field(..., min_length=1, max_length=1000)


class CommitRequest(BaseModel):
workspace_id: str
message: str = Field(..., min_length=1, max_length=200)
author_name: str = Field(default="PatchPilot", min_length=1, max_length=100)
author_email: str = Field(
default="patchpilot@localhost",
min_length=3,
max_length=254,
pattern=r"^[^@\s]+@[^@\s]+$",
)


class IssueRequest(BaseModel):
repo: str = Field(..., pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
issue_number: int = Field(..., ge=1)
Expand Down Expand Up @@ -275,6 +287,74 @@ def workspace_repo(workspace_id: str) -> Path:
return repo


def commit_workspace(
repo: Path,
message: str,
author_name: str,
author_email: str,
) -> dict[str, str]:
message = message.strip()
author_name = author_name.strip()
author_email = author_email.strip()
if not message:
raise HTTPException(400, "Commit message cannot be blank")
if not author_name:
raise HTTPException(400, "Commit author name cannot be blank")

code, status = run(["git", "status", "--porcelain"], repo, 30)
if code != 0:
raise HTTPException(500, f"Could not inspect workspace status: {status[-1500:]}")
if not status.strip():
raise HTTPException(400, "No workspace changes to commit")

code, output = run(["git", "add", "--all"], repo, 60)
if code != 0:
raise HTTPException(500, f"Could not stage workspace changes: {output[-1500:]}")

staged_code, staged_output = run(
["git", "diff", "--cached", "--quiet"],
repo,
30,
)
if staged_code == 0:
raise HTTPException(400, "No workspace changes to commit")
if staged_code != 1:
raise HTTPException(
500,
f"Could not inspect staged changes: {staged_output[-1500:]}",
)

commit_code, commit_output = run(
[
"git",
"-c",
f"user.name={author_name}",
"-c",
f"user.email={author_email}",
"commit",
"-m",
message,
],
repo,
120,
)
if commit_code != 0:
raise HTTPException(500, f"Git commit failed: {commit_output[-2000:]}")

sha_code, commit_sha = run(["git", "rev-parse", "HEAD"], repo, 30)
branch_code, branch = run(["git", "branch", "--show-current"], repo, 30)
if sha_code != 0 or branch_code != 0:
raise HTTPException(500, "Commit succeeded but Git metadata could not be read")

return {
"commit_sha": commit_sha.strip(),
"branch": branch.strip(),
"message": message,
"author_name": author_name,
"author_email": author_email,
}


def github_headers() -> dict[str, str]:
token = os.getenv("GITHUB_TOKEN", "")
if not token:
Expand Down Expand Up @@ -413,6 +493,17 @@ async def workspace(req: WorkspaceRequest) -> dict[str, Any]:
return {"workspace_id": req.workspace_id, "files": list_files(repo), "diff": build_patch(repo)}


@app.post("/api/commit")
async def commit(req: CommitRequest) -> dict[str, str]:
repo = workspace_repo(req.workspace_id)
return commit_workspace(
repo,
req.message,
req.author_name,
req.author_email,
)


@app.get("/api/openapi-summary")
async def openapi_summary() -> dict[str, Any]:
return {
Expand All @@ -425,6 +516,7 @@ async def openapi_summary() -> dict[str, Any]:
"test execution",
"repair loop",
"isolated branches",
"workspace commits",
"GitHub issue import",
],
}
80 changes: 80 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
Expand All @@ -9,8 +10,10 @@
from pydantic import ValidationError

from app.main import (
CommitRequest,
RunRequest,
apply_edits,
commit_workspace,
github_headers,
health,
parse_json_object,
Expand Down Expand Up @@ -123,6 +126,83 @@ def test_workspace_repo_returns_existing_repo_inside_root(self) -> None:
with patch("app.main.WORKSPACES", root):
self.assertEqual(workspace_repo("safe-id"), repo.resolve())

def test_commit_request_rejects_invalid_author_email(self) -> None:
with self.assertRaises(ValidationError):
CommitRequest(
workspace_id="safe-id",
message="feat: test",
author_email="not-an-email",
)

def test_commit_workspace_creates_real_commit_without_global_git_config(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
repo = Path(temp_dir)
subprocess.run(
["git", "init", "-b", "main"],
cwd=repo,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
(repo / "feature.txt").write_text("hello\n", encoding="utf-8")

result = commit_workspace(
repo,
"feat: add feature file",
"PatchPilot Test",
"patchpilot-test@example.com",
)

self.assertEqual(result["branch"], "main")
self.assertEqual(result["message"], "feat: add feature file")
self.assertEqual(len(result["commit_sha"]), 40)

log = subprocess.run(
["git", "show", "-s", "--format=%an|%ae|%s", "HEAD"],
cwd=repo,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
).stdout.strip()
self.assertEqual(
log,
"PatchPilot Test|patchpilot-test@example.com|feat: add feature file",
)
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=repo,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
).stdout
self.assertEqual(status, "")

def test_commit_workspace_rejects_clean_repository(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
repo = Path(temp_dir)
subprocess.run(
["git", "init", "-b", "main"],
cwd=repo,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)

with self.assertRaises(HTTPException) as context:
commit_workspace(
repo,
"feat: empty",
"PatchPilot",
"patchpilot@localhost",
)

self.assertEqual(context.exception.status_code, 400)
self.assertIn("No workspace changes", context.exception.detail)

def test_github_headers_requires_token(self) -> None:
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(HTTPException) as context:
Expand Down
Loading