Files
agent-prod/external/okf/skills/backfill/scripts/okf_backfill_events.py
T

1007 lines
35 KiB
Python
Raw Normal View History

2026-10-02 09:25:13 +02:00
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Event-sourcing extractor: reconstruct OKF bundle from git + session history.
Deterministic extraction: same input → byte-identical events.jsonl.
Run: uv run okf_backfill_events.py <repo-dir> [--out events.jsonl]
[--branch REF ...] [--no-sessions] [--sessions-dir DIR] [--max-text 2000]
[--skip-globs GLOB ...]
uv run okf_backfill_events.py <repo-dir> --show <sha> [--only <path>]
[--max-diff-lines 300] [--per-file-lines 120] [--max-line-chars 400]
[--skip-globs GLOB ...]
"""
from __future__ import annotations
import argparse
import glob as glob_module
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# Skip rule predicates
@dataclass
class SkipRule:
id: str
check: callable
def matches_globs(paths: list[str], globs: list[str]) -> bool:
"""True if ALL paths match at least one glob pattern."""
if not paths:
return False
for path in paths:
if not any(fnmatch.fnmatch(path, g) for g in globs):
return False
return True
LOCKFILE_GLOBS = [
"*.lock",
"package-lock.json",
"pnpm-lock.yaml",
"uv.lock",
"Cargo.lock",
"go.sum",
"vendor/**",
"node_modules/**",
"dist/**",
"*.min.*",
]
def rule_paths_only_generated(event: dict, skip_globs: list[str]) -> bool:
"""Commit only touches generated/lockfile paths."""
if event["source"] != "git":
return False
globs = LOCKFILE_GLOBS + skip_globs
paths = [f["path"] for f in event.get("files", [])]
return matches_globs(paths, globs)
def rule_merge_no_files(event: dict, skip_globs: list[str]) -> bool:
"""Merge commit with no files touched."""
if event["source"] != "git":
return False
return len(event.get("files", [])) == 0
def rule_session_command_noise(event: dict, skip_globs: list[str]) -> bool:
"""Session event that is just a slash command or too short with no outcome."""
if event["source"] != "session":
return False
user = event.get("user", "").strip()
# Bare slash command (no space or just "/" alone)
if user == "/" or (user.startswith("/") and " " not in user):
return True
# Local command output (starts with "<")
if user.startswith("<"):
return True
# Too short after strip (< 20 chars) AND no useful outcome
if len(user) < 20 and not event.get("outcome"):
return True
return False
SKIP_RULES = [
SkipRule("paths-only-generated", rule_paths_only_generated),
SkipRule("merge-no-files", rule_merge_no_files),
SkipRule("session-command-noise", rule_session_command_noise),
]
def normalize_ts(ts: str) -> float:
"""Convert ISO8601 timestamp to UTC epoch float."""
# Handle RFC3339 with or without timezone
ts = ts.strip()
# If it ends with Z, it's UTC
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
# Handle offset timezone offsets like +01:00 or -05:00
try:
# Try ISO format parsing
if "T" in ts:
dt = datetime.fromisoformat(ts)
else:
# Date only
dt = datetime.fromisoformat(ts + "T00:00:00")
return dt.timestamp()
except Exception:
print(f"Warning: could not parse timestamp {ts!r}, using epoch 0", file=sys.stderr)
return 0.0
def epoch_to_iso_z(epoch: float) -> str:
"""Format a UTC epoch as ISO8601 with a Z suffix, second precision."""
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def git_log(repo_dir: Path, branches: list[str]) -> list[dict]:
"""Extract events from git log across one or more branches (union, first
branch wins on duplicate SHAs)."""
branches = _resolve_branches(repo_dir, branches)
if not branches:
return []
events = []
seen_shas = set()
for branch in branches:
for event in _git_log_one_branch(repo_dir, branch):
if event["sha"] in seen_shas:
continue
seen_shas.add(event["sha"])
events.append(event)
return events
def _resolve_branches(repo_dir: Path, branches: list[str]) -> list[str]:
"""Resolve the branch list to query: explicit branches, else default
branch from origin/HEAD, else current branch."""
if branches:
return branches
# Try to get default branch from origin/HEAD
try:
result = subprocess.run(
["git", "-C", str(repo_dir), "symbolic-ref", "refs/remotes/origin/HEAD"],
capture_output=True, text=True, check=False
)
if result.returncode == 0:
# Typically returns "refs/remotes/origin/main" or similar
default_branch = result.stdout.strip().split("/")[-1]
branches = [default_branch]
except Exception:
pass
if not branches:
# Fallback to current branch
try:
result = subprocess.run(
["git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, check=True
)
branches = [result.stdout.strip()]
except Exception:
return []
return branches
def _git_log_one_branch(repo_dir: Path, branch: str) -> list[dict]:
"""Extract events from git log for a single branch (first-parent,
reverse, numstat)."""
events = []
# Get log info and numstat separately
try:
# First, get commit info (SHA, timestamp, author, subject, body)
log_result = subprocess.run(
["git", "-C", str(repo_dir), "log", "--first-parent", "--reverse",
"--format=%H%n%aI%n%an%n%s%n%b%n<<<SEP>>>", branch],
capture_output=True, text=True, check=True
)
# Get numstat (SHA and file changes)
numstat_result = subprocess.run(
["git", "-C", str(repo_dir), "log", "--first-parent", "--reverse",
"--numstat", "--format=%H%n<<<STAT_SEP>>>", branch],
capture_output=True, text=True, check=True
)
# Parse log output (maintain order)
log_blocks = log_result.stdout.split("<<<SEP>>>")
commits_ordered = [] # List to maintain order
commits_dict = {} # Dict for lookup during numstat
for block in log_blocks:
if not block.strip():
continue
lines = block.strip().split("\n")
if len(lines) < 4:
continue
sha = lines[0].strip()
ts_str = lines[1].strip()
author = lines[2].strip()
subject = lines[3].strip()
body = "\n".join(lines[4:]).strip() if len(lines) > 4 else ""
commit_data = {
"sha": sha,
"ts": normalize_ts(ts_str),
"author": author,
"subject": subject,
"body": body,
"files": [],
}
commits_ordered.append(commit_data)
commits_dict[sha] = commit_data
# Parse numstat output
numstat_blocks = numstat_result.stdout.split("<<<STAT_SEP>>>")
current_sha = None
for block in numstat_blocks:
lines = block.strip().split("\n")
for line in lines:
line = line.strip()
if not line:
continue
# First line in block (after <<<STAT_SEP>>>) is SHA
if line and "\t" not in line:
current_sha = line
if current_sha not in commits_dict:
# Shouldn't happen, but handle gracefully
commits_dict[current_sha] = {
"sha": current_sha,
"ts": 0,
"author": "",
"subject": "",
"body": "",
"files": [],
}
commits_ordered.append(commits_dict[current_sha])
elif current_sha and "\t" in line:
# Numstat line: additions\tdeletions\tpath
parts = line.split("\t", 2)
if len(parts) >= 3:
try:
add = int(parts[0]) if parts[0] != "-" else 0
dele = int(parts[1]) if parts[1] != "-" else 0
path = parts[2]
commits_dict[current_sha]["files"].append({
"path": path,
"add": add,
"del": dele
})
except (ValueError, IndexError):
pass
# Build events in the order we saw commits
for c in commits_ordered:
event = {
"id": f"git:{c['sha']}",
"source": "git",
"ts": c["ts"],
"sha": c["sha"],
"author": c["author"],
"subject": c["subject"],
"body": c["body"],
"files": c["files"],
}
events.append(event)
except Exception:
# Git command failed, return empty
pass
return events
def repo_slug(repo_abs: Path) -> str:
"""Slugify an absolute repo path the way Claude Code names transcript dirs.
Portable: also maps Windows separators and drive colons to dashes, so the
slug is stable regardless of platform path syntax.
"""
# Claude Code maps every non-alphanumeric character to a dash
# (verified: p-045_ekar_skills -> p-045-ekar-skills), not just separators.
return re.sub(r"[^A-Za-z0-9-]", "-", str(repo_abs))
def sessions_from_transcripts(repo_dir: Path, sessions_dir: Optional[Path]) -> list[dict]:
"""Extract events from Claude session transcripts."""
events = []
if sessions_dir is None:
sessions_dir = Path.home() / ".claude" / "projects"
if not sessions_dir.exists():
return events
# Compute repo slug and canonical path (for the cwd filter)
repo_abs = repo_dir.resolve()
slug = repo_slug(repo_abs)
# Find session files: <slug>/*.jsonl and <slug>--*/*.jsonl
session_globs = [
str(sessions_dir / slug / "*.jsonl"),
str(sessions_dir / f"{slug}--*/") + "*.jsonl",
]
session_files = []
for pattern in session_globs:
session_files.extend(glob_module.glob(pattern))
session_files = sorted(set(session_files))
for session_file in session_files:
session_path = Path(session_file)
# Read raw lines, keeping the real 1-based line number of each record
try:
raw_lines = session_path.read_text(encoding="utf-8").splitlines()
except Exception:
continue
records = [] # list of (lineno, dict)
for lineno, line in enumerate(raw_lines, start=1):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except Exception:
continue
if isinstance(rec, dict):
records.append((lineno, rec))
# Title: the LAST aiTitle in the file, computed once per file (not
# rescanned per turn).
title = None
for _, rec in records:
if rec.get("type") == "ai-title" and rec.get("aiTitle"):
title = rec["aiTitle"]
n = len(records)
i = 0
while i < n:
lineno, rec = records[i]
# Skip non-user messages, meta, and sidechain records
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
i += 1
continue
content = (rec.get("message") or {}).get("content")
if isinstance(content, str):
user_text = content
elif isinstance(content, list):
text_blocks = [
b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text"
]
if not text_blocks:
# e.g. tool_result-only user record: nothing to extract
i += 1
continue
user_text = "".join(text_blocks)
else:
i += 1
continue
# cwd filter: only keep records from this repo (or a worktree under it)
cwd = rec.get("cwd") or ""
cwd_path = Path(cwd) if cwd else None
if cwd_path is None or (cwd_path != repo_abs and repo_abs not in cwd_path.parents):
i += 1
continue
ts_str = rec.get("timestamp", "")
branch = rec.get("gitBranch")
# Find the outcome: scan forward to the next user record, taking
# the LAST non-empty text block across the assistant records
# in between (the turn wrap-up).
outcome_text = ""
j = i + 1
while j < n:
_, next_rec = records[j]
if next_rec.get("type") == "user":
break
if next_rec.get("type") == "assistant":
acontent = (next_rec.get("message") or {}).get("content")
if isinstance(acontent, list):
for block in acontent:
if isinstance(block, dict) and block.get("type") == "text" and block.get("text"):
outcome_text = block["text"]
j += 1
event = {
"id": f"session:{session_path.name}:{lineno}",
"source": "session",
"ts": normalize_ts(ts_str),
"user": user_text,
"outcome": outcome_text if outcome_text else None,
"title": title,
"branch": branch,
}
events.append(event)
i = j
return events
def apply_skip_rules(event: dict, skip_globs: list[str]) -> Optional[str]:
"""Apply skip rules and return rule_id if should be skipped, None otherwise."""
for rule in SKIP_RULES:
if rule.check(event, skip_globs):
return rule.id
return None
def merge_and_sort(git_events: list[dict], session_events: list[dict]) -> list[dict]:
"""Merge and sort events by (ts, source, id)."""
all_events = git_events + session_events
# Sort by timestamp, then source (git first), then id
def sort_key(event):
source_order = {"git": 0, "session": 1}
return (event["ts"], source_order.get(event["source"], 999), event["id"])
all_events.sort(key=sort_key)
return all_events
def truncate_text(text: str, max_len: int = 2000) -> str:
"""Apply head+tail truncation with marker."""
if len(text) <= max_len:
return text
head_len = max_len * 3 // 4
tail_len = max_len - head_len
head = text[:head_len]
tail = text[-tail_len:] if tail_len else ""
return head + "\n[...]\n" + tail
def check_coverage(events_path: Path, bundle_dir: Path) -> list[str]:
"""Check that all live events (without skip field) are mapped in the bundle.
Returns a list of unmapped event ids (empty if all are covered).
Live events are found by looking for those without a 'skip' key.
Coverage is determined by searching for event ids or their short shas in
the bundle's .md files (sources and log).
"""
if not events_path.exists():
print(f"Error: {events_path} does not exist", file=sys.stderr)
sys.exit(1)
if not bundle_dir.exists():
print(f"Error: {bundle_dir} does not exist", file=sys.stderr)
sys.exit(1)
# Collect all live event ids
live_ids = []
try:
with open(events_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
if "skip" not in event and "id" in event:
live_ids.append(event["id"])
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Error reading events: {e}", file=sys.stderr)
sys.exit(1)
# Collect all text from .md files in bundle
bundle_text = ""
try:
for md_file in bundle_dir.rglob("*.md"):
try:
bundle_text += md_file.read_text(encoding="utf-8") + "\n"
except Exception:
continue
except Exception as e:
print(f"Error reading bundle: {e}", file=sys.stderr)
sys.exit(1)
# Check coverage
unmapped = []
for event_id in live_ids:
if event_id.startswith("git:"):
# Git events: search for full sha and any >=7-char prefix
sha = event_id[4:] # Remove "git:" prefix
short_sha = sha[:7]
# Check if full sha or short sha appears in bundle
if sha not in bundle_text and short_sha not in bundle_text:
unmapped.append(event_id)
elif event_id.startswith("session:"):
# Session events: search for literal id (with colons)
if event_id not in bundle_text:
unmapped.append(event_id)
else:
# Unknown format, consider unmapped
unmapped.append(event_id)
return unmapped
def split_patch(patch_text: str) -> list[dict]:
"""Split -p output into blocks on lines starting with 'diff --git'.
Each block contains:
- path: str (the b/ path from the diff --git line, or new path for renames)
- header: list[str] (lines before first @@ line)
- hunks: list[list[str]] (groups starting at each @@ line)
"""
if not patch_text:
return []
lines = patch_text.split("\n")
blocks = []
current_block = None
for line in lines:
if line.startswith("diff --git "):
# Start new block: extract path from b/ side
# Format: diff --git a/<path> b/<path>
# ponytail: paths containing " b/" would be incorrectly split; git-quoted paths are out of scope
if " b/" in line:
path = line.split(" b/", 1)[1]
else:
path = ""
if current_block is not None:
blocks.append(current_block)
current_block = {
"path": path,
"header": [line],
"hunks": [],
}
elif current_block is not None:
if line.startswith("@@"):
# Start new hunk
current_block["hunks"].append([line])
elif current_block["hunks"]:
# Add to current hunk
current_block["hunks"][-1].append(line)
else:
# Header line (before first hunk)
current_block["header"].append(line)
if current_block is not None:
blocks.append(current_block)
return blocks
def cap_patch(
blocks: list[dict],
numstat_lines: list[str],
*,
per_file_lines: int,
max_diff_lines: int,
max_line_chars: int,
skip_globs: list[str],
only: Optional[str] = None,
) -> tuple[list[str], dict]:
"""Cap patches per-file and globally.
Returns (patch_lines, summary_dict) where:
- patch_lines: list of lines to emit (markers included, no '# patch' header)
- summary_dict: {shown, total, files_shown, files_total, truncated}
Capping rules:
- Glob-omitted files: emit marker, consume nothing, don't count toward total
- Per-file budget: max(per_file_lines, max_diff_lines - shown) under normal mode,
min(max_diff_lines, max_diff_lines - shown) under --only
- Header exceeds budget: emit header lines that fit, then accounting line with
N = unshown header lines + all hunk lines, M = len(hunks)
- Hunk doesn't fit: emit it whole if it fits remaining budget; otherwise emit
[+N more lines in M more hunks] or [hunk truncated: +N more lines]
- Global cap: before starting file, if shown >= max_diff_lines, emit
[patches omitted for K more files (+N lines)] and stop
- Line-length rule: lines longer than max_line_chars are truncated with marker
"""
globs = LOCKFILE_GLOBS + skip_globs
patch_lines = []
shown = 0
files_shown = 0
truncated = False
# Parse numstat for totals and --only validation
numstat_by_path = {}
for numstat_line in numstat_lines:
parts = numstat_line.split("\t", 2)
if len(parts) >= 3:
add_str, del_str, path = parts[0], parts[1], parts[2]
numstat_by_path[path] = (add_str, del_str)
files_total = len(numstat_lines)
# Total counts every eligible file (not glob-omitted, matching --only), including
# the ones the global cap omits: truncated=true must mean shown < total.
eligible = [
b for b in blocks
if not matches_globs([b["path"]], globs) and (only is None or b["path"] == only)
]
total = sum(len(b["header"]) + sum(len(h) for h in b["hunks"]) for b in eligible)
# Validate --only path
if only is not None:
if only not in numstat_by_path:
raise KeyError(f"Path not found in commit: {only}")
# Process blocks
block_idx = 0
for block_idx, block in enumerate(blocks):
block_path = block["path"]
# Check glob omission
if matches_globs([block_path], globs):
# Emit marker, consume nothing
if block_path in numstat_by_path:
add_str, del_str = numstat_by_path[block_path]
else:
add_str, del_str = "-", "-"
patch_lines.append(f"[patch omitted: {block_path} (generated), {add_str} {del_str}]")
continue
# Check --only filter
if only is not None and block_path != only:
continue
# Global cap check
if shown >= max_diff_lines:
# Count remaining non-glob blocks and their lines
remaining_files = 0
remaining_lines = 0
for j in range(block_idx, len(blocks)):
remaining_block = blocks[j]
if matches_globs([remaining_block["path"]], globs):
continue
if only is not None and remaining_block["path"] != only:
continue
remaining_files += 1
for hunk in remaining_block["hunks"]:
remaining_lines += len(hunk)
remaining_lines += len(remaining_block["header"])
if remaining_files > 0:
patch_lines.append(f"[patches omitted for {remaining_files} more files (+{remaining_lines} lines)]")
truncated = True
break
# Calculate file budget
if only is not None:
file_budget = min(max_diff_lines, max_diff_lines - shown)
else:
file_budget = min(per_file_lines, max_diff_lines - shown)
# Emit header lines
file_lines_emitted = 0
header_lines_to_emit = []
for hline in block["header"]:
if len(hline) > max_line_chars:
shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]"
header_lines_to_emit.append(shortened)
else:
header_lines_to_emit.append(hline)
file_lines_emitted += 1
if file_lines_emitted > file_budget:
# Header alone exceeds budget
header_lines_to_emit = header_lines_to_emit[: file_budget]
break
patch_lines.extend(header_lines_to_emit)
shown += len(header_lines_to_emit)
# Process hunks
if file_lines_emitted <= file_budget and len(block["header"]) > 0:
# Some header was emitted and didn't exceed budget
remaining_budget = file_budget - file_lines_emitted
else:
# Either no header or header exactly filled budget
remaining_budget = file_budget - len(header_lines_to_emit)
hunks_emitted = 0
for hunk_idx, hunk in enumerate(block["hunks"]):
hunk_size = len(hunk)
if hunk_size <= remaining_budget:
# Hunk fits
for hline in hunk:
if len(hline) > max_line_chars:
shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]"
patch_lines.append(shortened)
else:
patch_lines.append(hline)
shown += 1
remaining_budget -= hunk_size
hunks_emitted += 1
else:
# Hunk doesn't fit
truncated = True
if hunks_emitted == 0:
# No hunk emitted yet: either emit partial hunk or full accounting
if remaining_budget > 0:
# Emit first remaining_budget lines of this hunk
for hline in hunk[:remaining_budget]:
if len(hline) > max_line_chars:
shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]"
patch_lines.append(shortened)
else:
patch_lines.append(hline)
shown += 1
# Emit truncation marker
remaining_hunk_lines = hunk_size - remaining_budget
patch_lines.append(f"[hunk truncated: +{remaining_hunk_lines} more lines]")
else:
# Budget is 0 or negative, emit full accounting
remaining_hunk_lines = hunk_size + sum(len(h) for h in block["hunks"][hunk_idx + 1:])
remaining_hunks = len(block["hunks"]) - hunk_idx
patch_lines.append(f"[+{remaining_hunk_lines} more lines in {remaining_hunks} more hunks of {block_path}]")
else:
# Some hunks emitted, more remain
remaining_hunk_lines = hunk_size + sum(len(h) for h in block["hunks"][hunk_idx + 1:])
remaining_hunks = len(block["hunks"]) - hunk_idx
patch_lines.append(f"[+{remaining_hunk_lines} more lines in {remaining_hunks} more hunks of {block_path}]")
break
if hunks_emitted > 0 or len(header_lines_to_emit) > 0:
files_shown += 1
summary = {
"shown": shown,
"total": total,
"files_shown": files_shown,
"files_total": files_total,
"truncated": truncated,
}
return patch_lines, summary
def format_summary(summary: dict) -> str:
"""Format the fixed-form summary line."""
return (
f"[diff: shown={summary['shown']} total={summary['total']} "
f"files_shown={summary['files_shown']} files_total={summary['files_total']} "
f"truncated={'true' if summary['truncated'] else 'false'}]"
)
def show_commit(
repo_dir: Path,
sha: str,
*,
only: Optional[str] = None,
max_diff_lines: int = 300,
per_file_lines: int = 120,
max_line_chars: int = 400,
skip_globs: list[str] = (),
) -> str:
"""Run git, emit the full capped diff text.
Raises ValueError for a bad sha (exit 1) or KeyError for --only path not found (exit 2).
"""
# Resolve the sha first, separately from the parent check
resolve_result = subprocess.run(
["git", "-C", str(repo_dir), "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
capture_output=True, text=True, errors="replace", check=False
)
if resolve_result.returncode != 0:
raise ValueError(f"Bad commit: {sha}")
full_sha = resolve_result.stdout.strip()
# Check if this is a root commit or has a parent
parent_result = subprocess.run(
["git", "-C", str(repo_dir), "rev-parse", "--verify", "--quiet", f"{full_sha}^1"],
capture_output=True, text=True, errors="replace", check=False
)
if parent_result.returncode == 0:
# Has a parent: diff against first parent
base = parent_result.stdout.strip()
stat_result = subprocess.run(
["git", "-C", str(repo_dir), "diff", "--no-color", "-M", "--numstat", base, full_sha],
capture_output=True, text=True, errors="replace", check=True
)
patch_result = subprocess.run(
["git", "-C", str(repo_dir), "diff", "--no-color", "-M", "-p", base, full_sha],
capture_output=True, text=True, errors="replace", check=True
)
else:
# Root commit: use git show
stat_result = subprocess.run(
["git", "-C", str(repo_dir), "show", "--no-color", "-M", "--format=", "--numstat", full_sha],
capture_output=True, text=True, errors="replace", check=True
)
patch_result = subprocess.run(
["git", "-C", str(repo_dir), "show", "--no-color", "-M", "--format=", "-p", full_sha],
capture_output=True, text=True, errors="replace", check=True
)
# Parse stat
numstat_lines = [
line.strip()
for line in stat_result.stdout.split("\n")
if line.strip()
]
# Parse patch
blocks = split_patch(patch_result.stdout)
# Cap the patch
patch_lines, summary = cap_patch(
blocks,
numstat_lines,
per_file_lines=per_file_lines,
max_diff_lines=max_diff_lines,
max_line_chars=max_line_chars,
skip_globs=list(skip_globs),
only=only,
)
# Build output
lines = []
lines.append(f"# commit {full_sha}")
lines.append("# stat (complete)")
lines.extend(numstat_lines)
lines.append("# patch")
lines.extend(patch_lines)
lines.append(format_summary(summary))
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(
description="Event-sourcing extractor for OKF bundle reconstruction"
)
# Check if this is a coverage check (--check-coverage mode)
if "--check-coverage" in sys.argv:
parser.add_argument("--check-coverage", nargs=2, metavar=("EVENTS_JSONL", "BUNDLE_DIR"),
help="Check coverage of events in bundle")
args = parser.parse_args()
if args.check_coverage:
events_path = Path(args.check_coverage[0])
bundle_dir = Path(args.check_coverage[1])
unmapped = check_coverage(events_path, bundle_dir)
if unmapped:
print(f"Unmapped events (exit 1):")
for event_id in unmapped:
print(f" {event_id}")
sys.exit(1)
else:
print("All events mapped (exit 0)")
sys.exit(0)
# Normal extraction mode / show mode
parser.add_argument("repo_dir", nargs="?", help="Repository directory")
parser.add_argument("--out", default="events.jsonl", help="Output file")
parser.add_argument(
"--branch", action="append", default=[], help="Git branch(es) to extract"
)
parser.add_argument(
"--no-sessions", action="store_true", help="Skip session extraction"
)
parser.add_argument(
"--sessions-dir", type=Path, help="Override sessions directory"
)
parser.add_argument(
"--max-text", type=int, default=2000, help="Max text length per field"
)
parser.add_argument(
"--skip-globs", action="append", default=[], help="Additional skip globs"
)
parser.add_argument(
"--show", help="Show capped diff for a commit (exit 0 on success, 1 on bad sha)"
)
parser.add_argument(
"--only", help="Restrict patch to a single path (with --show)"
)
parser.add_argument(
"--max-diff-lines", type=int, default=300, help="Max diff lines total"
)
parser.add_argument(
"--per-file-lines", type=int, default=120, help="Max diff lines per file"
)
parser.add_argument(
"--max-line-chars", type=int, default=400, help="Max characters per line"
)
args = parser.parse_args()
if not args.repo_dir:
print("Error: repo_dir is required", file=sys.stderr)
sys.exit(1)
repo_dir = Path(args.repo_dir).resolve()
if not repo_dir.exists():
print(f"Error: {repo_dir} does not exist", file=sys.stderr)
sys.exit(1)
# Handle --show mode
if args.show:
try:
output = show_commit(
repo_dir,
args.show,
only=args.only,
max_diff_lines=args.max_diff_lines,
per_file_lines=args.per_file_lines,
max_line_chars=args.max_line_chars,
skip_globs=args.skip_globs,
)
sys.stdout.write(output)
sys.exit(0)
except ValueError as e:
# Bad sha
print(str(e), file=sys.stderr)
sys.exit(1)
except KeyError as e:
# --only path not found
print(e.args[0], file=sys.stderr)
sys.exit(2)
# Normal extraction mode
# Extract events
git_events = git_log(repo_dir, args.branch)
session_events = [] if args.no_sessions else sessions_from_transcripts(
repo_dir, args.sessions_dir
)
# Merge and sort
events = merge_and_sort(git_events, session_events)
# Apply skip rules and truncate
skip_globs = args.skip_globs
skip_counts = {}
for event in events:
skip_id = apply_skip_rules(event, skip_globs)
if skip_id:
event["skip"] = skip_id
skip_counts[skip_id] = skip_counts.get(skip_id, 0) + 1
# Truncate text fields
for key in ["subject", "body", "outcome", "user"]:
if key in event and isinstance(event[key], str):
event[key] = truncate_text(event[key], args.max_text)
# Emit ts as an ISO8601 UTC string; the epoch float was only needed
# for sorting.
event["ts"] = epoch_to_iso_z(event["ts"])
# Write JSONL (deterministic order)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
for event in events:
# Sort keys for determinism
sorted_event = {k: event[k] for k in sorted(event.keys())}
f.write(json.dumps(sorted_event, separators=(',', ':'), sort_keys=True) + "\n")
# Report
print(f"Extracted {len(events)} events")
print(f" Git: {len(git_events)}")
print(f" Sessions: {len(session_events)}")
if skip_counts:
print("Skipped:")
for rule_id, count in sorted(skip_counts.items()):
print(f" {rule_id}: {count}")
if __name__ == "__main__":
main()