feat(feedback): move feedback poster from WIP into pilot/

This commit is contained in:
Marcos
2026-08-22 14:46:07 +00:00
parent 8472f35a58
commit 4e5f43ada7
2 changed files with 225 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
"""pragent pilot — daily feedback report delivery.
Calls `feedback_analyze.analyze()` and posts the markdown report as a
comment on a single long-lived "feedback roll-up" issue in
`gitea_admin/pragent`. Comments are append-only history — one comment per
run, timestamped in the body. This keeps every report in one place, easy
to scroll, and avoids the issue-explosion of "one issue per day".
If the issue doesn't exist yet, create it. Subsequent runs just add a
new comment.
Designed for the daily K8s CronJob (`k8s/pragent-feedback-cronjob.yaml`)
but runnable from CLI for ad-hoc checks.
Env:
GITEA_API in-cluster Gitea base URL
PRAGENT_BOT_TOKEN bot token (Write collaborator on gitea_admin/pragent)
PRAGENT_FEEDBACK_DB path to SQLite (default /data/feedback.db)
PRAGENT_FEEDBACK_ISSUE_REPO default gitea_admin/pragent
PRAGENT_FEEDBACK_ISSUE_TITLE default "pragent feedback roll-up"
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import ai_review
from feedback_analyze import analyze
log = logging.getLogger("pragent.feedback.post")
REPO_DEFAULT = "gitea_admin/pragent"
TITLE_DEFAULT = "pragent feedback roll-up"
def _find_or_create_issue(api: str, token: str, repo: str, title: str) -> int:
"""Locate the open issue with this title; create one if missing.
Gitea's issue search is via `GET /repos/{o}/{r}/issues?state=open&q=...`
(q matches title + body). We filter client-side for the exact title
to avoid query-text false matches.
"""
status, raw = ai_review.gitea_get(api, repo, "issues?state=open&per_page=50", token)
if status == 200:
try:
for issue in json.loads(raw):
if issue.get("title") == title:
# NB: the comment URL needs the per-repo `number`, not the
# global `id`. `id=60 num=8` for an early-N create; we want
# `num=8` for `/repos/o/r/issues/8/comments`.
return int(issue["number"])
except (json.JSONDecodeError, ValueError, KeyError):
pass
# Create
status, raw = ai_review.gitea_post(
api, repo, "issues", token,
{"title": title, "body": "pragent feedback roll-up — auto-created."},
)
if status not in (200, 201):
raise RuntimeError(f"issue create failed: HTTP {status} body={raw[:200]!r}")
return int(json.loads(raw)["number"])
def _post_comment(api: str, token: str, repo: str, issue_number: int, body: str) -> int:
status, raw = ai_review.gitea_post(
api, repo, f"issues/{issue_number}/comments", token, {"body": body},
)
if status not in (200, 201):
raise RuntimeError(f"comment post failed: HTTP {status} body={raw[:200]!r}")
return json.loads(raw)["id"]
def deliver(
*, api: str, token: str, db_path: str,
repo: str = REPO_DEFAULT, title: str = TITLE_DEFAULT,
since_ts: int | None = None,
) -> dict:
"""Build the report and post it as a comment. Returns a stats dict."""
report = analyze(db_path, since_ts=since_ts)
issue_id = _find_or_create_issue(api, token, repo, title)
comment_id = _post_comment(api, token, repo, issue_id, report)
return {
"repo": repo, "issue_id": issue_id, "comment_id": comment_id,
"report_bytes": len(report.encode()),
}
def main() -> int:
p = argparse.ArgumentParser(
description="Post the daily feedback report to Gitea.",
)
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("--db", default=os.environ.get(
"PRAGENT_FEEDBACK_DB", "/data/feedback.db",
))
p.add_argument("--repo", default=os.environ.get(
"PRAGENT_FEEDBACK_ISSUE_REPO", REPO_DEFAULT,
))
p.add_argument("--title", default=os.environ.get(
"PRAGENT_FEEDBACK_ISSUE_TITLE", TITLE_DEFAULT,
))
p.add_argument("--since", type=int, default=None,
help="Unix timestamp; only include findings posted since")
args = p.parse_args()
if not args.token:
print("PRAGENT_BOT_TOKEN required", flush=True)
return 2
logging.basicConfig(level=logging.INFO)
stats = deliver(
api=args.api, token=args.token, db_path=args.db,
repo=args.repo, title=args.title, since_ts=args.since,
)
print(json.dumps(stats), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+97
View File
@@ -0,0 +1,97 @@
"""Tests for pilot/feedback_post.py — report delivery to Gitea.
Mock `ai_review.gitea_get` + `gitea_post` so we exercise the find-or-create
+ comment-post flow without hitting the real API.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "pilot"))
import feedback # noqa: E402
import feedback_analyze # noqa: E402
import feedback_post # noqa: E402
def _make_fake(method_routes: dict):
"""`method_routes` maps HTTP path substring → (status, body, method).
For our purposes both gitea_get and gitea_post share the same fake —
gitea_get is GET, gitea_post is POST, and the post helper also has a
body param. The fake returns whatever the route's body says.
"""
def fake_get(api, repo, path, token, accept="application/json"):
for needle in sorted(method_routes.keys(), key=len, reverse=True):
status, body, _m = method_routes[needle]
if needle in path:
return status, json.dumps(body).encode()
return 404, b'{"message":"not found"}'
def fake_post(api, repo, path, token, body):
for needle in sorted(method_routes.keys(), key=len, reverse=True):
status, resp_body, _m = method_routes[needle]
if needle in path:
return status, json.dumps(resp_body).encode()
return 404, b'{"message":"not found"}'
return fake_get, fake_post
class TestDeliver(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
conn = feedback.init(self.db)
rid = feedback.record_review(conn, repo="o/r", pr=1, head_sha="x")
feedback.record_inline_finding(
conn, review_id=rid, repo="o/r", pr=1,
path="a.ts", line=1, severity="HIGH",
problem="x", comment_id=99,
)
conn.close()
def tearDown(self):
self.tmp.cleanup()
def test_creates_issue_then_posts_comment(self):
routes = {
"issues?state=open": (200, [], "GET"), # no existing issue
"issues": (201, {"id": 42, "number": 7, "title": "..."}, "POST"),
"issues/7/comments": (201, {"id": 777}, "POST"),
}
fake_get, fake_post = _make_fake(routes)
with patch("ai_review.gitea_get", side_effect=fake_get), \
patch("ai_review.gitea_post", side_effect=fake_post):
stats = feedback_post.deliver(
api="http://x", token="t", db_path=self.db,
repo="gitea_admin/pragent", title="pragent feedback roll-up",
)
self.assertEqual(stats["issue_id"], 7)
self.assertEqual(stats["comment_id"], 777)
def test_reuses_existing_issue(self):
routes = {
"issues?state=open": (200, [
{"id": 99, "number": 9, "title": "pragent feedback roll-up"},
{"id": 100, "number": 10, "title": "something else"},
], "GET"),
"issues/9/comments": (201, {"id": 888}, "POST"),
}
fake_get, fake_post = _make_fake(routes)
with patch("ai_review.gitea_get", side_effect=fake_get), \
patch("ai_review.gitea_post", side_effect=fake_post):
stats = feedback_post.deliver(
api="http://x", token="t", db_path=self.db,
repo="gitea_admin/pragent", title="pragent feedback roll-up",
)
self.assertEqual(stats["issue_id"], 9)
self.assertEqual(stats["comment_id"], 888)
if __name__ == "__main__":
unittest.main()