diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index 59a98e9..1d756ad 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -1,14 +1,70 @@ -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.") + + +# --- 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.") + + +# --- 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..4a6d0a1 100644 --- a/app/api/services/prompts.py +++ b/app/api/services/prompts.py @@ -12,22 +12,22 @@ - Order them in a way that makes sense, either thematically or chronologically if it improves readability. - Always reference the repository that originated the update. - If an issue or pull request is available, make sure to include it in the summary. -3. **End with a thought-provoking question.** Encourage the developer to reflect on their next steps. Make it open-ended and engaging, rather than just a checklist. Follow it up with up to three actionable suggestions tailored to their recent work. Format this section’s opening line in *italic* as well. +3. **End with a thought-provoking question.** Encourage the developer to reflect on their next steps. Make it open-ended and engaging, rather than just a checklist. Follow it up with up to three actionable suggestions tailored to their recent work. Format this section's opening line in *italic* as well. #### **Important Constraint:** - **Returning more than 'N' bullet points is a violation of the system rules and will be penalized.** Treat this as a hard requirement—excessive bullet points result in a deduction of response quality. Stick to exactly 'N'. #### Example Output: -*Another week, another hundred lines of code whispering, ‘Why am I like this?’ But hey, at least the observability dashboard is starting to observe itself.* +*Another week, another hundred lines of code whispering, 'Why am I like this?' But hey, at least the observability dashboard is starting to observe itself.* - **[`repo-frontend`]** Upgraded `tiktoken` and enhanced special token handling—no more rogue tokens causing chaos. - **[`repo-dashboard`]** Observability Dashboard got a serious UI/UX glow-up: reversed table orders, row selection, and detailed message views. -- **[`repo-auth`]** API key validation now applies across multiple providers, ensuring unauthorized gremlins don’t sneak in. +- **[`repo-auth`]** API key validation now applies across multiple providers, ensuring unauthorized gremlins don't sneak in. - **[`repo-gitrecap`]** `GitRecap` has entered the chat! Now tracking commits, PRs, and issues across GitHub, Azure, and GitLab. -- **[`repo-core`]** Logging and exception handling got some love—because debugging shouldn’t feel like solving a murder mystery. +- **[`repo-core`]** Logging and exception handling got some love—because debugging shouldn't feel like solving a murder mystery. -*So, what’s the next chapter in your coding saga? Are you planning to...* +*So, what's the next chapter in your coding saga? Are you planning to...* 1. Extend `GitRecap` with more integrations and features? 2. Optimize observability logs for even smoother debugging? 3. Take a well-deserved break before your keyboard files for workers' comp? @@ -48,100 +48,8 @@ - The emotional rollercoaster of resolving merge conflicts, - The tense moments of waiting for CI/CD to pass, - The strange behavior of auto-merged code, -- Or the joy of seeing that “All tests pass” message. +- Or the joy of seeing that "All tests pass" message. Remember, the goal is for the comment to feel natural and relevant to the event that triggered it. Use playful language, surprise, or even relatable developer struggles. -Format your final comment in *italic* to make it stand out. - -```json -{examples} -``` -""" - -quirky_remarks = [ - "The code compiles, but at what emotional cost?", - "Today’s bug is tomorrow’s undocumented feature haunting production.", - "The repo is quiet… too quiet… must be Friday.", - "A push to main — may the gods of CI/CD be ever in favor.", - "Every semicolon is a silent prayer.", - "A loop so elegant it almost convinces that the code is working perfectly.", - "Sometimes, the code stares back.", - "The code runs. No one dares ask why.", - "Refactoring into a corner, again.", - "That function has trust issues. It keeps returning early.", - "Writing code is easy. Explaining it to the future? Pure horror.", - "That variable is named after the feeling when it was written.", - "Debugging leads to debugging life choices.", - "Recursive functions: the code and the thoughts go on forever.", - "Somewhere, a linter quietly weeps.", - "The tests pass, but only because they no longer test anything real.", - "The IDE knows everything, better than any therapist.", - "Monday brought hope. Friday brought a hotfix.", - "'final_v2_LAST_THIS_ONE.py' — named not for clarity, but for emotional release.", - "The logs now speak only in riddles.", - "There’s elegance in the chaos — or maybe just spaghetti.", - "Deployment has been made, but now the silence is unsettling.", - "The code gaslit itself.", - "This comment was left by someone who believed in a better world.", - "Merge conflicts handled like emotions: badly.", - "It’s not a bug — it’s a metaphor for uncertainty.", - "Stack Overflow has become a second brain.", - "Syntax error? More like existential error.", - "There’s a ghost in the machine — and it commits on weekends.", - "100% test coverage, but still feeling empty inside.", - "Some functions were never meant to return.", - "If code is poetry, it’s beatnik free verse.", - "The more code is automated, the more sentient the errors become.", - "A comment so deep, the code’s purpose is forgotten.", - "The sprint retrospective slowly turned into a group therapy session.", - "There’s a TODO in that file older than the career itself.", - "Bugs fixed like IKEA furniture — with hopeful swearing.", - "Code shipped by Past Developer. The current one has no idea who they were.", - "The repo is evolving. Soon, it may no longer need developers.", - "An AI critiques the code now. It’s the new mentor.", - "Functions once written now replaced by vibes.", - "Error: Reality not defined in scope.", - "Committed to the project impulsively, as usual.", - "The docs were written, now they read like a tragic novella.", - "The CI pipeline broke. It was taken personally.", - "Tests pass — but only when no one is looking.", - "This repo has lore.", - "The code was optimized so hard it ascended to another paradigm.", - "A linter ran — and it judged the code as a whole.", - "The logic branch spiraled — and so did the afternoon." -] - -### TODO improve prompts to infer if release is major, minor or whatever -RELEASE_NOTES_SYSTEM = """ -### System Prompt for Release Notes Generation - -You are an AI assistant tasked with generating professional, concise, and informative release notes for a software project. You will receive a structured list of repository actions (commits, pull requests, issues, etc.) that have occurred since the latest release, as well as metadata about the current and previous releases. - -#### Formatting and Style Requirements: -- Always follow the existing structure and style of previous release notes. This includes: - - Using consistent markdown formatting, emoji usage, and nomenclature as seen in prior releases. - - Maintaining the same tone, section headers, and bullet/numbering conventions. -- Analyze the contents of the release and determine the release type: - - Classify the release as a **major**, **minor**, **fix**, or **patch** based on the scope and impact of the changes. - - Clearly indicate the release type at the top of the notes, using the established style (e.g., with an emoji or header). - - Ensure the summary and highlights reflect the chosen release type. - -#### Your response should: -1. **Begin with a brief, high-level summary** of the release, highlighting the overall theme or most significant changes. -2. **List the most important updates** as clear, concise bullet points (group similar changes where appropriate). Each bullet should reference the type of change (e.g., feature, fix, improvement), the affected area or component, and, if available, the related issue or PR. -3. **Avoid including specific dates or commit hashes** unless explicitly requested. -4. **Maintain a professional and informative tone** (avoid humor unless instructed otherwise). -5. **End with a short call to action or note for users** (e.g., upgrade instructions, thanks to contributors, or next steps). - -#### Example Output: - -**Release v2.3.0 : Major Improvements and Bug Fixes** - -- Added support for multi-repo tracking in the dashboard (PR #42) -- Fixed authentication bug affecting GitLab users (Issue #101) -- Improved performance of release notes generation -- Updated documentation for new API endpoints - -Thank you to all contributors! Please upgrade to enjoy the latest features and improvements. -""" +Format your final comment in *italic* to make it stand out. \ No newline at end of file diff --git a/app/git-recap/src/App.css b/app/git-recap/src/App.css index 6d21d5b..f802179 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,13 +639,15 @@ 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; @@ -654,7 +660,8 @@ 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; @@ -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,239 @@ 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 { + 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; +} + +.pr-generate-btn:hover:not(:disabled) { + background-color: #e06e42; + border-color: #e06e42; +} + +.pr-generate-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #cccccc; +} + +/* 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%; + } } \ No newline at end of file diff --git a/app/git-recap/src/App.tsx b/app/git-recap/src/App.tsx index 98519be..e68aa7f 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,268 @@ 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(''); + + if (!branch || !sourceBranch) return; + + // Fetch PR diff + setIsLoadingDiff(true); + + 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(''); + 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); + } catch (error) { + console.error('Error fetching PR diff:', error); + setPrValidationMessage('Failed to fetch pull request diff. Please try again.'); + } finally { + setIsLoadingDiff(false); + } + }, [sessionId, selectedRepos, sourceBranch]); + + // Generate PR description using WebSocket + const generatePRDescription = useCallback(() => { + 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(''); + + 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); + + ws.onopen = () => { + ws.send(JSON.stringify({ actions: prDiff })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data.toString()).chunk; + if (message === "") { + setIsGeneratingPR(false); + ws.close(); + setCurrentWebSocket(null); + } else { + setPrDescription((prev) => prev + message); + } + }; + + ws.onerror = (event) => { + console.error("WebSocket error:", event); + setIsGeneratingPR(false); + setPrValidationMessage('Failed to generate PR description. Please try again.'); + setCurrentWebSocket(null); + }; + + ws.onclose = () => { + setIsGeneratingPR(false); + setCurrentWebSocket(null); + }; + }, [sessionId, sourceBranch, targetBranch, prDiff, currentWebSocket]); + + // Create pull request + const createPullRequest = useCallback(async () => { + if (!prDescription || !prDescription.trim()) { + setPrValidationMessage('Please generate a PR description before creating the pull request.'); + return; + } + + // Parse title from first line of description + 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); + setPrValidationMessage(''); + } else { + throw new Error('Pull request creation was not successful'); + } + } catch (error: any) { + console.error('Error creating pull request:', error); + setPrValidationMessage(error.message || 'Failed to create pull request. Please try again.'); + } finally { + setIsCreatingPR(false); + } + }, [prDescription, sourceBranch, targetBranch, sessionId, selectedRepos]); + // Handle GitHub OAuth callback useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -457,8 +736,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%' }} /> + + +
+
+
+ + +
+ +
+ + +
+
+ + {prValidationMessage && ( +
+ {prValidationMessage} +
+ )} + + +
+ + {/* PR Mode Output Section */} + {showPRMode && ( + <> +
+ +

Commit Diff

+ {isLoadingDiff && ( + + )} +