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 +1 -0
- pr_pilot/analyzer.py +123 -0
- pr_pilot/cli.py +120 -0
- pr_pilot/github_client.py +80 -0
- pr_pilot/templates.py +50 -0
- pullwise-0.1.0.dist-info/METADATA +149 -0
- pullwise-0.1.0.dist-info/RECORD +10 -0
- pullwise-0.1.0.dist-info/WHEEL +5 -0
- pullwise-0.1.0.dist-info/entry_points.txt +3 -0
- pullwise-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
pr_pilot
|