pullwise 0.1.0__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.
pr_pilot/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pr_pilot/analyzer.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+
6
+ from openai import OpenAI
7
+
8
+ from .templates import DESCRIBE_SYSTEM, DESCRIBE_USER, LABEL_SYSTEM, REVIEW_SYSTEM
9
+
10
+ _MAX_DIFF_CHARS = 24_000 # stay well within context limits
11
+ _MODEL = "gpt-4o"
12
+
13
+
14
+ @dataclass
15
+ class PRDescription:
16
+ title: str
17
+ summary: str
18
+ changes: list[str]
19
+ breaking: bool
20
+ breaking_notes: str | None
21
+ test_plan: str
22
+ labels: list[str]
23
+
24
+ def to_markdown(self) -> str:
25
+ lines = [
26
+ f"## Summary\n{self.summary}\n",
27
+ "## Changes",
28
+ ]
29
+ for c in self.changes:
30
+ lines.append(f"- {c}")
31
+ if self.breaking:
32
+ lines.append(f"\n## ⚠️ Breaking Changes\n{self.breaking_notes}")
33
+ lines.append(f"\n## Test Plan\n{self.test_plan}")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def _git(*args: str) -> str:
38
+ result = subprocess.run(["git"] + list(args), capture_output=True, text=True)
39
+ return result.stdout.strip()
40
+
41
+
42
+ def get_diff(base: str = "main") -> str:
43
+ diff = _git("diff", f"{base}...HEAD")
44
+ if len(diff) > _MAX_DIFF_CHARS:
45
+ diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated — showing first 24k chars]"
46
+ return diff
47
+
48
+
49
+ def get_commits(base: str = "main") -> str:
50
+ return _git("log", f"{base}...HEAD", "--oneline", "--no-merges")
51
+
52
+
53
+ def get_branch() -> str:
54
+ return _git("rev-parse", "--abbrev-ref", "HEAD")
55
+
56
+
57
+ def describe_pr(api_key: str, base: str = "main", model: str = _MODEL) -> PRDescription:
58
+ client = OpenAI(api_key=api_key)
59
+ diff = get_diff(base)
60
+ commits = get_commits(base)
61
+ branch = get_branch()
62
+
63
+ if not diff and not commits:
64
+ raise ValueError(f"No changes found between '{branch}' and '{base}'")
65
+
66
+ user_msg = DESCRIBE_USER.format(
67
+ branch=branch, base=base, commits=commits or "(none)", diff=diff or "(empty)"
68
+ )
69
+ resp = client.chat.completions.create(
70
+ model=model,
71
+ max_tokens=1024,
72
+ messages=[
73
+ {"role": "system", "content": DESCRIBE_SYSTEM},
74
+ {"role": "user", "content": user_msg},
75
+ ],
76
+ )
77
+ raw = resp.choices[0].message.content or "{}"
78
+ raw = raw.strip()
79
+ if raw.startswith("```"):
80
+ raw = "\n".join(raw.splitlines()[1:])
81
+ if raw.endswith("```"):
82
+ raw = "\n".join(raw.splitlines()[:-1])
83
+
84
+ data = json.loads(raw.strip())
85
+ return PRDescription(
86
+ title=data.get("title", branch),
87
+ summary=data.get("summary", ""),
88
+ changes=data.get("changes", []),
89
+ breaking=data.get("breaking", False),
90
+ breaking_notes=data.get("breaking_notes"),
91
+ test_plan=data.get("test_plan", ""),
92
+ labels=data.get("labels", ["feature"]),
93
+ )
94
+
95
+
96
+ def suggest_labels(api_key: str, title: str, body: str, model: str = _MODEL) -> list[str]:
97
+ client = OpenAI(api_key=api_key)
98
+ resp = client.chat.completions.create(
99
+ model=model,
100
+ max_tokens=64,
101
+ messages=[
102
+ {"role": "system", "content": LABEL_SYSTEM},
103
+ {"role": "user", "content": f"Title: {title}\n\nBody: {body[:2000]}"},
104
+ ],
105
+ )
106
+ raw = resp.choices[0].message.content or "[]"
107
+ return json.loads(raw.strip())
108
+
109
+
110
+ def review_pr(api_key: str, base: str = "main", model: str = _MODEL) -> str:
111
+ client = OpenAI(api_key=api_key)
112
+ diff = get_diff(base)
113
+ if not diff:
114
+ return "No changes to review."
115
+ resp = client.chat.completions.create(
116
+ model=model,
117
+ max_tokens=1024,
118
+ messages=[
119
+ {"role": "system", "content": REVIEW_SYSTEM},
120
+ {"role": "user", "content": f"Diff:\n\n{diff}"},
121
+ ],
122
+ )
123
+ return (resp.choices[0].message.content or "").strip()
pr_pilot/cli.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import os
4
+ import sys
5
+
6
+ from .analyzer import describe_pr, suggest_labels, review_pr
7
+
8
+ _RESET = "\033[0m"
9
+ _BOLD = "\033[1m"
10
+ _GREEN = "\033[32m"
11
+ _CYAN = "\033[36m"
12
+ _DIM = "\033[2m"
13
+ _YELLOW = "\033[33m"
14
+
15
+
16
+ def _key() -> str:
17
+ k = os.environ.get("OPENAI_API_KEY", "")
18
+ if not k:
19
+ print("pr-pilot: OPENAI_API_KEY is not set", file=sys.stderr)
20
+ sys.exit(1)
21
+ return k
22
+
23
+
24
+ def cmd_describe(args: argparse.Namespace) -> None:
25
+ print(f"\n {_DIM}Analyzing diff against '{args.base}'...{_RESET}\n")
26
+ desc = describe_pr(_key(), base=args.base, model=args.model)
27
+
28
+ print(f"{_BOLD}Title:{_RESET} {desc.title}\n")
29
+ print(f"{_BOLD}Summary:{_RESET}")
30
+ print(f" {desc.summary}\n")
31
+ print(f"{_BOLD}Changes:{_RESET}")
32
+ for c in desc.changes:
33
+ print(f" {_GREEN}•{_RESET} {c}")
34
+ if desc.breaking:
35
+ print(f"\n {_YELLOW}⚠ Breaking:{_RESET} {desc.breaking_notes}")
36
+ print(f"\n{_BOLD}Test plan:{_RESET}")
37
+ print(f" {desc.test_plan}")
38
+ print(f"\n{_BOLD}Suggested labels:{_RESET} {', '.join(desc.labels)}")
39
+
40
+ if args.markdown:
41
+ path = args.markdown
42
+ with open(path, "w") as f:
43
+ f.write(desc.to_markdown())
44
+ print(f"\n {_GREEN}✓{_RESET} Written to {path}")
45
+ print()
46
+
47
+
48
+ def cmd_review(args: argparse.Namespace) -> None:
49
+ print(f"\n {_DIM}Reviewing diff against '{args.base}'...{_RESET}\n")
50
+ review = review_pr(_key(), base=args.base, model=args.model)
51
+ print(review)
52
+ print()
53
+
54
+
55
+ def cmd_action(args: argparse.Namespace) -> None:
56
+ """Run as a GitHub Action — reads env vars set by the Actions runner."""
57
+ import json as _json
58
+ from .github_client import get_pr, update_pr, add_labels
59
+
60
+ repo = os.environ.get("GITHUB_REPOSITORY", "")
61
+ pr_num = int(os.environ.get("PR_NUMBER", "0"))
62
+ token = os.environ.get("GITHUB_TOKEN", "")
63
+ skip_labels = os.environ.get("SKIP_LABELS", "false").lower() == "true"
64
+ update_title = os.environ.get("UPDATE_TITLE", "false").lower() == "true"
65
+
66
+ if not repo or not pr_num or not token:
67
+ print("pr-pilot action: GITHUB_REPOSITORY, PR_NUMBER, GITHUB_TOKEN must be set", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ pr = get_pr(repo, pr_num)
71
+ print(f" PR #{pr_num}: {pr.title}")
72
+ print(f" Base: {pr.base} Head: {pr.head}")
73
+
74
+ desc = describe_pr(_key(), base=pr.base, model=args.model)
75
+ body = desc.to_markdown()
76
+
77
+ # Don't overwrite if user already wrote a substantial description
78
+ if pr.body and len(pr.body.strip()) > 100:
79
+ print(" PR already has a description — skipping body update")
80
+ body = None
81
+
82
+ update_pr(repo, pr_num, title=desc.title if update_title else None, body=body or pr.body)
83
+ print(f" ✓ Description updated")
84
+
85
+ if not skip_labels:
86
+ add_labels(repo, pr_num, desc.labels)
87
+ print(f" ✓ Labels added: {', '.join(desc.labels)}")
88
+
89
+
90
+ def main() -> None:
91
+ parser = argparse.ArgumentParser(
92
+ prog="pr-pilot",
93
+ description="AI-powered PR descriptions and labels using OpenAI.",
94
+ )
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ # --- describe ---
98
+ p_desc = sub.add_parser("describe", help="Generate a PR description for the current branch")
99
+ p_desc.add_argument("--base", default="main", help="Base branch to diff against (default: main)")
100
+ p_desc.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
101
+ p_desc.add_argument("--markdown", metavar="FILE", help="Write description as markdown to FILE")
102
+ p_desc.set_defaults(func=cmd_describe)
103
+
104
+ # --- review ---
105
+ p_rev = sub.add_parser("review", help="Get a quick code review of the current branch")
106
+ p_rev.add_argument("--base", default="main", help="Base branch to diff against (default: main)")
107
+ p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
108
+ p_rev.set_defaults(func=cmd_review)
109
+
110
+ # --- action (internal, called by entrypoint.sh) ---
111
+ p_act = sub.add_parser("action", help="Run as a GitHub Action (internal)")
112
+ p_act.add_argument("--model", default="gpt-4o")
113
+ p_act.set_defaults(func=cmd_action)
114
+
115
+ args = parser.parse_args()
116
+ args.func(args)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import os
4
+ import sys
5
+ import urllib.request
6
+ import urllib.error
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class PRInfo:
12
+ number: int
13
+ title: str
14
+ body: str
15
+ base: str
16
+ head: str
17
+
18
+
19
+ def _api(method: str, path: str, body: dict | None = None) -> dict:
20
+ token = os.environ.get("GITHUB_TOKEN", "")
21
+ url = f"https://api.github.com{path}"
22
+ data = json.dumps(body).encode() if body else None
23
+ req = urllib.request.Request(
24
+ url, data=data, method=method,
25
+ headers={
26
+ "Authorization": f"Bearer {token}",
27
+ "Accept": "application/vnd.github+json",
28
+ "X-GitHub-Api-Version": "2022-11-28",
29
+ "Content-Type": "application/json",
30
+ },
31
+ )
32
+ try:
33
+ with urllib.request.urlopen(req) as resp:
34
+ return json.loads(resp.read())
35
+ except urllib.error.HTTPError as e:
36
+ print(f"GitHub API error {e.code}: {e.read().decode()}", file=sys.stderr)
37
+ sys.exit(1)
38
+
39
+
40
+ def get_pr(repo: str, pr_number: int) -> PRInfo:
41
+ data = _api("GET", f"/repos/{repo}/pulls/{pr_number}")
42
+ return PRInfo(
43
+ number=data["number"],
44
+ title=data["title"],
45
+ body=data.get("body") or "",
46
+ base=data["base"]["ref"],
47
+ head=data["head"]["ref"],
48
+ )
49
+
50
+
51
+ def update_pr(repo: str, pr_number: int, title: str | None, body: str) -> None:
52
+ payload: dict = {"body": body}
53
+ if title:
54
+ payload["title"] = title
55
+ _api("PATCH", f"/repos/{repo}/pulls/{pr_number}", payload)
56
+
57
+
58
+ def add_labels(repo: str, pr_number: int, labels: list[str]) -> None:
59
+ # Ensure labels exist first
60
+ existing = {l["name"] for l in _api("GET", f"/repos/{repo}/labels")}
61
+ _COLORS = {
62
+ "bug": "d73a4a", "feature": "0075ca", "docs": "0075ca",
63
+ "refactor": "e4e669", "test": "bfd4f2", "chore": "ffffff",
64
+ "performance": "f9d0c4", "security": "ee0701", "breaking-change": "b60205",
65
+ }
66
+ for label in labels:
67
+ if label not in existing:
68
+ try:
69
+ _api("POST", f"/repos/{repo}/labels", {
70
+ "name": label,
71
+ "color": _COLORS.get(label, "ededed"),
72
+ })
73
+ except SystemExit:
74
+ pass # label might already exist in race condition
75
+
76
+ _api("POST", f"/repos/{repo}/issues/{pr_number}/labels", {"labels": labels})
77
+
78
+
79
+ def post_comment(repo: str, pr_number: int, body: str) -> None:
80
+ _api("POST", f"/repos/{repo}/issues/{pr_number}/comments", {"body": body})
pr_pilot/templates.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ DESCRIBE_SYSTEM = """\
4
+ You are an expert software engineer writing a pull request description.
5
+ Given a git diff and commit messages, produce a clear, structured PR description.
6
+
7
+ Return a JSON object with exactly these keys:
8
+ {
9
+ "title": "short imperative title, max 72 chars",
10
+ "summary": "1-3 sentence plain-English explanation of WHAT changed and WHY",
11
+ "changes": ["bullet point list of key changes (max 8 items)"],
12
+ "breaking": true or false,
13
+ "breaking_notes": "describe breaking changes if any, else null",
14
+ "test_plan": "how to verify this PR works (be specific, not generic)",
15
+ "labels": ["one or more of: bug, feature, docs, refactor, test, chore, performance, security, breaking-change"]
16
+ }
17
+
18
+ Rules:
19
+ - Be concrete. Never write "various improvements" or "updates code".
20
+ - title must be imperative: "Add X", "Fix Y", "Remove Z" — not "Added" or "Adding"
21
+ - If the diff is too large, summarize the most impactful changes
22
+ - Return ONLY the JSON object, no markdown fences
23
+ """
24
+
25
+ DESCRIBE_USER = """\
26
+ Branch: {branch}
27
+ Target: {base}
28
+ Commits:
29
+ {commits}
30
+
31
+ Diff (may be truncated):
32
+ {diff}
33
+ """
34
+
35
+ LABEL_SYSTEM = """\
36
+ You are a PR triage bot. Given a PR title, description, and diff summary,
37
+ return ONLY a JSON array of label strings from this set:
38
+ ["bug", "feature", "docs", "refactor", "test", "chore", "performance", "security", "breaking-change"]
39
+ Return between 1 and 3 labels. No prose, just the array.
40
+ """
41
+
42
+ REVIEW_SYSTEM = """\
43
+ You are a senior engineer doing a quick PR review pass.
44
+ Given a diff, summarize:
45
+ 1. What this PR does (2-3 sentences)
46
+ 2. Potential concerns or risks (if any)
47
+ 3. Suggested improvements (if any, max 3)
48
+
49
+ Be direct and specific. No flattery. Format as plain markdown.
50
+ """
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: pullwise
3
+ Version: 0.1.0
4
+ Summary: AI-powered PR descriptions, labels, and code review using OpenAI
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/albertusreza/pr-pilot
7
+ Project-URL: Issues, https://github.com/albertusreza/pr-pilot/issues
8
+ Project-URL: GitHub Action, https://github.com/marketplace/actions/pr-pilot
9
+ Keywords: github,pull-request,openai,ai,automation,devtools,cli,github-actions
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: openai>=1.0.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+
22
+ # pr-pilot ✈️
23
+
24
+ **Stop writing PR descriptions.** Let AI do it.
25
+
26
+ `pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
27
+
28
+ ```yaml
29
+ # .github/workflows/pr-description.yml
30
+ - uses: albertusreza/pr-pilot@main
31
+ with:
32
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
33
+ ```
34
+
35
+ That's it. Every new PR gets a description like this:
36
+
37
+ ---
38
+
39
+ > **Add rate limiting to upload endpoint**
40
+ >
41
+ > ## Summary
42
+ > Adds per-IP rate limiting to `/api/upload` using a sliding window algorithm.
43
+ > Without this, a single client could saturate the server with concurrent uploads.
44
+ >
45
+ > ## Changes
46
+ > - Add `RateLimiter` middleware with configurable window and max requests
47
+ > - Apply limiter to `/api/upload` route (100 req/min default)
48
+ > - Return `429 Too Many Requests` with `Retry-After` header
49
+ > - Add unit tests for edge cases (burst, reset, concurrent)
50
+ >
51
+ > ## Test Plan
52
+ > 1. Run `pytest tests/test_rate_limiter.py`
53
+ > 2. Send 101 rapid requests to `/api/upload` — 101st should return 429
54
+ > 3. Wait 60 seconds — requests should succeed again
55
+ >
56
+ > **Labels:** `feature`, `security`
57
+
58
+ ---
59
+
60
+ ## Install
61
+
62
+ ### As a GitHub Action (zero-config)
63
+
64
+ ```yaml
65
+ name: PR Pilot
66
+ on:
67
+ pull_request:
68
+ types: [opened, reopened]
69
+
70
+ jobs:
71
+ describe:
72
+ runs-on: ubuntu-latest
73
+ permissions:
74
+ pull-requests: write
75
+ contents: read
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ with:
79
+ fetch-depth: 0
80
+ - uses: albertusreza/pr-pilot@main
81
+ with:
82
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
83
+ ```
84
+
85
+ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any language, any repo size.
86
+
87
+ ### As a CLI
88
+
89
+ ```bash
90
+ pip install pr-pilot
91
+ export OPENAI_API_KEY=sk-...
92
+
93
+ # Generate a description for your current branch
94
+ pr-pilot describe
95
+
96
+ # Diff against a different base
97
+ pr-pilot describe --base develop
98
+
99
+ # Get a quick code review
100
+ pr-pilot review
101
+
102
+ # Save description as markdown
103
+ pr-pilot describe --markdown pr_description.md
104
+ ```
105
+
106
+ ## Options
107
+
108
+ | Input | Default | Description |
109
+ |---|---|---|
110
+ | `openai-api-key` | — | **Required.** Your OpenAI API key |
111
+ | `model` | `gpt-4o` | OpenAI model to use |
112
+ | `skip-labels` | `false` | Skip adding labels to the PR |
113
+ | `update-title` | `false` | Also rewrite the PR title |
114
+
115
+ ## How it works
116
+
117
+ 1. On PR open, checks out the branch with full history
118
+ 2. Runs `git diff <base>...HEAD` to get the full diff
119
+ 3. Collects commit messages with `git log --oneline`
120
+ 4. Sends both to GPT-4o with a structured prompt
121
+ 5. Parses the JSON response and updates the PR body + labels via GitHub API
122
+ 6. Skips update if the PR already has a substantial description (>100 chars)
123
+
124
+ ## Why
125
+
126
+ Over 60% of PRs are merged with descriptions like "fix bug", "WIP", or nothing at all. This makes code review harder, changelogs meaningless, and git history useless for future maintainers.
127
+
128
+ `pr-pilot` takes 30 seconds to set up and runs on every PR automatically — no discipline required.
129
+
130
+ ## Cost
131
+
132
+ Each PR description costs roughly **$0.01–0.03** with `gpt-4o` depending on diff size. For a team of 5 opening ~20 PRs/week, that's about **$1–3/month**.
133
+
134
+ ## Self-hosted / private repos
135
+
136
+ Works with private repos. Just add the secret and the workflow file — no data leaves your GitHub Actions runner except the diff sent to OpenAI.
137
+
138
+ ## Contributing
139
+
140
+ ```bash
141
+ git clone https://github.com/albertusreza/pr-pilot
142
+ cd pr-pilot
143
+ pip install -e ".[dev]"
144
+ pytest
145
+ ```
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,10 @@
1
+ pr_pilot/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ pr_pilot/analyzer.py,sha256=GiAlySWBUBOGb_wovbJ_ilPgiP_Jw0eSOMBdzYwH62Q,3716
3
+ pr_pilot/cli.py,sha256=N90-y5wKT7Cxv2iapxh0JiRaixCRS1x0NWR7koGsTzI,4253
4
+ pr_pilot/github_client.py,sha256=mk359AVH4GJfQdDdz_D1gZupJJb68ov3HzvmbyEeGcU,2521
5
+ pr_pilot/templates.py,sha256=_Z9OLyYTYK5Y4x8ByFfnjHykr4KRUDnS5x3k47my8JQ,1738
6
+ pullwise-0.1.0.dist-info/METADATA,sha256=p4rLEvM-PifOYx1wXJbymAmOA3c3ne_uqvdDjuvMPB4,4391
7
+ pullwise-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ pullwise-0.1.0.dist-info/entry_points.txt,sha256=1s4fb04UXwbcdX5_doW8Kj01NhN9EF_9STLYXKorjqU,76
9
+ pullwise-0.1.0.dist-info/top_level.txt,sha256=shJVaJpBrxFmcPsqjDgBLAwoQKdDOj0DuE5i2oJZbl8,9
10
+ pullwise-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pr-pilot = pr_pilot.cli:main
3
+ pullwise = pr_pilot.cli:main
@@ -0,0 +1 @@
1
+ pr_pilot