diff --git a/pilot/feedback_post.py b/pilot/feedback_post.py new file mode 100644 index 0000000..0c519e1 --- /dev/null +++ b/pilot/feedback_post.py @@ -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()) \ No newline at end of file diff --git a/tests/pilot/test_feedback_post.py b/tests/pilot/test_feedback_post.py new file mode 100644 index 0000000..a835a35 --- /dev/null +++ b/tests/pilot/test_feedback_post.py @@ -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() \ No newline at end of file