usepr 0.1.2__py3-none-any.whl

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.
usepr/__init__.py ADDED
File without changes
usepr/cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,178 @@
1
+ """GenerateCommand - CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Annotated, Optional
7
+
8
+ import pyperclip
9
+ import typer
10
+ from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+ from usecli import BaseCommand, Prompt, console, theme
13
+
14
+ from usepr.configs.dspy import configure_dspy
15
+ from usepr.services.pr_summary_service import (
16
+ gather_commits,
17
+ generate_summary,
18
+ get_templates,
19
+ )
20
+ from usepr.utils.github import PrTemplate
21
+
22
+
23
+ class GenerateCommand(BaseCommand):
24
+ def signature(self) -> str:
25
+ return "generate"
26
+
27
+ def description(self) -> str:
28
+ return "Description for generate command"
29
+
30
+ def aliases(self) -> list[str]:
31
+ return ["gen"]
32
+
33
+ def _prompt_for_template(self, templates: list[PrTemplate]) -> PrTemplate | None:
34
+ """Prompt the user to select a PR template, or skip."""
35
+ if not templates:
36
+ return None
37
+
38
+ if len(templates) == 1:
39
+ t = templates[0]
40
+ label = t.name or os.path.basename(t.path)
41
+ use = (
42
+ (
43
+ Prompt.ask(
44
+ f"\n[bold {theme.ACCENT}]Found PR template:[/bold {theme.ACCENT}] {label} — use it?",
45
+ default="y",
46
+ choices=["y", "n"],
47
+ show_choices=False,
48
+ )
49
+ or ""
50
+ )
51
+ .lower()
52
+ .strip()
53
+ )
54
+ return t if use == "y" else None
55
+
56
+ console.print(
57
+ f"\n[bold {theme.ACCENT}]Found {len(templates)} PR templates:[/bold {theme.ACCENT}]"
58
+ )
59
+ for i, t in enumerate(templates, 1):
60
+ label = t.name or os.path.basename(t.path)
61
+ console.print(f" {i}. {label} [dim]({t.path})[/dim]")
62
+
63
+ console.print(" 0. [dim]Skip template[/dim]")
64
+
65
+ choice = Prompt.ask(
66
+ "Select a template number",
67
+ default="0",
68
+ )
69
+ try:
70
+ idx = int(choice) if choice else 0
71
+ except ValueError:
72
+ return None
73
+ if idx == 0 or idx > len(templates):
74
+ return None
75
+ return templates[idx - 1]
76
+
77
+ def handle(
78
+ self,
79
+ model: Annotated[
80
+ Optional[str],
81
+ typer.Option("-m", "--model", help="Override the default LLM model."),
82
+ ] = None,
83
+ ) -> None:
84
+
85
+ configure_dspy(model)
86
+
87
+ repo = os.path.abspath(".")
88
+
89
+ console.clear()
90
+ console.print(f"[bold {theme.PRIMARY}]Pull Request Summary Generator")
91
+
92
+ from usepr.utils.git import get_default_branch
93
+
94
+ default_branch = get_default_branch(repo).strip()
95
+ base_branch = (
96
+ Prompt.ask(
97
+ f"\n[bold {theme.ACCENT}]Base branch to diff against[/bold {theme.ACCENT}] [dim](default: {default_branch})[/dim]",
98
+ default="",
99
+ )
100
+ or ""
101
+ ).strip()
102
+
103
+ if not base_branch:
104
+ base_branch = default_branch
105
+
106
+ commit_ctx = gather_commits(repo, base_branch)
107
+
108
+ if not commit_ctx.commits:
109
+ console.print(
110
+ "[red]No commits found between the specified tags or branches."
111
+ )
112
+ return
113
+
114
+ # proceed = Prompt.ask(
115
+ # "Do you want to proceed with generating the summary?",
116
+ # default="y",
117
+ # choices=["y", "n"],
118
+ # show_choices=False,
119
+ # )
120
+ # if proceed and proceed.lower() != "y":
121
+ # console.print("Aborting summary generation.")
122
+ # return
123
+
124
+ related_issues = Prompt.ask(
125
+ "List any related issues or tasks (comma-separated), or leave blank if none:",
126
+ default="",
127
+ )
128
+
129
+ templates = get_templates(repo)
130
+ selected_template = self._prompt_for_template(templates)
131
+ template_content = selected_template.content if selected_template else None
132
+
133
+ result = generate_summary(
134
+ commits=commit_ctx.commits,
135
+ related_issues=related_issues,
136
+ template=template_content,
137
+ )
138
+
139
+ console.print()
140
+
141
+ summary_md = Syntax(
142
+ result.summary,
143
+ "markdown",
144
+ theme="monokai",
145
+ line_numbers=False,
146
+ word_wrap=True,
147
+ )
148
+ console.print(
149
+ Panel(
150
+ summary_md,
151
+ title="Pull Request Summary",
152
+ border_style=theme.SECONDARY,
153
+ title_align="left",
154
+ )
155
+ )
156
+
157
+ copy_to_clipboard = (
158
+ (
159
+ Prompt.ask(
160
+ f"\n[bold {theme.WARNING}]Copy to clipboard?[/bold {theme.WARNING}]",
161
+ default="y",
162
+ choices=["y", "n"],
163
+ )
164
+ or ""
165
+ )
166
+ .lower()
167
+ .strip()
168
+ )
169
+ if copy_to_clipboard in ["y", "yes"]:
170
+ try:
171
+ pyperclip.copy(result.summary)
172
+ console.print(
173
+ f"[bold {theme.SECONDARY}]✓ Summary copied to clipboard![/bold {theme.SECONDARY}]"
174
+ )
175
+ except Exception as e:
176
+ console.print(
177
+ f"[bold {theme.ERROR}]✗ Failed to copy to clipboard: {e}[/bold {theme.ERROR}]"
178
+ )
@@ -0,0 +1,67 @@
1
+ """{{ class_name }} - CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from usecli import Argument, BaseCommand, Confirm, Menu, Option, Prompt, console, theme
6
+
7
+
8
+ class {{ class_name }}(BaseCommand):
9
+ def signature(self) -> str:
10
+ return "{{ command_name }}"
11
+
12
+ def description(self) -> str:
13
+ return "Description for {{ command_name }} command"
14
+
15
+ # def aliases(self) -> list[str]:
16
+ # return ["{{ command_name[:3] }}"]
17
+
18
+ def handle(
19
+ self,
20
+ name: str = Argument(..., help="An example argument"),
21
+ verbose: bool = Option(False, "--verbose", "-v", help="Enable verbose output"),
22
+ ) -> None:
23
+ console.print(
24
+ f"[bold {theme.SUCCESS}]Executing {{ command_name }}[/bold {theme.SUCCESS}]"
25
+ )
26
+ console.print(f"Hello, {name}!")
27
+
28
+ if verbose:
29
+ console.print("[dim]Verbose mode enabled[/dim]")
30
+
31
+ # Example: Text input prompt
32
+ favorite_color = Prompt.ask(
33
+ "What's your favorite color?",
34
+ choices=["red", "green", "blue", "yellow"],
35
+ )
36
+ console.print(
37
+ f"You chose: [bold {theme.ACCENT}]{favorite_color}[/bold {theme.ACCENT}]"
38
+ )
39
+
40
+ # Example: Confirmation prompt (only when verbose)
41
+ if verbose:
42
+ if not Confirm.ask("Do you want to continue?"):
43
+ console.print(f"[{theme.WARNING}]Cancelled by user[/{theme.WARNING}]")
44
+ return
45
+ console.print(f"[{theme.SUCCESS}]Proceeding...[/{theme.SUCCESS}]")
46
+
47
+ # Example: Single-select menu
48
+ single_choice = Menu.select(
49
+ ["Option A", "Option B", "Option C"],
50
+ title="Pick one option:",
51
+ )
52
+ if single_choice:
53
+ console.print(f"You selected: {single_choice}")
54
+
55
+ # Example: Multi-select menu
56
+ multi_choices = Menu.multi_select(
57
+ ["Feature 1", "Feature 2", "Feature 3", "Feature 4"],
58
+ title="Select multiple features (space to select, enter to confirm):",
59
+ )
60
+ if multi_choices:
61
+ console.print(f"You selected {len(multi_choices)} features:")
62
+ for choice in multi_choices:
63
+ console.print(f" - {choice}")
64
+
65
+ console.print(
66
+ f"[bold {theme.PRIMARY}]Command completed![/bold {theme.PRIMARY}]"
67
+ )
@@ -0,0 +1,29 @@
1
+ [colors]
2
+ # Core semantic colors
3
+ primary = "#60D7FF"
4
+ secondary = "#5EFF87"
5
+ accent = "#F5FE53"
6
+
7
+ success = "#5EFF87" # Green
8
+ error = "#FE686B" # Red
9
+ warning = "#F5FE53" # Yellow
10
+ info = "#60D7FF" # Teal / Blue
11
+
12
+ # Text
13
+ foreground = "#FFFFFF" # Text
14
+ foreground_muted = "#BBBBBB" # Subtext
15
+
16
+ # Surfaces
17
+ background = "#000000" # Base
18
+ border = "#60D7FF" # Surface
19
+ border_focus = "#5EFF87" # Focus Ring
20
+
21
+ # UI semantics
22
+ command = "#60D7FF"
23
+ option = "#60D7FF"
24
+ link = "#60D7FF"
25
+ prompt = "#5EFF87"
26
+
27
+ panel_primary = "#5EFF87"
28
+ panel_secondary = "#60D7FF"
29
+ panel_accent = "#F5FE53"
@@ -0,0 +1,7 @@
1
+ ▀██▀▀█▄ ▀██▀▀█▄
2
+ ▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ██ ██ ██ ██
3
+ ██ ██ ██▄ ▀ ▄█▄▄▄██ ██▄▄▄█▀ ██▀▀█▀
4
+ ██ ██ ▄ ▀█▄▄ ██ ██ ██ █▄
5
+ ▀█▄▄▀█▄ █▀▄▄█▀ ▀█▄▄▄▀ ▄██▄ ▄██▄ ▀█▀
6
+
7
+ █████▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓█████
@@ -0,0 +1,11 @@
1
+ [usecli]
2
+ command_name = "usepr"
3
+ title = "usepr"
4
+ title_file = "themes/title.txt"
5
+ title_font = "ansi_regular"
6
+ description = "Generate Pull Request based on git diff"
7
+ commands_dir = "commands"
8
+ templates_dir = "templates"
9
+ themes_dir = "themes"
10
+ theme = "default"
11
+ hide_inspire = true
File without changes
usepr/configs/dspy.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import dspy
7
+ import yaml
8
+
9
+ APPLICATION_NAME = "usepr"
10
+
11
+ DEFAULT_MODEL = "openrouter/openai/gpt-oss-120b"
12
+
13
+ DEFAULT_EXTRA_BODY: dict[str, Any] = {
14
+ "provider": {"order": ["groq"], "allow_fallbacks": False}
15
+ }
16
+
17
+ CONFIG_DIR = Path.home() / ".config" / "usepr"
18
+ CONFIG_FILE = CONFIG_DIR / "config.yml"
19
+
20
+
21
+ def load_config() -> dict[str, Any]:
22
+ """Load configuration from ~/.config/usepr/config.yml."""
23
+ if not CONFIG_FILE.exists():
24
+ return {}
25
+ try:
26
+ with open(CONFIG_FILE) as f:
27
+ data = yaml.safe_load(f)
28
+ return data if isinstance(data, dict) else {}
29
+ except (yaml.YAMLError, OSError):
30
+ return {}
31
+
32
+
33
+ def get_lm(model: str | None = None) -> dspy.LM:
34
+ """Get a configured DSPy LM instance.
35
+
36
+ Priority: explicit model arg > config file > default.
37
+ """
38
+ config = load_config()
39
+
40
+ resolved_model = model or config.get("model") or DEFAULT_MODEL
41
+
42
+ extra_body = config.get("extra_body", DEFAULT_EXTRA_BODY)
43
+ cache = config.get("cache", False)
44
+
45
+ return dspy.LM(
46
+ resolved_model,
47
+ cache=cache,
48
+ extra_body=extra_body,
49
+ extra_headers={
50
+ "HTTP-Referer": f"http://{APPLICATION_NAME}.local",
51
+ "X-Title": APPLICATION_NAME,
52
+ },
53
+ )
54
+
55
+
56
+ def configure_dspy(model: str | None = None) -> None:
57
+ """Configure DSPy with the resolved LM."""
58
+ lm = get_lm(model)
59
+ dspy.configure(lm=lm)
File without changes
@@ -0,0 +1,35 @@
1
+ import dspy
2
+
3
+ from usepr.signatures.pull_request_summary_generator import (
4
+ RULES,
5
+ TEMPLATE_RULES,
6
+ PullRequestSummaryGeneratorSignature,
7
+ )
8
+
9
+
10
+ class PullRequestSummaryGeneratorModule(dspy.Module):
11
+ def __init__(self, callbacks=None):
12
+ super().__init__(callbacks)
13
+ self.diff_to_pull_request_summary = dspy.ChainOfThought(
14
+ PullRequestSummaryGeneratorSignature
15
+ )
16
+
17
+ def forward(
18
+ self,
19
+ commits: str,
20
+ related_issues: str | None = None,
21
+ template: str | None = None,
22
+ ):
23
+ if template:
24
+ rules = TEMPLATE_RULES
25
+ elif not related_issues or not related_issues.strip():
26
+ rules = RULES[2:]
27
+ else:
28
+ rules = RULES
29
+ result = self.diff_to_pull_request_summary(
30
+ commits=commits,
31
+ rules=rules,
32
+ related_issues=related_issues,
33
+ template=template,
34
+ )
35
+ return result
@@ -0,0 +1 @@
1
+ """Services layer for business logic."""
@@ -0,0 +1,115 @@
1
+ """PR Summary generation service - business logic layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from usepr.modules.pull_request_summary_generator import (
9
+ PullRequestSummaryGeneratorModule,
10
+ )
11
+ from usepr.utils.git import (
12
+ ensure_git_repo,
13
+ get_commits_between,
14
+ get_default_branch,
15
+ parse_commits,
16
+ resolve_ref,
17
+ )
18
+ from usepr.utils.github import PrTemplate, find_pr_templates
19
+
20
+
21
+ @dataclass
22
+ class SummaryResult:
23
+ """Result from PR summary generation."""
24
+
25
+ reasoning: str
26
+ summary: str
27
+
28
+
29
+ @dataclass
30
+ class CommitContext:
31
+ """Context about commits for summary generation."""
32
+
33
+ commits: list[str]
34
+ base_branch: str
35
+ default_branch: str
36
+
37
+
38
+ def get_default_branch_name(repo_path: str) -> str:
39
+ """Get the default branch name for the repository.
40
+
41
+ Args:
42
+ repo_path: Absolute path to the repository.
43
+
44
+ Returns:
45
+ The default branch name (e.g., 'main' or 'master').
46
+ """
47
+ return get_default_branch(repo_path).strip()
48
+
49
+
50
+ def gather_commits(repo_path: str, base_branch: str) -> CommitContext:
51
+ """Gather commits between base branch and HEAD.
52
+
53
+ Args:
54
+ repo_path: Absolute path to the repository.
55
+ base_branch: The base branch to diff against.
56
+
57
+ Returns:
58
+ CommitContext with parsed commits and branch info.
59
+
60
+ Raises:
61
+ SystemExit: If not a git repository or ref cannot be resolved.
62
+ """
63
+ ensure_git_repo(repo_path)
64
+
65
+ default_branch = get_default_branch_name(repo_path)
66
+ prev_tag = resolve_ref(repo_path, base_branch)
67
+
68
+ commit_text = get_commits_between(repo_path, prev_tag, "HEAD")
69
+ commits = parse_commits(commit_text)
70
+
71
+ return CommitContext(
72
+ commits=commits,
73
+ base_branch=base_branch,
74
+ default_branch=default_branch,
75
+ )
76
+
77
+
78
+ def generate_summary(
79
+ commits: list[str],
80
+ related_issues: Optional[str] = None,
81
+ template: Optional[str] = None,
82
+ ) -> SummaryResult:
83
+ """Generate a PR summary from commits.
84
+
85
+ Args:
86
+ commits: List of commit messages.
87
+ related_issues: Optional comma-separated related issues.
88
+ template: Optional PR template content.
89
+
90
+ Returns:
91
+ SummaryResult with reasoning and summary.
92
+ """
93
+ program = PullRequestSummaryGeneratorModule()
94
+ result = program(
95
+ commits=commits,
96
+ related_issues=related_issues,
97
+ template=template,
98
+ )
99
+
100
+ return SummaryResult(
101
+ reasoning=result.reasoning,
102
+ summary=result.summary,
103
+ )
104
+
105
+
106
+ def get_templates(repo_path: str) -> list[PrTemplate]:
107
+ """Find PR templates in the repository.
108
+
109
+ Args:
110
+ repo_path: Absolute path to the repository.
111
+
112
+ Returns:
113
+ List of found PR templates.
114
+ """
115
+ return find_pr_templates(repo_path)
File without changes
@@ -0,0 +1,44 @@
1
+ from typing import List, Optional
2
+
3
+ import dspy
4
+
5
+ RULES = [
6
+ "Start the summary with a # Summary header.",
7
+ "The next section should be ## Linked Issues, listing any related issues or tasks.",
8
+ "Follow with a ## Description section that details the changes made in the pull request.",
9
+ "Use bullet points for each major change.",
10
+ "Keep the summary concise and to the point.",
11
+ "Highlight any breaking changes or important updates.",
12
+ "Avoid technical jargon; use clear and simple language.",
13
+ ]
14
+
15
+ TEMPLATE_RULES = [
16
+ "The output MUST follow the structure of the provided PR template.",
17
+ "Fill in every section of the template with relevant content derived from the commits.",
18
+ "Preserve the template's headings, checkbox placeholders, and formatting exactly.",
19
+ "If a template section does not apply, write 'N/A' rather than omitting it.",
20
+ "Do not add sections that are not in the template.",
21
+ "If no template is provided, use the default summary rules instead.",
22
+ ]
23
+
24
+
25
+ class PullRequestSummaryGeneratorSignature(dspy.Signature):
26
+ """Generate a concise summary of a pull request based on a list of conventional commit messages. The summary should highlight the main changes introduced by the commits, and may include markdown tables or mermaid diagrams for better visualization when appropriate."""
27
+
28
+ rules: List[str] = dspy.InputField(
29
+ desc="A list of rules to follow when generating the summary."
30
+ )
31
+ commits: List[str] = dspy.InputField(desc="A list of conventional commit messages.")
32
+ related_issues: Optional[str] = dspy.InputField(
33
+ default=None, desc="A list of related issues or tasks, if any."
34
+ )
35
+ template: Optional[str] = dspy.InputField(
36
+ default=None,
37
+ desc="A GitHub pull request template to fill out. When provided, the summary must follow this template's structure and fill in each section.",
38
+ )
39
+ reasoning: str = dspy.OutputField(
40
+ desc="Step-by-step reasoning about the commits and how they contribute to the summary."
41
+ )
42
+ summary: str = dspy.OutputField(
43
+ desc="A summary of the pull request based on the provided commits, following the template structure if one was provided."
44
+ )
File without changes
usepr/utils/git.py ADDED
@@ -0,0 +1,126 @@
1
+ import subprocess
2
+ import sys
3
+ from typing import List, Optional
4
+
5
+ from usecli import console
6
+
7
+
8
+ def run(cmd: list[str], cwd: Optional[str] = None) -> str:
9
+ """
10
+ Run a command and return its output as a string.
11
+
12
+ Args:
13
+ cmd: List of command arguments.
14
+ cwd: Optional working directory.
15
+
16
+ Returns:
17
+ The command output as a string.
18
+ """
19
+ return (
20
+ subprocess.check_output(cmd, stderr=subprocess.STDOUT, cwd=cwd)
21
+ .decode("utf-8", errors="replace")
22
+ .strip()
23
+ )
24
+
25
+
26
+ def ensure_git_repo(repo_path: str) -> None:
27
+ """
28
+ Ensure the given path is a git repository.
29
+
30
+ Args:
31
+ repo_path: Path to the repository.
32
+
33
+ Raises:
34
+ SystemExit: If not a git repository.
35
+ """
36
+ try:
37
+ run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo_path)
38
+ except subprocess.CalledProcessError:
39
+ sys.stderr.write(f"[error] Not a git repository: {repo_path}\n")
40
+ sys.exit(2)
41
+
42
+
43
+ def get_commits_between(
44
+ repo_path: str, prev_tag: Optional[str], latest_tag: str = "HEAD"
45
+ ) -> str:
46
+ """
47
+ Get commit messages between two tags.
48
+
49
+ Args:
50
+ repo_path: Path to the repository.
51
+ prev_tag: Previous tag, or None to use default branch.
52
+ latest_tag: Latest tag.
53
+
54
+ Returns:
55
+ Formatted commit messages as string.
56
+ """
57
+ # Format: subject, newline, newline, body, separator
58
+ fmt = "%s%n%n%b----"
59
+ if prev_tag is None:
60
+ prev_tag = get_default_branch(repo_path)
61
+ rng = f"{prev_tag}..{latest_tag}"
62
+ result = subprocess.run(
63
+ ["git", "log", "--no-merges", f"--format={fmt}", rng],
64
+ cwd=repo_path,
65
+ capture_output=True,
66
+ text=True,
67
+ check=True,
68
+ )
69
+ text = result.stdout.strip()
70
+ # Remove trailing separator
71
+ if text.endswith("----"):
72
+ text = text[:-4].rstrip()
73
+ return text
74
+
75
+
76
+ def get_default_branch(repo_path: str) -> str:
77
+ """Get the default branch of the repository (main or master)."""
78
+ try:
79
+ result = subprocess.run(
80
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
81
+ cwd=repo_path,
82
+ capture_output=True,
83
+ text=True,
84
+ check=True,
85
+ )
86
+ return result.stdout.strip().split("/")[-1]
87
+ except subprocess.CalledProcessError:
88
+ # Fallback: check if 'main' exists, else 'master'
89
+ try:
90
+ subprocess.run(
91
+ ["git", "show-ref", "--verify", "--quiet", "refs/heads/main"],
92
+ cwd=repo_path,
93
+ check=True,
94
+ )
95
+ return "main"
96
+ except subprocess.CalledProcessError:
97
+ return "master"
98
+
99
+
100
+ def resolve_ref(repo_path: str, ref: str) -> str:
101
+ """Resolve a ref, trying the bare name first then origin/<ref>."""
102
+ for candidate in [ref, f"origin/{ref}"]:
103
+ result = subprocess.run(
104
+ ["git", "rev-parse", "--verify", candidate],
105
+ cwd=repo_path,
106
+ capture_output=True,
107
+ )
108
+ if result.returncode == 0:
109
+ return candidate
110
+ console.print(
111
+ f"[bold red]Error: Could not resolve ref '{ref}'. Make sure the branch exists locally or on the remote.[/bold red]"
112
+ )
113
+ sys.exit(1)
114
+
115
+
116
+ def parse_commits(commit_text: str) -> List[str]:
117
+ """
118
+ Parse the commit output into a list of individual commit messages.
119
+
120
+ Args:
121
+ commit_text: The formatted commit messages string from get_commits_between.
122
+
123
+ Returns:
124
+ List of individual commit messages.
125
+ """
126
+ return [commit.strip() for commit in commit_text.split("----") if commit.strip()]
usepr/utils/github.py ADDED
@@ -0,0 +1,85 @@
1
+ """Utilities for detecting and reading GitHub PR templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+
10
+ @dataclass
11
+ class PrTemplate:
12
+ """A detected PR template."""
13
+
14
+ path: str
15
+ content: str
16
+ name: Optional[str] = None
17
+
18
+
19
+ TEMPLATE_FILENAMES = ["PULL_REQUEST_TEMPLATE.md"]
20
+
21
+ TEMPLATE_DIR_NAME = "PULL_REQUEST_TEMPLATE"
22
+
23
+ GITHUB_DIR = ".github"
24
+
25
+
26
+ def _read_text(path: Path) -> Optional[str]:
27
+ """Read a text file, returning None if unreadable."""
28
+ try:
29
+ return path.read_text(encoding="utf-8").strip()
30
+ except (OSError, UnicodeDecodeError):
31
+ return None
32
+
33
+
34
+ def find_pr_templates(repo_path: str) -> List[PrTemplate]:
35
+ """
36
+ Find all PR templates in the repository.
37
+
38
+ Searches the following locations in order:
39
+ 1. .github/PULL_REQUEST_TEMPLATE.md
40
+ 2. .github/PULL_REQUEST_TEMPLATE/*.md (all .md files in the directory)
41
+ 3. PULL_REQUEST_TEMPLATE.md (repo root)
42
+
43
+ Args:
44
+ repo_path: Absolute path to the repository root.
45
+
46
+ Returns:
47
+ List of PrTemplate objects found. Empty list if none found.
48
+ """
49
+ root = Path(repo_path)
50
+ templates: List[PrTemplate] = []
51
+
52
+ # 1. .github/PULL_REQUEST_TEMPLATE.md
53
+ github_dir = root / GITHUB_DIR
54
+ for filename in TEMPLATE_FILENAMES:
55
+ candidate = github_dir / filename
56
+ content = _read_text(candidate)
57
+ if content:
58
+ templates.append(
59
+ PrTemplate(path=str(candidate), content=content, name=None)
60
+ )
61
+
62
+ # 2. .github/PULL_REQUEST_TEMPLATE/*.md
63
+ template_dir = github_dir / TEMPLATE_DIR_NAME
64
+ if template_dir.is_dir():
65
+ for md_file in sorted(template_dir.glob("*.md")):
66
+ content = _read_text(md_file)
67
+ if content:
68
+ templates.append(
69
+ PrTemplate(
70
+ path=str(md_file),
71
+ content=content,
72
+ name=md_file.stem,
73
+ )
74
+ )
75
+
76
+ # 3. Root-level PULL_REQUEST_TEMPLATE.md
77
+ for filename in TEMPLATE_FILENAMES:
78
+ candidate = root / filename
79
+ content = _read_text(candidate)
80
+ if content:
81
+ templates.append(
82
+ PrTemplate(path=str(candidate), content=content, name=None)
83
+ )
84
+
85
+ return templates
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: usepr
3
+ Version: 0.1.2
4
+ Summary: Add your description here
5
+ Author: Edward Boswell
6
+ Author-email: Edward Boswell <thememium@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Developers
12
+ Requires-Dist: dspy>=3.2.1
13
+ Requires-Dist: pyperclip>=1.11.0
14
+ Requires-Dist: pyyaml>=6.0.3
15
+ Requires-Dist: rich>=15.0.0
16
+ Requires-Dist: typer>=0.26.7
17
+ Requires-Dist: usecli>=0.1.67
18
+ Requires-Python: >=3.13
19
+ Project-URL: Homepage, https://github.com/thememium/usepr
20
+ Project-URL: Documentation, https://github.com/thememium/usepr
21
+ Project-URL: Repository, https://github.com/thememium/usepr.git
22
+ Project-URL: Issues, https://github.com/thememium/usepr/issues
23
+ Project-URL: Changelog, https://github.com/thememium/usepr/blob/master/CHANGELOG.md
24
+ Description-Content-Type: text/markdown
25
+
26
+ <a name="readme-top"></a>
27
+
28
+ <div align="center">
29
+ <a href="https://github.com/thememium/usepr">
30
+ <img src="https://raw.githubusercontent.com/thememium/usepr/refs/heads/master/docs/images/usepr-logo-dark-bg.png" alt="usePR" width="360" height="162">
31
+ </a>
32
+
33
+ <p align="center">
34
+ <em>AI-powered PR summary generator for Git repositories</em>
35
+ </p>
36
+
37
+ <p align="center">
38
+ <a href="#table-of-contents"><strong>Explore the Documentation »</strong></a>
39
+ <br />
40
+ <a href="https://github.com/thememium/usepr/issues">Report Bug</a>
41
+ ·
42
+ <a href="https://github.com/thememium/usepr/issues">Request Feature</a>
43
+ </p>
44
+ </div>
45
+
46
+ <!-- TABLE OF CONTENTS -->
47
+
48
+ <a name="table-of-contents"></a>
49
+
50
+ <details>
51
+ <summary>Table of Contents</summary>
52
+ <ol>
53
+ <li><a href="#about">About</a></li>
54
+ <li><a href="#quick-start">Quick Start</a></li>
55
+ <li><a href="#usage">Usage</a></li>
56
+ <li><a href="#development">Development</a></li>
57
+ <li><a href="#contributing">Contributing</a></li>
58
+ <li><a href="#license">License</a></li>
59
+ </ol>
60
+ </details>
61
+
62
+ <!-- ABOUT -->
63
+
64
+ ## About
65
+
66
+ usepr (`usepr`) is a Python CLI that generates pull request summaries from your git commits using AI (DSPy). It analyzes your commit history and produces well-structured, meaningful PR descriptions.
67
+
68
+ - **AI-powered summaries** - Uses DSPy ChainOfThought to understand and summarize your changes
69
+ - **Template support** - Automatically detects and uses PR templates from your repository
70
+ - **Flexible diffing** - Generate summaries between any branches, tags, or commits
71
+ - **Interactive prompts** - Guided workflow with base branch and issue selection
72
+ - **Clipboard integration** - Copy generated summaries directly to clipboard
73
+ - **Model override** - Use different LLM models via the `-m` flag
74
+
75
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
76
+
77
+ <!-- QUICK START -->
78
+
79
+ ## Quick Start
80
+
81
+ ### Install usepr with uv (recommended)
82
+
83
+ ```sh
84
+ uv tool install usepr
85
+ ```
86
+
87
+ ### Install with pipx (alternative)
88
+
89
+ ```sh
90
+ pipx install usepr
91
+ ```
92
+
93
+ ### Generate a PR summary
94
+
95
+ ```sh
96
+ usepr generate
97
+ ```
98
+
99
+ This will prompt you to select a base branch, optionally link related issues, and generate a summary from your commits.
100
+
101
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
102
+
103
+ <!-- USAGE -->
104
+
105
+ ## Usage
106
+
107
+ ### Generate a PR Summary
108
+
109
+ ```sh
110
+ usepr generate
111
+ ```
112
+
113
+ The interactive workflow will:
114
+
115
+ 1. Detect your repository's default branch
116
+ 2. Prompt for a base branch to diff against
117
+ 3. Gather commits between base and HEAD
118
+ 4. Ask for related issues (optional)
119
+ 5. Detect and offer PR templates (if any)
120
+ 6. Generate and display the summary
121
+ 7. Offer to copy to clipboard
122
+
123
+ ### Use a Custom Model
124
+
125
+ Set your API key as an environment variable for the provider you're using:
126
+
127
+ ```sh
128
+ export OPENAI_API_KEY="sk-..."
129
+ export ANTHROPIC_API_KEY="sk-ant-..."
130
+ ```
131
+
132
+ Then run with the `provider/model` format:
133
+
134
+ ```sh
135
+ usepr generate -m openai/gpt-4o
136
+ usepr generate -m anthropic/claude-sonnet-4-20250514
137
+ usepr generate -m openrouter/google/gemini-2.5-flash
138
+ ```
139
+
140
+ ### Use the Short Alias
141
+
142
+ ```sh
143
+ usepr gen
144
+ ```
145
+
146
+ ### Available Commands
147
+
148
+ ```
149
+ generate (gen) Generate a PR summary from commits
150
+ help Show help
151
+ ```
152
+
153
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
154
+
155
+ <!-- DEVELOPMENT -->
156
+
157
+ ## Development
158
+
159
+ Common tasks:
160
+
161
+ ```sh
162
+ uv run poe clean-full
163
+ uv run poe test
164
+ uv run poe lint
165
+ uv run poe format
166
+ ```
167
+
168
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
169
+
170
+ <!-- CONTRIBUTING -->
171
+
172
+ ## Contributing
173
+
174
+ Quick workflow:
175
+
176
+ 1. Fork and branch: `git checkout -b feature/name`
177
+ 2. Make changes
178
+ 3. Run checks: `uv run poe clean-full`
179
+ 4. Commit and push
180
+ 5. Open a Pull Request
181
+
182
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
183
+
184
+ <!-- LICENSE -->
185
+
186
+ ## License
187
+
188
+ License information has not been added yet.
189
+
190
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
191
+
192
+ ---
193
+
194
+ <div align="center">
195
+ <p>
196
+ <sub>Built by <a href="https://github.com/thememium">thememium</a></sub>
197
+ </p>
198
+ </div>
@@ -0,0 +1,24 @@
1
+ usepr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ usepr/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ usepr/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ usepr/cli/commands/generate_command.py,sha256=-ouv_Mg-PoPTqoa8NrawC0_Ht82R_fy09ynN90PHsC4,5309
5
+ usepr/cli/templates/command.py.j2,sha256=glpKmz98KtFTZmmYtPYUeFrUqUfRJTFCYIQMKnaPQ-M,2322
6
+ usepr/cli/themes/default.toml,sha256=tNtI6yTUgbnTevSO2Q9RN06oH1FXs0H7_cAfIB8GY0I,583
7
+ usepr/cli/themes/title.txt,sha256=JiHOBLFhhhnd0c_jngSj2JqXV0-yaF61L3nRavCmsWs,619
8
+ usepr/cli/usecli.config.toml,sha256=63P9KMcDIRXtS22TsJb6S8Opcv1xt6MNCkMPur3xrkA,278
9
+ usepr/configs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ usepr/configs/dspy.py,sha256=w1KnqU6zA-5LMh0nrsc3I0_VzPqmko3UCSC1cE4cQDM,1476
11
+ usepr/modules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ usepr/modules/pull_request_summary_generator.py,sha256=l2F4nTt1CHe8llxQVI3gihafpvtU5VVhLTPTZO4LXjE,948
13
+ usepr/services/__init__.py,sha256=kMCe_42FMbwfHA6AT5yB7O2KB-mB89K46ZOamy-40Ak,41
14
+ usepr/services/pr_summary_service.py,sha256=LTOXcmgm79FMS0YJ2m1REL_1iXTHUOPXT2C1fRTTHkQ,2781
15
+ usepr/signatures/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ usepr/signatures/pull_request_summary_generator.py,sha256=1uMweJNqNLOk2DBBK2lhKUPi0W-TcTBJM8QUWdh6jhk,2214
17
+ usepr/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ usepr/utils/git.py,sha256=Nn3DSDuprgqGvB4AldCiVT3YYLmJAC6DEpDP7tHZ_eo,3581
19
+ usepr/utils/github.py,sha256=LW1ZHpv2nVD0qejYMqw0VUwknv4oZ1pDARKk0avyky0,2369
20
+ usepr-0.1.2.dist-info/licenses/LICENSE,sha256=3URXXqgUcmPG_29EcFLqyGt7EWNGU709amKh90jCQIA,1081
21
+ usepr-0.1.2.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
22
+ usepr-0.1.2.dist-info/entry_points.txt,sha256=sVOvvsPgBzAE5AHTqZ9EqwKy5Bcc50bvlgUvHUf-_nQ,39
23
+ usepr-0.1.2.dist-info/METADATA,sha256=9fQD_N4u2DAtgeMy4b1qNs-iNaFpuGWnqAwwnPqEyQY,4872
24
+ usepr-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ usepr = usecli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026–present Edward Boswell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.