703a7c9090
Implement Tier 1.5 conflict resolution as a peer driver to merge_drive.py that honours the same hard invariant: every commit on master came from a SHA whose CI passed against the exact current master. New components - tools/conflict_drive.py: deterministic driver that picks PRs labelled auto/needs-conflict-resolution, claims them via the shared auto/claimed-merge label, attempts a deterministic rebase with deepen-on-demand fallback, dispatches conflict-resolver-worker on conflict, force-pushes with --force-with-lease, and clears the label for merge_drive to re-pick. Includes 24h retry budget, escalation to auto/needs-implementer, single-instance lock + heartbeat, opt-in TOCTOU mitigations (CONFLICT_DRIVER_CYCLE_JITTER_SECONDS, CONFLICT_DRIVER_VERIFY_CLAIM), startup TTL constraint and required- labels assertions, and full SQLite telemetry. - tools/_claim_runtime.py: shared HTTP/lock/heartbeat/claim primitives extracted from merge_drive.py (driver-aware claim/release markers, injectable op_label_map). merge_drive re-exports for back-compat. - tools/_opencode_worker.py: blocking Python client for the OpenCode HTTP API with O(N) string-aware bracket matcher for tolerant JSON extraction from worker output. - .opencode/agents/conflict-resolver-worker.md: subagent definition with tight permissions and "always finish, never abort, never --skip" doctrine. - tools/inject_synthetic_conflict.py: CLI that creates PRs on a test fork with guaranteed conflicts (trivial / multi-commit / unresolvable) for end-to-end testing. Telemetry & dashboard - tools/_forgejo_cache.py: new conflict_drive_cycles table, cycle-level escalated_count, indexed retry-budget query, mark_*_escalated helper. - tools/render-pr-velocity.py + pr-velocity.canvas.template.tsx: conflict-resolution activity section showing 7-day cycle counts, resolved/escalated/timeout/push-rejected breakdowns. Open-issue dependency check - tools/merge_drive.py: pr_is_eligible now consults Forgejo's blocks endpoint and applies auto/blocked-by-deps when any open dependency exists. Read-only predicate _pr_has_open_dependencies; label mutations live with the eligibility caller. - tools/setup_auto_labels.py: provisions auto/blocked-by-deps. Documentation - docs/development/conflict-drive-plan.md: full plan including TOCTOU race documentation (§3.3.1) with implementation/test pointers. - AGENTS.md: operator-facing section on conflict_drive.py and the expanded label registry. Tests - 300 unit tests pass / 1 skipped (opt-in fork integration test). - Coverage includes JSON extractor fuzz, push-stderr classification, PAT scrub, deepen-on-demand fallback, retry budget escalation, cycle-level escalated_count stamping, claim collision detection (latest-claim-only with marker-primary identity), jitter wiring, and verify_claim_after_apply plumbing. Quality invariant unchanged: conflict_drive.py only operates on PR head branches, never on master. CI gating on the train-merge SHA continues to enforce the exact-current-master rule for everything that lands. Co-authored-by: Cursor <cursoragent@cursor.com>
1179 lines
50 KiB
Python
1179 lines
50 KiB
Python
"""Local SQLite cache of Forgejo data for the auto-agents repo (defaults
|
|
to ``cleveragents/cleveragents-core``; overridable via ``FORGEJO_OWNER``
|
|
and ``FORGEJO_REPO``).
|
|
|
|
Used by ``count-master-merges.py`` and ``pr-stats.py`` to answer all
|
|
master-branch and PR-lifecycle questions from a local database, syncing only
|
|
deltas from the API instead of re-fetching the whole window on every run.
|
|
|
|
Design
|
|
------
|
|
- **Single SQLite file** at ``tools/.cache/forgejo.sqlite`` (gitignored).
|
|
- **Three data tables**:
|
|
``commits`` - master branch, keyed by full SHA, append-only
|
|
``pulls`` - every PR we've seen, keyed by PR number
|
|
``reachability`` - cached compare/master...sha results
|
|
Plus ``sync_meta`` for cursors and ``schema_version`` for migrations.
|
|
- **Every row stores the raw API JSON** in a ``raw`` column so new fields can
|
|
be surfaced without a cache rebuild; parsed columns are indexed
|
|
projections for efficient queries.
|
|
- **Delta sync** walks the commits and PR endpoints from page 1 (newest
|
|
first) and stops as soon as the current page is entirely already-cached
|
|
(commits) or its max ``updated_at`` is older than the last sync
|
|
(PRs). Initial seeding is just "delta sync from an empty cache" and
|
|
continues until the API pages are exhausted.
|
|
- **PR enrichment** (``merged_by``) is lazy: on read, any terminal-state PR
|
|
that lacks ``merged_by_login`` but needs it gets one PR-detail fetch,
|
|
cached permanently.
|
|
- **Concurrency**: ``fcntl.flock`` on ``forgejo.sqlite.lock`` during syncs.
|
|
SQLite handles concurrent reads natively.
|
|
- **Force-push detection**: sync_meta tracks ``master_head_sha``; if the
|
|
previous HEAD is not seen anywhere in the first N pages of current master,
|
|
we emit a warning. The user can pass ``--full`` to rebuild.
|
|
|
|
Public API (used by the scripts)
|
|
--------------------------------
|
|
::
|
|
|
|
cache = ForgejoCache.open()
|
|
cache.sync(token) # delta-sync forward
|
|
commits = cache.commits_on_master(start, end) # list of raw commit dicts
|
|
closed = cache.pulls_closed_in_window(start, end)
|
|
merged = cache.pulls_merged_in_window(start, end)
|
|
opened = cache.pulls_opened_in_window(start, end)
|
|
sha_map = cache.sha_to_pr_map(start, end)
|
|
reach = cache.is_sha_reachable_from_master(sha, token)
|
|
detail = cache.enrich_pr(number, token)
|
|
|
|
All list/map queries return the raw PR/commit dicts exactly as the Forgejo
|
|
API returned them (deserialised from the ``raw`` column), so callers that
|
|
already expect raw API shape need no changes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
# ─── Config ─────────────────────────────────────────────────────────────────
|
|
|
|
# Env-driven so fork-mode reporting (`FORGEJO_OWNER=drew` etc.) reads
|
|
# from the fork's data instead of the canonical repo. The defaults
|
|
# preserve the original behaviour: when run without env vars in a
|
|
# canonical-mode shell, the cache, every API call, and every cache file
|
|
# resolve exactly as before.
|
|
REPO_OWNER = os.environ.get("FORGEJO_OWNER", "cleveragents")
|
|
REPO_NAME = os.environ.get("FORGEJO_REPO", "cleveragents-core")
|
|
API_BASE = os.environ.get(
|
|
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1"
|
|
).rstrip("/")
|
|
REQUEST_TIMEOUT_SEC = 30
|
|
REQUEST_RETRIES = 3
|
|
RETRY_BACKOFF_SEC = 2.0
|
|
|
|
DEFAULT_CACHE_DIR = Path(__file__).resolve().parent / ".cache"
|
|
# Cache path is partitioned per-(owner, repo) so a fork-mode run never
|
|
# clobbers the canonical cache (and vice versa). The default
|
|
# ``cleveragents/cleveragents-core`` keeps the historical filename
|
|
# ``forgejo.sqlite`` for backward compatibility with existing checkouts.
|
|
if REPO_OWNER == "cleveragents" and REPO_NAME == "cleveragents-core":
|
|
DEFAULT_CACHE_PATH = DEFAULT_CACHE_DIR / "forgejo.sqlite"
|
|
else:
|
|
_safe_owner = re.sub(r"[^a-zA-Z0-9._-]+", "-", REPO_OWNER)
|
|
_safe_repo = re.sub(r"[^a-zA-Z0-9._-]+", "-", REPO_NAME)
|
|
DEFAULT_CACHE_PATH = (
|
|
DEFAULT_CACHE_DIR / f"forgejo.{_safe_owner}.{_safe_repo}.sqlite"
|
|
)
|
|
# Schema versions:
|
|
# 1 — original (commits, pulls, reachability, sync_meta)
|
|
# 2 — Tier 1B telemetry: merge_cycle, ci_gate_events, llm_activity
|
|
SCHEMA_VERSION = 3
|
|
|
|
# Safety ceiling on pagination during a single sync pass.
|
|
SYNC_PAGE_HARD_LIMIT = 4000 # 4000 * 50 = 200,000 rows; far above anything real.
|
|
|
|
# How many consecutive pages with no new rows before we declare the sync done.
|
|
SYNC_EMPTY_PAGE_TOLERANCE = 2
|
|
|
|
|
|
# ─── HTTP helper (mirrors count-master-merges.py) ──────────────────────────
|
|
|
|
def api_get(url: str, token: str) -> Any:
|
|
"""GET a Forgejo API URL with retries on transient network errors."""
|
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
|
last_err: Exception | None = None
|
|
for attempt in range(1, REQUEST_RETRIES + 1):
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as resp:
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError:
|
|
# HTTP errors are authoritative: propagate so caller can decide
|
|
# (e.g. 404 on compare endpoint means "not reachable").
|
|
raise
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
last_err = e
|
|
if attempt < REQUEST_RETRIES:
|
|
time.sleep(RETRY_BACKOFF_SEC * attempt)
|
|
continue
|
|
raise RuntimeError(f"cannot reach {url} after {REQUEST_RETRIES} attempts: {last_err}")
|
|
|
|
|
|
# ─── Cache ─────────────────────────────────────────────────────────────────
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
version INTEGER PRIMARY KEY
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS commits (
|
|
sha TEXT PRIMARY KEY,
|
|
committer_date TEXT NOT NULL,
|
|
author_name TEXT,
|
|
committer_name TEXT,
|
|
message TEXT,
|
|
parents TEXT,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_commits_committer_date ON commits(committer_date);
|
|
|
|
CREATE TABLE IF NOT EXISTS pulls (
|
|
number INTEGER PRIMARY KEY,
|
|
state TEXT,
|
|
merged INTEGER,
|
|
created_at TEXT,
|
|
updated_at TEXT,
|
|
closed_at TEXT,
|
|
merged_at TEXT,
|
|
base_ref TEXT,
|
|
head_sha TEXT,
|
|
merge_commit_sha TEXT,
|
|
user_login TEXT,
|
|
merged_by_login TEXT,
|
|
additions INTEGER,
|
|
deletions INTEGER,
|
|
changed_files INTEGER,
|
|
labels TEXT,
|
|
has_detail INTEGER NOT NULL DEFAULT 0,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_closed_at ON pulls(closed_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_merged_at ON pulls(merged_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_created_at ON pulls(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_updated_at ON pulls(updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_head_sha ON pulls(head_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_merge_commit_sha ON pulls(merge_commit_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_state_merged ON pulls(state, merged);
|
|
|
|
CREATE TABLE IF NOT EXISTS reachability (
|
|
merge_sha TEXT PRIMARY KEY,
|
|
reachable INTEGER NOT NULL,
|
|
checked_at TEXT NOT NULL,
|
|
master_head_sha TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
|
|
-- ─── Tier 1B telemetry tables ────────────────────────────────────────────
|
|
--
|
|
-- merge_cycle: one row per merge driver attempt (single-PR or train-merge).
|
|
--
|
|
-- Phase columns are wall-clock seconds; NULL means the phase did not run
|
|
-- for this terminal_state. Action columns let the canvas surface "what
|
|
-- the driver actually did" without having to grep logs.
|
|
CREATE TABLE IF NOT EXISTS merge_cycle (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
pr_numbers TEXT NOT NULL, -- JSON array of PR numbers
|
|
train_id TEXT, -- e.g. "auto/train/<ts>" or NULL for single-PR
|
|
bisect_depth INTEGER NOT NULL DEFAULT 0,
|
|
rebase_seconds REAL,
|
|
ci_seconds REAL,
|
|
merge_seconds REAL,
|
|
total_seconds REAL,
|
|
terminal_state TEXT NOT NULL, -- merged | merged_in_train | rebase-conflict-vs-master
|
|
-- | ci-fail-on-rebased-sha | ci-timeout | bisect-budget-exhausted
|
|
-- | restart-budget-exhausted | umbrella-pr-creation-failed
|
|
-- | merge-409-race | crashed
|
|
action_taken TEXT, -- short slug e.g. "merged"|"released"|"escalated"
|
|
action_detail TEXT, -- free-form JSON or string with details
|
|
last_released_at TEXT, -- when the PR(s) was released back to the pool
|
|
flake_rate REAL, -- forward-fill from ci_gate_events for this cycle, if computed
|
|
raw TEXT -- optional JSON blob with extra fields for debug
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_started_at ON merge_cycle(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_ended_at ON merge_cycle(ended_at);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_terminal ON merge_cycle(terminal_state);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_pr_numbers ON merge_cycle(pr_numbers);
|
|
|
|
-- ci_gate_events: one row per individual CI gate observation (per-PR per-run).
|
|
--
|
|
-- gate is the status_check_context name (e.g. "CI / lint*"), outcome is
|
|
-- "passed" | "failed" | "timeout" | "skipped". Used for flake-rate, gate
|
|
-- fall-through analysis, and feeding the future T5A duplicate-failure
|
|
-- clusterer.
|
|
CREATE TABLE IF NOT EXISTS ci_gate_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
observed_at TEXT NOT NULL,
|
|
pr_number INTEGER,
|
|
head_sha TEXT NOT NULL,
|
|
gate TEXT NOT NULL,
|
|
outcome TEXT NOT NULL, -- passed | failed | timeout | skipped
|
|
duration_s REAL,
|
|
cycle_id INTEGER, -- FK to merge_cycle.id (nullable)
|
|
raw TEXT -- optional log/error excerpt
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_observed ON ci_gate_events(observed_at);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_gate ON ci_gate_events(gate);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_outcome ON ci_gate_events(outcome);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_head_sha ON ci_gate_events(head_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_pr ON ci_gate_events(pr_number);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_cycle ON ci_gate_events(cycle_id);
|
|
|
|
-- llm_activity: one row per LLM call by any agent in the auto-agents pipeline.
|
|
--
|
|
-- Both for cost accounting (T1A/T1C/T2C in the token-reduction plan) and
|
|
-- for activity-stream rendering. tokens_in/tokens_out/cached_tokens are
|
|
-- denormalised here for fast aggregation.
|
|
CREATE TABLE IF NOT EXISTS llm_activity (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
agent TEXT NOT NULL, -- e.g. "pr-review-worker", "task-implementor"
|
|
pr_number INTEGER,
|
|
session_tag TEXT, -- AUTO-* session tag, when applicable
|
|
model TEXT NOT NULL,
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
cached_tokens INTEGER,
|
|
decision TEXT, -- e.g. "approved", "request_changes", "ci_fix_pushed"
|
|
outcome TEXT, -- e.g. "success", "escalated", "timeout"
|
|
cycle_id INTEGER, -- FK to merge_cycle.id (nullable)
|
|
raw TEXT -- optional JSON blob (full request/response trace)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_started ON llm_activity(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_agent ON llm_activity(agent);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_pr ON llm_activity(pr_number);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_model ON llm_activity(model);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_outcome ON llm_activity(outcome);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_cycle ON llm_activity(cycle_id);
|
|
|
|
-- ─── conflict_drive_cycles (Tier 1.5 — conflict driver telemetry) ────────
|
|
--
|
|
-- One row per (cycle, PR). The cycle-level totals (candidates_count,
|
|
-- resolved_count, escalated_count, timeout_count, push_rejected_count)
|
|
-- are duplicated across all PR rows from the same cycle for cheap
|
|
-- per-cycle aggregation; the per-PR specifics live in details_json.
|
|
--
|
|
-- ``outcome`` and ``failure_kind`` are promoted to top-level columns so
|
|
-- the retry-budget query at § 5.1 of conflict-drive-plan.md can use the
|
|
-- ``(pr_number, started_at)`` index directly without parsing JSON.
|
|
CREATE TABLE IF NOT EXISTS conflict_drive_cycles (
|
|
cycle_id TEXT NOT NULL, -- shared across rows from one cycle
|
|
pr_number INTEGER NOT NULL, -- top-level for indexed lookups
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT NOT NULL,
|
|
outcome TEXT NOT NULL, -- 'resolved' | 'resolved-no-conflict'
|
|
-- | 'unresolvable' | 'verification-fail'
|
|
-- | 'rebase-failed' | 'timeout'
|
|
-- | 'transport-error' | 'lease-violation'
|
|
failure_kind TEXT, -- 'definite' | 'transient' | NULL on success
|
|
candidates_count INTEGER NOT NULL, -- cycle-level totals duplicated across PR rows
|
|
resolved_count INTEGER NOT NULL,
|
|
escalated_count INTEGER NOT NULL,
|
|
timeout_count INTEGER NOT NULL,
|
|
push_rejected_count INTEGER NOT NULL,
|
|
details_json TEXT NOT NULL, -- per-PR breakdown
|
|
PRIMARY KEY (cycle_id, pr_number)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cdc_pr_number_started ON conflict_drive_cycles(pr_number, started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_cdc_started_at ON conflict_drive_cycles(started_at);
|
|
"""
|
|
|
|
|
|
class ForgejoCache:
|
|
"""SQLite-backed cache with delta sync."""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = Path(db_path)
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# timeout=30s raises SQLite's busy-timeout so writers wait/retry when
|
|
# a sibling process (e.g. a subprocess opening the same DB) momentarily
|
|
# holds a lock, instead of failing immediately. Python's default is 5s,
|
|
# which isn't enough when a parent renderer spawns count-master-merges.
|
|
self._conn = sqlite3.connect(str(self.db_path), timeout=30.0)
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
# Belt-and-suspenders: the PRAGMA is redundant with timeout=30.0 above,
|
|
# but explicit documents intent and is useful if someone opens the
|
|
# connection differently in the future.
|
|
self._conn.execute("PRAGMA busy_timeout = 30000")
|
|
self._conn.executescript(SCHEMA_SQL)
|
|
self._migrate()
|
|
|
|
@classmethod
|
|
def open(cls, db_path: Path | None = None) -> "ForgejoCache":
|
|
return cls(db_path or DEFAULT_CACHE_PATH)
|
|
|
|
# ─── Schema management ──────────────────────────────────────────────
|
|
|
|
def _migrate(self) -> None:
|
|
row = self._conn.execute("SELECT MAX(version) AS v FROM schema_version").fetchone()
|
|
current = row["v"] if row and row["v"] is not None else 0
|
|
# The new tables in SCHEMA_SQL are all CREATE TABLE IF NOT EXISTS, so
|
|
# they are added on first ``__init__`` after upgrade. Any future
|
|
# migration that mutates an existing table goes here, gated on
|
|
# ``current < N``.
|
|
if current < SCHEMA_VERSION:
|
|
self._conn.execute(
|
|
"INSERT INTO schema_version(version) VALUES (?)", (SCHEMA_VERSION,)
|
|
)
|
|
self._conn.commit()
|
|
|
|
# ─── Tier 1B telemetry helpers ─────────────────────────────────────
|
|
|
|
def record_merge_cycle(self, cycle: dict[str, Any]) -> int:
|
|
"""Insert a ``merge_cycle`` row. Returns the new row id.
|
|
|
|
``cycle`` is a dict matching the table columns (extra keys are
|
|
captured into the ``raw`` JSON blob). Used by the merge driver to
|
|
record one full attempt (single-PR or train-merge).
|
|
"""
|
|
cols = (
|
|
"started_at",
|
|
"ended_at",
|
|
"pr_numbers",
|
|
"train_id",
|
|
"bisect_depth",
|
|
"rebase_seconds",
|
|
"ci_seconds",
|
|
"merge_seconds",
|
|
"total_seconds",
|
|
"terminal_state",
|
|
"action_taken",
|
|
"action_detail",
|
|
"last_released_at",
|
|
"flake_rate",
|
|
)
|
|
# Anything not in cols goes into raw for forensics.
|
|
raw_blob = {k: v for k, v in cycle.items() if k not in cols}
|
|
values = [cycle.get(c) for c in cols]
|
|
# pr_numbers MUST be a JSON-encoded array of ints — coerce here so
|
|
# callers can pass either a list or a string.
|
|
prs = values[cols.index("pr_numbers")]
|
|
if isinstance(prs, (list, tuple)):
|
|
values[cols.index("pr_numbers")] = json.dumps(list(prs))
|
|
elif prs is None:
|
|
values[cols.index("pr_numbers")] = "[]"
|
|
# bisect_depth is NOT NULL DEFAULT 0 in the schema; supply 0 when the
|
|
# caller doesn't pass it so the explicit-NULL doesn't violate the
|
|
# constraint.
|
|
if values[cols.index("bisect_depth")] is None:
|
|
values[cols.index("bisect_depth")] = 0
|
|
cur = self._conn.execute(
|
|
f"INSERT INTO merge_cycle ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)",
|
|
(*values, json.dumps(raw_blob) if raw_blob else None),
|
|
)
|
|
self._conn.commit()
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
return rid
|
|
|
|
def record_ci_gate_event(self, event: dict[str, Any]) -> int:
|
|
"""Insert one ``ci_gate_events`` row. Commits immediately — use
|
|
:meth:`record_ci_gate_events_batch` for high-volume callers (the
|
|
merge driver collects all 6 gate outcomes per PR ramp-up; an
|
|
eager fsync per row is wasteful).
|
|
"""
|
|
return self.record_ci_gate_events_batch([event])[-1]
|
|
|
|
def record_ci_gate_events_batch(self, events: list[dict[str, Any]]) -> list[int]:
|
|
"""Insert N ``ci_gate_events`` rows in a single transaction (one
|
|
fsync rather than N). Returns the list of new row ids in input
|
|
order."""
|
|
if not events:
|
|
return []
|
|
cols = (
|
|
"observed_at",
|
|
"pr_number",
|
|
"head_sha",
|
|
"gate",
|
|
"outcome",
|
|
"duration_s",
|
|
"cycle_id",
|
|
)
|
|
rows: list[tuple] = []
|
|
for event in events:
|
|
raw_blob = {k: v for k, v in event.items() if k not in cols}
|
|
rows.append(
|
|
(
|
|
*[event.get(c) for c in cols],
|
|
json.dumps(raw_blob) if raw_blob else None,
|
|
)
|
|
)
|
|
sql = (
|
|
f"INSERT INTO ci_gate_events ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)"
|
|
)
|
|
ids: list[int] = []
|
|
# executemany commits implicitly only inside a ``with self._conn``
|
|
# block; we want a single explicit commit at the end so partial
|
|
# batches don't half-write on error.
|
|
try:
|
|
for row in rows:
|
|
cur = self._conn.execute(sql, row)
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
ids.append(rid)
|
|
self._conn.commit()
|
|
except Exception:
|
|
self._conn.rollback()
|
|
raise
|
|
return ids
|
|
|
|
def record_llm_activity(self, activity: dict[str, Any]) -> int:
|
|
"""Insert one ``llm_activity`` row.
|
|
|
|
Used by both the cost-tracking dashboards (1B) and the agent
|
|
activity stream (Phase 2 deferred item) downstream of this. Callers
|
|
emit one row per LLM invocation.
|
|
"""
|
|
cols = (
|
|
"started_at",
|
|
"ended_at",
|
|
"agent",
|
|
"pr_number",
|
|
"session_tag",
|
|
"model",
|
|
"tokens_in",
|
|
"tokens_out",
|
|
"cached_tokens",
|
|
"decision",
|
|
"outcome",
|
|
"cycle_id",
|
|
)
|
|
raw_blob = {k: v for k, v in activity.items() if k not in cols}
|
|
cur = self._conn.execute(
|
|
f"INSERT INTO llm_activity ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)",
|
|
(
|
|
*[activity.get(c) for c in cols],
|
|
json.dumps(raw_blob) if raw_blob else None,
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
return rid
|
|
|
|
def merge_cycles_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
"""Return ``merge_cycle`` rows whose ``started_at`` is within
|
|
[start, end). Used by the pr-velocity canvas renderer."""
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM merge_cycle "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def ci_gate_events_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM ci_gate_events "
|
|
"WHERE observed_at >= ? AND observed_at < ? "
|
|
"ORDER BY observed_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def llm_activity_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM llm_activity "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# ─── conflict_drive_cycles helpers ─────────────────────────────────
|
|
|
|
def record_conflict_drive_cycle(self, row: dict[str, Any]) -> None:
|
|
"""Insert one ``conflict_drive_cycles`` row.
|
|
|
|
``row`` must carry every NOT NULL column. ``details_json`` is
|
|
encoded if a dict is passed; if the caller already JSON-encoded
|
|
it, the string is stored as-is. ``outcome`` and ``failure_kind``
|
|
live both as top-level indexed columns and inside ``details_json``;
|
|
callers MUST keep them in sync (a single
|
|
``record_telemetry`` helper in ``conflict_drive.py`` is the
|
|
intended source of truth — see plan § 9).
|
|
"""
|
|
details = row.get("details_json")
|
|
if isinstance(details, (dict, list)):
|
|
details_str = json.dumps(details)
|
|
else:
|
|
details_str = details if details is not None else "{}"
|
|
cols = (
|
|
"cycle_id",
|
|
"pr_number",
|
|
"started_at",
|
|
"ended_at",
|
|
"outcome",
|
|
"failure_kind",
|
|
"candidates_count",
|
|
"resolved_count",
|
|
"escalated_count",
|
|
"timeout_count",
|
|
"push_rejected_count",
|
|
"details_json",
|
|
)
|
|
values = list(row.get(c) for c in cols)
|
|
values[cols.index("details_json")] = details_str
|
|
# Use INSERT OR REPLACE so a same-cycle re-record (e.g. a retry
|
|
# mid-cycle) doesn't trip the PRIMARY KEY (cycle_id, pr_number).
|
|
# Cycle ids are intended to be unique-per-cycle; replace is the
|
|
# safe default if a caller emits twice by accident.
|
|
self._conn.execute(
|
|
f"INSERT OR REPLACE INTO conflict_drive_cycles "
|
|
f"({','.join(cols)}) VALUES ({','.join('?' for _ in cols)})",
|
|
values,
|
|
)
|
|
self._conn.commit()
|
|
|
|
def mark_conflict_drive_cycle_escalated(
|
|
self, cycle_id: str, pr_number: int
|
|
) -> int:
|
|
"""Set ``escalated_count = 1`` on the row keyed by
|
|
``(cycle_id, pr_number)``.
|
|
|
|
Telemetry semantics (plan § 9): ``escalated_count`` is a
|
|
cycle-level event — it fires exactly once across the rolling
|
|
24-hour retry budget when ``conflict_drive.run_one_cycle``
|
|
decides the budget is exhausted and applies
|
|
``auto/needs-implementer``. ``record_conflict_drive_cycle``
|
|
always writes ``escalated_count = 0`` because at row-write
|
|
time the budget verdict has not yet been computed (the
|
|
verdict needs the row that's being written). This helper is
|
|
the second-phase update; calling it twice for the same row
|
|
is idempotent.
|
|
|
|
Returns the number of rows updated. Zero rows means the
|
|
telemetry write earlier in the cycle either failed or used a
|
|
different ``cycle_id`` — the caller may use this to decide
|
|
whether to log a diagnostic. The caller is responsible for
|
|
any logging; this helper stays silent so the cache module
|
|
does not depend on a specific logger.
|
|
"""
|
|
cur = self._conn.execute(
|
|
"UPDATE conflict_drive_cycles SET escalated_count = 1 "
|
|
"WHERE cycle_id = ? AND pr_number = ?",
|
|
(cycle_id, pr_number),
|
|
)
|
|
self._conn.commit()
|
|
return cur.rowcount or 0
|
|
|
|
def count_conflict_drive_definite_failures_24h(
|
|
self, pr_number: int, *, now: datetime | None = None
|
|
) -> int:
|
|
"""Return the number of ``failure_kind='definite'`` rows for
|
|
``pr_number`` whose ``started_at`` is within the trailing 24 h
|
|
window. This is the retry-budget query at plan § 5.1 — used by
|
|
``conflict_drive.resolve_one`` to decide whether the next failure
|
|
triggers escalation to ``auto/needs-implementer``.
|
|
|
|
Uses the ``idx_cdc_pr_number_started`` index for an O(log N) lookup.
|
|
``now`` is injectable so unit tests can pin the window without
|
|
time-traveling the wall clock.
|
|
"""
|
|
cutoff = (now or datetime.now(timezone.utc)) - timedelta(hours=24)
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(*) AS n FROM conflict_drive_cycles "
|
|
"WHERE pr_number = ? AND failure_kind = 'definite' "
|
|
"AND started_at > ?",
|
|
(pr_number, cutoff.isoformat()),
|
|
).fetchone()
|
|
return int(row["n"]) if row else 0
|
|
|
|
def conflict_drive_cycles_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
"""Return ``conflict_drive_cycles`` rows whose ``started_at`` is
|
|
within [start, end). Used by ``tools/render-pr-velocity.py`` for
|
|
the "Conflict-resolution activity" section.
|
|
|
|
Uses the ``idx_cdc_started_at`` index for an O(log N) seek.
|
|
"""
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM conflict_drive_cycles "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def last_conflict_drive_cycle_for_pr(
|
|
self, pr_number: int
|
|
) -> dict[str, Any] | None:
|
|
"""Return the most recent ``conflict_drive_cycles`` row for
|
|
``pr_number`` (highest ``started_at``), or ``None`` if no row
|
|
exists.
|
|
|
|
Used by ``tools/conflict_drive.py`` for the per-cycle cooldown
|
|
check. Uses the ``idx_cdc_pr_number_started`` index, so the
|
|
lookup is O(log N) — important because this fires once per
|
|
candidate per cycle.
|
|
"""
|
|
row = self._conn.execute(
|
|
"SELECT * FROM conflict_drive_cycles "
|
|
"WHERE pr_number = ? "
|
|
"ORDER BY started_at DESC LIMIT 1",
|
|
(pr_number,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def last_released_at_for_pr(self, pr_number: int) -> str | None:
|
|
"""Most recent ``last_released_at`` from merge_cycle for one PR.
|
|
|
|
Used by the merge driver's candidate filter to enforce the
|
|
cooldown window without depending on labels (S4 simplification:
|
|
labels persist for visibility, but eligibility is computed from
|
|
telemetry).
|
|
"""
|
|
# Pull cycles that mention this PR and sort by ended_at desc.
|
|
# SQLite has no native JSON containment, so we use a LIKE filter
|
|
# and then verify in Python (cheap; merge_cycle is small).
|
|
rows = self._conn.execute(
|
|
"SELECT pr_numbers, last_released_at FROM merge_cycle "
|
|
"WHERE last_released_at IS NOT NULL "
|
|
" AND pr_numbers LIKE ? "
|
|
"ORDER BY last_released_at DESC LIMIT 50",
|
|
(f"%{pr_number}%",),
|
|
).fetchall()
|
|
for r in rows:
|
|
try:
|
|
prs = json.loads(r["pr_numbers"])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if pr_number in prs:
|
|
return r["last_released_at"]
|
|
return None
|
|
|
|
# ─── sync_meta helpers ──────────────────────────────────────────────
|
|
|
|
def _meta_get(self, key: str) -> str | None:
|
|
row = self._conn.execute("SELECT value FROM sync_meta WHERE key = ?", (key,)).fetchone()
|
|
return row["value"] if row else None
|
|
|
|
def _meta_set(self, key: str, value: str) -> None:
|
|
self._conn.execute(
|
|
"INSERT INTO sync_meta(key, value) VALUES(?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(key, value),
|
|
)
|
|
|
|
# ─── Locking ────────────────────────────────────────────────────────
|
|
|
|
def _lock(self):
|
|
"""fcntl.flock on ``<db_path>.lock`` for exclusive sync."""
|
|
lock_path = Path(str(self.db_path) + ".lock")
|
|
lock_path.touch()
|
|
fd = os.open(str(lock_path), os.O_RDWR)
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
return fd
|
|
|
|
@staticmethod
|
|
def _unlock(fd: int) -> None:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
# ─── Commits sync ───────────────────────────────────────────────────
|
|
|
|
def _upsert_commit(self, c: dict) -> bool:
|
|
"""Insert a commit if new. Returns True if a new row was inserted."""
|
|
sha = c.get("sha")
|
|
if not sha:
|
|
return False
|
|
row = self._conn.execute(
|
|
"INSERT OR IGNORE INTO commits "
|
|
"(sha, committer_date, author_name, committer_name, message, parents, raw) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
sha,
|
|
(c.get("commit", {}).get("committer", {}) or {}).get("date"),
|
|
(c.get("commit", {}).get("author", {}) or {}).get("name"),
|
|
(c.get("commit", {}).get("committer", {}) or {}).get("name"),
|
|
(c.get("commit", {}) or {}).get("message"),
|
|
json.dumps([p.get("sha") for p in (c.get("parents") or [])]),
|
|
json.dumps(c),
|
|
),
|
|
)
|
|
return row.rowcount > 0
|
|
|
|
def _sync_commits(self, token: str, full: bool, progress: bool) -> dict:
|
|
"""Pull master commits forward from HEAD until we reach cached content.
|
|
|
|
When ``full=True`` the cache is truncated first (use after a force-push
|
|
or to rebuild from scratch).
|
|
"""
|
|
if full:
|
|
self._conn.execute("DELETE FROM commits")
|
|
self._conn.commit()
|
|
|
|
prev_head = self._meta_get("master_head_sha")
|
|
known_shas = {
|
|
row["sha"] for row in self._conn.execute("SELECT sha FROM commits")
|
|
}
|
|
new_count = 0
|
|
consecutive_all_cached = 0
|
|
first_sha: str | None = None
|
|
saw_prev_head = False
|
|
|
|
for page in range(1, SYNC_PAGE_HARD_LIMIT + 1):
|
|
commits = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits"
|
|
f"?sha=master&limit=50&page={page}",
|
|
token,
|
|
)
|
|
if not commits:
|
|
break
|
|
if first_sha is None and commits:
|
|
first_sha = commits[0].get("sha")
|
|
page_new = 0
|
|
for c in commits:
|
|
sha = c.get("sha")
|
|
if sha == prev_head:
|
|
saw_prev_head = True
|
|
if sha in known_shas:
|
|
continue
|
|
if self._upsert_commit(c):
|
|
known_shas.add(sha)
|
|
new_count += 1
|
|
page_new += 1
|
|
if progress and (page == 1 or page % 10 == 0):
|
|
print(
|
|
f"# commits: page {page}, +{page_new} new (running total {new_count})",
|
|
file=sys.stderr,
|
|
)
|
|
# Stop when we've hit a page that's entirely old content (meaning
|
|
# we've caught up to the cache's existing HEAD).
|
|
if page_new == 0:
|
|
consecutive_all_cached += 1
|
|
if consecutive_all_cached >= SYNC_EMPTY_PAGE_TOLERANCE:
|
|
break
|
|
else:
|
|
consecutive_all_cached = 0
|
|
if len(commits) < 50:
|
|
break
|
|
|
|
self._conn.commit()
|
|
if first_sha:
|
|
self._meta_set("master_head_sha", first_sha)
|
|
self._meta_set("last_commit_sync_at", datetime.now(timezone.utc).isoformat())
|
|
|
|
return {
|
|
"new_commits": new_count,
|
|
"master_head_sha": first_sha,
|
|
"force_push_suspected": (
|
|
prev_head is not None and not saw_prev_head and new_count > 50
|
|
),
|
|
}
|
|
|
|
# ─── PR sync ────────────────────────────────────────────────────────
|
|
|
|
def _upsert_pr(self, pr: dict, has_detail: bool | None = None) -> bool:
|
|
"""Insert or update a PR. Returns True if row was new or updated_at advanced."""
|
|
num = pr.get("number")
|
|
if num is None:
|
|
return False
|
|
existing = self._conn.execute(
|
|
"SELECT updated_at, has_detail FROM pulls WHERE number = ?", (num,),
|
|
).fetchone()
|
|
new_updated = pr.get("updated_at")
|
|
if existing is not None and existing["updated_at"] == new_updated:
|
|
# No change; but we may have just pulled detail — update has_detail if so.
|
|
if has_detail and not existing["has_detail"]:
|
|
self._conn.execute(
|
|
"UPDATE pulls SET has_detail = 1, merged_by_login = ?, "
|
|
"additions = ?, deletions = ?, changed_files = ?, raw = ? "
|
|
"WHERE number = ?",
|
|
(
|
|
(pr.get("merged_by") or {}).get("login"),
|
|
pr.get("additions"),
|
|
pr.get("deletions"),
|
|
pr.get("changed_files"),
|
|
json.dumps(pr),
|
|
num,
|
|
),
|
|
)
|
|
return True
|
|
return False
|
|
|
|
self._conn.execute(
|
|
"INSERT INTO pulls "
|
|
"(number, state, merged, created_at, updated_at, closed_at, merged_at, "
|
|
" base_ref, head_sha, merge_commit_sha, user_login, merged_by_login, "
|
|
" additions, deletions, changed_files, labels, has_detail, raw) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(number) DO UPDATE SET "
|
|
" state = excluded.state, merged = excluded.merged, "
|
|
" created_at = excluded.created_at, updated_at = excluded.updated_at, "
|
|
" closed_at = excluded.closed_at, merged_at = excluded.merged_at, "
|
|
" base_ref = excluded.base_ref, head_sha = excluded.head_sha, "
|
|
" merge_commit_sha = excluded.merge_commit_sha, "
|
|
" user_login = excluded.user_login, "
|
|
" merged_by_login = COALESCE(excluded.merged_by_login, pulls.merged_by_login), "
|
|
" additions = COALESCE(excluded.additions, pulls.additions), "
|
|
" deletions = COALESCE(excluded.deletions, pulls.deletions), "
|
|
" changed_files = COALESCE(excluded.changed_files, pulls.changed_files), "
|
|
" labels = excluded.labels, "
|
|
" has_detail = MAX(excluded.has_detail, pulls.has_detail), "
|
|
" raw = excluded.raw",
|
|
(
|
|
num,
|
|
pr.get("state"),
|
|
1 if pr.get("merged") else 0,
|
|
pr.get("created_at"),
|
|
pr.get("updated_at"),
|
|
pr.get("closed_at"),
|
|
pr.get("merged_at"),
|
|
(pr.get("base") or {}).get("ref"),
|
|
(pr.get("head") or {}).get("sha"),
|
|
pr.get("merge_commit_sha"),
|
|
(pr.get("user") or {}).get("login"),
|
|
(pr.get("merged_by") or {}).get("login"),
|
|
pr.get("additions"),
|
|
pr.get("deletions"),
|
|
pr.get("changed_files"),
|
|
json.dumps([lbl.get("name") for lbl in (pr.get("labels") or [])]),
|
|
1 if has_detail else 0,
|
|
json.dumps(pr),
|
|
),
|
|
)
|
|
return True
|
|
|
|
def _sync_pulls(self, token: str, full: bool, progress: bool) -> dict:
|
|
"""Sync all PRs by walking ``pulls?state=all&sort=recentupdate``.
|
|
|
|
Stops when we see a page whose most-recent updated_at is older than
|
|
our last-sync cursor (everything older is already cached and stable).
|
|
"""
|
|
if full:
|
|
self._conn.execute("DELETE FROM pulls")
|
|
self._conn.commit()
|
|
|
|
last_sync = self._meta_get("last_pull_sync_updated_at")
|
|
if full or last_sync is None:
|
|
last_sync_dt = None
|
|
else:
|
|
last_sync_dt = last_sync # string comparison is safe on ISO 8601
|
|
|
|
upserts = 0
|
|
max_updated: str | None = None
|
|
consecutive_stale_pages = 0
|
|
for page in range(1, SYNC_PAGE_HARD_LIMIT + 1):
|
|
prs = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls"
|
|
f"?state=all&sort=recentupdate&limit=50&page={page}",
|
|
token,
|
|
)
|
|
if not prs:
|
|
break
|
|
page_new_or_updated = 0
|
|
page_min_updated: str | None = None
|
|
for pr in prs:
|
|
upd = pr.get("updated_at")
|
|
if upd is not None:
|
|
if page_min_updated is None or upd < page_min_updated:
|
|
page_min_updated = upd
|
|
if max_updated is None or upd > max_updated:
|
|
max_updated = upd
|
|
if last_sync_dt is not None and upd is not None and upd <= last_sync_dt:
|
|
continue
|
|
if self._upsert_pr(pr):
|
|
upserts += 1
|
|
page_new_or_updated += 1
|
|
|
|
if progress and (page == 1 or page % 20 == 0):
|
|
print(
|
|
f"# pulls: page {page}, +{page_new_or_updated} upserts "
|
|
f"(running total {upserts})",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Once the oldest updated_at on this page is older than our last
|
|
# sync cursor, any subsequent page is entirely older still.
|
|
if (
|
|
last_sync_dt is not None
|
|
and page_min_updated is not None
|
|
and page_min_updated <= last_sync_dt
|
|
):
|
|
break
|
|
|
|
if page_new_or_updated == 0:
|
|
consecutive_stale_pages += 1
|
|
if consecutive_stale_pages >= SYNC_EMPTY_PAGE_TOLERANCE:
|
|
break
|
|
else:
|
|
consecutive_stale_pages = 0
|
|
|
|
if len(prs) < 50:
|
|
break
|
|
|
|
self._conn.commit()
|
|
if max_updated:
|
|
self._meta_set("last_pull_sync_updated_at", max_updated)
|
|
self._meta_set("last_pull_sync_at", datetime.now(timezone.utc).isoformat())
|
|
return {"pulls_upserted": upserts, "max_updated_at": max_updated}
|
|
|
|
# ─── PR enrichment (lazy, on read) ──────────────────────────────────
|
|
|
|
def enrich_pr(self, number: int, token: str) -> dict:
|
|
"""Return the full PR dict, fetching detail on demand.
|
|
|
|
The list endpoint omits ``merged_by``, ``additions``, ``deletions``,
|
|
and ``changed_files``. We fetch the detail once per PR and persist it.
|
|
"""
|
|
row = self._conn.execute(
|
|
"SELECT raw, has_detail FROM pulls WHERE number = ?", (number,),
|
|
).fetchone()
|
|
if row is None:
|
|
# Not in cache at all — fetch and store.
|
|
pr = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{number}", token,
|
|
)
|
|
self._upsert_pr(pr, has_detail=True)
|
|
self._conn.commit()
|
|
return pr
|
|
if row["has_detail"]:
|
|
return json.loads(row["raw"])
|
|
# Need to fetch detail.
|
|
pr = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{number}", token,
|
|
)
|
|
self._upsert_pr(pr, has_detail=True)
|
|
self._conn.commit()
|
|
return pr
|
|
|
|
def backfill_merged_by(self, token: str, progress: bool = True) -> int:
|
|
"""One-shot: fetch detail for every in-cache closed+merged PR that
|
|
lacks ``merged_by_login``. Called optionally after a fresh sync to
|
|
warm the merged-by cache. Returns number of PRs enriched."""
|
|
rows = self._conn.execute(
|
|
"SELECT number FROM pulls "
|
|
"WHERE state = 'closed' AND merged = 1 AND merged_by_login IS NULL "
|
|
"AND has_detail = 0"
|
|
).fetchall()
|
|
n = 0
|
|
for i, row in enumerate(rows):
|
|
self.enrich_pr(row["number"], token)
|
|
n += 1
|
|
if progress and (i + 1) % 50 == 0:
|
|
print(f"# merged_by backfill: {i+1}/{len(rows)}", file=sys.stderr)
|
|
return n
|
|
|
|
# ─── Reachability (compare endpoint) ────────────────────────────────
|
|
|
|
def is_sha_reachable_from_master(
|
|
self, sha: str, token: str, *, master_head_sha: str | None = None,
|
|
) -> bool:
|
|
"""Cached reachability check.
|
|
|
|
Cache key is ``(merge_sha, master_head_sha)``; a positive result is
|
|
permanent (a commit that was ever an ancestor of master stays so,
|
|
absent a force-push), but negative results are re-checked if the
|
|
master HEAD has advanced.
|
|
"""
|
|
if not sha:
|
|
return False
|
|
row = self._conn.execute(
|
|
"SELECT reachable, master_head_sha FROM reachability WHERE merge_sha = ?",
|
|
(sha,),
|
|
).fetchone()
|
|
if row is not None:
|
|
if row["reachable"]:
|
|
return True
|
|
# Negative result: only trust it if master HEAD hasn't changed.
|
|
cur_head = master_head_sha or self._meta_get("master_head_sha")
|
|
if cur_head and row["master_head_sha"] == cur_head:
|
|
return False
|
|
# Miss or stale negative — do the API call.
|
|
url = f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/compare/master...{sha}"
|
|
try:
|
|
data = api_get(url, token)
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
reachable = False
|
|
else:
|
|
raise
|
|
else:
|
|
total = data.get("total_commits")
|
|
if total is None:
|
|
total = len(data.get("commits") or [])
|
|
reachable = total == 0
|
|
cur_head = master_head_sha or self._meta_get("master_head_sha")
|
|
self._conn.execute(
|
|
"INSERT INTO reachability(merge_sha, reachable, checked_at, master_head_sha) "
|
|
"VALUES(?, ?, ?, ?) "
|
|
"ON CONFLICT(merge_sha) DO UPDATE SET "
|
|
" reachable = excluded.reachable, "
|
|
" checked_at = excluded.checked_at, "
|
|
" master_head_sha = excluded.master_head_sha",
|
|
(sha, 1 if reachable else 0, datetime.now(timezone.utc).isoformat(), cur_head),
|
|
)
|
|
self._conn.commit()
|
|
return reachable
|
|
|
|
# ─── Top-level sync ─────────────────────────────────────────────────
|
|
|
|
def sync(
|
|
self,
|
|
token: str,
|
|
*,
|
|
full: bool = False,
|
|
backfill_details: bool = False,
|
|
progress: bool = True,
|
|
) -> dict:
|
|
"""Delta-sync the cache. Returns a summary dict."""
|
|
lock_fd = self._lock()
|
|
try:
|
|
if progress:
|
|
print("# syncing commits ...", file=sys.stderr)
|
|
commits_summary = self._sync_commits(token, full, progress)
|
|
if commits_summary.get("force_push_suspected"):
|
|
print(
|
|
"# WARN: previous master HEAD not seen in recent commits — "
|
|
"possible force-push. Re-run with --full to rebuild from scratch.",
|
|
file=sys.stderr,
|
|
)
|
|
if progress:
|
|
print("# syncing pulls ...", file=sys.stderr)
|
|
pulls_summary = self._sync_pulls(token, full, progress)
|
|
enriched = 0
|
|
if backfill_details:
|
|
if progress:
|
|
print("# back-filling merged_by for merged PRs ...", file=sys.stderr)
|
|
enriched = self.backfill_merged_by(token, progress=progress)
|
|
return {
|
|
"commits": commits_summary,
|
|
"pulls": pulls_summary,
|
|
"enriched_prs": enriched,
|
|
}
|
|
finally:
|
|
self._unlock(lock_fd)
|
|
|
|
# ─── Query API (all return raw API-shaped dicts) ────────────────────
|
|
|
|
def commits_on_master(
|
|
self, start: datetime, end: datetime,
|
|
) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM commits "
|
|
"WHERE committer_date >= ? AND committer_date < ? "
|
|
"ORDER BY committer_date DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_closed_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE state = 'closed' AND closed_at IS NOT NULL "
|
|
" AND closed_at >= ? AND closed_at < ? "
|
|
"ORDER BY closed_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_merged_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE merged = 1 AND merged_at IS NOT NULL "
|
|
" AND merged_at >= ? AND merged_at < ? "
|
|
"ORDER BY merged_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_opened_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE created_at >= ? AND created_at < ? "
|
|
"ORDER BY created_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def sha_to_pr_map(self, start: datetime, end: datetime) -> dict[str, dict]:
|
|
"""SHA -> PR dict for PRs merged in window (indexes both head.sha
|
|
and merge_commit_sha), for count-master-merges Phase 1 cross-reference.
|
|
"""
|
|
rows = self._conn.execute(
|
|
"SELECT raw, head_sha, merge_commit_sha FROM pulls "
|
|
"WHERE merged = 1 AND merged_at IS NOT NULL "
|
|
" AND merged_at >= ? AND merged_at < ?",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
out: dict[str, dict] = {}
|
|
for r in rows:
|
|
pr = json.loads(r["raw"])
|
|
if r["head_sha"]:
|
|
out[r["head_sha"]] = pr
|
|
if r["merge_commit_sha"]:
|
|
out[r["merge_commit_sha"]] = pr
|
|
return out
|
|
|
|
# ─── Introspection helpers ──────────────────────────────────────────
|
|
|
|
def stats(self) -> dict:
|
|
c = self._conn
|
|
row = c.execute("SELECT COUNT(*) AS n, MIN(committer_date) AS oldest, "
|
|
"MAX(committer_date) AS newest FROM commits").fetchone()
|
|
pull_row = c.execute(
|
|
"SELECT COUNT(*) AS n, MIN(created_at) AS oldest_created, "
|
|
"MAX(updated_at) AS latest_update FROM pulls"
|
|
).fetchone()
|
|
reach_row = c.execute("SELECT COUNT(*) AS n FROM reachability").fetchone()
|
|
return {
|
|
"cache_path": str(self.db_path),
|
|
"cache_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
|
|
"commits": {
|
|
"count": row["n"],
|
|
"oldest": row["oldest"],
|
|
"newest": row["newest"],
|
|
},
|
|
"pulls": {
|
|
"count": pull_row["n"],
|
|
"oldest_created": pull_row["oldest_created"],
|
|
"latest_update": pull_row["latest_update"],
|
|
},
|
|
"reachability_cached": reach_row["n"],
|
|
"last_commit_sync_at": self._meta_get("last_commit_sync_at"),
|
|
"last_pull_sync_at": self._meta_get("last_pull_sync_at"),
|
|
"master_head_sha": self._meta_get("master_head_sha"),
|
|
}
|
|
|
|
def close(self) -> None:
|
|
self._conn.close()
|