This release is a pre-release and may not be stable for production use.
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 readsconfig_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_routertherefore callsconfig_service.init()at mount time if, and only if, nothing else has: an app that already initializes the SDK keeps its own settings.
Install
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.
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).
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.
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 postMessages 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:
<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
401the 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:
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  |
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  placeholder is replaced with the markdown reference
GitLab returns after the matching file is uploaded. Response:
{ "issue_iid": 42, "web_url": "https://gitlab.example.com/group/project/-/issues/42" }
Deferred filing — the on_submission sink
By default the router files the issue inside the request and answers 201. If GitLab is down, that is a 502 and the report is gone — along with the screenshot the reporter just spent a minute capturing.
Set on_submission and the router instead does all the parsing and validation,
hands you the result, and answers 202 with the id you return. GitLab is never
contacted on this path; filing it later is your job, which is what lets a
submission survive an outage.
async def feedback_sink(db: Annotated[AsyncSession, Depends(get_session)]) -> FeedbackSink:
# The outer function is what the package `Depends()` on, which is the only
# reason the sink can reach a request-scoped session at all.
async def _enqueue(submission: ParsedFeedbackSubmission) -> int:
row = await my_queue.enqueue(
db,
title=submission.title,
description_markdown=submission.description_markdown,
uploads=submission.uploads,
cids_by_index=submission.cids_by_index,
reporter=submission.auth_user,
)
return row.id
return _enqueue
FeedbackConfig(..., on_submission=feedback_sink)
Response: {"status": "queued", "submission_id": 17}. The widget accepts this or
FeedbackIssueResponse and nothing else, so a 2xx with any other body — an empty
201, a 204, an HTML body from a proxy — is treated as a failure.
What the sink is handed
ParsedFeedbackSubmission carries only values that have already passed every
check the inline path applies, so you may persist all of it verbatim.
| Field | Guarantee |
|---|---|
title |
1..255 characters |
description_markdown |
Already quick-action escaped (see below) |
request_type |
A configured RequestTypeOption, or None when the field is off |
page_url |
Sanitized and gated on include_page_url; None when off |
uploads |
Every part in wire order, streams unread |
cids_by_index |
{index into uploads: [cid, ...]}, validated against the parts |
reporter |
Reporter(name, email), or None |
auth_user |
The raw require_user result, untouched |
reporter and auth_user both exist because they answer different questions.
Reporter is two strings, which is all the package's own footer needs. A host
storing a row with a foreign key to its user table needs the object its own
dependency returned — an id cannot be recovered from a name and an email.
The body arrives escaped because there is no later moment where escaping
would still be correct. You will substitute the cid: placeholders when you
file, in another process, with references GitLab itself returned — and those must
stay verbatim, so the escape has to have happened first. The one visible
consequence is that a stored body shows \/close where the reporter typed
/close; escaping is idempotent, so re-escaping on the way out is harmless.
Caps on this path
max_files and max_refs are enforced as usual, before a byte is read.
max_attachment_bytes and max_total_bytes are inert — they are enforced
while streaming to GitLab, and this path never reads a stream, since doing so
would consume it before your sink could. Byte accounting is yours.
An HTTPException your sink raises reaches the client unchanged rather than
being mapped to a 502: a storage failure is not an upstream failure.
mode="oauth" is refused at mount time with a sink. The issue would be authored
with the signed-in user's OAuth token, which exists only for the life of the
request, so deferred filing would have no credential left to file with.
Attachment references
Breaking in 0.2.0.
cid_mapvalues 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: " and again "
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:
---
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:
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:
**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 |
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
requestTypesprop 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. Thevalues must match on both sides.
Environment variables (example app)
The bundled example/app.py reads:
FEEDBACK_GITLAB_URL— e.g.https://gitlab.example.comFEEDBACK_GITLAB_PROJECT_ID— numeric id orgroup/projectpathFEEDBACK_GITLAB_PROJECT_TOKEN— access token withapiscope (Mode A)
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 aboveFEEDBACK_GITLAB_PROJECT_ID— as aboveFEEDBACK_OAUTH_CLIENT_ID/FEEDBACK_OAUTH_CLIENT_SECRET— the GitLab OAuth applicationFEEDBACK_OAUTH_REDIRECT_URI— must match the application's registered callback exactlyFEEDBACK_SESSION_SECRET— signing key for the Starlette session cookieFEEDBACK_TOKEN_SECRET— Fernet key encrypting the GitLab token at rest in that session
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 |
400 |
Title or description outside its length bounds — a FastAPI Form constraint, so detail is a list, not a string. The widget renders a string detail verbatim and falls back to generic copy otherwise, and its own client-side caps make this unreachable through the widget |
422 |
An HTTPException raised by your on_submission sink |
unchanged |
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.
./test.sh
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pjdev_feedback-0.3.0b1.tar.gz.
File metadata
- Download URL: pjdev_feedback-0.3.0b1.tar.gz
- Upload date:
- Size: 56.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
Hatch/1.18.0 {"ci":true,"cpu":"aarch64","distro":{"id":"noble","libc":{"lib":"glibc","version":"2.39"},"name":"Ubuntu","version":"24.04"},"implementation":{"name":"CPython","version":"3.12.3"},"installer":{"name":"hatch","version":"1.18.0"},"openssl_version":"OpenSSL 3.0.13 30 Jan 2024","python":"3.12.3","system":{"name":"Linux","release":"7.0.12-linuxkit"}} HTTPX2/2.12.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
47a2232e97a4f1dce871f1e7b71b25985d3ac46a6792a29114402a58e104f45e
|
|
| MD5 |
334979a17f6b8187450bc7a79e98833d
|
|
| BLAKE2b-256 |
32e184c991ae2c22fdc3bae4cf6e3f426aa90689cdc036b9ad5433dd297fd408
|
File details
Details for the file pjdev_feedback-0.3.0b1-py3-none-any.whl.
File metadata
- Download URL: pjdev_feedback-0.3.0b1-py3-none-any.whl
- Upload date:
- Size: 39.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
Hatch/1.18.0 {"ci":true,"cpu":"aarch64","distro":{"id":"noble","libc":{"lib":"glibc","version":"2.39"},"name":"Ubuntu","version":"24.04"},"implementation":{"name":"CPython","version":"3.12.3"},"installer":{"name":"hatch","version":"1.18.0"},"openssl_version":"OpenSSL 3.0.13 30 Jan 2024","python":"3.12.3","system":{"name":"Linux","release":"7.0.12-linuxkit"}} HTTPX2/2.12.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71c3640bf767f81e3a4a2e955b0a9e7868c1c92bb819a684c070224f0fb25a09
|
|
| MD5 |
c8f743e9dc7a72cf8a6c6f7035d8f017
|
|
| BLAKE2b-256 |
0cc0cc9dac965143b62aec8444c751e48e08e784af7ace5bd04d954e884fe893
|