consequence-ledger 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- consequence_ledger-0.3.0.dist-info/METADATA +191 -0
- consequence_ledger-0.3.0.dist-info/RECORD +24 -0
- consequence_ledger-0.3.0.dist-info/WHEEL +5 -0
- consequence_ledger-0.3.0.dist-info/entry_points.txt +2 -0
- consequence_ledger-0.3.0.dist-info/licenses/LICENSE +21 -0
- consequence_ledger-0.3.0.dist-info/top_level.txt +1 -0
- ledger/__init__.py +2 -0
- ledger/__main__.py +3 -0
- ledger/audit.py +187 -0
- ledger/cli.py +637 -0
- ledger/db.py +70 -0
- ledger/demo.py +306 -0
- ledger/estimate.py +412 -0
- ledger/gitio.py +102 -0
- ledger/incidents.py +202 -0
- ledger/label.py +386 -0
- ledger/pagerduty.py +102 -0
- ledger/plan.py +62 -0
- ledger/policy.py +52 -0
- ledger/report.py +330 -0
- ledger/segments.py +142 -0
- ledger/sentry.py +91 -0
- ledger/squash.py +92 -0
- ledger/status.py +56 -0
ledger/incidents.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Production incidents as outcome events.
|
|
2
|
+
|
|
3
|
+
A change can be bad without ever being reverted or "fixed" in git: it causes an
|
|
4
|
+
incident that is mitigated elsewhere (a flag flip, a config rollback). Incident
|
|
5
|
+
tools export the link to code in one of two ways, and both are supported:
|
|
6
|
+
|
|
7
|
+
suspect commits -- the incident names commits (Sentry "suspect commits",
|
|
8
|
+
a postmortem's root-cause SHA)
|
|
9
|
+
deploy window -- the incident started after a deploy; every commit shipped
|
|
10
|
+
in that deploy (since the previous one) is a suspect,
|
|
11
|
+
narrowed to commits touching the incident's files if given
|
|
12
|
+
|
|
13
|
+
Imports are CSV or JSON. Incident fields: id, started_at (ISO 8601 or unix
|
|
14
|
+
seconds), title, commits (SHAs or prefixes, separated by spaces, commas or
|
|
15
|
+
semicolons), files (paths, same separators). Deploy fields: sha, deployed_at.
|
|
16
|
+
"""
|
|
17
|
+
import csv
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .gitio import git
|
|
24
|
+
|
|
25
|
+
SCHEMA = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS incidents(
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
ts INTEGER NOT NULL,
|
|
29
|
+
title TEXT,
|
|
30
|
+
commits TEXT NOT NULL DEFAULT '[]', -- JSON list of SHAs/prefixes as exported
|
|
31
|
+
files TEXT NOT NULL DEFAULT '[]' -- JSON list of paths as exported
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS deploys(
|
|
34
|
+
sha TEXT NOT NULL,
|
|
35
|
+
ts INTEGER NOT NULL,
|
|
36
|
+
PRIMARY KEY (sha, ts)
|
|
37
|
+
);
|
|
38
|
+
"""
|
|
39
|
+
SPLIT_RE = re.compile(r"[\s,;]+")
|
|
40
|
+
DEPLOY_WINDOW_DAYS = 14 # an incident more than this long after the last deploy is not blamed on it
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_schema(con):
|
|
44
|
+
con.executescript(SCHEMA)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_time(value):
|
|
48
|
+
value = str(value).strip()
|
|
49
|
+
if re.fullmatch(r"\d+(\.\d+)?", value):
|
|
50
|
+
return int(float(value))
|
|
51
|
+
d = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
52
|
+
if d.tzinfo is None:
|
|
53
|
+
d = d.replace(tzinfo=dt.timezone.utc)
|
|
54
|
+
return int(d.timestamp())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def split_list(value):
|
|
58
|
+
if value is None:
|
|
59
|
+
return []
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
|
62
|
+
return [v for v in SPLIT_RE.split(str(value)) if v]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_records(path):
|
|
66
|
+
path = Path(path)
|
|
67
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
68
|
+
if path.suffix.lower() == ".json":
|
|
69
|
+
data = json.loads(text)
|
|
70
|
+
return data if isinstance(data, list) else data.get("incidents") or data.get("deploys") or []
|
|
71
|
+
return list(csv.DictReader(text.splitlines()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def first_field(record, *names):
|
|
75
|
+
for n in names:
|
|
76
|
+
if record.get(n) not in (None, ""):
|
|
77
|
+
return record[n]
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def import_incidents(con, path):
|
|
82
|
+
return import_incidents_records(con, read_records(path))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def import_incidents_records(con, records):
|
|
86
|
+
"""Insert incident records. A record with no suspect commits or files never wipes ones already stored
|
|
87
|
+
for the same id, so a live sync can refresh timing without undoing links from an earlier export."""
|
|
88
|
+
ensure_schema(con)
|
|
89
|
+
count = 0
|
|
90
|
+
for r in records:
|
|
91
|
+
incident_id = first_field(r, "id", "incident_id", "number", "key")
|
|
92
|
+
started = first_field(r, "started_at", "created_at", "start", "triggered_at", "ts")
|
|
93
|
+
if incident_id is None or started is None:
|
|
94
|
+
raise ValueError(f"incident record needs an id and started_at: {r}")
|
|
95
|
+
commits = split_list(first_field(r, "commits", "suspect_commits", "sha"))
|
|
96
|
+
files = split_list(first_field(r, "files", "paths"))
|
|
97
|
+
old = con.execute("SELECT commits, files FROM incidents WHERE id = ?", (str(incident_id),)).fetchone()
|
|
98
|
+
if old:
|
|
99
|
+
commits = commits or json.loads(old["commits"])
|
|
100
|
+
files = files or json.loads(old["files"])
|
|
101
|
+
con.execute("INSERT OR REPLACE INTO incidents VALUES (?,?,?,?,?)",
|
|
102
|
+
(str(incident_id), parse_time(started), first_field(r, "title", "summary", "name"),
|
|
103
|
+
json.dumps(commits), json.dumps(files)))
|
|
104
|
+
count += 1
|
|
105
|
+
con.commit()
|
|
106
|
+
return count
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def import_deploys(con, path):
|
|
110
|
+
ensure_schema(con)
|
|
111
|
+
count = 0
|
|
112
|
+
for r in read_records(path):
|
|
113
|
+
sha = first_field(r, "sha", "commit", "revision", "version")
|
|
114
|
+
when = first_field(r, "deployed_at", "finished_at", "created_at", "ts")
|
|
115
|
+
if sha is None or when is None:
|
|
116
|
+
raise ValueError(f"deploy record needs a sha and deployed_at: {r}")
|
|
117
|
+
con.execute("INSERT OR IGNORE INTO deploys VALUES (?,?)", (str(sha).strip(), parse_time(when)))
|
|
118
|
+
count += 1
|
|
119
|
+
con.commit()
|
|
120
|
+
return count
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def files_by_commit(repo):
|
|
124
|
+
"""{sha: set of paths it touched}, for all of history in one git call."""
|
|
125
|
+
out = git(repo, "log", "--all", "--no-renames", "--name-only", "--format=%x1e%H")
|
|
126
|
+
touched = {}
|
|
127
|
+
for record in out.split("\x1e"):
|
|
128
|
+
parts = [ln.strip() for ln in record.strip().splitlines() if ln.strip()]
|
|
129
|
+
if parts:
|
|
130
|
+
touched[parts[0]] = set(parts[1:])
|
|
131
|
+
return touched
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def incident_events(repo, con, commits, targets, deploy_mode="off"):
|
|
135
|
+
"""{sha: [(incident_ts, 'incident', incident_id)]} for target commits linked to an incident.
|
|
136
|
+
|
|
137
|
+
deploy_mode controls incidents that name no suspect commit:
|
|
138
|
+
off -- leave them unlinked (default). A miss can only understate a difference between arms.
|
|
139
|
+
latest -- blame the latest deploy in the window (narrowed to the incident's files if given)
|
|
140
|
+
window -- blame every commit in the window that touched the incident's files
|
|
141
|
+
In a synthetic repo where each file changes every few days, file-level linking put the true culprit
|
|
142
|
+
among the blamed commits only 6-13% of the time (research/FINDINGS.md), so it is opt-in.
|
|
143
|
+
"""
|
|
144
|
+
if deploy_mode not in ("off", "latest", "window"):
|
|
145
|
+
raise ValueError("deploy_mode must be 'off', 'latest' or 'window'")
|
|
146
|
+
ensure_schema(con)
|
|
147
|
+
from .label import resolve_prefix # local import: label imports this module
|
|
148
|
+
|
|
149
|
+
sorted_shas = sorted(c["sha"] for c in commits)
|
|
150
|
+
known = set(sorted_shas)
|
|
151
|
+
deploys = []
|
|
152
|
+
for row in con.execute("SELECT sha, ts FROM deploys ORDER BY ts"):
|
|
153
|
+
full = resolve_prefix(sorted_shas, row["sha"].lower())
|
|
154
|
+
if full:
|
|
155
|
+
deploys.append((row["ts"], full))
|
|
156
|
+
|
|
157
|
+
events = {}
|
|
158
|
+
links = {"suspect": 0, "deploy": 0, "unlinked": 0}
|
|
159
|
+
touched = None
|
|
160
|
+
shipped_cache = {}
|
|
161
|
+
for inc in con.execute("SELECT * FROM incidents ORDER BY ts"):
|
|
162
|
+
suspects = {resolve_prefix(sorted_shas, s.lower()) for s in json.loads(inc["commits"])} - {None}
|
|
163
|
+
how = "suspect"
|
|
164
|
+
if not suspects and deploy_mode != "off":
|
|
165
|
+
how = "deploy"
|
|
166
|
+
window_start = inc["ts"] - DEPLOY_WINDOW_DAYS * 86400
|
|
167
|
+
before = [d for d in deploys if d[0] <= inc["ts"]]
|
|
168
|
+
files = set(json.loads(inc["files"]))
|
|
169
|
+
# Walk back from the latest deploy. Without files, only the latest deploy is suspect.
|
|
170
|
+
# With files: "latest" blames the most recent deploy in the window that changed one of
|
|
171
|
+
# them; "window" blames every commit in the window that did (an incident can surface
|
|
172
|
+
# days after the deploy that caused it).
|
|
173
|
+
for i in range(len(before) - 1, -1, -1):
|
|
174
|
+
shipped_ts, shipped = before[i]
|
|
175
|
+
if shipped_ts < window_start:
|
|
176
|
+
break
|
|
177
|
+
earlier = [d for d in before[:i] if d[1] != shipped]
|
|
178
|
+
if earlier:
|
|
179
|
+
args = (f"{earlier[-1][1]}..{shipped}",)
|
|
180
|
+
else:
|
|
181
|
+
# First deploy on record: only commits from the window before it, not all history.
|
|
182
|
+
args = (f"--since={window_start}", shipped)
|
|
183
|
+
if args not in shipped_cache:
|
|
184
|
+
shipped_cache[args] = set(git(repo, "rev-list", "--no-merges", *args).split()) & known
|
|
185
|
+
shipped_commits = shipped_cache[args]
|
|
186
|
+
if not files:
|
|
187
|
+
suspects = shipped_commits
|
|
188
|
+
break
|
|
189
|
+
if touched is None:
|
|
190
|
+
touched = files_by_commit(repo)
|
|
191
|
+
matched = {s for s in shipped_commits if touched.get(s, set()) & files}
|
|
192
|
+
suspects |= matched
|
|
193
|
+
if matched and deploy_mode == "latest":
|
|
194
|
+
break
|
|
195
|
+
hit = suspects & targets
|
|
196
|
+
if hit:
|
|
197
|
+
links[how] += 1
|
|
198
|
+
else:
|
|
199
|
+
links["unlinked"] += 1
|
|
200
|
+
for sha in hit:
|
|
201
|
+
events.setdefault(sha, []).append((inc["ts"], "incident", inc["id"]))
|
|
202
|
+
return events, links
|
ledger/label.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Attribute commits to decisions and label each decision's delayed outcome.
|
|
2
|
+
|
|
3
|
+
Outcome signals, most precise first:
|
|
4
|
+
revert -- a later commit says "This reverts commit <sha>" (git's default message)
|
|
5
|
+
fix -- a later fix/hotfix commit edits lines this commit introduced (SZZ via git blame)
|
|
6
|
+
|
|
7
|
+
A decision is "bad" if either event lands within the horizon after its first
|
|
8
|
+
commit, and "mature" only once the whole horizon has elapsed. Estimators use
|
|
9
|
+
mature decisions only, so recent decisions can't look clean just because
|
|
10
|
+
their bugs haven't surfaced yet.
|
|
11
|
+
"""
|
|
12
|
+
import bisect
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
|
|
21
|
+
from .gitio import GitError, git, log_commits
|
|
22
|
+
from .squash import recover_squashed
|
|
23
|
+
|
|
24
|
+
TRAILER_RE = re.compile(r"^Ledger-Decision-Id:\s*(\S+)\s*$", re.M)
|
|
25
|
+
REVERT_RE = re.compile(r"This reverts commit ([0-9a-f]{7,40})")
|
|
26
|
+
REVERT_SUBJECT_RE = re.compile(r'^Revert "(.+)"(?: \(#\d+\))?$')
|
|
27
|
+
SQUASH_PR_RE = re.compile(r"\(#(\d+)\)\s*$")
|
|
28
|
+
MERGE_PR_RE = re.compile(r"^Merge pull request #(\d+)\b")
|
|
29
|
+
STRONG_REF_RE = re.compile(
|
|
30
|
+
r"\b(?:(?:introduced|caused|broken)\s+(?:in|by)|regress(?:ed|ion)\s+(?:in|from|by|of))\s+#(\d+)", re.I)
|
|
31
|
+
FOLLOWUP_REF_RE = re.compile(r"\bfollow[- ]?up\s+(?:to|for|on)\s+#(\d+)", re.I)
|
|
32
|
+
FIX_RE = re.compile(r"^(fix|hotfix)(\([^)]*\))?!?:", re.I)
|
|
33
|
+
# How fix commits are spotted. "conventional" trusts fix:/hotfix: prefixes (precise, needs the convention);
|
|
34
|
+
# "keywords" is the classic SZZ heuristic for repos that write "Fix crash in ..." (more recall, more noise).
|
|
35
|
+
FIX_STYLES = {
|
|
36
|
+
"conventional": FIX_RE,
|
|
37
|
+
"keywords": re.compile(r"\b(fix(es|ed)?|bug(fix)?|hotfix|regression|crash(es)?)\b", re.I),
|
|
38
|
+
}
|
|
39
|
+
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
40
|
+
# Files whose edits fix no product code: tests, docs, editor/CI config, lockfiles.
|
|
41
|
+
SKIP_PATHS = re.compile(
|
|
42
|
+
r"(^|/)(tests?|__tests__|spec|specs|docs?|\.vscode|\.idea|\.github)/"
|
|
43
|
+
r"|(_test|\.test|_spec|\.spec|Test|Tests)\.[^/]+$|(^|/)test_[^/]+$"
|
|
44
|
+
r"|\.(md|rst|txt|lock)$|(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum)$")
|
|
45
|
+
BLAME_RE = re.compile(r"^([0-9a-f]{40}) \d+ \d+")
|
|
46
|
+
|
|
47
|
+
DAY = 86400
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def attribute(con, commits):
|
|
51
|
+
"""Map commits to decisions using their Ledger-Decision-Id trailer."""
|
|
52
|
+
known = {row["id"] for row in con.execute("SELECT id FROM decisions")}
|
|
53
|
+
found = unknown = 0
|
|
54
|
+
for c in commits:
|
|
55
|
+
for decision_id in TRAILER_RE.findall(c["body"]):
|
|
56
|
+
if decision_id not in known:
|
|
57
|
+
unknown += 1
|
|
58
|
+
continue
|
|
59
|
+
cur = con.execute("INSERT OR IGNORE INTO commits VALUES (?,?,?,'trailer')",
|
|
60
|
+
(c["sha"], decision_id, c["ts"]))
|
|
61
|
+
found += cur.rowcount
|
|
62
|
+
return {"attributed": found, "unknown_trailers": unknown}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_prefix(sorted_shas, prefix):
|
|
66
|
+
"""The unique full SHA starting with prefix, or None if absent or ambiguous."""
|
|
67
|
+
i = bisect.bisect_left(sorted_shas, prefix)
|
|
68
|
+
hits = sorted_shas[i:i + 2]
|
|
69
|
+
if hits and hits[0].startswith(prefix) and not (len(hits) > 1 and hits[1].startswith(prefix)):
|
|
70
|
+
return hits[0]
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_reverts(commits):
|
|
75
|
+
"""{reverted_sha: (revert_ts, revert_sha)}, keeping the earliest revert.
|
|
76
|
+
|
|
77
|
+
Matches git's "This reverts commit <sha>" and, for GitHub revert PRs that were
|
|
78
|
+
squash-merged (no SHA, just 'Revert "<original subject>"'), the most recent
|
|
79
|
+
earlier commit with exactly that subject.
|
|
80
|
+
"""
|
|
81
|
+
sorted_shas = sorted(c["sha"] for c in commits)
|
|
82
|
+
by_subject = defaultdict(list) # subject -> [(ts, sha)] in history order
|
|
83
|
+
reverts = {}
|
|
84
|
+
|
|
85
|
+
def record(target, c):
|
|
86
|
+
if target and target != c["sha"] and (target not in reverts or c["ts"] < reverts[target][0]):
|
|
87
|
+
reverts[target] = (c["ts"], c["sha"])
|
|
88
|
+
|
|
89
|
+
for c in commits:
|
|
90
|
+
prefixes = REVERT_RE.findall(c["body"])
|
|
91
|
+
for prefix in prefixes:
|
|
92
|
+
record(resolve_prefix(sorted_shas, prefix), c)
|
|
93
|
+
m = REVERT_SUBJECT_RE.match(c["subject"])
|
|
94
|
+
if not prefixes and m:
|
|
95
|
+
earlier = [sha for ts, sha in by_subject.get(m.group(1), []) if ts <= c["ts"]]
|
|
96
|
+
if earlier:
|
|
97
|
+
record(earlier[-1], c)
|
|
98
|
+
by_subject[c["subject"]].append((c["ts"], c["sha"]))
|
|
99
|
+
return reverts
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_fix(commit, fix_re=FIX_RE):
|
|
103
|
+
"""A bug-fix commit by its message. Merge commits never count (MA-SZZ): "Merge pull request #12
|
|
104
|
+
from me/fix-crash" would otherwise blame every line its whole branch touched; the branch's own
|
|
105
|
+
commits are judged on their own."""
|
|
106
|
+
return (bool(fix_re.search(commit["subject"])) and len(commit.get("parents", ())) < 2
|
|
107
|
+
and not REVERT_RE.search(commit["body"]) and not REVERT_SUBJECT_RE.match(commit["subject"]))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_trivial(text):
|
|
111
|
+
"""Lines whose removal says nothing about a bug: blank, punctuation-only, or comment-only."""
|
|
112
|
+
s = text.strip()
|
|
113
|
+
return (not any(ch.isalnum() for ch in s)
|
|
114
|
+
or s.startswith(("//", "/*", "*", "# ", "<!--")) or s == "#")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def deleted_lines(diff):
|
|
118
|
+
"""Parse a -U0 diff into ({path: [(old_lineno, text)]}, {stripped text of every added line})."""
|
|
119
|
+
deleted = defaultdict(list)
|
|
120
|
+
added = set()
|
|
121
|
+
path = None
|
|
122
|
+
old_no = old_left = new_left = 0
|
|
123
|
+
for line in diff.splitlines():
|
|
124
|
+
if old_left or new_left:
|
|
125
|
+
# Inside a hunk; counting lines keeps "--- x" content apart from file headers.
|
|
126
|
+
if line.startswith("-") and old_left:
|
|
127
|
+
if path:
|
|
128
|
+
deleted[path].append((old_no, line[1:]))
|
|
129
|
+
old_no += 1
|
|
130
|
+
old_left -= 1
|
|
131
|
+
elif line.startswith("+") and new_left:
|
|
132
|
+
added.add(line[1:].strip())
|
|
133
|
+
new_left -= 1
|
|
134
|
+
continue
|
|
135
|
+
if line.startswith("--- "):
|
|
136
|
+
path = line[6:] if line.startswith("--- a/") else None
|
|
137
|
+
elif line.startswith("@@"):
|
|
138
|
+
m = HUNK_RE.match(line)
|
|
139
|
+
if not m:
|
|
140
|
+
continue # a malformed header; git never writes one, but a bad diff must not crash labelling
|
|
141
|
+
old_no, old_left = int(m.group(1)), int(m.group(2) or 1)
|
|
142
|
+
new_left = int(m.group(4) or 1)
|
|
143
|
+
return deleted, added
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def to_ranges(numbers):
|
|
147
|
+
ranges = []
|
|
148
|
+
for n in sorted(numbers):
|
|
149
|
+
if ranges and n == ranges[-1][0] + ranges[-1][1]:
|
|
150
|
+
ranges[-1][1] += 1
|
|
151
|
+
else:
|
|
152
|
+
ranges.append([n, 1])
|
|
153
|
+
return ranges
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def szz(repo, commit, ignore=frozenset(), skip_paths=SKIP_PATHS):
|
|
157
|
+
"""Commits whose lines this fix commit deleted or changed (SZZ, via blame).
|
|
158
|
+
|
|
159
|
+
Refinements over plain SZZ, each a documented source of false positives:
|
|
160
|
+
whitespace-only edits (diff -w), blank/comment-only lines, lines that were moved
|
|
161
|
+
rather than changed (deleted here but added elsewhere in the same commit), and
|
|
162
|
+
changes to tests, docs and editor config, which fix no product code.
|
|
163
|
+
"""
|
|
164
|
+
if not commit["parents"]:
|
|
165
|
+
return set()
|
|
166
|
+
parent = commit["parents"][0]
|
|
167
|
+
diff = git(repo, "diff", "-U0", "-w", "--no-color", "--no-renames", "--no-ext-diff",
|
|
168
|
+
parent, commit["sha"])
|
|
169
|
+
deleted, added = deleted_lines(diff)
|
|
170
|
+
ranges = {}
|
|
171
|
+
for path, lines in deleted.items():
|
|
172
|
+
if skip_paths and skip_paths.search(path):
|
|
173
|
+
continue
|
|
174
|
+
keep = [n for n, text in lines if not is_trivial(text) and text.strip() not in added]
|
|
175
|
+
if keep:
|
|
176
|
+
ranges[path] = to_ranges(keep)
|
|
177
|
+
introducers = set()
|
|
178
|
+
for path, spans in ranges.items():
|
|
179
|
+
args = ["blame", "--porcelain", "-w"]
|
|
180
|
+
for rev in sorted(ignore):
|
|
181
|
+
# Blame looks through formatting/refactor commits to the real author of the line.
|
|
182
|
+
args += ["--ignore-rev", rev]
|
|
183
|
+
for start, count in spans:
|
|
184
|
+
args += ["-L", f"{start},+{count}"]
|
|
185
|
+
try:
|
|
186
|
+
out = git(repo, *args, parent, "--", path)
|
|
187
|
+
except GitError:
|
|
188
|
+
continue # e.g. a path that only exists in a submodule
|
|
189
|
+
for line in out.splitlines():
|
|
190
|
+
m = BLAME_RE.match(line)
|
|
191
|
+
if m:
|
|
192
|
+
introducers.add(m.group(1))
|
|
193
|
+
return introducers
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
SZZ_VERSION = 2 # bump whenever szz() would return something different for the same commit
|
|
197
|
+
# A fix that blames more commits than this is too diffuse to attribute: in a study of SZZ variants, fixes
|
|
198
|
+
# blaming over 20 commits were outliers (3 fixes produced 72 suspects, 3 correct) and a cutoff of 10 raised
|
|
199
|
+
# precision further (Rosa et al., ICSE 2021, arXiv 2102.03300). 0 turns the filter off.
|
|
200
|
+
DEFAULT_MAX_BLAMED = 10
|
|
201
|
+
SZZ_WORKERS = min(8, (os.cpu_count() or 2) * 2)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SzzCache:
|
|
205
|
+
"""Remembers, per fix commit, which commits its changed lines came from.
|
|
206
|
+
|
|
207
|
+
That answer depends only on the fix commit, the history behind it, and how szz() is configured, and
|
|
208
|
+
git history is immutable (a rewritten commit has a new sha), so it never goes stale. Relabelling
|
|
209
|
+
then costs one `git log` plus new fix commits, instead of a diff and blame for every fix every time.
|
|
210
|
+
The key covers the algorithm version, the skip-path rules and the ignore list; entries made under a
|
|
211
|
+
different configuration are dropped.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, con, ignore):
|
|
215
|
+
text = f"{SZZ_VERSION}|{SKIP_PATHS.pattern}|{' '.join(sorted(ignore))}"
|
|
216
|
+
self.con = con
|
|
217
|
+
self.key = hashlib.sha1(text.encode("utf-8")).hexdigest()
|
|
218
|
+
self.hits = self.misses = 0
|
|
219
|
+
con.execute("DELETE FROM szz_cache WHERE key != ?", (self.key,))
|
|
220
|
+
|
|
221
|
+
def get(self, fix_sha):
|
|
222
|
+
row = self.con.execute("SELECT introducers FROM szz_cache WHERE fix_sha = ? AND key = ?",
|
|
223
|
+
(fix_sha, self.key)).fetchone()
|
|
224
|
+
if row is None:
|
|
225
|
+
self.misses += 1
|
|
226
|
+
return None
|
|
227
|
+
self.hits += 1
|
|
228
|
+
return set(json.loads(row[0]))
|
|
229
|
+
|
|
230
|
+
def put(self, fix_sha, introducers):
|
|
231
|
+
self.con.execute("INSERT OR REPLACE INTO szz_cache VALUES (?,?,?)",
|
|
232
|
+
(fix_sha, self.key, json.dumps(sorted(introducers))))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def outcome_events(repo, commits, targets, ignore_revs=(), fix_re=FIX_RE, cache_con=None, stats=None,
|
|
236
|
+
max_blamed=DEFAULT_MAX_BLAMED):
|
|
237
|
+
"""{sha: [(ts, 'revert'|'fix', event_sha)]} for each target commit that was later reverted or fixed.
|
|
238
|
+
|
|
239
|
+
With cache_con (a ledger connection) fix-commit analysis is remembered between runs; `stats`, if
|
|
240
|
+
given, receives how many were reused and how many computed.
|
|
241
|
+
"""
|
|
242
|
+
by_sha = {c["sha"]: c for c in commits}
|
|
243
|
+
events = defaultdict(list)
|
|
244
|
+
for target, (ts, sha) in find_reverts(commits).items():
|
|
245
|
+
if target in targets:
|
|
246
|
+
events[target].append((ts, "revert", sha))
|
|
247
|
+
# Only full SHAs present in history; unknown revs would make git blame fail.
|
|
248
|
+
ignore = frozenset(s for r in ignore_revs for s in by_sha if s.startswith(r.lower())) if ignore_revs else frozenset()
|
|
249
|
+
cache = SzzCache(cache_con, ignore) if cache_con is not None else None
|
|
250
|
+
first_ts = min((by_sha[s]["ts"] for s in targets if s in by_sha), default=None)
|
|
251
|
+
diffuse = 0
|
|
252
|
+
fixes = [f for f in commits if first_ts is not None and f["ts"] >= first_ts and is_fix(f, fix_re)]
|
|
253
|
+
found = {f["sha"]: cache.get(f["sha"]) for f in fixes} if cache else {}
|
|
254
|
+
todo = [f for f in fixes if found.get(f["sha"]) is None]
|
|
255
|
+
# Each analysis is a few git subprocesses that spend their time outside Python, so threads overlap them.
|
|
256
|
+
with ThreadPoolExecutor(max_workers=min(SZZ_WORKERS, len(todo)) or 1) as pool:
|
|
257
|
+
for fix, introducers in zip(todo, pool.map(lambda f: szz(repo, f, ignore), todo)):
|
|
258
|
+
found[fix["sha"]] = introducers
|
|
259
|
+
if cache:
|
|
260
|
+
cache.put(fix["sha"], introducers)
|
|
261
|
+
for fix in fixes:
|
|
262
|
+
introducers = found[fix["sha"]]
|
|
263
|
+
if max_blamed and len(introducers) > max_blamed:
|
|
264
|
+
diffuse += 1 # applied after the cache so changing the threshold never needs a recompute
|
|
265
|
+
continue
|
|
266
|
+
for intro in introducers & targets:
|
|
267
|
+
events[intro].append((fix["ts"], "fix", fix["sha"]))
|
|
268
|
+
if stats is not None:
|
|
269
|
+
stats.update(szz_reused=cache.hits if cache else 0, szz_computed=cache.misses if cache else 0,
|
|
270
|
+
fixes_too_diffuse=diffuse,
|
|
271
|
+
# Fixes that blame nothing: addition-only, or touching only tests and docs. Mostly not product
|
|
272
|
+
# bugs (5 of 10 on two local repos were notes or tests that mention a fix), but a high share
|
|
273
|
+
# is also where recall is lost, so it is reported rather than hidden.
|
|
274
|
+
fixes_untraceable=sum(1 for f in fixes if not found[f["sha"]]))
|
|
275
|
+
for target, (ts, sha) in find_pr_references(repo, commits, fix_re).items():
|
|
276
|
+
if target in targets:
|
|
277
|
+
events[target].append((ts, "fix", sha))
|
|
278
|
+
return events
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def find_pr_references(repo, commits, fix_re=FIX_RE):
|
|
282
|
+
"""{target_sha: (ts, fixing_sha)} from messages that name the PR they repair.
|
|
283
|
+
|
|
284
|
+
"Bug introduced in #1507", "caused by #12", "regression from #88" count on any commit;
|
|
285
|
+
"follow-up to #12" only on a commit that is itself a fix. #N resolves to the squash-merged
|
|
286
|
+
commit whose subject ends in "(#N)", or to the commits a "Merge pull request #N" brought in.
|
|
287
|
+
"""
|
|
288
|
+
squashed, merged = {}, {}
|
|
289
|
+
for c in commits:
|
|
290
|
+
m = SQUASH_PR_RE.search(c["subject"])
|
|
291
|
+
if m:
|
|
292
|
+
squashed.setdefault(m.group(1), c)
|
|
293
|
+
m = MERGE_PR_RE.match(c["subject"])
|
|
294
|
+
if m and len(c["parents"]) == 2:
|
|
295
|
+
merged.setdefault(m.group(1), c)
|
|
296
|
+
found = {}
|
|
297
|
+
for c in commits:
|
|
298
|
+
numbers = set(STRONG_REF_RE.findall(c["body"]))
|
|
299
|
+
if is_fix(c, fix_re):
|
|
300
|
+
numbers |= set(FOLLOWUP_REF_RE.findall(c["body"]))
|
|
301
|
+
for n in numbers:
|
|
302
|
+
if n in squashed:
|
|
303
|
+
targets = [squashed[n]["sha"]]
|
|
304
|
+
elif n in merged:
|
|
305
|
+
mc = merged[n]
|
|
306
|
+
targets = git(repo, "rev-list", "--no-merges", f"{mc['parents'][0]}..{mc['parents'][1]}").split()
|
|
307
|
+
else:
|
|
308
|
+
continue
|
|
309
|
+
for t in targets:
|
|
310
|
+
if t != c["sha"] and (t not in found or c["ts"] < found[t][0]):
|
|
311
|
+
found[t] = (c["ts"], c["sha"])
|
|
312
|
+
return found
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def first_bad(events, shas, start, horizon):
|
|
316
|
+
"""Earliest revert/fix of any of shas within horizon seconds of start, else None."""
|
|
317
|
+
hits = sorted(e for s in shas for e in events.get(s, []) if e[0] >= start)
|
|
318
|
+
return hits[0] if hits and hits[0][0] - start <= horizon else None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
OUTCOME_COLUMNS = ("decision_id", "bad", "mature", "kind", "event_sha", "delay_days", "horizon_days", "as_of",
|
|
322
|
+
"early_bad", "early_mature", "early_days")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def label(repo, con, horizon_days=30, as_of=None, rev="HEAD", ignore_revs=(), fix_re=FIX_RE, early_days=7,
|
|
326
|
+
incident_linking="off", max_blamed=DEFAULT_MAX_BLAMED):
|
|
327
|
+
"""Attribute, detect reverts, fixes and incidents, and write one outcome row per decision.
|
|
328
|
+
|
|
329
|
+
Each decision also gets an early label (bad within early_days), so a first readout is
|
|
330
|
+
possible a week in; see estimate.early_readout for how far to trust it.
|
|
331
|
+
"""
|
|
332
|
+
from .incidents import incident_events
|
|
333
|
+
|
|
334
|
+
as_of = int(as_of if as_of is not None else time.time())
|
|
335
|
+
if early_days and early_days >= horizon_days:
|
|
336
|
+
raise ValueError(f"the early window ({early_days} days) must be shorter than the horizon ({horizon_days} days): "
|
|
337
|
+
"lower --early-days, or pass --early-days 0 to turn the early readout off")
|
|
338
|
+
commits = log_commits(repo, rev)
|
|
339
|
+
stats = attribute(con, commits)
|
|
340
|
+
stats["squash_recovered"] = recover_squashed(con, repo, rev, commits, TRAILER_RE)
|
|
341
|
+
|
|
342
|
+
by_sha = {c["sha"]: c for c in commits}
|
|
343
|
+
owned = defaultdict(list) # decision_id -> [sha]
|
|
344
|
+
for row in con.execute("SELECT sha, decision_id FROM commits"):
|
|
345
|
+
if row["sha"] in by_sha:
|
|
346
|
+
owned[row["decision_id"]].append(row["sha"])
|
|
347
|
+
attributed = {sha for shas in owned.values() for sha in shas}
|
|
348
|
+
|
|
349
|
+
szz_stats = {}
|
|
350
|
+
events = outcome_events(repo, commits, attributed, ignore_revs, fix_re, cache_con=con, stats=szz_stats,
|
|
351
|
+
max_blamed=max_blamed)
|
|
352
|
+
inc_events, inc_links = incident_events(repo, con, commits, attributed, incident_linking)
|
|
353
|
+
for sha, evs in inc_events.items():
|
|
354
|
+
events[sha].extend(evs)
|
|
355
|
+
|
|
356
|
+
horizon, early = horizon_days * DAY, (early_days or 0) * DAY
|
|
357
|
+
counts = {"mature": 0, "immature": 0, "revert": 0, "fix": 0, "incident": 0, "clean": 0, "no_commit": 0}
|
|
358
|
+
insert = f"INSERT INTO outcomes ({', '.join(OUTCOME_COLUMNS)}) VALUES ({', '.join('?' * len(OUTCOME_COLUMNS))})"
|
|
359
|
+
con.execute("DELETE FROM outcomes")
|
|
360
|
+
for row in con.execute("SELECT id, ts FROM decisions").fetchall():
|
|
361
|
+
shas = owned.get(row["id"])
|
|
362
|
+
if not shas:
|
|
363
|
+
con.execute(insert, (row["id"], 0, 0, "no_commit", None, None, horizon_days, as_of, 0, 0, early_days))
|
|
364
|
+
counts["no_commit"] += 1
|
|
365
|
+
continue
|
|
366
|
+
start = min(by_sha[s]["ts"] for s in shas)
|
|
367
|
+
mature = int(start + horizon <= as_of)
|
|
368
|
+
early_mature = int(bool(early) and start + early <= as_of)
|
|
369
|
+
hit = first_bad(events, shas, start, horizon)
|
|
370
|
+
early_bad = int(bool(hit) and bool(early) and hit[0] - start <= early)
|
|
371
|
+
if hit:
|
|
372
|
+
ts, kind, sha = hit
|
|
373
|
+
con.execute(insert, (row["id"], 1, mature, kind, sha, (ts - start) / DAY, horizon_days, as_of,
|
|
374
|
+
early_bad, early_mature, early_days))
|
|
375
|
+
else:
|
|
376
|
+
kind = "clean"
|
|
377
|
+
con.execute(insert, (row["id"], 0, mature, kind, None, None, horizon_days, as_of,
|
|
378
|
+
0, early_mature, early_days))
|
|
379
|
+
# Event counts cover mature decisions only, matching what the estimators see.
|
|
380
|
+
if mature:
|
|
381
|
+
counts[kind] += 1
|
|
382
|
+
counts["mature" if mature else "immature"] += 1
|
|
383
|
+
con.commit()
|
|
384
|
+
return {**stats, "commits_scanned": len(commits), "fix_commits": sum(is_fix(c, fix_re) for c in commits),
|
|
385
|
+
"incident_links": inc_links, **szz_stats, "horizon_days": horizon_days, "early_days": early_days,
|
|
386
|
+
"as_of": as_of, **counts}
|