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.
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}