Skip to content

Commit 91c511c

Browse files
authored
feat: add feedback endpoint for Linear issue creation (#322)
* 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 * chore: fix lint errors * ci: wire LINEAR_API_KEY and LINEAR_TEAM_ID through deployment pipeline
1 parent 9ef93e6 commit 91c511c

6 files changed

Lines changed: 308 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,12 +596,14 @@ jobs:
596596
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
597597
MEILI_MASTER_KEY: ${{ secrets.MEILI_MASTER_KEY }}
598598
NGROK_API_KEY: ${{ secrets.NGROK_API_KEY }}
599+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
600+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
599601
with:
600602
host: ${{ secrets.SSH_HOST }}
601603
username: ${{ secrets.SSH_USER }}
602604
key: ${{ secrets.SSH_PRIVATE_KEY }}
603605
port: ${{ secrets.SSH_PORT }}
604-
envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY
606+
envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY,LINEAR_API_KEY,LINEAR_TEAM_ID
605607
script: |
606608
set -e
607609
cd /opt/syfthub
@@ -630,6 +632,26 @@ jobs:
630632
fi
631633
fi
632634
635+
# Ensure LINEAR_API_KEY is persisted in .env for docker-compose
636+
if [ -n "$LINEAR_API_KEY" ]; then
637+
if grep -q "^LINEAR_API_KEY=" .env 2>/dev/null; then
638+
sed -i "s|^LINEAR_API_KEY=.*|LINEAR_API_KEY=${LINEAR_API_KEY}|" .env
639+
else
640+
echo "LINEAR_API_KEY=${LINEAR_API_KEY}" >> .env
641+
echo "Added LINEAR_API_KEY to .env"
642+
fi
643+
fi
644+
645+
# Ensure LINEAR_TEAM_ID is persisted in .env for docker-compose
646+
if [ -n "$LINEAR_TEAM_ID" ]; then
647+
if grep -q "^LINEAR_TEAM_ID=" .env 2>/dev/null; then
648+
sed -i "s|^LINEAR_TEAM_ID=.*|LINEAR_TEAM_ID=${LINEAR_TEAM_ID}|" .env
649+
else
650+
echo "LINEAR_TEAM_ID=${LINEAR_TEAM_ID}" >> .env
651+
echo "Added LINEAR_TEAM_ID to .env"
652+
fi
653+
fi
654+
633655
# Export for docker-compose
634656
export IMAGE_TAG
635657
export GITHUB_REPOSITORY
@@ -786,12 +808,14 @@ jobs:
786808
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
787809
MEILI_MASTER_KEY: ${{ secrets.MEILI_MASTER_KEY }}
788810
NGROK_API_KEY: ${{ secrets.NGROK_API_KEY }}
811+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
812+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
789813
with:
790814
host: ${{ secrets.SSH_HOST }}
791815
username: ${{ secrets.SSH_USER }}
792816
key: ${{ secrets.SSH_PRIVATE_KEY }}
793817
port: ${{ secrets.SSH_PORT }}
794-
envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY
818+
envs: IMAGE_TAG,GITHUB_REPOSITORY,GHCR_TOKEN,MEILI_MASTER_KEY,NGROK_API_KEY,LINEAR_API_KEY,LINEAR_TEAM_ID
795819
script: |
796820
set -e
797821
cd /opt/syfthub
@@ -820,6 +844,26 @@ jobs:
820844
fi
821845
fi
822846
847+
# Ensure LINEAR_API_KEY is persisted in .env for docker-compose
848+
if [ -n "$LINEAR_API_KEY" ]; then
849+
if grep -q "^LINEAR_API_KEY=" .env 2>/dev/null; then
850+
sed -i "s|^LINEAR_API_KEY=.*|LINEAR_API_KEY=${LINEAR_API_KEY}|" .env
851+
else
852+
echo "LINEAR_API_KEY=${LINEAR_API_KEY}" >> .env
853+
echo "Added LINEAR_API_KEY to .env"
854+
fi
855+
fi
856+
857+
# Ensure LINEAR_TEAM_ID is persisted in .env for docker-compose
858+
if [ -n "$LINEAR_TEAM_ID" ]; then
859+
if grep -q "^LINEAR_TEAM_ID=" .env 2>/dev/null; then
860+
sed -i "s|^LINEAR_TEAM_ID=.*|LINEAR_TEAM_ID=${LINEAR_TEAM_ID}|" .env
861+
else
862+
echo "LINEAR_TEAM_ID=${LINEAR_TEAM_ID}" >> .env
863+
echo "Added LINEAR_TEAM_ID to .env"
864+
fi
865+
fi
866+
823867
# Export for docker-compose
824868
export IMAGE_TAG
825869
export GITHUB_REPOSITORY
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""Feedback endpoint — proxies user feedback to Linear as issues."""
2+
3+
import logging
4+
from typing import Annotated, Optional
5+
6+
import httpx
7+
from fastapi import APIRouter, Depends, File, Form, UploadFile
8+
from pydantic import BaseModel, Field
9+
10+
from syfthub.auth.db_dependencies import get_current_active_user
11+
from syfthub.core.config import settings
12+
from syfthub.schemas.user import User
13+
14+
logger = logging.getLogger(__name__)
15+
16+
router = APIRouter()
17+
18+
LINEAR_API_URL = "https://api.linear.app/graphql"
19+
20+
21+
class FeedbackResponse(BaseModel):
22+
"""Response model for feedback submission."""
23+
24+
success: bool
25+
message: str
26+
ticket_id: Optional[str] = Field(
27+
None, description="Linear issue identifier (e.g. OME-123)"
28+
)
29+
30+
31+
async def _upload_to_linear(
32+
filename: str, content: bytes, content_type: str
33+
) -> Optional[str]:
34+
"""Upload a file to Linear and return the asset URL."""
35+
assert settings.linear_api_key is not None
36+
api_key: str = settings.linear_api_key
37+
query = """
38+
mutation($contentType: String!, $filename: String!, $size: Int!) {
39+
fileUpload(contentType: $contentType, filename: $filename, size: $size) {
40+
success
41+
uploadFile {
42+
uploadUrl
43+
assetUrl
44+
headers { key value }
45+
}
46+
}
47+
}
48+
"""
49+
async with httpx.AsyncClient() as client:
50+
resp = await client.post(
51+
LINEAR_API_URL,
52+
json={
53+
"query": query,
54+
"variables": {
55+
"contentType": content_type,
56+
"filename": filename,
57+
"size": len(content),
58+
},
59+
},
60+
headers={
61+
"Authorization": api_key,
62+
"Content-Type": "application/json",
63+
},
64+
timeout=30.0,
65+
)
66+
data = resp.json()
67+
68+
upload_data = data.get("data", {}).get("fileUpload", {})
69+
if not upload_data.get("success"):
70+
logger.warning("Linear file upload request failed: %s", data)
71+
return None
72+
73+
upload_file = upload_data["uploadFile"]
74+
headers = {
75+
"Content-Type": content_type,
76+
"Cache-Control": "public, max-age=31536000",
77+
}
78+
for h in upload_file.get("headers", []):
79+
headers[h["key"]] = h["value"]
80+
81+
async with httpx.AsyncClient() as client:
82+
await client.put(
83+
upload_file["uploadUrl"], content=content, headers=headers, timeout=30.0
84+
)
85+
86+
asset_url: str = upload_file["assetUrl"]
87+
return asset_url
88+
89+
90+
@router.post("/feedback", response_model=FeedbackResponse)
91+
async def submit_feedback(
92+
current_user: Annotated[User, Depends(get_current_active_user)],
93+
category: str = Form(default="feedback"),
94+
description: str = Form(...),
95+
page_url: Optional[str] = Form(default=None),
96+
app_version: Optional[str] = Form(default=None),
97+
browser_info: Optional[str] = Form(default=None),
98+
screenshot: Optional[UploadFile] = File(default=None),
99+
) -> FeedbackResponse:
100+
"""Submit feedback or bug report — creates a Linear issue.
101+
102+
Uses the authenticated user's email as the reporter.
103+
"""
104+
if not settings.linear_api_key or not settings.linear_team_id:
105+
logger.warning("Linear API key or team ID not configured")
106+
return FeedbackResponse(
107+
success=False,
108+
message="Feedback service is not configured.",
109+
ticket_id=None,
110+
)
111+
112+
linear_api_key: str = settings.linear_api_key
113+
linear_team_id: str = settings.linear_team_id
114+
115+
category_labels = {"bug": "Bug", "feedback": "Feedback", "idea": "Feature Request"}
116+
label = category_labels.get(category, "Feedback")
117+
title = f"[Syft Space] [{label}] {description[:100]}"
118+
119+
# Build markdown body
120+
lines = [
121+
"## Feedback Details",
122+
f"- Reporter: {current_user.email}",
123+
]
124+
if app_version:
125+
lines.append(f"- App version: `{app_version}`")
126+
if page_url:
127+
lines.append(f"- Page: `{page_url}`")
128+
if browser_info:
129+
lines.append(f"- Browser: `{browser_info}`")
130+
lines.extend(["", "### Description", description])
131+
body = "\n".join(lines)
132+
133+
# Upload screenshot if provided
134+
screenshot_asset_url = None
135+
if screenshot:
136+
try:
137+
content = await screenshot.read()
138+
screenshot_asset_url = await _upload_to_linear(
139+
screenshot.filename or "screenshot.png",
140+
content,
141+
screenshot.content_type or "image/png",
142+
)
143+
except Exception as e:
144+
logger.warning("Failed to upload screenshot to Linear: %s", e)
145+
146+
# Create Linear issue
147+
create_mutation = """
148+
mutation($input: IssueCreateInput!) {
149+
issueCreate(input: $input) {
150+
success
151+
issue { id identifier }
152+
}
153+
}
154+
"""
155+
variables = {
156+
"input": {
157+
"teamId": linear_team_id,
158+
"title": title,
159+
"description": body,
160+
}
161+
}
162+
163+
try:
164+
async with httpx.AsyncClient() as client:
165+
resp = await client.post(
166+
LINEAR_API_URL,
167+
json={"query": create_mutation, "variables": variables},
168+
headers={
169+
"Authorization": linear_api_key,
170+
"Content-Type": "application/json",
171+
},
172+
timeout=30.0,
173+
)
174+
resp.raise_for_status()
175+
data = resp.json()
176+
except httpx.HTTPError as e:
177+
logger.error("Linear API request failed: %s", e)
178+
return FeedbackResponse(
179+
success=False,
180+
message="Failed to submit feedback. Please try again.",
181+
ticket_id=None,
182+
)
183+
184+
if "errors" in data:
185+
logger.error("Linear API error: %s", data["errors"])
186+
return FeedbackResponse(
187+
success=False, message="Failed to create feedback ticket.", ticket_id=None
188+
)
189+
190+
issue_data = data.get("data", {}).get("issueCreate", {})
191+
if not issue_data.get("success"):
192+
return FeedbackResponse(
193+
success=False, message="Failed to create feedback ticket.", ticket_id=None
194+
)
195+
196+
issue = issue_data["issue"]
197+
issue_id = issue["id"]
198+
identifier = issue["identifier"]
199+
200+
# Attach screenshot if uploaded
201+
if screenshot_asset_url:
202+
attach_mutation = """
203+
mutation($input: AttachmentCreateInput!) {
204+
attachmentCreate(input: $input) { success }
205+
}
206+
"""
207+
try:
208+
async with httpx.AsyncClient() as client:
209+
await client.post(
210+
LINEAR_API_URL,
211+
json={
212+
"query": attach_mutation,
213+
"variables": {
214+
"input": {
215+
"issueId": issue_id,
216+
"url": screenshot_asset_url,
217+
"title": "Screenshot",
218+
"subtitle": "Auto-captured screenshot",
219+
}
220+
},
221+
},
222+
headers={
223+
"Authorization": linear_api_key,
224+
"Content-Type": "application/json",
225+
},
226+
timeout=30.0,
227+
)
228+
except Exception as e:
229+
logger.warning("Failed to attach screenshot: %s", e)
230+
231+
logger.info("Created Linear issue %s for user %s", identifier, current_user.email)
232+
return FeedbackResponse(
233+
success=True,
234+
message="Bug report submitted successfully",
235+
ticket_id=identifier,
236+
)

components/backend/src/syfthub/api/router.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
accounting,
77
endpoints,
88
errors,
9+
feedback,
910
nats,
1011
organizations,
1112
peer,
@@ -40,5 +41,8 @@
4041
# NATS credentials endpoint
4142
api_router.include_router(nats.router, tags=["nats"])
4243

44+
# Feedback / bug report proxy (creates Linear issues)
45+
api_router.include_router(feedback.router, tags=["feedback"])
46+
4347
# Error reporting endpoint for frontend
4448
api_router.include_router(errors.router, tags=["observability"])

components/backend/src/syfthub/core/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,19 @@ def rag_available(self) -> bool:
394394
description="Base domain for ngrok reserved tunnel domains",
395395
)
396396

397+
# ===========================================
398+
# LINEAR INTEGRATION (Feedback / Bug Reports)
399+
# ===========================================
400+
401+
linear_api_key: Optional[str] = Field(
402+
default=None,
403+
description="Linear API key for creating feedback/bug report issues",
404+
)
405+
linear_team_id: Optional[str] = Field(
406+
default=None,
407+
description="Linear team ID to assign feedback issues to",
408+
)
409+
397410

398411
@lru_cache
399412
def get_settings() -> Settings:

components/backend/tests/test_accounting_endpoints.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ class TestGetAccountingClient:
7070

7171
def test_accounting_not_configured(self, client, mock_user_no_accounting):
7272
"""Test error when accounting is not configured."""
73-
app.dependency_overrides[get_current_active_user] = (
74-
lambda: mock_user_no_accounting
73+
app.dependency_overrides[get_current_active_user] = lambda: (
74+
mock_user_no_accounting
7575
)
7676

7777
response = client.get("/api/v1/accounting/user")
@@ -629,8 +629,8 @@ def test_create_tokens_multiple_owners(self, mock_client_class, client, mock_use
629629
)
630630

631631
mock_repo = MagicMock()
632-
mock_repo.get_by_username.side_effect = (
633-
lambda u: owner1 if u == "owner1" else owner2
632+
mock_repo.get_by_username.side_effect = lambda u: (
633+
owner1 if u == "owner1" else owner2
634634
)
635635

636636
app.dependency_overrides[get_current_active_user] = lambda: mock_user
@@ -815,8 +815,8 @@ def test_create_tokens_accounting_not_configured(
815815
self, client, mock_user_no_accounting
816816
):
817817
"""Test token creation when accounting not configured."""
818-
app.dependency_overrides[get_current_active_user] = (
819-
lambda: mock_user_no_accounting
818+
app.dependency_overrides[get_current_active_user] = lambda: (
819+
mock_user_no_accounting
820820
)
821821

822822
response = client.post(

0 commit comments

Comments
 (0)