From 621fd12f6d39ea1a9d6698fe8ada5bff190a6694 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 16:46:17 -0400 Subject: [PATCH 1/2] BDMS-897: Add feedback endpoint for bug reports and feature requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /feedback, which accepts a bug report or feature request from any authenticated user and does two things: 1. Creates a Jira issue in the BDMS project using the Jira API v3 2. Optionally posts a Slack notification if SLACK_FEEDBACK_WEBHOOK_URL is set Bug reports create a Jira Bug issue with severity and a link to the page where the report was filed. Feature requests create a Jira Task with the problem description, intended audience, and expected behaviour. Slack notification is best-effort — if it fails the HTTP response still returns the Jira key and URL so the UI can confirm the report was filed. Three env vars are required (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN). JIRA_DEFAULT_PROJECT defaults to BDMS. SLACK_FEEDBACK_WEBHOOK_URL is optional and will be provided separately. --- .env.example | 8 ++ api/feedback.py | 267 +++++++++++++++++++++++++++++++++++++++++++ core/initializers.py | 2 + 3 files changed, 277 insertions(+) create mode 100644 api/feedback.py diff --git a/.env.example b/.env.example index 3f835882..27f624d4 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,11 @@ AUTHENTIK_TOKEN_URL= # middleware SESSION_SECRET_KEY=your_secret_key_here + +# feedback endpoint (POST /feedback) — bug reports and feature requests +JIRA_BASE_URL=https://nmbgmr.atlassian.net +JIRA_EMAIL=your_jira_email +JIRA_API_TOKEN=your_jira_api_token +JIRA_DEFAULT_PROJECT=BDMS +# Optional — Slack notifications are skipped if this is blank +SLACK_FEEDBACK_WEBHOOK_URL= diff --git a/api/feedback.py b/api/feedback.py new file mode 100644 index 00000000..68f632b2 --- /dev/null +++ b/api/feedback.py @@ -0,0 +1,267 @@ +import os +from datetime import datetime, timezone +from typing import Literal + +import httpx +from fastapi import APIRouter +from pydantic import BaseModel + +from core.dependencies import viewer_dependency + +router = APIRouter(prefix="/feedback", tags=["feedback"]) + + +class FeedbackCreate(BaseModel): + type: Literal["bug", "feature"] + page_url: str + reporter_name: str | None = None + reporter_email: str | None = None + browser: str | None = None + submitted_at: str | None = None + # Bug fields + what_happened: str | None = None + severity: str = "Low" + # Feature fields + problem: str | None = None + who_would_use: str | None = None + what_it_should_do: str | None = None + + +class FeedbackResponse(BaseModel): + jira_key: str + jira_url: str + + +def _build_jira_payload(payload: FeedbackCreate) -> dict: + project = os.environ.get("JIRA_DEFAULT_PROJECT", "BDMS") + + reporter_line = payload.reporter_name or payload.reporter_email or "Unknown" + submitted = payload.submitted_at or datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + + context_items = [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": f"Page: {payload.page_url}"}], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": f"Reported by: {reporter_line}"} + ], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"Browser: {payload.browser or 'Unknown'}", + } + ], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": f"Submitted: {submitted}"}], + } + ], + }, + ] + + if payload.type == "bug": + summary = f"Bug: {(payload.what_happened or '')[:80].strip()}" + issue_type = "Bug" + body_content = [ + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What happened"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.what_happened or ""}], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Severity"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.severity}], + }, + ] + priority_map = {"Low": "Low", "Medium": "Medium", "High": "High"} + priority = priority_map.get(payload.severity, "Medium") + else: + summary = f"Feature request: {(payload.problem or '')[:80].strip()}" + issue_type = "Task" + body_content = [ + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What problem does this solve?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.problem or ""}], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Who would use this?"}], + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": payload.who_would_use or "Not specified"} + ], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What should it do?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.what_it_should_do or ""}], + }, + ] + priority = "Medium" + + description = { + "type": "doc", + "version": 1, + "content": [ + *body_content, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Context"}], + }, + {"type": "bulletList", "content": context_items}, + ], + } + + return { + "fields": { + "project": {"key": project}, + "issuetype": {"name": issue_type}, + "summary": summary, + "description": description, + "priority": {"name": priority}, + } + } + + +def _build_slack_payload(payload: FeedbackCreate, jira_key: str, jira_url: str) -> dict: + reporter = payload.reporter_name or payload.reporter_email or "Unknown" + submitted = payload.submitted_at or datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + + if payload.type == "bug": + header = f"🐛 Bug report — {jira_key}" + description_text = payload.what_happened or "" + severity_field = {"type": "mrkdwn", "text": f"*Severity:*\n{payload.severity}"} + extra_fields = [severity_field] + else: + header = f"💡 Feature request — {jira_key}" + description_text = payload.problem or "" + extra_fields = [] + if payload.who_would_use: + extra_fields.append( + { + "type": "mrkdwn", + "text": f"*Who would use this:*\n{payload.who_would_use}", + } + ) + + blocks = [ + {"type": "header", "text": {"type": "plain_text", "text": header}}, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*Reporter:*\n{reporter}"}, + {"type": "mrkdwn", "text": f"*Submitted:*\n{submitted}"}, + {"type": "mrkdwn", "text": f"*Page:*\n{payload.page_url}"}, + *extra_fields, + ], + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": description_text[:2900]}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"<{jira_url}|View {jira_key} in JIRA →>", + }, + }, + ] + + return {"text": header, "blocks": blocks} + + +@router.post("", response_model=FeedbackResponse) +async def create_feedback( + payload: FeedbackCreate, + _user=viewer_dependency, +): + jira_base = os.environ["JIRA_BASE_URL"] + jira_email = os.environ["JIRA_EMAIL"] + jira_token = os.environ["JIRA_API_TOKEN"] + + async with httpx.AsyncClient() as client: + jira_resp = await client.post( + f"{jira_base}/rest/api/3/issue", + json=_build_jira_payload(payload), + auth=(jira_email, jira_token), + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=15, + ) + jira_resp.raise_for_status() + jira_data = jira_resp.json() + + jira_key = jira_data["key"] + jira_url = f"{jira_base}/browse/{jira_key}" + + slack_webhook = os.environ.get("SLACK_FEEDBACK_WEBHOOK_URL") + if slack_webhook: + try: + async with httpx.AsyncClient() as client: + await client.post( + slack_webhook, + json=_build_slack_payload(payload, jira_key, jira_url), + timeout=10, + ) + except Exception: + # Slack notification is best-effort — don't fail the request if it errors + pass + + return FeedbackResponse(jira_key=jira_key, jira_url=jira_url) + + +# ============= EOF ============================================= diff --git a/core/initializers.py b/core/initializers.py index 98da4e8e..356005d8 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -216,6 +216,7 @@ def register_api_routes(app): from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router + from api.feedback import router as feedback_router app.include_router(asset_router) app.include_router(author_router) @@ -231,6 +232,7 @@ def register_api_routes(app): app.include_router(search_router) app.include_router(thing_router) app.include_router(ngwmn_router) + app.include_router(feedback_router) add_pagination(app) app.state.api_routes_registered = True From dc82d4b917930dff569262861464f9397607ae73 Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 15:36:36 -0600 Subject: [PATCH 2/2] ci: inject feedback endpoint config from Google Secret Manager Fetch jira-email, jira-api-token, and slack-feedback-webhook-url from Secret Manager in all three deploy workflows and render them into the App Engine config alongside JIRA_BASE_URL / JIRA_DEFAULT_PROJECT repo vars (with sensible defaults). The deploy service account needs roles/secretmanager.secretAccessor on these secrets in each project. Co-Authored-By: Claude Fable 5 --- .github/app.template.yaml | 7 +++++++ .github/workflows/CD_production.yml | 17 +++++++++++++++++ .github/workflows/CD_staging.yml | 17 +++++++++++++++++ .github/workflows/CD_testing.yml | 17 +++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 619ba4cc..d3eb23ab 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -38,3 +38,10 @@ env_variables: SESSION_SECRET_KEY: |- ${SESSION_SECRET_KEY} APITALLY_CLIENT_ID: "${APITALLY_CLIENT_ID}" + JIRA_BASE_URL: "${JIRA_BASE_URL}" + JIRA_EMAIL: "${JIRA_EMAIL}" + JIRA_API_TOKEN: |- + ${JIRA_API_TOKEN} + JIRA_DEFAULT_PROJECT: "${JIRA_DEFAULT_PROJECT}" + SLACK_FEEDBACK_WEBHOOK_URL: |- + ${SLACK_FEEDBACK_WEBHOOK_URL} diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index e135876e..14812072 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -68,6 +68,18 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} + # Feedback endpoint credentials live in Google Secret Manager, not + # GitHub secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. + - name: Fetch feedback secrets from Secret Manager + id: feedback-secrets + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on production database env: DB_DRIVER: "cloudsql" @@ -117,6 +129,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" + JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" + JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 5b14854f..3a38dddb 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -36,6 +36,18 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} + # Feedback endpoint credentials live in Google Secret Manager, not + # GitHub secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. + - name: Fetch feedback secrets from Secret Manager + id: feedback-secrets + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on staging database env: DB_DRIVER: "cloudsql" @@ -85,6 +97,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" + JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" + JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-staging" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index ff58a2d3..6150195e 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -36,6 +36,18 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} + # Feedback endpoint credentials live in Google Secret Manager, not + # GitHub secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. + - name: Fetch feedback secrets from Secret Manager + id: feedback-secrets + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on staging database env: DB_DRIVER: "cloudsql" @@ -85,6 +97,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" + JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" + JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-testing"