refactor: organize pilot packages
Group review, feedback, evaluation, observability, and entrypoint code into packages. Keep thin top-level compatibility shims for existing scripts and imports, and mirror the structure in the tests.
This commit is contained in:
+6
-393
@@ -1,394 +1,7 @@
|
||||
"""pragent pilot — feedback harvester.
|
||||
|
||||
For each PR the webhook server is about to review, walk back through the
|
||||
Gitea-side state of every bot comment from every prior review on that PR
|
||||
and record:
|
||||
- reactions on the review body + on each inline comment
|
||||
- thread-resolved state (Gitea's `resolver` field; non-empty = resolved)
|
||||
- replies (issue-comments with `review_comment_id` matching ours)
|
||||
- the bot's own findings_count + inline_count per review (for the
|
||||
restraint metric)
|
||||
|
||||
Everything is best-effort. A single 404 or 5xx is logged and skipped — we
|
||||
must never abort a review because the feedback DB had a hiccup.
|
||||
|
||||
The harvester is intentionally separate from `review_pr` so it can be
|
||||
called independently (e.g. by the daily analyzer's "backfill" mode) and
|
||||
tested in isolation against a mocked Gitea client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
import ai_review # used as ai_review.gitea_get(...) so test mocks land on the binding
|
||||
|
||||
from feedback import (
|
||||
init,
|
||||
record_inline_finding,
|
||||
record_reaction,
|
||||
record_reply,
|
||||
record_review,
|
||||
record_thread_state,
|
||||
posthash,
|
||||
)
|
||||
|
||||
log = logging.getLogger("pragent.feedback.harvest")
|
||||
|
||||
# Reviewer identity — only collect feedback on comments authored by us.
|
||||
# Avoids harvesting reactions on human comments (which we never want to
|
||||
# count toward "bot usefulness").
|
||||
REVIEWER_LOGIN = "pragent-bot"
|
||||
|
||||
# Reactions content tokens Gitea uses. We track +1 / -1 explicitly; the
|
||||
# others are stored as-is so the analyzer can mine them (👀 eyes,
|
||||
# laugh, hooray, confused, heart, rocket, …) without hardcoding a list
|
||||
# that drifts across Gitea versions.
|
||||
POSITIVE_REACTIONS = {"+1", "heart", "hooray", "laugh", "rocket"}
|
||||
NEGATIVE_REACTIONS = {"-1", "confused"}
|
||||
# Note: Gitea's `eyes` reaction (👀) means "I'm watching" — not approval
|
||||
# or disapproval. Treated as neutral by the analyzer.
|
||||
|
||||
# Phrases that, in a reply, indicate the author thinks the bot's finding
|
||||
# was wrong. Casing + punctuation ignored; substring match is good enough
|
||||
# (false positives in the analyzer cost a human minute; false negatives
|
||||
# hide regressions).
|
||||
FALSE_POSITIVE_PHRASES = (
|
||||
"false positive", "not actually", "this is fine", "this is intentional",
|
||||
"not a bug", "intentional", "wrong here", "isn't actually",
|
||||
"is not actually", "don't think this is", "i disagree", "this isn't right",
|
||||
"this is correct", "this is expected", "by design", "this is by design",
|
||||
)
|
||||
|
||||
# Gitea review-comment payload includes a 'body' field that may carry our
|
||||
# sha marker + severity header. We extract severity + path/line from it
|
||||
# as a fallback when the finding wasn't already seeded at post-time (old
|
||||
# reviews before feedback.py existed).
|
||||
SEV_RE = re.compile(r"\*\*\[(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]\*\*", re.IGNORECASE)
|
||||
PATH_LINE_RE = re.compile(r"`([^?:\n]+?):(\d+)`")
|
||||
SHA_MARKER_RE = re.compile(r"<!--\s*pragent:sha=([0-9a-f]+)\s*-->", re.IGNORECASE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level HTTP — tolerant JSON parse (Gitea sometimes returns `null` where
|
||||
# we expect `[]`, e.g. reactions on a fresh comment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gitea_get_json(api: str, repo: str, path: str, token: str) -> tuple[int, object]:
|
||||
status, raw = ai_review.gitea_get(api, repo, path, token)
|
||||
if status != 200:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return status, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_severity(body: str) -> str:
|
||||
m = SEV_RE.search(body or "")
|
||||
return m.group(1).upper() if m else "INFO"
|
||||
|
||||
|
||||
def _parse_path_line(body: str) -> tuple[Optional[str], Optional[int]]:
|
||||
m = PATH_LINE_RE.search(body or "")
|
||||
if not m:
|
||||
return None, None
|
||||
path = m.group(1).strip()
|
||||
try:
|
||||
return path, int(m.group(2))
|
||||
except ValueError:
|
||||
return path, None
|
||||
|
||||
|
||||
def _parse_sha(body: str) -> Optional[str]:
|
||||
m = SHA_MARKER_RE.search(body or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _is_negation_reply(body: str) -> bool:
|
||||
if not body:
|
||||
return False
|
||||
norm = body.lower()
|
||||
return any(p in norm for p in FALSE_POSITIVE_PHRASES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reaction classification (cheap, used by the analyzer — not the harvester
|
||||
# itself)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_reaction(content: str) -> str:
|
||||
"""Bucket a reaction into 'positive', 'negative', or 'neutral'."""
|
||||
c = (content or "").strip().lower()
|
||||
if c in POSITIVE_REACTIONS:
|
||||
return "positive"
|
||||
if c in NEGATIVE_REACTIONS:
|
||||
return "negative"
|
||||
return "neutral"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main harvest entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def harvest_for_pr(
|
||||
*,
|
||||
api: str,
|
||||
token: str,
|
||||
repo: str,
|
||||
pr_index: int,
|
||||
db_path: str,
|
||||
page_size: int = 50,
|
||||
) -> dict:
|
||||
"""Walk every bot-authored review on the given PR and record reactions
|
||||
+ thread state + replies. Returns a stats dict for logging.
|
||||
|
||||
`db_path` is the SQLite file path (env: `PRAGENT_FEEDBACK_DB`,
|
||||
typically `/data/feedback.db` mounted via the `feedback-data` PVC).
|
||||
"""
|
||||
conn = init(db_path)
|
||||
stats = {
|
||||
"reviews_seen": 0, "findings_seen": 0,
|
||||
"reactions_recorded": 0, "thread_states_recorded": 0,
|
||||
"replies_recorded": 0, "errors": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. List every review on the PR (paginated, but PRs rarely have >page_size)
|
||||
status, payload = _gitea_get_json(
|
||||
api, repo, f"pulls/{pr_index}/reviews?per_page={page_size}", token,
|
||||
)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
log.info("harvest: reviews list failed status=%d", status)
|
||||
stats["errors"] += 1
|
||||
return stats
|
||||
|
||||
for rev in payload:
|
||||
user = (rev.get("user") or {}).get("login", "")
|
||||
if user != REVIEWER_LOGIN:
|
||||
continue
|
||||
stats["reviews_seen"] += 1
|
||||
|
||||
review_id_gitea = rev.get("id")
|
||||
head_sha = rev.get("commit_id", "")
|
||||
review_body = rev.get("body", "") or ""
|
||||
body_sha = _parse_sha(review_body)
|
||||
# Trust the sha marker inside the body — Gitea's commit_id field is
|
||||
# for the LAST commit, not necessarily the reviewed head. If we
|
||||
# can't find a marker, fall back to commit_id.
|
||||
effective_sha = body_sha or head_sha
|
||||
created_at = _parse_iso_ts(rev.get("created_at", ""))
|
||||
|
||||
db_review_id = record_review(
|
||||
conn, repo=repo, pr=pr_index, head_sha=effective_sha,
|
||||
review_id_gitea=review_id_gitea,
|
||||
posted_at=created_at,
|
||||
)
|
||||
|
||||
# 2. Inline comments for this review
|
||||
if review_id_gitea is None:
|
||||
continue
|
||||
rstatus, rpayload = _gitea_get_json(
|
||||
api, repo, f"pulls/{pr_index}/reviews/{review_id_gitea}/comments",
|
||||
token,
|
||||
)
|
||||
if rstatus != 200 or not isinstance(rpayload, list):
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
for ic in rpayload:
|
||||
ic_id = ic.get("id")
|
||||
if ic_id is None:
|
||||
continue
|
||||
ic_body = ic.get("body", "") or ""
|
||||
ic_path = ic.get("path")
|
||||
ic_line = ic.get("position") or ic.get("line")
|
||||
ic_severity = _parse_severity(ic_body)
|
||||
# Fall back to body parse when Gitea didn't echo path/line
|
||||
if not ic_path or not ic_line:
|
||||
bp, bl = _parse_path_line(ic_body)
|
||||
ic_path = ic_path or bp
|
||||
ic_line = ic_line or bl
|
||||
|
||||
if not ic_path or not ic_line:
|
||||
log.info(
|
||||
"harvest: inline %s missing path/line, skipping", ic_id,
|
||||
)
|
||||
continue
|
||||
|
||||
finding_id = record_inline_finding(
|
||||
conn, review_id=db_review_id, repo=repo, pr=pr_index,
|
||||
path=ic_path, line=ic_line, severity=ic_severity,
|
||||
problem=_strip_severity_header(ic_body),
|
||||
fix="", suggestion="",
|
||||
comment_id=ic_id,
|
||||
posted_at=created_at,
|
||||
)
|
||||
stats["findings_seen"] += 1
|
||||
if finding_id is None:
|
||||
continue
|
||||
|
||||
# 3. Reactions on the inline comment
|
||||
react_status, react_payload = _gitea_get_json(
|
||||
api, repo, f"issues/comments/{ic_id}/reactions", token,
|
||||
)
|
||||
if react_status == 200 and isinstance(react_payload, list):
|
||||
for r in react_payload:
|
||||
ruser = (r.get("user") or {}).get("login", "") or "?"
|
||||
# Gitea has occasionally returned `content` as a
|
||||
# dict on older versions; coerce to str defensively.
|
||||
rcontent = str(r.get("content") or "").strip()
|
||||
if not rcontent:
|
||||
continue
|
||||
if record_reaction(
|
||||
conn, comment_id=ic_id, user=ruser,
|
||||
content=rcontent,
|
||||
created_at=_parse_iso_ts(r.get("created_at", "")),
|
||||
):
|
||||
stats["reactions_recorded"] += 1
|
||||
|
||||
# 4. Thread state (Gitea's `resolver` field on the inline
|
||||
# comment). Some Gitea versions serialize this as a user
|
||||
# object ({login, ...}) instead of a username string —
|
||||
# coerce defensively before calling .strip().
|
||||
resolver_raw = ic.get("resolver")
|
||||
if isinstance(resolver_raw, dict):
|
||||
resolver = (resolver_raw.get("login") or "").strip()
|
||||
else:
|
||||
resolver = str(resolver_raw or "").strip()
|
||||
if resolver_raw is not None: # field present, even if ""
|
||||
record_thread_state(
|
||||
conn, finding_id=finding_id,
|
||||
resolved=bool(resolver),
|
||||
)
|
||||
stats["thread_states_recorded"] += 1
|
||||
|
||||
# 5. Replies on this review (issue-comments whose
|
||||
# `review_comment_id` points at one of our inline comments).
|
||||
# Some Gitea versions don't expose `review_comment_id` on the
|
||||
# issue-comment endpoint — in that case `replies` stays
|
||||
# empty; we degrade gracefully.
|
||||
try:
|
||||
_harvest_replies(
|
||||
api=api, repo=repo, token=token,
|
||||
pr_index=pr_index, review_id=review_id_gitea,
|
||||
inline_comments=rpayload, conn=conn,
|
||||
stats=stats,
|
||||
)
|
||||
except Exception as e:
|
||||
log.info("harvest: replies fetch failed: %s", e)
|
||||
stats["errors"] += 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def _harvest_replies(
|
||||
*, api: str, repo: str, token: str, pr_index: int,
|
||||
review_id: int, inline_comments: list, conn, stats: dict,
|
||||
) -> None:
|
||||
"""Fetch issue comments on this PR; record those whose
|
||||
`review_comment_id` matches one of our inline comment IDs.
|
||||
Gitea 1.26 doesn't include that field — we fall back to fetching each
|
||||
inline comment individually via `issues/comments/{id}` (does include
|
||||
the field) only if the bulk fetch is empty.
|
||||
"""
|
||||
inline_ids = {c.get("id") for c in inline_comments if c.get("id") is not None}
|
||||
if not inline_ids:
|
||||
return
|
||||
|
||||
status, payload = _gitea_get_json(
|
||||
api, repo, f"issues/{pr_index}/comments?per_page=100", token,
|
||||
)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
return
|
||||
|
||||
# Build mapping inline_id -> finding_id (one SELECT instead of N)
|
||||
rows = conn.execute(
|
||||
"SELECT comment_id, id FROM inline_finding WHERE comment_id IN ("
|
||||
+ ",".join("?" * len(inline_ids)) + ")",
|
||||
list(inline_ids),
|
||||
).fetchall()
|
||||
inline_to_finding = {r[0]: r[1] for r in rows}
|
||||
|
||||
for c in payload:
|
||||
rcid = c.get("review_comment_id")
|
||||
if not rcid or rcid not in inline_to_finding:
|
||||
continue
|
||||
author = (c.get("user") or {}).get("login", "") or "?"
|
||||
body = c.get("body", "") or ""
|
||||
ts = _parse_iso_ts(c.get("created_at", ""))
|
||||
if record_reply(
|
||||
conn, finding_id=inline_to_finding[rcid],
|
||||
author=author, body=body, created_at=ts,
|
||||
):
|
||||
stats["replies_recorded"] += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _strip_severity_header(body: str) -> str:
|
||||
"""Drop the leading `**[SEVERITY]**` so the posthash captures the
|
||||
substance, not the severity label."""
|
||||
return SEV_RE.sub("", body or "", count=1).strip()
|
||||
|
||||
|
||||
def _parse_iso_ts(s: str) -> int:
|
||||
if not s:
|
||||
return int(time.time())
|
||||
try:
|
||||
# Python 3.11+ fromisoformat tolerates the trailing 'Z'.
|
||||
return int(__import__("datetime").datetime.fromisoformat(
|
||||
s.replace("Z", "+00:00")
|
||||
).timestamp())
|
||||
except Exception:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI for manual backfill / first-time seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
import argparse, os
|
||||
p = argparse.ArgumentParser(
|
||||
description="Harvest reactions/threads/replies on bot PR comments.",
|
||||
)
|
||||
p.add_argument("--api", default=os.environ.get(
|
||||
"GITEA_API", "http://gitea-http.gitea.svc.cluster.local:3000",
|
||||
))
|
||||
p.add_argument("--token", default=os.environ.get("PRAGENT_BOT_TOKEN", ""))
|
||||
p.add_argument("--repo", required=True, help="owner/name")
|
||||
p.add_argument("--pr", type=int, required=True, help="PR index")
|
||||
p.add_argument("--db", default=os.environ.get(
|
||||
"PRAGENT_FEEDBACK_DB", "/data/feedback.db",
|
||||
))
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.token:
|
||||
print("PRAGENT_BOT_TOKEN required", flush=True)
|
||||
return 2
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
stats = harvest_for_pr(
|
||||
api=args.api, token=args.token,
|
||||
repo=args.repo, pr_index=args.pr, db_path=args.db,
|
||||
)
|
||||
print(json.dumps(stats), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
"""Compatibility import for feedback harvesting."""
|
||||
import importlib
|
||||
import sys
|
||||
_module = importlib.import_module("feedback.harvest")
|
||||
sys.modules[__name__] = _module
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(_module.main())
|
||||
|
||||
Reference in New Issue
Block a user