Compare commits

...

5 commits

Author SHA1 Message Date
7672dd758a fix: self-hosted install — network_mode, cf-core bind mount, install script 2026-04-05 22:02:50 -07:00
663d92fc11 refactor: use shorter circuitforge_core.api import for feedback router 2026-04-05 21:21:54 -07:00
c2fa107c47 fix: use correct tab field name in feedback test 2026-04-05 20:50:13 -07:00
1ca9398df4 refactor: move feedback router import to top-level block 2026-04-05 18:45:16 -07:00
5a3f9cb460 feat: migrate feedback endpoint to circuitforge-core router
Replaces the inline feedback block (FeedbackRequest/FeedbackResponse
models, _fb_headers, _ensure_feedback_labels, status + submit routes)
with make_feedback_router() from circuitforge-core. Removes now-unused
imports (_requests, _platform, Literal, subprocess, datetime/timezone).
Adds 7 tests covering status + submit paths via TestClient.
2026-04-05 18:18:25 -07:00
7 changed files with 293 additions and 135 deletions

View file

@ -4,6 +4,50 @@
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next. **Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
## Quick install (self-hosted)
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
```bash
# One-line install — clones to ~/snipe by default
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
# Or clone manually and run the script:
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
bash snipe/install.sh
```
Then open **http://localhost:8509**.
### Manual setup (if you prefer)
Snipe's API image is built from a parent context that includes `circuitforge-core`. Both repos must sit as siblings in the same directory:
```
workspace/
├── snipe/ ← this repo
└── circuitforge-core/ ← required sibling
```
```bash
mkdir snipe-workspace && cd snipe-workspace
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
cd snipe
cp .env.example .env # edit if you have eBay API credentials (optional)
./manage.sh start
```
### Optional: eBay API credentials
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and inline seller account age (no extra scrape needed):
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
2. Copy your Production **App ID** and **Cert ID** into `.env`
3. Restart: `./manage.sh restart`
---
## What it does ## What it does
Snipe has two layers that work together: Snipe has two layers that work together:

View file

@ -11,12 +11,7 @@ from pathlib import Path
import csv import csv
import io import io
import platform as _platform
import subprocess
from datetime import datetime, timezone
from typing import Literal
import requests as _requests
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -24,6 +19,7 @@ from fastapi.middleware.cors import CORSMiddleware
from circuitforge_core.config import load_env from circuitforge_core.config import load_env
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
from circuitforge_core.api import make_feedback_router as _make_feedback_router
from app.db.store import Store from app.db.store import Store
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
from app.platforms import SearchFilters from app.platforms import SearchFilters
@ -81,6 +77,12 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
_feedback_router = _make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
)
app.include_router(_feedback_router, prefix="/api/feedback")
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
@ -645,117 +647,3 @@ async def import_blocklist(
return {"imported": imported, "errors": errors} return {"imported": imported, "errors": errors}
# ── Feedback ────────────────────────────────────────────────────────────────
# Creates Forgejo issues from in-app beta feedback.
# Silently disabled when FORGEJO_API_TOKEN is not set.
_FEEDBACK_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _fb_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_feedback_labels(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/snipe")
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in names:
if name in existing:
ids.append(existing[name])
else:
r = _requests.post(
f"{base}/repos/{repo}/labels",
headers=_fb_headers(),
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
view: str = "unknown"
submitter: str = ""
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
@app.get("/api/feedback/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
@app.post("/api/feedback", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""File a Forgejo issue from in-app feedback."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
).strip()
except Exception:
version = "dev"
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
body_lines = [
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
"",
payload.description,
"",
]
if payload.type == "bug" and payload.repro:
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
body_lines += [
"### Context", "",
f"- **view:** {payload.view}",
f"- **version:** {version}",
f"- **platform:** {_platform.platform()}",
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
"",
]
if payload.submitter:
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
labels = ["beta-feedback", "needs-triage",
{"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/snipe")
label_ids = _ensure_feedback_labels(labels)
resp = _requests.post(
f"{base}/repos/{repo}/issues",
headers=_fb_headers(),
json={"title": payload.title, "body": "\n".join(body_lines), "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"])

View file

@ -1,21 +1,17 @@
# compose.override.yml — dev-only additions (auto-applied by Docker Compose in dev).
# Safe to delete on a self-hosted machine — compose.yml is self-contained.
#
# What this adds over compose.yml:
# - Live source mounts so code changes take effect without rebuilding images
# - RELOAD=true to enable uvicorn --reload for the API
# - NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
# pick up cf-core changes. Mounting it as a bind volume would break self-hosted
# installs that don't have the sibling directory.
services: services:
api: api:
build:
context: ..
dockerfile: snipe/Dockerfile
network_mode: host
volumes: volumes:
- ../circuitforge-core:/app/circuitforge-core
- ./api:/app/snipe/api - ./api:/app/snipe/api
- ./app:/app/snipe/app - ./app:/app/snipe/app
- ./data:/app/snipe/data
- ./tests:/app/snipe/tests - ./tests:/app/snipe/tests
environment: environment:
- RELOAD=true - RELOAD=true
web:
build:
context: .
dockerfile: docker/web/Dockerfile
volumes:
- ./web/src:/app/src # not used at runtime but keeps override valid

View file

@ -3,11 +3,14 @@ services:
build: build:
context: .. context: ..
dockerfile: snipe/Dockerfile dockerfile: snipe/Dockerfile
ports: # Host networking lets nginx (in the web container) reach the API at
- "8510:8510" # 172.17.0.1:8510 (the Docker bridge gateway). Required — nginx.conf
# is baked into the image and hard-codes that address.
network_mode: host
env_file: .env env_file: .env
volumes: volumes:
- ./data:/app/snipe/data - ./data:/app/snipe/data
restart: unless-stopped
web: web:
build: build:

93
install.sh Executable file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Snipe — self-hosted install script
# Clones Snipe and its shared library (circuitforge-core) into a workspace,
# then starts the Docker stack.
#
# Usage:
# bash install.sh # installs to ~/snipe
# bash install.sh /opt/snipe # custom install directory
#
# Requirements: Docker with Compose plugin, Git
set -euo pipefail
INSTALL_DIR="${1:-$HOME/snipe}"
FORGEJO="https://git.opensourcesolarpunk.com/Circuit-Forge"
info() { echo " [snipe] $*"; }
ok() { echo "$*"; }
fail() { echo "$*" >&2; exit 1; }
echo ""
echo " Snipe — self-hosted installer"
echo " Install directory: $INSTALL_DIR"
echo ""
# ── Pre-flight checks ────────────────────────────────────────────────────────
command -v docker >/dev/null 2>&1 || fail "Docker is required. Install from https://docs.docker.com/get-docker/"
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin is required (docker compose, not docker-compose)."
command -v git >/dev/null 2>&1 || fail "Git is required."
ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) and Git found."
# ── Clone repos ──────────────────────────────────────────────────────────────
# compose.yml builds with context: .. so both repos must be siblings.
SNIPE_DIR="$INSTALL_DIR/snipe"
CORE_DIR="$INSTALL_DIR/circuitforge-core"
if [[ -d "$SNIPE_DIR" ]]; then
info "Snipe already exists at $SNIPE_DIR — pulling latest..."
git -C "$SNIPE_DIR" pull --ff-only
else
info "Cloning Snipe..."
mkdir -p "$INSTALL_DIR"
git clone "$FORGEJO/snipe.git" "$SNIPE_DIR"
fi
ok "Snipe cloned to $SNIPE_DIR"
if [[ -d "$CORE_DIR" ]]; then
info "circuitforge-core already exists — pulling latest..."
git -C "$CORE_DIR" pull --ff-only
else
info "Cloning circuitforge-core (shared library)..."
git clone "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
fi
ok "circuitforge-core cloned to $CORE_DIR"
# ── Configure environment ────────────────────────────────────────────────────
ENV_FILE="$SNIPE_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
cp "$SNIPE_DIR/.env.example" "$ENV_FILE"
ok ".env created from .env.example"
echo ""
echo " ┌────────────────────────────────────────────────────────┐"
echo " │ Next step: edit $ENV_FILE"
echo " │ │"
echo " │ Snipe works out of the box with no API keys. │"
echo " │ Add EBAY_APP_ID / EBAY_CERT_ID for faster searches │"
echo " │ and full seller account age data (optional). │"
echo " └────────────────────────────────────────────────────────┘"
echo ""
else
info ".env already exists — skipping (delete it to reset)"
fi
# ── Build and start ──────────────────────────────────────────────────────────
info "Building Docker images (first run downloads ~1 GB of dependencies)..."
cd "$SNIPE_DIR"
docker compose build
info "Starting Snipe..."
docker compose up -d
echo ""
ok "Snipe is running!"
echo ""
echo " Web UI: http://localhost:8509"
echo " API: http://localhost:8510/docs"
echo ""
echo " Manage: cd $SNIPE_DIR && ./manage.sh {start|stop|restart|logs|test}"
echo ""

View file

@ -78,7 +78,7 @@ case "$cmd" in
test) test)
echo "Running test suite..." echo "Running test suite..."
docker compose -f "$COMPOSE_FILE" exec api \ docker compose -f "$COMPOSE_FILE" exec api \
conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}" python -m pytest /app/snipe/tests/ -v "${@}"
;; ;;
# ── Cloud commands ──────────────────────────────────────────────────────── # ── Cloud commands ────────────────────────────────────────────────────────

134
tests/test_feedback.py Normal file
View file

@ -0,0 +1,134 @@
"""Tests for the shared feedback router (circuitforge-core) mounted in snipe."""
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
app = FastAPI()
router = make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
demo_mode_fn=demo_mode_fn,
)
app.include_router(router, prefix="/api/feedback")
return TestClient(app)
# ── GET /api/feedback/status ──────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.delenv("DEMO_MODE", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/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")
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/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")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /api/feedback ────────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.post("/api/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")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.post("/api/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")
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": 7,
"html_url": "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7",
}
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
res = client.post("/api/feedback", json={
"title": "Listing scores wrong",
"description": "Trust score shows 0 when seller has 1000 feedback",
"type": "bug",
"repro": "1. Search for anything\n2. Check trust score",
"tab": "search",
})
assert res.status_code == 200
data = res.json()
assert data["issue_number"] == 7
assert data["issue_url"] == "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7"
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
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": "question"},
]
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "internal server error"
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
res = client.post("/api/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})
assert res.status_code == 502