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.
@@ -0,0 +1,119 @@
1
+ """LLM provider backends.
2
+
3
+ Each provider turns a prompt into text. New backends (Gemini, Groq, a
4
+ company-internal gateway, ...) plug in by subclassing LLMProvider and
5
+ calling register() — no core code changes needed.
6
+ """
7
+
8
+ import os
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ class LLMProvider(ABC):
13
+ """One completion backend."""
14
+
15
+ name: str = ""
16
+
17
+ @abstractmethod
18
+ def complete(self, prompt: str, config: dict, max_tokens: int = 1024) -> str:
19
+ """Return the model's text response for a prompt."""
20
+
21
+
22
+ class AnthropicProvider(LLMProvider):
23
+ name = "anthropic"
24
+
25
+ def complete(self, prompt: str, config: dict, max_tokens: int = 1024) -> str:
26
+ try:
27
+ import anthropic
28
+ except ImportError:
29
+ raise ImportError("Install the Anthropic SDK: pip install anthropic")
30
+
31
+ if not os.getenv("ANTHROPIC_API_KEY"):
32
+ raise EnvironmentError(
33
+ "ANTHROPIC_API_KEY is not set.\nExport it: export ANTHROPIC_API_KEY=sk-ant-..."
34
+ )
35
+
36
+ client = anthropic.Anthropic()
37
+ message = client.messages.create(
38
+ model=config.get("anthropic_model", "claude-opus-4-8"),
39
+ max_tokens=max_tokens,
40
+ messages=[{"role": "user", "content": prompt}],
41
+ )
42
+ text = next((b.text for b in message.content if b.type == "text"), "")
43
+ return text.strip()
44
+
45
+
46
+ class OpenAIProvider(LLMProvider):
47
+ name = "openai"
48
+
49
+ def complete(self, prompt: str, config: dict, max_tokens: int = 1024) -> str:
50
+ try:
51
+ from openai import OpenAI
52
+ except ImportError:
53
+ raise ImportError("Install the OpenAI SDK: pip install openai")
54
+
55
+ api_key = os.getenv("OPENAI_API_KEY")
56
+ if not api_key:
57
+ raise EnvironmentError(
58
+ "OPENAI_API_KEY is not set.\nExport it: export OPENAI_API_KEY=sk-..."
59
+ )
60
+
61
+ client = OpenAI(api_key=api_key)
62
+ response = client.chat.completions.create(
63
+ model=config.get("openai_model", "gpt-4o-mini"),
64
+ max_tokens=max_tokens,
65
+ temperature=0.3,
66
+ messages=[{"role": "user", "content": prompt}],
67
+ )
68
+ return response.choices[0].message.content.strip()
69
+
70
+
71
+ class OllamaProvider(LLMProvider):
72
+ """Local LLM via Ollama's native HTTP API — stdlib only, no SDK, no API key."""
73
+
74
+ name = "ollama"
75
+
76
+ def complete(self, prompt: str, config: dict, max_tokens: int = 1024) -> str:
77
+ import json
78
+ import urllib.error
79
+ import urllib.request
80
+
81
+ host = config.get("ollama_host", "http://localhost:11434")
82
+ payload = json.dumps(
83
+ {
84
+ "model": config.get("ollama_model", "llama3.2"),
85
+ "prompt": prompt,
86
+ "stream": False,
87
+ "options": {"num_predict": max_tokens},
88
+ }
89
+ ).encode()
90
+ req = urllib.request.Request(
91
+ f"{host}/api/generate", data=payload, headers={"Content-Type": "application/json"}
92
+ )
93
+ try:
94
+ with urllib.request.urlopen(req, timeout=120) as resp:
95
+ return json.load(resp)["response"].strip()
96
+ except urllib.error.URLError as e:
97
+ raise EnvironmentError(
98
+ f"Cannot reach Ollama at {host} ({e.reason}).\n"
99
+ "Is Ollama running? Start it with: ollama serve\n"
100
+ f"And pull the model: ollama pull {config.get('ollama_model', 'llama3.2')}"
101
+ )
102
+
103
+
104
+ PROVIDERS: dict = {}
105
+
106
+
107
+ def register(provider: LLMProvider) -> None:
108
+ PROVIDERS[provider.name] = provider
109
+
110
+
111
+ def get(name: str) -> LLMProvider:
112
+ try:
113
+ return PROVIDERS[name]
114
+ except KeyError:
115
+ raise ValueError(f"Unknown provider: {name}") from None
116
+
117
+
118
+ for _p in (AnthropicProvider(), OpenAIProvider(), OllamaProvider()):
119
+ register(_p)
autocommit/review.py ADDED
@@ -0,0 +1,83 @@
1
+ """Code review of the staged diff.
2
+
3
+ AI providers get a structured review prompt. Local mode runs deterministic
4
+ checks only — leftover debug statements, conflict markers, new TODOs —
5
+ and says so, rather than pretending a heuristic can judge correctness.
6
+ """
7
+
8
+ import re
9
+ from collections import namedtuple
10
+
11
+ from .llm import LOCAL_PROVIDERS, complete
12
+
13
+ Issue = namedtuple("Issue", ["file", "line", "kind", "detail"])
14
+
15
+ REVIEW_PROMPT = """You are a senior engineer reviewing a git diff before it is committed.
16
+
17
+ Changed files:
18
+ {files}
19
+
20
+ Staged diff:
21
+ ```
22
+ {diff}
23
+ ```
24
+
25
+ Review ONLY the changed lines. Report:
26
+ 1. Bugs — logic errors, off-by-one, unhandled edge cases, broken error handling
27
+ 2. Security issues — injection, unsafe deserialization, path traversal
28
+ 3. Clear mistakes — leftover debug code, dead code, wrong variable used
29
+
30
+ Rules:
31
+ - Be specific: name the file and quote the problematic line
32
+ - Do NOT comment on style, formatting, or naming preferences
33
+ - Do NOT suggest speculative refactors
34
+ - If the diff looks correct, say exactly: "No issues found."
35
+ - Order findings most severe first
36
+ - Keep each finding to 1-3 sentences"""
37
+
38
+ _DEBUG_PATTERNS = [
39
+ ("debug statement", re.compile(r"^\s*(print\(|console\.(log|debug)\(|debugger\b|breakpoint\(\)|pdb\.set_trace)")),
40
+ ("merge conflict marker", re.compile(r"^(<{7}|={7}|>{7})( |$)")),
41
+ ("new TODO/FIXME", re.compile(r"\b(TODO|FIXME|XXX)\b")),
42
+ ]
43
+
44
+ _HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
45
+
46
+
47
+ def offline_review(diff):
48
+ """Deterministic checks over added lines. Returns a list of Issues."""
49
+ issues = []
50
+ current_file = None
51
+ line_no = 0
52
+
53
+ for raw in diff.split("\n"):
54
+ if raw.startswith("diff --git "):
55
+ _, _, rest = raw.partition(" b/")
56
+ current_file = rest or None
57
+ continue
58
+ header = _HUNK_HEADER.match(raw)
59
+ if header:
60
+ line_no = int(header.group(1)) - 1
61
+ continue
62
+ if raw.startswith("-"):
63
+ continue
64
+ if raw.startswith("+") and not raw.startswith("+++"):
65
+ line_no += 1
66
+ content = raw[1:]
67
+ for kind, pattern in _DEBUG_PATTERNS:
68
+ if pattern.search(content):
69
+ issues.append(Issue(current_file or "?", line_no, kind, content.strip()[:80]))
70
+ break
71
+ elif not raw.startswith("\\"):
72
+ line_no += 1
73
+
74
+ return issues
75
+
76
+
77
+ def review(diff, files, config):
78
+ """Return (text, is_offline). AI text for AI providers, None for local mode
79
+ (callers render offline_review() Issues instead)."""
80
+ if config.get("provider") in LOCAL_PROVIDERS:
81
+ return None, True
82
+ files_str = "\n".join(f" - {f}" for f in files)
83
+ return complete(REVIEW_PROMPT.format(files=files_str, diff=diff), config, max_tokens=2000), False
autocommit/secrets.py ADDED
@@ -0,0 +1,85 @@
1
+ """Secret scanner — regex-based, deterministic, no network, no LLM.
2
+
3
+ Scans only lines being ADDED in a diff, so pre-existing (already-committed)
4
+ secrets don't block unrelated commits. Findings carry file, line number,
5
+ rule name, and a redacted preview — the full secret is never printed.
6
+ """
7
+
8
+ import re
9
+ from collections import namedtuple
10
+
11
+ Finding = namedtuple("Finding", ["file", "line", "rule", "preview"])
12
+
13
+ # Each rule: (name, compiled pattern). Patterns target well-known token
14
+ # formats first (low false-positive rate), then a generic assignment
15
+ # catch-all last.
16
+ RULES = [
17
+ ("AWS access key ID", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
18
+ ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
19
+ ("GitHub fine-grained PAT", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b")),
20
+ ("Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")),
21
+ ("OpenAI API key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b")),
22
+ ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
23
+ ("Stripe live key", re.compile(r"\b[rs]k_live_[A-Za-z0-9]{20,}\b")),
24
+ ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
25
+ ("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY")),
26
+ ("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
27
+ (
28
+ "Hardcoded credential assignment",
29
+ re.compile(
30
+ r"""(?ix)
31
+ \b(password|passwd|secret|api_?key|auth_?token|access_?token)\b
32
+ \s*[:=]\s*
33
+ ["'][^"'\s]{8,}["']
34
+ """
35
+ ),
36
+ ),
37
+ ]
38
+
39
+ # Values that look like credentials but are clearly placeholders.
40
+ PLACEHOLDER = re.compile(
41
+ r"(?i)(xxx+|\.\.\.|<[^>]+>|\{\{.*\}\}|\$\{.*\}|your[_-]|example|changeme|dummy|placeholder)"
42
+ )
43
+
44
+ HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
45
+
46
+
47
+ def _redact(text, match):
48
+ """Show enough of the match to locate it, never the whole secret."""
49
+ secret = match.group(0)
50
+ if len(secret) <= 12:
51
+ return secret[:4] + "…"
52
+ return f"{secret[:8]}…{secret[-4:]}"
53
+
54
+
55
+ def scan_diff(diff):
56
+ """Return a list of Findings for secrets in the ADDED lines of a unified diff."""
57
+ findings = []
58
+ current_file = None
59
+ line_no = 0
60
+
61
+ for raw in diff.split("\n"):
62
+ if raw.startswith("diff --git "):
63
+ _, _, rest = raw.partition(" b/")
64
+ current_file = rest or None
65
+ continue
66
+ header = HUNK_HEADER.match(raw)
67
+ if header:
68
+ line_no = int(header.group(1)) - 1
69
+ continue
70
+ if raw.startswith("-"):
71
+ continue # removed line — doesn't count toward new-file line numbers...
72
+ if raw.startswith("+") and not raw.startswith("+++"):
73
+ line_no += 1
74
+ content = raw[1:]
75
+ for rule, pattern in RULES:
76
+ m = pattern.search(content)
77
+ if m and not PLACEHOLDER.search(m.group(0)):
78
+ findings.append(
79
+ Finding(current_file or "?", line_no, rule, _redact(content, m))
80
+ )
81
+ break # one finding per line is enough
82
+ elif not raw.startswith("\\"):
83
+ line_no += 1 # context line
84
+
85
+ return findings
autocommit/split.py ADDED
@@ -0,0 +1,106 @@
1
+ """Intelligent commit splitting.
2
+
3
+ Clusters the staged files into coherent commit groups — source changes by
4
+ scope, then tests, docs, and config — so one oversized `git add .` becomes
5
+ a clean series of atomic commits. AI providers propose the grouping from
6
+ the diff; local mode uses the same deterministic classifiers the heuristic
7
+ message generator uses. Splitting is file-level: a single file's hunks are
8
+ never divided across commits, so no group can produce a broken in-between
9
+ state that the file itself didn't have.
10
+ """
11
+
12
+ import json
13
+ import re
14
+ from collections import namedtuple
15
+
16
+ from .llm import LOCAL_PROVIDERS, _infer_scope, _is_config, _is_doc, _is_test, complete
17
+
18
+ Group = namedtuple("Group", ["label", "files", "reason"])
19
+
20
+ SPLIT_PROMPT = """You are splitting one large staged git change into atomic commits.
21
+
22
+ Staged files:
23
+ {files}
24
+
25
+ Staged diff:
26
+ ```
27
+ {diff}
28
+ ```
29
+
30
+ Group the files into 2-6 logical commits. Files that implement one change
31
+ belong together; unrelated concerns (docs, tests for other areas, config,
32
+ separate features) belong apart. Source changes come before their tests.
33
+
34
+ Respond with ONLY this JSON, no other text:
35
+ {{"groups": [{{"reason": "<short description of the commit>", "files": ["path", ...]}}, ...]}}
36
+
37
+ Every staged file must appear in exactly one group."""
38
+
39
+
40
+ def _classify(path):
41
+ """Return (sort_key, label) for a file. Source scopes sort first."""
42
+ if _is_test(path):
43
+ return (2, "tests")
44
+ if _is_doc(path):
45
+ return (3, "docs")
46
+ if _is_config(path):
47
+ return (4, "config")
48
+ return (1, _infer_scope([path]) or "core")
49
+
50
+
51
+ def propose_groups(files):
52
+ """Deterministic grouping: source files bucketed by scope, then tests, docs, config."""
53
+ buckets: dict = {}
54
+ for f in files:
55
+ key = _classify(f)
56
+ buckets.setdefault(key, []).append(f)
57
+
58
+ groups = []
59
+ for (order, label), members in sorted(buckets.items()):
60
+ reason = {
61
+ 2: "test changes",
62
+ 3: "documentation",
63
+ 4: "config and tooling",
64
+ }.get(order, f"{label} changes")
65
+ groups.append(Group(label, members, reason))
66
+ return groups
67
+
68
+
69
+ def _parse_ai_groups(raw, files):
70
+ """Parse the model's JSON. Returns groups only if they exactly partition files."""
71
+ fenced = re.search(r"\{.*\}", raw, re.DOTALL)
72
+ if not fenced:
73
+ return None
74
+ try:
75
+ data = json.loads(fenced.group(0))
76
+ except json.JSONDecodeError:
77
+ return None
78
+
79
+ groups = []
80
+ seen = []
81
+ for g in data.get("groups", []):
82
+ members = [f for f in g.get("files", []) if isinstance(f, str)]
83
+ if not members:
84
+ return None
85
+ groups.append(Group(_infer_scope(members) or "change", members, g.get("reason", "")))
86
+ seen.extend(members)
87
+
88
+ if sorted(seen) != sorted(files):
89
+ return None # missing, duplicated, or invented files — don't trust it
90
+ return groups
91
+
92
+
93
+ def propose_groups_ai(diff, files, config):
94
+ """AI grouping with strict validation; falls back to the heuristic.
95
+
96
+ Returns (groups, used_ai).
97
+ """
98
+ if config.get("provider") in LOCAL_PROVIDERS:
99
+ return propose_groups(files), False
100
+
101
+ files_str = "\n".join(f" - {f}" for f in files)
102
+ raw = complete(SPLIT_PROMPT.format(files=files_str, diff=diff), config, max_tokens=1500)
103
+ groups = _parse_ai_groups(raw, files)
104
+ if groups is None:
105
+ return propose_groups(files), False
106
+ return groups, True