diff --git a/pyproject.toml b/pyproject.toml index 0acf543..5efd28d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "httpx>=0.27", "google-genai>=0.3", # default provider (Gemini 3 Flash / Flash-Lite) "anthropic>=0.34", # secondary provider, same LLMProvider interface + "pyperclip>=1.8.2", ] [project.optional-dependencies] diff --git a/src/gitmate/changelog.py b/src/gitmate/changelog.py new file mode 100644 index 0000000..b721b6d --- /dev/null +++ b/src/gitmate/changelog.py @@ -0,0 +1,265 @@ +"""Changelog generation from conventional commits and git diffs.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from rich.console import Console + +from gitmate import config as config_mod +from gitmate.config import GitmateConfig, SecretStore +from gitmate.diff_extractor import DiffExtractor, GitCommandError, GitRunner, SubprocessGitRunner +from gitmate.fallback import generate_fallback_changelog +from gitmate.orchestrator import GenerationResult, check_budget_cap, run_generation_pipeline +from gitmate.providers.base import LLMProvider +from gitmate.token_budget import TokenCounter + +logger = logging.getLogger("gitmate.changelog") + +CONVENTIONAL_PATTERN = re.compile( + r"^(?P[a-zA-Z]+)(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.*)$" +) + +CATEGORIES: list[str] = [ + "Features", + "Bug Fixes", + "Performance & Refactoring", + "Documentation", + "Maintenance & Tooling", + "Other Changes", +] + +TYPE_TO_CATEGORY: dict[str, str] = { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance & Refactoring", + "refactor": "Performance & Refactoring", + "docs": "Documentation", + "chore": "Maintenance & Tooling", + "build": "Maintenance & Tooling", + "ci": "Maintenance & Tooling", + "test": "Maintenance & Tooling", + "style": "Maintenance & Tooling", +} + + +@dataclass(frozen=True) +class CommitLogEntry: + """Parsed commit record with conventional commit metadata.""" + + commit_hash: str + subject: str + body: str + author: str + date: str + commit_type: str | None = None + scope: str | None = None + description: str | None = None + is_breaking: bool = False + + +def parse_commit_message( + commit_hash: str, + subject: str, + body: str = "", + author: str = "", + date: str = "", +) -> CommitLogEntry: + """Parse a single commit subject into conventional commit type, scope, and description.""" + match = CONVENTIONAL_PATTERN.match(subject.strip()) + if match: + c_type = match.group("type").lower() + scope = match.group("scope") + desc = match.group("desc").strip() + is_breaking = bool(match.group("breaking")) or ( + "BREAKING CHANGE:" in body or "BREAKING-CHANGE:" in body + ) + return CommitLogEntry( + commit_hash=commit_hash, + subject=subject.strip(), + body=body.strip(), + author=author.strip(), + date=date.strip(), + commit_type=c_type, + scope=scope, + description=desc, + is_breaking=is_breaking, + ) + + is_breaking = "BREAKING CHANGE:" in body or "BREAKING-CHANGE:" in body + return CommitLogEntry( + commit_hash=commit_hash, + subject=subject.strip(), + body=body.strip(), + author=author.strip(), + date=date.strip(), + commit_type=None, + scope=None, + description=subject.strip(), + is_breaking=is_breaking, + ) + + +def extract_commits_between( + from_ref: str, + to_ref: str = "HEAD", + runner: GitRunner | None = None, + repo: Path | None = None, +) -> list[CommitLogEntry]: + """Retrieve and parse git commits in the revision range from_ref..to_ref.""" + git_runner = runner if runner is not None else SubprocessGitRunner() + repo_dir = repo if repo is not None else Path.cwd() + + # Use ASCII record separator \x1e between commits and \x00 between fields + output = git_runner.run( + ["log", f"{from_ref}..{to_ref}", "--format=%H%x00%s%x00%b%x00%an%x00%ad%x1e"], + repo_dir, + ) + + commits: list[CommitLogEntry] = [] + records = output.split("\x1e") + for rec in records: + rec = rec.strip() + if not rec: + continue + parts = rec.split("\x00") + if len(parts) >= 5: + commit_hash, subject, body, author, date = ( + parts[0], + parts[1], + parts[2], + parts[3], + parts[4], + ) + commits.append( + parse_commit_message( + commit_hash=commit_hash, + subject=subject, + body=body, + author=author, + date=date, + ) + ) + elif len(parts) >= 2: + commits.append( + parse_commit_message( + commit_hash=parts[0], + subject=parts[1], + ) + ) + + return commits + + +def group_commits(commits: list[CommitLogEntry]) -> dict[str, list[CommitLogEntry]]: + """Group commits into conventional categories with an explicit 'Other Changes' bucket.""" + grouped: dict[str, list[CommitLogEntry]] = {cat: [] for cat in CATEGORIES} + + for c in commits: + if c.commit_type and c.commit_type in TYPE_TO_CATEGORY: + category = TYPE_TO_CATEGORY[c.commit_type] + else: + category = "Other Changes" + grouped[category].append(c) + + return grouped + + +def format_commits_for_prompt(grouped: dict[str, list[CommitLogEntry]]) -> str: + """Format grouped commits into markdown sections for prompt template substitution.""" + sections: list[str] = [] + for category, items in grouped.items(): + if not items: + continue + sections.append(f"### {category}") + for item in items: + scope_prefix = f"({item.scope}): " if item.scope else ": " + t = item.commit_type or "other" + breaking_marker = "!" if item.is_breaking else "" + desc = item.description or item.subject + sections.append(f"- [{item.commit_hash[:7]}] {t}{breaking_marker}{scope_prefix}{desc}") + sections.append("") + + return "\n".join(sections).strip() + + +def generate_changelog( + from_ref: str, + to_ref: str = "HEAD", + include_diff: bool = False, + output_file: Path | None = None, + cfg: GitmateConfig | None = None, + diff_extractor: DiffExtractor | None = None, + git_runner: GitRunner | None = None, + provider: LLMProvider | None = None, + counter: TokenCounter | None = None, + console: Console | None = None, + secret_store: SecretStore | None = None, + bypass_cache: bool = False, + repo_dir: Path | None = None, +) -> GenerationResult: + """Generate structured release notes between two git references.""" + con = console if console is not None else Console() + active_cfg = cfg if cfg is not None else config_mod.load_config() + + check_budget_cap(active_cfg, console=con) + + repo_path = repo_dir if repo_dir is not None else Path.cwd() + runner = git_runner if git_runner is not None else SubprocessGitRunner() + + commits = extract_commits_between(from_ref, to_ref, runner=runner, repo=repo_path) + if not commits: + return GenerationResult( + text=f"No commits found between '{from_ref}' and '{to_ref}'.", + is_fallback=False, + model=active_cfg.model, + ) + + extractor = ( + diff_extractor + if diff_extractor is not None + else DiffExtractor(repo=repo_path, runner=runner) + ) + diffs = [] + if include_diff: + try: + diffs = extractor.rev_range(from_ref, to_ref) + except GitCommandError as exc: + con.print( + f"[yellow]warning: Could not extract diffs ({exc}), proceeding with commits only.[/yellow]" + ) + + grouped = group_commits(commits) + formatted_commits = format_commits_for_prompt(grouped) + + gen_result = run_generation_pipeline( + diffs=diffs, + cfg=active_cfg, + template_name="changelog", + fallback_generator=lambda: generate_fallback_changelog( + commits, from_ref=from_ref, to_ref=to_ref + ), + template_vars={ + "commits": formatted_commits, + "from_ref": from_ref, + "to_ref": to_ref, + }, + command="changelog", + provider=provider, + counter=counter, + console=con, + secret_store=secret_store, + bypass_cache=bypass_cache, + ) + + if output_file is not None: + try: + output_file.write_text(gen_result.text + "\n", encoding="utf-8") + con.print(f"[green]✔ Changelog written to {output_file}[/green]") + except OSError as exc: + con.print(f"[red]error writing changelog file:[/red] {exc}") + + return gen_result diff --git a/src/gitmate/cli.py b/src/gitmate/cli.py index 0544bfd..038e8b4 100644 --- a/src/gitmate/cli.py +++ b/src/gitmate/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +from pathlib import Path import typer from rich.console import Console @@ -20,6 +21,7 @@ app.add_typer(config_app, name="config") console = Console() +err_console = Console(stderr=True) secret_store: config_mod.SecretStore = config_mod.KeyringSecretStore() @@ -117,16 +119,97 @@ def stats( console.print(cmd_table) -@app.command() -def pr_summary() -> None: - """Generate a PR description for the current branch.""" - _stub("pr-summary", "coming in Phase 7") +@app.command("pr-summary") +def pr_summary( + base: str = typer.Option("main", "--base", "-b", help="Base branch to compare against."), + title: str | None = typer.Option(None, "--title", "-t", help="PR title for gh pr create."), + copy: bool = typer.Option(True, "--copy/--no-copy", help="Copy summary to clipboard."), + create_pr: bool = typer.Option( + False, "--create-pr", help="Open GitHub PR create flow with gh CLI." + ), + bypass_cache: bool = typer.Option(False, "--bypass-cache", help="Bypass cached generations."), +) -> None: + """Generate a PR description by comparing HEAD against a base branch.""" + from rich.markdown import Markdown + from rich.panel import Panel + from gitmate.diff_extractor import GitCommandError + from gitmate.orchestrator import BudgetCapExceededError + from gitmate.pr_summary import GitHubCliError, generate_pr_summary -@app.command() -def changelog() -> None: - """Generate a changelog section for a tag range.""" - _stub("changelog", "coming in Phase 7") + try: + res = generate_pr_summary( + base=base, + title=title, + console=err_console, + secret_store=secret_store, + bypass_cache=bypass_cache, + copy_to_cb=copy, + create_pr=create_pr, + ) + except (GitCommandError, BudgetCapExceededError, GitHubCliError) as exc: + err_console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None + + if res.text.startswith("No changes between"): + err_console.print(f"[dim]{res.text}[/dim]") + return + + status_badge = ( + "[yellow bold]⚠ FALLBACK TEMPLATE[/yellow bold]" + if res.is_fallback + else f"[green bold]{res.model}[/green bold]" + ) + panel = Panel( + Markdown(res.text), + title=f"PR Summary [dim]({base}...HEAD) ({status_badge})[/dim]", + border_style="yellow" if res.is_fallback else "green", + padding=(1, 2), + ) + console.print(panel) + + +@app.command("changelog") +def changelog( + from_ref: str = typer.Option( + ..., "--from", "-f", help="Starting git revision or tag (exclusive)." + ), + to_ref: str = typer.Option( + "HEAD", "--to", "-t", help="Ending git revision or tag (inclusive)." + ), + output: Path | None = typer.Option( # noqa: B008 + None, "--output", "-o", help="Output file path (prints to stdout if omitted)." + ), + include_diff: bool = typer.Option( + False, "--include-diff", help="Include diff summaries in addition to commits." + ), + bypass_cache: bool = typer.Option(False, "--bypass-cache", help="Bypass cached generations."), +) -> None: + """Generate a changelog section between two git tags or revisions.""" + from gitmate.changelog import generate_changelog + from gitmate.diff_extractor import GitCommandError + from gitmate.orchestrator import BudgetCapExceededError + + try: + res = generate_changelog( + from_ref=from_ref, + to_ref=to_ref, + include_diff=include_diff, + output_file=output, + console=err_console, + secret_store=secret_store, + bypass_cache=bypass_cache, + ) + except (GitCommandError, BudgetCapExceededError) as exc: + err_console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None + + if res.text.startswith("No commits found"): + err_console.print(f"[dim]{res.text}[/dim]") + return + + if output is None: + typer.echo(res.text) @app.command() diff --git a/src/gitmate/committer.py b/src/gitmate/committer.py index 2b7606d..8445b64 100644 --- a/src/gitmate/committer.py +++ b/src/gitmate/committer.py @@ -19,9 +19,13 @@ from gitmate.config import GitmateConfig from gitmate.diff_extractor import DiffExtractor, GitCommandError from gitmate.fallback import generate_commit_message -from gitmate.metrics import get_monthly_spend, record_invocation +from gitmate.orchestrator import ( + BudgetCapExceededError, + check_budget_cap, + safe_record_telemetry, +) from gitmate.providers.base import LLMProvider -from gitmate.token_budget import TokenCounter +from gitmate.token_budget import HeuristicTokenCounter, TokenCounter logger = logging.getLogger("gitmate.committer") @@ -30,31 +34,7 @@ "# with '#' will be ignored, and an empty message aborts the commit.\n" ) - -def _safe_record_telemetry( - command: str, - model: str, - tokens_in: int | None, - tokens_out: int | None, - cache_hit: bool, - latency_ms: int | None, - fallback_used: bool, - estimated_cost_usd: float | None = None, -) -> None: - """Record invocation to metrics database without propagating errors.""" - try: - record_invocation( - command=command, - model=model, - tokens_in=tokens_in, - tokens_out=tokens_out, - cache_hit=cache_hit, - latency_ms=latency_ms, - fallback_used=fallback_used, - estimated_cost_usd=estimated_cost_usd, - ) - except Exception as exc: # noqa: BLE001 - logger.warning("Failed to record telemetry: %s", exc) +_safe_record_telemetry = safe_record_telemetry def _resolve_editor() -> str: @@ -235,30 +215,16 @@ def commit_flow( return 1 # 3. Monthly budget cap enforcement - if active_cfg.budget_cap_usd is not None: - monthly_spend = get_monthly_spend() - if monthly_spend >= active_cfg.budget_cap_usd: - con.print( - f"[red]error:[/red] Monthly budget cap exceeded " - f"(${monthly_spend:.2f} >= ${active_cfg.budget_cap_usd:.2f}). " - "Aborting to prevent further LLM spend.\n" - "To adjust or remove the cap, run: gitmate config set budget_cap_usd " - ) - return 1 - if monthly_spend >= 0.8 * active_cfg.budget_cap_usd: - con.print( - f"[yellow]warning: Monthly spend has reached {int(monthly_spend / active_cfg.budget_cap_usd * 100)}% " - f"of budget cap (${monthly_spend:.2f} / ${active_cfg.budget_cap_usd:.2f}).[/yellow]" - ) - - active_counter = counter - if active_counter is None and provider is not None: - - class _SimpleCounter(TokenCounter): - def count_tokens(self, text: str, model: str) -> int: - return max(1, len(text) // 4) + try: + check_budget_cap(active_cfg, console=con) + except BudgetCapExceededError: + return 1 - active_counter = _SimpleCounter() + active_counter = ( + counter + if counter is not None + else (HeuristicTokenCounter() if provider is not None else None) + ) # 4. Initial generation & telemetry recording t0 = time.perf_counter() @@ -325,21 +291,10 @@ def count_tokens(self, text: str, model: str) -> int: _render_panel(con, current_message, current_model, current_is_fallback) elif choice in ("r", "regenerate"): - if active_cfg.budget_cap_usd is not None: - monthly_spend = get_monthly_spend() - if monthly_spend >= active_cfg.budget_cap_usd: - con.print( - f"[red]error:[/red] Monthly budget cap exceeded " - f"(${monthly_spend:.2f} >= ${active_cfg.budget_cap_usd:.2f}). " - "Aborting to prevent further LLM spend.\n" - "To adjust or remove the cap, run: gitmate config set budget_cap_usd " - ) - continue - if monthly_spend >= 0.8 * active_cfg.budget_cap_usd: - con.print( - f"[yellow]warning: Monthly spend has reached {int(monthly_spend / active_cfg.budget_cap_usd * 100)}% " - f"of budget cap (${monthly_spend:.2f} / ${active_cfg.budget_cap_usd:.2f}).[/yellow]" - ) + try: + check_budget_cap(active_cfg, console=con) + except BudgetCapExceededError: + continue con.print("Regenerating commit message with fresh API call...") t0 = time.perf_counter() diff --git a/src/gitmate/config.py b/src/gitmate/config.py index 6e78a14..b44fbe0 100644 --- a/src/gitmate/config.py +++ b/src/gitmate/config.py @@ -32,6 +32,8 @@ # the configured default above. "gemini-3.5-flash": 1_048_576, "gemini-3-flash": 1_048_576, + "gemini-3-flash-preview": 1_048_576, + "gemini-3-flash-lite-preview": 1_048_576, "gemini-flash": 1_048_576, "claude-3-5-sonnet": 200_000, "claude-3-5-haiku": 200_000, diff --git a/src/gitmate/fallback.py b/src/gitmate/fallback.py index d6b66a0..374ef42 100644 --- a/src/gitmate/fallback.py +++ b/src/gitmate/fallback.py @@ -2,31 +2,30 @@ from __future__ import annotations -import os -from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from gitmate import config as config_mod -from gitmate.chunking import summarize_omitted_diffs -from gitmate.config import API_KEY_ACCOUNT, GitmateConfig +from gitmate.config import GitmateConfig from gitmate.diff_extractor import FileDiff -from gitmate.metrics import calculate_cost -from gitmate.prompt import load_template, render_commit_prompt -from gitmate.providers.base import LLMProvider, ProviderUnavailable -from gitmate.providers.cache import CachedProvider, cache_key -from gitmate.providers.gemini import GeminiProvider -from gitmate.token_budget import ( - BudgetStrategy, - GeminiTokenCounter, - TokenBudgetError, - TokenBudgetManager, - TokenCounter, -) +from gitmate.orchestrator import GenerationResult, run_generation_pipeline +from gitmate.providers.base import LLMProvider +from gitmate.token_budget import TokenCounter + +__all__ = [ + "GenerationResult", + "generate_commit_message", + "generate_fallback_changelog", + "generate_fallback_message", + "generate_fallback_pr_summary", + "run_generation_pipeline", +] if TYPE_CHECKING: from rich.console import Console + from gitmate.changelog import CommitLogEntry + DOC_EXTENSIONS = {".md", ".rst", ".txt", ".adoc"} BUILD_FILENAMES = { "pyproject.toml", @@ -44,20 +43,6 @@ } -@dataclass(frozen=True) -class GenerationResult: - """Outcome of commit message generation, tracking fallback and usage.""" - - text: str - is_fallback: bool - model: str - fallback_reason: str | None = None - input_tokens: int | None = None - output_tokens: int | None = None - cache_hit: bool = False - estimated_cost_usd: float = 0.0 - - def _extract_scope(path_str: str) -> str | None: """Infer a concise scope from a file path, omitting redundant top-level directory names.""" path = Path(path_str) @@ -217,6 +202,58 @@ def _multi_file_fallback(diffs: list[FileDiff], style: str) -> str: return f"{header}\n\n{body}" +def generate_fallback_pr_summary(diffs: list[FileDiff], base: str = "main") -> str: + """Deterministic offline fallback PR summary when LLM provider is unavailable.""" + if not diffs: + return f"## Summary\n\nNo changes between `{base}` and `HEAD`.\n" + + total_added = sum(d.additions for d in diffs) + total_deleted = sum(d.deletions for d in diffs) + summary = ( + f"## Summary\n\n" + f"Branch comparison `{base}...HEAD` touching {len(diffs)} file(s) " + f"(+{total_added}, -{total_deleted}).\n\n" + f"## Changes\n" + ) + bullets: list[str] = [] + for d in diffs: + if d.is_binary: + bullets.append(f"- Binary file: `{d.path}`") + elif d.status == "added": + bullets.append(f"- Add `{d.path}` (+{d.additions})") + elif d.status == "deleted": + bullets.append(f"- Remove `{d.path}` (-{d.deletions})") + elif d.status == "renamed": + bullets.append(f"- Rename `{d.old_path or 'file'}` -> `{d.path}`") + else: + bullets.append(f"- Modify `{d.path}` (+{d.additions}, -{d.deletions})") + summary += "\n".join(bullets) + "\n\n" + summary += "## Verification & Testing\n\n- Deterministic template fallback generated offline.\n" + return summary + + +def generate_fallback_changelog(commits: list[CommitLogEntry], from_ref: str, to_ref: str) -> str: + """Deterministic offline fallback release notes when LLM provider is unavailable.""" + if not commits: + return f"# Release Notes ({from_ref}..{to_ref})\n\nNo commits in revision range.\n" + + from gitmate.changelog import group_commits + + grouped = group_commits(commits) + sections: list[str] = [f"# Release Notes ({from_ref}..{to_ref})", ""] + for category, category_commits in grouped.items(): + if not category_commits: + continue + sections.append(f"## {category}") + for c in category_commits: + scope_prefix = f"**{c.scope}**: " if c.scope else "" + breaking_prefix = "⚠ **BREAKING**: " if c.is_breaking else "" + desc = c.description if c.description else c.subject + sections.append(f"- {breaking_prefix}{scope_prefix}{desc} ({c.commit_hash[:7]})") + sections.append("") + return "\n".join(sections).strip() + "\n" + + def generate_commit_message( diffs: list[FileDiff], cfg: GitmateConfig, @@ -227,149 +264,15 @@ def generate_commit_message( bypass_cache: bool = False, ) -> GenerationResult: """Orchestrate commit message generation with graceful fallback on provider failure.""" - if not diffs: - return GenerationResult( - text="No changes staged for commit.", - is_fallback=False, - model=cfg.model, - ) - - store = secret_store if secret_store is not None else config_mod.KeyringSecretStore() - api_key = store.get_secret(API_KEY_ACCOUNT) or os.environ.get("GEMINI_API_KEY") - - # Resolve template and provider upfront so chunk summarization can use the active provider - active_template = load_template(cfg.commit_style) - version_key = active_template.version_key - - active_provider: LLMProvider - if provider is not None: - if bypass_cache and isinstance(provider, CachedProvider): - active_provider = provider.underlying - else: - active_provider = provider - else: - base_provider = GeminiProvider(api_key=api_key) - if bypass_cache: - active_provider = base_provider - else: - active_provider = CachedProvider( - provider=base_provider, - cache_dir=cfg.cache_dir, - template_version=version_key, - ) - - # 1. Budget evaluation with fallback on counter/budget failure - active_counter = counter if counter is not None else GeminiTokenCounter(api_key=api_key) - try: - budget_mgr = TokenBudgetManager.from_config(cfg, active_counter) - decision = budget_mgr.assess(diffs) - except TokenBudgetError as exc: - if console is not None: - console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") - fallback_msg = generate_fallback_message(diffs, style=cfg.commit_style) - return GenerationResult( - text=fallback_msg, - is_fallback=True, - model=cfg.model, - fallback_reason=f"Token budgeting failed: {exc}", - ) - - # 2. Generation / Chunk-and-summarize with graceful fallback on provider failure - total_input_tokens = 0 - total_output_tokens = 0 - chunk_cost = 0.0 - - try: - if decision.strategy is BudgetStrategy.NEEDS_CHUNKING: - if not decision.omitted_diffs: - if console is not None: - console.print( - "[yellow]⚠ Diff exceeds token budget, using template fallback[/yellow]" - ) - return GenerationResult( - text=generate_fallback_message(diffs, style=cfg.commit_style), - is_fallback=True, - model=cfg.model, - fallback_reason=decision.summary_note, - ) - - # Chunk and summarize omitted diffs preserving original patches - merged_summaries, chunk_in, chunk_out = summarize_omitted_diffs( - decision.omitted_diffs, - active_provider, - cfg.model, - ) - total_input_tokens += chunk_in - total_output_tokens += chunk_out - if chunk_in or chunk_out: - chunk_cost = calculate_cost( - model=cfg.model, - tokens_in=chunk_in or None, - tokens_out=chunk_out or None, - cache_hit=False, - fallback_used=False, - free_tier=cfg.free_tier, - ) - - summary_note = ( - f"{decision.summary_note}\n\nSummaries of large omitted files:\n{merged_summaries}" - ) - patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text] - diff_text = "\n".join(patch_chunks) - else: - patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text] - diff_text = "\n".join(patch_chunks) - summary_note = decision.summary_note - - # Render prompt template - prompt_text, _ = render_commit_prompt( - style=cfg.commit_style, - diff_text=diff_text, - summary_note=summary_note, - ) - - cache_hit = False - if not bypass_cache and isinstance(active_provider, CachedProvider): - key = cache_key(prompt_text, cfg.model, active_provider._template_version) - cache_hit = active_provider._cache.get(key, default=None) is not None - - resp = active_provider.generate(prompt=prompt_text, model=cfg.model) - if resp.input_tokens is not None: - total_input_tokens += resp.input_tokens - if resp.output_tokens is not None: - total_output_tokens += resp.output_tokens - - final_cost = calculate_cost( - model=resp.model, - tokens_in=resp.input_tokens, - tokens_out=resp.output_tokens, - cache_hit=cache_hit, - fallback_used=False, - free_tier=cfg.free_tier, - ) - cost = round(chunk_cost + final_cost, 6) - - return GenerationResult( - text=resp.text, - is_fallback=False, - model=resp.model, - input_tokens=total_input_tokens or None, - output_tokens=total_output_tokens or None, - cache_hit=cache_hit, - estimated_cost_usd=cost, - ) - - except ProviderUnavailable as exc: - if console is not None: - console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") - - fallback_msg = generate_fallback_message(diffs, style=cfg.commit_style) - return GenerationResult( - text=fallback_msg, - is_fallback=True, - model=cfg.model, - input_tokens=total_input_tokens or None, - output_tokens=total_output_tokens or None, - estimated_cost_usd=chunk_cost, - fallback_reason=str(exc), - ) + return run_generation_pipeline( + diffs=diffs, + cfg=cfg, + template_name=cfg.commit_style, + fallback_generator=lambda: generate_fallback_message(diffs, style=cfg.commit_style), + empty_diff_message="No changes staged for commit.", + provider=provider, + counter=counter, + console=console, + secret_store=secret_store, + bypass_cache=bypass_cache, + ) diff --git a/src/gitmate/orchestrator.py b/src/gitmate/orchestrator.py new file mode 100644 index 0000000..ab10816 --- /dev/null +++ b/src/gitmate/orchestrator.py @@ -0,0 +1,295 @@ +"""Central LLM generation pipeline with token budgeting, chunking, caching, and fallback.""" + +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable +from dataclasses import dataclass + +from rich.console import Console + +from gitmate import config as config_mod +from gitmate.chunking import summarize_omitted_diffs +from gitmate.config import API_KEY_ACCOUNT, GitmateConfig, SecretStore +from gitmate.diff_extractor import FileDiff +from gitmate.metrics import calculate_cost, get_monthly_spend, record_invocation +from gitmate.prompt import load_template, render_prompt +from gitmate.providers.base import LLMProvider, ProviderUnavailable +from gitmate.providers.cache import CachedProvider, cache_key +from gitmate.providers.gemini import GeminiProvider +from gitmate.token_budget import ( + BudgetStrategy, + GeminiTokenCounter, + HeuristicTokenCounter, + TokenBudgetError, + TokenBudgetManager, + TokenCounter, +) + +logger = logging.getLogger("gitmate.orchestrator") + + +class BudgetCapExceededError(Exception): + """Raised when monthly LLM spend has reached or exceeded the configured budget cap.""" + + +@dataclass(frozen=True) +class GenerationResult: + """Outcome of generation, tracking fallback, tokens, and estimated cost.""" + + text: str + is_fallback: bool + model: str + fallback_reason: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + cache_hit: bool = False + estimated_cost_usd: float = 0.0 + + +def safe_record_telemetry( + command: str, + model: str, + tokens_in: int | None, + tokens_out: int | None, + cache_hit: bool, + latency_ms: int | None, + fallback_used: bool, + estimated_cost_usd: float | None = None, + free_tier: bool = False, +) -> None: + """Record invocation to metrics database without propagating errors.""" + try: + record_invocation( + command=command, + model=model, + tokens_in=tokens_in, + tokens_out=tokens_out, + cache_hit=cache_hit, + latency_ms=latency_ms, + fallback_used=fallback_used, + estimated_cost_usd=estimated_cost_usd, + free_tier=free_tier, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to record telemetry: %s", exc) + + +def check_budget_cap(cfg: GitmateConfig, console: Console | None = None) -> None: + """Check whether monthly spend has exceeded or is near the configured cap. + + Raises BudgetCapExceededError if spend is at or above cap. + """ + if cfg.budget_cap_usd is None: + return + + monthly_spend = get_monthly_spend() + if monthly_spend >= cfg.budget_cap_usd: + if console is not None: + console.print( + f"[red]error:[/red] Monthly budget cap exceeded " + f"(${monthly_spend:.2f} >= ${cfg.budget_cap_usd:.2f}). " + "Aborting to prevent further LLM spend.\n" + "To adjust or remove the cap, run: gitmate config set budget_cap_usd " + ) + raise BudgetCapExceededError( + f"Monthly budget cap exceeded (${monthly_spend:.2f} >= ${cfg.budget_cap_usd:.2f})." + ) + + if monthly_spend >= 0.8 * cfg.budget_cap_usd and console is not None: + console.print( + f"[yellow]warning: Monthly spend has reached {int(monthly_spend / cfg.budget_cap_usd * 100)}% " + f"of budget cap (${monthly_spend:.2f} / ${cfg.budget_cap_usd:.2f}).[/yellow]" + ) + + +def run_generation_pipeline( + diffs: list[FileDiff], + cfg: GitmateConfig, + template_name: str, + fallback_generator: Callable[[], str], + template_vars: dict[str, str] | None = None, + empty_diff_message: str | None = None, + command: str | None = None, + provider: LLMProvider | None = None, + counter: TokenCounter | None = None, + console: Console | None = None, + secret_store: SecretStore | None = None, + bypass_cache: bool = False, +) -> GenerationResult: + """Execute unified generation: token budgeting, chunking, caching, LLM call, and fallback.""" + if empty_diff_message is not None and not diffs: + return GenerationResult( + text=empty_diff_message, + is_fallback=False, + model=cfg.model, + ) + + check_budget_cap(cfg, console=console) + + store = secret_store if secret_store is not None else config_mod.KeyringSecretStore() + api_key = store.get_secret(API_KEY_ACCOUNT) or os.environ.get("GEMINI_API_KEY") + + active_template = load_template(template_name) + version_key = active_template.version_key + + active_provider: LLMProvider + if provider is not None: + if bypass_cache and isinstance(provider, CachedProvider): + active_provider = provider.underlying + else: + active_provider = provider + else: + base_provider = GeminiProvider(api_key=api_key) + if bypass_cache: + active_provider = base_provider + else: + active_provider = CachedProvider( + provider=base_provider, + cache_dir=cfg.cache_dir, + template_version=version_key, + ) + + active_counter: TokenCounter + if counter is not None: + active_counter = counter + elif provider is not None: + active_counter = HeuristicTokenCounter() + else: + active_counter = GeminiTokenCounter(api_key=api_key) + + t0 = time.perf_counter() + gen_result: GenerationResult + + try: + try: + budget_mgr = TokenBudgetManager.from_config(cfg, active_counter) + decision = budget_mgr.assess(diffs) + except TokenBudgetError as exc: + if console is not None: + console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") + gen_result = GenerationResult( + text=fallback_generator(), + is_fallback=True, + model=cfg.model, + fallback_reason=f"Token budgeting failed: {exc}", + ) + return gen_result + + total_input_tokens = 0 + total_output_tokens = 0 + chunk_cost = 0.0 + + if decision.strategy is BudgetStrategy.NEEDS_CHUNKING: + if not decision.omitted_diffs: + if console is not None: + console.print( + "[yellow]⚠ Diff exceeds token budget, using template fallback[/yellow]" + ) + gen_result = GenerationResult( + text=fallback_generator(), + is_fallback=True, + model=cfg.model, + fallback_reason=decision.summary_note, + ) + return gen_result + + merged_summaries, chunk_in, chunk_out = summarize_omitted_diffs( + decision.omitted_diffs, + active_provider, + cfg.model, + ) + total_input_tokens += chunk_in + total_output_tokens += chunk_out + if chunk_in or chunk_out: + chunk_cost = calculate_cost( + model=cfg.model, + tokens_in=chunk_in or None, + tokens_out=chunk_out or None, + cache_hit=False, + fallback_used=False, + free_tier=cfg.free_tier, + ) + + summary_note = ( + f"{decision.summary_note}\n\nSummaries of large omitted files:\n{merged_summaries}" + ) + patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text] + diff_text = "\n".join(patch_chunks) + else: + patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text] + diff_text = "\n".join(patch_chunks) + summary_note = decision.summary_note + + render_kwargs: dict[str, str] = { + "diff": diff_text, + "summary_note": summary_note, + } + if template_vars: + render_kwargs.update(template_vars) + + prompt_text, _ = render_prompt(template_name, **render_kwargs) + + cache_hit = False + if not bypass_cache and isinstance(active_provider, CachedProvider): + key = cache_key(prompt_text, cfg.model, active_provider._template_version) + cache_hit = active_provider._cache.get(key, default=None) is not None + + resp = active_provider.generate(prompt=prompt_text, model=cfg.model) + if resp.input_tokens is not None: + total_input_tokens += resp.input_tokens + if resp.output_tokens is not None: + total_output_tokens += resp.output_tokens + + final_cost = calculate_cost( + model=resp.model, + tokens_in=resp.input_tokens, + tokens_out=resp.output_tokens, + cache_hit=cache_hit, + fallback_used=False, + free_tier=cfg.free_tier, + ) + cost = round(chunk_cost + final_cost, 6) + + gen_result = GenerationResult( + text=resp.text, + is_fallback=False, + model=resp.model, + input_tokens=total_input_tokens or None, + output_tokens=total_output_tokens or None, + cache_hit=cache_hit, + estimated_cost_usd=cost, + ) + return gen_result + + except ProviderUnavailable as exc: + if console is not None: + console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") + + gen_result = GenerationResult( + text=fallback_generator(), + is_fallback=True, + model=cfg.model, + input_tokens=total_input_tokens or None, + output_tokens=total_output_tokens or None, + estimated_cost_usd=chunk_cost, + fallback_reason=str(exc), + ) + return gen_result + + finally: + latency_ms = int((time.perf_counter() - t0) * 1000) + if command is not None and "gen_result" in locals(): + safe_record_telemetry( + command=command, + model=gen_result.model, + tokens_in=gen_result.input_tokens, + tokens_out=gen_result.output_tokens, + cache_hit=gen_result.cache_hit, + latency_ms=latency_ms, + fallback_used=gen_result.is_fallback, + estimated_cost_usd=gen_result.estimated_cost_usd, + free_tier=cfg.free_tier, + ) diff --git a/src/gitmate/pr_summary.py b/src/gitmate/pr_summary.py new file mode 100644 index 0000000..9ef2a71 --- /dev/null +++ b/src/gitmate/pr_summary.py @@ -0,0 +1,136 @@ +"""Pull request summary generation, clipboard integration, and GitHub CLI dispatch.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from pathlib import Path + +from rich.console import Console + +from gitmate import config as config_mod +from gitmate.config import GitmateConfig, SecretStore +from gitmate.diff_extractor import DiffExtractor +from gitmate.fallback import generate_fallback_pr_summary +from gitmate.orchestrator import GenerationResult, check_budget_cap, run_generation_pipeline +from gitmate.providers.base import LLMProvider +from gitmate.token_budget import TokenCounter + +logger = logging.getLogger("gitmate.pr_summary") + + +class GitHubCliError(Exception): + """Raised when gh pr create fails or is not found.""" + + +def copy_to_clipboard(text: str, console: Console | None = None) -> bool: + """Copy text to system clipboard, gracefully handling headless or unsupported systems. + + Returns True if successfully copied, False if clipboard is unavailable. + """ + try: + import pyperclip # type: ignore[import-untyped] + + pyperclip.copy(text) + return True + except Exception as exc: # noqa: BLE001 + if console is not None: + console.print( + f"[dim]Note: Clipboard unavailable ({exc}). PR summary displayed in terminal.[/dim]" + ) + return False + + +def create_github_pr( + base: str, + summary: str, + title: str | None = None, + cwd: Path | None = None, + console: Console | None = None, +) -> int: + """Dispatch to gh pr create CLI if installed, passing generated summary as body.""" + gh_path = shutil.which("gh") + if not gh_path: + if console is not None: + console.print( + "[yellow]warning: GitHub CLI ('gh') not found on PATH. " + "Install gh to create pull requests automatically.[/yellow]" + ) + return 1 + + repo_dir = cwd if cwd is not None else Path.cwd() + cmd = ["gh", "pr", "create", "--base", base, "--body", summary] + if title: + cmd.extend(["--title", title]) + + try: + proc = subprocess.run(cmd, cwd=repo_dir, check=False) + return proc.returncode + except Exception as exc: # noqa: BLE001 + if console is not None: + console.print(f"[red]error running gh pr create:[/red] {exc}") + return 1 + + +def generate_pr_summary( + base: str = "main", + title: str | None = None, + cfg: GitmateConfig | None = None, + diff_extractor: DiffExtractor | None = None, + provider: LLMProvider | None = None, + counter: TokenCounter | None = None, + console: Console | None = None, + secret_store: SecretStore | None = None, + bypass_cache: bool = False, + copy_to_cb: bool = True, + create_pr: bool = False, + repo_dir: Path | None = None, +) -> GenerationResult: + """Extract branch comparison diffs and generate a structured PR description.""" + con = console if console is not None else Console() + active_cfg = cfg if cfg is not None else config_mod.load_config() + + check_budget_cap(active_cfg, console=con) + + extractor = diff_extractor if diff_extractor is not None else DiffExtractor(repo=repo_dir) + diffs = extractor.branch_comparison(base=base) + + if not diffs: + return GenerationResult( + text=f"No changes between '{base}' and HEAD.", + is_fallback=False, + model=active_cfg.model, + ) + + gen_result = run_generation_pipeline( + diffs=diffs, + cfg=active_cfg, + template_name="pr_summary", + fallback_generator=lambda: generate_fallback_pr_summary(diffs, base=base), + template_vars={"base_branch": base}, + command="pr_summary", + provider=provider, + counter=counter, + console=con, + secret_store=secret_store, + bypass_cache=bypass_cache, + ) + + if copy_to_cb: + copied = copy_to_clipboard(gen_result.text, console=con) + if copied and con is not None: + con.print("[dim]✔ Copied PR summary to clipboard.[/dim]") + + if create_pr: + code = create_github_pr( + base=base, + summary=gen_result.text, + title=title, + cwd=repo_dir, + console=con, + ) + if code != 0: + raise GitHubCliError(f"GitHub CLI ('gh pr create') failed with exit code {code}.") + + return gen_result diff --git a/src/gitmate/prompt.py b/src/gitmate/prompt.py index c21d98e..d512901 100644 --- a/src/gitmate/prompt.py +++ b/src/gitmate/prompt.py @@ -12,6 +12,7 @@ "pr_summary": "pr_summary", "commit_conventional": "commit_conventional", "commit_plain": "commit_plain", + "changelog": "changelog", } @@ -96,8 +97,13 @@ def load_template(style: str) -> PromptTemplate: return parse_template_content(content, name=template_name) +def render_prompt(template_name_or_style: str, **kwargs: str) -> tuple[str, str]: + """Render any versioned template with kwargs and return (prompt_text, version_key).""" + template = load_template(template_name_or_style) + rendered = template.render(**kwargs) + return rendered, template.version_key + + def render_commit_prompt(style: str, diff_text: str, summary_note: str = "") -> tuple[str, str]: """Render a commit prompt for the given style and return (prompt, version_key).""" - template = load_template(style) - rendered = template.render(diff=diff_text, summary_note=summary_note) - return rendered, template.version_key + return render_prompt(style, diff=diff_text, summary_note=summary_note) diff --git a/src/gitmate/templates/changelog.txt b/src/gitmate/templates/changelog.txt new file mode 100644 index 0000000..f42278c --- /dev/null +++ b/src/gitmate/templates/changelog.txt @@ -0,0 +1,39 @@ +version: 2 +--- +You are an expert software engineer generating a clean, comprehensive release changelog in GitHub Markdown. + +Given the list of git commits and optional diff notes for the revision range $from_ref..$to_ref below, organize and synthesize them into a polished changelog entry. + +Format: +# Release Notes ($to_ref) + +## Features +- Concise summary of user-facing additions (with commit refs or scopes if applicable). + +## Bug Fixes +- Concise summary of resolved issues and fixes. + +## Performance & Refactoring +- Improvements to performance, architecture, and code hygiene. + +## Maintenance & Tooling +- Dependency updates, CI/CD tweaks, tests, and chores. + +## Other Changes +- Any additional modifications not fitting the above categories. + +Rules: +1. Synthesize related commits into unified bullet points where appropriate, rather than mindlessly repeating raw commit messages. +2. Maintain technical accuracy; do not invent features not reflected in the commits. +3. If a section has no changes, omit that section entirely. +4. Highlight breaking changes prominently under Features or their relevant section. +5. Output ONLY the markdown release notes — no introductory pleasantries or closing remarks. + +Release Range: $from_ref..$to_ref +$summary_note + +Commits: +$commits + +Diff Summary: +$diff diff --git a/src/gitmate/token_budget.py b/src/gitmate/token_budget.py index 1c33e84..a606f0c 100644 --- a/src/gitmate/token_budget.py +++ b/src/gitmate/token_budget.py @@ -84,6 +84,14 @@ def count_tokens(self, text: str, model: str) -> int: raise TokenCountError(f"failed to count tokens via Gemini SDK: {exc}") from exc +class HeuristicTokenCounter: + """Test/fallback estimation only (~4 chars/token); not accurate enough for real budget enforcement.""" + + def count_tokens(self, text: str, model: str) -> int: + """Estimate token count using 4 chars/token heuristic with a minimum of 1.""" + return max(1, len(text) // 4) + + class BudgetStrategy(str, Enum): """Strategy decided by the token budget manager.""" diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..763df3a --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,341 @@ +"""Unit and integration tests for changelog generation, commit parsing, grouping, and output.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from conftest import commit_file +from rich.console import Console + +from gitmate.changelog import ( + CommitLogEntry, + extract_commits_between, + format_commits_for_prompt, + generate_changelog, + group_commits, + parse_commit_message, +) +from gitmate.config import GitmateConfig +from gitmate.diff_extractor import GitCommandError +from gitmate.metrics import get_invocations, record_invocation +from gitmate.orchestrator import BudgetCapExceededError +from gitmate.providers.base import LLMProvider, LLMResponse, ProviderUnavailable + + +class FakeProvider(LLMProvider): + """In-memory provider recording calls and returning static responses.""" + + def __init__(self, response: str = "# Release Notes\n\n## Features\n- New feature") -> None: + self.response = response + self.calls: list[tuple[str, str]] = [] + + def generate(self, prompt: str, model: str) -> LLMResponse: + self.calls.append((prompt, model)) + return LLMResponse( + text=self.response, + model=model, + input_tokens=200, + output_tokens=50, + ) + + +class FailingProvider(LLMProvider): + """Provider simulating API outage.""" + + def generate(self, prompt: str, model: str) -> LLMResponse: + raise ProviderUnavailable("API connection refused") + + +@pytest.fixture +def metrics_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolate metrics database in temp directory.""" + m_dir = tmp_path / "metrics" + monkeypatch.setenv("GITMATE_METRICS_DIR", str(m_dir)) + return m_dir + + +def test_parse_commit_message_conventional_with_scope() -> None: + entry = parse_commit_message( + commit_hash="abc1234567890", + subject="feat(auth): implement oauth2 flow", + body="Body details", + author="Alice", + date="2026-09-18", + ) + assert entry.commit_type == "feat" + assert entry.scope == "auth" + assert entry.description == "implement oauth2 flow" + assert entry.author == "Alice" + + +def test_parse_commit_message_conventional_breaking() -> None: + entry = parse_commit_message( + commit_hash="def456", + subject="fix(db)!: drop legacy table", + ) + assert entry.commit_type == "fix" + assert entry.scope == "db" + assert entry.description == "drop legacy table" + assert entry.is_breaking is True + + +def test_parse_commit_message_body_breaking() -> None: + entry = parse_commit_message( + commit_hash="def456", + subject="feat(api): new endpoint", + body="BREAKING CHANGE: old endpoint removed", + ) + assert entry.is_breaking is True + + +def test_parse_commit_message_multiline_breaking_footer() -> None: + multiline_body = ( + "Migrate internal auth from HMAC tokens to OAuth2 bearer tokens.\n\n" + "This improves security and aligns with current RFC specifications.\n\n" + "Reviewed-on: https://github.com/example/repo/pull/42\n" + "BREAKING CHANGE: HMAC tokens are no longer accepted on API v2 endpoints." + ) + entry = parse_commit_message( + commit_hash="def789", + subject="feat(auth): transition to oauth2 tokens", + body=multiline_body, + ) + assert entry.commit_type == "feat" + assert entry.scope == "auth" + assert entry.description == "transition to oauth2 tokens" + assert entry.is_breaking is True + + +def test_parse_commit_message_multiline_breaking_hyphen_footer() -> None: + multiline_body = ( + "Remove deprecated configuration parameters.\n\n" + "BREAKING-CHANGE: The 'legacy_mode' setting has been completely removed." + ) + entry = parse_commit_message( + commit_hash="abc999", + subject="refactor(config): prune legacy config options", + body=multiline_body, + ) + assert entry.commit_type == "refactor" + assert entry.is_breaking is True + + +def test_parse_commit_message_non_conventional() -> None: + entry = parse_commit_message( + commit_hash="ghi789", + subject="random cleanup commit without prefix", + ) + assert entry.commit_type is None + assert entry.scope is None + assert entry.description == "random cleanup commit without prefix" + + +def test_group_commits_categorization_and_other_changes() -> None: + commits = [ + CommitLogEntry("1", "feat(ui): new button", "", "Dev", "", "feat", "ui", "new button"), + CommitLogEntry("2", "fix: fix off by one", "", "Dev", "", "fix", None, "fix off by one"), + CommitLogEntry("3", "docs: update readme", "", "Dev", "", "docs", None, "update readme"), + CommitLogEntry( + "4", "refactor: clean up loop", "", "Dev", "", "refactor", None, "clean up loop" + ), + CommitLogEntry("5", "chore: bump deps", "", "Dev", "", "chore", None, "bump deps"), + CommitLogEntry( + "6", "unformatted message", "", "Dev", "", None, None, "unformatted message" + ), + ] + + grouped = group_commits(commits) + assert len(grouped["Features"]) == 1 + assert len(grouped["Bug Fixes"]) == 1 + assert len(grouped["Documentation"]) == 1 + assert len(grouped["Performance & Refactoring"]) == 1 + assert len(grouped["Maintenance & Tooling"]) == 1 + assert len(grouped["Other Changes"]) == 1 + + # Verify zero dropped commits + total_grouped = sum(len(items) for items in grouped.values()) + assert total_grouped == len(commits) + + +def test_format_commits_for_prompt() -> None: + commits = [ + CommitLogEntry( + "123456789", "feat(cli): add stats", "", "Dev", "", "feat", "cli", "add stats" + ), + CommitLogEntry( + "987654321", + "fix(auth)!: drop tokens", + "", + "Dev", + "", + "fix", + "auth", + "drop tokens", + is_breaking=True, + ), + ] + grouped = group_commits(commits) + formatted = format_commits_for_prompt(grouped) + assert "### Features" in formatted + assert "[1234567] feat(cli): add stats" in formatted + assert "### Bug Fixes" in formatted + assert "[9876543] fix!(auth): drop tokens" in formatted + + +def test_extract_commits_between_real_git(git_repo: Path) -> None: + subprocess.run(["git", "tag", "v1.0.0"], cwd=git_repo, check=True) + commit_file(git_repo, "a.py", "a=1", "feat(core): add module a") + commit_file(git_repo, "b.py", "b=2", "fix(core): fix bug in b") + subprocess.run(["git", "tag", "v1.1.0"], cwd=git_repo, check=True) + + commits = extract_commits_between("v1.0.0", "v1.1.0", repo=git_repo) + assert len(commits) == 2 + subjects = [c.subject for c in commits] + assert "fix(core): fix bug in b" in subjects + assert "feat(core): add module a" in subjects + + +def test_extract_commits_between_multiline_breaking_footer(git_repo: Path) -> None: + subprocess.run(["git", "tag", "v2.0.0"], cwd=git_repo, check=True) + file_path = git_repo / "breaking.py" + file_path.write_text("API_V2 = True\n") + subprocess.run(["git", "add", "breaking.py"], cwd=git_repo, check=True) + subprocess.run( + [ + "git", + "commit", + "-m", + "feat(api): overhaul authorization", + "-m", + "Replaces API keys with scoped OAuth tokens.", + "-m", + "BREAKING CHANGE: Plain API keys are no longer supported.", + ], + cwd=git_repo, + check=True, + ) + subprocess.run(["git", "tag", "v2.1.0"], cwd=git_repo, check=True) + + commits = extract_commits_between("v2.0.0", "v2.1.0", repo=git_repo) + assert len(commits) == 1 + assert commits[0].commit_type == "feat" + assert commits[0].scope == "api" + assert commits[0].is_breaking is True + assert "Plain API keys are no longer supported" in commits[0].body + + +def test_generate_changelog_empty_range(git_repo: Path) -> None: + res = generate_changelog(from_ref="HEAD", to_ref="HEAD", repo_dir=git_repo) + assert res.is_fallback is False + assert "No commits found" in res.text + + +def test_generate_changelog_with_mock_provider(git_repo: Path, metrics_dir: Path) -> None: + subprocess.run(["git", "tag", "v1.0.0"], cwd=git_repo, check=True) + commit_file(git_repo, "feat.py", "feat = True", "feat(api): expose new endpoint") + subprocess.run(["git", "tag", "v1.1.0"], cwd=git_repo, check=True) + + provider = FakeProvider(response="# Release Notes v1.1.0\n\n## Features\n- New API endpoint") + cfg = GitmateConfig(model="gemini-3-flash-preview") + + res = generate_changelog( + from_ref="v1.0.0", + to_ref="v1.1.0", + cfg=cfg, + provider=provider, + repo_dir=git_repo, + ) + + assert res.is_fallback is False + assert "Release Notes v1.1.0" in res.text + assert len(provider.calls) == 1 + assert "expose new endpoint" in provider.calls[0][0] + + # Verify telemetry was recorded + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "changelog" + assert invocations[0].model == "gemini-3-flash-preview" + assert invocations[0].tokens_in == 200 + assert invocations[0].tokens_out == 50 + + +def test_generate_changelog_fallback_on_provider_error(git_repo: Path, metrics_dir: Path) -> None: + subprocess.run(["git", "tag", "v1.0.0"], cwd=git_repo, check=True) + commit_file(git_repo, "fix.py", "fix = True", "fix: correct calculation") + subprocess.run(["git", "tag", "v1.1.0"], cwd=git_repo, check=True) + + provider = FailingProvider() + cfg = GitmateConfig(model="gemini-3-flash-preview") + + res = generate_changelog( + from_ref="v1.0.0", + to_ref="v1.1.0", + cfg=cfg, + provider=provider, + repo_dir=git_repo, + ) + + assert res.is_fallback is True + assert "# Release Notes (v1.0.0..v1.1.0)" in res.text + assert "## Bug Fixes" in res.text + assert "correct calculation" in res.text + + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "changelog" + assert invocations[0].fallback_used == 1 + + +def test_generate_changelog_writes_to_file(git_repo: Path, tmp_path: Path) -> None: + subprocess.run(["git", "tag", "v1.0.0"], cwd=git_repo, check=True) + commit_file(git_repo, "c.py", "c = 3", "chore: setup config") + subprocess.run(["git", "tag", "v1.1.0"], cwd=git_repo, check=True) + + out_file = tmp_path / "CHANGELOG.md" + provider = FakeProvider(response="# Release Notes\n- Done") + + res = generate_changelog( + from_ref="v1.0.0", + to_ref="v1.1.0", + output_file=out_file, + provider=provider, + repo_dir=git_repo, + ) + + assert out_file.exists() + assert out_file.read_text(encoding="utf-8").strip() == res.text.strip() + + +def test_generate_changelog_budget_cap_exceeded(git_repo: Path, metrics_dir: Path) -> None: + record_invocation( + command="changelog", + model="gemini-3-flash-preview", + tokens_in=1_000_000, + tokens_out=200_000, + cache_hit=False, + latency_ms=500, + fallback_used=False, + estimated_cost_usd=15.0, + ) + + cfg = GitmateConfig(budget_cap_usd=10.0) + con = Console(record=True) + with pytest.raises(BudgetCapExceededError, match="budget cap exceeded"): + generate_changelog( + from_ref="HEAD~1", to_ref="HEAD", cfg=cfg, console=con, repo_dir=git_repo + ) + assert "budget cap exceeded" in con.export_text().lower() + + +def test_generate_changelog_git_error(git_repo: Path) -> None: + con = Console(record=True) + with pytest.raises(GitCommandError): + generate_changelog( + from_ref="non-existent-tag-xyz", + to_ref="HEAD", + console=con, + repo_dir=git_repo, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index fe62ee9..cc798c7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest @@ -28,10 +29,9 @@ def test_help_lists_all_commands() -> None: def test_stubs_report_not_implemented() -> None: - for name in ("pr-summary", "changelog", "doc"): - result = runner.invoke(cli.app, [name]) - assert result.exit_code == 0 - assert "not implemented" in result.output + result = runner.invoke(cli.app, ["doc"]) + assert result.exit_code == 0 + assert "not implemented" in result.output def test_config_show_reports_key_status( @@ -424,3 +424,178 @@ def test_stats_cli_invalid_days(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) res = runner.invoke(cli.app, ["stats", "--days", "0"]) assert res.exit_code != 0 assert "positive integer" in res.output + + +def test_pr_summary_cli_empty(git_repo: Path) -> None: + res = runner.invoke(cli.app, ["pr-summary", "--no-copy"]) + assert res.exit_code == 0 + assert "No changes between 'main' and HEAD." in res.output + assert "PR Summary" not in res.output + + +def test_pr_summary_cli_with_changes(git_repo: Path) -> None: + from conftest import commit_file + + subprocess.run(["git", "checkout", "-b", "feature-branch"], cwd=git_repo, check=True) + commit_file(git_repo, "feat.py", "x = 1\n", "feat: add feature") + + res = runner.invoke(cli.app, ["pr-summary", "--no-copy", "--base", "main"]) + assert res.exit_code == 0 + assert "PR Summary" in res.output + assert "feat.py" in res.output + + +def test_pr_summary_cli_git_error(git_repo: Path) -> None: + res = runner.invoke(cli.app, ["pr-summary", "--base", "non-existent-xyz", "--no-copy"]) + assert res.exit_code == 1 + assert "error" in res.output.lower() + + +def test_pr_summary_cli_budget_cap_exceeded( + git_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from gitmate.metrics import record_invocation + + m_dir = tmp_path / "metrics" + monkeypatch.setenv("GITMATE_METRICS_DIR", str(m_dir)) + record_invocation( + command="commit", + model="gemini-3-flash-preview", + tokens_in=1_000_000, + tokens_out=200_000, + cache_hit=False, + latency_ms=500, + fallback_used=False, + estimated_cost_usd=20.0, + ) + runner.invoke(cli.app, ["config", "set", "budget_cap_usd", "5.0"]) + + res = runner.invoke(cli.app, ["pr-summary", "--no-copy"]) + assert res.exit_code == 1 + assert "budget cap exceeded" in res.output.lower() + + +def test_pr_summary_cli_gh_error(git_repo: Path) -> None: + from unittest.mock import patch + + from conftest import commit_file + + subprocess.run(["git", "checkout", "-b", "feature-gh"], cwd=git_repo, check=True) + commit_file(git_repo, "gh.py", "x = 1\n", "feat: add gh test") + + with patch("shutil.which", return_value=None): + res = runner.invoke( + cli.app, ["pr-summary", "--no-copy", "--create-pr", "--title", "test PR"] + ) + assert res.exit_code == 1 + assert "GitHub CLI ('gh') not found" in res.output + + +def test_pr_summary_cli_gh_success_with_title(git_repo: Path) -> None: + from unittest.mock import patch + + from conftest import commit_file + + subprocess.run(["git", "checkout", "-b", "feature-gh-success"], cwd=git_repo, check=True) + commit_file(git_repo, "gh_success.py", "x = 2\n", "feat: gh success") + + real_run = subprocess.run + gh_calls: list[list[str]] = [] + + def fake_run( + cmd: list[str], *args: object, **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + if cmd and cmd[0] == "gh": + gh_calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=b"", stderr=b"") + return real_run(cmd, *args, **kwargs) # type: ignore[no-any-return,call-overload] + + with ( + patch("shutil.which", return_value="/usr/bin/gh"), + patch("subprocess.run", side_effect=fake_run), + ): + # Long form --title + res = runner.invoke( + cli.app, + ["pr-summary", "--no-copy", "--create-pr", "--title", "CLI PR Title", "--base", "main"], + ) + assert res.exit_code == 0 + assert len(gh_calls) == 1 + cmd_args = gh_calls[0] + assert "--title" in cmd_args + title_idx = cmd_args.index("--title") + assert cmd_args[title_idx + 1] == "CLI PR Title" + + # Short form -t + gh_calls.clear() + res = runner.invoke( + cli.app, + ["pr-summary", "--no-copy", "--create-pr", "-t", "Short Title", "--base", "main"], + ) + assert res.exit_code == 0 + assert len(gh_calls) == 1 + cmd_args = gh_calls[0] + assert "--title" in cmd_args + title_idx = cmd_args.index("--title") + assert cmd_args[title_idx + 1] == "Short Title" + + +def test_changelog_cli_empty_range(git_repo: Path) -> None: + res = runner.invoke(cli.app, ["changelog", "--from", "HEAD", "--to", "HEAD"]) + assert res.exit_code == 0 + assert "No commits found between 'HEAD' and 'HEAD'." in res.output + # Must not duplicate print + assert res.output.count("No commits found") == 1 + + +def test_changelog_cli_git_error(git_repo: Path) -> None: + res = runner.invoke(cli.app, ["changelog", "--from", "non-existent-xyz", "--to", "HEAD"]) + assert res.exit_code == 1 + assert "error" in res.output.lower() + + +def test_changelog_cli_budget_cap_exceeded( + git_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from gitmate.metrics import record_invocation + + m_dir = tmp_path / "metrics" + monkeypatch.setenv("GITMATE_METRICS_DIR", str(m_dir)) + record_invocation( + command="commit", + model="gemini-3-flash-preview", + tokens_in=1_000_000, + tokens_out=200_000, + cache_hit=False, + latency_ms=500, + fallback_used=False, + estimated_cost_usd=20.0, + ) + runner.invoke(cli.app, ["config", "set", "budget_cap_usd", "5.0"]) + + res = runner.invoke(cli.app, ["changelog", "--from", "HEAD~1", "--to", "HEAD"]) + assert res.exit_code == 1 + assert "budget cap exceeded" in res.output.lower() + + +def test_changelog_cli_command(git_repo: Path, tmp_path: Path) -> None: + from conftest import commit_file + + subprocess.run(["git", "tag", "v0.1.0"], cwd=git_repo, check=True) + commit_file(git_repo, "foo.py", "foo = 1", "feat: add foo") + subprocess.run(["git", "tag", "v0.2.0"], cwd=git_repo, check=True) + + # Test stdout output + res = runner.invoke(cli.app, ["changelog", "--from", "v0.1.0", "--to", "v0.2.0"]) + assert res.exit_code == 0 + assert "Release Notes" in res.output + assert "add foo" in res.output + + # Test file output + out_file = tmp_path / "RELEASE.md" + res_file = runner.invoke( + cli.app, ["changelog", "--from", "v0.1.0", "--to", "v0.2.0", "-o", str(out_file)] + ) + assert res_file.exit_code == 0 + assert out_file.exists() + assert "Release Notes" in out_file.read_text(encoding="utf-8") diff --git a/tests/test_pr_summary.py b/tests/test_pr_summary.py new file mode 100644 index 0000000..457cb98 --- /dev/null +++ b/tests/test_pr_summary.py @@ -0,0 +1,209 @@ +"""Unit and integration tests for PR summary generation, clipboard, and gh CLI dispatch.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from conftest import commit_file +from rich.console import Console + +from gitmate.config import GitmateConfig +from gitmate.diff_extractor import GitCommandError +from gitmate.metrics import get_invocations, record_invocation +from gitmate.orchestrator import BudgetCapExceededError +from gitmate.pr_summary import ( + GitHubCliError, + copy_to_clipboard, + create_github_pr, + generate_pr_summary, +) +from gitmate.providers.base import LLMProvider, LLMResponse, ProviderUnavailable + + +class FakeProvider(LLMProvider): + """In-memory provider recording calls and returning static responses.""" + + def __init__(self, response: str = "## Summary\nImplemented PR summary.") -> None: + self.response = response + self.calls: list[tuple[str, str]] = [] + + def generate(self, prompt: str, model: str) -> LLMResponse: + self.calls.append((prompt, model)) + return LLMResponse( + text=self.response, + model=model, + input_tokens=120, + output_tokens=40, + ) + + +class FailingProvider(LLMProvider): + """Provider that simulates an API outage.""" + + def generate(self, prompt: str, model: str) -> LLMResponse: + raise ProviderUnavailable("API is down") + + +@pytest.fixture +def metrics_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolate metrics database in temp directory.""" + m_dir = tmp_path / "metrics" + monkeypatch.setenv("GITMATE_METRICS_DIR", str(m_dir)) + return m_dir + + +def test_copy_to_clipboard_success() -> None: + with patch("pyperclip.copy") as mock_copy: + success = copy_to_clipboard("test text") + assert success is True + mock_copy.assert_called_once_with("test text") + + +def test_copy_to_clipboard_failure_graceful() -> None: + with patch("pyperclip.copy", side_effect=Exception("No display")): + con = Console(record=True) + success = copy_to_clipboard("test text", console=con) + assert success is False + assert "Clipboard unavailable" in con.export_text() + + +def test_create_github_pr_missing_gh() -> None: + with patch("shutil.which", return_value=None): + con = Console(record=True) + code = create_github_pr(base="main", summary="PR body", console=con) + assert code == 1 + assert "GitHub CLI ('gh') not found" in con.export_text() + + +def test_create_github_pr_success() -> None: + with ( + patch("shutil.which", return_value="/usr/bin/gh"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + code = create_github_pr(base="main", summary="PR body", title="feat: test") + assert code == 0 + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args == [ + "gh", + "pr", + "create", + "--base", + "main", + "--body", + "PR body", + "--title", + "feat: test", + ] + + +def test_pr_summary_no_diffs(git_repo: Path) -> None: + res = generate_pr_summary(base="main", repo_dir=git_repo) + assert res.is_fallback is False + assert "No changes" in res.text + + +def test_pr_summary_with_diffs_and_telemetry(git_repo: Path, metrics_dir: Path) -> None: + # Create a feature branch with a commit + subprocess.run(["git", "checkout", "-b", "feature-x"], cwd=git_repo, check=True) + commit_file(git_repo, "hello.py", "print('hello')", "feat: add hello") + + provider = FakeProvider(response="## Summary\nAdded greeting script.") + cfg = GitmateConfig(model="gemini-3-flash-preview") + + res = generate_pr_summary( + base="main", + cfg=cfg, + provider=provider, + repo_dir=git_repo, + copy_to_cb=False, + ) + + assert res.is_fallback is False + assert res.text == "## Summary\nAdded greeting script." + assert len(provider.calls) == 1 + assert "hello.py" in provider.calls[0][0] + + # Verify telemetry was recorded + invocations = get_invocations() + assert len(invocations) == 1 + row = invocations[0] + assert row.command == "pr_summary" + assert row.model == "gemini-3-flash-preview" + assert row.tokens_in == 120 + assert row.tokens_out == 40 + assert row.fallback_used == 0 + + +def test_pr_summary_fallback_on_provider_error(git_repo: Path, metrics_dir: Path) -> None: + subprocess.run(["git", "checkout", "-b", "feature-err"], cwd=git_repo, check=True) + commit_file(git_repo, "feat.py", "x = 1\n", "feat: new feature") + + provider = FailingProvider() + cfg = GitmateConfig(model="gemini-3-flash-preview") + + res = generate_pr_summary( + base="main", + cfg=cfg, + provider=provider, + repo_dir=git_repo, + copy_to_cb=False, + ) + + assert res.is_fallback is True + assert "## Summary" in res.text + assert "## Changes" in res.text + assert "feat.py" in res.text + + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "pr_summary" + assert invocations[0].fallback_used == 1 + + +def test_pr_summary_budget_cap_exceeded(git_repo: Path, metrics_dir: Path) -> None: + # Seed high spend to exceed cap + record_invocation( + command="commit", + model="gemini-3-flash-preview", + tokens_in=1_000_000, + tokens_out=200_000, + cache_hit=False, + latency_ms=500, + fallback_used=False, + estimated_cost_usd=10.0, + ) + + cfg = GitmateConfig(budget_cap_usd=5.0) + con = Console(record=True) + with pytest.raises(BudgetCapExceededError, match="budget cap exceeded"): + generate_pr_summary(base="main", cfg=cfg, console=con, repo_dir=git_repo) + assert "budget cap exceeded" in con.export_text().lower() + + +def test_pr_summary_git_error(git_repo: Path) -> None: + con = Console(record=True) + with pytest.raises(GitCommandError): + generate_pr_summary(base="non-existent-branch-xyz", console=con, repo_dir=git_repo) + + +def test_pr_summary_gh_cli_error(git_repo: Path) -> None: + subprocess.run(["git", "checkout", "-b", "feature-gh"], cwd=git_repo, check=True) + commit_file(git_repo, "feat_gh.py", "x = 42\n", "feat: test gh error") + + provider = FakeProvider() + with ( + patch("gitmate.pr_summary.create_github_pr", return_value=1), + pytest.raises(GitHubCliError, match="exit code 1"), + ): + generate_pr_summary( + base="main", + provider=provider, + repo_dir=git_repo, + copy_to_cb=False, + create_pr=True, + ) diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 4663f97..d09229b 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -11,6 +11,7 @@ load_template, parse_template_content, render_commit_prompt, + render_prompt, ) from gitmate.providers.cache import cache_key @@ -23,6 +24,12 @@ def test_load_known_templates() -> None: assert "$diff" in template.body assert template.version_key == f"{template.name}:v1" + changelog_tpl = load_template("changelog") + assert isinstance(changelog_tpl, PromptTemplate) + assert changelog_tpl.version == "2" + assert changelog_tpl.version_key == "changelog:v2" + assert "$commits" in changelog_tpl.body + def test_load_template_aliases() -> None: conv = load_template("conventional") @@ -93,6 +100,21 @@ def test_render_commit_prompt() -> None: assert version_key == "commit_conventional:v1" +def test_render_prompt_changelog() -> None: + prompt_text, version_key = render_prompt( + "changelog", + from_ref="v1.0.0", + to_ref="v1.1.0", + commits="- [abc1234] feat: new feature", + diff="diff text", + summary_note="1 file changed", + ) + assert "v1.0.0..v1.1.0" in prompt_text + assert "# Release Notes (v1.1.0)" in prompt_text + assert "- [abc1234] feat: new feature" in prompt_text + assert version_key == "changelog:v2" + + def test_template_version_bump_invalidates_cache_key() -> None: t1 = parse_template_content("version: 1\n---\nPrompt $diff", name="t") t2 = parse_template_content("version: 2\n---\nPrompt $diff", name="t") diff --git a/uv.lock b/uv.lock index 330ffe2..4d139ff 100644 --- a/uv.lock +++ b/uv.lock @@ -606,6 +606,7 @@ dependencies = [ { name = "google-genai" }, { name = "httpx" }, { name = "keyring" }, + { name = "pyperclip" }, { name = "rich" }, { name = "tenacity" }, { name = "tomli-w" }, @@ -628,6 +629,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "keyring", specifier = ">=25.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, + { name = "pyperclip", specifier = ">=1.8.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "rich", specifier = ">=13.0" }, @@ -1330,6 +1332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1"