commitstash 0.3.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.
- autocommit/__init__.py +1 -0
- autocommit/changelog.py +86 -0
- autocommit/cli.py +777 -0
- autocommit/config.py +35 -0
- autocommit/explain.py +43 -0
- autocommit/git.py +167 -0
- autocommit/llm.py +298 -0
- autocommit/pr.py +78 -0
- autocommit/providers.py +119 -0
- autocommit/review.py +83 -0
- autocommit/secrets.py +85 -0
- autocommit/split.py +106 -0
- commitstash-0.3.0.dist-info/METADATA +429 -0
- commitstash-0.3.0.dist-info/RECORD +17 -0
- commitstash-0.3.0.dist-info/WHEEL +4 -0
- commitstash-0.3.0.dist-info/entry_points.txt +2 -0
- commitstash-0.3.0.dist-info/licenses/LICENSE +21 -0
autocommit/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
CONFIG_PATH = Path.home() / ".autocommit" / "config.json"
|
|
5
|
+
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
"provider": "anthropic",
|
|
8
|
+
"anthropic_model": "claude-opus-4-8",
|
|
9
|
+
"openai_model": "gpt-4o-mini",
|
|
10
|
+
"ollama_model": "llama3.2",
|
|
11
|
+
"ollama_host": "http://localhost:11434",
|
|
12
|
+
"style": "conventional",
|
|
13
|
+
"include_scope": True,
|
|
14
|
+
"include_body": False,
|
|
15
|
+
"emoji": False,
|
|
16
|
+
"max_diff_lines": 500,
|
|
17
|
+
"scan_secrets": True,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config():
|
|
22
|
+
config = DEFAULTS.copy()
|
|
23
|
+
if CONFIG_PATH.exists():
|
|
24
|
+
with open(CONFIG_PATH) as f:
|
|
25
|
+
config.update(json.load(f))
|
|
26
|
+
return config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_config(config):
|
|
30
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
# Never persist API keys to disk
|
|
32
|
+
safe = {k: v for k, v in config.items() if "api_key" not in k}
|
|
33
|
+
with open(CONFIG_PATH, "w") as f:
|
|
34
|
+
json.dump(safe, f, indent=2)
|
|
35
|
+
return CONFIG_PATH
|
autocommit/explain.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Explain the staged diff in plain language.
|
|
2
|
+
|
|
3
|
+
Needs a real model — there is no offline mode; the heuristic can classify
|
|
4
|
+
a change but cannot explain intent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .llm import LOCAL_PROVIDERS, complete
|
|
8
|
+
|
|
9
|
+
EXPLAIN_PROMPT = """Explain this staged git diff to a reviewer who hasn't seen the codebase today.
|
|
10
|
+
|
|
11
|
+
Changed files:
|
|
12
|
+
{files}
|
|
13
|
+
|
|
14
|
+
Staged diff:
|
|
15
|
+
```
|
|
16
|
+
{diff}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Output exactly these markdown sections:
|
|
20
|
+
|
|
21
|
+
## What changed
|
|
22
|
+
<2-4 sentences describing the change in plain language>
|
|
23
|
+
|
|
24
|
+
## Why (inferred)
|
|
25
|
+
<the most likely motivation, inferred from the code — say "unclear" if it is>
|
|
26
|
+
|
|
27
|
+
## Impact
|
|
28
|
+
<what behavior changes for users or callers>
|
|
29
|
+
|
|
30
|
+
## Risk
|
|
31
|
+
<Low, Medium, or High — one line explaining the rating; call out missing tests>
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- Plain language, no file-by-file narration
|
|
35
|
+
- Be honest about uncertainty; never invent intent the diff doesn't support"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def explain(diff, files, config):
|
|
39
|
+
"""Return a markdown explanation, or None if the provider is offline-only."""
|
|
40
|
+
if config.get("provider") in LOCAL_PROVIDERS:
|
|
41
|
+
return None
|
|
42
|
+
files_str = "\n".join(f" - {f}" for f in files)
|
|
43
|
+
return complete(EXPLAIN_PROMPT.format(files=files_str, diff=diff), config, max_tokens=1500)
|
autocommit/git.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def is_git_repo():
|
|
6
|
+
result = subprocess.run(
|
|
7
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
8
|
+
capture_output=True,
|
|
9
|
+
text=True,
|
|
10
|
+
)
|
|
11
|
+
return result.returncode == 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_staged_diff():
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["git", "diff", "--cached"],
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
)
|
|
20
|
+
if result.returncode != 0:
|
|
21
|
+
return None, result.stderr
|
|
22
|
+
return result.stdout, None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_staged_files():
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "diff", "--cached", "--name-only"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
return [], result.stderr
|
|
33
|
+
files = [f for f in result.stdout.strip().split("\n") if f]
|
|
34
|
+
return files, None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stage_all():
|
|
38
|
+
result = subprocess.run(["git", "add", "-A"], capture_output=True, text=True)
|
|
39
|
+
return result.returncode == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_commit(message):
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
["git", "commit", "-m", message],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
)
|
|
48
|
+
return result.returncode == 0, result.stdout, result.stderr
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_current_branch():
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
)
|
|
57
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_default_branch():
|
|
61
|
+
"""Best-effort base branch: origin/HEAD if set, else main/master if they exist."""
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
if result.returncode == 0:
|
|
68
|
+
return result.stdout.strip().removeprefix("origin/")
|
|
69
|
+
for name in ("main", "master"):
|
|
70
|
+
check = subprocess.run(
|
|
71
|
+
["git", "rev-parse", "--verify", "--quiet", name],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
)
|
|
75
|
+
if check.returncode == 0:
|
|
76
|
+
return name
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_branch_commits(base):
|
|
81
|
+
"""Commit subjects on HEAD that are not on base, oldest first."""
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["git", "log", "--reverse", "--pretty=%s", f"{base}..HEAD"],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
return [], result.stderr
|
|
89
|
+
return [ln for ln in result.stdout.strip().split("\n") if ln], None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_branch_diff(base):
|
|
93
|
+
"""Diff of HEAD against the merge-base with base (what a PR would show)."""
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["git", "diff", f"{base}...HEAD"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
return None, result.stderr
|
|
101
|
+
return result.stdout, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_recent_commit_subjects(n=20):
|
|
105
|
+
"""Subjects of the last n commits, newest first. Empty list on any failure."""
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
["git", "log", f"-{n}", "--pretty=%s"],
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
)
|
|
111
|
+
if result.returncode != 0:
|
|
112
|
+
return []
|
|
113
|
+
return [ln for ln in result.stdout.strip().split("\n") if ln]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_last_tag():
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
["git", "describe", "--tags", "--abbrev=0"],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
)
|
|
122
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_commit_subjects_since(ref):
|
|
126
|
+
"""Subjects after ref (exclusive) up to HEAD, oldest first. ref=None -> all commits."""
|
|
127
|
+
rev_range = f"{ref}..HEAD" if ref else "HEAD"
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["git", "log", "--reverse", "--pretty=%s", rev_range],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
)
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
return [], result.stderr
|
|
135
|
+
return [ln for ln in result.stdout.strip().split("\n") if ln], None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_unstaged_files():
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["git", "diff", "--name-only"],
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
)
|
|
144
|
+
if result.returncode != 0:
|
|
145
|
+
return []
|
|
146
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def unstage_all():
|
|
150
|
+
result = subprocess.run(["git", "reset", "-q"], capture_output=True, text=True)
|
|
151
|
+
return result.returncode == 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def stage_files(files):
|
|
155
|
+
result = subprocess.run(["git", "add", "--", *files], capture_output=True, text=True)
|
|
156
|
+
return result.returncode == 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_repo_name():
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
)
|
|
165
|
+
if result.returncode == 0:
|
|
166
|
+
return Path(result.stdout.strip()).name
|
|
167
|
+
return None
|
autocommit/llm.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import PurePosixPath
|
|
3
|
+
|
|
4
|
+
from . import providers
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
STYLE_INSTRUCTIONS = {
|
|
8
|
+
"conventional": """\
|
|
9
|
+
Generate a conventional commit message.
|
|
10
|
+
|
|
11
|
+
Format: <type>(<scope>): <description>
|
|
12
|
+
|
|
13
|
+
Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
|
|
14
|
+
- feat: a new feature
|
|
15
|
+
- fix: a bug fix
|
|
16
|
+
- refactor: code change that neither fixes a bug nor adds a feature
|
|
17
|
+
- chore: tooling, deps, config changes
|
|
18
|
+
- docs: documentation only
|
|
19
|
+
- perf: performance improvement
|
|
20
|
+
- test: adding or fixing tests
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- First line must be under 72 characters
|
|
24
|
+
- Description is lowercase, no trailing period
|
|
25
|
+
- Scope is the module or component affected (e.g. auth, orders, cli)""",
|
|
26
|
+
"angular": """\
|
|
27
|
+
Generate an Angular-style commit message.
|
|
28
|
+
|
|
29
|
+
Format: <type>(<scope>): <subject>
|
|
30
|
+
|
|
31
|
+
Types: feat, fix, docs, style, refactor, test, chore
|
|
32
|
+
Subject is imperative, present tense, lowercase, no trailing period.
|
|
33
|
+
First line under 72 characters.""",
|
|
34
|
+
"simple": """\
|
|
35
|
+
Generate a short, plain-English commit message.
|
|
36
|
+
No special format. Imperative mood. Under 72 characters.
|
|
37
|
+
Example: "Add user authentication" or "Fix null pointer in order view" """,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_prompt(diff, files, config, recent_subjects=None):
|
|
42
|
+
style = config.get("style", "conventional")
|
|
43
|
+
include_scope = config.get("include_scope", True)
|
|
44
|
+
include_body = config.get("include_body", False)
|
|
45
|
+
emoji = config.get("emoji", False)
|
|
46
|
+
|
|
47
|
+
files_str = "\n".join(f" - {f}" for f in files)
|
|
48
|
+
style_block = STYLE_INSTRUCTIONS.get(style, STYLE_INSTRUCTIONS["conventional"])
|
|
49
|
+
|
|
50
|
+
recent_block = ""
|
|
51
|
+
if recent_subjects:
|
|
52
|
+
joined = "\n".join(f" {s}" for s in recent_subjects[:20])
|
|
53
|
+
recent_block = (
|
|
54
|
+
"\nRecent commit messages in this repository — match their tone, "
|
|
55
|
+
f"scope naming, and conventions:\n{joined}\n"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
scope_note = "" if include_scope else "Do NOT include a scope."
|
|
59
|
+
body_note = (
|
|
60
|
+
"After the first line, add one blank line then a short body explaining WHY (not what) the change was made."
|
|
61
|
+
if include_body
|
|
62
|
+
else "Do NOT include a body. First line only."
|
|
63
|
+
)
|
|
64
|
+
emoji_note = (
|
|
65
|
+
"Prepend a single relevant emoji before the type (e.g. ✨ feat, 🐛 fix, ♻️ refactor)."
|
|
66
|
+
if emoji
|
|
67
|
+
else ""
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return f"""You are an expert developer writing a git commit message.
|
|
71
|
+
|
|
72
|
+
Changed files:
|
|
73
|
+
{files_str}
|
|
74
|
+
|
|
75
|
+
Staged diff:
|
|
76
|
+
```
|
|
77
|
+
{diff}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
{style_block}
|
|
81
|
+
{scope_note}
|
|
82
|
+
{body_note}
|
|
83
|
+
{emoji_note}
|
|
84
|
+
{recent_block}
|
|
85
|
+
|
|
86
|
+
Additional rules:
|
|
87
|
+
- Be specific, not generic ("fix bug" is bad, "fix null check in order serializer" is good)
|
|
88
|
+
- Output ONLY the commit message — no explanation, no markdown, no backticks
|
|
89
|
+
- Never include Co-Authored-By lines"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
LOCAL_PROVIDERS = ("local", "none", "heuristic")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def complete(prompt, config, max_tokens=1024):
|
|
96
|
+
"""Send a prompt to the configured AI provider and return the text response.
|
|
97
|
+
|
|
98
|
+
Shared by every AI feature (commit messages, review, PR descriptions).
|
|
99
|
+
Local/heuristic mode has no completion backend — callers handle it themselves.
|
|
100
|
+
"""
|
|
101
|
+
return providers.get(config.get("provider", "anthropic")).complete(prompt, config, max_tokens)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate(diff, files, config, recent_subjects=None):
|
|
105
|
+
provider = config.get("provider", "anthropic")
|
|
106
|
+
if provider in LOCAL_PROVIDERS:
|
|
107
|
+
return _heuristic(diff, files, config)
|
|
108
|
+
return complete(_build_prompt(diff, files, config, recent_subjects), config, max_tokens=300)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
# Local heuristic generator — no API key, no network, works fully offline
|
|
113
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
EMOJI = {
|
|
116
|
+
"feat": "✨",
|
|
117
|
+
"fix": "🐛",
|
|
118
|
+
"docs": "📝",
|
|
119
|
+
"refactor": "♻️",
|
|
120
|
+
"test": "✅",
|
|
121
|
+
"chore": "🔧",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
GENERIC_DIRS = {"src", "lib", "app", "source", "pkg", "internal", "."}
|
|
125
|
+
|
|
126
|
+
DOC_STEMS = {"readme", "license", "licence", "changelog", "contributing", "authors", "notice"}
|
|
127
|
+
DOC_EXTS = {".md", ".rst", ".txt", ".adoc"}
|
|
128
|
+
|
|
129
|
+
CONFIG_EXTS = {".toml", ".yml", ".yaml", ".ini", ".cfg", ".lock", ".json", ".conf"}
|
|
130
|
+
CONFIG_NAMES = {
|
|
131
|
+
"dockerfile",
|
|
132
|
+
"makefile",
|
|
133
|
+
".gitignore",
|
|
134
|
+
".dockerignore",
|
|
135
|
+
".editorconfig",
|
|
136
|
+
"requirements.txt",
|
|
137
|
+
"pyproject.toml",
|
|
138
|
+
"setup.py",
|
|
139
|
+
"setup.cfg",
|
|
140
|
+
"package.json",
|
|
141
|
+
"package-lock.json",
|
|
142
|
+
"yarn.lock",
|
|
143
|
+
"poetry.lock",
|
|
144
|
+
"cargo.toml",
|
|
145
|
+
"go.mod",
|
|
146
|
+
"go.sum",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_test(path):
|
|
151
|
+
p = path.lower()
|
|
152
|
+
name = PurePosixPath(p).name
|
|
153
|
+
return (
|
|
154
|
+
"/test" in p
|
|
155
|
+
or p.startswith("test")
|
|
156
|
+
or "/tests/" in p
|
|
157
|
+
or name.startswith("test_")
|
|
158
|
+
or name.endswith("_test.py")
|
|
159
|
+
or ".test." in name
|
|
160
|
+
or ".spec." in name
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_doc(path):
|
|
165
|
+
p = PurePosixPath(path.lower())
|
|
166
|
+
return p.stem in DOC_STEMS or p.suffix in DOC_EXTS or "docs/" in path.lower()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_config(path):
|
|
170
|
+
p = PurePosixPath(path.lower())
|
|
171
|
+
return p.name in CONFIG_NAMES or p.suffix in CONFIG_EXTS
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_diff_status(diff):
|
|
175
|
+
"""Map each file path to 'A' (added), 'D' (deleted), or 'M' (modified)."""
|
|
176
|
+
status = {}
|
|
177
|
+
current = None
|
|
178
|
+
for line in diff.split("\n"):
|
|
179
|
+
if line.startswith("diff --git "):
|
|
180
|
+
_, _, rest = line.partition(" b/")
|
|
181
|
+
current = rest or None
|
|
182
|
+
if current:
|
|
183
|
+
status[current] = "M"
|
|
184
|
+
elif current and line.startswith("new file mode"):
|
|
185
|
+
status[current] = "A"
|
|
186
|
+
elif current and line.startswith("deleted file mode"):
|
|
187
|
+
status[current] = "D"
|
|
188
|
+
return status
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _count_lines(diff):
|
|
192
|
+
adds = sum(1 for ln in diff.split("\n") if ln.startswith("+") and not ln.startswith("+++"))
|
|
193
|
+
dels = sum(1 for ln in diff.split("\n") if ln.startswith("-") and not ln.startswith("---"))
|
|
194
|
+
return adds, dels
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _infer_type(files, added, deleted, adds, dels):
|
|
198
|
+
if files and all(_is_test(f) for f in files):
|
|
199
|
+
return "test"
|
|
200
|
+
if files and all(_is_doc(f) for f in files):
|
|
201
|
+
return "docs"
|
|
202
|
+
if files and all(_is_config(f) for f in files):
|
|
203
|
+
return "chore"
|
|
204
|
+
if added and not [f for f in files if f not in added]:
|
|
205
|
+
return "feat" # only brand-new files
|
|
206
|
+
if deleted and len(deleted) == len(files):
|
|
207
|
+
return "chore" # pure removals
|
|
208
|
+
if adds > dels * 3:
|
|
209
|
+
return "feat"
|
|
210
|
+
if dels > adds * 3:
|
|
211
|
+
return "refactor"
|
|
212
|
+
return "fix"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _deepest_scope(parts):
|
|
216
|
+
for comp in reversed(list(parts)):
|
|
217
|
+
c = comp.lower()
|
|
218
|
+
if c and c not in GENERIC_DIRS and not c.startswith("."):
|
|
219
|
+
return c
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _infer_scope(files):
|
|
224
|
+
if len(files) == 1:
|
|
225
|
+
return _deepest_scope(PurePosixPath(files[0]).parent.parts)
|
|
226
|
+
try:
|
|
227
|
+
common = os.path.commonpath(files)
|
|
228
|
+
except ValueError:
|
|
229
|
+
return None
|
|
230
|
+
return _deepest_scope([p for p in common.split("/") if p])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _display_name(path):
|
|
234
|
+
p = PurePosixPath(path)
|
|
235
|
+
return p.stem or p.name
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _join(items):
|
|
239
|
+
if len(items) == 1:
|
|
240
|
+
return items[0]
|
|
241
|
+
if len(items) == 2:
|
|
242
|
+
return f"{items[0]} and {items[1]}"
|
|
243
|
+
return ", ".join(items[:-1]) + f", and {items[-1]}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _name_list(files, limit=2):
|
|
247
|
+
names = list(dict.fromkeys(_display_name(f) for f in files))
|
|
248
|
+
if len(names) <= limit:
|
|
249
|
+
return _join(names)
|
|
250
|
+
return f"{len(files)} files"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _build_subject(ctype, files, added, deleted):
|
|
254
|
+
if deleted and len(deleted) == len(files):
|
|
255
|
+
return f"remove {_name_list(deleted)}"
|
|
256
|
+
|
|
257
|
+
verb = {
|
|
258
|
+
"feat": "add",
|
|
259
|
+
"fix": "fix",
|
|
260
|
+
"docs": "update",
|
|
261
|
+
"test": "add",
|
|
262
|
+
"chore": "update",
|
|
263
|
+
"refactor": "refactor",
|
|
264
|
+
}.get(ctype, "update")
|
|
265
|
+
|
|
266
|
+
if len(files) == 1:
|
|
267
|
+
name = _display_name(files[0])
|
|
268
|
+
if files[0] in added:
|
|
269
|
+
return f"add {name}"
|
|
270
|
+
return f"{verb} {name}"
|
|
271
|
+
|
|
272
|
+
return f"{verb} {_name_list(files)}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _format_message(ctype, scope, subject, config):
|
|
276
|
+
style = config.get("style", "conventional")
|
|
277
|
+
if style == "simple":
|
|
278
|
+
msg = subject[:1].upper() + subject[1:]
|
|
279
|
+
else:
|
|
280
|
+
head = f"{ctype}({scope})" if scope else ctype
|
|
281
|
+
prefix = f"{EMOJI[ctype]} " if config.get("emoji") and ctype in EMOJI else ""
|
|
282
|
+
msg = f"{prefix}{head}: {subject}"
|
|
283
|
+
return msg[:72]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _heuristic(diff, files, config):
|
|
287
|
+
status = _parse_diff_status(diff)
|
|
288
|
+
for f in files: # binary / untracked files may not appear in the diff body
|
|
289
|
+
status.setdefault(f, "M")
|
|
290
|
+
|
|
291
|
+
added = [f for f in files if status.get(f) == "A"]
|
|
292
|
+
deleted = [f for f in files if status.get(f) == "D"]
|
|
293
|
+
adds, dels = _count_lines(diff)
|
|
294
|
+
|
|
295
|
+
ctype = _infer_type(files, added, deleted, adds, dels)
|
|
296
|
+
scope = _infer_scope(files) if config.get("include_scope", True) else None
|
|
297
|
+
subject = _build_subject(ctype, files, added, deleted)
|
|
298
|
+
return _format_message(ctype, scope, subject, config)
|
autocommit/pr.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""PR description writer.
|
|
2
|
+
|
|
3
|
+
Builds a title + markdown body from the branch's commits and diff against
|
|
4
|
+
the base branch. AI providers write the prose; local mode assembles a
|
|
5
|
+
serviceable description from the commit subjects alone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .llm import LOCAL_PROVIDERS, complete
|
|
9
|
+
|
|
10
|
+
PR_PROMPT = """Write a pull request title and description for this branch.
|
|
11
|
+
|
|
12
|
+
Branch: {branch} (base: {base})
|
|
13
|
+
|
|
14
|
+
Commits on this branch, oldest first:
|
|
15
|
+
{commits}
|
|
16
|
+
|
|
17
|
+
Full diff against the base branch:
|
|
18
|
+
```
|
|
19
|
+
{diff}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Output format — exactly this structure, no other text:
|
|
23
|
+
|
|
24
|
+
TITLE: <one line, under 72 characters, imperative mood>
|
|
25
|
+
|
|
26
|
+
## Summary
|
|
27
|
+
<2-4 sentences: what this PR does and why>
|
|
28
|
+
|
|
29
|
+
## Changes
|
|
30
|
+
<bulleted list of the concrete changes, grouped logically>
|
|
31
|
+
|
|
32
|
+
## Testing
|
|
33
|
+
<how these changes were or should be verified; write "Not specified" if the diff contains no tests>
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
- Describe WHAT changed and WHY, not a file-by-file narration
|
|
37
|
+
- No marketing language, no filler
|
|
38
|
+
- Mention breaking changes prominently if any"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _offline_pr(branch, base, commits):
|
|
42
|
+
title = commits[-1] if commits else f"Merge {branch} into {base}"
|
|
43
|
+
if len(title) > 72:
|
|
44
|
+
title = title[:69] + "..."
|
|
45
|
+
bullets = "\n".join(f"- {c}" for c in commits) or "- (no commits found)"
|
|
46
|
+
body = (
|
|
47
|
+
"## Summary\n"
|
|
48
|
+
f"Changes from `{branch}` targeting `{base}` "
|
|
49
|
+
f"({len(commits)} commit{'s' if len(commits) != 1 else ''}).\n\n"
|
|
50
|
+
"## Changes\n"
|
|
51
|
+
f"{bullets}\n\n"
|
|
52
|
+
"## Testing\n"
|
|
53
|
+
"Not specified.\n"
|
|
54
|
+
)
|
|
55
|
+
return title, body
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_pr(branch, base, commits, diff, config):
|
|
59
|
+
"""Return (title, body)."""
|
|
60
|
+
if config.get("provider") in LOCAL_PROVIDERS:
|
|
61
|
+
return _offline_pr(branch, base, commits)
|
|
62
|
+
|
|
63
|
+
commits_str = "\n".join(f" - {c}" for c in commits) or " (none)"
|
|
64
|
+
raw = complete(
|
|
65
|
+
PR_PROMPT.format(branch=branch, base=base, commits=commits_str, diff=diff),
|
|
66
|
+
config,
|
|
67
|
+
max_tokens=2000,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
title, body = "", raw
|
|
71
|
+
for i, line in enumerate(raw.split("\n")):
|
|
72
|
+
if line.startswith("TITLE:"):
|
|
73
|
+
title = line.removeprefix("TITLE:").strip()
|
|
74
|
+
body = "\n".join(raw.split("\n")[i + 1 :]).strip()
|
|
75
|
+
break
|
|
76
|
+
if not title:
|
|
77
|
+
title, _ = _offline_pr(branch, base, commits)
|
|
78
|
+
return title, body
|