Skip to content
Merged

Dev #107

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
24 changes: 18 additions & 6 deletions prometheus/app/api/routes/issue.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio

from fastapi import APIRouter, Request

from prometheus.app.decorators.require_login import requireLogin
Expand Down Expand Up @@ -75,17 +77,17 @@ async def answer_issue(issue: IssueRequest, request: Request) -> Response[IssueR
issue_service: IssueService = request.app.state.service["issue_service"]

(
remote_branch_name,
patch,
passed_reproducing_test,
passed_build,
passed_existing_test,
issue_response,
) = await issue_service.answer_issue(
issue_type,
) = await asyncio.to_thread(
issue_service.answer_issue,
repository_id=repository.id,
repository=git_repository,
knowledge_graph=knowledge_graph,
issue_number=issue.issue_number,
issue_title=issue.issue_title,
issue_body=issue.issue_body,
issue_comments=issue.issue_comments if issue.issue_comments else [],
Expand All @@ -99,16 +101,26 @@ async def answer_issue(issue: IssueRequest, request: Request) -> Response[IssueR
workdir=issue.workdir,
build_commands=issue.build_commands,
test_commands=issue.test_commands,
push_to_remote=issue.push_to_remote,
)
repository_service.update_repository_status(repository.id, is_working=False)
if (
patch,
passed_reproducing_test,
passed_build,
passed_existing_test,
issue_response,
issue_type,
) == (None, False, False, False, None, None):
raise ServerException(
code=500,
message="Failed to process the issue. Please try again later.",
)
return Response(
data=IssueResponse(
patch=patch,
passed_reproducing_test=passed_reproducing_test,
passed_build=passed_build,
passed_existing_test=passed_existing_test,
issue_response=issue_response,
remote_branch_name=remote_branch_name,
issue_type=issue_type,
)
)
39 changes: 38 additions & 1 deletion prometheus/app/api/routes/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
from fastapi import APIRouter, Request

from prometheus.app.decorators.require_login import requireLogin
from prometheus.app.models.requests.repository import UploadRepositoryRequest
from prometheus.app.models.requests.repository import (
CreateBranchAndPushRequest,
UploadRepositoryRequest,
)
from prometheus.app.models.response.response import Response
from prometheus.app.services.knowledge_graph_service import KnowledgeGraphService
from prometheus.app.services.repository_service import RepositoryService
Expand Down Expand Up @@ -92,6 +95,40 @@ async def upload_github_repository(
return Response(data={"repository_id": repository_id})


@router.post(
"/create-branch-and-push/",
description="""
Create a new branch in the repository, commit changes, and push to remote.
""",
response_model=Response,
)
@requireLogin
async def create_branch_and_push(
create_branch_and_push_request: CreateBranchAndPushRequest, request: Request
):
repository_service: RepositoryService = request.app.state.service["repository_service"]
repository = repository_service.get_repository_by_id(
create_branch_and_push_request.repository_id
)
if not repository:
raise ServerException(code=404, message="Repository not found")
# Check if the user has permission to modify the repository
if settings.ENABLE_AUTHENTICATION and repository.user_id != request.state.user_id:
raise ServerException(
code=403, message="You do not have permission to modify this repository"
)
git_repo = repository_service.get_repository(repository.playground_path)
try:
await git_repo.create_and_push_branch(
branch_name=create_branch_and_push_request.branch_name,
commit_message=create_branch_and_push_request.commit_message,
patch=create_branch_and_push_request.patch,
)
except git.exc.GitCommandError as e:
raise e
return Response()


@router.delete(
"/delete/",
description="""
Expand Down
9 changes: 0 additions & 9 deletions prometheus/app/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,3 @@ async def custom_exception_handler(_request: Request, exc: ServerException):
return JSONResponse(
status_code=exc.code, content={"code": exc.code, "message": exc.message, "data": None}
)

@app.exception_handler(Exception)
async def global_exception_handler(_request: Request, _exc: Exception):
"""
Global exception handler for all uncaught exceptions.
"""
return JSONResponse(
status_code=500, content={"code": 500, "message": "Internal Server Error", "data": None}
)
1 change: 0 additions & 1 deletion prometheus/app/models/requests/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class LoginRequest(BaseModel):
max_length=30,
)

@classmethod
@field_validator("email", mode="after")
def validate_email_format(cls, v: str) -> str:
pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
Expand Down
6 changes: 0 additions & 6 deletions prometheus/app/models/requests/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ class IssueRequest(BaseModel):
repository_id: int = Field(
description="The ID of the repository this issue belongs to.", examples=[1]
)
issue_number: int = Field(description="The number of the issue", examples=[42])
issue_title: str = Field(
description="The title of the issue", examples=["There is a memory leak"]
)
Expand Down Expand Up @@ -82,8 +81,3 @@ class IssueRequest(BaseModel):
"you must also specify the test commands.",
examples=[["pytest ."]],
)
push_to_remote: Optional[bool] = Field(
default=False,
description="When editing the code, whenever we should push the changes to a remote branch",
examples=[True],
)
65 changes: 64 additions & 1 deletion prometheus/app/models/requests/repository.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from pydantic import BaseModel, Field
import re

from pydantic import BaseModel, Field, field_validator


class UploadRepositoryRequest(BaseModel):
Expand All @@ -15,3 +17,64 @@ class UploadRepositoryRequest(BaseModel):
description="Optional GitHub token for repository clone",
max_length=100,
)


class CreateBranchAndPushRequest(BaseModel):
repository_id: int = Field(
description="The ID of the repository this branch belongs to.", examples=[1]
)
patch: str = Field(
description="The patch to apply to the repository", examples=["diff --git a/foo.c b/foo.c"]
)
branch_name: str = Field(
description="The name of the branch to create", examples=["feature/new-feature"]
)
commit_message: str = Field(
description="The commit message for the changes", examples=["Add new feature"]
)

@field_validator("branch_name", mode="after")
def validate_branch_name_format(cls, name: str) -> str:
"""
Check if a branch name is valid according to Git's rules.
Reference: https://git-scm.com/docs/git-check-ref-format
"""
if not name or name in (".", "..") or name.strip() != name:
raise ValueError(
f"Invalid branch name '{name}': name cannot be empty, "
f"'.' or '..', and cannot have leading/trailing spaces."
)

# Cannot start or end with '/'
if name.startswith("/") or name.endswith("/"):
raise ValueError(
f"Invalid branch name '{name}': branch name cannot start or end with '/'. Example: 'feature/new'."
)

# Cannot contain consecutive slashes
if "//" in name:
raise ValueError(
f"Invalid branch name '{name}': branch name cannot contain consecutive slashes '//'."
)

# Cannot contain ASCII control characters or space
if re.search(r"[\000-\037\177\s]", name):
raise ValueError(
f"Invalid branch name '{name}': branch name cannot contain spaces or control characters. "
f"Use '-' or '_' instead of spaces."
)

# Cannot end with .lock
if name.endswith(".lock"):
raise ValueError(f"Invalid branch name '{name}': branch name cannot end with '.lock'.")

# Cannot contain these special sequences
forbidden = ["@", "\\", "?", "[", "~", "^", ":", "*", "..", "@{"]
for token in forbidden:
if token in name:
raise ValueError(
f"Invalid branch name '{name}': contains forbidden sequence or character {token}. "
f"Avoid '@', '?', '*', '..', '@{{', etc."
)

return name
4 changes: 3 additions & 1 deletion prometheus/app/models/response/issue.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from pydantic import BaseModel

from prometheus.lang_graph.graphs.issue_state import IssueType


class IssueResponse(BaseModel):
patch: str | None = None
passed_reproducing_test: bool
passed_build: bool
passed_existing_test: bool
issue_response: str | None = None
remote_branch_name: str | None = None
issue_type: IssueType | None = None
103 changes: 6 additions & 97 deletions prometheus/app/services/issue_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import asyncio
import logging
import threading
import traceback
import uuid
from datetime import datetime
from pathlib import Path
from typing import Mapping, Optional, Sequence
Expand All @@ -12,7 +10,6 @@
from prometheus.app.services.neo4j_service import Neo4jService
from prometheus.docker.general_container import GeneralContainer
from prometheus.docker.user_defined_container import UserDefinedContainer
from prometheus.exceptions.server_exception import ServerException
from prometheus.git.git_repository import GitRepository
from prometheus.graph.knowledge_graph import KnowledgeGraph
from prometheus.lang_graph.graphs.issue_graph import IssueGraph
Expand All @@ -38,12 +35,11 @@ def __init__(
self.answer_issue_log_dir.mkdir(parents=True, exist_ok=True)
self.logging_level = logging_level

async def answer_issue(
def answer_issue(
self,
repository_id: int,
repository: GitRepository,
knowledge_graph: KnowledgeGraph,
issue_number: int,
repository: GitRepository,
repository_id: int,
issue_title: str,
issue_body: str,
issue_comments: Sequence[Mapping[str, str]],
Expand All @@ -52,21 +48,19 @@ async def answer_issue(
run_existing_test: bool,
run_reproduce_test: bool,
number_of_candidate_patch: int,
build_commands: Optional[Sequence[str]],
test_commands: Optional[Sequence[str]],
dockerfile_content: Optional[str] = None,
image_name: Optional[str] = None,
workdir: Optional[str] = None,
build_commands: Optional[Sequence[str]] = None,
test_commands: Optional[Sequence[str]] = None,
push_to_remote: Optional[bool] = None,
):
) -> tuple[None, bool, bool, bool, None, None] | tuple[str, bool, bool, bool, str, IssueType]:
"""
Processes an issue, generates patches if needed, runs optional builds and tests, and returning the results.

Args:
repository_id: The ID of the repository to update.
repository (GitRepository): The Git repository instance.
knowledge_graph (KnowledgeGraph): The knowledge graph instance.
issue_number (int): The number of the issue.
issue_title (str): The title of the issue.
issue_body (str): The body of the issue.
issue_comments (Sequence[Mapping[str, str]]): Comments on the issue.
Expand All @@ -80,7 +74,6 @@ async def answer_issue(
workdir (Optional[str]): Working directory for the container.
build_commands (Optional[Sequence[str]]): Commands to build the project.
test_commands (Optional[Sequence[str]]): Commands to test the project.
push_to_remote (Optional[bool]): Whether to push changes to a remote branch.
Returns:
Tuple containing:
- edit_patch (str): The generated patch for the issue.
Expand All @@ -90,90 +83,6 @@ async def answer_issue(
- issue_response (str): Response generated for the issue.
"""

# Initialize the issue graph with the necessary services and parameters
(
edit_patch,
passed_reproducing_test,
passed_build,
passed_existing_test,
issue_response,
issue_type,
) = await asyncio.to_thread(
self.__answer,
repository_id=repository_id,
issue_title=issue_title,
issue_body=issue_body,
issue_comments=issue_comments,
issue_type=issue_type,
run_build=run_build,
run_existing_test=run_existing_test,
run_reproduce_test=run_reproduce_test,
number_of_candidate_patch=number_of_candidate_patch,
knowledge_graph=knowledge_graph,
repository=repository,
build_commands=build_commands,
test_commands=test_commands,
dockerfile_content=dockerfile_content,
image_name=image_name,
workdir=workdir,
)
if (
edit_patch,
passed_reproducing_test,
passed_build,
passed_existing_test,
issue_response,
issue_type,
) == (None, False, False, False, None, None):
raise ServerException(500, "Failed to process the issue due to an internal error.")
if issue_type == IssueType.BUG:
# push to remote if requested
remote_branch_name = None
if edit_patch and push_to_remote:
remote_branch_name = f"prometheus_fix_{uuid.uuid4().hex[:10]}"
await repository.create_and_push_branch(
remote_branch_name, f"Fixes #{issue_number}", edit_patch
)

return (
remote_branch_name,
edit_patch,
passed_reproducing_test,
passed_build,
passed_existing_test,
issue_response,
)
elif issue_type == IssueType.QUESTION:
return (
None,
None,
False,
False,
False,
issue_response,
)
else:
raise ValueError(f"Unknown issue type: {issue_type}. Expected BUG or QUESTION.")

def __answer(
self,
knowledge_graph: KnowledgeGraph,
repository: GitRepository,
repository_id: int,
issue_title: str,
issue_body: str,
issue_comments: Sequence[Mapping[str, str]],
issue_type: IssueType,
run_build: bool,
run_existing_test: bool,
run_reproduce_test: bool,
number_of_candidate_patch: int,
build_commands: Optional[Sequence[str]],
test_commands: Optional[Sequence[str]],
dockerfile_content: Optional[str] = None,
image_name: Optional[str] = None,
workdir: Optional[str] = None,
) -> tuple[None, bool, bool, bool, None, None] | tuple[str, bool, bool, bool, str, IssueType]:
# Set up a dedicated logger for this thread
logger = logging.getLogger(f"thread-{threading.get_ident()}.prometheus")
logger.setLevel(getattr(logging, self.logging_level))
Expand Down
Loading