Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

pjdev-feedback

PyPI - Version PyPI - Python Version

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

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 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:

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:

{ "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_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:

---
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 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 values 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)
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
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

pjdev_feedback-0.3.0b1.tar.gz (56.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pjdev_feedback-0.3.0b1-py3-none-any.whl (39.2 kB view details)

Uploaded Python 3

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

Hashes for pjdev_feedback-0.3.0b1.tar.gz
Algorithm Hash digest
SHA256 47a2232e97a4f1dce871f1e7b71b25985d3ac46a6792a29114402a58e104f45e
MD5 334979a17f6b8187450bc7a79e98833d
BLAKE2b-256 32e184c991ae2c22fdc3bae4cf6e3f426aa90689cdc036b9ad5433dd297fd408

See more details on using hashes here.

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

Hashes for pjdev_feedback-0.3.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 71c3640bf767f81e3a4a2e955b0a9e7868c1c92bb819a684c070224f0fb25a09
MD5 c8f743e9dc7a72cf8a6c6f7035d8f017
BLAKE2b-256 0cc0cc9dac965143b62aec8444c751e48e08e784af7ace5bd04d954e884fe893

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.0b1 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page