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

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, 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.

./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.2.0b1.tar.gz (48.7 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.2.0b1-py3-none-any.whl (34.7 kB view details)

Uploaded Python 3

File details

Details for the file pjdev_feedback-0.2.0b1.tar.gz.

File metadata

  • Download URL: pjdev_feedback-0.2.0b1.tar.gz
  • Upload date:
  • Size: 48.7 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.2.0b1.tar.gz
Algorithm Hash digest
SHA256 93709dae587679840ee4a0b48cafc2da12f577903a0286becbd509126aafc462
MD5 98567f506a3514100974080b6c6d6f9b
BLAKE2b-256 856639a732ff1e1a7323b6845d9655c4129c11b09d7f14dd2b639b6bfb6c6344

See more details on using hashes here.

File details

Details for the file pjdev_feedback-0.2.0b1-py3-none-any.whl.

File metadata

  • Download URL: pjdev_feedback-0.2.0b1-py3-none-any.whl
  • Upload date:
  • Size: 34.7 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.2.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 4dfb3bcd8bced28a9f44e904a2a1fc6b74cc3bdc96de82fab221ebcdea05b1ee
MD5 c3df64cd40f96df67e6c3e7f87165025
BLAKE2b-256 a80bd3dc15beb9716b62a161e1e5f4177e1ab528ff4f91bc56089c6de58b10d7

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.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