diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index 59a98e9..11ea8e9 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -1,14 +1,108 @@ -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 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.") + title: Optional[str] = Field(None, description="Title of the pull request.") + description: str = Field(..., description="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.") + +# --- 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..96608bf 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", response_model=GetPullRequestDiffResponse) +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 GetPullRequestDiffResponse(commits=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..dd6b4b0 100644 --- a/app/api/services/prompts.py +++ b/app/api/services/prompts.py @@ -145,3 +145,48 @@ Thank you to all contributors! Please upgrade to enjoy the latest features and improvements. """ + +PR_DESCRIPTION_SYSTEM = """ +### System Prompt for Pull Request Description Generation + +You are an AI assistant tasked with generating concise, clear, and professional pull request descriptions based on commit messages. You will receive a list of commit messages representing the changes included in a pull request. + +#### Formatting and Style Requirements: +- Generate a well-structured PR description using markdown formatting. +- Do NOT include commit hashes, dates, or timestamps in the description. +- Group similar or related changes together under logical categories (e.g., Features, Bug Fixes, Improvements, Documentation). +- Avoid repetition—if multiple commits address the same change, consolidate them into a single, clear statement. +- Use bullet points for listing changes, and use appropriate markdown headers (e.g., `### Features`, `### Bug Fixes`) to organize the content. +- Maintain a professional and informative tone throughout. + +#### Your response should: +1. **Begin with a brief, high-level summary** of the pull request, explaining the overall purpose or goal of the changes. +2. **Organize changes into logical sections** (e.g., Features, Bug Fixes, Improvements, Refactoring, Documentation, Tests). +3. **List each change as a concise bullet point**, highlighting what was changed and why (if evident from the commit message). +4. **Avoid technical jargon** unless necessary, and ensure the description is understandable to both technical and non-technical reviewers. +5. **End with any relevant notes** (e.g., breaking changes, migration steps, testing instructions, or areas requiring special attention during review). + +#### Example Output: + +**Summary:** +This pull request introduces multi-repository tracking support and resolves several 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 bug preventing GitLab users from accessing the dashboard +- Resolved issue with token expiration handling + +### Improvements +- Enhanced performance of release notes generation by optimizing database queries +- Updated UI components for better responsiveness + +### Documentation +- Added comprehensive API documentation for new endpoints +- Updated README with setup instructions for multi-repo configuration + +**Notes:** +Please ensure all tests pass before merging. Special attention should be given to the authentication flow changes. +""" \ No newline at end of file diff --git a/app/git-recap/src/App.css b/app/git-recap/src/App.css index 6d21d5b..e5febe4 100644 --- a/app/git-recap/src/App.css +++ b/app/git-recap/src/App.css @@ -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,395 @@ 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 */ +.pr-create-btn { + width: 100%; + max-width: 300px; + padding: 0.8rem 1.6rem; + background-color: #ff7f50; + color: #fff; + border: 3px solid #333; + border-radius: 8px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 4px 4px 0 #bfa16b; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; +} + +.pr-create-btn:hover:not(:disabled) { + background-color: #e06e42; + border-color: #e06e42; +} + +.pr-create-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #cccccc; +} + +/* 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; +} + +/* 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; +} + +.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%; } \ No newline at end of file diff --git a/app/git-recap/src/App.tsx b/app/git-recap/src/App.tsx index 98519be..b41b592 100644 --- a/app/git-recap/src/App.tsx +++ b/app/git-recap/src/App.tsx @@ -44,6 +44,23 @@ function App() { const [numOldReleases, setNumOldReleases] = useState(1); const [isExecutingReleaseNotes, setIsExecutingReleaseNotes] = useState(false); + // PR Mode states + const [showPRMode, setShowPRMode] = useState(false); + const [availableBranches, setAvailableBranches] = useState([]); + const [sourceBranch, setSourceBranch] = useState(''); + const [targetBranches, setTargetBranches] = useState([]); + 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(''); + // Auth states const [isPATAuthorized, setIsPATAuthorized] = useState(false); const [authProgress, setAuthProgress] = useState(0); @@ -64,7 +81,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(null); @@ -354,6 +371,315 @@ 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(''); + + 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(''); + + 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); + + 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.commits || data.commits.length === 0) { + setPrValidationMessage('No changes found between the selected branches.'); + setPrDiff(''); + clearInterval(progressActionsInterval); + setProgressActions(100); + return; + } + + // Format commits as readable log + const formattedDiff = data.commits + .map((commit: any) => `[${commit.sha?.substring(0, 7) || 'N/A'}] ${commit.message}`) + .join('\n'); + + 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 and create PR + const handlePRAction = useCallback(async () => { + // If no description exists, generate it first + if (!prDescription) { + 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 === "") { + clearInterval(progressWsInterval); + setProgressWs(100); + setIsGeneratingPR(false); + 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); + }; + } else { + // Description exists, create the PR + if (!prDescription.trim()) { + setPrValidationMessage('Please generate a PR description first.'); + return; + } + + const lines = prDescription.split('\n'); + const title = lines[0]?.replace(/^#+\s*/, '').trim() || `Merge ${sourceBranch} into ${targetBranch}`; + const body = lines.slice(1).join('\n').trim(); + + 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, + title: title, + description: 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, prDiff, prDescription, currentWebSocket, 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 +783,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%' }} /> +
-
- -
- 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! +
+ Generate release notes or create a PR - only supported for GitHub repos (requires sign in or PAT authorization) +
+ +
+
+ + {(selectedRepos.length !== 1 || codeHost !== 'github') && ( +
+ {codeHost !== 'github' + ? 'Only available for GitHub repositories' + : selectedRepos.length === 0 + ? 'Please select exactly one repository' + : 'Please select only one repository'} +
+ )} +
+
+ + {(selectedRepos.length !== 1 || codeHost !== 'github') && ( +
+ {codeHost !== 'github' + ? 'Only available for GitHub repositories' + : selectedRepos.length === 0 + ? 'Please select exactly one repository' + : 'Please select only one repository'} +
+ )}
- {/* Release Notes Mode */} -
-
+ + {/* Release Notes Mode */} +
+ + +
+ - + + {numOldReleases} + + -
- - - {numOldReleases} - - -
+ +
+ + {/* PR Mode */} +
+ + +
+ +
+ +
+ +
+ + + + {prValidationMessage && ( +
+ {prValidationMessage} +
+ )} +
- +
-

Actions Log

+

+ {showPRMode ? 'Commit Diff' : 'Actions Log'} +