blot-git 0.2.0__tar.gz → 0.3.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.
Files changed (88) hide show
  1. {blot_git-0.2.0 → blot_git-0.3.0}/PKG-INFO +3 -1
  2. {blot_git-0.2.0 → blot_git-0.3.0}/README.md +28 -0
  3. {blot_git-0.2.0 → blot_git-0.3.0}/docs/PRD.md +2 -2
  4. {blot_git-0.2.0 → blot_git-0.3.0}/docs/PYPI.md +2 -0
  5. {blot_git-0.2.0 → blot_git-0.3.0}/pyproject.toml +1 -1
  6. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/__init__.py +1 -1
  7. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/bench.py +3 -0
  8. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/checks/base.py +7 -3
  9. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/checks/readded_bug.py +1 -1
  10. blot_git-0.3.0/src/blot/checks/removed_fix.py +62 -0
  11. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/classify.py +23 -0
  12. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/cli.py +33 -5
  13. blot_git-0.3.0/src/blot/github.py +131 -0
  14. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/index.py +0 -3
  15. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/store.py +34 -1
  16. blot_git-0.3.0/src/blot/trace.py +115 -0
  17. blot_git-0.3.0/src/blot/why.py +104 -0
  18. blot_git-0.3.0/tests/test_why_github.py +145 -0
  19. blot_git-0.2.0/src/blot/checks/removed_fix.py +0 -146
  20. {blot_git-0.2.0 → blot_git-0.3.0}/.gitattributes +0 -0
  21. {blot_git-0.2.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  22. {blot_git-0.2.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {blot_git-0.2.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  24. {blot_git-0.2.0 → blot_git-0.3.0}/.github/pull_request_template.md +0 -0
  25. {blot_git-0.2.0 → blot_git-0.3.0}/.github/workflows/ci.yml +0 -0
  26. {blot_git-0.2.0 → blot_git-0.3.0}/.github/workflows/release.yml +0 -0
  27. {blot_git-0.2.0 → blot_git-0.3.0}/.gitignore +0 -0
  28. {blot_git-0.2.0 → blot_git-0.3.0}/.pre-commit-hooks.yaml +0 -0
  29. {blot_git-0.2.0 → blot_git-0.3.0}/CONTRIBUTING.md +0 -0
  30. {blot_git-0.2.0 → blot_git-0.3.0}/LICENSE +0 -0
  31. {blot_git-0.2.0 → blot_git-0.3.0}/assets/demo/demo.gif +0 -0
  32. {blot_git-0.2.0 → blot_git-0.3.0}/assets/demo/make_demo.py +0 -0
  33. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/alert.svg +0 -0
  34. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/faces.svg +0 -0
  35. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/happy.svg +0 -0
  36. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/idle.svg +0 -0
  37. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/sad.svg +0 -0
  38. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/sleepy.svg +0 -0
  39. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/walk.svg +0 -0
  40. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/blue/worried.svg +0 -0
  41. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/build_sprites.py +0 -0
  42. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/alert.svg +0 -0
  43. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/faces.svg +0 -0
  44. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/happy.svg +0 -0
  45. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/idle.svg +0 -0
  46. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/sad.svg +0 -0
  47. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/sleepy.svg +0 -0
  48. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/walk.svg +0 -0
  49. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/mustard/worried.svg +0 -0
  50. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/alert.svg +0 -0
  51. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/faces.svg +0 -0
  52. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/happy.svg +0 -0
  53. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/idle.svg +0 -0
  54. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/sad.svg +0 -0
  55. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/sleepy.svg +0 -0
  56. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/walk.svg +0 -0
  57. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/orange/worried.svg +0 -0
  58. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/alert.svg +0 -0
  59. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/faces.svg +0 -0
  60. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/happy.svg +0 -0
  61. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/idle.svg +0 -0
  62. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/sad.svg +0 -0
  63. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/sleepy.svg +0 -0
  64. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/walk.svg +0 -0
  65. {blot_git-0.2.0 → blot_git-0.3.0}/assets/sora/sage/worried.svg +0 -0
  66. {blot_git-0.2.0 → blot_git-0.3.0}/bench/README.md +0 -0
  67. {blot_git-0.2.0 → blot_git-0.3.0}/bench/RESULTS.md +0 -0
  68. {blot_git-0.2.0 → blot_git-0.3.0}/bench/cases.json +0 -0
  69. {blot_git-0.2.0 → blot_git-0.3.0}/docs/EXPLAINER.md +0 -0
  70. {blot_git-0.2.0 → blot_git-0.3.0}/docs/competitors.md +0 -0
  71. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/__main__.py +0 -0
  72. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/checks/__init__.py +0 -0
  73. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/checks/cochange.py +0 -0
  74. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/config.py +0 -0
  75. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/diff.py +0 -0
  76. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/git.py +0 -0
  77. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/hooks.py +0 -0
  78. {blot_git-0.2.0 → blot_git-0.3.0}/src/blot/report.py +0 -0
  79. {blot_git-0.2.0 → blot_git-0.3.0}/tests/conftest.py +0 -0
  80. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_bench.py +0 -0
  81. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_checks.py +0 -0
  82. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_classify.py +0 -0
  83. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_cli.py +0 -0
  84. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_config.py +0 -0
  85. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_diff.py +0 -0
  86. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_git.py +0 -0
  87. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_hooks.py +0 -0
  88. {blot_git-0.2.0 → blot_git-0.3.0}/tests/test_index.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blot-git
3
- Version: 0.2.0
3
+ Version: 0.3.0
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 with the #3761 link |
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.2.0"
3
+ version = "0.3.0"
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" }
@@ -1,3 +1,3 @@
1
1
  """blot: history-aware pre-commit checks."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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: 'abc1234 "subject" (2020-10-03, fixes #3761)'."""
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
- details.append("fixes " + ", ".join(row.issues))
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, github=args.github)
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
- raise NotImplementedError("TODO(M3): trace a line to its explaining commits")
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="add PR/issue data via the gh CLI (milestone M3)")
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 history behind a line")
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
@@ -105,7 +105,6 @@ def _removed_lines(repo: str, shas: list[str]) -> list[tuple[str, str, str]]:
105
105
  def build_index(
106
106
  repo: str,
107
107
  full: bool = False,
108
- github: bool = False,
109
108
  config: Config | None = None,
110
109
  upto: str = "HEAD",
111
110
  db_path: str | None = None,
@@ -147,6 +146,4 @@ def build_index(
147
146
  store.set_meta("last_sha", head)
148
147
  store.commit()
149
148
  store.close()
150
- if github:
151
- print("blot: --github enrichment arrives in milestone M3; indexed git history only.")
152
149
  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 = 1
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 {
@@ -0,0 +1,115 @@
1
+ """Find the commit that explains a line, seeing past commits that only moved code.
2
+
3
+ `git blame` answers "who last touched this line". When that is a refactor (a file split, a rename,
4
+ a mass reformat), the reason the line exists is older. For each such hop we look up where the
5
+ refactor took the line from (the line it deleted with the same normalized text, preferring the
6
+ same path) and blame again at the refactor's parent, up to MAX_HOPS times.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ from blot.diff import normalize_line, parse_unified_diff
16
+ from blot.git import run_git
17
+
18
+ if TYPE_CHECKING:
19
+ from blot.store import Store
20
+
21
+ MAX_HOPS = 3
22
+
23
+
24
+ def _ranges(numbers: list[int]) -> list[tuple[int, int]]:
25
+ """Collapse sorted line numbers into inclusive (start, end) runs."""
26
+ runs: list[tuple[int, int]] = []
27
+ for n in sorted(set(numbers)):
28
+ if runs and n == runs[-1][1] + 1:
29
+ runs[-1] = (runs[-1][0], n)
30
+ else:
31
+ runs.append((n, n))
32
+ return runs
33
+
34
+
35
+ def blame(repo: str, rev: str, path: str, lines: list[int]) -> dict[int, tuple[str, str, int]]:
36
+ """{line: (sha, path in that commit, line in that commit)} via one porcelain blame call."""
37
+ args = ["blame", "-w", "-M", "-C", "--porcelain"]
38
+ for start, end in _ranges(lines):
39
+ args += ["-L", f"{start},{end}"]
40
+ try:
41
+ out = run_git(repo, *args, rev, "--", path)
42
+ except subprocess.CalledProcessError:
43
+ return {}
44
+ result: dict[int, tuple[str, str, int]] = {}
45
+ filenames: dict[str, str] = {}
46
+ sha, orig, final = "", 0, 0
47
+ for line in out.splitlines():
48
+ if line.startswith("\t"):
49
+ result[final] = (sha, filenames.get(sha, path), orig)
50
+ elif line.startswith("filename "):
51
+ filenames[sha] = line[9:]
52
+ else:
53
+ parts = line.split(" ")
54
+ if len(parts) >= 3 and len(parts[0]) == 40 and parts[1].isdigit() and parts[2].isdigit():
55
+ sha, orig, final = parts[0], int(parts[1]), int(parts[2])
56
+ return result
57
+
58
+
59
+ class RefactorOrigins:
60
+ """Where a refactor commit took each line from: normalized text -> [(old path, old line)]."""
61
+
62
+ def __init__(self, repo: str):
63
+ self.repo = repo
64
+ self.cache: dict[str, dict[str, list[tuple[str, int]]]] = {}
65
+
66
+ def find(self, sha: str, path: str, text: str) -> tuple[str, int] | None:
67
+ if sha not in self.cache:
68
+ origins: dict[str, list[tuple[str, int]]] = {}
69
+ try:
70
+ diff = run_git(self.repo, "show", "--format=", "-U0", "--no-color", "--no-ext-diff", "-M", sha)
71
+ except subprocess.CalledProcessError:
72
+ diff = ""
73
+ for change in parse_unified_diff(diff):
74
+ for number, removed in change.removed:
75
+ origins.setdefault(normalize_line(removed), []).append((change.old_path or change.path, number))
76
+ self.cache[sha] = origins
77
+ candidates = self.cache[sha].get(normalize_line(text), [])
78
+ same_path = [c for c in candidates if c[0] == path]
79
+ return (same_path or candidates or [None])[0]
80
+
81
+
82
+
83
+ @dataclass
84
+ class Trail:
85
+ sha: str # the commit that explains the line
86
+ path: str # the line's path in that commit
87
+ line: int # the line's number in that commit
88
+ via: list[str] = field(default_factory=list) # refactor commits traced past, newest first
89
+
90
+
91
+ def trace_lines(store: Store, rev: str, path: str, texts: dict[int, str],
92
+ origins: RefactorOrigins | None = None) -> dict[int, Trail]:
93
+ """{line number at `rev`: Trail} for the given lines of `path` (text keyed by line number)."""
94
+ repo = store.repo
95
+ origins = origins or RefactorOrigins(repo)
96
+ trail = {n: Trail(sha, p, o) for n, (sha, p, o) in blame(repo, rev, path, list(texts)).items() if n in texts}
97
+ for _ in range(MAX_HOPS):
98
+ pending: dict[tuple[str, str], list[tuple[int, int]]] = {} # (parent rev, path) -> [(line, origin line)]
99
+ for n, t in trail.items():
100
+ row = store.get_commit(t.sha)
101
+ if row is None or not row.is_refactor:
102
+ continue
103
+ origin = origins.find(t.sha, t.path, texts[n])
104
+ if origin is None:
105
+ continue
106
+ pending.setdefault((f"{t.sha}^", origin[0]), []).append((n, origin[1]))
107
+ if not pending:
108
+ break
109
+ for (parent, origin_path), items in pending.items():
110
+ earlier = blame(repo, parent, origin_path, [o for _, o in items])
111
+ for n, o in items:
112
+ if o in earlier:
113
+ sha, p, line = earlier[o]
114
+ trail[n] = Trail(sha, p, line, [*trail[n].via, trail[n].sha])
115
+ return trail