Metadata-Version: 2.5
Name: pjdev-feedback
Version: 0.1.0b1
Summary: Mountable FastAPI router that backs the @purplejayllc/feedback-modal widget with GitLab-created feedback issues.
Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-feedback/README.md
Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
Project-URL: Source, https://gitlab.purplejay.io/keystone/python
Author-email: Purple Jay LLC <developers@purplejay.io>
License-Expression: MIT
License-File: LICENSE.txt
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.12
Requires-Dist: fastapi
Requires-Dist: httpx
Requires-Dist: pjdev-gitlab>=5.0.2
Requires-Dist: python-multipart
Provides-Extra: oauth
Requires-Dist: cryptography>=42.0; extra == 'oauth'
Requires-Dist: itsdangerous>=2.0; extra == 'oauth'
Provides-Extra: test
Requires-Dist: cryptography>=42.0; extra == 'test'
Requires-Dist: itsdangerous>=2.0; extra == 'test'
Requires-Dist: pytest; extra == 'test'
Description-Content-Type: text/markdown

# pjdev-feedback

[![PyPI - Version](https://img.shields.io/pypi/v/pjdev-feedback.svg)](https://pypi.org/project/pjdev-feedback)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-feedback.svg)](https://pypi.org/project/pjdev-feedback)

A self-contained, pip-installable FastAPI router that backs the
`@purplejayllc/feedback-modal` widget with GitLab-created feedback issues.
Mount one router, point it at a GitLab project, and `POST /api/feedback/issue`
turns a title + markdown description + inline screenshot attachments into a
labelled GitLab issue.

It is a generalized port of the ServiceNow-Workflows feedback backend, with the
app-specific globals removed: instead of an import-time SDK singleton, the
router builds a per-request `httpx.AsyncClient` and injects it into the
`pjdev_gitlab` SDK via its `client=` seam. Auth and base URL therefore come from
that client, not from SDK global state.

> The `client=` seam does not remove the SDK's global config entirely. Every SDK
> entry point used here is wrapped in `@async_retry_http`, whose wrapper reads
> `config_service.get_config()` for the retry policy before the decorated
> function sees the client — so an uninitialized global raises
> "pjdev_gitlab is not initialized" on every call. `create_feedback_router`
> therefore calls `config_service.init()` at mount time if, and only if, nothing
> else has: an app that already initializes the SDK keeps its own settings.

## Install

```console
pip install pjdev-feedback
```

Requires Python >= 3.12. Depends on `fastapi`, `httpx`,
`pjdev-gitlab>=5.0.2`, and `python-multipart`.

## Modes

| Mode | `mode=` | Auth to GitLab | Issue author | Reporter footer |
| --- | --- | --- | --- | --- |
| A | `"project"` | project/personal access token (`PRIVATE-TOKEN`) | the token owner | appended from the authenticated user |
| B | `"oauth"` | signed-in user's OAuth token (`Authorization: Bearer`) | the user | omitted (already authored by user) |

## Mode A — project token

The app owns a project (or personal) access token with `api` scope. Because
every issue is created by that account — spending the *app's* credential — the
router **requires an authenticated app user**: `require_user` is mandatory in
this mode (`create_feedback_router` raises at mount time without it), and a
request whose user resolves to `None` gets a **401**. The authenticated
identity is rendered as a reporter footer
(`--- Reported by: NAME <EMAIL> at TS`); it is never taken from client-supplied
fields. Set `app_version` to add an `App version: X` line beneath it — see
[Attribution footer](#attribution-footer).

`require_user` is wired as a FastAPI dependency. It may return an object with
`.display_name`/`.name` and `.email`, or a `(name, email)` pair; it may also
raise its own `HTTPException` (e.g. your app's `current_user`).

```python
import os
from fastapi import FastAPI
from pjdev_feedback import FeedbackConfig, create_feedback_router
from myapp.auth import require_active_user

app = FastAPI()
app.include_router(
    create_feedback_router(
        FeedbackConfig(
            gitlab_url=os.environ["FEEDBACK_GITLAB_URL"],
            project_id=os.environ["FEEDBACK_GITLAB_PROJECT_ID"],
            project_token=os.environ["FEEDBACK_GITLAB_PROJECT_TOKEN"],
            mode="project",
            require_user=require_active_user,
            # Recorded in the issue footer, so a report names the build it came from.
            app_version=os.environ.get("APP_VERSION"),
            # server-side knobs (defaults shown)
            labels=["feedback"],
            max_attachment_bytes=8 * 1024 * 1024,  # per file
            max_total_bytes=8 * 1024 * 1024,       # per request
            max_files=10,
        )
    )
)
```

## Mode B — user OAuth

The issue is authored as the signed-in user via `Authorization: Bearer`, so no
project token is needed and no reporter line is appended (an `app_version` line
still is, if configured). There are two ways to wire it: **turnkey** (the router
runs the OAuth dance) or **bring-your-own** (you resolve the token from your own
session/store).

### Turnkey (recommended) — `oauth=OAuthConfig(...)`

Pass an `OAuthConfig` and the router mounts the full authorization-code flow for
you and stores the token in the Starlette session:

```
GET  /api/feedback/auth/login     -> redirect to GitLab authorize
GET  /api/feedback/auth/callback  -> exchange code, store token, close popup
GET  /api/feedback/auth/status    -> {"authenticated": bool}
POST /api/feedback/auth/logout    -> revoke the token upstream + clear it
```

You only need a **registered GitLab OAuth application** (with the `api` scope and
a matching redirect URI — GitLab has no issues-only write scope, so `api` is
required) and Starlette's `SessionMiddleware` (install the extra:
`pip install "pjdev-feedback[oauth]"`).

Token handling: Starlette's session cookie is HttpOnly (unreadable from page
JavaScript) and signed, but **not encrypted** — so the router stores the token
Fernet-encrypted with a key derived from `OAuthConfig.token_secret`. Whoever
reads the cookie (device access, logs, backups) still can't recover the token
without the server-side secret. Logout calls GitLab's `/oauth/revoke` before
clearing the session, so a logged-out token is actually dead. Rotating
`token_secret` signs everyone out.

```python
import os
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from pjdev_feedback import FeedbackConfig, OAuthConfig, create_feedback_router

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["FEEDBACK_SESSION_SECRET"])
app.include_router(
    create_feedback_router(
        FeedbackConfig(
            gitlab_url="https://gitlab.example.com",
            project_id="group/project",
            mode="oauth",
            oauth=OAuthConfig(
                client_id=os.environ["FEEDBACK_OAUTH_CLIENT_ID"],
                client_secret=os.environ["FEEDBACK_OAUTH_CLIENT_SECRET"],
                # must match the GitLab app's registered callback
                redirect_uri="https://app.example.com/api/feedback/auth/callback",
                # encrypts the session-stored token (Fernet, SHA-256 derived)
                token_secret=os.environ["FEEDBACK_TOKEN_SECRET"],
                scopes="api",
            ),
        )
    )
)
```

The callback page `postMessage`s the modal's popup opener (`{ type: 'pjfm-auth',
ok }`) and closes; `state` is verified against the session for CSRF protection.
See `example/oauth_app.py`. On the frontend:

```tsx
<FeedbackModal
  auth={{ mode: 'oauth', authorizeUrl: '/api/feedback/auth/login', statusUrl: '/api/feedback/auth/status' }}
  ...
/>
```

> Token refresh is out of scope — GitLab access tokens expire (2h by default); on
> a `401` the user is re-prompted through the popup. For custom storage (Redis,
> encrypted store) or a different flow, use bring-your-own below.

### Bring-your-own — `get_user_gitlab_token=...`

Own the OAuth dance and session yourself, and just tell the router how to read
the token. This callable is wired as a **FastAPI dependency** (so it may declare
`Request`, session, etc.) and takes precedence over `oauth` when both are set:

```python
from fastapi import Request
from pjdev_feedback import FeedbackConfig, create_feedback_router

async def get_user_gitlab_token(request: Request) -> str | None:
    # Return None to signal "not signed in" — the endpoint responds 401 and the
    # widget re-prompts through the popup. (Don't raise plain exceptions here:
    # dependency errors surface as 500s. Raising HTTPException yourself is fine.)
    return request.session.get("gitlab_token")

app.include_router(
    create_feedback_router(
        FeedbackConfig(
            gitlab_url="https://gitlab.example.com",
            project_id="group/project",
            mode="oauth",
            get_user_gitlab_token=get_user_gitlab_token,
        )
    )
)
```

## Endpoint

`POST /api/feedback/issue` (multipart/form-data):

| Field | Type | Notes |
| --- | --- | --- |
| `title` | form | 1..255 chars |
| `description` | form | markdown, 1..65536 chars; inline images as `![alt](cid:XYZ)` |
| `request_type` | form | one of the configured `request_types` values. Ignored unless `request_types` is populated — which it is not by default (see below) |
| `page_url` | form | the page the report was filed from, max 2048 chars. Ignored unless `include_page_url=True`; `http`/`https` only |
| `cid_map` | form | JSON object `{cid: display filename}`, default `"{}"` |
| `files` | files | uploaded attachments; each part is **named by its cid** (unique), so duplicate display filenames can't collide |

Each `![alt](cid:XYZ)` placeholder is replaced with the markdown reference
GitLab returns after the matching file is uploaded. Response:

```json
{ "issue_iid": 42, "web_url": "https://gitlab.example.com/group/project/-/issues/42" }
```

## Attribution footer

The issue body ends with a footer recording who reported it and from which build:

```markdown
---
Reported by: Ann Smith <ann@example.com> at 2026-08-17 14:32 UTC

Page: <https://app.example.com/systems/42>

App version: `2.0.6`
```

Each entry is its own paragraph — GitLab renders a lone newline as a space, so
single-newline separation runs them together. `Page` and `App version` are
treated differently on purpose: `Page` is wrapped in `<...>`, CommonMark's
explicit autolink syntax, so it renders as a real clickable link — the same
construct that makes the reporter's email a link above. `App version` stays a
code span (`` ` ` ``, inert) because a bare hex string would otherwise be
autolinked by GitLab as a *commit* reference — resolved against the issue's own
project, i.e. the feedback tracker, not the app's repo.

Either line may be absent. The reporter line comes from the `require_user`
dependency and is omitted in Mode B (the issue is already authored by the user).
The version line appears only when `app_version` is set, and appears in **both**
modes — knowing which build a report came from matters regardless of who filed it:

```python
FeedbackConfig(..., app_version=settings.VERSION)
```

`page_url` is the one **client-supplied** value here, and it is off by default
(`include_page_url=False`). Turning it on publishes a URL the browser chose into
your tracker, so check first that your query strings and fragments carry no reset
tokens, invite codes or implicit-flow access tokens — or have the widget send a
sanitized value via its `pageUrl` prop. The router drops anything whose scheme is
not `http`/`https` (killing `javascript:` and `data:`), collapses whitespace so it
cannot add markdown lines, rejects values containing a backtick that would close
the code span, and caps the field at 2 KB. A value that fails those checks is
dropped rather than 400'd — a bad URL is not worth losing a report someone spent
time writing.

`app_version` is deliberately a config value rather than a form field. A
browser-sent version could be spoofed, and being able to trust it is the whole
point during triage. It is also not markdown-escaped, unlike the reporter name —
it comes from your own config, not from a user-settable profile field.

With neither line configured the body is left untouched, rather than growing a
bare horizontal rule.

## Type of request (optional)

**Off by default:** `request_types` is empty, nothing is required, and a
`request_type` sent by a widget anyway is ignored — so an existing deployment
upgrades without touching its frontend.

Once you populate it, `request_type` is what the reporter said their submission is.
The router validates it against `config.request_types`, applies the matching
option's `request::*` label alongside `labels`, and leads the issue body with its
title:

```markdown
**Type of request:** Something isn't working

<the reporter's description>
```

It is the reporter's own claim — not a verified classification — hence the
`request::*` scope rather than `type::*`, which stays the triage team's verdict.
`DEFAULT_REQUEST_TYPES` is a ready-made set mirroring the widget's exported one:

| value | title | label |
| --- | --- | --- |
| `bug` | Something isn't working | `request::bug` |
| `how-to` | I need help using the app | `request::how-to` |
| `access` | I need access to a system | `request::access` |
| `idea` | I have an idea or suggestion | `request::idea` |

```python
from pjdev_feedback import DEFAULT_REQUEST_TYPES, FeedbackConfig, RequestTypeOption

FeedbackConfig(
    ...,
    request_types=list(DEFAULT_REQUEST_TYPES),
    # ...or custom wording and your own label; `label` defaults to f"request::{value}":
    # request_types=[RequestTypeOption(value="ops", title="Ops request", label="team::ops")],
    require_request_type=True,   # default; False accepts submissions without one
)
```

Once enabled, a missing (when required) or unrecognized value is a **400**, so a
frontend whose `requestTypes` prop has drifted from this list fails loudly instead
of quietly filing unlabelled issues. Duplicate values raise `ValueError` at mount.

> **Enable it on both halves.** This list decides what is *accepted*; the widget's
> `requestTypes` prop decides what is *rendered*. Enabling only one side means every
> submission is a 400 — the backend rejecting a missing value, or the widget sending
> one the backend does not know. The `value`s must match on both sides.

## Environment variables (example app)

The bundled `example/app.py` reads:

- `FEEDBACK_GITLAB_URL` — e.g. `https://gitlab.example.com`
- `FEEDBACK_GITLAB_PROJECT_ID` — numeric id or `group/project` path
- `FEEDBACK_GITLAB_PROJECT_TOKEN` — access token with `api` scope (Mode A)

```bash
uvicorn example.app:app --reload
```

`example/oauth_app.py` shows the turnkey Mode B setup. Every variable below is
read with `os.environ[...]` and has no default — omitting any one of them raises
`KeyError` at import time:

- `FEEDBACK_GITLAB_URL` — as above
- `FEEDBACK_GITLAB_PROJECT_ID` — as above
- `FEEDBACK_OAUTH_CLIENT_ID` / `FEEDBACK_OAUTH_CLIENT_SECRET` — the GitLab OAuth application
- `FEEDBACK_OAUTH_REDIRECT_URI` — must match the application's registered callback exactly
- `FEEDBACK_SESSION_SECRET` — signing key for the Starlette session cookie
- `FEEDBACK_TOKEN_SECRET` — Fernet key encrypting the GitLab token at rest in that session

```bash
uvicorn example.oauth_app:app --reload
```

## Error mapping

| Condition | HTTP status |
| --- | --- |
| Local validation (bad `cid_map`, unknown/duplicate cid, too many files, missing/unknown `request_type` when enabled, title/description constraints) | 400 |
| Not authenticated (Mode A: `require_user` resolved `None`; Mode B: no GitLab token in session/store) | 401 |
| Attachment over the per-file or per-request byte cap; GitLab 413 | 413 |
| Missing config (Mode A without a project token; Mode B without `oauth` or `get_user_gitlab_token`) | 503 |
| Any other GitLab upstream error | 502 |

`GitlabUpstreamError(status_code, message)` is raised internally on upstream
failures; the SDK wraps retried HTTP errors in an `ExceptionGroup`, which the
service unwraps to find the last `httpx.HTTPStatusError`.

## Tests

`tests/test_feedback.py` mounts the router against a fake GitLab by
monkeypatching the SDK functions and drives it with `httpx.ASGITransport`.

```bash
./test.sh
```
