From d81011f6e5eca72a050452fad26efd8d46580af9 Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:03:18 +0800 Subject: [PATCH 1/9] Add conditional edges for patch application in bug reproduction and verification workflows --- prometheus/lang_graph/nodes/git_diff_node.py | 13 ++++++++----- .../subgraphs/bug_reproduction_subgraph.py | 6 +++++- .../subgraphs/issue_verified_bug_subgraph.py | 7 ++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/prometheus/lang_graph/nodes/git_diff_node.py b/prometheus/lang_graph/nodes/git_diff_node.py index 4cce12bd..f6923cd4 100644 --- a/prometheus/lang_graph/nodes/git_diff_node.py +++ b/prometheus/lang_graph/nodes/git_diff_node.py @@ -48,7 +48,7 @@ def __call__(self, state: Dict): project_path key specifying the Git repository location. Returns: - Dictionary that update the state containing: + Dictionary that updates the state containing: - patch: String containing the Git diff output showing all changes made to the project. """ excluded_files = None @@ -64,8 +64,11 @@ def __call__(self, state: Dict): f"Excluding the following files when generating the patch: {excluded_files}" ) patch = self.git_repo.get_diff(excluded_files) - self._logger.info(f"Generated patch:\n{patch}") + if patch: + self._logger.info(f"Generated patch:\n{patch}") + result = [patch] if self.return_list else patch + else: + self._logger.info("No changes detected, no patch generated.") + result = [] if self.return_list else "" - if self.return_list: - patch = [patch] - return {self.state_patch_name: patch} + return {self.state_patch_name: result} diff --git a/prometheus/lang_graph/subgraphs/bug_reproduction_subgraph.py b/prometheus/lang_graph/subgraphs/bug_reproduction_subgraph.py index a186954a..1911c65e 100644 --- a/prometheus/lang_graph/subgraphs/bug_reproduction_subgraph.py +++ b/prometheus/lang_graph/subgraphs/bug_reproduction_subgraph.py @@ -182,7 +182,11 @@ def __init__( workflow.add_edge("bug_reproducing_file_tools", "bug_reproducing_file_node") # Proceed to execution after code is updated - workflow.add_edge("git_diff_node", "update_container_node") + workflow.add_conditional_edges( + "git_diff_node", + lambda state: bool(state["bug_reproducing_patch"]), + {True: "update_container_node", False: "bug_reproducing_write_message_node"}, + ) workflow.add_edge("update_container_node", "bug_reproducing_execute_node") # Handle command execution tool usage diff --git a/prometheus/lang_graph/subgraphs/issue_verified_bug_subgraph.py b/prometheus/lang_graph/subgraphs/issue_verified_bug_subgraph.py index c4dbfbcf..ab5089d6 100644 --- a/prometheus/lang_graph/subgraphs/issue_verified_bug_subgraph.py +++ b/prometheus/lang_graph/subgraphs/issue_verified_bug_subgraph.py @@ -150,7 +150,12 @@ def __init__( ) workflow.add_edge("edit_tools", "edit_node") - workflow.add_edge("git_diff_node", "update_container_node") + # Apply the patch if available, otherwise do it again + workflow.add_conditional_edges( + "git_diff_node", + lambda state: bool(state["edit_patch"]), + {True: "update_container_node", False: "issue_bug_analyzer_message_node"}, + ) workflow.add_edge("update_container_node", "bug_fix_verification_subgraph_node") # If test still fails, loop back to reanalyze the bug From 2c55f7cc500e824274482bbef542a289b320ca7d Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:30:26 +0800 Subject: [PATCH 2/9] Handle Git command errors during patch application and ensure repository reset to original commit --- prometheus/git/git_repository.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/prometheus/git/git_repository.py b/prometheus/git/git_repository.py index 2619cbd3..5adafc89 100644 --- a/prometheus/git/git_repository.py +++ b/prometheus/git/git_repository.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Optional, Sequence -from git import Git, InvalidGitRepositoryError, Repo +from git import Git, GitCommandError, InvalidGitRepositoryError, Repo class GitRepository: @@ -145,16 +145,24 @@ async def create_and_push_branch(self, branch_name: str, commit_message: str, pa """ if self.repo is None: raise InvalidGitRepositoryError("No repository is currently set.") - with tempfile.NamedTemporaryFile(mode="w", suffix=".patch") as tmp_file: - tmp_file.write(patch) - tmp_file.flush() - new_branch = self.repo.create_head(branch_name) - new_branch.checkout() - - self.repo.git.apply(tmp_file.name) - self.repo.git.add(A=True) - self.repo.index.commit(commit_message) - await asyncio.to_thread(self.repo.git.push, "--set-upstream", "origin", branch_name) + # Get the current commit SHA to ensure we can reset later + start_commit_sha = self.repo.head.commit.hexsha + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".patch") as tmp_file: + tmp_file.write(patch) + tmp_file.flush() + + new_branch = self.repo.create_head(branch_name) + new_branch.checkout() + + self.repo.git.apply(tmp_file.name) + self.repo.git.add(A=True) + self.repo.index.commit(commit_message) + await asyncio.to_thread(self.repo.git.push, "--set-upstream", "origin", branch_name) + except GitCommandError as e: + raise e + finally: self.reset_repository() - self.switch_branch(self.default_branch) + # Reset to the original commit + self.checkout_commit(start_commit_sha) From 609cef736c3cc03094fa5b3edbcfa204384526c7 Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:45:16 +0800 Subject: [PATCH 3/9] Refactor issue processing to improve error handling and response structure --- prometheus/app/api/routes/issue.py | 24 ++++-- prometheus/app/models/requests/issue.py | 6 -- prometheus/app/models/response/issue.py | 4 +- prometheus/app/services/issue_service.py | 103 ++--------------------- 4 files changed, 27 insertions(+), 110 deletions(-) diff --git a/prometheus/app/api/routes/issue.py b/prometheus/app/api/routes/issue.py index 05253252..d32bb447 100644 --- a/prometheus/app/api/routes/issue.py +++ b/prometheus/app/api/routes/issue.py @@ -1,3 +1,5 @@ +import asyncio + from fastapi import APIRouter, Request from prometheus.app.decorators.require_login import requireLogin @@ -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 [], @@ -99,9 +101,19 @@ 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, @@ -109,6 +121,6 @@ async def answer_issue(issue: IssueRequest, request: Request) -> Response[IssueR passed_build=passed_build, passed_existing_test=passed_existing_test, issue_response=issue_response, - remote_branch_name=remote_branch_name, + issue_type=issue_type, ) ) diff --git a/prometheus/app/models/requests/issue.py b/prometheus/app/models/requests/issue.py index 5b8c8f1b..b2a314d8 100644 --- a/prometheus/app/models/requests/issue.py +++ b/prometheus/app/models/requests/issue.py @@ -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"] ) @@ -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], - ) diff --git a/prometheus/app/models/response/issue.py b/prometheus/app/models/response/issue.py index 6dc08ce9..40fe2101 100644 --- a/prometheus/app/models/response/issue.py +++ b/prometheus/app/models/response/issue.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +from prometheus.lang_graph.graphs.issue_state import IssueType + class IssueResponse(BaseModel): patch: str | None = None @@ -7,4 +9,4 @@ class IssueResponse(BaseModel): passed_build: bool passed_existing_test: bool issue_response: str | None = None - remote_branch_name: str | None = None + issue_type: IssueType | None = None diff --git a/prometheus/app/services/issue_service.py b/prometheus/app/services/issue_service.py index 79df5f39..ad4a3f56 100644 --- a/prometheus/app/services/issue_service.py +++ b/prometheus/app/services/issue_service.py @@ -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 @@ -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 @@ -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]], @@ -52,13 +48,12 @@ 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. @@ -66,7 +61,6 @@ async def answer_issue( 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. @@ -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. @@ -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)) From 9b7c3349d78f3173deb8628de87475db96491c40 Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:03:44 +0800 Subject: [PATCH 4/9] Refactor issue handling in tests to streamline response structure and remove unnecessary parameters --- tests/app/api/test_issue.py | 80 ++++++++---------------- tests/app/services/test_issue_service.py | 12 ++-- 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/tests/app/api/test_issue.py b/tests/app/api/test_issue.py index c93ffe97..713318eb 100644 --- a/tests/app/api/test_issue.py +++ b/tests/app/api/test_issue.py @@ -7,8 +7,6 @@ from prometheus.app.api.routes import issue from prometheus.app.entity.repository import Repository from prometheus.app.exception_handler import register_exception_handlers -from prometheus.git.git_repository import GitRepository -from prometheus.graph.knowledge_graph import KnowledgeGraph from prometheus.lang_graph.graphs.issue_state import IssueType app = FastAPI() @@ -36,22 +34,19 @@ def test_answer_issue(mock_service): kg_chunk_size=1000, kg_chunk_overlap=100, ) - mock_service["issue_service"].answer_issue = mock.AsyncMock( - return_value=( - "feature/fix-42", # remote_branch_name - "test patch", # patch - True, # passed_reproducing_test - True, # passed_build - True, # passed_existing_test - "Issue fixed", # issue_response - ) + mock_service["issue_service"].answer_issue.return_value = ( + "test patch", # patch + True, # passed_reproducing_test + True, # passed_build + True, # passed_existing_test + "Issue fixed", # issue_response + IssueType.BUG, # issue_type ) response = client.post( "/issue/answer/", json={ "repository_id": 1, - "issue_number": 42, "issue_title": "Test Issue", "issue_body": "Test description", }, @@ -62,12 +57,12 @@ def test_answer_issue(mock_service): "code": 200, "message": "success", "data": { - "remote_branch_name": "feature/fix-42", "patch": "test patch", "passed_reproducing_test": True, "passed_build": True, "passed_existing_test": True, "issue_response": "Issue fixed", + "issue_type": "bug", }, } @@ -79,7 +74,6 @@ def test_answer_issue_no_repository(mock_service): "/issue/answer/", json={ "repository_id": 1, - "issue_number": 42, "issue_title": "Test Issue", "issue_body": "Test description", }, @@ -105,7 +99,6 @@ def test_answer_issue_invalid_container_config(mock_service): "/issue/answer/", json={ "repository_id": 1, - "issue_number": 42, "issue_title": "Test Issue", "issue_body": "Test description", "dockerfile_content": "FROM python:3.11", @@ -117,10 +110,6 @@ def test_answer_issue_invalid_container_config(mock_service): def test_answer_issue_with_container(mock_service): - git_repo = GitRepository() - - knowledge_graph = KnowledgeGraph(100, 1000, 100, 0) - mock_service["repository_service"].get_repository_by_id.return_value = Repository( id=1, url="https://github.com/fake/repo.git", @@ -133,24 +122,17 @@ def test_answer_issue_with_container(mock_service): kg_chunk_overlap=100, ) - mock_service["knowledge_graph_service"].get_knowledge_graph.return_value = knowledge_graph - - mock_service["repository_service"].get_repository.return_value = git_repo - - mock_service["issue_service"].answer_issue = mock.AsyncMock( - return_value=( - "feature/fix-42", - "test patch", - True, - True, - True, - "Issue fixed", - ) + mock_service["issue_service"].answer_issue.return_value = ( + "test patch", + True, + True, + True, + "Issue fixed", + IssueType.BUG, ) test_payload = { "repository_id": 1, - "issue_number": 42, "issue_title": "Test Issue", "issue_body": "Test description", "dockerfile_content": "FROM python:3.11", @@ -163,23 +145,15 @@ def test_answer_issue_with_container(mock_service): response = client.post("/issue/answer/", json=test_payload) assert response.status_code == 200 - mock_service["issue_service"].answer_issue.assert_called_once_with( - repository_id=1, - repository=git_repo, - knowledge_graph=knowledge_graph, - issue_number=42, - issue_title="Test Issue", - issue_body="Test description", - issue_comments=[], - issue_type=IssueType.AUTO, - run_build=False, - run_existing_test=False, - run_reproduce_test=True, - number_of_candidate_patch=4, - dockerfile_content="FROM python:3.11", - image_name=None, - workdir="/app", - build_commands=["pip install -r requirements.txt"], - test_commands=["pytest ."], - push_to_remote=False, - ) + assert response.json() == { + "code": 200, + "message": "success", + "data": { + "patch": "test patch", + "passed_reproducing_test": True, + "passed_build": True, + "passed_existing_test": True, + "issue_response": "Issue fixed", + "issue_type": "bug", + }, + } diff --git a/tests/app/services/test_issue_service.py b/tests/app/services/test_issue_service.py index 79d9a0d6..9867f4d2 100644 --- a/tests/app/services/test_issue_service.py +++ b/tests/app/services/test_issue_service.py @@ -74,11 +74,10 @@ async def test_answer_issue_with_general_container(issue_service, monkeypatch): mock_issue_graph.invoke.return_value = mock_output_state # Exercise - result = await issue_service.answer_issue( + result = issue_service.answer_issue( repository_id=1, repository=repository, knowledge_graph=knowledge_graph, - issue_number=-1, issue_title="Test Issue", issue_body="Test Body", issue_comments=[], @@ -87,6 +86,8 @@ async def test_answer_issue_with_general_container(issue_service, monkeypatch): run_existing_test=True, number_of_candidate_patch=1, run_reproduce_test=True, + build_commands=None, + test_commands=None, ) # Verify @@ -102,7 +103,7 @@ async def test_answer_issue_with_general_container(issue_service, monkeypatch): build_commands=None, test_commands=None, ) - assert result == (None, "test_patch", True, True, True, "test_response") + assert result == ("test_patch", True, True, True, "test_response", IssueType.BUG) @pytest.mark.asyncio @@ -135,11 +136,10 @@ async def test_answer_issue_with_user_defined_container(issue_service, monkeypat mock_issue_graph.invoke.return_value = mock_output_state # Exercise - result = await issue_service.answer_issue( + result = issue_service.answer_issue( repository_id=1, repository=repository, knowledge_graph=knowledge_graph, - issue_number=-1, issue_title="Test Issue", issue_body="Test Body", issue_comments=[], @@ -164,4 +164,4 @@ async def test_answer_issue_with_user_defined_container(issue_service, monkeypat "FROM python:3.8", "test-image", ) - assert result == (None, None, False, False, False, "test_response") + assert result == (None, False, False, False, "test_response", IssueType.QUESTION) From 15a93b379a0e45c8b39493b702a1088727ba201c Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:20:43 +0800 Subject: [PATCH 5/9] Remove global exception handler to simplify error management in the application --- prometheus/app/exception_handler.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/prometheus/app/exception_handler.py b/prometheus/app/exception_handler.py index 2a9a56f0..bae8b2b2 100644 --- a/prometheus/app/exception_handler.py +++ b/prometheus/app/exception_handler.py @@ -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} - ) From 778ab244e229618e9a47612014c75856bdbe62e2 Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:45:42 +0800 Subject: [PATCH 6/9] Add create branch and push endpoint with validation for branch names --- prometheus/app/api/routes/repository.py | 39 +++++++++++- prometheus/app/models/requests/auth.py | 1 - prometheus/app/models/requests/repository.py | 65 +++++++++++++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/prometheus/app/api/routes/repository.py b/prometheus/app/api/routes/repository.py index f78d44f4..8f726af1 100644 --- a/prometheus/app/api/routes/repository.py +++ b/prometheus/app/api/routes/repository.py @@ -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 @@ -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=""" diff --git a/prometheus/app/models/requests/auth.py b/prometheus/app/models/requests/auth.py index 9e36f94f..29c0fc9d 100644 --- a/prometheus/app/models/requests/auth.py +++ b/prometheus/app/models/requests/auth.py @@ -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]+$" diff --git a/prometheus/app/models/requests/repository.py b/prometheus/app/models/requests/repository.py index ad1ce080..42bd51b5 100644 --- a/prometheus/app/models/requests/repository.py +++ b/prometheus/app/models/requests/repository.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, Field +import re + +from pydantic import BaseModel, Field, field_validator class UploadRepositoryRequest(BaseModel): @@ -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 From 0c2ccfb6d130efa6db21393275aab0d3ce44012c Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:52:47 +0800 Subject: [PATCH 7/9] Add unit test for create branch and push functionality --- tests/app/api/test_repository.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/app/api/test_repository.py b/tests/app/api/test_repository.py index 886373e6..c2dfd6b5 100644 --- a/tests/app/api/test_repository.py +++ b/tests/app/api/test_repository.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI @@ -46,6 +47,27 @@ def test_upload_repository_at_commit(mock_service): assert response.status_code == 200 +def test_create_branch_and_push(mock_service): + # Mock git_repo + git_repo_mock = MagicMock() + git_repo_mock.create_and_push_branch = AsyncMock(return_value=None) + + # Let repository_service.get_repository return the mocked git_repo + mock_service["repository_service"].get_repository.return_value = git_repo_mock + + response = client.post( + "/repository/create-branch-and-push/", + json={ + "repository_id": 1, + "branch_name": "new_branch", + "commit_message": "Initial commit on new branch", + "patch": "mock_patch_content", + }, + ) + + assert response.status_code == 200 + + def test_delete(mock_service): mock_service["repository_service"].get_repository_by_id.return_value = Repository( id=1, From f649c270c6034f15756d6c8943a40e8fbfbd184e Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:14:26 +0800 Subject: [PATCH 8/9] Enhance read_file_with_line_numbers function with docstring and improve error message clarity --- prometheus/utils/file_utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/prometheus/utils/file_utils.py b/prometheus/utils/file_utils.py index f1691e90..c47221cb 100644 --- a/prometheus/utils/file_utils.py +++ b/prometheus/utils/file_utils.py @@ -8,6 +8,11 @@ def read_file_with_line_numbers( relative_path: str, root_path: str, start_line: int, end_line: int ) -> str: + """ + Reads a file and returns its content with line numbers, starting from a specific line + to an end line (inclusive). + """ + if os.path.isabs(relative_path): raise FileOperationException( f"relative_path: {relative_path} is a absolute path, not relative path." @@ -22,8 +27,7 @@ def read_file_with_line_numbers( if end_line < start_line: raise FileOperationException( - f"The end line number {end_line} must be greater than " - f"the start line number {start_line}." + "The end line number must be greater than the start line number." ) zero_based_start_line = start_line - 1 @@ -34,5 +38,5 @@ def read_file_with_line_numbers( lines = f.readlines() return pre_append_line_numbers( - "".join(lines[zero_based_start_line:zero_based_end_line]), zero_based_start_line + "".join(lines[zero_based_start_line:zero_based_end_line]), start_line ) From 79219298dc527ea4240b5c8f8896dc82480ba857 Mon Sep 17 00:00:00 2001 From: Yue Pan <79363355+dcloud347@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:14:38 +0800 Subject: [PATCH 9/9] Add temp_test_dir fixture and corresponding tests for file operations --- tests/test_utils/fixtures.py | 9 +++++++++ tests/tools/test_file_operation.py | 26 ++++++++------------------ tests/utils/test_file_utils.py | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 tests/utils/test_file_utils.py diff --git a/tests/test_utils/fixtures.py b/tests/test_utils/fixtures.py index 556c67a0..0dca00b7 100644 --- a/tests/test_utils/fixtures.py +++ b/tests/test_utils/fixtures.py @@ -76,3 +76,12 @@ def git_repo_fixture(): yield repo finally: shutil.rmtree(temp_project_dir) + + +@pytest.fixture +def temp_test_dir(tmp_path): + """Create a temporary test directory.""" + test_dir = tmp_path / "test_files" + test_dir.mkdir() + yield test_dir + # Cleanup happens automatically after tests due to tmp_path fixture diff --git a/tests/tools/test_file_operation.py b/tests/tools/test_file_operation.py index d172fd78..9283a805 100644 --- a/tests/tools/test_file_operation.py +++ b/tests/tools/test_file_operation.py @@ -1,5 +1,3 @@ -import pytest - from prometheus.tools.file_operation import ( create_file, delete, @@ -7,18 +5,10 @@ read_file, read_file_with_line_numbers, ) +from tests.test_utils.fixtures import temp_test_dir # noqa: F401 -@pytest.fixture -def temp_test_dir(tmp_path): - """Create a temporary test directory.""" - test_dir = tmp_path / "test_files" - test_dir.mkdir() - yield test_dir - # Cleanup happens automatically after tests due to tmp_path fixture - - -def test_create_and_read_file(temp_test_dir): +def test_create_and_read_file(temp_test_dir): # noqa: F811 """Test creating a file and reading its contents.""" test_file = temp_test_dir / "test.txt" content = "line 1\nline 2\nline 3" @@ -35,13 +25,13 @@ def test_create_and_read_file(temp_test_dir): assert result == expected -def test_read_file_nonexistent(temp_test_dir): +def test_read_file_nonexistent(temp_test_dir): # noqa: F811 """Test reading a nonexistent file.""" result = read_file("nonexistent_file.txt", str(temp_test_dir)) assert result == "The file nonexistent_file.txt does not exist." -def test_read_file_with_line_numbers(temp_test_dir): +def test_read_file_with_line_numbers(temp_test_dir): # noqa: F811 """Test reading specific line ranges from a file.""" content = "line 1\nline 2\nline 3\nline 4\nline 5" create_file("test_lines.txt", str(temp_test_dir), content) @@ -56,7 +46,7 @@ def test_read_file_with_line_numbers(temp_test_dir): assert result == "The end line number 2 must be greater than the start line number 4." -def test_delete(temp_test_dir): +def test_delete(temp_test_dir): # noqa: F811 """Test file and directory deletion.""" # Test file deletion test_file = temp_test_dir / "to_delete.txt" @@ -75,13 +65,13 @@ def test_delete(temp_test_dir): assert not test_subdir.exists() -def test_delete_nonexistent(temp_test_dir): +def test_delete_nonexistent(temp_test_dir): # noqa: F811 """Test deleting a nonexistent path.""" result = delete("nonexistent_path", str(temp_test_dir)) assert result == "The file nonexistent_path does not exist." -def test_edit_file(temp_test_dir): +def test_edit_file(temp_test_dir): # noqa: F811 """Test editing specific lines in a file.""" # Test case 1: Successfully edit a single occurrence initial_content = "line 1\nline 2\nline 3\nline 4\nline 5" @@ -114,7 +104,7 @@ def test_edit_file(temp_test_dir): ) -def test_create_file_already_exists(temp_test_dir): +def test_create_file_already_exists(temp_test_dir): # noqa: F811 """Test creating a file that already exists.""" create_file("existing.txt", str(temp_test_dir), "content") result = create_file("existing.txt", str(temp_test_dir), "new content") diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py new file mode 100644 index 00000000..2c46a699 --- /dev/null +++ b/tests/utils/test_file_utils.py @@ -0,0 +1,25 @@ +import pytest + +from prometheus.exceptions.file_operation_exception import FileOperationException +from prometheus.tools.file_operation import create_file +from prometheus.utils.file_utils import ( + read_file_with_line_numbers, +) +from tests.test_utils.fixtures import temp_test_dir # noqa: F401 + + +def test_read_file_with_line_numbers(temp_test_dir): # noqa: F811 + """Test reading specific line ranges from a file.""" + content = "line 1\nline 2\nline 3\nline 4\nline 5" + create_file("test_lines.txt", str(temp_test_dir), content) + + # Test reading specific lines + result = read_file_with_line_numbers("test_lines.txt", str(temp_test_dir), 2, 4) + expected = "2. line 2\n3. line 3\n4. line 4" + assert result == expected + + # Test invalid range should raise exception + with pytest.raises(FileOperationException) as exc_info: + read_file_with_line_numbers("test_lines.txt", str(temp_test_dir), 4, 2) + + assert str(exc_info.value) == "The end line number must be greater than the start line number."