diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index df69dbe..a600d6c 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -3,6 +3,10 @@ import ulid import re +class CloneRequest(BaseModel): + """Request model for repository cloning endpoint.""" + url: str + class ChatRequest(BaseModel): session_id: str = "" message: str @@ -142,4 +146,67 @@ class CommitMessagesForPRDescriptionRequest(BaseModel): 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 + description: str = Field(..., description="LLM-generated pull request description.") + + +# --- Authors Endpoint Schemas --- +class AuthorInfo(BaseModel): + """Individual author information""" + name: str = Field(..., description="Author's name") + email: str = Field(..., description="Author's email address") + + +class GetAuthorsRequest(BaseModel): + """Request model for fetching authors""" + session_id: str = Field(..., description="Session identifier") + repo_names: Optional[List[str]] = Field( + default=[], + description="List of repository names to fetch authors from. Empty list fetches from all repositories." + ) + + +class GetAuthorsResponse(BaseModel): + """Response model containing list of authors""" + authors: List[AuthorInfo] = Field(..., description="List of unique authors") + total_count: int = Field(..., description="Total number of unique authors") + repo_count: int = Field(..., description="Number of repositories processed") + + +# --- Current Author Endpoint Schema --- +class GetCurrentAuthorResponse(BaseModel): + """Response model for current author endpoint.""" + author: Optional[Dict[str, str]] = Field( + None, + description="Current authenticated user's information (name and email), or None if not available" + ) + + +# --- Actions Response Schema --- +class ActionsResponse(BaseModel): + """ + Structured response for the actions endpoint. + + This model encapsulates the response from the actions endpoint, including + the list of Git actionables, an optional user-facing informational message, + and metadata about any trimming operations performed to satisfy token limits. + + Attributes: + actions: Formatted string containing Git actionables (commits, PRs, issues, etc.) + message: User-facing informational message (optional, present when trimming occurs) + trimmed_count: Number of actionables removed during trimming to satisfy token limits + total_count: Original number of actionables before any trimming was applied + """ + actions: str = Field(..., description="Formatted string of Git actionables") + message: Optional[str] = Field(None, description="User-facing informational message about trimming") + trimmed_count: int = Field(0, description="Number of items removed during trimming") + total_count: int = Field(..., description="Total number of items before trimming") + + class Config: + json_schema_extra = { + "example": { + "actions": "2025-03-14:\n - [Commit] in repo-frontend: Fix bug in authentication\n - [Pull Request] in repo-backend: Add new API endpoint (PR #42)\n\n2025-03-15:\n - [Commit] in repo-core: Update dependencies\n", + "message": "We're running the free version with a maximum token limit for contextual input. To stay within this limit, we automatically trimmed 15 older Git actionables from the context. We hope you understand!", + "trimmed_count": 15, + "total_count": 50 + } + } \ No newline at end of file diff --git a/app/api/server/routes.py b/app/api/server/routes.py index 6be4ca0..fd0458c 100644 --- a/app/api/server/routes.py +++ b/app/api/server/routes.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, Request, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field +from typing import Optional, List, Dict from models.schemas import ( BranchListResponse, @@ -7,26 +8,30 @@ ValidTargetBranchesResponse, CreatePullRequestRequest, CreatePullRequestResponse, + GetPullRequestDiffRequest, + GetPullRequestDiffResponse, + GetAuthorsRequest, + GetAuthorsResponse, + AuthorInfo, + ActionsResponse, + GetCurrentAuthorResponse, + CloneRequest ) -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 from aicore.llm.config import LlmConfig from datetime import datetime, timezone -from typing import Optional, List import requests import os router = APIRouter() -class CloneRequest(BaseModel): - """Request model for repository cloning endpoint.""" - url: str GITHUB_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token' + @router.post("/clone-repo") async def clone_repository(request: CloneRequest): """ @@ -51,12 +56,26 @@ async def clone_repository(request: CloneRequest): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to clone repository: {str(e)}") + @router.get("/external-signup") async def external_signup(app: str, accessToken: str, provider: str): + """ + Handle external OAuth signup flow. + + Args: + app: Application name + accessToken: OAuth access token or authorization code + provider: Provider name (e.g., "github") + + Returns: + dict: Contains session_id, token, and provider information + + Raises: + HTTPException: 400 for unsupported provider or token errors + """ if provider.lower() != "github": raise HTTPException(status_code=400, detail="Unsupported provider") - # Build the URL to exchange the code for a token params = { "client_id": os.getenv("VITE_GITHUB_CLIENT_ID"), "client_secret": os.getenv("VITE_GITHUB_CLIENT_SECRET"), @@ -83,6 +102,7 @@ async def external_signup(app: str, accessToken: str, provider: str): response["provider"] = provider return await store_fetcher_endpoint(response) + @router.post("/pat") async def store_fetcher_endpoint(request: Request): """ @@ -92,7 +112,7 @@ async def store_fetcher_endpoint(request: Request): request: Contains JSON payload with 'session_id' and 'pat' Returns: - dict: Contains session_id + dict: Contains session_id and username Raises: HTTPException: 400 if PAT is missing @@ -112,11 +132,10 @@ async def store_fetcher_endpoint(request: Request): username = store_fetcher(session_id, token, provider) return {"session_id": session_id, "username": username} -async def create_llm_session( - request: Optional[LlmConfig] = None -): + +async def create_llm_session(request: Optional[LlmConfig] = None): """ - Create a new LLM session with custom configuration + Create a new LLM session with custom configuration. Args: request: Optional LLM configuration @@ -136,6 +155,7 @@ async def create_llm_session( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.get("/repos") async def get_repos(session_id: str): """ @@ -153,7 +173,8 @@ async def get_repos(session_id: str): fetcher = get_fetcher(session_id) return {"repos": fetcher.repos_names} -@router.get("/actions") + +@router.get("/actions", response_model=ActionsResponse) async def get_actions( session_id: str, start_date: Optional[str] = Query(None), @@ -164,6 +185,10 @@ async def get_actions( """ Get actions for the specified session with optional filters. + Returns a structured response including the actions list, user-facing + informational message (if trimming occurred), and metadata about the + trimming operation. + Args: session_id: The session identifier start_date: Optional start date filter @@ -172,7 +197,7 @@ async def get_actions( authors: Optional list of authors to filter Returns: - dict: Contains formatted action entries + ActionsResponse: Structured response with actions, message, and metadata Raises: HTTPException: 404 if session not found @@ -183,7 +208,6 @@ async def get_actions( authors = sum([author.split(",") for author in authors], []) fetcher = get_fetcher(session_id) - # Convert date strings to datetime objects start_dt = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc) if start_date else None end_dt = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc) if end_date else None @@ -198,10 +222,37 @@ async def get_actions( llm = get_llm(session_id) actions = fetcher.get_authored_messages() - actions = trim_messages(actions, llm.tokenizer) - print(f"\n\n\n{actions=}\n\n\n") - return {"actions": parse_entries_to_txt(actions)} + # Store original count before trimming + original_count = len(actions) + + # Apply token limit trimming + trimmed_actions = trim_messages(actions, llm.tokenizer) + + # Calculate how many items were removed + trimmed_count = original_count - len(trimmed_actions) + + # Generate user-facing message if trimming occurred + message = None + if trimmed_count > 0: + message = ( + f"We're running the free version with a maximum token limit for contextual input. " + f"To stay within this limit, we automatically trimmed {trimmed_count} older Git " + f"actionable{'s' if trimmed_count != 1 else ''} from the context. " + f"We hope you understand!" + ) + + # Parse actions to text format + actions_txt = parse_entries_to_txt(trimmed_actions) + + # Return structured response + return ActionsResponse( + actions=actions_txt, + message=message, + trimmed_count=trimmed_count, + total_count=original_count + ) + @router.get("/release_notes") async def get_release_notes( @@ -211,20 +262,27 @@ async def get_release_notes( ): """ Generate release notes for the latest release of a single repository. - Validates input, fetches releases, fetches actions since latest release, and returns compiled release notes. + + Args: + session_id: The session identifier + repo_filter: Must contain exactly one repository name + num_old_releases: Number of previous releases to include for context + + Returns: + dict: Contains actions and release notes text + + Raises: + HTTPException: 400 for invalid input, 404 for session not found, 500 for errors """ - # Validate repo_filter: must be a single repo if repo_filter is None or len(repo_filter) != 1: raise HTTPException(status_code=400, detail="repo_filter must be a list containing exactly one repository name.") repo = repo_filter[0] - # Get fetcher for session try: fetcher = get_fetcher(session_id) except HTTPException: raise - # Check if fetcher supports fetch_releases try: releases = fetcher.fetch_releases() except NotImplementedError: @@ -233,7 +291,6 @@ async def get_release_notes( raise HTTPException(status_code=500, detail=f"Error fetching releases: {str(e)}") releases_txt = parse_releases_to_txt(releases[:num_old_releases]) - # Filter releases for the requested repo repo_releases = [r for r in releases if r.get("repo") == repo] n_releases = len(repo_releases) if n_releases < 1: @@ -244,7 +301,6 @@ async def get_release_notes( detail=f"num_old_releases must be at least 1 and less than the number of releases available ({n_releases}) for this repository." ) - # Sort releases by published_at descending (latest first) try: repo_releases.sort(key=lambda r: r.get("published_at") or r.get("created_at"), reverse=True) except Exception: @@ -252,11 +308,9 @@ async def get_release_notes( latest_release = repo_releases[0] - # Determine the start_date for actions (latest release date) release_date = latest_release.get("published_at") or latest_release.get("created_at") if not release_date: raise HTTPException(status_code=500, detail="Latest release does not have a valid date.") - # Accept both datetime and string if isinstance(release_date, datetime): start_date_iso = release_date.astimezone(timezone.utc).isoformat() else: @@ -266,9 +320,6 @@ async def get_release_notes( except Exception: raise HTTPException(status_code=500, detail="Release date is not a valid ISO format.") - # Fetch actions since latest release for this repo - # Reuse get_actions logic, but inline to avoid async call - # Set fetcher filters fetcher.start_date = datetime.fromisoformat(start_date_iso) fetcher.end_dt = None fetcher.repo_filter = [repo] @@ -280,14 +331,21 @@ async def get_release_notes( return {"actions": "\n\n".join([actions_txt, releases_txt])} -# --- Branch and Pull Request Management Endpoints --- + @router.get("/branches", response_model=BranchListResponse) -async def get_branches( - session_id: str, - repo: str -): +async def get_branches(session_id: str, repo: str): """ Get all branches for a given repository in the current session. + + Args: + session_id: The session identifier + repo: Repository name + + Returns: + BranchListResponse: Contains list of branch names + + Raises: + HTTPException: 400 if not supported, 404 if session not found, 500 for errors """ fetcher = get_fetcher(session_id) try: @@ -299,12 +357,20 @@ async def get_branches( 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 -): +async def get_valid_target_branches(req: ValidTargetBranchesRequest): """ Get all valid target branches for a given source branch in a repository. + + Args: + req: ValidTargetBranchesRequest containing session_id, repo, and source_branch + + Returns: + ValidTargetBranchesResponse: Contains list of valid target branch names + + Raises: + HTTPException: 400 for validation errors, 404 if session not found, 500 for errors """ fetcher = get_fetcher(req.session_id) try: @@ -318,10 +384,21 @@ async def get_valid_target_branches( 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 -): +async def create_pull_request(req: CreatePullRequestRequest): + """ + Create a pull request between two branches with optional metadata. + + Args: + req: CreatePullRequestRequest containing all PR details + + Returns: + CreatePullRequestResponse: Contains PR URL, number, state, and success status + + Raises: + HTTPException: 400 for validation errors, 404 if session not found, 500 for errors + """ fetcher = get_fetcher(req.session_id) fetcher.repo_filter = [req.repo] if not req.description or not req.description.strip(): @@ -351,8 +428,21 @@ async def create_pull_request( generated_description=None ) + @router.post("/get-pull-request-diff") async def get_pull_request_diff(req: GetPullRequestDiffRequest): + """ + Get the diff between two branches for pull request preview. + + Args: + req: GetPullRequestDiffRequest containing session_id, repo, source_branch, and target_branch + + Returns: + dict: Contains formatted commit actions between branches + + Raises: + HTTPException: 400 if not supported or GitHub only, 404 if session not found, 500 for errors + """ fetcher = get_fetcher(req.session_id) fetcher.repo_filter = [req.repo] provider = type(fetcher).__name__.lower() @@ -365,3 +455,94 @@ async def get_pull_request_diff(req: GetPullRequestDiffRequest): 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)} + + +@router.post("/authors", response_model=GetAuthorsResponse) +async def get_authors(request: GetAuthorsRequest): + """ + Retrieve list of unique authors from specified repositories. + + Args: + request: GetAuthorsRequest containing session_id and optional repo_names + + Returns: + GetAuthorsResponse with list of authors and metadata + + Raises: + HTTPException: 404 if session not found, 500 for fetcher errors + """ + try: + fetcher = get_fetcher(request.session_id) + + if not fetcher: + raise HTTPException( + status_code=404, + detail=f"Session {request.session_id} not found or expired" + ) + + authors_data = fetcher.get_authors(request.repo_names or []) + + authors = [ + AuthorInfo(name=author["name"], email=author["email"]) + for author in authors_data + ] + + response = GetAuthorsResponse( + authors=authors, + total_count=len(authors), + repo_count=len(request.repo_names) if request.repo_names else 0 + ) + + return response + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Error fetching authors: {str(e)}" + ) + + +@router.get("/current-author", response_model=GetCurrentAuthorResponse) +async def get_current_author(session_id: str = Query(..., description="Session identifier")): + """ + Retrieve the current authenticated user's information from the fetcher. + + Args: + session_id: The session identifier + + Returns: + GetCurrentAuthorResponse: Contains optional author information (name and email) + + Raises: + HTTPException: 404 if session not found, 500 for errors + """ + try: + fetcher = get_fetcher(session_id) + + if not fetcher: + raise HTTPException( + status_code=404, + detail=f"Session {session_id} not found or expired" + ) + + try: + author_info = fetcher.get_current_author() + except NotImplementedError: + author_info = None + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Error retrieving current author: {str(e)}" + ) + + return GetCurrentAuthorResponse(author=author_info) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Error fetching current author: {str(e)}" + ) \ No newline at end of file diff --git a/app/git-recap/src/App.tsx b/app/git-recap/src/App.tsx index 8065a68..1772879 100644 --- a/app/git-recap/src/App.tsx +++ b/app/git-recap/src/App.tsx @@ -1,12 +1,10 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Github, Hammer, BookText, Plus, Minus } from 'lucide-react'; +import { Github, Hammer, BookText, Plus, Minus, AlertCircle } from 'lucide-react'; import githubIcon from './assets/github-mark-white.png'; import { toPng } from 'html-to-image'; import ReactMarkdown from 'react-markdown'; import './App.css'; -import { Info } from "lucide-react"; - import { Button, Card, @@ -20,6 +18,14 @@ import { Popup } from 'pixel-retroui'; +// Type definition for the structured response from actions endpoint +interface ActionsResponse { + actions: string; + message?: string; + trimmed_count: number; + total_count: number; +} + function App() { const [pat, setPat] = useState(''); const [codeHost, setCodeHost] = useState(() => { @@ -94,6 +100,9 @@ function App() { const [showReleaseMode, setShowReleaseMode] = useState(false); const [showMenu, setShowMenu] = useState(false); + // Info message state for displaying backend notifications + const [infoMessage, setInfoMessage] = useState(null); + const actionsLogRef = useRef(null); const summaryLogRef = useRef(null); const textAreaRef = useRef(null); @@ -182,6 +191,7 @@ function App() { setProgressWs(0); setShowExportButton(false); setRecapDone(true); + setInfoMessage(null); // Clear any previous info message handleRecap(); }; @@ -287,6 +297,7 @@ function App() { setIsExecutingReleaseNotes(true); setShowExportButton(false); setRecapDone(false); + setInfoMessage(null); // Clear any previous info message setTimeout(() => { actionsLogRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 100); @@ -348,6 +359,7 @@ function App() { setProgressWs(0); setIsExecuting(true); setShowExportButton(false); + setInfoMessage(null); // Clear any previous info message setTimeout(() => { actionsLogRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 100); @@ -367,7 +379,10 @@ function App() { method: 'GET' }); if (!response.ok) throw new Error(`Request failed! Status: ${response.status}`); - const data = await response.json(); + + // Parse the structured response + const data: ActionsResponse = await response.json(); + if (!data.actions) { setPopupMessage('Got no actionables from Git. Please check your filters or date range. If you are signing with GitHub, you will need to install GitRecap from the Marketplace or authenticate with a PAT instead.'); setIsPopupOpen(true); @@ -375,7 +390,16 @@ function App() { setProgressActions(100); return; } + + // Set the actions output setCommitsOutput(data.actions); + + // Display informational message if present + if (data.message) { + setInfoMessage(data.message); + console.log(`Trimming info: ${data.trimmed_count} items removed out of ${data.total_count} total`); + } + clearInterval(progressActionsInterval); setProgressActions(100); summaryLogRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -1073,6 +1097,41 @@ function App() { setExportModalOpen(false); }, [githubUsername, badgeTheme, generateBadgeContent]); + // Info banner component for displaying backend messages + const InfoBanner: React.FC<{ message: string; onDismiss: () => void }> = ({ + message, + onDismiss + }) => ( +
+ + {message} + +
+ ); + return (
@@ -1081,6 +1140,14 @@ function App() {

Git Recap

+ {/* Display informational message if present */} + {infoMessage && ( + setInfoMessage(null)} + /> + )} +
{!isAuthorized ? ( <> @@ -1224,7 +1291,7 @@ function App() {