diff --git a/git0/api/deps.py b/git0/api/deps.py index 0177fc2..e5bc44a 100644 --- a/git0/api/deps.py +++ b/git0/api/deps.py @@ -66,17 +66,19 @@ async def get_organization_service( async def get_repository_service( - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_db)], + s3_storage: Annotated[S3Storage, Depends(get_s3_storage)], # Added S3Storage dependency ) -> RepositoryService: """Get a repository service instance. Args: db: The database session + s3_storage: The S3 storage instance Returns: RepositoryService: The repository service """ - return RepositoryService(db) + return RepositoryService(db, s3_storage) # Pass s3_storage to constructor async def get_issue_service( @@ -94,17 +96,19 @@ async def get_issue_service( async def get_pull_request_service( - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_db)], + s3_storage: Annotated[S3Storage, Depends(get_s3_storage)], # Added S3Storage dependency ) -> PullRequestService: """Get a pull request service. Args: db: The database session + s3_storage: The S3 storage instance Returns: PullRequestService: The pull request service """ - return PullRequestService(db) + return PullRequestService(db, s3_storage) # Pass s3_storage to constructor async def get_commit_service( diff --git a/git0/api/v1/branches.py b/git0/api/v1/branches.py new file mode 100644 index 0000000..8762304 --- /dev/null +++ b/git0/api/v1/branches.py @@ -0,0 +1,166 @@ +"""Branches API router for Git0.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Response + +from git0.api.deps import get_repository_service +from git0.core.exceptions import NotFoundError, ConflictError +from git0.models.branch import BranchRead, BranchCreate # Added BranchCreate +from git0.services.repository import RepositoryService + +router = APIRouter(prefix="/orgs/{org_name}/repos/{repo_name}/branches", tags=["branches"]) + + +@router.get( + "", + response_model=list[BranchRead], + summary="List Repository Branches", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization or repository not found."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "An unexpected error occurred while listing branches."}, + }, +) +async def list_repository_branches( + org_name: str, + repo_name: str, + service: RepositoryService = Depends(get_repository_service), +) -> list[BranchRead]: + """ + Retrieve a list of all branches in the specified repository. + + Each branch object in the list includes its name and the SHA of its head commit. + """ + try: + return await service.list_branches( + organization_name=org_name, repository_name=repo_name + ) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An unexpected error occurred while listing branches: {str(e)}", + ) + + +@router.get( + "/{branch_name:path}", + response_model=BranchRead, + summary="Get a Specific Branch", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or branch not found."}, + status.HTTP_409_CONFLICT: {"description": "Error retrieving branch details (e.g., invalid SHA content)."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "An unexpected error occurred while getting the branch."}, + }, +) +async def get_repository_branch( + org_name: str, + repo_name: str, + branch_name: str, # Path parameter allows slashes, e.g., "feature/new-ux" + service: RepositoryService = Depends(get_repository_service), +) -> BranchRead: + """ + Retrieve details for a specific branch within a repository. + + The `branch_name` can include slashes if it's a hierarchical branch (e.g., `feature/login`). + Returns the branch name and the SHA of its head commit. + """ + try: + return await service.get_branch( + organization_name=org_name, + repository_name=repo_name, + branch_name=branch_name, + ) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ConflictError as e: # If get_branch raises ConflictError for other issues + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An unexpected error occurred while getting branch '{branch_name}': {str(e)}", + ) + + +@router.post( + "", + response_model=BranchRead, + status_code=status.HTTP_201_CREATED, + summary="Create a New Branch", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or source commit SHA not found."}, + status.HTTP_409_CONFLICT: {"description": "Branch already exists or other conflict during creation."}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Validation error, e.g., invalid branch name format or invalid source SHA format."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "An unexpected error occurred while creating the branch."}, + }, +) +async def create_new_branch( + org_name: str, + repo_name: str, + branch_create: BranchCreate, + service: RepositoryService = Depends(get_repository_service), +) -> BranchRead: + """ + Create a new branch in the specified repository. + + The new branch will point to the commit specified by `source_sha`. + Branch names must adhere to Git reference naming conventions. + """ + try: + return await service.create_branch( + organization_name=org_name, + repository_name=repo_name, + branch_create=branch_create, + ) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ConflictError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except ValueError as e: # Catch Pydantic validation errors for branch name or SHA + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An unexpected error occurred while creating branch '{branch_create.name}': {str(e)}", + ) + + +@router.delete( + "/{branch_name:path}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a Branch", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or branch not found."}, + status.HTTP_409_CONFLICT: {"description": "Error during deletion process (e.g., could not verify branch for deletion)."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "An unexpected error occurred while deleting the branch."}, + }, +) +async def delete_repository_branch( + org_name: str, + repo_name: str, + branch_name: str, # Path parameter allows slashes + service: RepositoryService = Depends(get_repository_service), +) -> Response: + """ + Delete a specific branch from the repository. + + The `branch_name` can include slashes if it's a hierarchical branch. + This operation is permanent and cannot be undone through the API. + """ + try: + await service.delete_branch( + organization_name=org_name, + repository_name=repo_name, + branch_name=branch_name, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ConflictError as e: # If delete_branch raises ConflictError for other issues + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An unexpected error occurred while deleting branch '{branch_name}': {str(e)}", + ) diff --git a/git0/api/v1/git_http.py b/git0/api/v1/git_http.py index faea8be..efc23e4 100644 --- a/git0/api/v1/git_http.py +++ b/git0/api/v1/git_http.py @@ -679,7 +679,7 @@ async def notify_push( return {"ref_updates": response_ref_updates} -@router.post("/orgs/{org_name}/repos/{repo_name}.git/git-remote-config") +@router.get("/orgs/{org_name}/repos/{repo_name}/git-remote-config") async def get_git_remote_config( org_name: Annotated[str, Path(description="Organization name.")], repo_name: Annotated[str, Path(description="Repository name.")], diff --git a/git0/api/v1/pull_requests.py b/git0/api/v1/pull_requests.py index c033dca..872ce43 100644 --- a/git0/api/v1/pull_requests.py +++ b/git0/api/v1/pull_requests.py @@ -11,19 +11,36 @@ PullRequestCreate, PullRequestUpdate, ) +from git0.models.pull_request_comment import PullRequestCommentCreate, PullRequestCommentRead +from git0.models.pull_request_review import PullRequestReviewCreate, PullRequestReviewRead # Added import from git0.services.pull_request import PullRequestService router = APIRouter(prefix="/orgs/{org_name}/repos/{repo_name}/pulls", tags=["pull_requests"]) -@router.post("", response_model=PullRequest, status_code=status.HTTP_201_CREATED) +@router.post( + "", + response_model=PullRequest, + status_code=status.HTTP_201_CREATED, + summary="Create a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization or repository not found."}, + status.HTTP_409_CONFLICT: {"description": "Pull request conflicts with an existing one or other creation conflict."}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Validation error, e.g., invalid branch names or missing title."}, + }, +) async def create_pull_request( org_name: str, repo_name: str, pull_request_create: PullRequestCreate, - service: PullRequestService = Depends(get_pull_request_service) + service: PullRequestService = Depends(get_pull_request_service), ) -> PullRequest: - """Create a new pull request.""" + """ + Create a new pull request in the specified repository. + + Allows for specifying title, description, source and target branches, + draft status, linked issue, assignee, and labels. + """ try: return await service.create_pull_request( organization_name=org_name, @@ -43,14 +60,25 @@ async def create_pull_request( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) -@router.get("", response_model=list[PullRequest]) +@router.get( + "", + response_model=list[PullRequest], + summary="List Pull Requests", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization or repository not found."}, + }, +) async def list_pull_requests( org_name: str, repo_name: str, - state: PullRequestState | None = Query(None, description="Pull request state"), - service: PullRequestService = Depends(get_pull_request_service) + state: PullRequestState | None = Query(None, description="Filter pull requests by state (e.g., OPEN, CLOSED, MERGED)."), + service: PullRequestService = Depends(get_pull_request_service), ) -> list[PullRequest]: - """List pull requests in a repository.""" + """ + List all pull requests in the specified repository. + + Allows filtering by pull request state (e.g., OPEN, CLOSED, MERGED). + """ try: return await service.list_pull_requests( organization_name=org_name, @@ -61,14 +89,23 @@ async def list_pull_requests( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@router.get("/{number}", response_model=PullRequest) +@router.get( + "/{number}", + response_model=PullRequest, + summary="Get a specific Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + }, +) async def get_pull_request( org_name: str, repo_name: str, number: int, - service: PullRequestService = Depends(get_pull_request_service) + service: PullRequestService = Depends(get_pull_request_service), ) -> PullRequest: - """Get a pull request by number.""" + """ + Retrieve a specific pull request by its number within the repository. + """ try: return await service.get_pull_request( organization_name=org_name, @@ -79,15 +116,28 @@ async def get_pull_request( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@router.patch("/{number}", response_model=PullRequest) +@router.patch( + "/{number}", + response_model=PullRequest, + summary="Update a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + status.HTTP_409_CONFLICT: {"description": "Update conflicts with existing data or state."}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Validation error in the request body."}, + }, +) async def update_pull_request( org_name: str, repo_name: str, number: int, pull_request_update: PullRequestUpdate, - service: PullRequestService = Depends(get_pull_request_service) + service: PullRequestService = Depends(get_pull_request_service), ) -> PullRequest: - """Update a pull request.""" + """ + Update details of an existing pull request. + + Allows modification of fields such as title, description, state, labels, assignee, etc. + """ try: return await service.update_pull_request( organization_name=org_name, @@ -101,14 +151,23 @@ async def update_pull_request( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) -@router.delete("/{number}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{number}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + }, +) async def delete_pull_request( org_name: str, repo_name: str, number: int, - service: PullRequestService = Depends(get_pull_request_service) + service: PullRequestService = Depends(get_pull_request_service), ) -> None: - """Delete a pull request.""" + """ + Delete a specific pull request by its number from the repository. + """ try: await service.delete_pull_request( organization_name=org_name, @@ -119,14 +178,30 @@ async def delete_pull_request( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@router.post("/{pull_number}/merge", response_model=PullRequest) +@router.post( + "/{pull_number}/merge", + response_model=PullRequest, + summary="Merge a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + status.HTTP_409_CONFLICT: {"description": "Merge conflict, PR not mergeable (e.g., draft, not open, not fast-forward and auto-merge failed), or other merge-related issue."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "Unexpected error during the merge process."}, + }, +) async def merge_pull_request( org_name: str, repo_name: str, pull_number: int, - service: PullRequestService = Depends(get_pull_request_service) + service: PullRequestService = Depends(get_pull_request_service), ) -> PullRequest: - """Merge a pull request.""" + """ + Merge a pull request into its target branch. + + Supports fast-forward merges. If the merge is not a fast-forward, + it attempts a three-way merge by creating a merge commit. + Raises errors if the pull request is not in a mergeable state (e.g., draft, not OPEN) + or if merge conflicts occur that cannot be automatically resolved by the simplified three-way merge logic. + """ try: return await service.merge_pull_request(org_name, repo_name, pull_number) except NotFoundError as e: @@ -134,4 +209,100 @@ async def merge_pull_request( except ConflictError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) except Exception as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post( + "/{pull_number}/comments", + response_model=PullRequestCommentRead, + status_code=status.HTTP_201_CREATED, + summary="Add a Comment to a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + status.HTTP_409_CONFLICT: {"description": "Conflict adding the comment (e.g., database conflict)."}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Validation error in the comment data (e.g., empty body)."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "Unexpected error occurred while adding the comment."}, + }, +) +async def add_pull_request_comment( + org_name: str, + repo_name: str, + pull_number: int, + comment_create: PullRequestCommentCreate, + service: PullRequestService = Depends(get_pull_request_service), + # In a real app, user_id would likely come from an auth dependency + # For now, we allow it to be passed in the body or be None +) -> PullRequestCommentRead: + """ + Add a new comment to the specified pull request. + The user ID for the comment can be optionally provided in the request body. + """ + try: + # Assuming user_id from comment_create is sufficient for now, + # or a dedicated user_id parameter could be added to the endpoint + # and passed to the service if auth was in place. + # The service method already handles taking user_id from comment_create. + return await service.add_comment_to_pull_request( + organization_name=org_name, + repository_name=repo_name, + pr_number=pull_number, + comment_create=comment_create, + # user_id=comment_create.user_id # Or pass an authenticated user_id here + ) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ConflictError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred while adding the comment.", + ) + + +@router.post( + "/{pull_number}/reviews", + response_model=PullRequestReviewRead, + status_code=status.HTTP_201_CREATED, + summary="Add a Review to a Pull Request", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Organization, repository, or pull request not found."}, + status.HTTP_409_CONFLICT: {"description": "Conflict adding the review (e.g., database conflict)."}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Validation error in the review data (e.g., invalid state or body)."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "Unexpected error occurred while adding the review."}, + }, +) +async def add_pull_request_review( + org_name: str, + repo_name: str, + pull_number: int, + review_create: PullRequestReviewCreate, + service: PullRequestService = Depends(get_pull_request_service), + # In a real app, user_id would likely come from an auth dependency +) -> PullRequestReviewRead: + """ + Submit a review for the specified pull request. + + A review includes an optional body text and a state (e.g., APPROVED, CHANGES_REQUESTED, COMMENTED). + The user ID for the review can be optionally provided in the request body. + """ + try: + # The service method handles taking user_id from review_create if provided. + return await service.add_review_to_pull_request( + organization_name=org_name, + repository_name=repo_name, + pr_number=pull_number, + review_create=review_create, + # user_id=review_create.user_id # Or pass an authenticated user_id here + ) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ConflictError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except Exception as e: + # Log the exception e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred while adding the review.", + ) \ No newline at end of file diff --git a/git0/api/v1/routes.py b/git0/api/v1/routes.py index b84a68f..ee92824 100644 --- a/git0/api/v1/routes.py +++ b/git0/api/v1/routes.py @@ -16,6 +16,7 @@ from git0.api.v1.repositories import public_router as public_repositories_router from git0.api.v1.repositories import router as repositories_router from git0.api.v1.storage import router as storage_router +from git0.api.v1.branches import router as branches_router # Added import from git0.core.config import settings @@ -40,6 +41,7 @@ def setup_routes(app): v1_router.include_router(health_router) v1_router.include_router(storage_router) v1_router.include_router(git_http_router) + v1_router.include_router(branches_router) # Added router # Register v1 router with app app.include_router(v1_router) diff --git a/git0/db/models/__init__.py b/git0/db/models/__init__.py index 6a298fd..a87505b 100644 --- a/git0/db/models/__init__.py +++ b/git0/db/models/__init__.py @@ -1,21 +1,26 @@ """Git0 database models.""" from .commit import Commit -from .enums import IssueState, PullRequestState +from .enums import IssueState, PullRequestState, PullRequestReviewState from .file_content import FileContent from .issue import Issue from .organization import Organization from .pull_request import PullRequest +from .pull_request_comment import PullRequestComment +from .pull_request_review import PullRequestReview # Added import from .repository import Repository from .user import User __all__ = [ "PullRequestState", "IssueState", + "PullRequestReviewState", "Organization", "Repository", "Issue", "PullRequest", + "PullRequestComment", + "PullRequestReview", # Added model "Commit", "User", "FileContent", diff --git a/git0/db/models/enums.py b/git0/db/models/enums.py index bfc9083..d3e605c 100644 --- a/git0/db/models/enums.py +++ b/git0/db/models/enums.py @@ -13,4 +13,11 @@ class PullRequestState(str, enum.Enum): class IssueState(str, enum.Enum): """Issue state enum.""" OPEN = "OPEN" - CLOSED = "CLOSED" \ No newline at end of file + CLOSED = "CLOSED" + + +class PullRequestReviewState(str, enum.Enum): + """Pull request review state enum.""" + APPROVED = "APPROVED" + CHANGES_REQUESTED = "CHANGES_REQUESTED" + COMMENTED = "COMMENTED" \ No newline at end of file diff --git a/git0/db/models/pull_request.py b/git0/db/models/pull_request.py index 42a0f13..e70c3eb 100644 --- a/git0/db/models/pull_request.py +++ b/git0/db/models/pull_request.py @@ -12,6 +12,8 @@ from .issue import Issue from .repository import Repository from .user import User + from .pull_request_comment import PullRequestComment + from .pull_request_review import PullRequestReview # Added import from git0.db.models.enums import PullRequestState @@ -93,8 +95,11 @@ class PullRequest(SQLModel, table=True): updated_at: datetime = Field( default_factory=lambda: datetime.now(UTC).replace(tzinfo=None) ) + merge_commit_sha: str | None = Field(default=None, nullable=True) # Added field # Relationships repository: "Repository" = Relationship(back_populates="pull_requests") issue: Optional["Issue"] = Relationship(back_populates="pull_requests") - assignee: Optional["User"] = Relationship() \ No newline at end of file + assignee: Optional["User"] = Relationship() + comments: list["PullRequestComment"] = Relationship(back_populates="pull_request") + reviews: list["PullRequestReview"] = Relationship(back_populates="pull_request") # Added relationship \ No newline at end of file diff --git a/git0/db/models/pull_request_comment.py b/git0/db/models/pull_request_comment.py new file mode 100644 index 0000000..ad5040a --- /dev/null +++ b/git0/db/models/pull_request_comment.py @@ -0,0 +1,31 @@ +"""Pull request comment model for Git0.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from .pull_request import PullRequest + from .user import User + + +class PullRequestComment(SQLModel, table=True): + """Pull request comment model.""" + + __tablename__ = "pull_request_comments" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + body: str + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None)) + + pull_request_id: UUID = Field(foreign_key="pull_requests.id") + user_id: UUID | None = Field(foreign_key="users.id", default=None) # Nullable for now + + # Relationships + pull_request: "PullRequest" = Relationship(back_populates="comments") + user: "User" | None = Relationship(back_populates="pull_request_comments") diff --git a/git0/db/models/pull_request_review.py b/git0/db/models/pull_request_review.py new file mode 100644 index 0000000..4d5d670 --- /dev/null +++ b/git0/db/models/pull_request_review.py @@ -0,0 +1,35 @@ +"""Pull request review model for Git0.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import Text # Import Text for body +from sqlmodel import Field, Relationship, SQLModel + +from .enums import PullRequestReviewState # Import the enum + +if TYPE_CHECKING: + from .pull_request import PullRequest + from .user import User + + +class PullRequestReview(SQLModel, table=True): + """Pull request review model.""" + + __tablename__ = "pull_request_reviews" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + body: str | None = Field(default=None, sa_column=Text) # Optional text body + state: PullRequestReviewState # Use the enum for state + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None)) + + pull_request_id: UUID = Field(foreign_key="pull_requests.id") + user_id: UUID | None = Field(foreign_key="users.id", default=None) # Nullable for now + + # Relationships + pull_request: "PullRequest" = Relationship(back_populates="reviews") + user: "User" | None = Relationship(back_populates="pull_request_reviews") diff --git a/git0/db/models/user.py b/git0/db/models/user.py index 9871d3b..73837b6 100644 --- a/git0/db/models/user.py +++ b/git0/db/models/user.py @@ -3,9 +3,14 @@ from __future__ import annotations from datetime import UTC, datetime +from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlmodel import Field, SQLModel +from sqlmodel import Field, SQLModel, Relationship + +if TYPE_CHECKING: + from .pull_request_comment import PullRequestComment + from .pull_request_review import PullRequestReview # Added import class User(SQLModel, table=True): @@ -23,4 +28,6 @@ class User(SQLModel, table=True): # Note: Relationships with Issue and PullRequest models are handled via foreign keys # The assignee_id fields in those models reference this User.id - # Direct relationship definitions are omitted to avoid SQLModel/SQLAlchemy compatibility issues \ No newline at end of file + # Direct relationship definitions are omitted to avoid SQLModel/SQLAlchemy compatibility issues + pull_request_comments: list["PullRequestComment"] = Relationship(back_populates="user") + pull_request_reviews: list["PullRequestReview"] = Relationship(back_populates="user") # Added relationship \ No newline at end of file diff --git a/git0/models/__init__.py b/git0/models/__init__.py index a834ca8..65d74b3 100644 --- a/git0/models/__init__.py +++ b/git0/models/__init__.py @@ -24,6 +24,15 @@ RepositoryResponse, RepositoryUpdate, ) +from git0.models.pull_request_comment import ( # Added import + PullRequestCommentCreate, + PullRequestCommentRead, +) +from git0.models.pull_request_review import ( # Added import + PullRequestReviewCreate, + PullRequestReviewRead, +) +from git0.models.branch import BranchRead, BranchCreate # Updated import __all__ = [ # Domain models @@ -45,5 +54,11 @@ "FileContent", "FileContentCreate", "FileContentUpdate", - "FileContentDelete" + "FileContentDelete", + "PullRequestCommentCreate", + "PullRequestCommentRead", + "PullRequestReviewCreate", + "PullRequestReviewRead", + "BranchRead", + "BranchCreate", # Added model ] diff --git a/git0/models/branch.py b/git0/models/branch.py new file mode 100644 index 0000000..f2679b1 --- /dev/null +++ b/git0/models/branch.py @@ -0,0 +1,48 @@ +"""Pydantic models for Branches.""" + +from pydantic import BaseModel, Field, validator # Added Field and validator + + +class BranchRead(BaseModel): + """Model for reading branch information.""" + name: str = Field(..., description="The name of the branch (e.g., 'main', 'feature/login').") + commit_sha: str = Field(..., description="The SHA of the commit this branch currently points to.") + + class Config: + orm_mode = True + from_attributes = True + + +class BranchCreate(BaseModel): + """Model for creating a new branch.""" + name: str = Field(..., description="The desired name for the new branch. Must adhere to Git reference naming conventions.", example="feature/new-feature-branch") + source_sha: str = Field(..., description="The 40-character SHA of the commit from which this new branch will be created.", example="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + + @validator('name') + def validate_branch_name(cls, v: str) -> str: + if not v: + raise ValueError("Branch name cannot be empty.") + if " " in v: + raise ValueError("Branch name cannot contain spaces.") + if v.startswith("/") or v.endswith("/"): + raise ValueError("Branch name cannot start or end with a slash.") + if ".." in v: + raise ValueError("Branch name cannot contain '..'.") + if any(char in v for char in "*?[\\~^:"): + raise ValueError("Branch name cannot contain characters: * ? [ \\ ~ ^ :") + # Basic check for consecutive slashes, though more complex patterns exist + if "//" in v: + raise ValueError("Branch name cannot contain consecutive slashes.") + # Check for typical control characters or other disallowed ASCII + for char_code in range(32): # ASCII 0-31 + if chr(char_code) in v: + raise ValueError(f"Branch name cannot contain control character ASCII {char_code}.") + if chr(127) in v: # DEL character + raise ValueError("Branch name cannot contain DEL character (ASCII 127).") + return v + + @validator('source_sha') + def validate_source_sha(cls, v: str) -> str: + if not (len(v) == 40 and all(c in "0123456789abcdef" for c in v.lower())): + raise ValueError("source_sha must be a valid 40-character hex SHA.") + return v diff --git a/git0/models/pull_request.py b/git0/models/pull_request.py index 185e496..11c5c1c 100644 --- a/git0/models/pull_request.py +++ b/git0/models/pull_request.py @@ -9,38 +9,45 @@ class PullRequestBase(BaseModel): - """Base model for PullRequest.""" - title: str = Field(..., description="Title of the pull request") - description: str | None = Field(None, description="Description of the pull request") - source_branch: str = Field(..., description="Source branch") - target_branch: str = Field(..., description="Target branch") - status: PullRequestState = Field(PullRequestState.OPEN, description="Status of the pull request") - labels: list[str] = Field(default_factory=list, description="Labels attached to the pull request") - milestone: str | None = Field(None, description="Milestone this pull request belongs to") - assignee_id: UUID | None = Field(None, description="ID of the user assigned to this pull request") + """Base model for PullRequest, containing common fields for creation and response.""" + title: str = Field(..., description="Title of the pull request.", example="Fix authentication bug") + description: str | None = Field(None, description="Detailed description of the pull request, can include Markdown.", example="This PR fixes an issue where users could not log in under specific circumstances...") + source_branch: str = Field(..., description="The name of the branch where the changes are implemented.", example="feature/auth-fix") + target_branch: str = Field(..., description="The name of the branch the changes are intended to be merged into.", example="main") + status: PullRequestState = Field(PullRequestState.OPEN, description="Current status of the pull request (e.g., OPEN, CLOSED, MERGED).") + labels: list[str] = Field(default_factory=list, description="A list of labels associated with the pull request.", example=["bug", "critical"]) + milestone: str | None = Field(None, description="The milestone this pull request is associated with, if any.", example="v1.2.0") + assignee_id: UUID | None = Field(None, description="The UUID of the user assigned to this pull request.", example="a1b2c3d4-e5f6-7890-1234-567890abcdef") + is_draft: bool = Field(False, description="Indicates if the pull request is a draft and not yet ready for review.") # Added is_draft + merge_commit_sha: str | None = Field(None, description="SHA of the merge commit, if the pull request has been merged. Read-only.") class PullRequestCreate(PullRequestBase): - """Model for creating a PullRequest.""" + """Model for creating a new PullRequest. Inherits all fields from PullRequestBase.""" pass class PullRequestUpdate(BaseModel): - """Model for updating a PullRequest.""" - title: str | None = Field(None, description="Title of the pull request") - description: str | None = Field(None, description="Description of the pull request") - status: PullRequestState | None = Field(None, description="Status of the pull request") - labels: list[str] | None = Field(None, description="Labels attached to the pull request") - milestone: str | None = Field(None, description="Milestone this pull request belongs to") - assignee_id: UUID | None = Field(None, description="ID of the user assigned to this pull request") + """Model for updating an existing PullRequest. All fields are optional.""" + title: str | None = Field(None, description="New title for the pull request.", example="Resolve issue with user login") + description: str | None = Field(None, description="New detailed description for the pull request.", example="Updated description with more details on the fix.") + status: PullRequestState | None = Field(None, description="New status for the pull request (e.g., OPEN, CLOSED).") + labels: list[str] | None = Field(None, description="New list of labels for the pull request. Replaces existing labels.", example=["bug", "urgent"]) + milestone: str | None = Field(None, description="New milestone for the pull request.", example="v1.2.1") + assignee_id: UUID | None = Field(None, description="New assignee UUID for the pull request.", example="b2c3d4e5-f6a7-8901-2345-67890abcdef0") + is_draft: bool | None = Field(None, description="Set or unset the draft status of the pull request.") # Added is_draft + source_branch: str | None = Field(None, description="Change the source branch of the pull request.", example="hotfix/login-issue") + target_branch: str | None = Field(None, description="Change the target branch of the pull request.", example="develop") class PullRequestResponse(PullRequestBase): """Model for PullRequest response.""" model_config = ConfigDict(from_attributes=True) - id: UUID = Field(..., description="Unique identifier for the pull request") - number: int = Field(..., description="Pull request number") - repository_id: UUID = Field(..., description="ID of the repository this pull request belongs to") - created_at: datetime = Field(..., description="When the pull request was created") - updated_at: datetime = Field(..., description="When the pull request was last updated") \ No newline at end of file + id: UUID = Field(..., description="Unique identifier for the pull request.") + number: int = Field(..., description="Sequential number of the pull request within the repository.") + repository_id: UUID = Field(..., description="UUID of the repository this pull request belongs to.") + # Assuming 'user_id' or 'author_id' might be relevant for who created the PR, if not implicitly the authenticated user + # For now, sticking to the existing model structure. + created_at: datetime = Field(..., description="Timestamp of when the pull request was created (UTC).") + updated_at: datetime = Field(..., description="Timestamp of when the pull request was last updated (UTC).") \ No newline at end of file diff --git a/git0/models/pull_request_comment.py b/git0/models/pull_request_comment.py new file mode 100644 index 0000000..fd0d0d6 --- /dev/null +++ b/git0/models/pull_request_comment.py @@ -0,0 +1,31 @@ +"""Pydantic models for Pull Request Comments.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +from pydantic import Field # Added Field import + +class PullRequestCommentBase(BaseModel): + """Base model for pull request comments, containing the main content.""" + body: str = Field(..., description="The content of the comment, can include Markdown.", example="This looks good, but consider refactoring the loop on line 42.") + + +class PullRequestCommentCreate(PullRequestCommentBase): + """Model for creating a new pull request comment.""" + user_id: UUID | None = Field(None, description="Optional UUID of the user posting the comment. If not provided, it might be inferred from authentication or be system-generated.", example="a1b2c3d4-e5f6-7890-1234-567890abcdef") + + +class PullRequestCommentRead(PullRequestCommentBase): + """Model for reading a pull request comment, including all database-generated fields.""" + id: UUID = Field(..., description="Unique identifier for the comment.") + pull_request_id: UUID = Field(..., description="UUID of the pull request this comment belongs to.") + user_id: UUID | None = Field(None, description="UUID of the user who posted the comment, if available.") + created_at: datetime = Field(..., description="Timestamp of when the comment was created (UTC).") + updated_at: datetime = Field(..., description="Timestamp of when the comment was last updated (UTC).") + + class Config: + orm_mode = True # Compatibility with SQLAlchemy models + from_attributes = True # Pydantic v2 alias for orm_mode diff --git a/git0/models/pull_request_review.py b/git0/models/pull_request_review.py new file mode 100644 index 0000000..90422a5 --- /dev/null +++ b/git0/models/pull_request_review.py @@ -0,0 +1,34 @@ +"""Pydantic models for Pull Request Reviews.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field # Added Field import +from typing import Optional # Added Optional import + +from git0.db.models.enums import PullRequestReviewState + + +class PullRequestReviewBase(BaseModel): + """Base model for pull request reviews, defining the core review content.""" + body: Optional[str] = Field(None, description="Optional text content of the review, can include Markdown.", example="LGTM! Just a few minor nits.") + state: PullRequestReviewState = Field(..., description="The state of the review (e.g., APPROVED, CHANGES_REQUESTED, COMMENTED).", example=PullRequestReviewState.APPROVED) + + +class PullRequestReviewCreate(PullRequestReviewBase): + """Model for creating a new pull request review.""" + user_id: Optional[UUID] = Field(None, description="Optional UUID of the user submitting the review. If not provided, it might be inferred from authentication.", example="a1b2c3d4-e5f6-7890-1234-567890abcdef") + + +class PullRequestReviewRead(PullRequestReviewBase): + """Model for reading a pull request review, including all database-generated fields.""" + id: UUID = Field(..., description="Unique identifier for the review.") + pull_request_id: UUID = Field(..., description="UUID of the pull request this review belongs to.") + user_id: Optional[UUID] = Field(None, description="UUID of the user who submitted the review, if available.") + created_at: datetime = Field(..., description="Timestamp of when the review was created (UTC).") + updated_at: datetime = Field(..., description="Timestamp of when the review was last updated (UTC).") + + class Config: + orm_mode = True + use_enum_values = True # Ensures that enum values (strings) are used in the schema and responses + from_attributes = True # Pydantic v2 alias for orm_mode diff --git a/git0/services/pull_request.py b/git0/services/pull_request.py index b316c5f..ed806ee 100644 --- a/git0/services/pull_request.py +++ b/git0/services/pull_request.py @@ -5,24 +5,34 @@ from sqlalchemy import and_, func, select from sqlalchemy.exc import IntegrityError +import zlib # Added import +import logging # Added import from sqlalchemy.ext.asyncio import AsyncSession from git0.core.exceptions import ConflictError, NotFoundError from git0.db.models.organization import Organization from git0.db.models.pull_request import PullRequest, PullRequestState, PullRequestUpdate +from git0.db.models.pull_request_comment import PullRequestComment +from git0.db.models.pull_request_review import PullRequestReview from git0.db.models.repository import Repository +from git0.models.pull_request_comment import PullRequestCommentCreate, PullRequestCommentRead +from git0.models.pull_request_review import PullRequestReviewCreate, PullRequestReviewRead +from git0.storage.s3 import S3Storage # Added import +logger = logging.getLogger(__name__) # Added logger class PullRequestService: """Service for managing pull requests.""" - def __init__(self, session: AsyncSession): + def __init__(self, session: AsyncSession, s3_storage: S3Storage): # Modified constructor """Initialize the pull request service. Args: session: Database session. + s3_storage: S3 storage instance. """ self.session = session + self.s3_storage = s3_storage # Added s3_storage async def create_pull_request( self, @@ -315,10 +325,506 @@ async def merge_pull_request( pull_request.state = PullRequestState.MERGED # Update updated_at timestamp - from datetime import datetime - pull_request.updated_at = datetime.now(UTC).replace(tzinfo=None) + from datetime import datetime # Keep this import + + # --- Start of Git Merge Logic --- + source_head_sha = await self._get_branch_head_sha( + organization_name, repository_name, pull_request.source_branch + ) + target_head_sha = await self._get_branch_head_sha( + organization_name, repository_name, pull_request.target_branch + ) + + if not source_head_sha: + raise ConflictError(f"Could not resolve source branch '{pull_request.source_branch}' to a commit SHA.") + if not target_head_sha: + raise ConflictError(f"Could not resolve target branch '{pull_request.target_branch}' to a commit SHA.") + + # Find merge base + # Simplified approach: get ancestors of target, then walk source. + # Max depth to prevent excessive S3 calls in complex histories. + max_ancestor_depth = 50 + target_ancestors = {target_head_sha} + queue = [(target_head_sha, 0)] + visited_target = {target_head_sha} + + while queue: + current_sha, depth = queue.pop(0) + if depth >= max_ancestor_depth: + continue + parents = await self._get_commit_parents(organization_name, repository_name, current_sha) + for parent_sha in parents: + if parent_sha not in visited_target: + target_ancestors.add(parent_sha) + visited_target.add(parent_sha) + queue.append((parent_sha, depth + 1)) - await self.session.commit() - await self.session.refresh(pull_request) + merge_base_sha = None + source_queue = [(source_head_sha, 0)] + visited_source = {source_head_sha} + + while source_queue: + current_sha, depth = source_queue.pop(0) + if current_sha in target_ancestors: + merge_base_sha = current_sha + logger.info(f"Merge base for PR #{number} ('{pull_request.source_branch}' -> '{pull_request.target_branch}') is {merge_base_sha}") + break + if depth >= max_ancestor_depth: # Also limit source traversal + continue + parents = await self._get_commit_parents(organization_name, repository_name, current_sha) + for parent_sha in parents: + if parent_sha not in visited_source: + visited_source.add(parent_sha) + source_queue.append((parent_sha, depth + 1)) + + if not merge_base_sha: + raise ConflictError( + f"Could not find a common ancestor for branches '{pull_request.source_branch}' and " + f"'{pull_request.target_branch}' within {max_ancestor_depth} commits." + ) + + # Check for fast-forward merge + if merge_base_sha == target_head_sha: + # This is a fast-forward merge + logger.info(f"PR #{number} is a fast-forward merge. Updating target branch '{pull_request.target_branch}' to {source_head_sha}.") + + target_ref_path = f"{organization_name}/{repository_name}/refs/heads/{pull_request.target_branch}" + try: + await self.s3_storage.put_object_content(target_ref_path, source_head_sha.encode('utf-8')) + except Exception as e: + logger.error(f"Failed to update target branch ref '{target_ref_path}' in S3: {e}") + raise ConflictError(f"Failed to update target branch in S3 during fast-forward merge: {e}") + + pull_request.state = PullRequestState.MERGED + pull_request.merge_commit_sha = source_head_sha # Will add this field to the model + pull_request.updated_at = datetime.now(UTC).replace(tzinfo=None) + + await self.session.commit() + await self.session.refresh(pull_request) + logger.info(f"PR #{number} successfully fast-forward merged.") + return pull_request + else: + # For non-fast-forward, if merge_base_sha == source_head_sha, target is ahead of source. No merge needed, PR could be closed. + # Or, if they diverged, a real merge (creating a merge commit) would be needed. + if merge_base_sha == source_head_sha: # Target is ahead of source + logger.info(f"Target branch '{pull_request.target_branch}' is already ahead of source branch '{pull_request.source_branch}'. No merge needed.") + # Potentially close the PR or mark as merged if desired, but for now, this is a no-op from merge perspective. + # Depending on product requirements, this might be an error or just an indication that the PR is "up-to-date". + # For now, let's treat it as if the merge is trivially completed by target being ahead. + # However, typically a PR wouldn't be merged if target is already ahead, it would just be closed. + # For this implementation, we will raise a conflict to indicate this state clearly. + raise ConflictError(f"Target branch '{pull_request.target_branch}' is already ahead of or identical to source branch '{pull_request.source_branch}'. No merge action taken.") + + logger.info(f"PR #{number} is not a fast-forward merge. Attempting three-way merge. Merge base: {merge_base_sha}, Source: {source_head_sha}, Target: {target_head_sha}") + + # Get tree SHAs for all three commits + source_tree_sha = await self._get_commit_tree_sha(organization_name, repository_name, source_head_sha) + target_tree_sha = await self._get_commit_tree_sha(organization_name, repository_name, target_head_sha) + base_tree_sha = await self._get_commit_tree_sha(organization_name, repository_name, merge_base_sha) + + if not source_tree_sha or not target_tree_sha or not base_tree_sha: + raise ConflictError("Could not retrieve tree SHAs for source, target, or merge base commits.") + + # Get tree entries + source_entries = await self._get_tree_entries(organization_name, repository_name, source_tree_sha) + target_entries = await self._get_tree_entries(organization_name, repository_name, target_tree_sha) + base_entries = await self._get_tree_entries(organization_name, repository_name, base_tree_sha) + + merged_tree_entries: dict[str, tuple[str, str]] = {} + conflicts_found = False + + all_entry_names = set(source_entries.keys()) | set(target_entries.keys()) | set(base_entries.keys()) + + for name in all_entry_names: + s_entry = source_entries.get(name) + t_entry = target_entries.get(name) + b_entry = base_entries.get(name) + + s_sha = s_entry[1] if s_entry else None + t_sha = t_entry[1] if t_entry else None + b_sha = b_entry[1] if b_entry else None + + s_mode = s_entry[0] if s_entry else None + t_mode = t_entry[0] if t_entry else None + # b_mode = b_entry[0] if b_entry else None + + + if s_sha == t_sha: # No change or same change in both + if s_entry: merged_tree_entries[name] = s_entry + elif t_entry: merged_tree_entries[name] = t_entry # Should be same if shas are same + # If both None, it means it was deleted in both (or never existed), so not in merged. + elif s_sha == b_sha: # Source didn't change, target did (or target deleted it) + if t_entry: merged_tree_entries[name] = t_entry # Take target's change (incl. deletion if t_entry is None) + # If t_entry is None, it means target deleted it, so it's not added to merged_tree_entries + elif t_sha == b_sha: # Target didn't change, source did + if s_entry: merged_tree_entries[name] = s_entry # Take source's change + # If s_entry is None, source deleted it + else: # All three are different, or one changed and the other changed too (s_sha != b_sha and t_sha != b_sha) + # or one deleted and other modified, or added differently. This is a conflict. + logger.warning( + f"Conflict detected for entry '{name}' in PR #{number}. " + f"Base: {b_sha}, Source: {s_sha}, Target: {t_sha}" + ) + conflicts_found = True + break + + if conflicts_found: + raise ConflictError(f"Automatic merge for PR #{number} failed due to conflicting changes. Please resolve conflicts manually.") + + # Write the new merged tree + new_merged_tree_sha = await self._write_tree_object(organization_name, repository_name, merged_tree_entries) + + # Create the merge commit + commit_message = f"Merge pull request #{number} from {pull_request.source_branch}\n\nMerge commit for PR #{number}" + # Order of parents: first parent is the target branch, second is the source branch being merged. + new_merge_commit_sha = await self._write_commit_object( + org_name=organization_name, repo_name=repository_name, + tree_sha=new_merged_tree_sha, + parent_shas=[target_head_sha, source_head_sha], + message=commit_message + ) + + # Update target branch ref + target_ref_path = f"{organization_name}/{repository_name}/refs/heads/{pull_request.target_branch}" + try: + await self.s3_storage.put_object_content(target_ref_path, new_merge_commit_sha.encode('utf-8')) + except Exception as e: + logger.error(f"Failed to update target branch ref '{target_ref_path}' in S3 for merge commit: {e}") + raise ConflictError(f"Failed to update target branch in S3 after creating merge commit: {e}") + + pull_request.state = PullRequestState.MERGED + pull_request.merge_commit_sha = new_merge_commit_sha + pull_request.updated_at = datetime.now(UTC).replace(tzinfo=None) + + await self.session.commit() + await self.session.refresh(pull_request) + logger.info(f"PR #{number} successfully three-way merged. New merge commit: {new_merge_commit_sha}") + return pull_request + # --- End of Git Merge Logic --- + + async def add_comment_to_pull_request( + self, + organization_name: str, + repository_name: str, + pr_number: int, + comment_create: PullRequestCommentCreate, + user_id: UUID | None = None, # Assuming user_id might come from auth in the future + ) -> PullRequestCommentRead: + """Add a comment to a pull request. + + Args: + organization_name: Organization name + repository_name: Repository name + pr_number: Pull request number + comment_create: Pydantic model for comment creation + user_id: Optional ID of the user creating the comment + + Returns: + The created pull request comment as a Pydantic model. + + Raises: + NotFoundError: If the pull request is not found. + """ + # Get the pull request + pull_request = await self.get_pull_request( + organization_name=organization_name, + repository_name=repository_name, + number=pr_number, + ) + + # Create the pull request comment instance + db_comment = PullRequestComment( + body=comment_create.body, + pull_request_id=pull_request.id, + user_id=user_id if user_id else comment_create.user_id, # Use provided user_id or from create model + ) + + self.session.add(db_comment) + try: + await self.session.commit() + await self.session.refresh(db_comment) + except IntegrityError as e: # Catch potential issues like foreign key violations + await self.session.rollback() + # Log the error e + raise ConflictError(f"Could not add comment to PR #{pr_number} due to a database conflict.") from e + + # Convert to Pydantic model for response + # Ensure all necessary fields are present in db_comment for PullRequestCommentRead + return PullRequestCommentRead.model_validate(db_comment) + + async def add_review_to_pull_request( + self, + organization_name: str, + repository_name: str, + pr_number: int, + review_create: PullRequestReviewCreate, + user_id: UUID | None = None, # Assuming user_id might come from auth in the future + ) -> PullRequestReviewRead: + """Add a review to a pull request. + + Args: + organization_name: Organization name + repository_name: Repository name + pr_number: Pull request number + review_create: Pydantic model for review creation + user_id: Optional ID of the user creating the review + + Returns: + The created pull request review as a Pydantic model. + + Raises: + NotFoundError: If the pull request is not found. + ConflictError: If there's a database conflict. + """ + # Get the pull request + pull_request = await self.get_pull_request( + organization_name=organization_name, + repository_name=repository_name, + number=pr_number, + ) + + # Create the pull request review instance + db_review = PullRequestReview( + body=review_create.body, + state=review_create.state, # Ensure state is passed from Pydantic model + pull_request_id=pull_request.id, + user_id=user_id if user_id else review_create.user_id, # Use provided user_id or from create model + ) + + self.session.add(db_review) + try: + await self.session.commit() + await self.session.refresh(db_review) + except IntegrityError as e: # Catch potential issues like foreign key violations + await self.session.rollback() + # Log the error e + raise ConflictError(f"Could not add review to PR #{pr_number} due to a database conflict.") from e + + # Convert to Pydantic model for response + return PullRequestReviewRead.model_validate(db_review) + + async def _get_branch_head_sha( + self, org_name: str, repo_name: str, branch_name: str + ) -> str | None: + """Get the commit SHA for the head of a branch from S3.""" + # Note: S3Storage._sanitize_key might affect how branch names are stored if they contain slashes. + # Assuming branch_name is the plain name like 'main' or 'feature/xyz' + # The S3 key for refs/heads/ needs to be constructed carefully. + # S3Storage._sanitize_key is more about general object key sanitization, + # but ref paths are specific. Standard Git ref paths are like 'refs/heads/main'. + ref_path = f"{org_name}/{repo_name}/refs/heads/{branch_name}" + try: + sha_bytes = await self.s3_storage.get_object_content_bytes(ref_path) + sha = sha_bytes.decode('utf-8').strip() + if len(sha) == 40 and all(c in "0123456789abcdef" for c in sha.lower()): + return sha + else: + logger.error(f"Invalid SHA '{sha}' found for branch '{branch_name}' in '{org_name}/{repo_name}'.") + return None + except NotFoundError: # Assuming get_object_content_bytes raises NotFoundError (or similar) + logger.warning(f"Branch '{branch_name}' not found in S3 for '{org_name}/{repo_name}' at path '{ref_path}'.") + return None + except Exception as e: + logger.error(f"Error fetching SHA for branch '{branch_name}' in '{org_name}/{repo_name}': {e}") + return None + + async def _get_commit_parents( + self, org_name: str, repo_name: str, commit_sha: str + ) -> list[str]: + """Fetch a commit object from S3 and parse its parent SHAs.""" + if not commit_sha or len(commit_sha) != 40: + logger.error(f"Invalid commit_sha provided: {commit_sha}") + return [] + + obj_s3_path = f"{org_name}/{repo_name}/objects/{commit_sha[:2]}/{commit_sha[2:]}" + parents = [] + try: + compressed_data = await self.s3_storage.get_object_content_bytes(obj_s3_path) + obj_content_inflated = zlib.decompress(compressed_data) + + # The commit object format is: "commit \0" + # Content includes tree, parent(s), author, committer, message. + header_end_idx = obj_content_inflated.find(b'\x00') + if header_end_idx == -1: + logger.warning(f"Invalid Git object {commit_sha} at {obj_s3_path}: Missing null terminator in header.") + return [] + + # Skip header, decode the rest as UTF-8. It might fail for arbitrary binary data, but commit messages are usually UTF-8. + commit_data_start = header_end_idx + 1 + commit_text = obj_content_inflated[commit_data_start:].decode('utf-8', errors='replace') + + lines = commit_text.split('\n') + for line in lines: + if line.startswith("parent "): + parts = line.split(" ", 1) + if len(parts) == 2 and len(parts[1]) == 40: + parents.append(parts[1]) + elif not line.strip(): # Empty line signifies end of commit headers (tree, parents, author, committer) + break + logger.debug(f"Parents for commit {commit_sha}: {parents}") + return parents + except NotFoundError: + logger.warning(f"Commit object not found in S3 for SHA {commit_sha} at path {obj_s3_path}.") + return [] + except zlib.error as e: + logger.error(f"Zlib decompression error for SHA {commit_sha} at {obj_s3_path}: {e}") + return [] + except UnicodeDecodeError as e: + logger.error(f"Unicode decode error processing commit {commit_sha}: {e}") + return [] + except Exception as e: + logger.error(f"Unexpected error processing commit SHA {commit_sha} at {obj_s3_path}: {type(e).__name__} - {e}") + return [] + + async def _get_commit_tree_sha(self, org_name: str, repo_name: str, commit_sha: str) -> str | None: + """Fetch a commit object and parse its tree SHA.""" + if not commit_sha or len(commit_sha) != 40: + logger.error(f"Invalid commit_sha for getting tree SHA: {commit_sha}") + return None + + obj_s3_path = f"{org_name}/{repo_name}/objects/{commit_sha[:2]}/{commit_sha[2:]}" + try: + compressed_data = await self.s3_storage.get_object_content_bytes(obj_s3_path) + obj_content_inflated = zlib.decompress(compressed_data) + header_end_idx = obj_content_inflated.find(b'\x00') + if header_end_idx == -1: + logger.warning(f"Invalid Git commit object {commit_sha} at {obj_s3_path}: Missing null terminator.") + return None + + commit_data_start = header_end_idx + 1 + commit_text = obj_content_inflated[commit_data_start:].decode('utf-8', errors='replace') + lines = commit_text.split('\n') + for line in lines: + if line.startswith("tree "): + parts = line.split(" ", 1) + if len(parts) == 2 and len(parts[1]) == 40: + return parts[1] + logger.warning(f"Tree SHA not found in commit {commit_sha}.") + return None + except NotFoundError: + logger.warning(f"Commit object not found in S3 for SHA {commit_sha} (for tree SHA) at path {obj_s3_path}.") + return None + except Exception as e: + logger.error(f"Error getting tree SHA for commit {commit_sha}: {e}") + return None + + async def _get_tree_entries(self, org_name: str, repo_name: str, tree_sha: str) -> dict[str, tuple[str, str]]: + """Fetches and parses a tree object from S3, returning a dict of name -> (mode, sha).""" + if not tree_sha or len(tree_sha) != 40: + logger.error(f"Invalid tree_sha: {tree_sha}") + return {} + + obj_s3_path = f"{org_name}/{repo_name}/objects/{tree_sha[:2]}/{tree_sha[2:]}" + entries = {} + try: + compressed_data = await self.s3_storage.get_object_content_bytes(obj_s3_path) + data = zlib.decompress(compressed_data) + + # Format: "tree \0..." + # Entry: " \0<20_byte_sha>" + header_end = data.find(b'\0') + if header_end == -1: + logger.error(f"Malformed tree object {tree_sha}: no null byte in header.") + return {} + + ptr = header_end + 1 + while ptr < len(data): + mode_end = data.find(b' ', ptr) + if mode_end == -1: break + mode = data[ptr:mode_end].decode('ascii') + + name_end = data.find(b'\0', mode_end + 1) + if name_end == -1: break + name = data[mode_end + 1:name_end].decode('utf-8', errors='replace') # Path names can be UTF-8 + + sha_start = name_end + 1 + sha_end = sha_start + 20 # SHA-1 is 20 bytes in binary + if sha_end > len(data): break + sha_hex = data[sha_start:sha_end].hex() + + entries[name] = (mode, sha_hex) + ptr = sha_end + return entries + except NotFoundError: + logger.warning(f"Tree object not found in S3 for SHA {tree_sha} at path {obj_s3_path}.") + return {} + except Exception as e: + logger.error(f"Error parsing tree {tree_sha}: {e}") + return {} + + async def _write_tree_object(self, org_name: str, repo_name: str, entries: dict[str, tuple[str, str]]) -> str: + """Constructs, compresses, SHAs, and writes a tree object to S3. Returns the tree's SHA.""" + from io import BytesIO + import hashlib + + tree_buffer = BytesIO() + # Entries must be sorted by name for canonical tree representation + for name, (mode, sha_hex) in sorted(entries.items()): + sha_bytes = bytes.fromhex(sha_hex) + # Mode needs to be ascii. Name needs to be utf-8 then encoded. + tree_buffer.write(f"{mode} ".encode('ascii')) + tree_buffer.write(name.encode('utf-8')) + tree_buffer.write(b'\0') + tree_buffer.write(sha_bytes) + + tree_content_unformatted = tree_buffer.getvalue() + + # Format: "tree \0" + header = f"tree {len(tree_content_unformatted)}\0".encode('ascii') + full_tree_data = header + tree_content_unformatted + + new_tree_sha = hashlib.sha1(full_tree_data).hexdigest() + compressed_tree_data = zlib.compress(full_tree_data) + + obj_s3_path = f"{org_name}/{repo_name}/objects/{new_tree_sha[:2]}/{new_tree_sha[2:]}" + await self.s3_storage.put_object_content(obj_s3_path, compressed_tree_data) + logger.info(f"Written new tree object {new_tree_sha} to {obj_s3_path}") + return new_tree_sha + + async def _write_commit_object( + self, org_name: str, repo_name: str, tree_sha: str, parent_shas: list[str], + message: str, # Simplified author/committer for now + ) -> str: + """Constructs, compresses, SHAs, and writes a commit object to S3. Returns the commit's SHA.""" + import hashlib + from datetime import datetime, timezone, timedelta # For timezone + + # Using fixed author/committer for simplicity as per instructions + # In a real system, this would come from the user session or PR author. + author_name = "Git0 Service" + author_email = "service@git0.com" + committer_name = "Git0 Service" + committer_email = "service@git0.com" + + # Git specific timestamp format: + # Example: 1678886400 +0000 + now = datetime.now(timezone.utc) + timestamp = int(now.timestamp()) + offset_seconds = now.utcoffset().total_seconds() if now.utcoffset() else 0 + offset_hours = int(offset_seconds // 3600) + offset_minutes = int((offset_seconds % 3600) // 60) + timezone_offset_str = f"{offset_hours:+03d}{offset_minutes:02d}" + + git_date_str = f"{timestamp} {timezone_offset_str}" + + commit_lines = [] + commit_lines.append(f"tree {tree_sha}") + for parent_sha in parent_shas: + commit_lines.append(f"parent {parent_sha}") + commit_lines.append(f"author {author_name} <{author_email}> {git_date_str}") + commit_lines.append(f"committer {committer_name} <{committer_email}> {git_date_str}") + commit_lines.append("") # Empty line before message + commit_lines.append(message) + + commit_content_unformatted = "\n".join(commit_lines).encode('utf-8') + + header = f"commit {len(commit_content_unformatted)}\0".encode('ascii') + full_commit_data = header + commit_content_unformatted + + new_commit_sha = hashlib.sha1(full_commit_data).hexdigest() + compressed_commit_data = zlib.compress(full_commit_data) - return pull_request \ No newline at end of file + obj_s3_path = f"{org_name}/{repo_name}/objects/{new_commit_sha[:2]}/{new_commit_sha[2:]}" + await self.s3_storage.put_object_content(obj_s3_path, compressed_commit_data) + logger.info(f"Written new commit object {new_commit_sha} to {obj_s3_path}") + return new_commit_sha \ No newline at end of file diff --git a/git0/services/repository.py b/git0/services/repository.py index 7846385..be5532a 100644 --- a/git0/services/repository.py +++ b/git0/services/repository.py @@ -19,16 +19,23 @@ logger = get_logger(__name__) +from git0.storage.s3 import S3Storage # Added import +from git0.models.branch import BranchRead # Added import +from git0.storage.models import StorageObject # Added import + + class RepositoryService: """Service for managing repositories.""" - def __init__(self, session: AsyncSession): + def __init__(self, session: AsyncSession, s3_storage: S3Storage): # Modified constructor """Initialize repository service. Args: session: Database session. + s3_storage: S3 storage instance. """ self.session = session + self.s3_storage = s3_storage # Added s3_storage async def create_repository( self, @@ -442,4 +449,134 @@ async def _cache_repository( organization_name, repository_name, repo_data, ttl=600 ) except Exception as e: - logger.error(f"Failed to cache repository: {e}") \ No newline at end of file + logger.error(f"Failed to cache repository: {e}") + + async def list_branches(self, organization_name: str, repository_name: str) -> list[BranchRead]: + """List branches in a repository by reading refs from S3.""" + branches = [] + prefix = f"{organization_name}/{repository_name}/refs/heads/" + try: + s3_objects: list[StorageObject] = await self.s3_storage.list_objects(prefix) + for s3_obj in s3_objects: + # s3_obj.object_key is already desanitized by S3Storage.list_objects + # Example key: "org/repo/refs/heads/main" or "org/repo/refs/heads/feature/foo" + if not s3_obj.object_key.startswith(prefix): + logger.warning(f"Unexpected object key '{s3_obj.object_key}' listed with prefix '{prefix}'") + continue + + branch_name_full = s3_obj.object_key[len(prefix):] # "main" or "feature/foo" + + try: + commit_sha_bytes = await self.s3_storage.get_object_content_bytes(s3_obj.object_key) + commit_sha = commit_sha_bytes.decode('utf-8').strip() + if len(commit_sha) == 40 and all(c in "0123456789abcdef" for c in commit_sha.lower()): + branches.append(BranchRead(name=branch_name_full, commit_sha=commit_sha)) + else: + logger.error(f"Invalid SHA '{commit_sha}' found for branch ref '{s3_obj.object_key}'.") + except NotFoundError: + logger.warning(f"Ref file '{s3_obj.object_key}' found in list but content not found (possibly deleted during listing).") + except Exception as e_inner: + logger.error(f"Error reading content for ref '{s3_obj.object_key}': {e_inner}") + + except Exception as e: + logger.error(f"Error listing branches for {organization_name}/{repository_name} from S3: {e}") + # Depending on desired behavior, could raise an exception or return empty list + # For now, returning empty list on general S3 error during listing. + return [] + return branches + + async def get_branch(self, organization_name: str, repository_name: str, branch_name: str) -> BranchRead: + """Get a specific branch from a repository by reading its ref from S3.""" + # branch_name can be "main" or "feature/foo" + ref_path = f"{organization_name}/{repository_name}/refs/heads/{branch_name}" + try: + commit_sha_bytes = await self.s3_storage.get_object_content_bytes(ref_path) + commit_sha = commit_sha_bytes.decode('utf-8').strip() + + if not (len(commit_sha) == 40 and all(c in "0123456789abcdef" for c in commit_sha.lower())): + logger.error(f"Invalid SHA '{commit_sha}' for branch '{branch_name}' at path '{ref_path}'.") + raise NotFoundError(f"Branch '{branch_name}' has invalid SHA content.") + + return BranchRead(name=branch_name, commit_sha=commit_sha) + except NotFoundError: # Re-raise service specific NotFoundError + logger.warning(f"Branch '{branch_name}' not found at S3 path '{ref_path}'.") + raise NotFoundError(f"Branch '{branch_name}' not found in repository '{organization_name}/{repository_name}'.") + except Exception as e: + logger.error(f"Error fetching branch '{branch_name}' for '{organization_name}/{repository_name}': {e}") + # For other unexpected errors, might be better to raise a generic server error or a specific service error + raise ConflictError(f"Could not retrieve branch '{branch_name}' due to an unexpected error.") + + async def _commit_exists(self, organization_name: str, repository_name: str, commit_sha: str) -> bool: + """Check if a commit object exists in S3.""" + if not (len(commit_sha) == 40 and all(c in "0123456789abcdef" for c in commit_sha.lower())): + # This check is more for internal robustness, source_sha in BranchCreate is already validated. + logger.warning(f"Invalid commit SHA format for _commit_exists: {commit_sha}") + return False + + commit_obj_path = f"{organization_name}/{repository_name}/objects/{commit_sha[:2]}/{commit_sha[2:]}" + try: + await self.s3_storage.head_object(object_key=commit_obj_path) + return True + except NotFoundError: # Assuming head_object or its callers convert S3 404 to service's NotFoundError + return False + except Exception as e: # Catching generic exception for robustness, could be more specific + logger.error(f"Error checking existence of commit {commit_sha} at {commit_obj_path}: {e}") + return False # Or re-raise as a different error if appropriate + + async def create_branch(self, organization_name: str, repository_name: str, branch_create: BranchCreate) -> BranchRead: + """Create a new branch by writing its ref to S3.""" + # BranchCreate model already validates name and source_sha format. + + branch_ref_path = f"{organization_name}/{repository_name}/refs/heads/{branch_create.name}" + + # Check if branch already exists + try: + await self.s3_storage.head_object(object_key=branch_ref_path) + # If head_object succeeds, the branch ref already exists. + raise ConflictError(f"Branch '{branch_create.name}' already exists in repository '{organization_name}/{repository_name}'.") + except NotFoundError: + # This is expected: the branch should not exist. + pass + except Exception as e: # Catch other potential errors from head_object + logger.error(f"Unexpected error checking branch existence {branch_ref_path}: {e}") + raise ConflictError(f"Could not verify branch '{branch_create.name}' existence due to an unexpected error.") + + + # Verify source_sha commit object exists + if not await self._commit_exists(organization_name, repository_name, branch_create.source_sha): + raise NotFoundError(f"Source commit SHA '{branch_create.source_sha}' not found in repository '{organization_name}/{repository_name}'.") + + # Create the branch ref file with the source_sha as content + try: + await self.s3_storage.put_object_content( + object_key=branch_ref_path, + content=branch_create.source_sha.encode('utf-8') + ) + logger.info(f"Branch '{branch_create.name}' created at '{branch_ref_path}' pointing to '{branch_create.source_sha}'.") + except Exception as e: + logger.error(f"Failed to create branch '{branch_create.name}' in S3: {e}") + raise ConflictError(f"Could not create branch '{branch_create.name}' due to an unexpected storage error.") + + return BranchRead(name=branch_create.name, commit_sha=branch_create.source_sha) + + async def delete_branch(self, organization_name: str, repository_name: str, branch_name: str) -> None: + """Delete a branch by removing its ref file from S3.""" + branch_ref_path = f"{organization_name}/{repository_name}/refs/heads/{branch_name}" + + # Check if branch exists before attempting delete + try: + await self.s3_storage.head_object(object_key=branch_ref_path) + except NotFoundError: + raise NotFoundError(f"Branch '{branch_name}' not found in repository '{organization_name}/{repository_name}'. Cannot delete.") + except Exception as e: # Catch other potential errors + logger.error(f"Error checking existence of branch {branch_ref_path} before deletion: {e}") + raise ConflictError(f"Could not verify branch '{branch_name}' for deletion due to an unexpected error.") + + # Delete the branch ref file + try: + await self.s3_storage.delete_object(object_key=branch_ref_path) + logger.info(f"Branch '{branch_name}' deleted from '{branch_ref_path}'.") + except Exception as e: + logger.error(f"Failed to delete branch '{branch_name}' from S3: {e}") + # Depending on S3Storage.delete_object behavior, might not need to catch if it's resilient + raise ConflictError(f"Could not delete branch '{branch_name}' due to an unexpected storage error.") \ No newline at end of file diff --git a/sdk/python/README.md b/sdk/python/README.md index 8022f22..be4584a 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -7,8 +7,9 @@ The Git0 SDK provides a Python interface to the Git0 API, enabling developers to - Create and manage organizations and repositories - Clone and initialize Git repositories - Manage issues and pull requests -- Perform file operations (create, read, update, delete) - Review and comment on pull requests +- Manage branches (list, get, create, delete) +- Perform file operations (create, read, update, delete) - Automate Git workflows and CI/CD processes ## Installation @@ -71,6 +72,7 @@ We provide several examples to demonstrate how to use the Git0 SDK for different 2. [Collaborative Workflow](examples/collaborate_workflow.py) - Complete Git workflow between team members 3. [CI/CD Integration](examples/cicd_integration.py) - Integration with CI/CD systems 4. [Repository Automation](examples/automation_example.py) - Automate tasks across repositories +5. [Common Workflows](examples/workflows.py) - Demonstrates branch management and pull request lifecycles ### Asynchronous Support @@ -97,18 +99,36 @@ The SDK provides specific exception classes for handling different error cases: from git0_sdk import ( APIError, AuthenticationError, - NotFoundError, - OrganizationExistsError, - RepositoryExistsError, - ConflictError + NotFoundError, + OrganizationExistsError, + RepositoryExistsError, + ConflictError, + # New specific exceptions + BranchNotFound, + PullRequestNotFound, + IssueNotFound, + CommitNotFound, + BranchAlreadyExists, + InvalidRefName, + MergeConflict, + GitRemoteError # GitRemoteError was already there but good to list it comprehensively ) try: - client.create_org(name="my-org", description="My Organization") -except OrganizationExistsError: - print("Organization already exists") + # Example: Creating a branch that already exists + client.create_branch(org_name="my-org", repo_name="my-repo", name="main", source_sha="some-valid-sha") +except BranchAlreadyExists as e: + print(f"Error: {e}") +except CommitNotFound as e: + print(f"Error: Source commit not found - {e}") +except InvalidRefName as e: + print(f"Error: Invalid branch name provided - {e}") except AuthenticationError: print("Invalid API key") +except NotFoundError: + print("A required resource was not found.") +except APIError as e: + print(f"A general API error occurred: {e}") ``` ## License diff --git a/sdk/python/docs/api_reference.md b/sdk/python/docs/api_reference.md index 497d0df..9184daa 100644 --- a/sdk/python/docs/api_reference.md +++ b/sdk/python/docs/api_reference.md @@ -526,7 +526,7 @@ result = client.merge_pull_request( - `repo_name` (str): Repository identifier - `pr_number` (int): Pull request number -**Returns:** Merge result dict +**Returns:** `PullRequestSDK` object (Pydantic model) with updated state and `merge_commit_sha` if successful. **Exceptions:** - `NotFoundError`: If the pull request doesn't exist @@ -545,7 +545,7 @@ result = await client.merge_pull_request_async( ``` **Parameters:** Same as synchronous version -**Returns:** PullRequest object (Pydantic model) with updated state +**Returns:** `PullRequestSDK` object (Pydantic model) with updated state and `merge_commit_sha` if successful. **Exceptions:** Same as synchronous version ### Get Pull Request Diff @@ -594,22 +594,22 @@ comment = client.add_pull_request_comment( repo_name="my-repo", pr_number=1, body="This looks good, but could you fix the formatting?", - commit_id="abcdef123456", # Optional - path="src/main.py", # Optional - position=10 # Optional + org_name="my-org", + repo_name="my-repo", + pr_number=1, + body="This looks good, but could you fix the formatting?", + user_id="a1b2c3d4-e5f6-7890-1234-567890abcdef" # Optional: UUID of the user ) ``` **Parameters:** -- `org_name` (str): Organization identifier -- `repo_name` (str): Repository identifier -- `pr_number` (int): Pull request number -- `body` (str): Comment text -- `commit_id` (str, optional): Commit SHA -- `path` (str, optional): File path for line comments -- `position` (int, optional): Line position for line comments +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. +- `pr_number` (int): Pull request number. +- `body` (str): The text content of the comment. +- `user_id` (UUID, optional): The UUID of the user posting the comment. If not provided, it might be inferred by the server or be anonymous depending on API setup. -**Returns:** Comment dict with details +**Returns:** `PullRequestCommentSDK` object with comment details. **Exceptions:** - `NotFoundError`: If the pull request doesn't exist @@ -624,14 +624,16 @@ comment = await client.add_pull_request_comment_async( repo_name="my-repo", pr_number=1, body="This looks good, but could you fix the formatting?", - commit_id="abcdef123456", # Optional - path="src/main.py", # Optional - position=10 # Optional + org_name="my-org", + repo_name="my-repo", + pr_number=1, + body="This looks good, but could you fix the formatting?", + user_id="a1b2c3d4-e5f6-7890-1234-567890abcdef" # Optional: UUID of the user ) ``` -**Parameters:** Same as synchronous version -**Returns:** PullRequestComment object (Pydantic model) +**Parameters:** Same as synchronous version. +**Returns:** `PullRequestCommentSDK` object (Pydantic model). **Exceptions:** Same as synchronous version ### Create a Pull Request Review @@ -641,27 +643,24 @@ review = client.create_pull_request_review( org_name="my-org", repo_name="my-repo", pr_number=1, - body="Overall looks good with a few comments.", - event="APPROVE", # APPROVE, REQUEST_CHANGES, COMMENT - comments=[ - { - "body": "Fix this method name", - "path": "src/main.py", - "position": 23 - } - ] + org_name="my-org", + repo_name="my-repo", + pr_number=1, + event="APPROVED", # e.g., "APPROVED", "REQUEST_CHANGES", "COMMENTED" + body="Overall looks good with a few comments.", # Optional + user_id="a1b2c3d4-e5f6-7890-1234-567890abcdef" # Optional: UUID of the user ) ``` **Parameters:** -- `org_name` (str): Organization identifier -- `repo_name` (str): Repository identifier -- `pr_number` (int): Pull request number -- `body` (str, optional): Review text -- `event` (str, optional): Review action, default is "COMMENT" -- `comments` (list, optional): List of inline comments +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. +- `pr_number` (int): Pull request number. +- `event` (str): The review event type (e.g., "APPROVED", "REQUEST_CHANGES", "COMMENTED"). This maps to the `state` field in the API. +- `body` (str, optional): The main text content of the review. +- `user_id` (UUID, optional): The UUID of the user submitting the review. -**Returns:** Review dict with details +**Returns:** `PullRequestReviewSDK` object with review details. **Exceptions:** - `NotFoundError`: If the pull request doesn't exist @@ -675,20 +674,17 @@ review = await client.create_pull_request_review_async( org_name="my-org", repo_name="my-repo", pr_number=1, - body="Overall looks good with a few comments.", - event="APPROVE", # APPROVE, REQUEST_CHANGES, COMMENT - comments=[ - { - "body": "Fix this method name", - "path": "src/main.py", - "position": 23 - } - ] + org_name="my-org", + repo_name="my-repo", + pr_number=1, + event="APPROVED", # e.g., "APPROVED", "REQUEST_CHANGES", "COMMENTED" + body="Overall looks good with a few comments.", # Optional + user_id="a1b2c3d4-e5f6-7890-1234-567890abcdef" # Optional: UUID of the user ) ``` -**Parameters:** Same as synchronous version -**Returns:** PullRequestReview object (Pydantic model) +**Parameters:** Same as synchronous version. +**Returns:** `PullRequestReviewSDK` object (Pydantic model). **Exceptions:** Same as synchronous version ## Models @@ -699,10 +695,11 @@ The SDK provides Pydantic models for working with API responses in a typed manne - `Repository`: Repository details - `Issue`: Issue details - `PullRequest`: Pull request details -- `PullRequestComment`: Comment on a pull request -- `PullRequestReview`: Review of a pull request +- `PullRequestCommentSDK`: Comment on a pull request +- `PullRequestReviewSDK`: Review of a pull request +- `BranchSDK`: Branch details (name and commit SHA) -When using the async methods, these models are returned instead of dictionaries. +When using the async methods, these models are returned instead of dictionaries (unless specified otherwise for simple types like `str` or `None`). ## Exception Handling @@ -735,4 +732,145 @@ except APIError as e: - `ConflictError`: Resource conflict - `OrganizationExistsError`: Organization already exists - `RepositoryExistsError`: Repository already exists -- `GitRemoteError`: Error in Git operations \ No newline at end of file +- `BranchNotFound(NotFoundError)`: Specific branch not found. +- `PullRequestNotFound(NotFoundError)`: Specific pull request not found. +- `IssueNotFound(NotFoundError)`: Specific issue not found. +- `CommitNotFound(NotFoundError)`: Specific commit (e.g., a `source_sha`) not found. +- `BranchAlreadyExists(ConflictError)`: Attempting to create a branch that already exists. +- `InvalidRefName(ValueError)`: An invalid Git reference name was provided. +- `MergeConflict(ConflictError)`: A Git merge operation failed due to actual merge conflicts. +- `GitRemoteError`: Error in Git operations + +## Branches + +### List Branches + +```python +branches = client.list_branches(org_name="my-org", repo_name="my-repo") +for branch in branches: + print(f"Branch: {branch.name}, Commit SHA: {branch.commit_sha}") +``` + +**Parameters:** +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. + +**Returns:** List of `BranchSDK` objects. + +**Exceptions:** +- `NotFoundError`: If the organization or repository doesn't exist. +- `APIError`: For other API errors. + +### List Branches (Async) + +```python +branches = await client.list_branches_async(org_name="my-org", repo_name="my-repo") +``` + +**Parameters:** Same as synchronous version. +**Returns:** List of `BranchSDK` objects. +**Exceptions:** Same as synchronous version. + +### Get a Branch + +```python +branch = client.get_branch(org_name="my-org", repo_name="my-repo", branch_name="main") +print(f"Branch: {branch.name}, Commit SHA: {branch.commit_sha}") +``` + +**Parameters:** +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. +- `branch_name` (str): Name of the branch (can include slashes, e.g., "feature/new-design"). + +**Returns:** `BranchSDK` object. + +**Exceptions:** +- `NotFoundError`: If the organization, repository, or branch doesn't exist. +- `APIError`: For other API errors. + +### Get a Branch (Async) + +```python +branch = await client.get_branch_async(org_name="my-org", repo_name="my-repo", branch_name="main") +``` + +**Parameters:** Same as synchronous version. +**Returns:** `BranchSDK` object. +**Exceptions:** Same as synchronous version. + +### Create a Branch + +```python +new_branch = client.create_branch( + org_name="my-org", + repo_name="my-repo", + name="feature/experimental", + source_sha="abcdef1234567890abcdef1234567890abcdef12" +) +print(f"Created branch: {new_branch.name} pointing to {new_branch.commit_sha}") +``` + +**Parameters:** +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. +- `name` (str): The desired name for the new branch. +- `source_sha` (str): The 40-character SHA of the commit from which the new branch will be created. + +**Returns:** `BranchSDK` object representing the newly created branch. + +**Exceptions:** +- `NotFoundError`: If the organization, repository, or source commit SHA doesn't exist. +- `ConflictError`: If the branch already exists. +- `APIError`: For other API errors (includes 422 for validation errors on name/SHA). + +### Create a Branch (Async) + +```python +new_branch = await client.create_branch_async( + org_name="my-org", + repo_name="my-repo", + name="feature/experimental-async", + source_sha="abcdef1234567890abcdef1234567890abcdef12" +) +``` + +**Parameters:** Same as synchronous version. +**Returns:** `BranchSDK` object. +**Exceptions:** Same as synchronous version. + +### Delete a Branch + +```python +client.delete_branch( + org_name="my-org", + repo_name="my-repo", + branch_name="feature/old-feature" +) +print("Branch deleted successfully.") +``` + +**Parameters:** +- `org_name` (str): Organization identifier. +- `repo_name` (str): Repository identifier. +- `branch_name` (str): Name of the branch to delete. + +**Returns:** `None`. + +**Exceptions:** +- `NotFoundError`: If the organization, repository, or branch doesn't exist. +- `APIError`: For other API errors. + +### Delete a Branch (Async) + +```python +await client.delete_branch_async( + org_name="my-org", + repo_name="my-repo", + branch_name="feature/old-feature-async" +) +``` + +**Parameters:** Same as synchronous version. +**Returns:** `None`. +**Exceptions:** Same as synchronous version. \ No newline at end of file diff --git a/sdk/python/examples/workflows.py b/sdk/python/examples/workflows.py new file mode 100644 index 0000000..d57837c --- /dev/null +++ b/sdk/python/examples/workflows.py @@ -0,0 +1,307 @@ +""" +Git0 Python SDK: Workflow Examples + +This file demonstrates common workflows using the Git0 Python SDK. +To run these examples, you'll need: +1. A running Git0 instance. +2. A valid API key for your Git0 instance. +3. The Git0 Python SDK installed (`pip install git0-sdk`). + +Replace `YOUR_GIT0_BASE_URL` and `YOUR_GIT0_API_KEY` with your actual credentials. +""" +import asyncio +import os +from uuid import uuid4 # For unique names + +from git0_sdk import ( + Git0Client, + APIError, + BranchAlreadyExists, + BranchNotFound, + CommitNotFound, + ConflictError, + NotFoundError, + PullRequestNotFound, + RepositoryExistsError, +) + +# --- Configuration --- +# Replace with your Git0 instance details +GIT0_BASE_URL = os.environ.get("GIT0_BASE_URL", "http://localhost:8000/api/v1") # Example +GIT0_API_KEY = os.environ.get("GIT0_API_KEY", "your_secret_api_key") # Example + +# --- Helper for Unique Names --- +def generate_unique_name(prefix: str) -> str: + """Generates a unique name using a prefix and a UUID fragment.""" + return f"{prefix}-{uuid4().hex[:8]}" + +# --- Example 1: Full Branch Workflow --- +async def full_branch_workflow_example(client: Git0Client): + """Demonstrates creating, listing, getting, and deleting branches.""" + print("\n--- Starting Full Branch Workflow Example ---") + + org_name = generate_unique_name("sdk-org-branchflow") + repo_name = generate_unique_name("sdk-repo-branchflow") + + try: + print(f"Creating organization: {org_name}") + await client.create_org_async(name=org_name, description="Org for branch workflow example") + + print(f"Creating repository: {org_name}/{repo_name}") + await client.create_repo_async( + org_name=org_name, + name=repo_name, + description="Repo for branch workflow example" + ) + + # Create an initial commit on the default branch (e.g., 'main') + # The default branch is usually created by the API upon repo creation or first push. + # If not, this create_file operation will establish it. + print(f"Creating initial commit on default branch (main) in {org_name}/{repo_name}...") + try: + await client.create_file_async( + org_name=org_name, + repo_name=repo_name, + path="README.md", + content="# Initial Commit\nHello, Git0!", + commit_message="Initial commit for branch workflow", + branch="main" # Assuming 'main' is default or will be created + ) + initial_commit_sha = "main" # For simplicity in example, actual SHA is in response + # To get the actual SHA, you'd typically get the branch details: + main_branch_details = await client.get_branch_async(org_name, repo_name, "main") + initial_commit_sha = main_branch_details.commit_sha + print(f"Initial commit created on main, head SHA: {initial_commit_sha}") + + except APIError as e: + print(f"Error creating initial commit (API might handle default branch differently): {e}") + print("Skipping rest of branch workflow due to initial commit failure.") + return + + + feature_branch_name = "feature/example-branch" + print(f"\nCreating new branch '{feature_branch_name}' from SHA '{initial_commit_sha}'...") + try: + new_branch = await client.create_branch_async( + org_name=org_name, + repo_name=repo_name, + name=feature_branch_name, + source_sha=initial_commit_sha + ) + print(f"Branch '{new_branch.name}' created, pointing to commit '{new_branch.commit_sha}'.") + except BranchAlreadyExists as e: + print(f"Error: {e}") + # For the example, let's try to continue by getting the existing branch + try: + new_branch = await client.get_branch_async(org_name, repo_name, feature_branch_name) + print(f"Branch '{feature_branch_name}' already existed, fetched details.") + except NotFoundError: + print(f"Failed to fetch existing branch '{feature_branch_name}' after BranchAlreadyExists error.") + return # Cannot continue if branch is not available + except CommitNotFound as e: + print(f"Error creating branch: Source commit '{initial_commit_sha}' not found. {e}") + return # Cannot continue + + print("\nListing all branches...") + branches = await client.list_branches_async(org_name=org_name, repo_name=repo_name) + for branch in branches: + print(f"- Branch: {branch.name}, SHA: {branch.commit_sha}") + + print(f"\nGetting details for branch '{feature_branch_name}'...") + try: + detailed_branch = await client.get_branch_async( + org_name=org_name, + repo_name=repo_name, + branch_name=feature_branch_name + ) + print(f"Details for '{detailed_branch.name}': Commit SHA is '{detailed_branch.commit_sha}'.") + except BranchNotFound as e: + print(f"Error: {e}") + + + print(f"\nDeleting branch '{feature_branch_name}'...") + await client.delete_branch_async( + org_name=org_name, + repo_name=repo_name, + branch_name=feature_branch_name + ) + print(f"Branch '{feature_branch_name}' deleted.") + + print("\nListing branches again...") + branches = await client.list_branches_async(org_name=org_name, repo_name=repo_name) + if not any(b.name == feature_branch_name for b in branches): + print(f"Branch '{feature_branch_name}' successfully removed from list.") + else: + print(f"Error: Branch '{feature_branch_name}' still found in list after deletion.") + for branch in branches: + print(f"- Branch: {branch.name}, SHA: {branch.commit_sha}") + + except RepositoryExistsError as e: + print(f"Error: {e}. This example requires a unique repository name.") + except APIError as e: + print(f"An API error occurred during branch workflow: {e}") + finally: + print("--- Finished Full Branch Workflow Example ---") + + +# --- Example 2: Pull Request Lifecycle --- +async def pull_request_lifecycle_example(client: Git0Client): + """Demonstrates creating a PR, commenting, reviewing, and merging.""" + print("\n--- Starting Pull Request Lifecycle Example ---") + + org_name = generate_unique_name("sdk-org-prflow") + repo_name = generate_unique_name("sdk-repo-prflow") + main_branch_name = "main" + feature_branch_name = "feature/awesome-new-stuff" + + try: + print(f"Creating organization: {org_name}") + await client.create_org_async(name=org_name, description="Org for PR workflow example") + + print(f"Creating repository: {org_name}/{repo_name}") + repo = await client.create_repo_async( + org_name=org_name, + name=repo_name, + description="Repo for PR workflow example" + ) + print(f"Repository '{repo.name}' created with default branch '{repo.default_branch}'.") + # Note: API default_branch might be 'main', or it might be empty until first push/commit. + # For this example, we'll explicitly work with 'main_branch_name'. + + print(f"\nCreating initial commit on '{main_branch_name}'...") + readme_content = "# My Awesome Project\n\nThis is the README." + file_creation_response = await client.create_file_async( + org_name=org_name, + repo_name=repo_name, + path="README.md", + content=readme_content, + commit_message="Initial commit: Add README.md", + branch=main_branch_name + ) + # The actual commit SHA would be in file_creation_response.commit.sha or similar, + # but for branch creation, we need the branch head, which this operation sets. + main_branch_details = await client.get_branch_async(org_name, repo_name, main_branch_name) + main_head_sha = main_branch_details.commit_sha + print(f"Initial README.md created on '{main_branch_name}' (commit: {main_head_sha}).") + + + print(f"\nCreating feature branch '{feature_branch_name}' from '{main_branch_name}' (SHA: {main_head_sha})...") + await client.create_branch_async( + org_name=org_name, + repo_name=repo_name, + name=feature_branch_name, + source_sha=main_head_sha + ) + print(f"Feature branch '{feature_branch_name}' created.") + + print(f"\nMaking a change on '{feature_branch_name}' (updating README.md)...") + updated_readme_content = readme_content + "\n\nThis project is going to be great!" + # To update a file, we need its current SHA. + # For simplicity, we assume the create_file_async response from initial commit would give this, + # or we could use get_file_content to get the full file object including SHA. + # Here, we'll do a create_file again which effectively overwrites, assuming a simple git model or that the file API handles this. + # A more robust flow would use update_file with the correct blob SHA. + # For this example, we'll assume create_file can overwrite or the API handles it. + # A true update would be: + # current_readme = await client.get_file_async(org_name, repo_name, "README.md", ref=feature_branch_name) # Assuming get_file_async exists and returns object with sha + # await client.update_file_async(..., sha=current_readme.sha) + updated_file_response = await client.create_file_async( # Using create_file to simulate change for simplicity + org_name=org_name, + repo_name=repo_name, + path="README.md", + content=updated_readme_content, + commit_message="Update README.md with new project excitement", + branch=feature_branch_name + ) + print(f"README.md updated on '{feature_branch_name}'.") + + + print("\nCreating pull request...") + pr_title = "Add project excitement to README" + pr_body = "This pull request updates the README file with more enthusiasm for the project." + # SDK's create_pull_request_async uses source_branch and target_branch + pr = await client.create_pull_request_async( + org_name=org_name, + repo_name=repo_name, + title=pr_title, + source_branch=feature_branch_name, # API model uses source_branch + target_branch=main_branch_name, # API model uses target_branch + body=pr_body, + is_draft=False + ) + print(f"Pull Request #{pr.number} ('{pr.title}') created.") + + print(f"\nAdding a comment to PR #{pr.number}...") + comment = await client.add_pull_request_comment_async( + org_name=org_name, + repo_name=repo_name, + pr_number=pr.number, + body="This is a great addition!" + # user_id can be specified if needed and supported by API for non-authed user actions + ) + print(f"Comment added to PR #{pr.number}: '{comment.body[:30]}...'") + + print(f"\nAdding a review to PR #{pr.number}...") + review = await client.create_pull_request_review_async( + org_name=org_name, + repo_name=repo_name, + pr_number=pr.number, + event="APPROVED", # API 'state' maps to SDK 'event' + body="Looks good to merge!" + ) + print(f"Review ({review.state}) added to PR #{pr.number}: '{review.body}'") + + print(f"\nMerging PR #{pr.number}...") + merged_pr = await client.merge_pull_request_async( + org_name=org_name, + repo_name=repo_name, + pr_number=pr.number + ) + print(f"PR #{merged_pr.number} merged. State: {merged_pr.state}. Merge Commit SHA: {merged_pr.merge_commit_sha}") + + print(f"\nVerifying PR #{pr.number} details after merge...") + final_pr_details = await client.get_pull_request_async( + org_name=org_name, + repo_name=repo_name, + pr_number=pr.number + ) + print(f"PR #{final_pr_details.number} final state: {final_pr_details.state}, Merge SHA: {final_pr_details.merge_commit_sha}") + if final_pr_details.state.lower() == "merged" and final_pr_details.merge_commit_sha: + print("PR successfully reflects merged state and merge commit SHA.") + else: + print("Error: PR does not correctly reflect merged state or merge commit SHA.") + + except RepositoryExistsError as e: + print(f"Error: {e}. This example requires a unique repository name.") + except APIError as e: + print(f"An API error occurred during PR workflow: {e}") + finally: + print("--- Finished Pull Request Lifecycle Example ---") + + +async def main(): + """Runs the Git0 SDK workflow examples.""" + if GIT0_API_KEY == "your_secret_api_key": + print("Please set the GIT0_BASE_URL and GIT0_API_KEY environment variables,") + print("or update them directly in the script to run the examples.") + return + + client = Git0Client(base_url=GIT0_BASE_URL, api_key=GIT0_API_KEY) + + print("Initializing Git0 client...") + # Basic connectivity test (optional) + try: + print("Fetching organizations to test connectivity...") + await client.list_orgs_async() # Use an async version if client is primarily async + print("Successfully connected to Git0 API.") + except APIError as e: + print(f"Failed to connect to Git0 API: {e}") + print("Please ensure Git0 is running and accessible, and your API key is correct.") + return + + await full_branch_workflow_example(client) + await pull_request_lifecycle_example(client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/git0_sdk/client.py b/sdk/python/git0_sdk/client.py index 16ed533..9c94690 100644 --- a/sdk/python/git0_sdk/client.py +++ b/sdk/python/git0_sdk/client.py @@ -13,13 +13,37 @@ from git0_sdk.git_remote import GitRemoteError, clone_repo, init_repo from .exceptions import ( + APIError, APIError, AuthenticationError, ConflictError, NotFoundError, OrganizationExistsError, RepositoryExistsError, + BranchNotFound, # New + PullRequestNotFound, # New + IssueNotFound, # New + CommitNotFound, # New + BranchAlreadyExists, # New + InvalidRefName, # New + MergeConflict, # New +) +from .models import ( + BranchSDK, + PullRequestCommentSDK, + PullRequestReviewSDK, + PullRequestSDK, # Renamed from PullRequest + PullRequestCreateSDK, # Renamed from PullRequestCreate + PullRequestCommentCreateSDK, # Renamed from PullRequestCommentCreate + PullRequestReviewCreateSDK, # Renamed from PullRequestReviewCreate + User as UserSDK, # Alias to avoid conflict if User is used elsewhere + Organization as OrganizationSDK, + Repository as RepositorySDK, + Issue as IssueSDK, + # Add other SDK-specific models if they differ from API response dicts ) +from typing import List, Optional, Union # Added List, Optional, Union +from uuid import UUID # Added UUID class Git0Client: @@ -95,19 +119,53 @@ async def _request( # If we can't parse the JSON response, just use the error message error_message = response.text or error_message + # Handle specific errors # Handle specific errors if response.status_code == 401: raise AuthenticationError(error_message) + if response.status_code == 404: - raise NotFoundError(error_message) - if response.status_code == 409 or (response.status_code == 400 and "already exists" in error_message.lower()): - if "organization" in path.lower() and "already exists" in error_message.lower(): + if "/branches/" in path: + raise BranchNotFound(error_message) + elif "/pulls/" in path: # Covers /pulls/{pr_number} and sub-resources like comments/reviews + if path.endswith("/merge") and "commit" in error_message.lower() and "not found" in error_message.lower(): # Heuristic for source_sha not found during merge + raise CommitNotFound(f"Source or target commit for merge not found: {error_message}") + raise PullRequestNotFound(error_message) + elif "/issues/" in path: + raise IssueNotFound(error_message) + # Heuristic for create_branch source_sha not found + elif method == "POST" and "/branches" in path and ("commit" in error_message.lower() or "source_sha" in error_message.lower()): + raise CommitNotFound(error_message) + else: + raise NotFoundError(error_message) + + if response.status_code == 409: + if method == "POST" and "/branches" in path: # Creating a branch that exists + raise BranchAlreadyExists(error_message) + elif method == "POST" and "/pulls/" in path and "/merge" in path: # Merging a PR + if "merge conflict" in error_message.lower(): # API signals actual Git conflict + raise MergeConflict(error_message) + # Other PR merge conflicts (e.g. not mergeable) remain ConflictError + raise ConflictError(error_message) + elif "organization" in path.lower() and "already exists" in error_message.lower(): raise OrganizationExistsError(error_message) elif "repo" in path.lower() and "already exists" in error_message.lower(): raise RepositoryExistsError(error_message) else: raise ConflictError(error_message) + if response.status_code == 422: # Unprocessable Entity, often for validation + if "invalid" in error_message.lower() and ("ref" in error_message.lower() or "branch name" in error_message.lower()): + raise InvalidRefName(error_message) + # Let other 422s fall through to general APIError or be more specific if needed + + # Fallback for unhandled specific errors but with known status codes + if response.status_code == 400 and "already exists" in error_message.lower(): # Generalize already exists for 400 + if "organization" in path.lower(): raise OrganizationExistsError(error_message) + if "repo" in path.lower(): raise RepositoryExistsError(error_message) + if "/branches" in path.lower() : raise BranchAlreadyExists(error_message) # if API uses 400 for this + raise ConflictError(error_message) # Default to conflict + # Handle general API errors raise APIError(error_message, status_code=response.status_code) @@ -182,18 +240,48 @@ def _request_sync( error_message = response.text or error_message # Handle specific errors + # Refined specific error handling for _request_sync if response.status_code == 401: raise AuthenticationError(error_message) + if response.status_code == 404: - raise NotFoundError(error_message) - if response.status_code == 409 or (response.status_code == 400 and "already exists" in error_message.lower()): - if "organization" in path.lower() and "already exists" in error_message.lower(): + if "/branches/" in path: + raise BranchNotFound(error_message) + elif "/pulls/" in path: + if path.endswith("/merge") and "commit" in error_message.lower() and "not found" in error_message.lower(): + raise CommitNotFound(f"Source or target commit for merge not found: {error_message}") + raise PullRequestNotFound(error_message) + elif "/issues/" in path: + raise IssueNotFound(error_message) + elif method == "POST" and "/branches" in path and ("commit" in error_message.lower() or "source_sha" in error_message.lower()): + raise CommitNotFound(error_message) + else: + raise NotFoundError(error_message) + + if response.status_code == 409: + if method == "POST" and "/branches" in path: + raise BranchAlreadyExists(error_message) + elif method == "POST" and "/pulls/" in path and "/merge" in path: + if "merge conflict" in error_message.lower(): + raise MergeConflict(error_message) + raise ConflictError(error_message) + elif "organization" in path.lower() and "already exists" in error_message.lower(): raise OrganizationExistsError(error_message) elif "repo" in path.lower() and "already exists" in error_message.lower(): raise RepositoryExistsError(error_message) else: raise ConflictError(error_message) + + if response.status_code == 422: + if "invalid" in error_message.lower() and ("ref" in error_message.lower() or "branch name" in error_message.lower()): + raise InvalidRefName(error_message) + if response.status_code == 400 and "already exists" in error_message.lower(): + if "organization" in path.lower(): raise OrganizationExistsError(error_message) + if "repo" in path.lower(): raise RepositoryExistsError(error_message) + if "/branches" in path.lower() : raise BranchAlreadyExists(error_message) + raise ConflictError(error_message) + # Handle general API errors raise APIError(error_message, status_code=response.status_code) @@ -360,18 +448,18 @@ async def create_issue_async( repo_name: str, title: str, body: str | None = None, - ) -> "Issue": + ) -> IssueSDK: # Changed to IssueSDK """Create a new issue in a repository asynchronously. Returns a proper Issue model instance. """ - from git0_sdk.models import Issue + # from git0_sdk.models import Issue # Now imported at top data = await self._request( method="POST", path=f"/orgs/{org_name}/repos/{repo_name}/issues", data={"title": title, "body": body}, ) - return Issue(**data) + return IssueSDK(**data) def list_issues( self, org_name: str, repo_name: str @@ -384,17 +472,17 @@ def list_issues( async def list_issues_async( self, org_name: str, repo_name: str - ) -> list["Issue"]: + ) -> List[IssueSDK]: # Changed to List[IssueSDK] """List all issues in a repository asynchronously. Returns a list of Issue model instances. """ - from git0_sdk.models import Issue + # from git0_sdk.models import Issue # Now imported at top data = await self._request( method="GET", path=f"/orgs/{org_name}/repos/{repo_name}/issues", ) - return [Issue(**issue) for issue in data] + return [IssueSDK(**issue) for issue in data] def get_issue( self, org_name: str, repo_name: str, issue_number: int @@ -407,17 +495,17 @@ def get_issue( async def get_issue_async( self, org_name: str, repo_name: str, issue_number: int - ) -> "Issue": + ) -> IssueSDK: # Changed to IssueSDK """Get a specific issue from a repository by its number asynchronously. Returns an Issue model instance. """ - from git0_sdk.models import Issue + # from git0_sdk.models import Issue # Now imported at top data = await self._request( method="GET", path=f"/orgs/{org_name}/repos/{repo_name}/issues/{issue_number}", ) - return Issue(**data) + return IssueSDK(**data) # ===== Pull Requests ===== @@ -426,47 +514,55 @@ def create_pull_request( org_name: str, repo_name: str, title: str, - head_branch: str, - base_branch: str, + source_branch: str, # Changed from head_branch + target_branch: str, # Changed from base_branch body: str | None = None, - ) -> dict[str, Any]: + is_draft: bool = False, # Added is_draft + ) -> PullRequestSDK: # Return PullRequestSDK """Create a new pull request in a repository.""" - return self._request_sync( + payload = PullRequestCreateSDK( + title=title, + description=body, # API model uses description + source_branch=source_branch, + target_branch=target_branch, + is_draft=is_draft + ).model_dump(exclude_none=True) + + response_data = self._request_sync( method="POST", path=f"/orgs/{org_name}/repos/{repo_name}/pulls", - data={ - "title": title, - "head_branch": head_branch, - "base_branch": base_branch, - "body": body, - }, + data=payload, ) + return PullRequestSDK(**response_data) async def create_pull_request_async( self, org_name: str, repo_name: str, title: str, - head_branch: str, - base_branch: str, + source_branch: str, # Changed from head_branch + target_branch: str, # Changed from base_branch body: str | None = None, - ) -> "PullRequest": + is_draft: bool = False, # Added is_draft + ) -> PullRequestSDK: # Return PullRequestSDK """Create a new pull request in a repository asynchronously. Returns a PullRequest model instance. """ - from git0_sdk.models import PullRequest - data = await self._request( + payload = PullRequestCreateSDK( + title=title, + description=body, # API model uses description + source_branch=source_branch, + target_branch=target_branch, + is_draft=is_draft + ).model_dump(exclude_none=True) + + response_data = await self._request( method="POST", path=f"/orgs/{org_name}/repos/{repo_name}/pulls", - data={ - "title": title, - "head_branch": head_branch, - "base_branch": base_branch, - "body": body, - }, + data=payload, ) - return PullRequest(**data) + return PullRequestSDK(**response_data) def list_pull_requests( self, org_name: str, repo_name: str @@ -479,17 +575,17 @@ def list_pull_requests( async def list_pull_requests_async( self, org_name: str, repo_name: str - ) -> list["PullRequest"]: + ) -> List[PullRequestSDK]: # Changed to List[PullRequestSDK] """List all pull requests in a repository asynchronously. Returns a list of PullRequest model instances. """ - from git0_sdk.models import PullRequest + # from git0_sdk.models import PullRequest # Now imported at top data = await self._request( method="GET", path=f"/orgs/{org_name}/repos/{repo_name}/pulls", ) - return [PullRequest(**pr) for pr in data] + return [PullRequestSDK(**pr) for pr in data] def get_pull_request( self, org_name: str, repo_name: str, pr_number: int @@ -502,40 +598,41 @@ def get_pull_request( async def get_pull_request_async( self, org_name: str, repo_name: str, pr_number: int - ) -> "PullRequest": + ) -> PullRequestSDK: # Changed to PullRequestSDK """Get a specific pull request from a repository by its number asynchronously. Returns a PullRequest model instance. """ - from git0_sdk.models import PullRequest + # from git0_sdk.models import PullRequest # Now imported at top data = await self._request( method="GET", path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}", ) - return PullRequest(**data) + return PullRequestSDK(**data) def merge_pull_request( self, org_name: str, repo_name: str, pr_number: int - ) -> dict[str, Any]: + ) -> PullRequestSDK: # Return PullRequestSDK """Merge a pull request.""" - return self._request_sync( + response_data = self._request_sync( method="POST", path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/merge", ) + return PullRequestSDK(**response_data) # Parse into PullRequestSDK async def merge_pull_request_async( self, org_name: str, repo_name: str, pr_number: int - ) -> "PullRequest": + ) -> PullRequestSDK: # Return PullRequestSDK """Merge a pull request asynchronously. Returns a PullRequest model instance. """ - from git0_sdk.models import PullRequest - data = await self._request( + # from git0_sdk.models import PullRequest # Now imported at top + response_data = await self._request( method="POST", path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/merge", ) - return PullRequest(**data) + return PullRequestSDK(**response_data) # Parse into PullRequestSDK # ===== Files ===== @@ -941,10 +1038,8 @@ def add_pull_request_comment( repo_name: str, pr_number: int, body: str, - commit_id: str | None = None, - path: str | None = None, - position: int | None = None, - ) -> dict[str, Any]: + user_id: Optional[UUID] = None, # Changed parameters + ) -> PullRequestCommentSDK: # Return PullRequestCommentSDK """ Add a comment to a pull request. @@ -953,37 +1048,19 @@ def add_pull_request_comment( repo_name: Repository name pr_number: Pull request number body: Comment text - commit_id: The SHA of the commit to comment on - path: Relative path of the file to comment on - position: Line index in the diff to comment on + user_id: Optional UUID of the user posting the comment. Returns: - Comment data + The created comment data as a PullRequestCommentSDK model. """ - data = {"body": body} - if commit_id: - data["commit_id"] = commit_id - if path: - data["path"] = path - if position is not None: - data["position"] = position - - try: - return self._request_sync( - method="POST", - path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/comments", - data=data, - ) - except NotFoundError: - print(f"WARNING: Pull request comment endpoint not implemented for PR #{pr_number}") - # Return mock data - return { - "id": 12345, - "body": body, - "user": {"login": "user"}, - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z", - } + payload = PullRequestCommentCreateSDK(body=body, user_id=user_id).model_dump(exclude_none=True) + + response_data = self._request_sync( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/comments", + data=payload, + ) + return PullRequestCommentSDK(**response_data) async def add_pull_request_comment_async( self, @@ -991,42 +1068,21 @@ async def add_pull_request_comment_async( repo_name: str, pr_number: int, body: str, - commit_id: str | None = None, - path: str | None = None, - position: int | None = None, - ) -> "PullRequestComment": + user_id: Optional[UUID] = None, # Changed parameters + ) -> PullRequestCommentSDK: # Return PullRequestCommentSDK """ Add a comment to a pull request asynchronously. - Returns a PullRequestComment model instance. + Returns a PullRequestCommentSDK model instance. """ - from git0_sdk.models import PullRequestComment - data = {"body": body} - if commit_id: - data["commit_id"] = commit_id - if path: - data["path"] = path - if position is not None: - data["position"] = position + payload = PullRequestCommentCreateSDK(body=body, user_id=user_id).model_dump(exclude_none=True) - try: - data = await self._request( - method="POST", - path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/comments", - data=data, - ) - return PullRequestComment(**data) - except NotFoundError: - print(f"WARNING: Pull request comment endpoint not implemented for PR #{pr_number}") - # Return mock data - mock_data = { - "id": 12345, - "body": body, - "user": {"login": "user", "id": 1}, - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z", - } - return PullRequestComment(**mock_data) + response_data = await self._request( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/comments", + data=payload, + ) + return PullRequestCommentSDK(**response_data) def get_pull_request_diff( self, @@ -1101,10 +1157,10 @@ def create_pull_request_review( org_name: str, repo_name: str, pr_number: int, - body: str | None = None, - event: str = "COMMENT", # APPROVE, REQUEST_CHANGES, COMMENT - comments: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: + event: str, # Changed from body: str | None = None, event: str = "COMMENT" + body: Optional[str] = None, # Added body as optional + user_id: Optional[UUID] = None, # Added user_id + ) -> PullRequestReviewSDK: # Return PullRequestReviewSDK """ Create a review for a pull request. @@ -1112,74 +1168,121 @@ def create_pull_request_review( org_name: Organization name repo_name: Repository name pr_number: Pull request number - body: Review text - event: Review event type (APPROVE, REQUEST_CHANGES, COMMENT) - comments: List of comments to include in the review + event: Review event type (e.g., "APPROVED", "REQUEST_CHANGES", "COMMENTED"). This maps to 'state' in API. + body: Optional review text. + user_id: Optional UUID of the user submitting the review. Returns: - Review data + The created review data as a PullRequestReviewSDK model. """ - data = {"event": event} - if body: - data["body"] = body - if comments: - data["comments"] = comments + payload = PullRequestReviewCreateSDK( + body=body, + state=event, # Map event to state + user_id=user_id + ).model_dump(exclude_none=True) - try: - return self._request_sync( - method="POST", - path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/reviews", - data=data, - ) - except NotFoundError: - print(f"WARNING: Pull request review endpoint not implemented for PR #{pr_number}") - # Return mock data - return { - "id": 12345, - "body": body or "", - "state": event.lower(), - "user": {"login": "user"}, - "submitted_at": "2023-01-01T00:00:00Z", - } + response_data = self._request_sync( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/reviews", + data=payload, + ) + return PullRequestReviewSDK(**response_data) async def create_pull_request_review_async( self, org_name: str, repo_name: str, pr_number: int, - body: str | None = None, - event: str = "COMMENT", # APPROVE, REQUEST_CHANGES, COMMENT - comments: list[dict[str, Any]] | None = None, - ) -> "PullRequestReview": + event: str, # Changed from body: str | None = None, event: str = "COMMENT" + body: Optional[str] = None, # Added body as optional + user_id: Optional[UUID] = None, # Added user_id + ) -> PullRequestReviewSDK: # Return PullRequestReviewSDK """ Create a review for a pull request asynchronously. - Returns a PullRequestReview model instance. + Returns a PullRequestReviewSDK model instance. """ - from git0_sdk.models import PullRequestReview - data = {"event": event} - if body: - data["body"] = body - if comments: - data["comments"] = comments + payload = PullRequestReviewCreateSDK( + body=body, + state=event, # Map event to state + user_id=user_id + ).model_dump(exclude_none=True) - try: - data = await self._request( - method="POST", - path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/reviews", - data=data, - ) - return PullRequestReview(**data) - except NotFoundError: - print(f"WARNING: Pull request review endpoint not implemented for PR #{pr_number}") - # Return mock data - mock_data = { - "id": 12345, - "body": body or "", - "state": event.lower(), - "user": {"login": "user", "id": 1}, - "submitted_at": "2023-01-01T00:00:00Z", - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z", - } - return PullRequestReview(**mock_data) \ No newline at end of file + response_data = await self._request( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/pulls/{pr_number}/reviews", + data=payload, + ) + return PullRequestReviewSDK(**response_data) + + # ===== Branches ===== + + def list_branches(self, org_name: str, repo_name: str) -> List[BranchSDK]: + """List all branches in a repository.""" + response_data = self._request_sync( + method="GET", + path=f"/orgs/{org_name}/repos/{repo_name}/branches", + ) + return [BranchSDK(**branch) for branch in response_data] + + async def list_branches_async(self, org_name: str, repo_name: str) -> List[BranchSDK]: + """List all branches in a repository asynchronously.""" + response_data = await self._request( + method="GET", + path=f"/orgs/{org_name}/repos/{repo_name}/branches", + ) + return [BranchSDK(**branch) for branch in response_data] + + def get_branch(self, org_name: str, repo_name: str, branch_name: str) -> BranchSDK: + """Get a specific branch from a repository by its name.""" + # Note: branch_name can contain slashes, e.g., "feature/new-thing" + # The path should be correctly constructed by httpx or the server should handle it. + response_data = self._request_sync( + method="GET", + path=f"/orgs/{org_name}/repos/{repo_name}/branches/{branch_name}", + ) + return BranchSDK(**response_data) + + async def get_branch_async(self, org_name: str, repo_name: str, branch_name: str) -> BranchSDK: + """Get a specific branch from a repository by its name asynchronously.""" + response_data = await self._request( + method="GET", + path=f"/orgs/{org_name}/repos/{repo_name}/branches/{branch_name}", + ) + return BranchSDK(**response_data) + + def create_branch(self, org_name: str, repo_name: str, name: str, source_sha: str) -> BranchSDK: + """Create a new branch in a repository.""" + payload = {"name": name, "source_sha": source_sha} + response_data = self._request_sync( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/branches", + data=payload, + ) + return BranchSDK(**response_data) + + async def create_branch_async(self, org_name: str, repo_name: str, name: str, source_sha: str) -> BranchSDK: + """Create a new branch in a repository asynchronously.""" + payload = {"name": name, "source_sha": source_sha} + response_data = await self._request( + method="POST", + path=f"/orgs/{org_name}/repos/{repo_name}/branches", + data=payload, + ) + return BranchSDK(**response_data) + + def delete_branch(self, org_name: str, repo_name: str, branch_name: str) -> None: + """Delete a specific branch from a repository.""" + self._request_sync( + method="DELETE", + path=f"/orgs/{org_name}/repos/{repo_name}/branches/{branch_name}", + ) + return # DELETE typically returns 204 No Content + + async def delete_branch_async(self, org_name: str, repo_name: str, branch_name: str) -> None: + """Delete a specific branch from a repository asynchronously.""" + await self._request( + method="DELETE", + path=f"/orgs/{org_name}/repos/{repo_name}/branches/{branch_name}", + ) + return \ No newline at end of file diff --git a/sdk/python/git0_sdk/exceptions.py b/sdk/python/git0_sdk/exceptions.py index cf0e87f..2b48ded 100644 --- a/sdk/python/git0_sdk/exceptions.py +++ b/sdk/python/git0_sdk/exceptions.py @@ -34,8 +34,46 @@ def __init__(self, message: str = "Resource not found"): class ConflictError(APIError): """Raised when there is a conflict with the current state of the resource.""" - pass + def __init__(self, message: str = "Resource conflict", status_code: int = 409): + super().__init__(message=message, status_code=status_code) class GitRemoteError(Exception): """Raised for git-remote-s3 related errors.""" pass + +# New Specific Exceptions + +class BranchNotFound(NotFoundError): + """Raised when a specific branch is not found.""" + def __init__(self, message: str = "Branch not found"): + super().__init__(message=message) + +class PullRequestNotFound(NotFoundError): + """Raised when a specific pull request is not found.""" + def __init__(self, message: str = "Pull request not found"): + super().__init__(message=message) + +class IssueNotFound(NotFoundError): + """Raised when a specific issue is not found.""" + def __init__(self, message: str = "Issue not found"): + super().__init__(message=message) + +class CommitNotFound(NotFoundError): + """Raised when a specific commit (e.g., a source_sha) is not found.""" + def __init__(self, message: str = "Commit not found"): + super().__init__(message=message) + +class BranchAlreadyExists(ConflictError): + """Raised when attempting to create a branch that already exists.""" + def __init__(self, message: str = "Branch already exists"): + super().__init__(message=message) + +class InvalidRefName(ValueError, Git0SDKError): # Inherit from ValueError for type checking, and Git0SDKError for base SDK error + """Raised when an invalid Git reference name is provided (client-side or API signaled).""" + def __init__(self, message: str = "Invalid Git reference name"): + super().__init__(message) # Git0SDKError does not take message in constructor, ValueError does + +class MergeConflict(ConflictError): + """Raised specifically when a Git merge operation fails due to merge conflicts.""" + def __init__(self, message: str = "Merge conflict detected"): + super().__init__(message=message) diff --git a/sdk/python/git0_sdk/models.py b/sdk/python/git0_sdk/models.py index 7c4df6d..d57cfcc 100644 --- a/sdk/python/git0_sdk/models.py +++ b/sdk/python/git0_sdk/models.py @@ -2,100 +2,143 @@ Pydantic models for Git0 API resources. """ from datetime import datetime +from uuid import UUID # Added UUID +from typing import Optional # Added Optional from pydantic import BaseModel, Field # Base model for common fields -class BaseAuditModel(BaseModel): - id: int +class BaseAuditModel(BaseModel): # Keeping this for existing models that might use int IDs from a different source/assumption + id: int # Assuming some existing models might rely on int ID. New models will use UUID if API does. created_at: datetime updated_at: datetime -# User Model -class User(BaseModel): - id: int +class User(BaseModel): # Assuming API User model might use UUID, adjust if necessary based on actual API + id: UUID # Changed to UUID, assuming consistency with other new models login: str - node_id: str | None = None - type: str | None = None - site_admin: bool | None = False + # Assuming these fields are still relevant for the SDK's User model + login: str # This might be 'name' or 'username' in API response + node_id: Optional[str] = None + type: Optional[str] = None + site_admin: Optional[bool] = False + email: Optional[str] = None # Adding email as it's common # Organization Models -class OrganizationCreate(BaseModel): +class OrganizationCreate(BaseModel): # Matches API name: str = Field(..., description="Unique name of the organization (slug)") - description: str | None = Field(None, description="Description of the organization") + description: Optional[str] = Field(None, description="Description of the organization") -class Organization(BaseAuditModel): +class Organization(BaseModel): # Aligning with typical API responses + id: UUID name: str - description: str | None + description: Optional[str] + created_at: datetime + updated_at: datetime + # Removed BaseAuditModel if IDs are UUIDs # Repository Models -class RepositoryCreate(BaseModel): +class RepositoryCreate(BaseModel): # Matches API name: str = Field(..., description="Name of the repository") - description: str | None = Field(None, description="Description of the repository") + description: Optional[str] = Field(None, description="Description of the repository") is_private: bool = Field(False, description="Whether the repository is private") + default_branch: Optional[str] = Field("main", description="Default branch name") + -class Repository(BaseAuditModel): +class Repository(BaseModel): # Aligning with typical API responses + id: UUID name: str - org_name: str # Assuming the API returns the org name slug - description: str | None + # org_name: str # API likely returns organization_id or nested Organization object + organization_id: UUID + description: Optional[str] is_private: bool - # clone_url: Optional[str] # Example, if API provides it + default_branch: str + created_at: datetime + updated_at: datetime + # clone_url: Optional[str] # Issue Models -class IssueCreate(BaseModel): +class IssueCreate(BaseModel): # Matches API title: str = Field(..., description="Title of the issue") - body: str | None = Field(None, description="Body content of the issue") + body: Optional[str] = Field(None, description="Body content of the issue") + # labels: Optional[list[str]] = None + # assignee_id: Optional[UUID] = None -class Issue(BaseAuditModel): - number: int # Issues usually have a repo-local number +class Issue(BaseModel): # Aligning with typical API responses + id: UUID + number: int title: str - body: str | None - state: str = Field(..., description="State of the issue (e.g., open, closed)") - user: User - # author_username: Optional[str] + body: Optional[str] + state: str # Assuming API uses string state like "OPEN", "CLOSED" + # user: User # API might return user_id or a nested User object + user_id: Optional[UUID] = None # Or user: Optional[User] + repository_id: UUID + created_at: datetime + updated_at: datetime + # labels: Optional[list[str]] + # assignees: Optional[list[User]] -# Pull Request Models -class PullRequestCreate(BaseModel): - title: str = Field(..., description="Title of the pull request") - body: str | None = Field(None, description="Body content of the pull request") - head_branch: str = Field(..., description="Name of the branch with changes") - base_branch: str = Field(..., description="Name of the branch to merge into") -class PullRequest(BaseAuditModel): - number: int # PRs usually have a repo-local number +# Pull Request Models - Aligning with API models from previous tasks +class PullRequestCreateSDK(BaseModel): # Renamed to avoid conflict with API's model if SDK needs different structure + title: str = Field(..., description="Title of the pull request") + description: Optional[str] = Field(None, description="Body content of the pull request") + source_branch: str = Field(..., description="Name of the branch with changes (head)") + target_branch: str = Field(..., description="Name of the branch to merge into (base)") + is_draft: Optional[bool] = Field(False, description="Is the pull request a draft?") + # issue_id: Optional[UUID] = None + # assignee_id: Optional[UUID] = None + # labels: Optional[list[str]] = None + +class PullRequestSDK(BaseModel): # Renamed + id: UUID + number: int title: str - body: str | None - state: str = Field(..., description="State of the pull request (e.g., open, closed, merged)") - head_branch: str - base_branch: str - user: User = Field(..., description="User who created the pull request") - # merged_at: Optional[datetime] - # closed_at: Optional[datetime] - -# Pull Request Comment Models -class PullRequestCommentCreate(BaseModel): + description: Optional[str] + source_branch: str + target_branch: str + state: str # API uses PullRequestState enum, SDK can use string + is_draft: bool + # user: User # Or user_id: UUID + user_id: Optional[UUID] = None # Assuming API provides user_id + repository_id: UUID + created_at: datetime + updated_at: datetime + merge_commit_sha: Optional[str] = Field(None, description="SHA of the merge commit, if merged.") + + +# Pull Request Comment Models - Redefined to match API +class PullRequestCommentCreateSDK(BaseModel): body: str = Field(..., description="Text of the comment") - commit_id: str | None = Field(None, description="SHA of the commit to comment on") - path: str | None = Field(None, description="Relative path of the file to comment on") - position: int | None = Field(None, description="Line index in the diff to comment on") + user_id: Optional[UUID] = Field(None, description="Optional UUID of the user posting the comment.") -class PullRequestComment(BaseAuditModel): +class PullRequestCommentSDK(BaseModel): + id: UUID + pull_request_id: UUID + user_id: Optional[UUID] body: str - user: User - commit_id: str | None = None - path: str | None = None - position: int | None = None - pull_request_url: str | None = None - -# Pull Request Review Models -class PullRequestReviewCreate(BaseModel): - body: str | None = Field(None, description="Text of the review") - event: str = Field("COMMENT", description="Review event (APPROVE, REQUEST_CHANGES, COMMENT)") - comments: list[PullRequestCommentCreate] | None = Field(None, description="Inline comments") - -class PullRequestReview(BaseAuditModel): - body: str | None - state: str = Field(..., description="State of the review (approved, changes_requested, commented)") - user: User - submitted_at: datetime + created_at: datetime + updated_at: datetime + +# Pull Request Review Models - Redefined to match API +# API uses PullRequestReviewState enum (APPROVED, CHANGES_REQUESTED, COMMENTED) +# SDK create model will take this state as a string. +class PullRequestReviewCreateSDK(BaseModel): + body: Optional[str] = Field(None, description="Text of the review") + state: str = Field(..., description="Review state (e.g., APPROVED, CHANGES_REQUESTED, COMMENTED)") # Was 'event' + user_id: Optional[UUID] = Field(None, description="Optional UUID of the user submitting the review.") + # comments: Optional[list[PullRequestCommentCreateSDK]] = None # If API supports inline comments for reviews + +class PullRequestReviewSDK(BaseModel): + id: UUID + pull_request_id: UUID + user_id: Optional[UUID] + body: Optional[str] + state: str # (e.g., APPROVED, CHANGES_REQUESTED, COMMENTED) + created_at: datetime + updated_at: datetime # API has created_at and updated_at + +# Branch Models - As specified in current subtask +class BranchSDK(BaseModel): + name: str + commit_sha: str