blot-git 0.2.0__tar.gz → 0.3.1__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.
- {blot_git-0.2.0 → blot_git-0.3.1}/PKG-INFO +3 -1
- {blot_git-0.2.0 → blot_git-0.3.1}/README.md +28 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/docs/PRD.md +2 -2
- {blot_git-0.2.0 → blot_git-0.3.1}/docs/PYPI.md +2 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/pyproject.toml +1 -1
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/__init__.py +1 -1
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/bench.py +3 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/checks/base.py +7 -3
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/checks/readded_bug.py +1 -1
- blot_git-0.3.1/src/blot/checks/removed_fix.py +62 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/classify.py +23 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/cli.py +33 -5
- blot_git-0.3.1/src/blot/github.py +131 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/index.py +37 -9
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/store.py +34 -1
- blot_git-0.3.1/src/blot/trace.py +115 -0
- blot_git-0.3.1/src/blot/why.py +104 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_index.py +14 -0
- blot_git-0.3.1/tests/test_why_github.py +145 -0
- blot_git-0.2.0/src/blot/checks/removed_fix.py +0 -146
- {blot_git-0.2.0 → blot_git-0.3.1}/.gitattributes +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/pull_request_template.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/workflows/ci.yml +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.github/workflows/release.yml +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.gitignore +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/.pre-commit-hooks.yaml +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/CONTRIBUTING.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/LICENSE +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/demo/demo.gif +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/demo/make_demo.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/alert.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/faces.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/happy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/idle.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/sad.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/sleepy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/walk.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/blue/worried.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/build_sprites.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/alert.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/faces.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/happy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/idle.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/sad.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/sleepy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/walk.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/mustard/worried.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/alert.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/faces.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/happy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/idle.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/sad.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/sleepy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/walk.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/orange/worried.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/alert.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/faces.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/happy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/idle.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/sad.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/sleepy.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/walk.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/assets/sora/sage/worried.svg +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/bench/README.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/bench/RESULTS.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/bench/cases.json +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/docs/EXPLAINER.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/docs/competitors.md +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/__main__.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/checks/__init__.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/checks/cochange.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/config.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/diff.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/git.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/hooks.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/src/blot/report.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/conftest.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_bench.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_checks.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_classify.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_cli.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_config.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_diff.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_git.py +0 -0
- {blot_git-0.2.0 → blot_git-0.3.1}/tests/test_hooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: blot-git
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Sym-jay/Blot
|
|
6
6
|
Project-URL: Issues, https://github.com/Sym-jay/Blot/issues
|
|
@@ -42,6 +42,8 @@ pip install blot-git
|
|
|
42
42
|
cd your-repo
|
|
43
43
|
blot index # one-time, builds .git/blot.db
|
|
44
44
|
blot install-hook # adds Blot to your pre-commit hook
|
|
45
|
+
blot why src/app.py:42 # which commit explains this line, skipping refactors
|
|
46
|
+
blot index --github # optional: issue/PR titles and labels via the gh CLI
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
@@ -59,6 +59,34 @@ Remove it any time with `blot install-hook --uninstall`. Settings go in `[tool.b
|
|
|
59
59
|
|
|
60
60
|
Or with the [pre-commit](https://pre-commit.com) framework, see `.pre-commit-hooks.yaml`.
|
|
61
61
|
|
|
62
|
+
## Ask why a line exists
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
$ blot why src/flask/app.py:355
|
|
66
|
+
src/flask/app.py:355
|
|
67
|
+
| self_ref = weakref.ref(self)
|
|
68
|
+
explained by 8efea0cc 2020-10-03 Break reference cycle created by default in Flask instances. [bug fix]
|
|
69
|
+
traced past 0ec7f713 2023-06-11 Split the App and Blueprint into Sansio and IO parts [refactor]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`blot why FILE:LINE` (or `FILE:START-END`) finds the commit that explains a line, skipping commits that only moved it. Plain `git blame` stops at the refactor.
|
|
73
|
+
|
|
74
|
+
## Issue and PR titles (optional)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
blot index --github
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With the [GitHub CLI](https://cli.github.com) logged in, Blot fetches the titles and labels of the issues and PRs your fix commits mention, so warnings read `fixes #2935 "copy_current_request_context copies old session object"` (a real Flask issue) instead of a bare number. A `bug` label makes a commit count as a load-bearing fix; a fix-looking commit whose issues are only labelled `enhancement` or `documentation` is demoted. Refreshes only fetch what's new. Repos with GitHub Issues turned off (they track bugs elsewhere) are skipped.
|
|
81
|
+
|
|
82
|
+
## Measure it on your repo
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
blot bench --replay 500
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Re-checks your last 500 commits as if each were being committed, and lists every warning Blot would have shown. See [bench/README.md](bench/README.md).
|
|
89
|
+
|
|
62
90
|
## Docs
|
|
63
91
|
|
|
64
92
|
- [What is this? (simple explanation)](docs/EXPLAINER.md)
|
|
@@ -190,8 +190,8 @@ The user either reads the linked issue and adjusts the change, or commits anyway
|
|
|
190
190
|
|---|---|---|
|
|
191
191
|
| 1 | **M1: index + config + hook installer** | `blot index` builds the DB on Flask in under 10 s; `install-hook` is safe with existing hooks |
|
|
192
192
|
| 2 | **M2: three checks** | All unit tests pass, including the "trace past refactor" test (currently skipped) |
|
|
193
|
-
| 3 | **M3: `why` + `--github` + output polish** | The Flask case prints the 2020 fix
|
|
194
|
-
| 4 | **M4: bench + launch** | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
|
|
193
|
+
| 3 | **M3: `why` + `--github` + output polish** ✅ | The Flask case prints the 2020 fix, traced past the 2023 refactor; `--github` adds issue titles and labels |
|
|
194
|
+
| 4 | **M4: bench + launch** (bench ✅, launch pending) | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
|
|
195
195
|
|
|
196
196
|
## 13. Open questions
|
|
197
197
|
|
|
@@ -17,6 +17,8 @@ pip install blot-git
|
|
|
17
17
|
cd your-repo
|
|
18
18
|
blot index # one-time, builds .git/blot.db
|
|
19
19
|
blot install-hook # adds Blot to your pre-commit hook
|
|
20
|
+
blot why src/app.py:42 # which commit explains this line, skipping refactors
|
|
21
|
+
blot index --github # optional: issue/PR titles and labels via the gh CLI
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "blot-git"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too."
|
|
5
5
|
readme = "docs/PYPI.md" # text-only; the GitHub README's relative Sora images break on PyPI
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -50,6 +50,9 @@ class _ReplayStore:
|
|
|
50
50
|
def get_commit(self, sha: str) -> CommitRow | None:
|
|
51
51
|
return self._full.get_commit(sha)
|
|
52
52
|
|
|
53
|
+
def describe_issue(self, ref: str) -> str:
|
|
54
|
+
return self._full.describe_issue(ref)
|
|
55
|
+
|
|
53
56
|
def fixes_that_removed(self, line_hash: str) -> list[tuple[str, str]]:
|
|
54
57
|
return [(p, s) for p, s in self._full.fixes_that_removed(line_hash) if self._is_ancestor(s)]
|
|
55
58
|
|
|
@@ -53,9 +53,13 @@ def is_skipped(path: str, config: Config) -> bool:
|
|
|
53
53
|
return any(fnmatch(path, p) for p in patterns)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def describe_commit(row: CommitRow) -> str:
|
|
57
|
-
"""Evidence line: '
|
|
56
|
+
def describe_commit(row: CommitRow, store: Store | None = None) -> str:
|
|
57
|
+
"""Evidence line: '8efea0c "subject" (2020-10-03, fixes #3761 "Memory leak in app")'.
|
|
58
|
+
|
|
59
|
+
Issue titles appear once `blot index --github` has fetched them.
|
|
60
|
+
"""
|
|
58
61
|
details = [row.date[:10]]
|
|
59
62
|
if row.issues:
|
|
60
|
-
|
|
63
|
+
refs = [store.describe_issue(ref) for ref in row.issues] if store else row.issues
|
|
64
|
+
details.append("fixes " + ", ".join(refs))
|
|
61
65
|
return f'{row.sha[:7]} "{row.subject}" ({", ".join(details)})'
|
|
@@ -61,6 +61,6 @@ class ReaddedBug(Check):
|
|
|
61
61
|
self.id, change.path, number,
|
|
62
62
|
"You're adding back a line that a bug fix removed.\n"
|
|
63
63
|
"Check that this doesn't bring the bug back.",
|
|
64
|
-
[describe_commit(row)], row.severity,
|
|
64
|
+
[describe_commit(row, store)], row.severity,
|
|
65
65
|
))
|
|
66
66
|
return findings
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Check 1: you're deleting or editing lines that a load-bearing bug fix added.
|
|
2
|
+
|
|
3
|
+
Algorithm:
|
|
4
|
+
1. for each file, take the removed lines (old line numbers)
|
|
5
|
+
2. `git blame -w -M -C --porcelain -L a,b HEAD -- <old_path>` for those ranges
|
|
6
|
+
3. if the blamed commit is a refactor (classify.is_refactor), re-blame at its parent
|
|
7
|
+
on the original path, up to 3 hops
|
|
8
|
+
4. warn if the final commit's severity >= config.min_severity
|
|
9
|
+
|
|
10
|
+
"The original path" is found in the refactor's own diff: the line it deleted with the same
|
|
11
|
+
normalized text, preferring the same path. That is how a file split is followed back.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from blot.checks.base import Check, Finding, describe_commit, is_skipped
|
|
17
|
+
from blot.diff import KeptLines, is_meaningful_line, normalize_line
|
|
18
|
+
from blot.trace import RefactorOrigins, trace_lines
|
|
19
|
+
|
|
20
|
+
class RemovedFix(Check):
|
|
21
|
+
id = "removed-fix"
|
|
22
|
+
description = "Changing code that a bug fix added"
|
|
23
|
+
|
|
24
|
+
def run(self, changes, store, config) -> list[Finding]:
|
|
25
|
+
origins = RefactorOrigins(store.repo)
|
|
26
|
+
findings: list[Finding] = []
|
|
27
|
+
# a line that reappears anywhere in the change was moved, not removed
|
|
28
|
+
moved = {normalize_line(text) for c in changes for _, text in c.added}
|
|
29
|
+
for change in changes:
|
|
30
|
+
if change.old_path is None or not change.removed or is_skipped(change.old_path, config):
|
|
31
|
+
continue
|
|
32
|
+
# reformatted or extended in place (quotes, wrapping, type hints): not removed either
|
|
33
|
+
kept = KeptLines([text for _, text in change.added])
|
|
34
|
+
# blank, generic (imports, `return`) and prose lines say nothing about the fix
|
|
35
|
+
texts = {
|
|
36
|
+
n: t for n, t in change.removed
|
|
37
|
+
if is_meaningful_line(normalize_line(t)) and normalize_line(t) not in moved and not kept.keeps(t)
|
|
38
|
+
}
|
|
39
|
+
if not texts:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
trail = trace_lines(store, store.rev, change.old_path, texts, origins)
|
|
43
|
+
|
|
44
|
+
reported: set[str] = set()
|
|
45
|
+
for n in sorted(trail):
|
|
46
|
+
sha, via = trail[n].sha, trail[n].via
|
|
47
|
+
row = store.get_commit(sha)
|
|
48
|
+
if row is None or row.is_refactor or row.severity < config.min_severity or sha in reported:
|
|
49
|
+
continue
|
|
50
|
+
reported.add(sha) # one finding per fix per file, at its first line
|
|
51
|
+
evidence = [describe_commit(row, store)]
|
|
52
|
+
for ref_sha in via:
|
|
53
|
+
ref = store.get_commit(ref_sha)
|
|
54
|
+
if ref is not None:
|
|
55
|
+
evidence.append(f'traced past refactor {ref_sha[:7]} "{ref.subject}"')
|
|
56
|
+
findings.append(Finding(
|
|
57
|
+
self.id, change.old_path, n,
|
|
58
|
+
"You're removing or changing a line that a bug fix added.\n"
|
|
59
|
+
"Check that the bug it fixed can't come back.",
|
|
60
|
+
evidence, row.severity,
|
|
61
|
+
))
|
|
62
|
+
return findings
|
|
@@ -122,6 +122,29 @@ def is_refactor(commit: CommitInfo) -> bool:
|
|
|
122
122
|
return big and balanced
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
_BUG_LABEL = re.compile(r"\b(?:bug|regression|security|crash|defect|vulnerability|cve)\b", re.IGNORECASE)
|
|
126
|
+
_NOT_BUG_LABEL = re.compile(
|
|
127
|
+
r"\b(?:enhancement|feature|feature request|documentation|docs|question|chore|refactor\w*|"
|
|
128
|
+
r"dependencies|ci|tests?|cleanup|performance)\b",
|
|
129
|
+
re.IGNORECASE,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def adjust_for_labels(subject: str, severity: int, labels: list[str]) -> int:
|
|
134
|
+
"""Re-judge a commit using its issues' labels (from `blot index --github`).
|
|
135
|
+
|
|
136
|
+
A bug-type label makes it load-bearing. Only "not a bug" labels (enhancement, docs, ...) demote
|
|
137
|
+
a fix-looking commit to routine, unless its subject names a real defect (crash, leak, CVE...).
|
|
138
|
+
"""
|
|
139
|
+
if not labels:
|
|
140
|
+
return severity
|
|
141
|
+
if any(_BUG_LABEL.search(label) for label in labels):
|
|
142
|
+
return max(severity, 2)
|
|
143
|
+
if severity >= 2 and all(_NOT_BUG_LABEL.search(label) for label in labels) and not _DEFECT.search(subject):
|
|
144
|
+
return 1
|
|
145
|
+
return severity
|
|
146
|
+
|
|
147
|
+
|
|
125
148
|
def linked_issues(commit: CommitInfo) -> list[str]:
|
|
126
149
|
"""Issue references in the message, in order, without duplicates."""
|
|
127
150
|
seen: dict[str, None] = {}
|
|
@@ -22,11 +22,23 @@ from blot.report import render
|
|
|
22
22
|
def _cmd_index(args: argparse.Namespace) -> int:
|
|
23
23
|
from blot.index import build_index
|
|
24
24
|
|
|
25
|
-
result = build_index(args.repo, full=args.full
|
|
25
|
+
result = build_index(args.repo, full=args.full)
|
|
26
26
|
if not args.quiet:
|
|
27
27
|
kind = "full" if result.full else "incremental"
|
|
28
28
|
print(f"blot: indexed {result.commits} commits ({result.fixes} load-bearing fixes), "
|
|
29
29
|
f"{kind}, in {result.seconds:.1f}s")
|
|
30
|
+
if args.github:
|
|
31
|
+
from blot.github import enrich
|
|
32
|
+
from blot.store import Store
|
|
33
|
+
|
|
34
|
+
store = Store.open(args.repo)
|
|
35
|
+
got = enrich(args.repo, store)
|
|
36
|
+
store.close()
|
|
37
|
+
if got.status != "ok":
|
|
38
|
+
print(f"blot: --github skipped: {got.status}", file=sys.stderr)
|
|
39
|
+
elif not args.quiet:
|
|
40
|
+
print(f"blot: GitHub: {got.fetched} issues/PRs fetched ({got.missing} not found); "
|
|
41
|
+
f"{got.upgraded} commits upgraded to bug fix, {got.demoted} demoted by their labels")
|
|
30
42
|
return 0
|
|
31
43
|
|
|
32
44
|
|
|
@@ -51,7 +63,22 @@ def _cmd_check(args: argparse.Namespace) -> int:
|
|
|
51
63
|
|
|
52
64
|
|
|
53
65
|
def _cmd_why(args: argparse.Namespace) -> int:
|
|
54
|
-
|
|
66
|
+
from blot.store import Store
|
|
67
|
+
from blot.why import WhyError, explain, render
|
|
68
|
+
|
|
69
|
+
store = Store.open(args.repo)
|
|
70
|
+
if store is None:
|
|
71
|
+
print("blot: no index yet, run `blot index` first", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
try:
|
|
74
|
+
path, explained = explain(store, args.location, args.rev)
|
|
75
|
+
except WhyError as exc:
|
|
76
|
+
print(f"blot: {exc}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
text = render(store, path, explained)
|
|
79
|
+
store.close()
|
|
80
|
+
print(text, end="")
|
|
81
|
+
return 0
|
|
55
82
|
|
|
56
83
|
|
|
57
84
|
def _cmd_install_hook(args: argparse.Namespace) -> int:
|
|
@@ -101,7 +128,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
101
128
|
|
|
102
129
|
p = sub.add_parser("index", help="build or refresh the local history index")
|
|
103
130
|
p.add_argument("--full", action="store_true", help="rebuild from scratch instead of incrementally")
|
|
104
|
-
p.add_argument("--github", action="store_true", help="
|
|
131
|
+
p.add_argument("--github", action="store_true", help="also fetch issue/PR titles and labels with the gh CLI")
|
|
105
132
|
p.add_argument("--quiet", action="store_true", help="print nothing on success (for git hooks)")
|
|
106
133
|
p.set_defaults(func=_cmd_index)
|
|
107
134
|
|
|
@@ -112,8 +139,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
112
139
|
p.add_argument("--format", choices=["text", "json"], default="text")
|
|
113
140
|
p.set_defaults(func=_cmd_check)
|
|
114
141
|
|
|
115
|
-
p = sub.add_parser("why", help="show the
|
|
116
|
-
p.add_argument("location", metavar="FILE:LINE")
|
|
142
|
+
p = sub.add_parser("why", help="show the commit that explains a line, past refactors")
|
|
143
|
+
p.add_argument("location", metavar="FILE:LINE", help="e.g. src/app.py:42 or src/app.py:40-48")
|
|
144
|
+
p.add_argument("--rev", default="HEAD", help="explain the file as of this commit (default: HEAD)")
|
|
117
145
|
p.set_defaults(func=_cmd_why)
|
|
118
146
|
|
|
119
147
|
p = sub.add_parser("install-hook", help="add blot to .git/hooks/pre-commit")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""`blot index --github`: issue and PR titles and labels, fetched with the user's `gh` login.
|
|
2
|
+
|
|
3
|
+
Only commits that look like fixes are looked up, in batches of 50 per GraphQL call, and refs that are
|
|
4
|
+
already stored are skipped, so a refresh costs nothing until new fixes land. Labels sharpen fix
|
|
5
|
+
detection: a `bug`/`regression`/`security` label makes a commit load-bearing, while a fix-looking
|
|
6
|
+
commit whose issues are only labelled `enhancement`/`documentation` is demoted.
|
|
7
|
+
|
|
8
|
+
Nothing is sent anywhere except GitHub's API, through `gh`. Repos with GitHub Issues disabled are
|
|
9
|
+
skipped: their `#N` refs usually point at another tracker (Django uses Trac), and looking them up on
|
|
10
|
+
GitHub would attach the wrong titles.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from blot.classify import adjust_for_labels
|
|
23
|
+
from blot.git import run_git
|
|
24
|
+
from blot.store import Store
|
|
25
|
+
|
|
26
|
+
BATCH = 50
|
|
27
|
+
_REMOTE = re.compile(r"github\.com[:/](?P<owner>[\w.-]+)/(?P<name>[\w.-]+?)(?:\.git)?/?$")
|
|
28
|
+
_NUMBER = re.compile(r"^(?:#|gh-)(\d+)$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
Runner = Callable[[str], dict] # GraphQL query -> parsed JSON response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EnrichResult:
|
|
35
|
+
status: str # "ok", or why nothing was fetched
|
|
36
|
+
fetched: int = 0
|
|
37
|
+
missing: int = 0
|
|
38
|
+
upgraded: int = 0
|
|
39
|
+
demoted: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def github_repo(repo: str) -> tuple[str, str] | None:
|
|
43
|
+
for remote in ("origin", "upstream"):
|
|
44
|
+
try:
|
|
45
|
+
url = run_git(repo, "remote", "get-url", remote).strip()
|
|
46
|
+
except subprocess.CalledProcessError:
|
|
47
|
+
continue
|
|
48
|
+
m = _REMOTE.search(url)
|
|
49
|
+
if m:
|
|
50
|
+
return m.group("owner"), m.group("name")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def gh_runner(query: str) -> dict:
|
|
55
|
+
"""Run a GraphQL query through `gh`. Partial results (some refs not found) still parse."""
|
|
56
|
+
done = subprocess.run(["gh", "api", "graphql", "-f", f"query={query}"], capture_output=True,
|
|
57
|
+
text=True, encoding="utf-8", errors="replace")
|
|
58
|
+
if not done.stdout.strip():
|
|
59
|
+
raise RuntimeError(done.stderr.strip() or "gh api graphql failed")
|
|
60
|
+
return json.loads(done.stdout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _label_names(node: dict) -> list[str]:
|
|
64
|
+
return [n["name"] for n in (node.get("labels") or {}).get("nodes", [])]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_FIELDS = "title labels(first: 10) { nodes { name } }"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _query(owner: str, name: str, numbers: list[int]) -> str:
|
|
71
|
+
parts = [
|
|
72
|
+
f"n{n}: issueOrPullRequest(number: {n}) {{ __typename "
|
|
73
|
+
f"... on Issue {{ {_FIELDS} }} "
|
|
74
|
+
f"... on PullRequest {{ {_FIELDS} closingIssuesReferences(first: 5) {{ nodes {{ {_FIELDS} }} }} }} }}"
|
|
75
|
+
for n in numbers
|
|
76
|
+
]
|
|
77
|
+
return f'{{ repository(owner: "{owner}", name: "{name}") {{ hasIssuesEnabled {" ".join(parts)} }} }}'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def enrich(repo: str, store: Store, runner: Runner | None = None) -> EnrichResult:
|
|
81
|
+
found = github_repo(repo)
|
|
82
|
+
if found is None:
|
|
83
|
+
return EnrichResult("no GitHub remote (origin or upstream)")
|
|
84
|
+
if runner is None:
|
|
85
|
+
if shutil.which("gh") is None:
|
|
86
|
+
return EnrichResult("the GitHub CLI `gh` isn't installed (https://cli.github.com)")
|
|
87
|
+
runner = gh_runner
|
|
88
|
+
owner, name = found
|
|
89
|
+
|
|
90
|
+
fixes = store.commits_with_issues(min_severity=1)
|
|
91
|
+
known = store.known_issue_refs()
|
|
92
|
+
wanted = sorted({ref for row in fixes for ref in row.issues if _NUMBER.match(ref) and ref not in known})
|
|
93
|
+
|
|
94
|
+
result = EnrichResult("ok")
|
|
95
|
+
for i in range(0, len(wanted), BATCH):
|
|
96
|
+
batch = wanted[i:i + BATCH]
|
|
97
|
+
numbers = [int(_NUMBER.match(ref).group(1)) for ref in batch]
|
|
98
|
+
try:
|
|
99
|
+
data = (runner(_query(owner, name, numbers)).get("data") or {}).get("repository") or {}
|
|
100
|
+
except (RuntimeError, json.JSONDecodeError) as exc:
|
|
101
|
+
return EnrichResult(f"GitHub lookup failed: {exc}", result.fetched, result.missing)
|
|
102
|
+
if i == 0 and data.get("hasIssuesEnabled") is False:
|
|
103
|
+
return EnrichResult(f"GitHub Issues are disabled for {owner}/{name}; its #N refs probably point to "
|
|
104
|
+
"another tracker, so they were not looked up")
|
|
105
|
+
rows = []
|
|
106
|
+
for ref, n in zip(batch, numbers):
|
|
107
|
+
node = data.get(f"n{n}")
|
|
108
|
+
if not node:
|
|
109
|
+
rows.append((ref, "", [], "missing"))
|
|
110
|
+
result.missing += 1
|
|
111
|
+
continue
|
|
112
|
+
labels = _label_names(node)
|
|
113
|
+
for linked in (node.get("closingIssuesReferences") or {}).get("nodes", []):
|
|
114
|
+
labels += _label_names(linked)
|
|
115
|
+
kind = "pr" if node["__typename"] == "PullRequest" else "issue"
|
|
116
|
+
rows.append((ref, node.get("title", ""), sorted(set(labels)), kind))
|
|
117
|
+
result.fetched += 1
|
|
118
|
+
store.add_issues(rows)
|
|
119
|
+
|
|
120
|
+
# re-judge every fix-looking commit with what its issues' labels say
|
|
121
|
+
for row in fixes:
|
|
122
|
+
labels = [label for ref in row.issues if (got := store.issue(ref)) for label in got[1]]
|
|
123
|
+
new = adjust_for_labels(row.subject, row.severity, labels)
|
|
124
|
+
if new != row.severity:
|
|
125
|
+
store.set_severity(row.sha, new)
|
|
126
|
+
if new > row.severity:
|
|
127
|
+
result.upgraded += 1
|
|
128
|
+
else:
|
|
129
|
+
result.demoted += 1
|
|
130
|
+
store.commit()
|
|
131
|
+
return result
|
|
@@ -10,9 +10,11 @@ Targets: under 60 s for 50k commits on a laptop; incremental refresh under 1 s.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
import subprocess
|
|
15
16
|
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
18
|
from dataclasses import dataclass
|
|
17
19
|
|
|
18
20
|
from blot.classify import CommitInfo, fix_severity, is_refactor, linked_issues
|
|
@@ -85,12 +87,43 @@ def _head(repo: str, rev: str = "HEAD") -> str | None:
|
|
|
85
87
|
return None
|
|
86
88
|
|
|
87
89
|
|
|
90
|
+
_FORMAT = f"--format={_RECORD}%H{_FIELD}%aI{_FIELD}%s{_FIELD}%b{_FIELD}"
|
|
91
|
+
# Most of indexing is git computing --numstat line counts, one commit after another. Big histories
|
|
92
|
+
# are split into slices that separate git processes read in parallel (Django: 34k commits, ~170 s
|
|
93
|
+
# serially). The result is the same set of commits; order doesn't matter to the index.
|
|
94
|
+
_PARALLEL_FROM = 2000
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _workers() -> int:
|
|
98
|
+
return max(1, min(8, (os.cpu_count() or 2) - 1))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _git_stdin(repo: str, args: list[str], revs: list[str]) -> str:
|
|
102
|
+
done = subprocess.run(["git", "-C", repo, *args], input="\n".join(revs) + "\n", capture_output=True,
|
|
103
|
+
text=True, encoding="utf-8", errors="replace", check=True)
|
|
104
|
+
return done.stdout
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_history(repo: str, rev: str) -> list[tuple[CommitInfo, str, list[str]]]:
|
|
108
|
+
args = ["log", "--no-walk=unsorted", "--stdin", "-M", "--numstat", _FORMAT]
|
|
109
|
+
shas = run_git(repo, "rev-list", "--no-merges", rev).split()
|
|
110
|
+
if len(shas) < _PARALLEL_FROM:
|
|
111
|
+
return _parse_log(_git_stdin(repo, args, shas)) if shas else []
|
|
112
|
+
# interleaved slices have similar sizes; never pass an empty one (git log would fall back to HEAD)
|
|
113
|
+
slices = [part for i in range(_workers() * 2) if (part := shas[i::_workers() * 2])]
|
|
114
|
+
with ThreadPoolExecutor(_workers()) as pool:
|
|
115
|
+
outs = pool.map(lambda part: _git_stdin(repo, args, part), slices)
|
|
116
|
+
return [commit for out in outs for commit in _parse_log(out)]
|
|
117
|
+
|
|
118
|
+
|
|
88
119
|
def _removed_lines(repo: str, shas: list[str]) -> list[tuple[str, str, str]]:
|
|
89
120
|
"""(line_hash, path, sha) for every meaningful line the given commits deleted."""
|
|
121
|
+
batches = [shas[i : i + _SHOW_BATCH] for i in range(0, len(shas), _SHOW_BATCH)]
|
|
122
|
+
show = ["show", f"--format={_RECORD}%H", "-U0", "--no-color", "--no-ext-diff", "-M"]
|
|
123
|
+
with ThreadPoolExecutor(_workers()) as pool:
|
|
124
|
+
outs = list(pool.map(lambda batch: run_git(repo, *show, *batch), batches))
|
|
90
125
|
rows: list[tuple[str, str, str]] = []
|
|
91
|
-
for
|
|
92
|
-
batch = shas[i : i + _SHOW_BATCH]
|
|
93
|
-
out = run_git(repo, "show", f"--format={_RECORD}%H", "-U0", "--no-color", "--no-ext-diff", "-M", *batch)
|
|
126
|
+
for out in outs:
|
|
94
127
|
for chunk in out.split(_RECORD)[1:]:
|
|
95
128
|
sha, _, diff_text = chunk.partition("\n")
|
|
96
129
|
for change in parse_unified_diff(diff_text):
|
|
@@ -105,7 +138,6 @@ def _removed_lines(repo: str, shas: list[str]) -> list[tuple[str, str, str]]:
|
|
|
105
138
|
def build_index(
|
|
106
139
|
repo: str,
|
|
107
140
|
full: bool = False,
|
|
108
|
-
github: bool = False,
|
|
109
141
|
config: Config | None = None,
|
|
110
142
|
upto: str = "HEAD",
|
|
111
143
|
db_path: str | None = None,
|
|
@@ -129,9 +161,7 @@ def build_index(
|
|
|
129
161
|
return IndexResult(0, 0, time.perf_counter() - start, rebuild)
|
|
130
162
|
|
|
131
163
|
rev = head if rebuild else f"{last}..{head}"
|
|
132
|
-
|
|
133
|
-
log = run_git(repo, "log", "--no-merges", "-M", "--numstat", fmt, rev)
|
|
134
|
-
parsed = _parse_log(log)
|
|
164
|
+
parsed = _read_history(repo, rev)
|
|
135
165
|
|
|
136
166
|
fix_shas = []
|
|
137
167
|
for info, date, paths in parsed:
|
|
@@ -147,6 +177,4 @@ def build_index(
|
|
|
147
177
|
store.set_meta("last_sha", head)
|
|
148
178
|
store.commit()
|
|
149
179
|
store.close()
|
|
150
|
-
if github:
|
|
151
|
-
print("blot: --github enrichment arrives in milestone M3; indexed git history only.")
|
|
152
180
|
return IndexResult(len(parsed), len(fix_shas), time.perf_counter() - start, rebuild)
|
|
@@ -16,7 +16,7 @@ from dataclasses import dataclass
|
|
|
16
16
|
|
|
17
17
|
from blot.git import git_dir
|
|
18
18
|
|
|
19
|
-
SCHEMA_VERSION =
|
|
19
|
+
SCHEMA_VERSION = 2 # 2: issues table (blot index --github)
|
|
20
20
|
|
|
21
21
|
_SCHEMA = """
|
|
22
22
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS file_commits (path TEXT PRIMARY KEY, count INTEGER);
|
|
|
30
30
|
CREATE TABLE IF NOT EXISTS cochange (
|
|
31
31
|
path_a TEXT, path_b TEXT, support INTEGER, PRIMARY KEY (path_a, path_b)
|
|
32
32
|
);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS issues (ref TEXT PRIMARY KEY, title TEXT, labels TEXT, kind TEXT);
|
|
33
34
|
"""
|
|
34
35
|
|
|
35
36
|
|
|
@@ -163,6 +164,38 @@ class Store:
|
|
|
163
164
|
]
|
|
164
165
|
return sorted(result, key=lambda r: (-r[2], -r[1], r[0]))
|
|
165
166
|
|
|
167
|
+
# --- issues and PRs (blot index --github) ----------------------------------------------
|
|
168
|
+
|
|
169
|
+
def add_issues(self, rows: list[tuple[str, str, list[str], str]]) -> None:
|
|
170
|
+
"""(ref like "#3761", title, labels, kind "issue"/"pr"/"missing")."""
|
|
171
|
+
self.conn.executemany(
|
|
172
|
+
"INSERT OR REPLACE INTO issues VALUES (?, ?, ?, ?)",
|
|
173
|
+
[(ref, title, ",".join(labels), kind) for ref, title, labels, kind in rows],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def known_issue_refs(self) -> set[str]:
|
|
177
|
+
return {r[0] for r in self.conn.execute("SELECT ref FROM issues")}
|
|
178
|
+
|
|
179
|
+
def issue(self, ref: str) -> tuple[str, list[str]] | None:
|
|
180
|
+
row = self.conn.execute("SELECT title, labels, kind FROM issues WHERE ref=?", (ref,)).fetchone()
|
|
181
|
+
if row is None or row[2] == "missing":
|
|
182
|
+
return None
|
|
183
|
+
return row[0], [label for label in row[1].split(",") if label]
|
|
184
|
+
|
|
185
|
+
def describe_issue(self, ref: str) -> str:
|
|
186
|
+
"""'#3761 "Memory leak in app"' when the title is known, else just '#3761'."""
|
|
187
|
+
found = self.issue(ref)
|
|
188
|
+
return f'{ref} "{found[0]}"' if found else ref
|
|
189
|
+
|
|
190
|
+
def set_severity(self, sha: str, severity: int) -> None:
|
|
191
|
+
self.conn.execute("UPDATE commits SET severity=? WHERE sha=?", (severity, sha))
|
|
192
|
+
|
|
193
|
+
def commits_with_issues(self, min_severity: int = 0) -> list[CommitRow]:
|
|
194
|
+
rows = self.conn.execute(
|
|
195
|
+
"SELECT sha FROM commits WHERE issues != '' AND severity >= ?", (min_severity,)
|
|
196
|
+
).fetchall()
|
|
197
|
+
return [r for r in (self.get_commit(sha) for (sha,) in rows) if r is not None]
|
|
198
|
+
|
|
166
199
|
def stats(self) -> dict[str, int]:
|
|
167
200
|
q = lambda sql: self.conn.execute(sql).fetchone()[0] # noqa: E731
|
|
168
201
|
return {
|