Merge pull request 'feat: feedback button, cf-core env-var LLM config, mobile polish' (#14) from feature/orch-auto-lifecycle into main
This commit is contained in:
commit
63559dcdaa
13 changed files with 801 additions and 12 deletions
22
.env.example
22
.env.example
|
|
@ -21,6 +21,23 @@ DATA_DIR=./data
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
||||||
|
|
||||||
|
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
|
||||||
|
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
|
||||||
|
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
|
||||||
|
# CF_ORCH_URL=https://orch.circuitforge.tech
|
||||||
|
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
||||||
|
|
||||||
|
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
||||||
|
# LLMRouter checks these in priority order:
|
||||||
|
# 1. Anthropic cloud — set ANTHROPIC_API_KEY
|
||||||
|
# 2. OpenAI cloud — set OPENAI_API_KEY
|
||||||
|
# 3. Local Ollama — set OLLAMA_HOST (+ optionally OLLAMA_MODEL)
|
||||||
|
# All three are optional; leave unset to rely on a local llm.yaml instead.
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# OLLAMA_HOST=http://localhost:11434
|
||||||
|
# OLLAMA_MODEL=llama3.2
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
USE_GPU=true
|
USE_GPU=true
|
||||||
GPU_MEMORY_LIMIT=6144
|
GPU_MEMORY_LIMIT=6144
|
||||||
|
|
@ -53,3 +70,8 @@ DEMO_MODE=false
|
||||||
|
|
||||||
# Directus JWT (must match cf-directus SECRET env var)
|
# Directus JWT (must match cf-directus SECRET env var)
|
||||||
# DIRECTUS_JWT_SECRET=
|
# DIRECTUS_JWT_SECRET=
|
||||||
|
|
||||||
|
# In-app feedback → Forgejo issue creation
|
||||||
|
# FORGEJO_API_TOKEN=
|
||||||
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
|
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
|
|
|
||||||
7
.gitleaksignore
Normal file
7
.gitleaksignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Findings suppressed here are historical false positives or already-rotated secrets.
|
||||||
|
# .env was accidentally included in the initial commit; it is now gitignored.
|
||||||
|
# Rotate DIRECTUS_JWT_SECRET if it has not been changed since 2026-03-30.
|
||||||
|
|
||||||
|
# c166e5216 (chore: initial commit) — .env included by mistake
|
||||||
|
c166e5216af532a08112ef87e8542cd51c184115:.env:generic-api-key:25
|
||||||
|
c166e5216af532a08112ef87e8542cd51c184115:.env:cf-generic-env-token:25
|
||||||
169
app/api/endpoints/feedback.py
Normal file
169
app/api/endpoints/feedback.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""
|
||||||
|
Feedback endpoint — creates Forgejo issues from in-app feedback.
|
||||||
|
Ported from peregrine/scripts/feedback_api.py; adapted for Kiwi context.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
# ── Forgejo helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_LABEL_COLORS = {
|
||||||
|
"beta-feedback": "#0075ca",
|
||||||
|
"needs-triage": "#e4e669",
|
||||||
|
"bug": "#d73a4a",
|
||||||
|
"feature-request": "#a2eeef",
|
||||||
|
"question": "#d876e3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_headers() -> dict:
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_labels(label_names: list[str]) -> list[int]:
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||||
|
headers = _forgejo_headers()
|
||||||
|
resp = requests.get(f"{base}/repos/{repo}/labels", headers=headers, timeout=10)
|
||||||
|
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
|
||||||
|
ids: list[int] = []
|
||||||
|
for name in label_names:
|
||||||
|
if name in existing:
|
||||||
|
ids.append(existing[name])
|
||||||
|
else:
|
||||||
|
r = requests.post(
|
||||||
|
f"{base}/repos/{repo}/labels",
|
||||||
|
headers=headers,
|
||||||
|
json={"name": name, "color": _LABEL_COLORS.get(name, "#ededed")},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
ids.append(r.json()["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_context(tab: str) -> dict:
|
||||||
|
"""Collect lightweight app context: tab, version, platform, timestamp."""
|
||||||
|
try:
|
||||||
|
version = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--always"],
|
||||||
|
cwd=_ROOT, text=True, timeout=5,
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
version = "dev"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tab": tab,
|
||||||
|
"version": version,
|
||||||
|
"demo_mode": settings.DEMO_MODE,
|
||||||
|
"cloud_mode": settings.CLOUD_MODE,
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_issue_body(form: dict, context: dict) -> str:
|
||||||
|
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
|
||||||
|
lines: list[str] = [
|
||||||
|
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
|
||||||
|
"",
|
||||||
|
form.get("description", ""),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if form.get("type") == "bug" and form.get("repro"):
|
||||||
|
lines += ["### Reproduction Steps", "", form["repro"], ""]
|
||||||
|
|
||||||
|
lines += ["### Context", ""]
|
||||||
|
for k, v in context.items():
|
||||||
|
lines.append(f"- **{k}:** {v}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if form.get("submitter"):
|
||||||
|
lines += ["---", f"*Submitted by: {form['submitter']}*"]
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FeedbackRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: Literal["bug", "feature", "other"] = "other"
|
||||||
|
repro: str = ""
|
||||||
|
tab: str = "unknown"
|
||||||
|
submitter: str = "" # optional "Name <email>" attribution
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackResponse(BaseModel):
|
||||||
|
issue_number: int
|
||||||
|
issue_url: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def feedback_status() -> dict:
|
||||||
|
"""Return whether feedback submission is configured on this instance."""
|
||||||
|
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FeedbackResponse)
|
||||||
|
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||||
|
"""
|
||||||
|
File a Forgejo issue from in-app feedback.
|
||||||
|
Silently disabled when FORGEJO_API_TOKEN is not set (demo/offline mode).
|
||||||
|
"""
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.",
|
||||||
|
)
|
||||||
|
if settings.DEMO_MODE:
|
||||||
|
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
|
||||||
|
|
||||||
|
context = _collect_context(payload.tab)
|
||||||
|
form = {
|
||||||
|
"type": payload.type,
|
||||||
|
"description": payload.description,
|
||||||
|
"repro": payload.repro,
|
||||||
|
"submitter": payload.submitter,
|
||||||
|
}
|
||||||
|
body = _build_issue_body(form, context)
|
||||||
|
labels = ["beta-feedback", "needs-triage"]
|
||||||
|
labels.append({"bug": "bug", "feature": "feature-request"}.get(payload.type, "question"))
|
||||||
|
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||||
|
headers = _forgejo_headers()
|
||||||
|
|
||||||
|
label_ids = _ensure_labels(labels)
|
||||||
|
resp = requests.post(
|
||||||
|
f"{base}/repos/{repo}/issues",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": payload.title, "body": body, "labels": label_ids},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -10,4 +10,5 @@ api_router.include_router(export.router, tags=["export"
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
|
|
@ -46,6 +46,10 @@ class Settings:
|
||||||
# CF-core resource coordinator (VRAM lease management)
|
# CF-core resource coordinator (VRAM lease management)
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
|
|
||||||
|
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
||||||
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Kiwi — Pantry Tracker</title>
|
<title>Kiwi — Pantry Tracker</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
@ -11,6 +11,18 @@
|
||||||
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
<!-- Anti-FOUC: critical layout CSS inline so it's available before the JS bundle.
|
||||||
|
Without this, the sidebar flashes visible on mobile for ~100ms while the
|
||||||
|
bundle hydrates and injects component styles. -->
|
||||||
|
<style>
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.bottom-nav { display: flex; }
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.sidebar { display: flex; flex-direction: column; }
|
||||||
|
.bottom-nav { display: none; }
|
||||||
|
.app-body { display: flex; flex-direction: column; flex: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,9 @@
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
||||||
|
<FeedbackButton :current-tab="currentTab" />
|
||||||
|
|
||||||
<!-- Easter egg: Kiwi bird sprite — triggered by typing "kiwi" -->
|
<!-- Easter egg: Kiwi bird sprite — triggered by typing "kiwi" -->
|
||||||
<Transition name="kiwi-fade">
|
<Transition name="kiwi-fade">
|
||||||
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
|
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
|
||||||
|
|
@ -153,6 +156,7 @@ import InventoryList from './components/InventoryList.vue'
|
||||||
import ReceiptsView from './components/ReceiptsView.vue'
|
import ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
import RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
|
|
||||||
|
|
@ -220,7 +224,6 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-bottom: 68px; /* bottom nav clearance */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
|
|
@ -247,6 +250,8 @@ body {
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--spacing-md) 0 var(--spacing-xl);
|
padding: var(--spacing-md) 0 var(--spacing-xl);
|
||||||
|
/* Clear fixed bottom nav — env() gives extra room for iPhone home bar */
|
||||||
|
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|
@ -324,7 +329,10 @@ body {
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.container { padding: 0 var(--spacing-sm); }
|
.container { padding: 0 var(--spacing-sm); }
|
||||||
.app-main { padding: var(--spacing-sm) 0 var(--spacing-lg); }
|
.app-main {
|
||||||
|
padding: var(--spacing-sm) 0 var(--spacing-lg);
|
||||||
|
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|
|
||||||
413
frontend/src/components/FeedbackButton.vue
Normal file
413
frontend/src/components/FeedbackButton.vue
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
<template>
|
||||||
|
<!-- Floating trigger button -->
|
||||||
|
<button
|
||||||
|
v-if="enabled"
|
||||||
|
class="feedback-fab"
|
||||||
|
@click="open = true"
|
||||||
|
aria-label="Send feedback or report a bug"
|
||||||
|
title="Send feedback or report a bug"
|
||||||
|
>
|
||||||
|
<svg class="feedback-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="feedback-fab-label">Feedback</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modal — teleported to body to avoid z-index / overflow clipping -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="open" class="feedback-overlay" @click.self="close">
|
||||||
|
<div class="feedback-modal" role="dialog" aria-modal="true" aria-label="Send Feedback">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="feedback-header">
|
||||||
|
<h2 class="feedback-title">{{ step === 1 ? "What's on your mind?" : "Review & submit" }}</h2>
|
||||||
|
<button class="feedback-close" @click="close" aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 1: Form ─────────────────────────────────────────── -->
|
||||||
|
<div v-if="step === 1" class="feedback-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<div class="filter-chip-row">
|
||||||
|
<button
|
||||||
|
v-for="t in types"
|
||||||
|
:key="t.value"
|
||||||
|
:class="['btn-chip', { active: form.type === t.value }]"
|
||||||
|
@click="form.type = t.value"
|
||||||
|
type="button"
|
||||||
|
>{{ t.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Title <span class="form-required">*</span></label>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Short summary of the issue or idea"
|
||||||
|
maxlength="120"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description <span class="form-required">*</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-input feedback-textarea"
|
||||||
|
placeholder="Describe what happened or what you'd like to see…"
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.type === 'bug'" class="form-group">
|
||||||
|
<label class="form-label">Reproduction steps</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.repro"
|
||||||
|
class="form-input feedback-textarea"
|
||||||
|
placeholder="1. Go to… 2. Tap… 3. See error"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 2: Attribution + confirm ──────────────────────────── -->
|
||||||
|
<div v-if="step === 2" class="feedback-body">
|
||||||
|
<div class="feedback-summary card">
|
||||||
|
<div class="feedback-summary-row">
|
||||||
|
<span class="text-muted text-sm">Type</span>
|
||||||
|
<span class="text-sm font-semibold">{{ typeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-summary-row">
|
||||||
|
<span class="text-muted text-sm">Title</span>
|
||||||
|
<span class="text-sm">{{ form.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-summary-row">
|
||||||
|
<span class="text-muted text-sm">Description</span>
|
||||||
|
<span class="text-sm feedback-summary-desc">{{ form.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-md">
|
||||||
|
<label class="form-label">Attribution (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="form.submitter"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name <email@example.com>"
|
||||||
|
/>
|
||||||
|
<p class="text-muted text-xs mt-xs">Include your name and email in the issue if you'd like a response. Never required.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
|
||||||
|
<div v-if="submitted" class="feedback-success">
|
||||||
|
Issue filed! <a :href="issueUrl" target="_blank" rel="noopener" class="feedback-link">View on Forgejo →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="feedback-footer">
|
||||||
|
<button v-if="step === 2 && !submitted" class="btn btn-ghost" @click="step = 1" :disabled="loading">← Back</button>
|
||||||
|
<button v-if="!submitted" class="btn btn-ghost" @click="close" :disabled="loading">Cancel</button>
|
||||||
|
<button
|
||||||
|
v-if="step === 1"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="nextStep"
|
||||||
|
>Next →</button>
|
||||||
|
<button
|
||||||
|
v-if="step === 2 && !submitted"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
>{{ loading ? 'Filing…' : 'Submit' }}</button>
|
||||||
|
<button v-if="submitted" class="btn btn-primary" @click="close">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ currentTab?: string }>()
|
||||||
|
|
||||||
|
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
||||||
|
const enabled = ref(false)
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/feedback/status')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
enabled.value = data.enabled === true
|
||||||
|
}
|
||||||
|
} catch { /* network error — stay hidden */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const step = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const stepError = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
const issueUrl = ref('')
|
||||||
|
|
||||||
|
const types: { value: 'bug' | 'feature' | 'other'; label: string }[] = [
|
||||||
|
{ value: 'bug', label: '🐛 Bug' },
|
||||||
|
{ value: 'feature', label: '✨ Feature request' },
|
||||||
|
{ value: 'other', label: '💬 Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
type: 'bug' as 'bug' | 'feature' | 'other',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
repro: '',
|
||||||
|
submitter: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open.value = false
|
||||||
|
// reset after transition
|
||||||
|
setTimeout(reset, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
step.value = 1
|
||||||
|
loading.value = false
|
||||||
|
stepError.value = ''
|
||||||
|
submitError.value = ''
|
||||||
|
submitted.value = false
|
||||||
|
issueUrl.value = ''
|
||||||
|
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
stepError.value = ''
|
||||||
|
if (!form.value.title.trim() || !form.value.description.trim()) {
|
||||||
|
stepError.value = 'Please fill in both Title and Description.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
description: form.value.description.trim(),
|
||||||
|
type: form.value.type,
|
||||||
|
repro: form.value.repro.trim(),
|
||||||
|
tab: props.currentTab ?? 'unknown',
|
||||||
|
submitter: form.value.submitter.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||||
|
submitError.value = err.detail ?? 'Submission failed.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
issueUrl.value = data.issue_url
|
||||||
|
submitted.value = true
|
||||||
|
} catch (e) {
|
||||||
|
submitError.value = 'Network error — please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Floating action button ─────────────────────────────────────────── */
|
||||||
|
.feedback-fab {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--spacing-md);
|
||||||
|
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
|
||||||
|
z-index: 190;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: 9px var(--spacing-md);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.feedback-fab:hover {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-border-focus);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||||
|
.feedback-fab-label { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* On desktop, bottom nav is gone — drop to standard corner */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.feedback-fab {
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlay ──────────────────────────────────────────────────────────── */
|
||||||
|
.feedback-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.feedback-overlay {
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ────────────────────────────────────────────────────────────── */
|
||||||
|
.feedback-modal {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.feedback-modal {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.feedback-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.feedback-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.feedback-close:hover { color: var(--color-text-primary); }
|
||||||
|
|
||||||
|
.feedback-body {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-required { color: var(--color-error); margin-left: 2px; }
|
||||||
|
|
||||||
|
.feedback-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border: 1px solid var(--color-success-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Summary card (step 2) */
|
||||||
|
.feedback-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.feedback-summary-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
|
||||||
|
.feedback-summary-desc {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-md { margin-top: var(--spacing-md); }
|
||||||
|
.mt-xs { margin-top: var(--spacing-xs); }
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
||||||
|
</style>
|
||||||
|
|
@ -507,10 +507,12 @@ const manualLoading = ref(false)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.fetchItems()
|
await store.fetchItems()
|
||||||
await store.fetchStats()
|
await store.fetchStats()
|
||||||
// Auto-focus scanner gun input
|
// Auto-focus scanner gun input — desktop only (avoids popping mobile keyboard)
|
||||||
setTimeout(() => {
|
if (!('ontouchstart' in window)) {
|
||||||
scannerGunInput.value?.focus()
|
setTimeout(() => {
|
||||||
}, 100)
|
scannerGunInput.value?.focus()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function onFilterChange() {
|
function onFilterChange() {
|
||||||
|
|
@ -762,7 +764,8 @@ function getItemClass(item: InventoryItem): string {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
padding: var(--spacing-xs) 0 var(--spacing-xl);
|
padding: var(--spacing-xs) 0 0;
|
||||||
|
overflow-x: hidden; /* prevent item rows from expanding page width on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|
@ -1231,6 +1234,16 @@ function getItemClass(item: InventoryItem): string {
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mode toggle fills the card width when header stacks */
|
||||||
|
.scan-mode-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.scan-meta-row {
|
.scan-meta-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -1257,6 +1270,31 @@ function getItemClass(item: InventoryItem): string {
|
||||||
.inv-actions {
|
.inv-actions {
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prevent right section from blowing out row width on narrow screens */
|
||||||
|
.inv-row-right {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shrink action buttons slightly on mobile */
|
||||||
|
.inv-row-right .btn-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very narrow phones (360px and below): hide mode button labels, keep icons */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.scan-mode-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-mode-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 481px) and (max-width: 768px) {
|
@media (min-width: 481px) and (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden; /* prevent any element from expanding the mobile viewport */
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,8 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: var(--spacing-xs);
|
padding-bottom: var(--spacing-xs);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
min-width: 0; /* allow flex item to shrink below content; lets overflow-x scroll internally */
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip-row::-webkit-scrollbar {
|
.filter-chip-row::-webkit-scrollbar {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ dependencies = [
|
||||||
"numpy>=1.25",
|
"numpy>=1.25",
|
||||||
"pyzbar>=0.1.9",
|
"pyzbar>=0.1.9",
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
# HTTP client
|
# HTTP clients
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"requests>=2.31",
|
||||||
# CircuitForge shared scaffold
|
# CircuitForge shared scaffold
|
||||||
"circuitforge-core",
|
"circuitforge-core>=0.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|
|
||||||
111
tests/api/test_feedback.py
Normal file
111
tests/api/test_feedback.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Tests for the /feedback endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ── /feedback/status ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_status_disabled_when_no_token(monkeypatch):
|
||||||
|
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||||
|
res = client.get("/api/v1/feedback/status")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_enabled_when_token_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||||
|
res = client.get("/api/v1/feedback/status")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_disabled_in_demo_mode(monkeypatch):
|
||||||
|
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||||
|
res = client.get("/api/v1/feedback/status")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /feedback ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_submit_returns_503_when_no_token(monkeypatch):
|
||||||
|
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||||
|
res = client.post("/api/v1/feedback", json={
|
||||||
|
"title": "Test", "description": "desc", "type": "bug",
|
||||||
|
})
|
||||||
|
assert res.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_returns_403_in_demo_mode(monkeypatch):
|
||||||
|
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||||
|
res = client.post("/api/v1/feedback", json={
|
||||||
|
"title": "Test", "description": "desc", "type": "bug",
|
||||||
|
})
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_creates_issue(monkeypatch):
|
||||||
|
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||||
|
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||||
|
|
||||||
|
# Mock the two Forgejo HTTP calls: label fetch + issue create
|
||||||
|
label_response = MagicMock()
|
||||||
|
label_response.ok = True
|
||||||
|
label_response.json.return_value = [
|
||||||
|
{"id": 1, "name": "beta-feedback"},
|
||||||
|
{"id": 2, "name": "needs-triage"},
|
||||||
|
{"id": 3, "name": "bug"},
|
||||||
|
]
|
||||||
|
|
||||||
|
issue_response = MagicMock()
|
||||||
|
issue_response.ok = True
|
||||||
|
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||||
|
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
|
||||||
|
res = client.post("/api/v1/feedback", json={
|
||||||
|
"title": "Something broke",
|
||||||
|
"description": "It broke when I tapped X",
|
||||||
|
"type": "bug",
|
||||||
|
"repro": "1. Open app\n2. Tap X",
|
||||||
|
"tab": "pantry",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["issue_number"] == 42
|
||||||
|
assert data["issue_url"] == "https://example.com/issues/42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_returns_502_on_forgejo_error(monkeypatch):
|
||||||
|
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||||
|
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||||
|
|
||||||
|
label_response = MagicMock()
|
||||||
|
label_response.ok = True
|
||||||
|
label_response.json.return_value = []
|
||||||
|
|
||||||
|
bad_response = MagicMock()
|
||||||
|
bad_response.ok = False
|
||||||
|
bad_response.text = "forbidden"
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||||
|
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
|
||||||
|
res = client.post("/api/v1/feedback", json={
|
||||||
|
"title": "Oops", "description": "desc", "type": "other",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert res.status_code == 502
|
||||||
Loading…
Reference in a new issue