"""pragent pilot — feedback storage. A thin SQLite layer that records every bot review comment + the reactions / thread-state / replies it accumulates over time. Powers the daily analysis that produces suggested addenda for `.pr-review.json:instructions` and `PRAGENT_ADDITIONAL_CONTEXT_URL` (see `feedback_analyze.py`). Why SQLite: stdlib, no extra deps in the container, single writer (the webhook server is one process per pod). Mount at `/data/feedback.db` via the `feedback-data` PVC. Schema (idempotent — safe to call `init` at every boot): review(repo, pr, head_sha, body_comment_id, posted_at, review_id_gitea) inline_finding(review_id → review.id, repo, pr, path, line, severity, problem, fix, suggestion, comment_id, posthash UNIQUE, posted_at) reaction(comment_id, user, content, created_at, PRIMARY KEY (comment_id, user, content)) thread_state(finding_id → inline_finding.id, resolved, checked_at, PRIMARY KEY (finding_id)) reply(finding_id → inline_finding.id, author, body, created_at, PRIMARY KEY (finding_id, created_at)) `posthash` is a short hash of (path|line|severity|first 80 chars of problem). It survives across reviews of the same finding on the same line — same finding on PR #5 and PR #12 of the same file de-duplicate, so the daily analyzer can count votes across reviews instead of one-at-a-time. Everything is best-effort. The webhook server never aborts a review because the feedback DB had a hiccup — `record_*` functions log and swallow. """ from __future__ import annotations import hashlib import logging import sqlite3 import time from typing import Iterable, Optional log = logging.getLogger("pragent.feedback") # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- _SCHEMA = """ CREATE TABLE IF NOT EXISTS review ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr INTEGER NOT NULL, head_sha TEXT NOT NULL, review_id_gitea INTEGER, body_comment_id INTEGER, posted_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS review_repo_pr ON review(repo, pr); CREATE TABLE IF NOT EXISTS inline_finding ( id INTEGER PRIMARY KEY AUTOINCREMENT, review_id INTEGER REFERENCES review(id), repo TEXT NOT NULL, pr INTEGER NOT NULL, path TEXT NOT NULL, line INTEGER NOT NULL, severity TEXT NOT NULL, problem TEXT NOT NULL, fix TEXT, suggestion TEXT, comment_id INTEGER, posthash TEXT NOT NULL, posted_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS inline_finding_posthash_idx ON inline_finding(posthash); CREATE INDEX IF NOT EXISTS inline_finding_repo_pr ON inline_finding(repo, pr); CREATE INDEX IF NOT EXISTS inline_finding_posthash ON inline_finding(posthash); CREATE TABLE IF NOT EXISTS reaction ( comment_id INTEGER NOT NULL, user TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (comment_id, user, content) ); CREATE INDEX IF NOT EXISTS reaction_comment ON reaction(comment_id); CREATE TABLE IF NOT EXISTS thread_state ( finding_id INTEGER NOT NULL REFERENCES inline_finding(id), resolved INTEGER NOT NULL, checked_at INTEGER NOT NULL, PRIMARY KEY (finding_id) ); CREATE TABLE IF NOT EXISTS reply ( finding_id INTEGER NOT NULL REFERENCES inline_finding(id), author TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (finding_id, created_at) ); """ def init(db_path: str) -> sqlite3.Connection: """Open (or create) the DB, ensure schema. Returns a Connection.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row # so callers can use row["name"] conn.executescript(_SCHEMA) conn.commit() return conn # --------------------------------------------------------------------------- # Posthash — cross-review finding dedup # --------------------------------------------------------------------------- def posthash(path: str, line: int, severity: str, problem: str) -> str: """Short stable hash of the finding's identifying triple + a problem fingerprint. Designed so two reviews of the SAME finding (same file, same line, same severity, same core complaint) collapse to one row — reactions across PRs aggregate. `line` is the post-change (RIGHT-side) line — the agent anchors on it and so does this hash. Different lines = different finding, by design. `severity` participates because "this is a CRITICAL bug" and "this is a LOW nitpick" at the same line on the same problem text are different signals to learn from. """ h = hashlib.sha256() h.update(f"{path}\n".encode()) h.update(f"{line}\n".encode()) h.update(f"{severity.upper()}\n".encode()) h.update(problem[:80].strip().lower().encode()) return h.hexdigest()[:16] # --------------------------------------------------------------------------- # Write helpers — all best-effort. Log + swallow. # --------------------------------------------------------------------------- def record_review( conn: sqlite3.Connection, *, repo: str, pr: int, head_sha: str, review_id_gitea: Optional[int] = None, body_comment_id: Optional[int] = None, posted_at: Optional[int] = None, ) -> Optional[int]: """Insert a review row. Returns the new row id, or None on failure.""" try: cur = conn.execute( "INSERT INTO review(repo, pr, head_sha, review_id_gitea, body_comment_id, posted_at) " "VALUES(?,?,?,?,?,?)", (repo, pr, head_sha, review_id_gitea, body_comment_id, posted_at or int(time.time())), ) conn.commit() return cur.lastrowid except Exception as e: log.warning("record_review failed: %s", e) return None def record_inline_finding( conn: sqlite3.Connection, *, review_id: Optional[int], repo: str, pr: int, path: str, line: int, severity: str, problem: str, fix: str = "", suggestion: str = "", comment_id: Optional[int] = None, posted_at: Optional[int] = None, ) -> Optional[int]: """Insert an inline-finding row, deduped on posthash. `comment_id` is filled in by the harvester when it discovers the Gitea-assigned comment id for this finding. The post path returns the `review_id` only; the inline ids come from a follow-up fetch. """ ph = posthash(path, line, severity, problem) ts = posted_at or int(time.time()) # Every call inserts a fresh row. Aggregation by posthash is the # caller's job — see `findings_with_votes` which GROUP BYs posthash. # Letting each finding be its own row means reactions on different # comment_ids across multiple PR reviews are not lost when one of # those comment_ids becomes stale. try: cur = conn.execute( "INSERT INTO inline_finding(review_id, repo, pr, path, line, severity, " "problem, fix, suggestion, comment_id, posthash, posted_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (review_id, repo, pr, path, line, severity, problem, fix, suggestion, comment_id, ph, ts), ) conn.commit() return cur.lastrowid except Exception as e: log.warning("record_inline_finding failed: %s", e) return None def record_reaction( conn: sqlite3.Connection, *, comment_id: int, user: str, content: str, created_at: Optional[int] = None, ) -> bool: """Upsert one reaction. PK = (comment_id, user, content).""" try: conn.execute( "INSERT OR IGNORE INTO reaction(comment_id, user, content, created_at) " "VALUES(?,?,?,?)", (comment_id, user, content, created_at or int(time.time())), ) conn.commit() return True except Exception as e: log.warning("record_reaction failed: %s", e) return False def record_thread_state( conn: sqlite3.Connection, *, finding_id: int, resolved: bool, checked_at: Optional[int] = None, ) -> bool: """Upsert the latest thread-state check.""" try: conn.execute( "INSERT INTO thread_state(finding_id, resolved, checked_at) " "VALUES(?,?,?) " "ON CONFLICT(finding_id) DO UPDATE SET " " resolved = excluded.resolved, checked_at = excluded.checked_at", (finding_id, 1 if resolved else 0, checked_at or int(time.time())), ) conn.commit() return True except Exception as e: log.warning("record_thread_state failed: %s", e) return False def record_reply( conn: sqlite3.Connection, *, finding_id: int, author: str, body: str, created_at: int, ) -> bool: """Insert one reply. PK includes created_at → re-imports are idempotent.""" try: conn.execute( "INSERT OR IGNORE INTO reply(finding_id, author, body, created_at) " "VALUES(?,?,?,?)", (finding_id, author, body, created_at), ) conn.commit() return True except Exception as e: log.warning("record_reply failed: %s", e) return False # --------------------------------------------------------------------------- # Read helpers — for the analyzer # --------------------------------------------------------------------------- def findings_with_votes( conn: sqlite3.Connection, *, repo: Optional[str] = None, since_ts: Optional[int] = None, ) -> Iterable[sqlite3.Row]: """Stream every inline finding with rolled-up votes attached. Joins: inline_finding ◀ reaction (count by content) inline_finding ◀ thread_state (latest resolved flag) inline_finding ◀ reply (count + concatenation of bodies for negation pattern matching) Yielded rows expose: id, repo, pr, path, line, severity, problem, fix, suggestion, comment_id, posthash, posted_at, upvotes INT, downvotes INT, resolved INT (0/1/NULL), reply_count INT, reply_bodies TEXT ('\\n\\n'-joined for substring match), review_posted_at INT """ where = [] params: list = [] if repo: where.append("f.repo = ?") params.append(repo) if since_ts is not None: where.append("COALESCE(r.posted_at, f.posted_at) >= ?") params.append(since_ts) where_sql = ("WHERE " + " AND ".join(where)) if where else "" sql = f""" SELECT f.posthash AS id, -- alias for compat — every row IS an aggregated posthash f.repo, MAX(f.pr) AS pr, f.path, f.line, MAX(f.severity) AS severity, MAX(f.problem) AS problem, MAX(f.fix) AS fix, MAX(f.suggestion) AS suggestion, MAX(f.comment_id) AS comment_id, f.posthash, MAX(f.posted_at) AS posted_at, COUNT(*) AS occurrences, r.posted_at AS review_posted_at, COALESCE(SUM(CASE WHEN rct.content = '+1' THEN 1 ELSE 0 END), 0) AS upvotes, COALESCE(SUM(CASE WHEN rct.content = '-1' THEN 1 ELSE 0 END), 0) AS downvotes, MAX(ts.resolved) AS resolved, COALESCE((SELECT COUNT(*) FROM reply WHERE finding_id IN (SELECT id FROM inline_finding WHERE posthash = f.posthash AND repo = f.repo AND path = f.path AND line = f.line)), 0) AS reply_count, COALESCE((SELECT GROUP_CONCAT(body, char(10)||char(10)) FROM reply WHERE finding_id IN (SELECT id FROM inline_finding WHERE posthash = f.posthash AND repo = f.repo AND path = f.path AND line = f.line)), '') AS reply_bodies FROM inline_finding f LEFT JOIN review r ON r.id = f.review_id LEFT JOIN reaction rct ON rct.comment_id = f.comment_id LEFT JOIN thread_state ts ON ts.finding_id = f.id {where_sql} GROUP BY f.posthash, f.repo, f.path, f.line ORDER BY posted_at DESC """ return conn.execute(sql, params) def known_posthashes_for_repo(conn: sqlite3.Connection, repo: str) -> set[str]: """For the harvester: which findings on this repo have already been recorded? Used to skip re-fetching reactions we already harvested this round.""" return { row[0] for row in conn.execute( "SELECT DISTINCT posthash FROM inline_finding WHERE repo = ?", (repo,) ).fetchall() } def comment_ids_for_finding(conn: sqlite3.Connection, posthash: str) -> Optional[int]: """Return the current Gitea comment_id for an existing finding (used to harvest votes for findings the harvester discovers on a brand-new PR that ALSO has older bot comments on prior PRs).""" row = conn.execute( "SELECT comment_id FROM inline_finding WHERE posthash = ?", (posthash,) ).fetchone() return row[0] if row else None