From cb4e4f4b22a183513427544605f4fa97ea6b043c Mon Sep 17 00:00:00 2001 From: Tauquir <30658453+itstauq@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:41:01 +0530 Subject: [PATCH 1/3] feat: add feedback endpoint that creates Linear issues Add authenticated POST /api/v1/feedback endpoint that accepts user feedback/bug reports and creates Linear issues via GraphQL API. Supports screenshot upload as file attachments. Ref: OME-76 --- .../src/syfthub/api/endpoints/feedback.py | 223 ++++++++++++++++++ components/backend/src/syfthub/api/router.py | 4 + components/backend/src/syfthub/core/config.py | 13 + 3 files changed, 240 insertions(+) create mode 100644 components/backend/src/syfthub/api/endpoints/feedback.py diff --git a/components/backend/src/syfthub/api/endpoints/feedback.py b/components/backend/src/syfthub/api/endpoints/feedback.py new file mode 100644 index 00000000..6cbc7e51 --- /dev/null +++ b/components/backend/src/syfthub/api/endpoints/feedback.py @@ -0,0 +1,223 @@ +"""Feedback endpoint — proxies user feedback to Linear as issues.""" + +import logging +from typing import Annotated, Optional + +import httpx +from fastapi import APIRouter, Depends, File, Form, UploadFile +from pydantic import BaseModel, Field + +from syfthub.auth.db_dependencies import get_current_active_user +from syfthub.core.config import settings +from syfthub.schemas.user import User + +logger = logging.getLogger(__name__) + +router = APIRouter() + +LINEAR_API_URL = "https://api.linear.app/graphql" + + +class FeedbackResponse(BaseModel): + """Response model for feedback submission.""" + + success: bool + message: str + ticket_id: Optional[str] = Field( + None, description="Linear issue identifier (e.g. OME-123)" + ) + + +async def _upload_to_linear( + filename: str, content: bytes, content_type: str +) -> Optional[str]: + """Upload a file to Linear and return the asset URL.""" + query = """ + mutation($contentType: String!, $filename: String!, $size: Int!) { + fileUpload(contentType: $contentType, filename: $filename, size: $size) { + success + uploadFile { + uploadUrl + assetUrl + headers { key value } + } + } + } + """ + async with httpx.AsyncClient() as client: + resp = await client.post( + LINEAR_API_URL, + json={ + "query": query, + "variables": { + "contentType": content_type, + "filename": filename, + "size": len(content), + }, + }, + headers={ + "Authorization": settings.linear_api_key, + "Content-Type": "application/json", + }, + timeout=30.0, + ) + data = resp.json() + + upload_data = data.get("data", {}).get("fileUpload", {}) + if not upload_data.get("success"): + logger.warning("Linear file upload request failed: %s", data) + return None + + upload_file = upload_data["uploadFile"] + headers = { + "Content-Type": content_type, + "Cache-Control": "public, max-age=31536000", + } + for h in upload_file.get("headers", []): + headers[h["key"]] = h["value"] + + async with httpx.AsyncClient() as client: + await client.put(upload_file["uploadUrl"], content=content, headers=headers, timeout=30.0) + + return upload_file["assetUrl"] + + +@router.post("/feedback", response_model=FeedbackResponse) +async def submit_feedback( + current_user: Annotated[User, Depends(get_current_active_user)], + category: str = Form(default="feedback"), + description: str = Form(...), + page_url: Optional[str] = Form(default=None), + app_version: Optional[str] = Form(default=None), + browser_info: Optional[str] = Form(default=None), + screenshot: Optional[UploadFile] = File(default=None), +) -> FeedbackResponse: + """Submit feedback or bug report — creates a Linear issue. + + Uses the authenticated user's email as the reporter. + """ + if not settings.linear_api_key or not settings.linear_team_id: + logger.warning("Linear API key or team ID not configured") + return FeedbackResponse( + success=False, + message="Feedback service is not configured.", + ) + + category_labels = {"bug": "Bug", "feedback": "Feedback", "idea": "Feature Request"} + label = category_labels.get(category, "Feedback") + title = f"[Syft Space] [{label}] {description[:100]}" + + # Build markdown body + lines = [ + "## Feedback Details", + f"- Reporter: {current_user.email}", + ] + if app_version: + lines.append(f"- App version: `{app_version}`") + if page_url: + lines.append(f"- Page: `{page_url}`") + if browser_info: + lines.append(f"- Browser: `{browser_info}`") + lines.extend(["", "### Description", description]) + body = "\n".join(lines) + + # Upload screenshot if provided + screenshot_asset_url = None + if screenshot: + try: + content = await screenshot.read() + screenshot_asset_url = await _upload_to_linear( + screenshot.filename or "screenshot.png", content, screenshot.content_type or "image/png" + ) + except Exception as e: + logger.warning("Failed to upload screenshot to Linear: %s", e) + + # Create Linear issue + create_mutation = """ + mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier } + } + } + """ + variables = { + "input": { + "teamId": settings.linear_team_id, + "title": title, + "description": body, + } + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + LINEAR_API_URL, + json={"query": create_mutation, "variables": variables}, + headers={ + "Authorization": settings.linear_api_key, + "Content-Type": "application/json", + }, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + logger.error("Linear API request failed: %s", e) + return FeedbackResponse( + success=False, message="Failed to submit feedback. Please try again." + ) + + if "errors" in data: + logger.error("Linear API error: %s", data["errors"]) + return FeedbackResponse( + success=False, message="Failed to create feedback ticket." + ) + + issue_data = data.get("data", {}).get("issueCreate", {}) + if not issue_data.get("success"): + return FeedbackResponse( + success=False, message="Failed to create feedback ticket." + ) + + issue = issue_data["issue"] + issue_id = issue["id"] + identifier = issue["identifier"] + + # Attach screenshot if uploaded + if screenshot_asset_url: + attach_mutation = """ + mutation($input: AttachmentCreateInput!) { + attachmentCreate(input: $input) { success } + } + """ + try: + async with httpx.AsyncClient() as client: + await client.post( + LINEAR_API_URL, + json={ + "query": attach_mutation, + "variables": { + "input": { + "issueId": issue_id, + "url": screenshot_asset_url, + "title": "Screenshot", + "subtitle": "Auto-captured screenshot", + } + }, + }, + headers={ + "Authorization": settings.linear_api_key, + "Content-Type": "application/json", + }, + timeout=30.0, + ) + except Exception as e: + logger.warning("Failed to attach screenshot: %s", e) + + logger.info("Created Linear issue %s for user %s", identifier, current_user.email) + return FeedbackResponse( + success=True, + message="Bug report submitted successfully", + ticket_id=identifier, + ) diff --git a/components/backend/src/syfthub/api/router.py b/components/backend/src/syfthub/api/router.py index 239b6884..45d5b48c 100644 --- a/components/backend/src/syfthub/api/router.py +++ b/components/backend/src/syfthub/api/router.py @@ -4,6 +4,7 @@ from syfthub.api.endpoints import ( accounting, + feedback, endpoints, errors, nats, @@ -40,5 +41,8 @@ # NATS credentials endpoint api_router.include_router(nats.router, tags=["nats"]) +# Feedback / bug report proxy (creates Linear issues) +api_router.include_router(feedback.router, tags=["feedback"]) + # Error reporting endpoint for frontend api_router.include_router(errors.router, tags=["observability"]) diff --git a/components/backend/src/syfthub/core/config.py b/components/backend/src/syfthub/core/config.py index 8409f926..de71df49 100644 --- a/components/backend/src/syfthub/core/config.py +++ b/components/backend/src/syfthub/core/config.py @@ -394,6 +394,19 @@ def rag_available(self) -> bool: description="Base domain for ngrok reserved tunnel domains", ) + # =========================================== + # LINEAR INTEGRATION (Feedback / Bug Reports) + # =========================================== + + linear_api_key: Optional[str] = Field( + default=None, + description="Linear API key for creating feedback/bug report issues", + ) + linear_team_id: Optional[str] = Field( + default=None, + description="Linear team ID to assign feedback issues to", + ) + @lru_cache def get_settings() -> Settings: From a1485ee569c642b249ea3d130ef48d789864b69f Mon Sep 17 00:00:00 2001 From: Tauquir <30658453+itstauq@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:57:34 +0530 Subject: [PATCH 2/3] chore: fix lint errors --- .../src/syfthub/api/endpoints/feedback.py | 30 +++++++++++++------ components/backend/src/syfthub/api/router.py | 2 +- .../tests/test_accounting_endpoints.py | 12 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/components/backend/src/syfthub/api/endpoints/feedback.py b/components/backend/src/syfthub/api/endpoints/feedback.py index 6cbc7e51..8dd612c9 100644 --- a/components/backend/src/syfthub/api/endpoints/feedback.py +++ b/components/backend/src/syfthub/api/endpoints/feedback.py @@ -32,6 +32,7 @@ async def _upload_to_linear( filename: str, content: bytes, content_type: str ) -> Optional[str]: """Upload a file to Linear and return the asset URL.""" + assert settings.linear_api_key is not None query = """ mutation($contentType: String!, $filename: String!, $size: Int!) { fileUpload(contentType: $contentType, filename: $filename, size: $size) { @@ -77,9 +78,12 @@ async def _upload_to_linear( headers[h["key"]] = h["value"] async with httpx.AsyncClient() as client: - await client.put(upload_file["uploadUrl"], content=content, headers=headers, timeout=30.0) + await client.put( + upload_file["uploadUrl"], content=content, headers=headers, timeout=30.0 + ) - return upload_file["assetUrl"] + asset_url: str = upload_file["assetUrl"] + return asset_url @router.post("/feedback", response_model=FeedbackResponse) @@ -101,8 +105,12 @@ async def submit_feedback( return FeedbackResponse( success=False, message="Feedback service is not configured.", + ticket_id=None, ) + linear_api_key: str = settings.linear_api_key + linear_team_id: str = settings.linear_team_id + category_labels = {"bug": "Bug", "feedback": "Feedback", "idea": "Feature Request"} label = category_labels.get(category, "Feedback") title = f"[Syft Space] [{label}] {description[:100]}" @@ -127,7 +135,9 @@ async def submit_feedback( try: content = await screenshot.read() screenshot_asset_url = await _upload_to_linear( - screenshot.filename or "screenshot.png", content, screenshot.content_type or "image/png" + screenshot.filename or "screenshot.png", + content, + screenshot.content_type or "image/png", ) except Exception as e: logger.warning("Failed to upload screenshot to Linear: %s", e) @@ -143,7 +153,7 @@ async def submit_feedback( """ variables = { "input": { - "teamId": settings.linear_team_id, + "teamId": linear_team_id, "title": title, "description": body, } @@ -155,7 +165,7 @@ async def submit_feedback( LINEAR_API_URL, json={"query": create_mutation, "variables": variables}, headers={ - "Authorization": settings.linear_api_key, + "Authorization": linear_api_key, "Content-Type": "application/json", }, timeout=30.0, @@ -165,19 +175,21 @@ async def submit_feedback( except httpx.HTTPError as e: logger.error("Linear API request failed: %s", e) return FeedbackResponse( - success=False, message="Failed to submit feedback. Please try again." + success=False, + message="Failed to submit feedback. Please try again.", + ticket_id=None, ) if "errors" in data: logger.error("Linear API error: %s", data["errors"]) return FeedbackResponse( - success=False, message="Failed to create feedback ticket." + success=False, message="Failed to create feedback ticket.", ticket_id=None ) issue_data = data.get("data", {}).get("issueCreate", {}) if not issue_data.get("success"): return FeedbackResponse( - success=False, message="Failed to create feedback ticket." + success=False, message="Failed to create feedback ticket.", ticket_id=None ) issue = issue_data["issue"] @@ -207,7 +219,7 @@ async def submit_feedback( }, }, headers={ - "Authorization": settings.linear_api_key, + "Authorization": linear_api_key, "Content-Type": "application/json", }, timeout=30.0, diff --git a/components/backend/src/syfthub/api/router.py b/components/backend/src/syfthub/api/router.py index 45d5b48c..55cffc98 100644 --- a/components/backend/src/syfthub/api/router.py +++ b/components/backend/src/syfthub/api/router.py @@ -4,9 +4,9 @@ from syfthub.api.endpoints import ( accounting, - feedback, endpoints, errors, + feedback, nats, organizations, peer, diff --git a/components/backend/tests/test_accounting_endpoints.py b/components/backend/tests/test_accounting_endpoints.py index 2a4bbd96..be2e77ac 100644 --- a/components/backend/tests/test_accounting_endpoints.py +++ b/components/backend/tests/test_accounting_endpoints.py @@ -70,8 +70,8 @@ class TestGetAccountingClient: def test_accounting_not_configured(self, client, mock_user_no_accounting): """Test error when accounting is not configured.""" - app.dependency_overrides[get_current_active_user] = ( - lambda: mock_user_no_accounting + app.dependency_overrides[get_current_active_user] = lambda: ( + mock_user_no_accounting ) response = client.get("/api/v1/accounting/user") @@ -629,8 +629,8 @@ def test_create_tokens_multiple_owners(self, mock_client_class, client, mock_use ) mock_repo = MagicMock() - mock_repo.get_by_username.side_effect = ( - lambda u: owner1 if u == "owner1" else owner2 + mock_repo.get_by_username.side_effect = lambda u: ( + owner1 if u == "owner1" else owner2 ) app.dependency_overrides[get_current_active_user] = lambda: mock_user @@ -815,8 +815,8 @@ def test_create_tokens_accounting_not_configured( self, client, mock_user_no_accounting ): """Test token creation when accounting not configured.""" - app.dependency_overrides[get_current_active_user] = ( - lambda: mock_user_no_accounting + app.dependency_overrides[get_current_active_user] = lambda: ( + mock_user_no_accounting ) response = client.post( From 25dc2aee04aacb97380bfc21eca77c5893b723d2 Mon Sep 17 00:00:00 2001 From: Tauquir <30658453+itstauq@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:06:24 +0530 Subject: [PATCH 3/3] ci: wire LINEAR_API_KEY and LINEAR_TEAM_ID through deployment pipeline --- .github/workflows/ci.yml | 48 ++++++++++++++++++- .../src/syfthub/api/endpoints/feedback.py | 3 +- deploy/docker-compose.deploy.yml | 3 ++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdf1dc61..7a6fb73d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -596,12 +596,14 @@ jobs: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} MEILI_MASTER_KEY: ${{ secrets.MEILI_MASTER_KEY }} NGROK_API_KEY: ${{ secrets.NGROK_API_KEY }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT }} - envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY + envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY,LINEAR_API_KEY,LINEAR_TEAM_ID script: | set -e cd /opt/syfthub @@ -630,6 +632,26 @@ jobs: fi fi + # Ensure LINEAR_API_KEY is persisted in .env for docker-compose + if [ -n "$LINEAR_API_KEY" ]; then + if grep -q "^LINEAR_API_KEY=" .env 2>/dev/null; then + sed -i "s|^LINEAR_API_KEY=.*|LINEAR_API_KEY=${LINEAR_API_KEY}|" .env + else + echo "LINEAR_API_KEY=${LINEAR_API_KEY}" >> .env + echo "Added LINEAR_API_KEY to .env" + fi + fi + + # Ensure LINEAR_TEAM_ID is persisted in .env for docker-compose + if [ -n "$LINEAR_TEAM_ID" ]; then + if grep -q "^LINEAR_TEAM_ID=" .env 2>/dev/null; then + sed -i "s|^LINEAR_TEAM_ID=.*|LINEAR_TEAM_ID=${LINEAR_TEAM_ID}|" .env + else + echo "LINEAR_TEAM_ID=${LINEAR_TEAM_ID}" >> .env + echo "Added LINEAR_TEAM_ID to .env" + fi + fi + # Export for docker-compose export IMAGE_TAG export GITHUB_REPOSITORY @@ -786,12 +808,14 @@ jobs: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} MEILI_MASTER_KEY: ${{ secrets.MEILI_MASTER_KEY }} NGROK_API_KEY: ${{ secrets.NGROK_API_KEY }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT }} - envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY + envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY,LINEAR_API_KEY,LINEAR_TEAM_ID script: | set -e cd /opt/syfthub @@ -820,6 +844,26 @@ jobs: fi fi + # Ensure LINEAR_API_KEY is persisted in .env for docker-compose + if [ -n "$LINEAR_API_KEY" ]; then + if grep -q "^LINEAR_API_KEY=" .env 2>/dev/null; then + sed -i "s|^LINEAR_API_KEY=.*|LINEAR_API_KEY=${LINEAR_API_KEY}|" .env + else + echo "LINEAR_API_KEY=${LINEAR_API_KEY}" >> .env + echo "Added LINEAR_API_KEY to .env" + fi + fi + + # Ensure LINEAR_TEAM_ID is persisted in .env for docker-compose + if [ -n "$LINEAR_TEAM_ID" ]; then + if grep -q "^LINEAR_TEAM_ID=" .env 2>/dev/null; then + sed -i "s|^LINEAR_TEAM_ID=.*|LINEAR_TEAM_ID=${LINEAR_TEAM_ID}|" .env + else + echo "LINEAR_TEAM_ID=${LINEAR_TEAM_ID}" >> .env + echo "Added LINEAR_TEAM_ID to .env" + fi + fi + # Export for docker-compose export IMAGE_TAG export GITHUB_REPOSITORY diff --git a/components/backend/src/syfthub/api/endpoints/feedback.py b/components/backend/src/syfthub/api/endpoints/feedback.py index 8dd612c9..d8456597 100644 --- a/components/backend/src/syfthub/api/endpoints/feedback.py +++ b/components/backend/src/syfthub/api/endpoints/feedback.py @@ -33,6 +33,7 @@ async def _upload_to_linear( ) -> Optional[str]: """Upload a file to Linear and return the asset URL.""" assert settings.linear_api_key is not None + api_key: str = settings.linear_api_key query = """ mutation($contentType: String!, $filename: String!, $size: Int!) { fileUpload(contentType: $contentType, filename: $filename, size: $size) { @@ -57,7 +58,7 @@ async def _upload_to_linear( }, }, headers={ - "Authorization": settings.linear_api_key, + "Authorization": api_key, "Content-Type": "application/json", }, timeout=30.0, diff --git a/deploy/docker-compose.deploy.yml b/deploy/docker-compose.deploy.yml index 86fe2113..7424026b 100644 --- a/deploy/docker-compose.deploy.yml +++ b/deploy/docker-compose.deploy.yml @@ -128,6 +128,9 @@ services: - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # ngrok tunnel credentials (optional - tunnel credentials endpoint disabled if not set) - NGROK_API_KEY=${NGROK_API_KEY:-} + # Linear API credentials (optional - feedback endpoint disabled if not set) + - LINEAR_API_KEY=${LINEAR_API_KEY:-} + - LINEAR_TEAM_ID=${LINEAR_TEAM_ID:-} networks: - syfthub-network depends_on: