Metadata-Version: 2.5
Name: pjdev-feedback
Version: 0.2.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 distinct file
            max_total_bytes=8 * 1024 * 1024,       # per request, distinct files
            max_files=20,                          # multipart parts
            max_refs=50,                           # cid_map entries
        )
    )
)
```

## 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: index}` — a **0-based index into `files`**, as a JSON number (`{"a": 0}`, never `{"a": "0"}`). Default `"{}"` |
| `files` | files | the attachments, in the order `cid_map` indexes refer to. Every part carries its own display filename, which becomes the upload name on GitLab |

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" }
```

## Attachment references

> **Breaking in 0.2.0.** `cid_map` values changed from display filenames to
> indexes. This needs a widget release that posts the index form; an older
> widget gets a 400 on every submission that carries an attachment. There is no
> transitional acceptance of the old shape — see the end of this section.

`cid_map` values are **indexes into `files`, not filenames**. Filenames cannot
identify a part unambiguously: two captures may share a basename, and an editor
mints a fresh cid each time the reporter embeds the *same* file. Both parts do
arrive on the wire intact; it was the server that could not tell which one a cid
meant, so the old filename-keyed form silently dropped the losing part and
uploaded it as zero bytes. Indexes are unique by construction.

Part filenames are still sent and still matter — they are the upload name on the
GitLab side — but they are no longer *identifiers*.

Several cids may share one index, and that is the intended encoding of "the same
image embedded twice":

```
description: "![a](cid:a) and again ![b](cid:b)"
cid_map:     {"a": 0, "b": 0}
files:       [shot.png]
```

That file is uploaded **once** and charged **once** against `max_total_bytes`,
and both placeholders resolve to the same reference. It does not collapse to one
reference, though: each cid counts against `max_refs`. That asymmetry is why the
two counts are capped separately, and why `max_refs` (50) is set higher than
`max_files` (20).

A part nothing references is accepted, never uploaded, and still counts toward
`max_files` — it was received and buffered, so it is not free.

**400 is a count, 413 is a size.** They are easy to conflate, and a client that
renders every rejection as "your files are too large" says the wrong thing when
the real problem is too many of them. The widget intercepts 413 and substitutes
exactly that sentence, which is why the count caps are deliberately not 413.

The 400 `detail` strings are **user-facing copy**, not developer diagnostics —
the widget renders them verbatim in its alert banner:

| Condition | `detail` |
| --- | --- |
| `cid_map` is not valid JSON, or not a JSON object | `cid_map must be a JSON object of {cid: file index}` |
| More than `max_files` parts | `A submission may carry at most N files. Remove some attachments and try again.` |
| More than `max_refs` entries | `A submission may reference at most N attachments. Remove some embedded images or files and try again.` |
| A part with an empty filename | `All uploaded files must have a filename` |
| A key that is empty, over 64 chars, or outside `[A-Za-z0-9_-]` | `cid_map keys must be 1-64 characters of letters, numbers, hyphen or underscore` |
| A **string** value (the old contract) | `This page is running an outdated version of the app. Reloading will clear this form, so copy your description first, then reload, re-attach your files and submit again.` |
| A boolean, float or other non-integer value | `cid_map values must be whole numbers indexing the uploaded files` |
| An index that is negative or `>= len(files)` | `cid_map references a missing file: index N (only M file(s) were uploaded)` |

A string value is rejected rather than interpreted: a filename branch would
restore the exact ambiguity the contract removes. It gets its own message
because that reporter did nothing wrong — they are holding a stale bundle, and
they are usually mid-report with a screenshot they just spent a minute
capturing. The warning comes *before* the word "reload" on purpose: reloading
also discards the description they typed, so naming that first turns the message
into a copy-paste instead of an apology.

## 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 `<`, `>` and backtick — any of which would
truncate or break the `<...>` autolink it renders as — 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: malformed `cid_map`, a non-integer or out-of-range index, a malformed cid key, a part without a filename, more than `max_files` parts, more than `max_refs` references, a missing or 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 |
| A distinct file over `max_attachment_bytes`, or the distinct files together over `max_total_bytes` (a file shared by several cids is charged once); 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
```
