pr-descriptor 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: pr-descriptor
3
+ Version: 0.1.0
4
+ Summary: AI-powered PR description generator using Claude
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: anthropic>=0.40.0
7
+ Requires-Dist: click>=8.1.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: python-dotenv>=1.0.0
10
+ Requires-Dist: pyperclip>=1.9.0
11
+ Requires-Dist: requests>=2.31.0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: pr-descriptor
3
+ Version: 0.1.0
4
+ Summary: AI-powered PR description generator using Claude
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: anthropic>=0.40.0
7
+ Requires-Dist: click>=8.1.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: python-dotenv>=1.0.0
10
+ Requires-Dist: pyperclip>=1.9.0
11
+ Requires-Dist: requests>=2.31.0
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ pr_descriptor.egg-info/PKG-INFO
3
+ pr_descriptor.egg-info/SOURCES.txt
4
+ pr_descriptor.egg-info/dependency_links.txt
5
+ pr_descriptor.egg-info/entry_points.txt
6
+ pr_descriptor.egg-info/requires.txt
7
+ pr_descriptor.egg-info/top_level.txt
8
+ pr_writer/__init__.py
9
+ pr_writer/ai_client.py
10
+ pr_writer/cli.py
11
+ pr_writer/git_utils.py
12
+ pr_writer/platforms.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pr-descriptor = pr_writer.cli:main
@@ -0,0 +1,6 @@
1
+ anthropic>=0.40.0
2
+ click>=8.1.0
3
+ rich>=13.0.0
4
+ python-dotenv>=1.0.0
5
+ pyperclip>=1.9.0
6
+ requests>=2.31.0
@@ -0,0 +1 @@
1
+ pr_writer
File without changes
@@ -0,0 +1,53 @@
1
+ from typing import Iterator
2
+
3
+ from anthropic import Anthropic
4
+
5
+ from .git_utils import GitContext
6
+
7
+ SYSTEM_PROMPT = """\
8
+ You are an expert software engineer who writes clear, concise, and professional pull request descriptions.
9
+
10
+ Given git context (branch name, commits, changed files, diff), generate a well-structured PR description \
11
+ in GitHub-flavored Markdown using this exact format:
12
+
13
+ ## Summary
14
+ A concise paragraph explaining what this PR does and the motivation behind it.
15
+
16
+ ## Changes
17
+ - Bullet points listing the key changes
18
+
19
+ ## Testing
20
+ Steps or notes on how to test these changes.
21
+
22
+ ## Breaking Changes
23
+ List any breaking changes, or write "None" if there are none.
24
+
25
+ ## Related Issues
26
+ Reference issue numbers mentioned in commits (e.g., Fixes #123), or write "None".
27
+
28
+ Be specific and professional. Focus on the *why* as much as the *what*. No filler phrases.\
29
+ """
30
+
31
+
32
+ def _build_prompt(ctx: GitContext) -> str:
33
+ commits = "\n".join(ctx.commits) if ctx.commits else "No commits"
34
+ files = "\n".join(ctx.changed_files) if ctx.changed_files else "No changed files"
35
+ return (
36
+ f"Branch: `{ctx.current_branch}` → `{ctx.base_branch}`\n\n"
37
+ f"Commits:\n{commits}\n\n"
38
+ f"Changed files:\n{files}\n\n"
39
+ f"Diff:\n```diff\n{ctx.diff}\n```"
40
+ )
41
+
42
+
43
+ def stream_pr_description(ctx: GitContext) -> Iterator[str]:
44
+ """Stream the PR description token by token from Claude."""
45
+ client = Anthropic()
46
+ with client.messages.stream(
47
+ model="claude-sonnet-4-6",
48
+ max_tokens=1024,
49
+ system=SYSTEM_PROMPT,
50
+ messages=[{"role": "user", "content": _build_prompt(ctx)}],
51
+ ) as stream:
52
+ for text in stream.text_stream:
53
+ yield text
@@ -0,0 +1,124 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from dotenv import load_dotenv
7
+ from rich.console import Console
8
+ from rich.markdown import Markdown
9
+ from rich.panel import Panel
10
+
11
+ from .ai_client import stream_pr_description
12
+ from .git_utils import Platform, collect_git_context
13
+ from .platforms import PlatformError, push_description
14
+
15
+ console = Console()
16
+
17
+
18
+ def _load_env() -> None:
19
+ load_dotenv()
20
+ load_dotenv(Path.home() / ".pr-writer" / ".env")
21
+
22
+
23
+ @click.command()
24
+ @click.option("--base", "-b", default="main", show_default=True, help="Base branch to compare against.")
25
+ @click.option("--repo", "-r", default=".", show_default=True, help="Path to the git repository.")
26
+ @click.option("--copy", "-c", is_flag=True, help="Copy the generated description to clipboard.")
27
+ @click.option("--push", "-p", is_flag=True, help="Update the open PR on GitHub/Gitea with the description.")
28
+ @click.option("--raw", is_flag=True, help="Print raw Markdown without the formatted preview panel.")
29
+ @click.version_option(version="0.1.0")
30
+ def main(base: str, repo: str, copy: bool, push: bool, raw: bool) -> None:
31
+ """Generate AI-powered PR descriptions from your git diff.
32
+
33
+ \b
34
+ Reads your local git history, sends the context to Claude, and streams
35
+ back a structured PR description. Supports GitHub and Gitea remotes.
36
+
37
+ \b
38
+ Examples:
39
+ pr-writer # compare current branch vs main
40
+ pr-writer --base develop # compare vs develop
41
+ pr-writer --copy # copy output to clipboard
42
+ pr-writer --push # update the open PR description via API
43
+ """
44
+ _load_env()
45
+
46
+ if not os.getenv("ANTHROPIC_API_KEY"):
47
+ console.print("[red]Error:[/red] ANTHROPIC_API_KEY is not set.")
48
+ console.print("Add it to a [bold].env[/bold] file in your project or home directory.")
49
+ sys.exit(1)
50
+
51
+ # ── Collect git context ──────────────────────────────────────────────────
52
+ with console.status("[bold blue]Reading git context...", spinner="dots"):
53
+ try:
54
+ ctx = collect_git_context(repo, base)
55
+ except RuntimeError as exc:
56
+ console.print(f"[red]Git error:[/red] {exc}")
57
+ sys.exit(1)
58
+
59
+ if not ctx.commits and not ctx.diff:
60
+ console.print(
61
+ f"[yellow]No changes[/yellow] between "
62
+ f"[bold]{ctx.current_branch}[/bold] and [bold]{base}[/bold]."
63
+ )
64
+ sys.exit(0)
65
+
66
+ # ── Print summary line ───────────────────────────────────────────────────
67
+ remote_label = ""
68
+ if ctx.remote:
69
+ platform_name = "GitHub" if ctx.remote.platform == Platform.GITHUB else "Gitea"
70
+ remote_label = (
71
+ f" [dim]Remote:[/dim] {platform_name} "
72
+ f"([dim]{ctx.remote.owner}/{ctx.remote.repo}[/dim])"
73
+ )
74
+
75
+ console.print(
76
+ f"[dim]Branch:[/dim] [bold cyan]{ctx.current_branch}[/bold cyan] → [bold]{base}[/bold]"
77
+ f" [dim]Commits:[/dim] {len(ctx.commits)}"
78
+ f" [dim]Files:[/dim] {len(ctx.changed_files)}"
79
+ + remote_label
80
+ )
81
+ console.print()
82
+
83
+ # ── Stream description from Claude ───────────────────────────────────────
84
+ console.print("[bold green]Generating PR description[/bold green]\n")
85
+ parts: list[str] = []
86
+ try:
87
+ for token in stream_pr_description(ctx):
88
+ console.print(token, end="")
89
+ parts.append(token)
90
+ except Exception as exc:
91
+ console.print(f"\n[red]API error:[/red] {exc}")
92
+ sys.exit(1)
93
+
94
+ description = "".join(parts)
95
+ console.print("\n")
96
+
97
+ # ── Formatted preview ────────────────────────────────────────────────────
98
+ if not raw:
99
+ console.print(
100
+ Panel(
101
+ Markdown(description),
102
+ title="[bold]Formatted Preview[/bold]",
103
+ border_style="dim",
104
+ )
105
+ )
106
+
107
+ # ── Copy to clipboard ────────────────────────────────────────────────────
108
+ if copy:
109
+ try:
110
+ import pyperclip # noqa: PLC0415
111
+ pyperclip.copy(description)
112
+ console.print("[green]Copied to clipboard.[/green]")
113
+ except Exception as exc:
114
+ console.print(f"[yellow]Could not copy to clipboard:[/yellow] {exc}")
115
+
116
+ # ── Push to platform ─────────────────────────────────────────────────────
117
+ if push:
118
+ with console.status("[bold blue]Updating PR description...", spinner="dots"):
119
+ try:
120
+ pr_url = push_description(ctx, description)
121
+ except PlatformError as exc:
122
+ console.print(f"[red]Push failed:[/red] {exc}")
123
+ sys.exit(1)
124
+ console.print(f"[green]PR description updated:[/green] {pr_url}")
@@ -0,0 +1,117 @@
1
+ import re
2
+ import subprocess
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+
9
+ class Platform(Enum):
10
+ GITHUB = "github"
11
+ GITEA = "gitea"
12
+ UNKNOWN = "unknown"
13
+
14
+
15
+ @dataclass
16
+ class RemoteInfo:
17
+ platform: Platform
18
+ host: str
19
+ owner: str
20
+ repo: str
21
+ url: str
22
+
23
+
24
+ @dataclass
25
+ class GitContext:
26
+ current_branch: str
27
+ base_branch: str
28
+ commits: List[str]
29
+ diff: str
30
+ changed_files: List[str]
31
+ repo_path: str
32
+ remote: Optional[RemoteInfo] = None
33
+
34
+
35
+ def _run(args: List[str], cwd: str) -> str:
36
+ """Run a git command and return stdout. Raises RuntimeError on failure."""
37
+ result = subprocess.run(
38
+ ["git"] + args,
39
+ cwd=cwd,
40
+ capture_output=True,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ )
45
+ if result.returncode != 0:
46
+ raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed")
47
+ return result.stdout.strip()
48
+
49
+
50
+ def parse_remote_url(url: str) -> Optional[RemoteInfo]:
51
+ """
52
+ Parse a git remote URL into a RemoteInfo.
53
+
54
+ Handles both HTTPS and SSH formats:
55
+ https://github.com/owner/repo.git
56
+ git@github.com:owner/repo.git
57
+ """
58
+ https_re = re.compile(r"https?://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$")
59
+ ssh_re = re.compile(r"git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$")
60
+
61
+ match = https_re.match(url) or ssh_re.match(url)
62
+ if not match:
63
+ return None
64
+
65
+ host, owner, repo = match.group(1), match.group(2), match.group(3)
66
+ platform = Platform.GITHUB if host == "github.com" else Platform.GITEA
67
+
68
+ return RemoteInfo(platform=platform, host=host, owner=owner, repo=repo, url=url)
69
+
70
+
71
+ def get_remote_info(repo_path: str) -> Optional[RemoteInfo]:
72
+ try:
73
+ url = _run(["remote", "get-url", "origin"], repo_path)
74
+ return parse_remote_url(url)
75
+ except RuntimeError:
76
+ return None
77
+
78
+
79
+ def get_current_branch(repo_path: str) -> str:
80
+ return _run(["branch", "--show-current"], repo_path)
81
+
82
+
83
+ def get_commits(repo_path: str, base_branch: str) -> List[str]:
84
+ output = _run(
85
+ ["log", f"{base_branch}..HEAD", "--oneline", "--no-merges"],
86
+ repo_path,
87
+ )
88
+ return [line for line in output.splitlines() if line]
89
+
90
+
91
+ def get_diff(repo_path: str, base_branch: str, max_chars: int = 14000) -> str:
92
+ diff = _run(["diff", f"{base_branch}...HEAD", "--no-color"], repo_path)
93
+ if len(diff) > max_chars:
94
+ diff = (
95
+ diff[:max_chars]
96
+ + f"\n\n... [diff truncated — {len(diff) - max_chars} additional chars omitted]"
97
+ )
98
+ return diff
99
+
100
+
101
+ def get_changed_files(repo_path: str, base_branch: str) -> List[str]:
102
+ output = _run(["diff", f"{base_branch}...HEAD", "--name-status"], repo_path)
103
+ return [line for line in output.splitlines() if line]
104
+
105
+
106
+ def collect_git_context(repo_path: str, base_branch: str) -> GitContext:
107
+ """Gather all git context needed to generate a PR description."""
108
+ resolved = str(Path(repo_path).resolve())
109
+ return GitContext(
110
+ current_branch=get_current_branch(resolved),
111
+ base_branch=base_branch,
112
+ commits=get_commits(resolved, base_branch),
113
+ diff=get_diff(resolved, base_branch),
114
+ changed_files=get_changed_files(resolved, base_branch),
115
+ repo_path=resolved,
116
+ remote=get_remote_info(resolved),
117
+ )
@@ -0,0 +1,114 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ import requests
5
+
6
+ from .git_utils import GitContext, Platform, RemoteInfo
7
+
8
+
9
+ class PlatformError(Exception):
10
+ pass
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Header helpers
15
+ # ---------------------------------------------------------------------------
16
+
17
+ def _github_headers(token: str) -> dict:
18
+ return {
19
+ "Authorization": f"Bearer {token}",
20
+ "Accept": "application/vnd.github+json",
21
+ "X-GitHub-Api-Version": "2022-11-28",
22
+ }
23
+
24
+
25
+ def _gitea_headers(token: str) -> dict:
26
+ return {
27
+ "Authorization": f"token {token}",
28
+ "Content-Type": "application/json",
29
+ }
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # PR lookup
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _find_github_pr(remote: RemoteInfo, branch: str, token: str) -> Optional[int]:
37
+ url = f"https://api.github.com/repos/{remote.owner}/{remote.repo}/pulls"
38
+ resp = requests.get(
39
+ url,
40
+ headers=_github_headers(token),
41
+ params={"state": "open", "head": f"{remote.owner}:{branch}"},
42
+ timeout=10,
43
+ )
44
+ resp.raise_for_status()
45
+ prs = resp.json()
46
+ return prs[0]["number"] if prs else None
47
+
48
+
49
+ def _find_gitea_pr(remote: RemoteInfo, branch: str, token: str) -> Optional[int]:
50
+ url = f"https://{remote.host}/api/v1/repos/{remote.owner}/{remote.repo}/pulls"
51
+ resp = requests.get(
52
+ url,
53
+ headers=_gitea_headers(token),
54
+ params={"state": "open", "limit": 50},
55
+ timeout=10,
56
+ )
57
+ resp.raise_for_status()
58
+ for pr in resp.json():
59
+ head = pr.get("head", {})
60
+ if head.get("ref") == branch or head.get("label") == branch:
61
+ return pr["number"]
62
+ return None
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Public API
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def push_description(ctx: GitContext, description: str) -> str:
70
+ """
71
+ Find the open PR for the current branch and update its description.
72
+ Returns the PR URL on success. Raises PlatformError on failure.
73
+ """
74
+ remote = ctx.remote
75
+ if remote is None:
76
+ raise PlatformError("Could not detect a git remote. Is 'origin' set?")
77
+
78
+ if remote.platform == Platform.GITHUB:
79
+ token = os.getenv("GITHUB_TOKEN")
80
+ if not token:
81
+ raise PlatformError("GITHUB_TOKEN is not set in your environment.")
82
+
83
+ pr_number = _find_github_pr(remote, ctx.current_branch, token)
84
+ if pr_number is None:
85
+ raise PlatformError(
86
+ f"No open PR found for branch '{ctx.current_branch}' "
87
+ f"on {remote.owner}/{remote.repo}."
88
+ )
89
+
90
+ url = f"https://api.github.com/repos/{remote.owner}/{remote.repo}/pulls/{pr_number}"
91
+ resp = requests.patch(
92
+ url, headers=_github_headers(token), json={"body": description}, timeout=10
93
+ )
94
+ resp.raise_for_status()
95
+ return f"https://github.com/{remote.owner}/{remote.repo}/pull/{pr_number}"
96
+
97
+ else: # Gitea
98
+ token = os.getenv("GITEA_TOKEN")
99
+ if not token:
100
+ raise PlatformError("GITEA_TOKEN is not set in your environment.")
101
+
102
+ pr_number = _find_gitea_pr(remote, ctx.current_branch, token)
103
+ if pr_number is None:
104
+ raise PlatformError(
105
+ f"No open PR found for branch '{ctx.current_branch}' "
106
+ f"on {remote.owner}/{remote.repo}."
107
+ )
108
+
109
+ url = f"https://{remote.host}/api/v1/repos/{remote.owner}/{remote.repo}/pulls/{pr_number}"
110
+ resp = requests.patch(
111
+ url, headers=_gitea_headers(token), json={"body": description}, timeout=10
112
+ )
113
+ resp.raise_for_status()
114
+ return f"https://{remote.host}/{remote.owner}/{remote.repo}/pulls/{pr_number}"
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pr-descriptor"
7
+ version = "0.1.0"
8
+ description = "AI-powered PR description generator using Claude"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "anthropic>=0.40.0",
12
+ "click>=8.1.0",
13
+ "rich>=13.0.0",
14
+ "python-dotenv>=1.0.0",
15
+ "pyperclip>=1.9.0",
16
+ "requests>=2.31.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ pr-descriptor = "pr_writer.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["."]
24
+ include = ["pr_writer*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+