culprit 0.1.0__tar.gz

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.
culprit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noordeen
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.
culprit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: culprit
3
+ Version: 0.1.0
4
+ Summary: Root-cause analysis for a PR or branch: classify feature vs bugfix, find the introducing commit (suspect set) or the blast radius.
5
+ Author: Noordeen
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/noordeen123/culprit
8
+ Project-URL: Repository, https://github.com/noordeen123/culprit
9
+ Project-URL: Issues, https://github.com/noordeen123/culprit/issues
10
+ Keywords: git,rca,root-cause,pull-request,regression,blame
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: api
15
+ Requires-Dist: anthropic>=0.40; extra == "api"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # culprit
21
+
22
+ Root-cause analysis for a pull request or branch.
23
+
24
+ `culprit` looks at a PR (or the current branch), decides whether it's a **bugfix**
25
+ or a **feature**, then:
26
+
27
+ - **Bugfix** → finds the commit that *introduced* the bug. It blames the lines the
28
+ fix removed/changed at the base revision and ranks the commits that last touched
29
+ them (the **suspect set**), then explains why it broke and whether the fix is
30
+ complete.
31
+ - **Feature** → maps the **blast radius**: who imports the changed modules, which
32
+ tests cover them, and which touched files live in high-risk shared/core areas.
33
+
34
+ It is **read-only** — it never modifies your repo or the PR.
35
+
36
+ ## Why the split design
37
+
38
+ The deterministic git work (diff parsing, `git blame` / `git log -L`, the
39
+ suspect set, the reverse-import map) lives in a plain Python engine that emits
40
+ **structured JSON**. The only LLM step — the "why it broke" narrative — is
41
+ isolated behind a `ReasoningAdapter`:
42
+
43
+ - **HarnessAdapter** — used by the Claude Code skill. Returns the structured
44
+ result + a markdown skeleton; the agent writes the narrative. No API key.
45
+ - **ClaudeAPIAdapter** — used standalone. Calls the Claude API
46
+ (`claude-opus-4-8` by default, `--fast` → `claude-sonnet-4-6`).
47
+
48
+ Same engine, two frontends.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install -e . # engine + CLI
54
+ pip install -e ".[api]" # + Claude API reasoning layer (anthropic SDK)
55
+ ```
56
+
57
+ PR metadata uses the GitHub CLI when available: `brew install gh && gh auth login`.
58
+ For **public repos you don't even need `gh`** — `rca --pr N` falls back to the
59
+ unauthenticated REST API (**GitHub and GitLab**) for metadata plus a read-only
60
+ `git fetch` of the PR/MR head (set `GITHUB_TOKEN` / `GITLAB_TOKEN` to raise rate
61
+ limits). With neither, culprit uses local git (base vs head) — fully offline,
62
+ minus PR title/labels.
63
+
64
+ ### Any host, any language
65
+
66
+ - **Hosts:** deep links (commit / PR / file) are generated for **GitHub, GitLab,
67
+ Bitbucket, and Gitea**; the suspect-set + line-evolution timeline work on *any*
68
+ git repo regardless of host. For a self-hosted forge the URL can't disambiguate,
69
+ so set `host = "gitlab"` (or `github`/`bitbucket`/`gitea`) in `.culprit.toml`, or
70
+ `CULPRIT_HOST`.
71
+ - **Languages:** suspect/timeline are language-agnostic (pure `git blame`/`log -L`).
72
+ Blast-radius + test-gap detect imports across JS/TS, Python, Go, Java/Kotlin,
73
+ Ruby, C/C++, C#, PHP, Rust, Scala, Swift (quoted *and* bare/dotted import forms).
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ rca # current branch vs the configured base (or latest commit)
79
+ rca --last # just the latest commit ("the change I just made")
80
+ rca --pr 16786 # a specific GitHub PR (uses the PR's own base)
81
+ rca --repo /path --base main
82
+ rca --mode api --fast # standalone reasoning via the Claude API
83
+ rca --json # structured result only
84
+ rca --html report.html --open # self-contained visual report (timeline UI)
85
+ ```
86
+
87
+ ### Visual HTML report
88
+
89
+ `--html PATH` writes a **single self-contained HTML file** (inline CSS/JS, data
90
+ embedded, no CDN — opens offline, shareable, CI-attachable). For a bugfix it
91
+ renders a **line-evolution timeline**: for each line the fix touched, every commit
92
+ that ever changed those lines, from creation → … → **the commit that broke it
93
+ (red)** → **the fix (green)**, each step expandable to its diff.
94
+
95
+ ```bash
96
+ rca --pr 16889 --html rca.html --open # narrative via --mode api if key set
97
+ rca --pr 16889 --html rca.html --narrative-file why.md # embed a pre-written narrative
98
+ ```
99
+
100
+ The timeline needs no API key. The "Analysis" prose comes from `--narrative-file`
101
+ (e.g. written by the Claude Code `/rca` skill) or from `--mode api`.
102
+
103
+ The report also includes: a **TL;DR banner** naming the prime suspect and how long
104
+ the bug lived before the fix; **GitHub deep links** on every commit / PR / file
105
+ (derived from `origin`); **weight bars** ranking the suspects; **expand/collapse-all**
106
+ and a **per-file filter** for the timeline; and a one-click **copy-as-markdown** to
107
+ paste into the PR.
108
+
109
+ ### Choosing the base branch
110
+
111
+ The base differs per repo (`main`, `master`, `develop`, a long-lived release
112
+ branch, …). Resolution order:
113
+ `--base <ref>` → `CULPRIT_BASE` env → `.culprit.toml` (`base = "..."`) → the latest
114
+ commit. The static HTML report is generated for one base (shown in the footer with a
115
+ regenerate hint). For an **interactive base picker**, use `serve` mode:
116
+
117
+ ```bash
118
+ rca serve --repo /path/to/repo # opens http://127.0.0.1:8722
119
+ ```
120
+
121
+ It launches a local web app (stdlib only — no extra deps) with a form: enter a
122
+ PR/branch, **pick the base from a dropdown** (pre-filled from `.culprit.toml`,
123
+ the repo's default branch, then all local/remote branches), choose
124
+ classification + reasoning, and run a fresh analysis that renders the same visual
125
+ report. The base picker repopulates when you point it at a different repo. Binds
126
+ to localhost only.
127
+
128
+ ### Base branch
129
+
130
+ In local mode (no PR), culprit needs a base to diff against. Resolution order:
131
+
132
+ 1. `--base <ref>` on the CLI
133
+ 2. `CULPRIT_BASE` environment variable
134
+ 3. `base = "..."` in a `.culprit.toml` at the repo root
135
+ 4. otherwise the latest commit (`HEAD~1`)
136
+
137
+ So pin your repo's real base once and forget it:
138
+
139
+ ```toml
140
+ # .culprit.toml
141
+ base = "origin/main" # whatever your repo is actually cut from
142
+ ```
143
+
144
+ `--last` always forces the latest-commit view regardless of config.
145
+
146
+ ## Tests
147
+
148
+ ```bash
149
+ pip install -e ".[dev]" && pytest
150
+ ```
@@ -0,0 +1,131 @@
1
+ # culprit
2
+
3
+ Root-cause analysis for a pull request or branch.
4
+
5
+ `culprit` looks at a PR (or the current branch), decides whether it's a **bugfix**
6
+ or a **feature**, then:
7
+
8
+ - **Bugfix** → finds the commit that *introduced* the bug. It blames the lines the
9
+ fix removed/changed at the base revision and ranks the commits that last touched
10
+ them (the **suspect set**), then explains why it broke and whether the fix is
11
+ complete.
12
+ - **Feature** → maps the **blast radius**: who imports the changed modules, which
13
+ tests cover them, and which touched files live in high-risk shared/core areas.
14
+
15
+ It is **read-only** — it never modifies your repo or the PR.
16
+
17
+ ## Why the split design
18
+
19
+ The deterministic git work (diff parsing, `git blame` / `git log -L`, the
20
+ suspect set, the reverse-import map) lives in a plain Python engine that emits
21
+ **structured JSON**. The only LLM step — the "why it broke" narrative — is
22
+ isolated behind a `ReasoningAdapter`:
23
+
24
+ - **HarnessAdapter** — used by the Claude Code skill. Returns the structured
25
+ result + a markdown skeleton; the agent writes the narrative. No API key.
26
+ - **ClaudeAPIAdapter** — used standalone. Calls the Claude API
27
+ (`claude-opus-4-8` by default, `--fast` → `claude-sonnet-4-6`).
28
+
29
+ Same engine, two frontends.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install -e . # engine + CLI
35
+ pip install -e ".[api]" # + Claude API reasoning layer (anthropic SDK)
36
+ ```
37
+
38
+ PR metadata uses the GitHub CLI when available: `brew install gh && gh auth login`.
39
+ For **public repos you don't even need `gh`** — `rca --pr N` falls back to the
40
+ unauthenticated REST API (**GitHub and GitLab**) for metadata plus a read-only
41
+ `git fetch` of the PR/MR head (set `GITHUB_TOKEN` / `GITLAB_TOKEN` to raise rate
42
+ limits). With neither, culprit uses local git (base vs head) — fully offline,
43
+ minus PR title/labels.
44
+
45
+ ### Any host, any language
46
+
47
+ - **Hosts:** deep links (commit / PR / file) are generated for **GitHub, GitLab,
48
+ Bitbucket, and Gitea**; the suspect-set + line-evolution timeline work on *any*
49
+ git repo regardless of host. For a self-hosted forge the URL can't disambiguate,
50
+ so set `host = "gitlab"` (or `github`/`bitbucket`/`gitea`) in `.culprit.toml`, or
51
+ `CULPRIT_HOST`.
52
+ - **Languages:** suspect/timeline are language-agnostic (pure `git blame`/`log -L`).
53
+ Blast-radius + test-gap detect imports across JS/TS, Python, Go, Java/Kotlin,
54
+ Ruby, C/C++, C#, PHP, Rust, Scala, Swift (quoted *and* bare/dotted import forms).
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ rca # current branch vs the configured base (or latest commit)
60
+ rca --last # just the latest commit ("the change I just made")
61
+ rca --pr 16786 # a specific GitHub PR (uses the PR's own base)
62
+ rca --repo /path --base main
63
+ rca --mode api --fast # standalone reasoning via the Claude API
64
+ rca --json # structured result only
65
+ rca --html report.html --open # self-contained visual report (timeline UI)
66
+ ```
67
+
68
+ ### Visual HTML report
69
+
70
+ `--html PATH` writes a **single self-contained HTML file** (inline CSS/JS, data
71
+ embedded, no CDN — opens offline, shareable, CI-attachable). For a bugfix it
72
+ renders a **line-evolution timeline**: for each line the fix touched, every commit
73
+ that ever changed those lines, from creation → … → **the commit that broke it
74
+ (red)** → **the fix (green)**, each step expandable to its diff.
75
+
76
+ ```bash
77
+ rca --pr 16889 --html rca.html --open # narrative via --mode api if key set
78
+ rca --pr 16889 --html rca.html --narrative-file why.md # embed a pre-written narrative
79
+ ```
80
+
81
+ The timeline needs no API key. The "Analysis" prose comes from `--narrative-file`
82
+ (e.g. written by the Claude Code `/rca` skill) or from `--mode api`.
83
+
84
+ The report also includes: a **TL;DR banner** naming the prime suspect and how long
85
+ the bug lived before the fix; **GitHub deep links** on every commit / PR / file
86
+ (derived from `origin`); **weight bars** ranking the suspects; **expand/collapse-all**
87
+ and a **per-file filter** for the timeline; and a one-click **copy-as-markdown** to
88
+ paste into the PR.
89
+
90
+ ### Choosing the base branch
91
+
92
+ The base differs per repo (`main`, `master`, `develop`, a long-lived release
93
+ branch, …). Resolution order:
94
+ `--base <ref>` → `CULPRIT_BASE` env → `.culprit.toml` (`base = "..."`) → the latest
95
+ commit. The static HTML report is generated for one base (shown in the footer with a
96
+ regenerate hint). For an **interactive base picker**, use `serve` mode:
97
+
98
+ ```bash
99
+ rca serve --repo /path/to/repo # opens http://127.0.0.1:8722
100
+ ```
101
+
102
+ It launches a local web app (stdlib only — no extra deps) with a form: enter a
103
+ PR/branch, **pick the base from a dropdown** (pre-filled from `.culprit.toml`,
104
+ the repo's default branch, then all local/remote branches), choose
105
+ classification + reasoning, and run a fresh analysis that renders the same visual
106
+ report. The base picker repopulates when you point it at a different repo. Binds
107
+ to localhost only.
108
+
109
+ ### Base branch
110
+
111
+ In local mode (no PR), culprit needs a base to diff against. Resolution order:
112
+
113
+ 1. `--base <ref>` on the CLI
114
+ 2. `CULPRIT_BASE` environment variable
115
+ 3. `base = "..."` in a `.culprit.toml` at the repo root
116
+ 4. otherwise the latest commit (`HEAD~1`)
117
+
118
+ So pin your repo's real base once and forget it:
119
+
120
+ ```toml
121
+ # .culprit.toml
122
+ base = "origin/main" # whatever your repo is actually cut from
123
+ ```
124
+
125
+ `--last` always forces the latest-commit view regardless of config.
126
+
127
+ ## Tests
128
+
129
+ ```bash
130
+ pip install -e ".[dev]" && pytest
131
+ ```
@@ -0,0 +1,9 @@
1
+ """culprit — root-cause analysis for a PR or branch.
2
+
3
+ Repo-agnostic engine: deterministic git/PR analysis that emits structured JSON.
4
+ The only LLM step (the "why it broke" narrative) is isolated behind
5
+ ``culprit.reasoning`` so the same engine drives both the Claude Code skill
6
+ (harness reasons) and the standalone CLI (Claude API reasons).
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,46 @@
1
+ """Thin, read-only subprocess helpers for git and gh.
2
+
3
+ Every command here is read-only by construction. Nothing in culprit ever
4
+ mutates the target repository or the PR.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import subprocess
10
+ from typing import List, Optional
11
+
12
+
13
+ class ProcError(RuntimeError):
14
+ """A subprocess exited non-zero."""
15
+
16
+ def __init__(self, cmd: List[str], returncode: int, stderr: str):
17
+ self.cmd = cmd
18
+ self.returncode = returncode
19
+ self.stderr = stderr
20
+ super().__init__("`{}` exited {}: {}".format(" ".join(cmd), returncode, stderr.strip()))
21
+
22
+
23
+ def run(cmd: List[str], cwd: Optional[str] = None, check: bool = True) -> str:
24
+ """Run a command and return stdout. Raise ProcError on failure when check."""
25
+ proc = subprocess.run(
26
+ cmd,
27
+ cwd=cwd,
28
+ stdout=subprocess.PIPE,
29
+ stderr=subprocess.PIPE,
30
+ text=True,
31
+ )
32
+ if check and proc.returncode != 0:
33
+ raise ProcError(cmd, proc.returncode, proc.stderr)
34
+ return proc.stdout
35
+
36
+
37
+ def git(args: List[str], repo: str, check: bool = True) -> str:
38
+ return run(["git", "-C", repo] + args, check=check)
39
+
40
+
41
+ def have_gh() -> bool:
42
+ return shutil.which("gh") is not None
43
+
44
+
45
+ def gh(args: List[str], repo: str, check: bool = True) -> str:
46
+ return run(["gh"] + args, cwd=repo, check=check)
@@ -0,0 +1,124 @@
1
+ """Feature path: what can this change break?
2
+
3
+ For each changed source file, find who imports it (reverse-import map), which
4
+ tests cover those modules, and which touched files live in shared/core areas
5
+ (high blast radius). Heuristic but grounded — the reasoning layer ranks risk
6
+ and recommends the test surface from this structured map.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from . import _proc
15
+
16
+ DEFAULT_SOURCE_GLOBS = [
17
+ "*.js", "*.jsx", "*.ts", "*.tsx", "*.mjs", "*.cjs", "*.vue", "*.svelte",
18
+ "*.py", "*.go", "*.rb", "*.java", "*.kt", "*.scala", "*.cs", "*.php",
19
+ "*.rs", "*.c", "*.h", "*.cc", "*.cpp", "*.hpp", "*.m", "*.swift",
20
+ ]
21
+ # Test-file conventions across ecosystems: JS spec/test, Python test_*/*_test,
22
+ # Go *_test.go, Java/Kotlin/C# *Test/*Tests, Ruby *_spec, plus test dirs.
23
+ DEFAULT_TEST_RE = re.compile(
24
+ r"(\.spec\.|\.test\.|_test\.|_spec\.|/__tests__/|(^|/)cypress/|(^|/)tests?/"
25
+ r"|(^|/)test_[^/]*\.(py|rb)$|Tests?\.(java|kt|cs|scala|swift)$|_test\.go$)", re.I)
26
+ HIGH_RISK_RE = re.compile(r"(^|/)(shared|common|core|lib|utils?|helpers?|base|hooks|store)(/|$)", re.I)
27
+
28
+ _INDEX_RE = re.compile(r"(^|/)(index|__init__|mod)\.[^/]+$")
29
+
30
+
31
+ def _module_token(path: str) -> str:
32
+ """The identifier other files most likely import this module by."""
33
+ if _INDEX_RE.search(path):
34
+ # package entry files (index.js / __init__.py / mod.go) are imported by dir name
35
+ return os.path.basename(os.path.dirname(path)) or os.path.basename(path)
36
+ return os.path.splitext(os.path.basename(path))[0]
37
+
38
+
39
+ def _importers(repo: str, token: str, exclude: str, source_globs: List[str]) -> List[str]:
40
+ if not token:
41
+ return []
42
+ tok = re.escape(token)
43
+ # An import-ish line that references the token as a delimited path segment.
44
+ # Covers JS/TS (`import x from '…/token'`, `require('…token…')`), Python
45
+ # (`from a.token import x`, `import a.token`), Java (`import a.b.Token;`),
46
+ # Go/Ruby/C (`"…/token"`, `<token.h>`). Uses POSIX classes only — git grep -E
47
+ # has no \w / \b, so token boundaries are spelled [^A-Za-z0-9_].
48
+ pat = r"(import|require|include|from|use).*[^A-Za-z0-9_]{}([^A-Za-z0-9_]|$)".format(tok)
49
+ args = ["grep", "-l", "-I", "-E", "-e", pat, "--"] + source_globs
50
+ out = _proc.git(args, repo, check=False)
51
+ return [f for f in out.splitlines() if f.strip() and f != exclude]
52
+
53
+
54
+ def test_gap(changed_files: List[str], repo: str,
55
+ source_globs: Optional[List[str]] = None, max_files: int = 60) -> Dict[str, Any]:
56
+ """For a bugfix: which changed (non-test) files have no covering tests.
57
+
58
+ A regression usually slips through because the touched code isn't tested.
59
+ Reuses the reverse-import map to find test files that import each module.
60
+ """
61
+ source_globs = source_globs or DEFAULT_SOURCE_GLOBS
62
+ files = [f for f in changed_files if f]
63
+ notes: List[str] = []
64
+ if len(files) > max_files:
65
+ notes.append("{} files; checked the first {}".format(len(files), max_files))
66
+ files = files[:max_files]
67
+ covering = set()
68
+ untested: List[str] = []
69
+ for path in files:
70
+ if DEFAULT_TEST_RE.search(path):
71
+ continue # the changed file is itself a test
72
+ token = _module_token(path)
73
+ tests = [i for i in _importers(repo, token, path, source_globs) if DEFAULT_TEST_RE.search(i)]
74
+ if tests:
75
+ covering.update(tests)
76
+ else:
77
+ untested.append(path)
78
+ return {"untested": untested, "covering_tests": sorted(covering), "notes": notes}
79
+
80
+
81
+ def analyze(ctx: Dict[str, Any], repo: str,
82
+ source_globs: Optional[List[str]] = None,
83
+ max_dependents: int = 50, max_files: int = 200) -> Dict[str, Any]:
84
+ source_globs = source_globs or DEFAULT_SOURCE_GLOBS
85
+ changed = [f for f in ctx.get("changed_files", []) if f]
86
+ notes: List[str] = []
87
+ if len(changed) > max_files:
88
+ notes.append("changeset has {} files; mapping dependents for the first {} "
89
+ "(narrow the base or analyze one commit)".format(len(changed), max_files))
90
+ changed = changed[:max_files]
91
+
92
+ dependents: Dict[str, List[str]] = {}
93
+ covering_tests = set()
94
+ high_risk: List[str] = []
95
+
96
+ for path in changed:
97
+ if DEFAULT_TEST_RE.search(path):
98
+ covering_tests.add(path) # the change itself touches a test
99
+ if HIGH_RISK_RE.search(path):
100
+ high_risk.append(path)
101
+
102
+ token = _module_token(path)
103
+ imps = _importers(repo, token, path, source_globs)[:max_dependents]
104
+ if imps:
105
+ dependents[path] = imps
106
+ for imp in imps:
107
+ if DEFAULT_TEST_RE.search(imp):
108
+ covering_tests.add(imp)
109
+
110
+ # A changed file with many dependents is also high-risk even outside shared/.
111
+ for path, imps in dependents.items():
112
+ if len(imps) >= 10 and path not in high_risk:
113
+ high_risk.append(path)
114
+
115
+ ranked = sorted(dependents.items(), key=lambda kv: len(kv[1]), reverse=True)
116
+ return {
117
+ "changed_files": changed,
118
+ "dependents": dict(ranked),
119
+ "dependent_counts": {p: len(v) for p, v in ranked},
120
+ "covering_tests": sorted(covering_tests),
121
+ "high_risk": high_risk,
122
+ "total_dependents": sum(len(v) for v in dependents.values()),
123
+ "notes": notes,
124
+ }
@@ -0,0 +1,87 @@
1
+ """Classify a change as a bugfix or a feature, with evidence.
2
+
3
+ Deterministic scoring over branch name, PR labels, and commit/title prefixes.
4
+ The verdict is advisory: the Claude Code harness (or the API reasoning layer)
5
+ makes the final call, but the score + evidence give it grounded signal instead
6
+ of guessing.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, List, Tuple
12
+
13
+ _BUG_BRANCH = re.compile(r"^(bug|bugfix|fix|hotfix|patch)[/\-_]", re.I)
14
+ _FEAT_BRANCH = re.compile(r"^(feat|feature|enhancement|chore|refactor)[/\-_]", re.I)
15
+
16
+ # Leading [\W_]* tolerates real-world prefixes like "- fix:", "🚀 feat:", ": fixes".
17
+ _BUG_PREFIX = re.compile(r"^[\W_]*(bug\s*)?fix(es|ed)?\b|^[\W_]*hotfix\b|^[\W_]*patch\b", re.I)
18
+ _FEAT_PREFIX = re.compile(r"^[\W_]*(feat|feature|add|implement|introduce|chore|refactor)\b", re.I)
19
+
20
+ _BUG_LABELS = {"bug", "bugfix", "regression", "defect", "hotfix"}
21
+ _FEAT_LABELS = {"feature", "enhancement", "feat", "improvement"}
22
+
23
+
24
+ def _add(evidence: List[str], score: int, delta: int, msg: str) -> int:
25
+ evidence.append(msg)
26
+ return score + delta
27
+
28
+
29
+ def classify(ctx: Dict[str, Any]) -> Dict[str, Any]:
30
+ """Return {verdict, confidence, evidence, score} from a pr_context dict."""
31
+ score = 0 # positive → bugfix, negative → feature
32
+ evidence: List[str] = []
33
+
34
+ branch = ctx.get("head_ref") or ""
35
+ if _BUG_BRANCH.match(branch):
36
+ score = _add(evidence, score, 2, "branch '{}' uses a fix/bug prefix".format(branch))
37
+ elif _FEAT_BRANCH.match(branch):
38
+ score = _add(evidence, score, -2, "branch '{}' uses a feat/feature prefix".format(branch))
39
+
40
+ labels = [str(l).lower() for l in (ctx.get("labels") or [])]
41
+ for lab in labels:
42
+ if lab in _BUG_LABELS:
43
+ score = _add(evidence, score, 3, "PR label '{}' indicates a bug".format(lab))
44
+ elif lab in _FEAT_LABELS:
45
+ score = _add(evidence, score, -3, "PR label '{}' indicates a feature".format(lab))
46
+
47
+ title = ctx.get("title") or ""
48
+ if title:
49
+ if _BUG_PREFIX.search(title):
50
+ score = _add(evidence, score, 2, "PR title '{}' reads like a fix".format(title))
51
+ elif _FEAT_PREFIX.search(title):
52
+ score = _add(evidence, score, -2, "PR title '{}' reads like a feature".format(title))
53
+
54
+ bug_commits = 0
55
+ feat_commits = 0
56
+ for c in ctx.get("commits", []):
57
+ subj = c.get("subject") or ""
58
+ if _BUG_PREFIX.search(subj):
59
+ bug_commits += 1
60
+ elif _FEAT_PREFIX.search(subj):
61
+ feat_commits += 1
62
+ if bug_commits or feat_commits:
63
+ if bug_commits > feat_commits:
64
+ score = _add(evidence, score, 1,
65
+ "{} of {} commit subjects look like fixes".format(
66
+ bug_commits, len(ctx.get("commits", []))))
67
+ elif feat_commits > bug_commits:
68
+ score = _add(evidence, score, -1,
69
+ "{} of {} commit subjects look like features".format(
70
+ feat_commits, len(ctx.get("commits", []))))
71
+
72
+ if score > 0:
73
+ verdict = "bugfix"
74
+ elif score < 0:
75
+ verdict = "feature"
76
+ else:
77
+ verdict = "unknown"
78
+
79
+ # Confidence scales with the margin; capped at a readable 0.95.
80
+ confidence = min(0.95, 0.5 + 0.1 * abs(score)) if verdict != "unknown" else 0.0
81
+
82
+ return {
83
+ "verdict": verdict,
84
+ "confidence": round(confidence, 2),
85
+ "score": score,
86
+ "evidence": evidence,
87
+ }