diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index 59a98e9..df69dbe 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -1,14 +1,145 @@ -from pydantic import BaseModel, model_validator -from typing import Dict, Self, Optional, Any +from pydantic import BaseModel, model_validator, Field +from typing import Dict, Self, Optional, Any, List import ulid +import re class ChatRequest(BaseModel): - session_id: str="" + session_id: str = "" message: str model_params: Optional[Dict[str, Any]] = None @model_validator(mode="after") - def set_session_id(self)->Self: + def set_session_id(self) -> Self: if not self.session_id: self.session_id = ulid.ulid() - return self \ No newline at end of file + return self + + +# --- Branch Listing --- +class BranchListResponse(BaseModel): + branches: List[str] = Field(..., description="List of branch names in the repository.") + + @model_validator(mode='after') + def sort_branches(self): + """Sort branches with main/master at the top, then alphabetically.""" + priority_branches = [] + other_branches = [] + + for branch in self.branches: + if branch.lower() in ('main', 'master'): + priority_branches.append(branch) + else: + other_branches.append(branch) + + # Sort priority branches (main, master) and other branches separately + priority_branches.sort(key=lambda x: (x.lower() != 'main', x.lower())) + other_branches.sort() + + self.branches = priority_branches + other_branches + return self + + +# --- Valid Target Branches --- +class ValidTargetBranchesRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + +class ValidTargetBranchesResponse(BaseModel): + valid_target_branches: List[str] = Field(..., description="List of valid target branch names.") + + @model_validator(mode='after') + def sort_branches(self): + """Sort branches with main/master at the top, then alphabetically.""" + priority_branches = [] + other_branches = [] + + for branch in self.valid_target_branches: + if branch.lower() in ('main', 'master'): + priority_branches.append(branch) + else: + other_branches.append(branch) + + # Sort priority branches (main, master) and other branches separately + priority_branches.sort(key=lambda x: (x.lower() != 'main', x.lower())) + other_branches.sort() + + self.valid_target_branches = priority_branches + other_branches + return self + + +# --- Pull Request Creation --- +class CreatePullRequestRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + target_branch: str = Field(..., description="Target branch name.") + body: str = Field(..., description="Body of the pull request. This field is required.") + draft: Optional[bool] = Field(False, description="Whether to create the PR as a draft.") + reviewers: Optional[List[str]] = Field(None, description="List of reviewer usernames.") + assignees: Optional[List[str]] = Field(None, description="List of assignee usernames.") + labels: Optional[List[str]] = Field(None, description="List of label names.") + description: Optional[str]=None + title: Optional[str]=None + + @model_validator(mode="after") + def get_title_description(self)->Self: + title, description = self.extract_title_and_description(self.body) + if self.title is None: + self.title = title + if self.description is None: + self.description = description + + return self + + @staticmethod + def extract_title_and_description(pr_text: str): + """ + Extracts the PR title and description from a markdown-formatted PR text. + + Expected format: + Title: + + ## Summary + ... + """ + + # Use regex to find the title (first line starting with 'Title:') + title_match = re.search(r'^\s*Title:\s*(.+?)\s*$', pr_text, re.MULTILINE) + + # Everything after the title is the description + description_match = re.search(r'^\s*Title:.*?\n+(.*)', pr_text, re.DOTALL) + + title = title_match.group(1).strip() if title_match else "" + description = description_match.group(1).strip() if description_match else "" + + return title, description + + + +# --- Pull Request Diff --- +class GetPullRequestDiffRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + target_branch: str = Field(..., description="Target branch name.") + +class GetPullRequestDiffResponse(BaseModel): + commits: List[dict] = Field(..., description="List of commit dicts in the diff.") + +class CreatePullRequestResponse(BaseModel): + url: str = Field(..., description="URL of the created pull request.") + number: int = Field(..., description="Pull request number.") + state: str = Field(..., description="State of the pull request (e.g., open, closed).") + success: bool = Field(..., description="Whether the pull request was created successfully.") + # Optionally, include the generated description if LLM was used + generated_description: Optional[str] = Field(None, description="LLM-generated PR description, if applicable.") + + +# --- Utility: Commit List for PR Description Generation --- +class CommitMessagesForPRDescriptionRequest(BaseModel): + commit_messages: List[str] = Field(..., description="List of commit messages to summarize.") + session_id: str = Field(..., description="Session identifier.") + +class PRDescriptionResponse(BaseModel): + description: str = Field(..., description="LLM-generated pull request description.") \ No newline at end of file diff --git a/app/api/server/routes.py b/app/api/server/routes.py index bbbc778..c468b21 100644 --- a/app/api/server/routes.py +++ b/app/api/server/routes.py @@ -1,6 +1,15 @@ from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel +from models.schemas import ( + BranchListResponse, + ValidTargetBranchesRequest, + ValidTargetBranchesResponse, + CreatePullRequestRequest, + CreatePullRequestResponse, +) + +from models.schemas import GetPullRequestDiffRequest, GetPullRequestDiffResponse from services.llm_service import set_llm, get_llm, trim_messages from services.fetcher_service import store_fetcher, get_fetcher from git_recap.utils import parse_entries_to_txt, parse_releases_to_txt @@ -214,7 +223,7 @@ async def get_release_notes( # Get fetcher for session try: fetcher = get_fetcher(session_id) - except HTTPException as e: + except HTTPException: raise # Check if fetcher supports fetch_releases @@ -273,13 +282,88 @@ async def get_release_notes( return {"actions": "\n\n".join([actions_txt, releases_txt])} -# @router.post("/chat") -# async def chat( -# chat_request: ChatRequest -# ): -# try: -# llm = await initialize_llm_session(chat_request.session_id) -# response = await llm.acomplete(chat_request.message) -# return {"response": response} -# except Exception as e: -# raise HTTPException(status_code=500, detail=str(e)) +# --- Branch and Pull Request Management Endpoints --- +@router.get("/branches", response_model=BranchListResponse) +async def get_branches( + session_id: str, + repo: str +): + """ + Get all branches for a given repository in the current session. + """ + fetcher = get_fetcher(session_id) + try: + fetcher.repo_filter = [repo] + branches = fetcher.get_branches() + except NotImplementedError: + raise HTTPException(status_code=400, detail="Branch listing is not supported for this provider.") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch branches: {str(e)}") + return BranchListResponse(branches=branches) + +@router.post("/valid-target-branches", response_model=ValidTargetBranchesResponse) +async def get_valid_target_branches( + req: ValidTargetBranchesRequest +): + """ + Get all valid target branches for a given source branch in a repository. + """ + fetcher = get_fetcher(req.session_id) + try: + fetcher.repo_filter = [req.repo] + valid_targets = fetcher.get_valid_target_branches(req.source_branch) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Target branch validation is not supported for this provider.") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to validate target branches: {str(e)}") + return ValidTargetBranchesResponse(valid_target_branches=valid_targets) + +@router.post("/create-pull-request", response_model=CreatePullRequestResponse) +async def create_pull_request( + req: CreatePullRequestRequest +): + fetcher = get_fetcher(req.session_id) + fetcher.repo_filter = [req.repo] + if not req.description or not req.description.strip(): + raise HTTPException(status_code=400, detail="Description is required for pull request creation.") + try: + result = fetcher.create_pull_request( + head_branch=req.source_branch, + base_branch=req.target_branch, + title=req.title or f"Merge {req.source_branch} into {req.target_branch}", + body=req.description, + draft=req.draft or False, + reviewers=req.reviewers, + assignees=req.assignees, + labels=req.labels, + ) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Pull request creation is not supported for this provider.") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create pull request: {str(e)}") + return CreatePullRequestResponse( + url=result.get("url"), + number=result.get("number"), + state=result.get("state"), + success=result.get("success", False), + generated_description=None + ) + +@router.post("/get-pull-request-diff") +async def get_pull_request_diff(req: GetPullRequestDiffRequest): + fetcher = get_fetcher(req.session_id) + fetcher.repo_filter = [req.repo] + provider = type(fetcher).__name__.lower() + if "github" not in provider: + raise HTTPException(status_code=400, detail="Pull request diff is only supported for GitHub provider.") + try: + commits = fetcher.fetch_branch_diff_commits(req.source_branch, req.target_branch) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Branch diff is not supported for this provider.") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch pull request diff: {str(e)}") + return {"actions": parse_entries_to_txt(commits)} diff --git a/app/api/server/websockets.py b/app/api/server/websockets.py index c33a002..90774ea 100644 --- a/app/api/server/websockets.py +++ b/app/api/server/websockets.py @@ -1,11 +1,21 @@ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect import json -from typing import Optional +from typing import Literal, Optional +import asyncio -from services.prompts import SELECT_QUIRKY_REMARK_SYSTEM, SYSTEM, RELEASE_NOTES_SYSTEM, quirky_remarks -from services.llm_service import get_random_quirky_remarks, run_concurrent_tasks, get_llm +from services.prompts import ( + PR_DESCRIPTION_SYSTEM, + SELECT_QUIRKY_REMARK_SYSTEM, + SYSTEM, + RELEASE_NOTES_SYSTEM, + quirky_remarks, +) +from services.llm_service import ( + get_random_quirky_remarks, + run_concurrent_tasks, + get_llm, +) from aicore.const import SPECIAL_TOKENS, STREAM_END_TOKEN -import asyncio router = APIRouter() @@ -26,54 +36,96 @@ {ACTIONS} """ +TRIGGER_PULL_REQUEST_PROMPT = """ +You will now receive a list of commit messages between two branches. +Using the system instructions provided above, generate a clear, concise, and professional **Pull Request Description** summarizing all changes. + +Commits: +{COMMITS} + +Please follow these steps: +1. Read and analyze the commit messages. +2. Identify and group related changes under appropriate markdown headers (e.g., Features, Bug Fixes, Improvements, Documentation, Tests). +3. Write a short **summary paragraph** explaining the overall purpose of this pull request. +4. Format the final output as a complete markdown-formatted PR description, ready to paste into GitHub. + +Begin your response directly with the formatted PR description—no extra commentary or explanation. +""" + @router.websocket("/ws/{session_id}/{action_type}") async def websocket_endpoint( - websocket: WebSocket, + websocket: WebSocket, session_id: Optional[str] = None, - action_type: str="recap" + action_type: Literal["recap", "release", "pull_request"] = "recap" ): + """ + WebSocket endpoint for real-time LLM operations. + + Handles three action types: + - recap: Generate commit summaries with quirky remarks + - release: Generate release notes based on git history + - pull_request: Generate PR descriptions from commit diffs + + Args: + websocket: WebSocket connection instance + session_id: Session identifier for LLM and fetcher management + action_type: Type of operation to perform + + Raises: + HTTPException: If action_type is invalid + """ await websocket.accept() + # Select appropriate system prompt based on action type if action_type == "recap": QUIRKY_SYSTEM = SELECT_QUIRKY_REMARK_SYSTEM.format( examples=json.dumps(get_random_quirky_remarks(quirky_remarks), indent=4) ) - system = [SYSTEM, QUIRKY_SYSTEM] - elif action_type == "release": system = RELEASE_NOTES_SYSTEM - + elif action_type == "pull_request": + system = PR_DESCRIPTION_SYSTEM else: - raise HTTPException(status_code=404) - - # Store the connection + raise HTTPException(status_code=404, detail="Invalid action type") + + # Store the active WebSocket connection active_connections[session_id] = websocket - # Initialize LLM + # Initialize LLM session llm = get_llm(session_id) try: while True: + # Receive message from client message = await websocket.receive_text() msg_json = json.loads(message) - message = msg_json.get("actions") + message_content = msg_json.get("actions") N = msg_json.get("n", 5) - assert int(N) <= 15 - assert message + + # Validate inputs + assert int(N) <= 15, "N must be <= 15" + assert message_content, "Message content is required" + + # Build history/prompt based on action type if action_type == "recap": history = [ TRIGGER_PROMPT.format( N=N, - ACTIONS=message + ACTIONS=message_content ) ] elif action_type == "release": history = [ - TRIGGER_RELEASE_PROMPT.format(ACTIONS=message) + TRIGGER_RELEASE_PROMPT.format(ACTIONS=message_content) + ] + elif action_type == "pull_request": + history = [ + TRIGGER_PULL_REQUEST_PROMPT.format(COMMITS=message_content) ] + # Stream LLM response back to client response = [] async for chunk in run_concurrent_tasks( llm, @@ -85,24 +137,38 @@ async def websocket_endpoint( break elif chunk in SPECIAL_TOKENS: continue - await websocket.send_text(json.dumps({"chunk": chunk})) response.append(chunk) - + + # Store response in history for potential follow-up history.append("".join(response)) - + except WebSocketDisconnect: + # Clean up connection on disconnect + if session_id in active_connections: + del active_connections[session_id] + except AssertionError as e: + # Handle validation errors if session_id in active_connections: + await websocket.send_text(json.dumps({"error": f"Validation error: {str(e)}"})) del active_connections[session_id] except Exception as e: + # Handle unexpected errors if session_id in active_connections: await websocket.send_text(json.dumps({"error": str(e)})) del active_connections[session_id] + def close_websocket_connection(session_id: str): """ - Clean up and close the active websocket connection associated with the given session_id. + Clean up and close the active WebSocket connection associated with the given session_id. + + This function is called during session expiration to ensure proper cleanup + of WebSocket resources. + + Args: + session_id: The session identifier whose WebSocket connection should be closed """ websocket = active_connections.pop(session_id, None) if websocket: - asyncio.create_task(websocket.close()) + asyncio.create_task(websocket.close()) \ No newline at end of file diff --git a/app/api/services/llm_service.py b/app/api/services/llm_service.py index 509fc70..f85824f 100644 --- a/app/api/services/llm_service.py +++ b/app/api/services/llm_service.py @@ -184,3 +184,50 @@ async def expire_session(session_id: str): # Expire any active websocket connections associated with session_id. from server.websockets import close_websocket_connection close_websocket_connection(session_id) + + +# --- LLM PR Description Generation Utility --- +from aicore.const import SPECIAL_TOKENS, STREAM_END_TOKEN + +async def generate_pr_description_from_commits(commit_messages: List[str], session_id: str) -> str: + """ + Generate a pull request description using the LLM, given a list of commit messages. + This function is intended to be called from REST endpoints for PR creation. + + Args: + commit_messages: List of commit message strings to summarize. + session_id: The LLM session ID to use for the LLM call. + + Returns: + str: The generated PR description. + """ + if not commit_messages: + raise ValueError("No commit messages provided for PR description generation.") + + llm = get_llm(session_id) + + pr_prompt = ( + "You are an AI assistant tasked with generating a concise, clear, and professional pull request description " + "based on the following commit messages. Summarize the overall changes, highlight key improvements or fixes, " + "and provide a brief, readable description suitable for a pull request body. Do not include commit hashes or dates. " + "Group similar changes and avoid repetition. Use markdown formatting for clarity if appropriate.\n\n" + "Commit messages:\n" + + "\n".join(f"- {msg.strip()}" for msg in commit_messages) + ) + + response_chunks = [] + async for chunk in run_concurrent_tasks( + llm, + message=[pr_prompt], + system_prompt="You are a helpful assistant that writes clear, professional pull request descriptions for developers." + ): + if chunk == STREAM_END_TOKEN: + break + elif chunk in SPECIAL_TOKENS: + continue + response_chunks.append(chunk) + + pr_description = "".join(response_chunks).strip() + if not pr_description: + raise RuntimeError("LLM did not return a PR description.") + return pr_description \ No newline at end of file diff --git a/app/api/services/prompts.py b/app/api/services/prompts.py index 9e5280d..7eff363 100644 --- a/app/api/services/prompts.py +++ b/app/api/services/prompts.py @@ -145,3 +145,100 @@ Thank you to all contributors! Please upgrade to enjoy the latest features and improvements. """ + +PR_DESCRIPTION_SYSTEM = """ +### System Prompt for Pull Request Title and Description Generation + +You are an AI assistant tasked with generating professional, concise, and well-structured pull request (PR) titles and descriptions based on a list of commit messages. + +#### Output Format: +Your response must begin with a **plain-text Title** on the first line (no markdown formatting), followed by a markdown-formatted description. + +Example structure: +``` + +Title: <short, imperative summary> + +## Summary + +<high-level explanation> + +## Features + +* ... + +## Bug Fixes + +* ... + +## Notes + +* ... + +``` + +--- + +#### Formatting and Style Requirements: + +- **Title:** + - Provide a single-line, concise summary of the overall change. + - Use the **imperative mood** (e.g., “Add…”, “Fix…”, “Improve…”). + - Keep it under **72 characters**. + - Do not include markdown formatting or punctuation at the end. + +- **Description:** + - Begin with a `## Summary` section explaining the overall purpose or goal of the PR. + - Organize related changes into logical sections using markdown headers: + - `## Features` + - `## Bug Fixes` + - `## Improvements` + - `## Refactoring` + - `## Documentation` + - `## Tests` + - Use bullet points for individual changes and consolidate redundant commits. + - Maintain a professional, clear, and reviewer-friendly tone. + - Avoid commit hashes, timestamps, or author information. + - Avoid unnecessary repetition and overly technical jargon unless essential. + +- **End with a `## Notes` section** (if relevant): + - Include testing instructions, migration steps, or review considerations. + - Highlight any **breaking changes** or dependencies reviewers should note. + +--- + +#### Your Response Should: +1. **Start with a Title** summarizing the overall purpose of the PR. +2. **Follow with a structured Description** containing: + - A high-level summary. + - Grouped, clear lists of changes under markdown headers. +3. **Conclude with Notes** if applicable. + +--- + +#### Example Output: + +Title: Add multi-repository tracking and fix authentication issues + +## Summary +This pull request introduces support for managing multiple repositories and resolves authentication issues affecting GitLab users. + +## Features +- Added support for tracking commits, pull requests, and issues across multiple repositories +- Implemented new API endpoints for repository management + +## Bug Fixes +- Fixed authentication error preventing GitLab users from logging in +- Resolved issue with token expiration handling + +## Improvements +- Optimized release notes generation for better performance +- Improved UI responsiveness across dashboard components + +## Documentation +- Added API documentation for new repository endpoints +- Updated README with setup instructions for multi-repo support + +## Notes +Please verify all tests pass before merging. Pay special attention to the authentication flow and new API endpoints. +""" diff --git a/app/git-recap/src/App.css b/app/git-recap/src/App.css index 6d21d5b..ef7a85e 100644 --- a/app/git-recap/src/App.css +++ b/app/git-recap/src/App.css @@ -39,7 +39,7 @@ button:disabled, .Button:disabled { /* Centering elements - including buttons */ .form-container, -.output-section, +.output-section, .authorize-btn, .recap-button, .github-signin-btn, @@ -271,7 +271,7 @@ button:disabled, .Button:disabled { } /* Output Section */ -.output-section { +.output-section{ display: flex; gap: 2rem; justify-content: space-between; @@ -476,7 +476,8 @@ button, .Button { /* Mode switching areas */ .recap-main-btn-area, -.release-main-btn-area { +.release-main-btn-area, +.pr-main-area { display: flex; align-items: center; justify-content: center; @@ -603,7 +604,8 @@ button, .Button { /* Base styles for button areas */ .recap-main-btn-area, -.release-main-btn-area { +.release-main-btn-area, +.pr-main-area { display: flex; align-items: center; gap: 12px; @@ -617,7 +619,8 @@ button, .Button { /* Desktop styles - horizontal layout (default) */ @media (min-width: 768px) { .recap-main-btn-area, - .release-main-btn-area { + .release-main-btn-area, + .pr-main-area { flex-direction: row; justify-content: center; flex-wrap: nowrap; @@ -627,7 +630,8 @@ button, .Button { /* Mobile styles - vertical layout */ @media (max-width: 767px) { .recap-main-btn-area, - .release-main-btn-area { + .release-main-btn-area, + .pr-main-area { flex-direction: column; align-items: center; gap: 8px; @@ -635,16 +639,18 @@ button, .Button { } /* Remove margin-right on mobile for back button */ - .release-back-rect-btn { + .release-back-rect-btn, + .pr-back-rect-btn { margin-right: 0; } } /* Your existing component styles */ -.release-back-rect-btn { +.release-back-rect-btn, +.pr-back-rect-btn { background-color: beige; min-width: 90px; - height: 44px; + height: 32px; font-size: 1rem; padding: 0.6rem 0.7rem; margin-right: 1.2rem; @@ -654,10 +660,11 @@ button, .Button { justify-content: center; } -.release-back-rect-btn:disabled { +.release-back-rect-btn:disabled, +.pr-back-rect-btn:disabled { background-color: beige; min-width: 90px; - height: 44px; + height: 32px; font-size: 1rem; padding: 0.6rem 0.7rem; margin-right: 1.2rem; @@ -668,11 +675,42 @@ button, .Button { width: auto !important; } -.release-back-arrow { +.release-back-arrow, +.pr-back-arrow { font-size: 1.2rem; margin-right: 0.3rem; } +/* PR Mode Button Styles */ +.release-pr-rect-btn { + background-color: beige; + min-width: 90px; + height: 44px; + font-size: 1rem; + padding: 0.6rem 0.7rem; + margin-right: 1.2rem; + gap: 0.5rem; + display: flex; + align-items: center; + justify-content: center; +} + +.release-pr-rect-btn:disabled { + background-color: beige; + min-width: 90px; + height: 44px; + font-size: 1rem; + padding: 0.6rem 0.7rem; + margin-right: 1.2rem; + gap: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: auto !important; + opacity: 0.6; + cursor: not-allowed; +} + /* Counter container */ .release-counter-rect { display: flex; @@ -782,6 +820,10 @@ button, .Button { .recap-release-switcher.show-release { height: 180px; /* Height for release mode (3 elements vertically) */ } + + .recap-release-switcher.show-pr { + height: 240px; /* Height for PR mode (more elements vertically) */ + } } @media (max-width: 700px) { @@ -796,7 +838,7 @@ button, .Button { .release-main-btn-area, .recap-main-btn-area { gap: 0.5rem; } - .recap-3dots-rect-btn, .release-back-rect-btn, .release-counter-rect { + .recap-3dots-rect-btn, .release-back-rect-btn, .pr-back-rect-btn, .release-counter-rect { height: 40px; min-height: 40px; } @@ -1291,4 +1333,651 @@ a { .author-add-button { flex: 1; /* Full width when wrapped */ } +} + +/* Pull Request Mode Styles */ + +/* PR Mode Main Area */ +.pr-main-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; + padding: 1rem; +} + +/* PR Controls Container */ +.pr-controls-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; + max-width: 600px; +} + +/* PR Branch Selectors */ +.pr-branch-selectors { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; +} + +.pr-branch-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.pr-branch-label { + font-weight: 500; + color: #5c4033; + font-size: 0.95rem; +} + +/* PR Branch Dropdown */ +.pr-branch-dropdown { + width: 100%; + padding: 0.7rem; + border: 3px solid #333; + border-radius: 8px; + background-color: #fffaf0; + color: #333; + font-size: 1rem; + font-family: 'Minecraft', sans-serif; + cursor: pointer; + box-shadow: 4px 4px 0 #bfa16b; + transition: all 0.2s ease; + height: auto; + min-height: 44px; +} + +.pr-branch-dropdown:hover:not(:disabled) { + background-color: #fff8e1; + border-color: #e06e42; +} + +.pr-branch-dropdown:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #f0f0f0; +} + +.pr-branch-dropdown:focus { + outline: none; + border-color: #e06e42; + box-shadow: 4px 4px 0 #bfa16b, 0 0 0 3px rgba(224, 110, 66, 0.2); +} + +/* PR Validation Message */ +.pr-validation-message { + width: 100%; + padding: 0.75rem; + background-color: #fff3cd; + border: 2px solid #ffc107; + border-radius: 8px; + color: #856404; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +/* PR Error Message */ +.pr-error-message { + width: 100%; + padding: 0.75rem; + background-color: #f8d7da; + border: 2px solid #dc3545; + border-radius: 8px; + color: #721c24; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +/* PR Success Message */ +.pr-success-message { + width: 100%; + padding: 0.75rem; + background-color: #d4edda; + border: 2px solid #28a745; + border-radius: 8px; + color: #155724; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +.pr-success-message a { + color: #0056b3; + text-decoration: underline; + font-weight: bold; +} + +.pr-success-message a:hover { + color: #003d82; +} + +/* PR Generate Button */ +.pr-generate-btn { + min-width: 200px; + font-size: 1.05rem; + padding: 0.6rem 0.5rem; + height: 44px; + min-height: 44px; + background-color: #cccccc; + margin-right: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.pr-generate-btn:disabled { + min-width: 200px; + font-size: 1.05rem; + padding: 0.6rem 0.5rem; + height: 44px; + min-height: 44px; + background-color: #cccccc; + margin-right: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + width: auto !important; +} + +.pr-generate-btn:hover:not(:disabled) { + background-color: #e06e42; + border-color: #e06e42; +} + +/* PR Create Button - Updated to match recap-main-btn */ +.pr-create-btn { + width: 70vw; + font-size: 1.15rem; + padding: 0.7rem 0; + height: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + background-color: #cccccc; + /* border: 3px solid #333; */ + border-radius: 8px; + font-weight: bold; + cursor: pointer; + /* transition: all 0.2s ease; */ + /* box-shadow: 4px 4px 0 #bfa16b, 0 2px 8px rgba(0,0,0,0.09); */ + color: #fff; + margin: 0 auto; + max-width: 300px; +} + +.pr-create-btn:hover:not(:disabled) { + background: #e06e42; + border-color: #e06e42; + color: #fff; +} + +.pr-create-btn:active:not(:disabled) { + box-shadow: 2px 2px 0 #bfa16b; +} + +.pr-create-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background: #cccccc; + border-color: #333; + color: #fff; +} + +/* PR Text Areas */ +.pr-diff-area, +.pr-description-area { + width: 100%; + padding: 0.8rem; + border: 3px solid #333; + border-radius: 8px; + background-color: #fffaf0; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + resize: vertical; + min-height: 200px; + box-shadow: 4px 4px 0 #bfa16b; + color: #333; +} + +.pr-diff-area:focus, +.pr-description-area:focus { + outline: none; + border-color: #e06e42; + box-shadow: 4px 4px 0 #bfa16b, 0 0 0 3px rgba(224, 110, 66, 0.2); +} + +.pr-diff-area { + background-color: #f5f5f5; + color: #333; +} + +/* PR Creation Section */ +.pr-creation-section { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 2rem; + width: 100%; +} + +.pr-auth-message { + padding: 0.75rem; + background-color: #fff3cd; + border: 2px solid #ffc107; + border-radius: 8px; + color: #856404; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; + max-width: 500px; + margin: 0 auto; +} + +/* Responsive PR Mode Adjustments */ +@media (max-width: 768px) { + .pr-main-area { + padding: 0.5rem; + } + + .pr-controls-container { + max-width: 100%; + } + + .pr-branch-selectors { + gap: 0.75rem; + } + + .pr-generate-btn, + .pr-create-btn { + max-width: 100%; + } +} + +.options-menu { + display: flex; + gap: 12px; + transition: all 0.3s ease; + align-items: center; + margin-left: 12px; +} + +/* Button container - Mobile Layout */ +@media (max-width: 767px) { + .output-section.menu-open { + margin-top: 10rem; + } + + .button-with-tooltip { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 12px !important; /* Space between 3-dots button and menu */ + width: 100%; + margin-bottom: 24px !important; /* More space below to prevent overlap */ + } + + .recap-3dots-rect-btn { + width: auto !important; + align-self: center !important; + } + + .options-menu { + position: relative !important; + left: 0 !important; + right: 0 !important; + transform: none !important; + flex-direction: column !important; + align-items: center !important; + gap: 12px !important; /* Space between menu buttons */ + width: 100% !important; + max-width: 300px !important; + margin: 6px auto !important; + padding-bottom: 8px !important; /* Extra padding at bottom of menu */ + /* Make sure the menu takes up space when visible */ + height: auto; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease, padding 0.3s ease; + } + + .options-menu.slide-in-menu { + max-height: 200px; /* Adjust based on your menu height */ + padding-bottom: 8px !important; + } + + .options-menu.slide-out-menu { + max-height: 0; + padding-bottom: 0 !important; + opacity: 0; + pointer-events: none; + } + + .menu-option-btn { + width: 100% !important; + min-width: 180px !important; + max-width: 200px !important; + margin: 6px auto !important; + } + + .menu-button-with-tooltip { + width: 100%; + display: flex !important; + justify-content: center !important; + } +} + +/* Small screen adjustments */ +@media (max-width: 480px) { + .button-with-tooltip { + gap: 10px !important; + margin-bottom: 20px !important; + } + + .options-menu { + gap: 10px !important; /* Space between buttons on small screens */ + max-width: 250px !important; + } + + .options-menu.slide-in-menu { + max-height: 160px; /* Adjust for small screens */ + } + + .menu-option-btn { + height: 40px; + font-size: 0.9rem; + min-width: 160px !important; + max-width: 180px !important; + } +} + +/* For larger screens, keep the original behavior */ +@media (min-width: 768px) { + .slide-in-menu { + opacity: 1; + transform: translateX(0); + pointer-events: auto; + } + + .slide-out-menu { + opacity: 0; + transform: translateX(-20px); + pointer-events: none; + position: absolute; + left: 100%; + } +} + +.slide-in-menu { + opacity: 1; + transform: translateX(0); + pointer-events: auto; +} + +.slide-out-menu { + opacity: 0; + transform: translateX(-20px); + pointer-events: none; + position: absolute; + left: 100%; +} + +.menu-option-btn { + height: 44px; + padding: 8px 16px; + white-space: nowrap; + font-size: 14px; + font-weight: 500; + flex-shrink: 0; +} + +/* Ensure the button container allows horizontal layout */ +.recap-main-btn-area { + display: flex; + align-items: center; + gap: 12px; +} + +.button-with-tooltip { + position: relative; + display: flex; + align-items: center; +} + +.menu-button-with-tooltip { + position: relative; + display: inline-block; +} + +.menu-tooltip-text { + visibility: hidden; + opacity: 0; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: #1f2937; + color: white; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + width: max-content; + max-width: 250px; + margin-bottom: 8px; + transition: opacity 0.2s; + z-index: 10; + pointer-events: none; + text-align: center; +} + +.menu-tooltip-text::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: #1f2937; +} + +.menu-button-with-tooltip:hover .menu-tooltip-text { + visibility: visible; + opacity: 1; +} + +/* Show tooltip when button is disabled */ +.menu-button-with-tooltip button:disabled ~ .menu-tooltip-text { + display: block; +} + +.menu-button-with-tooltip button:disabled:hover ~ .menu-tooltip-text { + visibility: visible; + opacity: 1; +} + +/* PR Mode - Horizontal Layout */ +.pr-main-area { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 25px !important; + flex-wrap: nowrap !important; + justify-content: center !important; +} + +.pr-branch-group-inline { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + max-width: 150px; +} + +.pr-branch-label { + font-size: 13px; + font-weight: 500; + color: #374151; + white-space: nowrap; +} + +.pr-branch-dropdown { + padding: 8px 12px; + height: 44px; + border: 2px solid #000; + border-radius: 4px; + font-size: 14px; + background: white; + min-width: 150px; + cursor: pointer; +} + +.pr-branch-dropdown:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pr-generate-btn { + white-space: nowrap; +} + +.pr-validation-message-inline { + padding: 8px 12px; + background: #fef3c7; + border: 2px solid #fbbf24; + border-radius: 6px; + font-size: 13px; + color: #92400e; + width: 100%; + text-align: center; + flex-basis: 100%; +} + +/* PR Mode - Mobile Layout */ +@media (max-width: 767px) { + .pr-main-area { + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + padding: 0 16px !important; + flex-wrap: wrap !important; + } +} + + +/* Mobile adjustments for PR elements */ +@media (max-width: 767px) { + .pr-branch-group-inline { + width: 100%; + max-width: none; + margin-bottom: 8px; + } + + .pr-branch-dropdown { + width: 100%; + min-width: 0; + } + + .pr-generate-btn { + width: 70vw; + min-width: 0; + max-width: none; + } + + .pr-create-btn { + width: 40vw; + min-width: 0; + max-width: none; + } + + .pr-auth-message { + width: 90%; + font-size: 0.85rem; + } +} + +/* Small screen adjustments */ +@media (max-width: 480px) { + .pr-main-area { + gap: 6px !important; + padding: 0 8px !important; + } + + .pr-branch-dropdown { + height: 40px; + font-size: 13px; + } + + .pr-generate-btn { + height: 40px; + font-size: 1rem; + } + + .pr-create-btn { + height: 40px; + font-size: 1rem; + } + + .pr-auth-message { + font-size: 0.8rem; + padding: 0.6rem; + } +} + +/* Medium screen adjustments */ +@media (min-width: 768px) and (max-width: 1024px) { + .pr-main-area { + max-width: 700px; + gap: 16px !important; + } + + .pr-branch-group-inline { + min-width: 180px; + max-width: 220px; + } + + .pr-generate-btn { + min-width: 140px; + max-width: 170px; + } +} + +/* Large screen adjustments */ +@media (min-width: 1025px) { + .pr-main-area { + max-width: 800px; + gap: 20px !important; + } + + .pr-branch-group-inline { + min-width: 200px; + max-width: 250px; + } + + .pr-generate-btn { + min-width: 160px; + max-width: 200px; + } + + .pr-creation-section { + max-width: 1000px; + } + + .pr-create-btn { + max-width: 350px; + } + + .pr-auth-message { + max-width: 600px; + } } \ No newline at end of file diff --git a/app/git-recap/src/App.tsx b/app/git-recap/src/App.tsx index 98519be..df82ab8 100644 --- a/app/git-recap/src/App.tsx +++ b/app/git-recap/src/App.tsx @@ -44,6 +44,24 @@ function App() { const [numOldReleases, setNumOldReleases] = useState(1); const [isExecutingReleaseNotes, setIsExecutingReleaseNotes] = useState(false); + // PR Mode states + const [showPRMode, setShowPRMode] = useState(false); + const [availableBranches, setAvailableBranches] = useState<string[]>([]); + const [sourceBranch, setSourceBranch] = useState(''); + const [targetBranches, setTargetBranches] = useState<string[]>([]); + const [targetBranch, setTargetBranch] = useState(''); + const [prDiff, setPrDiff] = useState(''); + const [prDescription, setPrDescription] = useState(''); + const [isGeneratingPR, setIsGeneratingPR] = useState(false); + const [prValidationMessage, setPrValidationMessage] = useState(''); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [isLoadingTargets, setIsLoadingTargets] = useState(false); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [isCreatingPR, setIsCreatingPR] = useState(false); + const [prCreationSuccess, setPrCreationSuccess] = useState(false); + const [prUrl, setPrUrl] = useState(''); + const [prGenerated, setPrGenerated] = useState(false); // New state to track if PR was generated + // Auth states const [isPATAuthorized, setIsPATAuthorized] = useState(false); const [authProgress, setAuthProgress] = useState(0); @@ -64,7 +82,7 @@ function App() { const [recapDone, setRecapDone] = useState(true); const [isReposLoading, setIsReposLoading] = useState(true); const [repoProgress, setRepoProgress] = useState(0); - // UI mode for recap/release + // UI mode for recap/release/pr const [showReleaseMode, setShowReleaseMode] = useState(false); const actionsLogRef = useRef<HTMLDivElement>(null); @@ -354,6 +372,311 @@ function App() { } }; + // PR Mode Navigation Handlers + const handleShowPRMode = useCallback(() => { + // Validation: single repository selection + if (selectedRepos.length !== 1) { + setPopupMessage('Please select exactly one repository to create a pull request.'); + setIsPopupOpen(true); + return; + } + + // Validation: GitHub provider only + if (codeHost !== 'github') { + setPopupMessage('Pull request creation is only supported for GitHub repositories.'); + setIsPopupOpen(true); + return; + } + + // Reset PR mode state + setSourceBranch(''); + setTargetBranch(''); + setTargetBranches([]); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + setPrCreationSuccess(false); + setPrUrl(''); + setPrGenerated(false); // Reset PR generated state + + setShowPRMode(true); + + // Fetch available branches + fetchAvailableBranches(); + }, [selectedRepos, codeHost, sessionId]); + + const handleBackFromPR = useCallback(() => { + if (currentWebSocket) { + currentWebSocket.close(); + setCurrentWebSocket(null); + } + setShowPRMode(false); + }, [currentWebSocket]); + + // Fetch available branches when entering PR mode + const fetchAvailableBranches = useCallback(async () => { + if (!sessionId || selectedRepos.length !== 1) return; + + setIsLoadingBranches(true); + setPrValidationMessage(''); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch( + `${backendUrl}/branches?session_id=${sessionId}&repo=${encodeURIComponent(selectedRepos[0])}`, + { method: 'GET' } + ); + + if (!response.ok) throw new Error('Failed to fetch branches'); + + const data = await response.json(); + setAvailableBranches(data.branches || []); + + if (!data.branches || data.branches.length === 0) { + setPrValidationMessage('No branches found in this repository.'); + } + } catch (error) { + console.error('Error fetching branches:', error); + setPrValidationMessage('Failed to fetch branches. Please try again.'); + } finally { + setIsLoadingBranches(false); + } + }, [sessionId, selectedRepos]); + + // Handle source branch selection + const handleSourceBranchChange = useCallback(async (branch: string) => { + setSourceBranch(branch); + setTargetBranch(''); + setTargetBranches([]); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + setPrGenerated(false); // Reset PR generated state when branch changes + + if (!branch) return; + + // Fetch valid target branches + setIsLoadingTargets(true); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/valid-target-branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: branch + }) + }); + + if (!response.ok) throw new Error('Failed to fetch valid target branches'); + + const data = await response.json(); + setTargetBranches(data.valid_target_branches || []); + + if (!data.valid_target_branches || data.valid_target_branches.length === 0) { + setPrValidationMessage('No valid target branches available for the selected source branch.'); + } + } catch (error) { + console.error('Error fetching target branches:', error); + setPrValidationMessage('Failed to fetch valid target branches. Please try again.'); + } finally { + setIsLoadingTargets(false); + } + }, [sessionId, selectedRepos]); + + // Handle target branch selection and fetch diff + const handleTargetBranchChange = useCallback(async (branch: string) => { + setTargetBranch(branch); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + setProgressActions(0); + setPrGenerated(false); // Reset PR generated state when branch changes + + if (!branch || !sourceBranch) return; + + // Fetch PR diff + setIsLoadingDiff(true); + + const progressActionsInterval = setInterval(() => { + setProgressActions((prev) => (prev < 95 ? prev + 5 : prev)); + }, 300); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/get-pull-request-diff`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: sourceBranch, + target_branch: branch + }) + }); + + if (!response.ok) throw new Error('Failed to fetch pull request diff'); + + const data = await response.json(); + + if (!data.actions) { + setPrValidationMessage('No changes found between the selected branches.'); + setPrDiff(''); + clearInterval(progressActionsInterval); + setProgressActions(100); + return; + } + + // Format commits as readable log + const formattedDiff = data.actions; + + setPrDiff(formattedDiff); + clearInterval(progressActionsInterval); + setProgressActions(100); + } catch (error) { + console.error('Error fetching PR diff:', error); + setPrValidationMessage('Failed to fetch pull request diff. Please try again.'); + clearInterval(progressActionsInterval); + setProgressActions(100); + } finally { + setIsLoadingDiff(false); + } + }, [sessionId, selectedRepos, sourceBranch]); + + // Generate PR description + const handleGeneratePRDescription = useCallback(async () => { + if (!sourceBranch || !targetBranch || !prDiff) { + setPrValidationMessage('Please select both branches and ensure there are changes to summarize.'); + return; + } + + if (currentWebSocket) { + currentWebSocket.close(); + } + + setPrDescription(''); + setIsGeneratingPR(true); + setPrValidationMessage(''); + setProgressWs(0); + + // Scroll to summary section when starting generation + setTimeout(() => { + summaryLogRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 100); + + const backendUrl = import.meta.env.VITE_AICORE_API; + const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/ws/${sessionId}/pull_request`; + const ws = new WebSocket(wsUrl); + + setCurrentWebSocket(ws); + + const progressWsInterval = setInterval(() => { + setProgressWs((prev) => (prev < 95 ? prev + 5 : prev)); + }, 500); + + ws.onopen = () => { + ws.send(JSON.stringify({ actions: prDiff })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data.toString()).chunk; + if (message === "</end>") { + clearInterval(progressWsInterval); + setProgressWs(100); + setIsGeneratingPR(false); + setPrGenerated(true); // Mark PR as generated + ws.close(); + setCurrentWebSocket(null); + } else { + setPrDescription((prev) => { + const newOutput = prev + message; + scrollToBottom(); + return newOutput; + }); + } + }; + + ws.onerror = (event) => { + console.error("WebSocket error:", event); + clearInterval(progressWsInterval); + setProgressWs(100); + setIsGeneratingPR(false); + setPrValidationMessage('Failed to generate PR description. Please try again.'); + setCurrentWebSocket(null); + }; + + ws.onclose = () => { + clearInterval(progressWsInterval); + setIsGeneratingPR(false); + setCurrentWebSocket(null); + }; + }, [sessionId, sourceBranch, targetBranch, prDiff, currentWebSocket]); + + // Create PR on GitHub + const handleCreatePR = useCallback(async () => { + if (!prDescription.trim()) { + setPrValidationMessage('Please generate a PR description first.'); + return; + } + + setIsCreatingPR(true); + setPrValidationMessage(''); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/create-pull-request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: sourceBranch, + target_branch: targetBranch, + body: prDescription + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to create pull request'); + } + + const data = await response.json(); + + if (data.success) { + setPrCreationSuccess(true); + setPrUrl(data.url); + setPopupMessage(`Pull request created successfully on GitHub from ${sourceBranch} to ${targetBranch}!`); + setIsPopupOpen(true); + } else { + throw new Error('Pull request creation was not successful'); + } + } catch (error: any) { + console.error('Error creating pull request:', error); + setPopupMessage(error.message || 'Failed to create pull request. Please try again.'); + setIsPopupOpen(true); + } finally { + setIsCreatingPR(false); + } + }, [sessionId, sourceBranch, targetBranch, prDescription, selectedRepos]); + + // 1. Add this to your state declarations (around line 60): + const [showMenu, setShowMenu] = useState(false); + + // 2. Add these handlers after handleShowPRMode (around line 280): + const handleShowReleaseMode = useCallback(() => { + setShowMenu(false); + setShowReleaseMode(true); + setShowPRMode(false); + }, []); + + const handleShowPRModeFromMenu = useCallback(() => { + setShowMenu(false); + handleShowPRMode(); + }, [handleShowPRMode]); + // Handle GitHub OAuth callback useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -457,8 +780,8 @@ function App() { value={repoUrl} onChange={(e) => setRepoUrl(e.target.value)} placeholder="Enter Git repository URL" - className="flex-grow min-w-0" // Takes remaining space - style={{ flex: '2 1 0%' }} // Explicit 2:1 ratio + className="flex-grow min-w-0" + style={{ flex: '2 1 0%' }} /> <Button onClick={handleCloneRepo} @@ -466,9 +789,9 @@ function App() { color="accent" className="flex-shrink-0" style={{ - width: '33.333%', // Force 1/3 width - flex: '1 1 0%', // Flex basis 0% - minWidth: 'fit-content' // Prevent squeezing + width: '33.333%', + flex: '1 1 0%', + minWidth: 'fit-content' }} > {isCloning ? 'Cloning...' : 'Clone'} @@ -665,90 +988,198 @@ function App() { </Accordion> </Card> - <div className="recap-release-switcher-container mt-8"> - <div className={`recap-release-switcher${showReleaseMode ? ' show-release' : ''}`}> - {/* Recap Mode */} - <div className={`recap-main-btn-area${showReleaseMode ? ' slide-left-out' : ' slide-in'}`}> + <div className={`recap-release-switcher${showReleaseMode ? ' show-release' : ''}${showPRMode ? ' show-pr' : ''}`}> + {/* Recap Mode */} + <div className={`recap-main-btn-area${showReleaseMode || showPRMode ? ' slide-left-out' : ' slide-in'}`}> + <Button + onClick={handleFullRecap} + disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized} + color="accent" + className="recap-main-btn" + > + {isExecuting ? 'Processing...' : 'Recap'} + </Button> + <div className="button-with-tooltip"> <Button - onClick={handleFullRecap} + className="recap-3dots-rect-btn" + onClick={() => setShowMenu(!showMenu)} + aria-label="Show options menu" disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized} - color="accent" - className="recap-main-btn" + type="button" > - {isExecuting ? 'Processing...' : 'Recap'} + <span className="recap-3dots-rect-inner"> + <span className="recap-dot"></span> + <span className="recap-dot"></span> + <span className="recap-dot"></span> + </span> + <span className="recap-3dots-badge"> + New + </span> </Button> - <div className="button-with-tooltip"> - <Button - className="recap-3dots-rect-btn" - onClick={() => setShowReleaseMode(true)} - aria-label="Show release notes options" - disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized} - type="button" - > - <span className="recap-3dots-rect-inner"> - <span className="recap-dot"></span> - <span className="recap-dot"></span> - <span className="recap-dot"></span> - </span> - <span className="recap-3dots-badge"> - New - </span> - </Button> - <div className="tooltip-text"> - You can now generate releases - only supported for GitHub repos (requires sign in or PAT authorization) and select one repo from a dropdown from above! + <div className="tooltip-text"> + Generate release notes or create a PR - only supported for GitHub repos (requires sign in or PAT authorization) + </div> + + <div className={`options-menu${showMenu ? ' slide-in-menu' : ' slide-out-menu'}`}> + <div className="menu-button-with-tooltip"> + <Button + className="menu-option-btn" + onClick={handleShowReleaseMode} + disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized || selectedRepos.length !== 1 || codeHost !== 'github'} + color="accent" + style={{ minWidth: '180px', maxWidth: '180px' }} + > + Generate Release Notes + </Button> + {(selectedRepos.length !== 1 || codeHost !== 'github') && ( + <div className="menu-tooltip-text"> + {codeHost !== 'github' + ? 'Only available for GitHub repositories' + : selectedRepos.length === 0 + ? 'Please select exactly one repository' + : 'Please select only one repository'} + </div> + )} + </div> + <div className="menu-button-with-tooltip"> + <Button + className="menu-option-btn" + onClick={handleShowPRModeFromMenu} + disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized || selectedRepos.length !== 1 || codeHost !== 'github'} + color="accent" + style={{ minWidth: '120px', maxWidth: '120px' }} + > + Create PR + </Button> + {(selectedRepos.length !== 1 || codeHost !== 'github') && ( + <div className="menu-tooltip-text"> + {codeHost !== 'github' + ? 'Only available for GitHub repositories' + : selectedRepos.length === 0 + ? 'Please select exactly one repository' + : 'Please select only one repository'} + </div> + )} </div> </div> </div> - {/* Release Notes Mode */} - <div className={`release-main-btn-area${showReleaseMode ? ' slide-in' : ' slide-right-out'}`}> - <Button - className="release-back-rect-btn" - onClick={() => setShowReleaseMode(false)} - disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized} - type="button" + </div> + + {/* Release Notes Mode */} + <div className={`release-main-btn-area${showReleaseMode && !showPRMode ? ' slide-in' : ' slide-right-out'}`}> + <Button + className="release-back-rect-btn" + onClick={() => setShowReleaseMode(false)} + disabled={isExecuting || isExecutingReleaseNotes || !isAuthorized} + type="button" + style={{ minWidth: '32px', height: '32px' }} + > + <span className="release-back-arrow">←</span> + <span className="release-back-label">Back</span> + </Button> + <Button + onClick={handleReleaseNotes} + disabled={isExecutingReleaseNotes || isExecuting || !isAuthorized} + color="accent" + className="release-main-btn" + > + {isExecutingReleaseNotes ? 'Processing...' : 'Generate Release Notes'} + </Button> + <div className="release-counter-rect"> + <button + onClick={() => setNumOldReleases(Math.max(1, numOldReleases - 1))} + disabled={numOldReleases <= 1 || isExecutingReleaseNotes || isExecuting} + className="counter-btn-rect" style={{ minWidth: '32px', height: '32px' }} > - <span className="release-back-arrow">←</span> - <span className="release-back-label">Back</span> - </Button> - <Button - onClick={handleReleaseNotes} - disabled={isExecutingReleaseNotes || isExecuting || !isAuthorized} - color="accent" - className="release-main-btn" + <Minus className="h-3 w-3" /> + </button> + <span className="counter-value-rect"> + {numOldReleases} + </span> + <button + onClick={() => setNumOldReleases(numOldReleases + 1)} + disabled={isExecutingReleaseNotes || isExecuting} + className="counter-btn-rect" + style={{ minWidth: '32px', height: '32px' }} > - {isExecutingReleaseNotes ? 'Processing...' : 'Generate Release Notes'} - </Button> - <div className="release-counter-rect"> - <button - onClick={() => setNumOldReleases(Math.max(1, numOldReleases - 1))} - disabled={numOldReleases <= 1 || isExecutingReleaseNotes || isExecuting} - className="counter-btn-rect" - style={{ minWidth: '32px', height: '32px' }} - > - <Minus className="h-3 w-3" /> - </button> - <span className="counter-value-rect"> - {numOldReleases} - </span> - <button - onClick={() => setNumOldReleases(numOldReleases + 1)} - disabled={isExecutingReleaseNotes || isExecuting} - className="counter-btn-rect" - style={{ minWidth: '32px', height: '32px' }} - > - <Plus className="h-3 w-3" /> - </button> - </div> + <Plus className="h-3 w-3" /> + </button> </div> </div> + + {/* PR Mode */} + <div + className={`pr-main-area${showPRMode ? ' slide-in' : ' slide-right-out'}`} + > + <Button + className="pr-back-rect-btn" + onClick={handleBackFromPR} + disabled={isGeneratingPR || isCreatingPR} + type="button" + style={{ width: '32px', height: '32px', flexShrink: 0 }} + > + <span className="pr-back-arrow">←</span> + <span className="pr-back-label">Back</span> + </Button> + + <div className="pr-branch-group-inline" style={{ flex: 1 }}> + <select + className="pr-branch-dropdown" + value={sourceBranch} + onChange={(e) => handleSourceBranchChange(e.target.value)} + disabled={isLoadingBranches || isGeneratingPR || isCreatingPR} + > + <option value="">Select source</option> + {availableBranches.map((branch) => ( + <option key={branch} value={branch}> + {branch} + </option> + ))} + </select> + </div> + + <div className="pr-branch-group-inline" style={{ flex: 1 }}> + <select + className="pr-branch-dropdown" + value={targetBranch} + onChange={(e) => handleTargetBranchChange(e.target.value)} + disabled={!sourceBranch || isLoadingTargets || isGeneratingPR || isCreatingPR} + > + <option value="">Select target</option> + {targetBranches.map((branch) => ( + <option key={branch} value={branch}> + {branch} + </option> + ))} + </select> + </div> + + <Button + onClick={handleGeneratePRDescription} + disabled={!sourceBranch || !targetBranch || !prDiff || isGeneratingPR || isCreatingPR || prGenerated} + color="accent" + className="pr-generate-btn" + style={{ width: '120px', height: '32px', flexShrink: 0 }} + > + {isGeneratingPR ? 'Generating...' : 'Generate PR'} + </Button> + + {prValidationMessage && ( + <div className="pr-validation-message-inline"> + {prValidationMessage} + </div> + )} + </div> </div> - - <div className="output-section mt-8" ref={actionsLogRef}> + + <div className={`output-section mt-8 ${showMenu ? 'menu-open' : ''}`} ref={actionsLogRef}> <Card className="output-box p-6"> - <h2 className="text-xl font-bold mb-4">Actions Log</h2> + <h2 className="text-xl font-bold mb-4"> + {showPRMode ? 'Commit Diff' : 'Actions Log'} + </h2> <ProgressBar - progress={progressActions} + progress={showPRMode && isLoadingDiff ? 50 : progressActions} size="md" color="orange" borderColor="black" @@ -756,51 +1187,64 @@ function App() { /> <TextArea readOnly - value={commitsOutput} + value={showPRMode ? prDiff : commitsOutput} rows={10} + placeholder={showPRMode ? "Select source and target branches to view commit diff..." : ""} /> </Card> </div> + <div className="output-section mt-8" ref={summaryLogRef}> <Card className="output-box p-6"> <div className="summary-header"> - <h2>Summary (by `{import.meta.env.VITE_LLM_MODEL}`)</h2> - <div className="n-selector"> - <Button - onClick={() => handleNSelection(5)} - className={`summary-n-btn ${selectedN === 5 ? 'active-btn' : ''}`} - disabled={!recapDone || isExecutingReleaseNotes || isExecuting} - > - 5 - </Button> - <Button - onClick={() => handleNSelection(10)} - className={`summary-n-btn ${selectedN === 10 ? 'active-btn' : ''}`} - disabled={!recapDone || isExecutingReleaseNotes || isExecuting} - > - 10 - </Button> - <Button - onClick={() => handleNSelection(15)} - className={`summary-n-btn ${selectedN === 15 ? 'active-btn' : ''}`} - disabled={!recapDone || isExecutingReleaseNotes || isExecuting} - > - 15 - </Button> - </div> + <h2> + {showPRMode + ? 'Pull Request Description' + : showReleaseMode + ? 'Release Notes' + : `Summary (by \`${import.meta.env.VITE_LLM_MODEL}\`)` + } + </h2> + {!showPRMode && !showReleaseMode && ( + <div className="n-selector"> + <Button + onClick={() => handleNSelection(5)} + className={`summary-n-btn ${selectedN === 5 ? 'active-btn' : ''}`} + disabled={!recapDone || isExecutingReleaseNotes || isExecuting} + > + 5 + </Button> + <Button + onClick={() => handleNSelection(10)} + className={`summary-n-btn ${selectedN === 10 ? 'active-btn' : ''}`} + disabled={!recapDone || isExecutingReleaseNotes || isExecuting} + > + 10 + </Button> + <Button + onClick={() => handleNSelection(15)} + className={`summary-n-btn ${selectedN === 15 ? 'active-btn' : ''}`} + disabled={!recapDone || isExecutingReleaseNotes || isExecuting} + > + 15 + </Button> + </div> + )} </div> <ProgressBar - progress={progressWs} + progress={showPRMode && isGeneratingPR ? 50 : progressWs} size="md" color="orange" borderColor="black" className="w-full mb-4" /> <TextArea - readOnly - value={dummyOutput} + readOnly={!showPRMode} + value={showPRMode ? prDescription : dummyOutput} + onChange={showPRMode ? (e) => setPrDescription(e.target.value) : undefined} rows={10} ref={textAreaRef} + placeholder={showPRMode ? "Click 'Generate PR Description' to create a description..." : ""} style={{ height: '500px', overflowY: 'auto', @@ -809,6 +1253,32 @@ function App() { fontFamily: 'monospace' }} /> + + {showPRMode && prCreationSuccess && prUrl && ( + <div className="pr-success-message mt-4"> + Pull request created successfully! <a href={prUrl} target="_blank" rel="noopener noreferrer">View PR</a> + </div> + )} + + {/* New PR Creation Button or Message */} + {showPRMode && prDescription && ( + <div className="pr-creation-section mt-4"> + {isPATAuthorized ? ( + <Button + onClick={handleCreatePR} + disabled={isCreatingPR || !prDescription.trim()} + color="accent" + className="pr-create-btn" + > + {isCreatingPR ? 'Creating PR...' : 'Create Pull Request on GitHub'} + </Button> + ) : ( + <div className="pr-auth-message"> + Automatic creation into GitHub is only available via Personal Access Token (PAT) authentication. + </div> + )} + </div> + )} </Card> </div> diff --git a/git_recap/providers/azure_fetcher.py b/git_recap/providers/azure_fetcher.py index 62a5b29..42a8300 100644 --- a/git_recap/providers/azure_fetcher.py +++ b/git_recap/providers/azure_fetcher.py @@ -1,7 +1,7 @@ from azure.devops.connection import Connection from msrest.authentication import BasicAuthentication from datetime import datetime -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from git_recap.providers.base_fetcher import BaseFetcher class AzureFetcher(BaseFetcher): @@ -236,4 +236,81 @@ def fetch_releases(self) -> List[Dict[str, Any]]: NotImplementedError: Always, since release fetching is not supported for AzureFetcher. """ # If Azure DevOps release fetching is supported in the future, implement logic here. - raise NotImplementedError("Release fetching is not supported for Azure DevOps (AzureFetcher).") \ No newline at end of file + raise NotImplementedError("Release fetching is not supported for Azure DevOps (AzureFetcher).") + + def get_branches(self) -> List[str]: + """ + Get all branches in the repository. + + Returns: + List[str]: List of branch names. + + Raises: + NotImplementedError: Always, since branch listing is not yet implemented for AzureFetcher. + """ + # TODO: Implement get_branches() for Azure DevOps support + # This would use: git_client.get_branches(repository_id, project) + # and extract branch names from the returned objects + raise NotImplementedError("Branch listing is not yet implemented for Azure DevOps (AzureFetcher).") + + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + + Validates that the source branch exists, filters out branches with existing + open PRs from source, excludes the source branch itself, and optionally + checks if source is ahead of target. + + Args: + source_branch (str): The source branch name. + + Returns: + List[str]: List of valid target branch names. + + Raises: + NotImplementedError: Always, since PR target validation is not yet implemented for AzureFetcher. + """ + # TODO: Implement get_valid_target_branches() for Azure DevOps support + # This would require: + # 1. Verify source_branch exists using git_client.get_branch() + # 2. Get all branches using get_branches() + # 3. Filter out source branch + # 4. Check for existing pull requests using git_client.get_pull_requests() + # 5. Filter out branches with existing open PRs from source + # 6. Optionally check branch policies and protection rules + raise NotImplementedError("Pull request target branch validation is not yet implemented for Azure DevOps (AzureFetcher).") + + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request between two branches with optional metadata. + + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + + Returns: + Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information. + + Raises: + NotImplementedError: Always, since PR creation is not yet implemented for AzureFetcher. + """ + # TODO: Implement create_pull_request() for Azure DevOps support + # This would use: git_client.create_pull_request() with appropriate parameters + # Would need to handle reviewers, work item links (assignees), labels, and draft status + raise NotImplementedError("Pull request creation is not yet implemented for Azure DevOps (AzureFetcher).") \ No newline at end of file diff --git a/git_recap/providers/base_fetcher.py b/git_recap/providers/base_fetcher.py index 618ebb7..4741cda 100644 --- a/git_recap/providers/base_fetcher.py +++ b/git_recap/providers/base_fetcher.py @@ -90,6 +90,72 @@ def fetch_releases(self) -> List[Dict[str, Any]]: """ raise NotImplementedError("Release fetching is not implemented for this provider.") + @abstractmethod + def get_branches(self) -> List[str]: + """ + Get all branches in the repository. + + Returns: + List[str]: List of branch names. + + Raises: + NotImplementedError: Subclasses must implement this method. + """ + raise NotImplementedError("Subclasses must implement get_branches() to return all repository branches") + + @abstractmethod + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + + Validates that the source branch exists, filters out branches with existing + open PRs from source, excludes the source branch itself, and optionally + checks if source is ahead of target. + + Args: + source_branch (str): The source branch name. + + Returns: + List[str]: List of valid target branch names. + + Raises: + NotImplementedError: Subclasses must implement this method. + """ + raise NotImplementedError("Subclasses must implement get_valid_target_branches() to return valid PR target branches for the given source branch") + + @abstractmethod + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request between two branches with optional metadata. + + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + + Returns: + Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information. + + Raises: + NotImplementedError: Subclasses must implement this method. + """ + raise NotImplementedError("Subclasses must implement create_pull_request() to create a pull request with the specified parameters") + def get_authored_messages(self) -> List[Dict[str, Any]]: """ Aggregates all commit, pull request, and issue entries into a single list, diff --git a/git_recap/providers/github_fetcher.py b/git_recap/providers/github_fetcher.py index 422150c..98f9cd9 100644 --- a/git_recap/providers/github_fetcher.py +++ b/git_recap/providers/github_fetcher.py @@ -1,7 +1,12 @@ from github import Github +from github import GithubException from datetime import datetime -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from git_recap.providers.base_fetcher import BaseFetcher +import logging + +logger = logging.getLogger(__name__) + class GitHubFetcher(BaseFetcher): """ @@ -59,6 +64,32 @@ def fetch_commits(self) -> List[Dict[str, Any]]: break return entries + def fetch_branch_diff_commits(self, source_branch: str, target_branch: str) -> List[Dict[str, Any]]: + entries = [] + processed_commits = set() + for repo in self.repos: + if self.repo_filter and repo.name not in self.repo_filter: + continue + try: + comparison = repo.compare(target_branch, source_branch) + for commit in comparison.commits: + commit_date = commit.commit.author.date + sha = commit.sha + if sha not in processed_commits: + entry = { + "type": "commit", + "repo": repo.name, + "message": commit.commit.message.strip(), + "timestamp": commit_date, + "sha": sha, + } + entries.append(entry) + processed_commits.add(sha) + except GithubException as e: + logger.error(f"Failed to compare branches in {repo.name}: {str(e)}") + continue + return entries + def fetch_pull_requests(self) -> List[Dict[str, Any]]: entries = [] # Maintain a local set to skip duplicate commits already captured in a PR. @@ -174,4 +205,204 @@ def fetch_releases(self) -> List[Dict[str, Any]]: except Exception: # If fetching releases fails for a repo, skip it (could be permissions or no releases) continue - return releases \ No newline at end of file + return releases + + def get_branches(self) -> List[str]: + """ + Get all branches in the repository. + Returns: + List[str]: List of branch names. + Raises: + Exception: If API rate limits are exceeded or authentication fails. + """ + logger.debug("Fetching branches from all accessible repositories") + try: + branches = [] + for repo in self.repos: + if self.repo_filter and repo.name not in self.repo_filter: + continue + logger.debug(f"Fetching branches for repository: {repo.name}") + repo_branches = repo.get_branches() + for branch in repo_branches: + branches.append(branch.name) + logger.debug(f"Successfully fetched {len(branches)} branches") + return branches + except GithubException as e: + if e.status == 403: + logger.error(f"Rate limit exceeded or authentication failed: {str(e)}") + raise Exception(f"Failed to fetch branches: Rate limit exceeded or authentication failed - {str(e)}") + elif e.status == 401: + logger.error(f"Authentication failed: {str(e)}") + raise Exception(f"Failed to fetch branches: Authentication failed - {str(e)}") + else: + logger.error(f"GitHub API error while fetching branches: {str(e)}") + raise Exception(f"Failed to fetch branches: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error while fetching branches: {str(e)}") + raise Exception(f"Failed to fetch branches: {str(e)}") + + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + Validates that the source branch exists, filters out branches with existing + open PRs from source, excludes the source branch itself, and optionally + checks if source is ahead of target. + Args: + source_branch (str): The source branch name. + Returns: + List[str]: List of valid target branch names. + Raises: + ValueError: If source branch does not exist. + Exception: If API errors occur during validation. + """ + logger.debug(f"Validating target branches for source branch: {source_branch}") + try: + all_branches = self.get_branches() + if source_branch not in all_branches: + logger.error(f"Source branch '{source_branch}' does not exist") + raise ValueError(f"Source branch '{source_branch}' does not exist") + valid_targets = [] + for repo in self.repos: + if self.repo_filter and repo.name not in self.repo_filter: + continue + logger.debug(f"Processing repository: {repo.name}") + repo_branches = [branch.name for branch in repo.get_branches()] + # Get existing open PRs from source branch + try: + open_prs = repo.get_pulls(state='open', head=source_branch) + except GithubException as e: + logger.error(f"GitHub API error while getting PRs: {str(e)}") + raise Exception(f"Failed to validate target branches: {str(e)}") + existing_pr_targets = set() + for pr in open_prs: + existing_pr_targets.add(pr.base.ref) + logger.debug(f"Found existing PR from {source_branch} to {pr.base.ref}") + for branch_name in repo_branches: + if branch_name == source_branch: + logger.debug(f"Excluding source branch: {branch_name}") + continue + if branch_name in existing_pr_targets: + logger.debug(f"Excluding branch with existing PR: {branch_name}") + continue + # Optionally check if source is ahead of target (performance cost) + valid_targets.append(branch_name) + logger.debug(f"Valid target branch: {branch_name}") + logger.debug(f"Found {len(valid_targets)} valid target branches") + return valid_targets + except ValueError: + raise + except GithubException as e: + logger.error(f"GitHub API error while validating target branches: {str(e)}") + raise Exception(f"Failed to validate target branches: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error while validating target branches: {str(e)}") + raise Exception(f"Failed to validate target branches: {str(e)}") + + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request between two branches with optional metadata. + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + Returns: + Dict[str, Any]: Dictionary containing PR metadata or error information. + Raises: + ValueError: If branches don't exist or PR already exists. + """ + logger.info(f"Creating pull request from {head_branch} to {base_branch}") + try: + all_branches = self.get_branches() + if head_branch not in all_branches: + logger.error(f"Head branch '{head_branch}' does not exist") + raise ValueError(f"Head branch '{head_branch}' does not exist") + if base_branch not in all_branches: + logger.error(f"Base branch '{base_branch}' does not exist") + raise ValueError(f"Base branch '{base_branch}' does not exist") + for repo in self.repos: + if self.repo_filter and repo.name not in self.repo_filter: + continue + logger.debug(f"Checking for existing PRs in repository: {repo.name}") + try: + existing_prs = repo.get_pulls(state='open', head=head_branch, base=base_branch) + except GithubException as e: + logger.error(f"GitHub API error while getting PRs: {str(e)}") + raise + if hasattr(existing_prs, "totalCount") and existing_prs.totalCount > 0: + logger.error(f"Pull request already exists from {head_branch} to {base_branch}") + raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}") + elif isinstance(existing_prs, list) and len(existing_prs) > 0: + logger.error(f"Pull request already exists from {head_branch} to {base_branch}") + raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}") + logger.info(f"Creating pull request in repository: {repo.name}") + try: + pr = repo.create_pull( + title=title, + body=body, + head=head_branch, + base=base_branch, + draft=draft + ) + logger.info(f"Pull request created successfully: {pr.html_url}") + if reviewers and len(reviewers) > 0: + try: + logger.debug(f"Adding reviewers: {reviewers}") + pr.create_review_request(reviewers=reviewers) + logger.info(f"Successfully added reviewers: {reviewers}") + except GithubException as e: + logger.warning(f"Failed to add reviewers: {str(e)}") + if assignees and len(assignees) > 0: + try: + logger.debug(f"Adding assignees: {assignees}") + pr.add_to_assignees(*assignees) + logger.info(f"Successfully added assignees: {assignees}") + except GithubException as e: + logger.warning(f"Failed to add assignees: {str(e)}") + if labels and len(labels) > 0: + try: + logger.debug(f"Adding labels: {labels}") + pr.add_to_labels(*labels) + logger.info(f"Successfully added labels: {labels}") + except GithubException as e: + logger.warning(f"Failed to add labels: {str(e)}") + return { + "url": pr.html_url, + "number": pr.number, + "state": pr.state, + "success": True + } + except GithubException as e: + if e.status == 404: + logger.error(f"Branch not found: {str(e)}") + raise ValueError(f"Branch not found: {str(e)}") + elif e.status == 403: + logger.error(f"Permission denied: {str(e)}") + raise GithubException(e.status, f"Permission denied: {str(e)}", e.headers) + elif e.status == 422: + logger.error(f"Merge conflict or validation error: {str(e)}") + raise ValueError(f"Merge conflict or validation error: {str(e)}") + else: + logger.error(f"GitHub API error: {str(e)}") + raise + logger.error("No repository found to create pull request") + raise ValueError("No repository found to create pull request") + except (ValueError, GithubException): + raise + except Exception as e: + logger.error(f"Unexpected error while creating pull request: {str(e)}") + raise Exception(f"Failed to create pull request: {str(e)}") \ No newline at end of file diff --git a/git_recap/providers/gitlab_fetcher.py b/git_recap/providers/gitlab_fetcher.py index 4fe4834..96c974c 100644 --- a/git_recap/providers/gitlab_fetcher.py +++ b/git_recap/providers/gitlab_fetcher.py @@ -1,6 +1,6 @@ import gitlab from datetime import datetime -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from git_recap.providers.base_fetcher import BaseFetcher class GitLabFetcher(BaseFetcher): @@ -183,5 +183,67 @@ def fetch_releases(self) -> List[Dict[str, Any]]: Raises: NotImplementedError: Always, since release fetching is not supported for GitLabFetcher. """ - # If GitLab release fetching is supported in the future, implement logic here. - raise NotImplementedError("Release fetching is not supported for GitLab (GitLabFetcher).") \ No newline at end of file + raise NotImplementedError("Release fetching is not supported for GitLab (GitLabFetcher).") + + def get_branches(self) -> List[str]: + """ + Get all branches in the repository. + + Returns: + List[str]: List of branch names. + + Raises: + NotImplementedError: Always, since branch listing is not yet implemented for GitLabFetcher. + """ + raise NotImplementedError("Branch listing is not yet implemented for GitLab (GitLabFetcher).") + + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + + Validates that the source branch exists, filters out branches with existing + open PRs from source, excludes the source branch itself, and optionally + checks if source is ahead of target. + + Args: + source_branch (str): The source branch name. + + Returns: + List[str]: List of valid target branch names. + + Raises: + NotImplementedError: Always, since PR target validation is not yet implemented for GitLabFetcher. + """ + raise NotImplementedError("Pull request target branch validation is not yet implemented for GitLab (GitLabFetcher).") + + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request (merge request) between two branches with optional metadata. + + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + + Returns: + Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information. + + Raises: + NotImplementedError: Always, since PR creation is not yet implemented for GitLabFetcher. + """ + raise NotImplementedError("Pull request (merge request) creation is not yet implemented for GitLab (GitLabFetcher).") \ No newline at end of file diff --git a/git_recap/providers/url_fetcher.py b/git_recap/providers/url_fetcher.py index ddb0f27..e4424c9 100644 --- a/git_recap/providers/url_fetcher.py +++ b/git_recap/providers/url_fetcher.py @@ -229,10 +229,67 @@ def fetch_releases(self) -> List[Dict[str, Any]]: Raises: NotImplementedError: Always, since release fetching is not supported for URLFetcher. """ - # If in the future, support for fetching releases from generic git repos is added, - # implement logic here (e.g., parse tags and annotate with metadata). raise NotImplementedError("Release fetching is not supported for generic Git URLs (URLFetcher).") + def get_branches(self) -> List[str]: + """ + Get all branches in the repository. + + Returns: + List[str]: List of branch names. + + Raises: + NotImplementedError: Always, since branch listing is not yet implemented for URLFetcher. + """ + raise NotImplementedError("Branch listing is not yet implemented for generic Git URLs (URLFetcher).") + + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + + Args: + source_branch (str): The source branch name. + + Returns: + List[str]: List of valid target branch names. + + Raises: + NotImplementedError: Always, since PR target validation is not supported for URLFetcher. + """ + raise NotImplementedError("Pull request target branch validation is not supported for generic Git URLs (URLFetcher).") + + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request between two branches. + + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + + Returns: + Dict[str, Any]: Dictionary containing PR metadata or error information. + + Raises: + NotImplementedError: Always, since PR creation is not supported for URLFetcher. + """ + raise NotImplementedError("Pull request creation is not supported for generic Git URLs (URLFetcher).") + def clear(self) -> None: """Clean up temporary directory.""" if self.temp_dir and os.path.exists(self.temp_dir): diff --git a/setup.py b/setup.py index 9227908..dff29f7 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="git-recap", - version="0.1.4", + version="0.1.5", packages=find_packages(), install_requires=[ "PyGithub==2.6.1", diff --git a/tests/test_parser.py b/tests/test_parser.py index 35ce8f0..b5bacfe 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,7 @@ import pytest from datetime import datetime from unittest.mock import Mock, patch +from github import GithubException from git_recap.utils import parse_entries_to_txt def test_parse_entries_to_txt(): @@ -234,4 +235,396 @@ def test_fetch_releases_not_implemented_providers(): url_fetcher.fetch_releases() except Exception: # If URLFetcher can't be instantiated with dummy data, that's fine - pass \ No newline at end of file + pass + + +class TestGitHubFetcherBranchOperations: + """ + Unit tests for GitHub branch management and pull request creation functionality. + """ + + @patch('git_recap.providers.github_fetcher.Github') + def test_get_branches_returns_branch_list(self, mock_github_class): + """ + Test that get_branches() returns a list of branch names from the repository. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + mock_branch1 = Mock() + mock_branch2 = Mock() + mock_branch3 = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + + # Configure mock branches + mock_branch1.name = "main" + mock_branch2.name = "develop" + mock_branch3.name = "feature/new-ui" + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2, mock_branch3] + mock_repo.name = "test-repo" + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + branches = fetcher.get_branches() + + # Assertions + assert isinstance(branches, list) + assert len(branches) == 3 + assert "main" in branches + assert "develop" in branches + assert "feature/new-ui" in branches + + @patch('git_recap.providers.github_fetcher.Github') + def test_get_valid_target_branches_filters_correctly(self, mock_github_class): + """ + Test that get_valid_target_branches() correctly filters branches. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "develop" + mock_branch3 = Mock() + mock_branch3.name = "feature-branch" + mock_branch4 = Mock() + mock_branch4.name = "hotfix" + + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2, mock_branch3, mock_branch4] + + # Mock existing PR from feature-branch to develop + mock_pr = Mock() + mock_pr.head.ref = "feature-branch" + mock_pr.base.ref = "develop" + mock_repo.get_pulls.return_value = [mock_pr] + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + valid_targets = fetcher.get_valid_target_branches("feature-branch") + + # Assertions + assert isinstance(valid_targets, list) + # Should exclude source branch (feature-branch) and branch with existing PR (develop) + assert "feature-branch" not in valid_targets + assert "develop" not in valid_targets + assert "main" in valid_targets + assert "hotfix" in valid_targets + + @patch('git_recap.providers.github_fetcher.Github') + def test_get_valid_target_branches_raises_on_invalid_source(self, mock_github_class): + """ + Test that get_valid_target_branches() raises ValueError for non-existent source branch. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches (without the source branch) + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "develop" + + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2] + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise ValueError for non-existent source branch + with pytest.raises(ValueError) as exc_info: + fetcher.get_valid_target_branches("non-existent-branch") + + assert "does not exist" in str(exc_info.value) + + @patch('git_recap.providers.github_fetcher.Github') + def test_create_pull_request_success(self, mock_github_class): + """ + Test successful pull request creation with all metadata. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + mock_pr = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "feature-branch" + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2] + + # Mock no existing PRs + mock_repo.get_pulls.return_value = [] + + # Configure mock PR creation + mock_pr.html_url = "https://github.com/test/test-repo/pull/1" + mock_pr.number = 1 + mock_pr.state = "open" + mock_repo.create_pull.return_value = mock_pr + + # Mock reviewer/assignee/label methods + mock_pr.create_review_request = Mock() + mock_pr.add_to_assignees = Mock() + mock_pr.add_to_labels = Mock() + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + result = fetcher.create_pull_request( + head_branch="feature-branch", + base_branch="main", + title="New Feature", + body="Description of new feature", + reviewers=["reviewer1"], + assignees=["assignee1"], + labels=["enhancement"] + ) + + # Assertions + assert result["success"] is True + assert result["url"] == "https://github.com/test/test-repo/pull/1" + assert result["number"] == 1 + assert result["state"] == "open" + + # Verify methods were called + mock_repo.create_pull.assert_called_once() + mock_pr.create_review_request.assert_called_once_with(reviewers=["reviewer1"]) + mock_pr.add_to_assignees.assert_called_once_with("assignee1") + mock_pr.add_to_labels.assert_called_once_with("enhancement") + + @patch('git_recap.providers.github_fetcher.Github') + def test_create_pull_request_handles_branch_not_found(self, mock_github_class): + """ + Test that create_pull_request() handles branch not found errors. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches (only main exists) + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_repo.get_branches.return_value = [mock_branch1] + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise ValueError for non-existent branch + with pytest.raises(ValueError) as exc_info: + fetcher.create_pull_request( + head_branch="non-existent", + base_branch="main", + title="Test PR", + body="Test" + ) + + assert "does not exist" in str(exc_info.value) + + @patch('git_recap.providers.github_fetcher.Github') + def test_create_pull_request_handles_existing_pr(self, mock_github_class): + """ + Test that create_pull_request() handles existing PR scenario. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "feature-branch" + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2] + + # Mock existing PR + mock_pr = Mock() + mock_pr.head.ref = "feature-branch" + mock_pr.base.ref = "main" + mock_repo.get_pulls.return_value = [mock_pr] + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise ValueError for existing PR + with pytest.raises(ValueError) as exc_info: + fetcher.create_pull_request( + head_branch="feature-branch", + base_branch="main", + title="Test PR", + body="Test" + ) + + assert "already exists" in str(exc_info.value) + + @patch('git_recap.providers.github_fetcher.Github') + def test_create_pull_request_handles_github_exception(self, mock_github_class): + """ + Test that create_pull_request() handles GithubException errors appropriately. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "feature-branch" + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2] + + # Mock no existing PRs + mock_repo.get_pulls.return_value = [] + + # Mock create_pull to raise GithubException + mock_repo.create_pull.side_effect = GithubException(403, "Permission denied", None) + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise GithubException + with pytest.raises(GithubException): + fetcher.create_pull_request( + head_branch="feature-branch", + base_branch="main", + title="Test PR", + body="Test" + ) + + @patch('git_recap.providers.github_fetcher.Github') + def test_get_branches_handles_api_errors(self, mock_github_class): + """ + Test that get_branches() handles API errors gracefully. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Mock get_branches to raise GithubException + mock_repo.get_branches.side_effect = GithubException(403, "Rate limit exceeded", None) + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise Exception with descriptive message + with pytest.raises(Exception) as exc_info: + fetcher.get_branches() + + assert "Failed to fetch branches" in str(exc_info.value) + + @patch('git_recap.providers.github_fetcher.Github') + def test_get_valid_target_branches_handles_api_errors(self, mock_github_class): + """ + Test that get_valid_target_branches() handles API errors gracefully. + """ + from git_recap.providers.github_fetcher import GitHubFetcher + + # Create mock objects + mock_github = Mock() + mock_user = Mock() + mock_repo = Mock() + + # Configure the mock hierarchy + mock_github_class.return_value = mock_github + mock_github.get_user.return_value = mock_user + mock_user.login = "testuser" + mock_user.get_repos.return_value = [mock_repo] + mock_repo.name = "test-repo" + + # Configure mock branches + mock_branch1 = Mock() + mock_branch1.name = "main" + mock_branch2 = Mock() + mock_branch2.name = "feature-branch" + mock_repo.get_branches.return_value = [mock_branch1, mock_branch2] + + # Mock get_pulls to raise GithubException + mock_repo.get_pulls.side_effect = GithubException(500, "Internal server error", None) + + # Create GitHubFetcher instance and test + fetcher = GitHubFetcher(pat="dummy_token") + + # Should raise Exception with descriptive message + with pytest.raises(Exception) as exc_info: + fetcher.get_valid_target_branches("feature-branch") + + assert "Failed to validate target branches" in str(exc_info.value) \ No newline at end of file