Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
7 changes: 7 additions & 0 deletions .github/app.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
17 changes: 17 additions & 0 deletions .github/workflows/CD_production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/CD_staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/CD_testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
267 changes: 267 additions & 0 deletions api/feedback.py
Original file line number Diff line number Diff line change
@@ -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 =============================================
2 changes: 2 additions & 0 deletions core/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
Loading