From 9378fe0427d7de4d5f7b1905a97568805979c9a7 Mon Sep 17 00:00:00 2001 From: marcos Date: Tue, 18 Aug 2026 12:31:09 +0000 Subject: [PATCH] initial: toy notes service used to demo pragent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deliberately small Python service — auth helpers, a sqlite-backed store, and two request handlers — plus a .pr-review.json that steers the reviewer toward this repo's house rules. Pull requests against it carry planted defects so the reviewer has something real to find. --- .gitignore | 3 +++ .pr-review.json | 6 ++++++ README.md | 39 +++++++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/api.py | 32 ++++++++++++++++++++++++++++++++ app/auth.py | 26 ++++++++++++++++++++++++++ app/store.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_store.py | 21 +++++++++++++++++++++ 8 files changed, 162 insertions(+) create mode 100644 .gitignore create mode 100644 .pr-review.json create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api.py create mode 100644 app/auth.py create mode 100644 app/store.py create mode 100644 tests/test_store.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75c6182 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/.pr-review.json b/.pr-review.json new file mode 100644 index 0000000..7571dbb --- /dev/null +++ b/.pr-review.json @@ -0,0 +1,6 @@ +{ + "focus": ["security", "sql-injection", "auth", "path-traversal"], + "exclude_paths": ["tests/fixtures/**"], + "languages": ["python"], + "instructions": "House rules: all SQL must be parameterised — flag any query built by string concatenation or f-string as critical. Secrets and tokens must be compared with hmac.compare_digest, never ==. Any filesystem path derived from user input must be resolved and checked to stay inside its intended root. Public handlers in app/api.py must have an explicit authorisation check, not merely an authentication check." +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8722290 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# pragent-demo + +A tiny, deliberately small service used to demonstrate +[**pragent**](https://git.marcospaulo.dev.br/gitea_admin/pragent) — an AI pull-request +reviewer for Gitea. + +## How the demo works + +1. Someone opens a pull request. +2. A maintainer adds the **`AI-REVIEW`** label. +3. A Gitea webhook notifies the pragent service, which checks the repo out at the + PR's head commit, reads the changed files *and the code around them*, runs the + repo's own linters, and posts a review — a summary plus **inline comments** + anchored to the exact lines, each with a suggested fix. +4. Adding **`AI-USAGE`** as well appends a token/cost report to the review, so the + price of every review is visible rather than mysterious. + +No review fires without the label. Removing it stops re-reviews. + +> [!WARNING] +> **This repository is a teaching fixture, not example code.** +> Pull requests here contain *deliberately planted defects* — SQL injection, unsafe +> path handling, weak comparisons — so the reviewer has something real to find. +> Do not copy anything from this repo into a real project. + +## Steering the reviewer + +`.pr-review.json` on the **default branch** configures the review for this repo: +focus areas, paths to ignore, and house rules. It is read from the base branch on +purpose — otherwise a pull request could rewrite the rules it is judged by. + +## Layout + +``` +app/auth.py token + session helpers +app/store.py a toy in-memory data store +app/api.py the request handlers +tests/ what little coverage exists +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..b9b3a0c --- /dev/null +++ b/app/api.py @@ -0,0 +1,32 @@ +"""Request handlers. `request` is a dict of {path, query, headers}.""" + +from app import auth +from app.store import Store + +STORE = Store() + + +def _current_user(request: dict) -> str | None: + token = request.get("headers", {}).get("Authorization", "").removeprefix("Bearer ") + if not token: + return None + return auth.user_for_token(token) + + +def list_notes(request: dict) -> dict: + user = _current_user(request) + if user is None: + return {"status": 401, "body": "unauthenticated"} + return {"status": 200, "body": STORE.notes_for(user)} + + +def get_note(request: dict) -> dict: + user = _current_user(request) + if user is None: + return {"status": 401, "body": "unauthenticated"} + note = STORE.get_note(int(request["query"]["id"])) + if note is None: + return {"status": 404, "body": "not found"} + if note[1] != user: + return {"status": 403, "body": "forbidden"} + return {"status": 200, "body": note} diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..7eab8db --- /dev/null +++ b/app/auth.py @@ -0,0 +1,26 @@ +"""Token and session helpers.""" + +import hmac +import secrets + +_SESSIONS: dict[str, str] = {} + + +def new_token() -> str: + """Generate an opaque session token.""" + return secrets.token_urlsafe(32) + + +def verify_token(supplied: str, expected: str) -> bool: + """Constant-time token comparison.""" + return hmac.compare_digest(supplied, expected) + + +def login(user_id: str) -> str: + token = new_token() + _SESSIONS[token] = user_id + return token + + +def user_for_token(token: str) -> str | None: + return _SESSIONS.get(token) diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..619f81b --- /dev/null +++ b/app/store.py @@ -0,0 +1,35 @@ +"""A toy data store. Pretend it is a real database.""" + +import sqlite3 + + +class Store: + def __init__(self, path: str = ":memory:"): + self.conn = sqlite3.connect(path, check_same_thread=False) + self.conn.execute( + "CREATE TABLE IF NOT EXISTS notes (" + " id INTEGER PRIMARY KEY," + " owner TEXT NOT NULL," + " title TEXT NOT NULL," + " body TEXT NOT NULL)" + ) + + def add_note(self, owner: str, title: str, body: str) -> int: + cur = self.conn.execute( + "INSERT INTO notes (owner, title, body) VALUES (?, ?, ?)", + (owner, title, body), + ) + self.conn.commit() + return cur.lastrowid + + def get_note(self, note_id: int) -> tuple | None: + cur = self.conn.execute( + "SELECT id, owner, title, body FROM notes WHERE id = ?", (note_id,) + ) + return cur.fetchone() + + def notes_for(self, owner: str) -> list[tuple]: + cur = self.conn.execute( + "SELECT id, owner, title, body FROM notes WHERE owner = ?", (owner,) + ) + return cur.fetchall() diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..ad7de2f --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,21 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.store import Store # noqa: E402 + + +def test_add_and_get_note(): + s = Store() + nid = s.add_note("alice", "hello", "world") + note = s.get_note(nid) + assert note[1] == "alice" + assert note[2] == "hello" + + +def test_notes_are_scoped_to_owner(): + s = Store() + s.add_note("alice", "a", "1") + s.add_note("bob", "b", "2") + assert len(s.notes_for("alice")) == 1