diff --git a/.github/workflows/wework-app.yml b/.github/workflows/wework-app.yml index 8dbb3b48fe..7a818b369b 100644 --- a/.github/workflows/wework-app.yml +++ b/.github/workflows/wework-app.yml @@ -324,11 +324,14 @@ jobs: chmod 0755 dist/wegent-executor dist/wegent-executor --version - - name: Prepare bundled Codex + - name: Prepare bundled sidecars working-directory: wework env: WEWORK_CODEX_TARGET: ${{ matrix.rust_target }} - run: pnpm run prepare:codex + WEWORK_DWS_TARGET: ${{ matrix.rust_target }} + run: | + pnpm run prepare:codex + pnpm run prepare:dws - name: Sync Wework version files working-directory: wework @@ -608,11 +611,14 @@ jobs: Copy-Item target/x86_64-pc-windows-msvc/release/wegent-executor.exe ../wework/src-tauri/binaries/wegent-executor-x86_64-pc-windows-msvc.exe & ../wework/src-tauri/binaries/wegent-executor-x86_64-pc-windows-msvc.exe --version - - name: Prepare bundled Codex + - name: Prepare bundled sidecars working-directory: wework env: WEWORK_CODEX_TARGET: x86_64-pc-windows-msvc - run: pnpm run prepare:codex + WEWORK_DWS_TARGET: x86_64-pc-windows-msvc + run: | + pnpm run prepare:codex + pnpm run prepare:dws - name: Build Wework app bundle shell: pwsh diff --git a/.github/workflows/wework-e2e.yml b/.github/workflows/wework-e2e.yml index 4d8a1e2399..dc8c9a2588 100644 --- a/.github/workflows/wework-e2e.yml +++ b/.github/workflows/wework-e2e.yml @@ -190,9 +190,11 @@ jobs: restore-keys: | ${{ runner.os }}-wework-desktop-e2e- - - name: Prepare real Codex binary + - name: Prepare bundled sidecars working-directory: ./wework - run: pnpm run prepare:codex + run: | + pnpm run prepare:codex + pnpm run prepare:dws - name: Run Wework desktop ${{ matrix.name }} E2E env: @@ -253,10 +255,11 @@ jobs: restore-keys: | ${{ runner.os }}-${{ runner.arch }}-wework-desktop-memory-e2e- - - name: Prepare real Codex binary + - name: Prepare bundled sidecars working-directory: ./wework run: | pnpm run prepare:codex + pnpm run prepare:dws codex_bin="$(find "$PWD/src-tauri/binaries/codex" -type f -path '*/bin/codex' -perm -u+x -print -quit)" test -n "$codex_bin" echo "CODEX_BIN=$codex_bin" >> "$GITHUB_ENV" diff --git a/backend/.env.example b/backend/.env.example index c17da5aba4..2715061d6f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -242,6 +242,11 @@ DEFAULT_TEAM_CHAT=wegent-chat#default DEFAULT_TEAM_KNOWLEDGE=wegent-notebook#default DEFAULT_TEAM_TASK=wegent-wework#default +# Cloud project ID that receives Wework feedback. Empty disables submission. +WEWORK_FEEDBACK_PROJECT_ID= +# Maximum uploaded diagnostic bundle size in MB. +WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB=250 + # Long-term memory configuration (mem0) # Optional API key for mem0 service authentication MEMORY_API_KEY= diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 58ac6d4d9b..916edb9f31 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -16,6 +16,7 @@ device_chat_tasks, devices, dingtalk_docs, + feedback, groups, health, im_sessions, @@ -136,6 +137,7 @@ cloud_projects.router, prefix="/v1/cloud-projects", tags=["cloud-projects"] ) api_router.include_router(deliveries.router, prefix="/v1", tags=["deliveries"]) +api_router.include_router(feedback.router, prefix="/v1/feedback", tags=["feedback"]) api_router.include_router(api_keys.router, prefix="/api-keys", tags=["api-keys"]) api_router.include_router(devices.router, prefix="/devices", tags=["devices"]) api_router.include_router( diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index ace07c218e..326ab534d2 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -14,9 +14,11 @@ UploadFile, status, ) +from fastapi.responses import Response from sqlalchemy.orm import Session from app.api.dependencies import get_db +from app.core.config import settings from app.core.security import get_current_user from app.models.delivery import Delivery from app.models.user import User @@ -48,6 +50,10 @@ from app.services.delivery import delivery_service from app.services.loop_items import loop_item_service from app.services.loop_items.external_provider import external_loop_item_provider +from app.services.loop_items.provider_router import ( + loop_item_attachment_provider_router, + loop_item_provider_router, +) router = APIRouter() @@ -242,14 +248,8 @@ def create_loop_item( current_user: User = Depends(get_current_user), ) -> LoopItemResponse: project = cloud_project_service.get(db, project_id, current_user.id) - if project.task_provider in {"github", "gitlab"}: - return LoopItemResponse.model_validate( - external_loop_item_provider.create( - db, project_id, current_user.id, current_user.user_name, values - ) - ) - item = loop_item_service.create(db, project_id, current_user.id, values) - return _loop_item_response(db, item, current_user) + created = loop_item_provider_router.create(db, project, current_user, values) + return LoopItemResponse.model_validate(created.values) @router.post( @@ -334,8 +334,9 @@ def list_loop_item_attachments( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> list[LoopItemAttachmentResponse]: - external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) - attachments = loop_item_service.list_attachments(db, item_id, current_user.id) + attachments = loop_item_attachment_provider_router.list( + db, item_id, current_user.id + ) return [LoopItemAttachmentResponse.model_validate(item) for item in attachments] @@ -350,14 +351,14 @@ def add_loop_item_attachment( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemAttachmentResponse: - external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) - attachment = loop_item_service.add_attachment( + attachment = loop_item_attachment_provider_router.add( db, item_id, current_user.id, file.filename or "attachment", file.content_type or "application/octet-stream", file.file, + settings.DELIVERY_MAX_ASSET_SIZE_MB * 1024 * 1024, ) return LoopItemAttachmentResponse.model_validate(attachment) @@ -371,9 +372,28 @@ def access_loop_item_attachment( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemAttachmentAccessResponse: + loop_item_attachment_provider_router.require_access( + db, attachment_id, current_user.id + ) return LoopItemAttachmentAccessResponse( - url=loop_item_service.attachment_access_url(db, attachment_id, current_user.id), - expires_in_seconds=900, + url=f"wegent://attachments/{attachment_id}", + expires_in_seconds=0, + ) + + +@router.get("/loop-item-attachments/{attachment_id}/content") +def read_loop_item_attachment( + attachment_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Response: + content, content_type, filename = loop_item_attachment_provider_router.content( + db, attachment_id, current_user.id + ) + return Response( + content=content, + media_type=content_type, + headers={"Content-Disposition": f'inline; filename="{filename}"'}, ) @@ -385,7 +405,7 @@ def delete_loop_item_attachment( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> None: - loop_item_service.delete_attachment(db, attachment_id, current_user.id) + loop_item_attachment_provider_router.delete(db, attachment_id, current_user.id) @router.get( diff --git a/backend/app/api/endpoints/feedback.py b/backend/app/api/endpoints/feedback.py new file mode 100644 index 0000000000..efb8c23aa0 --- /dev/null +++ b/backend/app/api/endpoints/feedback.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Authenticated Wework feedback endpoint.""" + +import json + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.api.dependencies import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.schemas.feedback import FeedbackCreate, FeedbackResponse +from app.services.feedback_service import feedback_service + +router = APIRouter() + + +@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED) +def submit_feedback( + report_id: str = Form(...), + title: str = Form(...), + description: str = Form(""), + context: str = Form("{}"), + bundle: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> FeedbackResponse: + try: + parsed_context = json.loads(context) + values = FeedbackCreate( + report_id=report_id, + title=title, + description=description, + context=parsed_context, + ) + except (json.JSONDecodeError, ValidationError) as error: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(error)) from error + if not isinstance(parsed_context, dict): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, "context must be an object" + ) + return feedback_service.submit(db, current_user, values, bundle) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b4dd558e1b..978cfc0ab2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -654,6 +654,10 @@ def parse_rag_runtime_mode(cls, v: Any) -> str | dict[str, str]: DEFAULT_TEAM_WEWORK: str = ( "wegent-wework#default" # Default team for WeWork workbench ) + # Cloud project that receives authenticated Wework feedback submissions. + # An empty value disables the feedback channel. + WEWORK_FEEDBACK_PROJECT_ID: str = "" + WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB: int = 250 # JSON configuration for MCP servers (similar to Claude Desktop format) # Example: diff --git a/backend/app/main.py b/backend/app/main.py index 23e5ab5c5a..87bb08a9cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -91,7 +91,6 @@ def _format_forwarded_headers_for_log(headers) -> str: def _get_mcp_lifespan_servers(): from app.mcp_server.server import ( - delivery_mcp_server, interactive_form_question_mcp_server, knowledge_mcp_server, prompt_optimization_mcp_server, @@ -105,7 +104,6 @@ def _get_mcp_lifespan_servers(): ("interactive_form_question", interactive_form_question_mcp_server), ("Prompt optimization", prompt_optimization_mcp_server), ("Subscription", subscription_mcp_server), - ("Delivery", delivery_mcp_server), ] if settings.EXTERNAL_KNOWLEDGE_MCP_ENABLED: from app.mcp_server.server import external_knowledge_mcp_server diff --git a/backend/app/mcp_server/server.py b/backend/app/mcp_server/server.py index f40f85e26e..a075097d2d 100644 --- a/backend/app/mcp_server/server.py +++ b/backend/app/mcp_server/server.py @@ -69,18 +69,6 @@ PROMPT_OPTIMIZATION_MCP_TRANSPORT_PATH = "/sse" SUBSCRIPTION_MCP_MOUNT_PATH = "/mcp/subscription" SUBSCRIPTION_MCP_TRANSPORT_PATH = "/sse" -DELIVERY_MCP_MOUNT_PATH = "/mcp/delivery" -DELIVERY_MCP_TRANSPORT_PATH = "/sse" -PROJECT_SPACE_PROTOCOL = "wegent.project-space" -PROJECT_SPACE_PROTOCOL_VERSION = 1 -PROJECT_SPACE_CAPABILITIES = { - "projects.read": True, - "projects.create": True, - "todos.read": True, - "todos.write": True, - "files.read": True, - "deliveries.read": True, -} @dataclass(frozen=True) @@ -538,82 +526,6 @@ def ensure_subscription_tools_registered() -> None: _register_subscription_tools() -# ============== Delivery MCP Server ============== - -delivery_mcp_server = FastMCP( - "wegent_delivery", - stateless_http=True, - json_response=True, - streamable_http_path="/", - transport_security=_build_transport_security_settings(), -) -_delivery_request_token_info: contextvars.ContextVar[Optional[TaskTokenInfo]] = ( - contextvars.ContextVar("_delivery_request_token_info", default=None) -) -_delivery_tools_registered = False - - -def ensure_delivery_tools_registered() -> None: - """Register the project-space tools and addressable cloud resources.""" - global _delivery_tools_registered - if _delivery_tools_registered: - return - from app.mcp_server.tool_registry import register_tools_to_server - from app.mcp_server.tools import delivery # noqa: F401 - - count = register_tools_to_server(delivery_mcp_server, "delivery") - delivery_mcp_server.resource( - "cloud://projects", - name="Wegent project spaces", - description="Every project space accessible to the authenticated user.", - mime_type="application/json", - )(_read_cloud_projects_resource) - delivery_mcp_server.resource( - "cloud://projects/{project_id}", - name="Wegent project space", - description="A project space with its shared workspace and board items.", - mime_type="application/json", - )(_read_cloud_project_resource) - delivery_mcp_server.resource( - "cloud://projects/{project_id}/{resource_type}/{resource_id}", - name="Wegent project-space object", - description="A task, file, or delivery in a project space.", - mime_type="application/json", - )(_read_cloud_object_resource) - logger.info("[MCP:Delivery] Registered %s tools", count) - _delivery_tools_registered = True - - -def _delivery_resource_token() -> MCPAuthInfo: - token_info = get_token_info_from_context() - if token_info is None: - raise PermissionError("Authentication required") - return token_info - - -def _serialize_delivery_resource(reference: str) -> str: - from app.mcp_server.tools.delivery import resolve_cloud_reference - - result = resolve_cloud_reference(reference, _delivery_resource_token()) - return json.dumps(result, ensure_ascii=False, default=str) - - -def _read_cloud_projects_resource() -> str: - return _serialize_delivery_resource("cloud://projects") - - -def _read_cloud_project_resource(project_id: str) -> str: - return _serialize_delivery_resource(f"cloud://projects/{project_id}") - - -def _read_cloud_object_resource( - project_id: str, resource_type: str, resource_id: str -) -> str: - return _serialize_delivery_resource( - f"cloud://projects/{project_id}/{resource_type}/{resource_id}" - ) - - # ============== Starlette App Factory ============== _SYSTEM_MCP_SPEC = McpAppSpec( @@ -671,25 +583,12 @@ def _read_cloud_object_resource( include_root_metadata=True, ) -_DELIVERY_MCP_SPEC = McpAppSpec( - name="delivery", - service_name="wegent_delivery", - mount_path=DELIVERY_MCP_MOUNT_PATH, - transport_path=DELIVERY_MCP_TRANSPORT_PATH, - server=delivery_mcp_server, - token_context=_delivery_request_token_info, - log_prefix="Delivery", - include_root_metadata=True, - allow_user_token=True, -) - MCP_APP_SPECS = ( _SYSTEM_MCP_SPEC, _KNOWLEDGE_MCP_SPEC, _INTERACTIVE_FORM_MCP_SPEC, _PROMPT_OPTIMIZATION_MCP_SPEC, _SUBSCRIPTION_MCP_SPEC, - _DELIVERY_MCP_SPEC, ) MCP_CONTEXT_SERVER_NAMES = frozenset( @@ -698,7 +597,6 @@ def _read_cloud_object_resource( "interactive_form_question", "prompt_optimization", "subscription", - "delivery", } ) @@ -712,14 +610,6 @@ def _build_root_metadata(spec: McpAppSpec) -> Dict[str, Any]: "health": f"{spec.mount_path}/health", }, } - if spec.name == "delivery": - metadata.update( - { - "protocol": PROJECT_SPACE_PROTOCOL, - "protocolVersion": PROJECT_SPACE_PROTOCOL_VERSION, - "capabilities": PROJECT_SPACE_CAPABILITIES, - } - ) return metadata @@ -742,8 +632,6 @@ def _build_mcp_app(spec: McpAppSpec) -> Starlette: ensure_prompt_optimization_tools_registered() elif spec.name == "subscription": ensure_subscription_tools_registered() - elif spec.name == "delivery": - ensure_delivery_tools_registered() @asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: diff --git a/backend/app/mcp_server/tools/delivery.py b/backend/app/mcp_server/tools/delivery.py deleted file mode 100644 index f837d7fc5c..0000000000 --- a/backend/app/mcp_server/tools/delivery.py +++ /dev/null @@ -1,581 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Weibo, Inc. -# -# SPDX-License-Identifier: Apache-2.0 - -"""MCP tools for AI access to authorized delivery snapshots.""" - -from typing import Any -from urllib.parse import urlparse - -from app.db.session import SessionLocal -from app.mcp_server.auth import MCPAuthInfo -from app.mcp_server.tools.decorator import mcp_tool -from app.models.delivery import DeliveryAsset, LoopItem, loop_datetime_value_is_unset -from app.schemas.cloud_project import CloudProjectCreate -from app.schemas.delivery import LoopItemCreate, LoopItemUpdate -from app.services.cloud_files import cloud_file_service -from app.services.cloud_projects import cloud_project_service -from app.services.delivery import delivery_service -from app.services.loop_items import loop_item_service - -TEXT_ASSET_LIMIT = 1024 * 1024 - - -def _serialize_todo(item: LoopItem) -> dict[str, Any]: - return { - "id": item.id, - "cloudProjectId": item.cloud_project_id, - "parentId": item.parent_id or None, - "title": item.title, - "description": item.description, - "status": item.status, - "priority": item.priority, - "assigneeUserId": item.assignee_user_id or None, - "dueAt": (None if loop_datetime_value_is_unset(item.due_at) else item.due_at), - "currentDeliveryId": item.current_delivery_id or None, - "version": item.version, - "createdByUserId": item.created_by_user_id, - "createdAt": item.created_at, - "updatedAt": item.updated_at, - "completedAt": ( - None - if loop_datetime_value_is_unset(item.completed_at) - else item.completed_at - ), - "deletedAt": ( - None if loop_datetime_value_is_unset(item.deleted_at) else item.deleted_at - ), - } - - -def _serialize_collaborator(row: dict[str, Any]) -> dict[str, Any]: - return { - "id": row["id"], - "loopItemId": row["loop_item_id"], - "userId": row["user_id"], - "userName": row["user_name"], - "email": row["email"] or None, - "source": row["source"], - "addedByUserId": row["added_by_user_id"], - "createdAt": row["created_at"], - } - - -def _serialize_project(project: Any) -> dict[str, Any]: - return { - "id": project.id, - "key": project.project_key, - "name": project.name, - "description": project.description, - "projectStore": project.project_store, - "taskProvider": project.task_provider, - "providerConfig": project.provider_config, - } - - -@mcp_tool( - name="list_loop_item_deliveries", - description="List immutable deliveries available for a TODO or Loop Item.", - server="delivery", - exclude_params=["token_info"], -) -def list_loop_item_deliveries( - loop_item_id: str, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - deliveries = delivery_service.list_deliveries( - db, loop_item_id, token_info.user_id - ) - return { - "deliveries": [ - { - "id": delivery.id, - "loopItemId": delivery.loop_item_id, - "sourceTask": delivery.source_task_snapshot, - "deliveredAt": delivery.delivered_at, - "assets": [ - { - "id": asset.id, - "path": asset.relative_path, - "size": asset.size_bytes, - "contentType": asset.content_type, - "sha256": asset.sha256, - } - for asset in delivery_service.list_assets(db, delivery.id) - ], - } - for delivery in deliveries - ] - } - - -@mcp_tool( - name="read_delivery_markdown", - description="Read the Markdown handoff instructions from an authorized delivery.", - server="delivery", - exclude_params=["token_info"], -) -def read_delivery_markdown(delivery_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - delivery = delivery_service.get_delivery(db, delivery_id, token_info.user_id) - return { - "deliveryId": delivery.id, - "markdown": delivery_service.read_markdown(delivery), - "chat": delivery_service.read_chat(delivery), - } - - -@mcp_tool( - name="read_delivery_asset", - description=( - "Read a small text delivery asset or obtain a short-lived URL for a binary or " - "large asset. The URL is intended for the running AI task, not end-user sharing." - ), - server="delivery", - exclude_params=["token_info"], -) -def read_delivery_asset(asset_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - asset = db.query(DeliveryAsset).filter(DeliveryAsset.id == asset_id).first() - if asset is None: - return {"error": "Delivery asset not found"} - delivery_service.get_delivery(db, asset.delivery_id, token_info.user_id) - response: dict[str, Any] = { - "id": asset.id, - "path": asset.relative_path, - "size": asset.size_bytes, - "contentType": asset.content_type, - "sha256": asset.sha256, - } - is_text = (asset.content_type or "").startswith("text/") - if is_text and asset.size_bytes <= TEXT_ASSET_LIMIT: - response["content"] = delivery_service.storage.get_bytes( - asset.object_key, TEXT_ASSET_LIMIT - ).decode(errors="replace") - else: - response["downloadUrl"] = delivery_service.storage.download_url( - asset.object_key - ) - response["expiresInSeconds"] = 900 - return response - - -@mcp_tool( - name="list_cloud_projects", - description=( - "List shared cloud project spaces and their independent taskProvider. " - "For github or gitlab projects, use the local wegent_tasks MCP tools for " - "TODO/Issue operations; never copy the project or create a Backend TODO." - ), - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_projects(token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - projects = cloud_project_service.list_accessible(db, token_info.user_id) - return {"projects": [_serialize_project(project) for project in projects]} - - -@mcp_tool( - name="create_cloud_project", - description=( - "Create a cloud project space for sharing TODOs, deliveries, and files. " - "project_key (2-16 alphanumeric characters, uppercased automatically) sets " - "the task numbering prefix, e.g. WEG produces TODO ids like WEG-18; when " - "omitted a key is generated from the name. The creator automatically " - "becomes the project Owner." - ), - server="delivery", - exclude_params=["token_info"], -) -def create_cloud_project( - name: str, - token_info: MCPAuthInfo, - project_key: str | None = None, - description: str = "", -) -> dict[str, Any]: - with SessionLocal() as db: - values = CloudProjectCreate( - name=name, project_key=project_key, description=description - ) - project = cloud_project_service.create(db, token_info.user_id, values) - return _serialize_project(project) - - -@mcp_tool( - name="list_cloud_workspace", - description="List authorized shared files and folders in a cloud project.", - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_workspace( - cloud_project_id: int, - token_info: MCPAuthInfo, - prefix: str = "", -) -> dict[str, Any]: - with SessionLocal() as db: - files = cloud_file_service.list( - db, cloud_project_id, token_info.user_id, prefix or None - ) - return { - "items": [ - { - "id": file.id, - "path": file.path, - "kind": file.kind, - "size": file.size_bytes, - "contentType": file.content_type, - "sha256": file.sha256, - } - for file in files - ] - } - - -@mcp_tool( - name="read_cloud_file", - description=( - "Read an authorized small text file from a cloud project, or obtain a " - "short-lived URL for a binary or large file." - ), - server="delivery", - exclude_params=["token_info"], -) -def read_cloud_file(file_id: int, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - file = cloud_file_service.get(db, file_id, token_info.user_id) - if file.kind != "file" or not file.object_key: - return {"error": "Cloud path is not a file"} - response: dict[str, Any] = { - "id": file.id, - "path": file.path, - "size": file.size_bytes, - "contentType": file.content_type, - "sha256": file.sha256, - } - is_text = (file.content_type or "").startswith("text/") - if is_text and file.size_bytes <= TEXT_ASSET_LIMIT: - response["content"] = cloud_file_service.storage.get_bytes( - file.object_key, TEXT_ASSET_LIMIT - ).decode(errors="replace") - else: - response["downloadUrl"] = cloud_file_service.storage.download_url( - file.object_key - ) - response["expiresInSeconds"] = 900 - return response - - -@mcp_tool( - name="list_cloud_todos", - description=( - "List Backend-native TODOs only when the cloud project's taskProvider is " - "local. GitHub and GitLab Issues are handled by wegent_tasks." - ), - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_todos(cloud_project_id: int, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - items = loop_item_service.list(db, cloud_project_id, token_info.user_id) - return { - "items": [ - { - "id": item.id, - "title": item.title, - "status": item.status, - "assigneeUserId": item.assignee_user_id, - "currentDeliveryId": item.current_delivery_id, - "updatedAt": item.updated_at, - } - for item in items - ] - } - - -@mcp_tool( - name="get_cloud_todo", - description="Get full details of one TODO in an authorized cloud project.", - server="delivery", - exclude_params=["token_info"], -) -def get_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - item = loop_item_service.get(db, item_id, token_info.user_id) - return _serialize_todo(item) - - -@mcp_tool( - name="create_cloud_todo", - description=( - "Create a Backend-native TODO only when the cloud project's taskProvider " - "is local. GitHub and GitLab projects must use wegent_tasks.create_todo. " - "Status must be one of " - "inbox, pending, in_progress, in_review, completed; priority one of " - "none, low, medium, high, urgent; due_at is an ISO 8601 datetime." - ), - server="delivery", - exclude_params=["token_info"], -) -def create_cloud_todo( - cloud_project_id: int, - title: str, - token_info: MCPAuthInfo, - description: str = "", - status: str = "inbox", - priority: str = "none", - assignee_user_id: int | None = None, - due_at: str | None = None, - parent_id: str | None = None, -) -> dict[str, Any]: - with SessionLocal() as db: - values = LoopItemCreate( - title=title, - description=description, - status=status, - priority=priority, - assignee_user_id=assignee_user_id, - due_at=due_at, - parent_id=parent_id, - ) - item = loop_item_service.create( - db, cloud_project_id, token_info.user_id, values - ) - return _serialize_todo(item) - - -@mcp_tool( - name="update_cloud_todo", - description=( - "Update a TODO with optimistic locking: pass the current version from " - "get_cloud_todo. Only provided fields are changed; fields left out keep " - "their current values." - ), - server="delivery", - exclude_params=["token_info"], -) -def update_cloud_todo( - item_id: str, - version: int, - token_info: MCPAuthInfo, - title: str | None = None, - description: str | None = None, - status: str | None = None, - priority: str | None = None, - assignee_user_id: int | None = None, - due_at: str | None = None, - parent_id: str | None = None, -) -> dict[str, Any]: - with SessionLocal() as db: - provided = { - field: value - for field, value in { - "title": title, - "description": description, - "status": status, - "priority": priority, - "assignee_user_id": assignee_user_id, - "due_at": due_at, - "parent_id": parent_id, - }.items() - if value is not None - } - values = LoopItemUpdate(version=version, **provided) - item = loop_item_service.update(db, item_id, token_info.user_id, values) - return _serialize_todo(item) - - -@mcp_tool( - name="delete_cloud_todo", - description=( - "Soft delete a TODO. The TODO moves to the recycle bin and can be " - "restored with restore_cloud_todo." - ), - server="delivery", - exclude_params=["token_info"], -) -def delete_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - item = loop_item_service.delete(db, item_id, token_info.user_id) - return _serialize_todo(item) - - -@mcp_tool( - name="restore_cloud_todo", - description="Restore a soft-deleted TODO from the recycle bin.", - server="delivery", - exclude_params=["token_info"], -) -def restore_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: - with SessionLocal() as db: - item = loop_item_service.restore(db, item_id, token_info.user_id) - return _serialize_todo(item) - - -@mcp_tool( - name="list_cloud_todo_recycle_bin", - description=( - "List soft-deleted TODOs of an authorized cloud project, most recently " - "deleted first." - ), - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_todo_recycle_bin( - cloud_project_id: int, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - items = loop_item_service.list_deleted(db, cloud_project_id, token_info.user_id) - return {"items": [_serialize_todo(item) for item in items]} - - -@mcp_tool( - name="list_cloud_todo_collaborators", - description="List collaborators of a TODO in an authorized cloud project.", - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_todo_collaborators( - item_id: str, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - rows = loop_item_service.list_collaborators(db, item_id, token_info.user_id) - return {"collaborators": [_serialize_collaborator(row) for row in rows]} - - -@mcp_tool( - name="add_cloud_todo_collaborator", - description=( - "Add a cloud project member as a collaborator of a TODO. Requires " - "Developer permission on the project." - ), - server="delivery", - exclude_params=["token_info"], -) -def add_cloud_todo_collaborator( - item_id: str, collaborator_user_id: int, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - row = loop_item_service.add_collaborator( - db, item_id, collaborator_user_id, token_info.user_id - ) - return _serialize_collaborator(row) - - -@mcp_tool( - name="remove_cloud_todo_collaborator", - description=( - "Remove a collaborator from a TODO. Requires Developer permission on " - "the project." - ), - server="delivery", - exclude_params=["token_info"], -) -def remove_cloud_todo_collaborator( - item_id: str, collaborator_user_id: int, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - loop_item_service.remove_collaborator( - db, item_id, collaborator_user_id, token_info.user_id - ) - return {"removed": True, "loopItemId": item_id, "userId": collaborator_user_id} - - -@mcp_tool( - name="list_cloud_todo_attachments", - description="List attachments of a TODO in an authorized cloud project.", - server="delivery", - exclude_params=["token_info"], -) -def list_cloud_todo_attachments( - item_id: str, token_info: MCPAuthInfo -) -> dict[str, Any]: - with SessionLocal() as db: - attachments = loop_item_service.list_attachments( - db, item_id, token_info.user_id - ) - return { - "attachments": [ - { - "id": attachment.id, - "loopItemId": attachment.loop_item_id, - "displayName": attachment.display_name, - "contentType": attachment.content_type or None, - "sizeBytes": attachment.size_bytes, - "sha256": attachment.sha256, - "createdByUserId": attachment.created_by_user_id, - "createdAt": attachment.created_at, - } - for attachment in attachments - ] - } - - -@mcp_tool( - name="resolve_cloud_reference", - description=( - "Resolve a cloud:// reference inserted by Wework @ mentions. Returns the " - "referenced project overview, file content, TODO, or immutable delivery." - ), - server="delivery", - exclude_params=["token_info"], -) -def resolve_cloud_reference(reference: str, token_info: MCPAuthInfo) -> dict[str, Any]: - parsed = urlparse(reference) - if parsed.scheme != "cloud" or parsed.netloc != "projects": - return {"error": "Unsupported cloud reference"} - parts = [part for part in parsed.path.split("/") if part] - if not parts: - # Generic cloud space reference: return every accessible project. - return list_cloud_projects(token_info) - try: - project_id = int(parts[0]) - except ValueError: - return {"error": "Invalid cloud project id"} - - if len(parts) == 1: - with SessionLocal() as db: - project = cloud_project_service.get(db, project_id, token_info.user_id) - project_data = _serialize_project(project) - todos = ( - list_cloud_todos(project_id, token_info) - if project.task_provider == "local" - else { - "items": [], - "taskProvider": project.task_provider, - "todoTool": "wegent_tasks.create_todo", - } - ) - return { - "projectId": project_id, - "project": project_data, - "workspace": list_cloud_workspace(project_id, token_info), - "todos": todos, - } - if len(parts) != 3: - return {"error": "Unsupported cloud reference path"} - - resource_type, resource_id = parts[1], parts[2] - if resource_type == "files": - try: - return read_cloud_file(int(resource_id), token_info) - except ValueError: - return {"error": "Invalid cloud file id"} - if resource_type == "deliveries": - return read_delivery_markdown(resource_id, token_info) - if resource_type == "todos": - with SessionLocal() as db: - item = loop_item_service.get(db, resource_id, token_info.user_id) - if item.cloud_project_id != project_id: - return {"error": "TODO does not belong to the referenced project"} - return { - "id": item.id, - "title": item.title, - "description": item.description, - "status": item.status, - "priority": item.priority, - "assigneeUserId": item.assignee_user_id, - "currentDeliveryId": item.current_delivery_id, - "deliveries": list_loop_item_deliveries(item.id, token_info), - } - return {"error": "Unsupported cloud resource type"} diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index e11729b6b9..a8db94cbc8 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -165,7 +165,8 @@ def task_provider(self) -> str: if not isinstance(metadata, dict): return "local" provider = metadata.get("task_provider") - return provider if provider in {"local", "github", "gitlab"} else "local" + known = {"local", "github", "gitlab", "dingtalk_aitable"} + return provider if provider in known else "local" @property def provider_config(self) -> dict[str, object]: diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index e4086c53aa..d853fefd7f 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -21,7 +21,7 @@ from app.schemas.tagging import MAX_TAGS_PER_ITEM, normalize_tags SnowflakeId = Annotated[str, BeforeValidator(str)] -TaskProvider = Literal["local", "github", "gitlab"] +TaskProvider = Literal["local", "github", "gitlab", "dingtalk_aitable"] ProjectVisibility = Literal["private", "public"] @@ -40,6 +40,20 @@ def normalize_provider_config( config = dict(provider_config) if task_provider == "local": return {} + if task_provider == "dingtalk_aitable": + if "credential" in config: + raise ValueError("encrypted provider credentials cannot be supplied") + for key in ("base_id", "table_id"): + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"provider_config.{key} is required") + config[key] = value.strip() + if "token" in config: + raise ValueError("DingTalk authentication is managed by the local Executor") + mapping = config.get("board_mapping") + if mapping is not None and not isinstance(mapping, dict): + raise ValueError("provider_config.board_mapping must be an object") + return config repository = config.get("repository") if not isinstance(repository, str) or not repository.strip(): raise ValueError("provider_config.repository is required") @@ -117,7 +131,10 @@ class CloudProjectResponse(BaseModel): name: str description: str project_store: Literal["backend"] = "backend" - task_provider: TaskProvider = "local" + # Responses must remain forward-compatible with provider kinds written by + # newer services. Request schemas stay strict so this service only creates + # provider kinds it can operate. + task_provider: str = "local" provider_config: dict[str, object] = Field(default_factory=dict) visibility: ProjectVisibility = "private" created_by_user_id: int diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index a91a73fba8..cece310d3a 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -7,7 +7,14 @@ from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + computed_field, + field_validator, + model_validator, +) from app.schemas.cloud_project import CloudProjectResponse, SnowflakeId from app.schemas.tagging import MAX_TAGS_PER_ITEM @@ -138,6 +145,21 @@ class LoopItemAttachmentResponse(BaseModel): sha256: str created_by_user_id: int created_at: datetime + markdown: str | None = None + + @computed_field + @property + def markdown_url(self) -> str: + return f"wegent://attachments/{self.id}" + + @model_validator(mode="after") + def populate_markdown(self) -> "LoopItemAttachmentResponse": + if self.markdown is None: + self.markdown = ( + f"[{self.display_name}]({self.markdown_url})\n" + f"" + ) + return self @field_validator("content_type", mode="before") @classmethod diff --git a/backend/app/schemas/feedback.py b/backend/app/schemas/feedback.py new file mode 100644 index 0000000000..2ab403ea6c --- /dev/null +++ b/backend/app/schemas/feedback.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Schemas for authenticated Wework feedback submissions.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class FeedbackCreate(BaseModel): + report_id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9._-]+$") + title: str = Field(min_length=1, max_length=255) + description: str = Field(default="", max_length=20_000) + context: dict[str, Any] = Field(default_factory=dict) + + +class FeedbackResponse(BaseModel): + report_id: str + project_id: str + item_id: str + created_by_user_id: int + duplicate: bool diff --git a/backend/app/services/feedback_service.py b/backend/app/services/feedback_service.py new file mode 100644 index 0000000000..3e91116b20 --- /dev/null +++ b/backend/app/services/feedback_service.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Create idempotent Wework feedback items in the configured project.""" + +from __future__ import annotations + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.delivery import CloudProject, LoopItem, loop_datetime_is_unset +from app.models.user import User +from app.schemas.delivery import LoopItemCreate +from app.schemas.feedback import FeedbackCreate, FeedbackResponse +from app.services.loop_items import loop_item_service +from app.services.loop_items.external_provider import external_loop_item_provider +from app.services.loop_items.provider_router import loop_item_provider_router + +CHANNEL_ERROR = "反馈通道异常,请联系开发者" +EXTERNAL_PROVIDERS = {"github", "gitlab"} + + +class FeedbackService: + def submit( + self, + db: Session, + user: User, + values: FeedbackCreate, + bundle: UploadFile, + ) -> FeedbackResponse: + project = self._configured_project(db) + existing = self._find_existing(db, project, user.id, values.report_id) + if existing is None: + created = loop_item_provider_router.create( + db, + project, + user, + LoopItemCreate( + title=values.title, + description=self._description(values), + tags=["feedback", "wework"], + ), + ) + item_id = str(created.values["id"]) + internal_item = created.internal_item + if internal_item is not None: + internal_item.metadata_json = { + **internal_item.metadata_json, + "feedback_report_id": values.report_id, + } + db.commit() + else: + item_id, internal_item = existing + + self._ensure_bundle( + db, project, item_id, internal_item, user.id, values.report_id, bundle + ) + return FeedbackResponse( + report_id=values.report_id, + project_id=str(project.id), + item_id=item_id, + created_by_user_id=user.id, + duplicate=existing is not None, + ) + + @staticmethod + def _configured_project(db: Session) -> CloudProject: + project_id = settings.WEWORK_FEEDBACK_PROJECT_ID.strip() + if not project_id: + raise FeedbackService._channel_error() + project = ( + db.query(CloudProject) + .filter( + CloudProject.id == project_id, + loop_datetime_is_unset(CloudProject.deleted_at), + ) + .first() + ) + if project is None: + raise FeedbackService._channel_error() + return project + + @staticmethod + def _ensure_bundle( + db: Session, + project: CloudProject, + item_id: str, + internal_item: LoopItem | None, + user_id: int, + report_id: str, + bundle: UploadFile, + ) -> None: + filename = f"wework-feedback-{report_id}.zip" + if internal_item is not None: + if loop_item_service.has_attachment(db, internal_item.id, filename): + return + loop_item_service.add_feedback_attachment( + db, + internal_item, + user_id, + filename, + bundle.content_type or "application/zip", + bundle.file, + ) + elif project.task_provider == "gitlab": + external_loop_item_provider.attach_gitlab_upload( + db, + item_id, + user_id, + filename, + bundle.content_type or "application/zip", + bundle.file, + settings.WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB * 1024 * 1024, + ) + # GitHub Issues have no generic file-upload API. The Issue is still + # created, but its diagnostic bundle is intentionally not persisted. + + @staticmethod + def _find_existing( + db: Session, project: CloudProject, user_id: int, report_id: str + ) -> tuple[str, LoopItem | None] | None: + marker = FeedbackService._report_marker(report_id) + if project.task_provider in EXTERNAL_PROVIDERS: + items = external_loop_item_provider.list(db, project.id, user_id) + return next( + ( + (str(item["id"]), None) + for item in items + if item.get("created_by_user_id") == user_id + and marker in str(item.get("description", "")) + ), + None, + ) + + items = ( + db.query(LoopItem) + .filter( + LoopItem.cloud_project_id == project.id, + LoopItem.created_by_user_id == user_id, + loop_datetime_is_unset(LoopItem.deleted_at), + ) + .all() + ) + item = next( + ( + item + for item in items + if marker in item.description + or ( + isinstance(item.metadata_json, dict) + and item.metadata_json.get("feedback_report_id") == report_id + ) + ), + None, + ) + return (item.id, item) if item is not None else None + + @staticmethod + def _description(values: FeedbackCreate) -> str: + sections = [ + values.description.strip(), + FeedbackService._report_marker(values.report_id), + ] + return "\n\n".join(section for section in sections if section) + + @staticmethod + def _report_marker(report_id: str) -> str: + return f"Feedback report: {report_id}" + + @staticmethod + def _channel_error() -> HTTPException: + return HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, CHANNEL_ERROR) + + +feedback_service = FeedbackService() diff --git a/backend/app/services/loop_items/external_provider.py b/backend/app/services/loop_items/external_provider.py index 34ca0bbe63..2568ab58fb 100644 --- a/backend/app/services/loop_items/external_provider.py +++ b/backend/app/services/loop_items/external_provider.py @@ -6,8 +6,12 @@ from __future__ import annotations +import base64 +import hashlib +import re +import tempfile from datetime import datetime, timezone -from typing import Any +from typing import Any, BinaryIO from urllib.parse import quote import httpx @@ -23,11 +27,22 @@ CloudProjectAccess, require_cloud_project_role, ) +from app.services.delivery.storage import delivery_storage PRIORITY_PREFIX = "wegent:priority:" STATUS_PREFIX = "wegent:status:" CREATOR_PREFIX = "wegent:creator:" PARENT_MARKER = "Wegent-Parent:" +GITLAB_PROVIDER_UPLOAD_PATTERN = re.compile( + r"(?P!)?\[(?P[^\]]+)\]\((?P[^)]*/uploads/[^)]+)\)" +) +WEGENT_ATTACHMENT_PATTERN = re.compile( + r"(?P!)?\[(?P[^\]]+)\]\((?P[^)]+)\)" + r"\s*" +) +LEGACY_WEGENT_ATTACHMENT_PATTERN = re.compile( + r"(?P!)?\[(?P[^\]]+)\]\(wegent://attachments/(?Pgitlab-[^)]+)\)" +) class ExternalLoopItemProvider: @@ -90,6 +105,300 @@ def create( ) return self._response(project, issue, access, user_id) + def attach_gitlab_upload( + self, + db: Session, + item_id: str, + user_id: int, + filename: str, + content_type: str, + source: BinaryIO, + max_size_bytes: int, + ) -> dict[str, object] | None: + project, number = self._resolve_project(db, item_id) + if project.task_provider != "gitlab": + return None + require_cloud_project_role(db, project.id, user_id, BaseRole.RestrictedAnalyst) + issue = self._get_issue(project, number) + description = str(issue.get("description") or "") + if filename in description: + attachment = next( + ( + attachment + for attachment in self._gitlab_attachments(project, item_id, issue) + if attachment["display_name"] == filename + ), + None, + ) + if attachment is not None and "wegent://attachments/" in description: + native_markdown = ( + f"[{filename}]({self._decode_attachment_id(str(attachment['id']))[1]})\n" + f"" + ) + description = LEGACY_WEGENT_ATTACHMENT_PATTERN.sub( + lambda match: ( + native_markdown + if match.group("id") == attachment["id"] + else match.group(0) + ), + description, + ) + self._update_issue(project, number, {"description": description}) + attachment["markdown"] = native_markdown + if attachment is not None: + self._store_external_attachment( + str(attachment["id"]), source, max_size_bytes + ) + return attachment + + length = 0 + with tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) as staged: + while chunk := source.read(1024 * 1024): + length += len(chunk) + if length > max_size_bytes: + raise HTTPException( + status.HTTP_413_CONTENT_TOO_LARGE, + "Feedback bundle is too large", + ) + staged.write(chunk) + staged.seek(0) + uploaded = self._request( + project, + "POST", + f"/projects/{quote(self._repository(project), safe='')}/uploads", + files={"file": (filename, staged, content_type)}, + ) + provider_markdown = str(uploaded.get("markdown") or "").strip() + if not provider_markdown: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + "GitLab upload did not return an attachment link", + ) + url = str(uploaded.get("full_path") or uploaded.get("url") or "") + if not url: + match = GITLAB_PROVIDER_UPLOAD_PATTERN.search(provider_markdown) + url = match.group("url") if match else "" + attachment = self._gitlab_attachment_values( + project, + item_id, + filename, + url, + content_type, + length, + user_id, + str(issue.get("updated_at") or self._now()), + ) + markdown = ( + f"{provider_markdown}\n" + f"" + ) + attachment["markdown"] = markdown + self._update_issue( + project, + number, + {"description": f"{description.rstrip()}\n\n{markdown}".strip()}, + ) + staged.seek(0) + self._store_external_attachment( + str(attachment["id"]), staged, max_size_bytes + ) + return attachment + + def list_attachments( + self, db: Session, item_id: str, user_id: int + ) -> list[dict[str, object]]: + project, number = self._resolve_project(db, item_id) + if project.task_provider != "gitlab": + raise HTTPException( + status.HTTP_409_CONFLICT, + "Attachments are not supported by this Issue provider", + ) + require_cloud_project_role(db, project.id, user_id, BaseRole.RestrictedAnalyst) + return self._gitlab_attachments( + project, item_id, self._get_issue(project, number) + ) + + def add_attachment( + self, + db: Session, + item_id: str, + user_id: int, + filename: str, + content_type: str, + source: BinaryIO, + max_size_bytes: int, + ) -> dict[str, object]: + attachment = self.attach_gitlab_upload( + db, + item_id, + user_id, + filename, + content_type, + source, + max_size_bytes, + ) + if attachment is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Attachments are not supported by this Issue provider", + ) + return attachment + + def attachment_access_url( + self, db: Session, attachment_id: str, user_id: int + ) -> str: + item_id, url = self._decode_attachment_id(attachment_id) + project, _ = self._resolve_project(db, item_id) + require_cloud_project_role(db, project.id, user_id, BaseRole.RestrictedAnalyst) + if project.task_provider != "gitlab": + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO attachment not found") + return self._absolute_gitlab_url(project, url) + + def attachment_content( + self, db: Session, attachment_id: str, user_id: int + ) -> tuple[bytes, str, str]: + item_id, url = self._decode_attachment_id(attachment_id) + project, _ = self._resolve_project(db, item_id) + require_cloud_project_role(db, project.id, user_id, BaseRole.RestrictedAnalyst) + try: + content = delivery_storage.get_bytes( + self._external_attachment_key(attachment_id) + ) + except Exception as exc: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "Provider attachment is not available in unified storage", + ) from exc + filename = url.rstrip("/").rsplit("/", 1)[-1] or "attachment" + return content, "application/octet-stream", filename + + def delete_attachment(self, db: Session, attachment_id: str, user_id: int) -> None: + item_id, url = self._decode_attachment_id(attachment_id) + project, number = self._resolve_project(db, item_id) + access = require_cloud_project_role( + db, project.id, user_id, BaseRole.RestrictedAnalyst + ) + issue = self._get_issue(project, number) + response = self._response(project, issue, access, user_id) + if not response["can_edit"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") + description = str(issue.get("description") or "") + updated = description + for pattern in (WEGENT_ATTACHMENT_PATTERN, LEGACY_WEGENT_ATTACHMENT_PATTERN): + updated = pattern.sub( + lambda match: ( + "" if match.group("id") == attachment_id else match.group(0) + ), + updated, + ) + updated = updated.strip() + self._update_issue(project, number, {"description": updated}) + delivery_storage.remove_objects([self._external_attachment_key(attachment_id)]) + + @staticmethod + def _external_attachment_key(attachment_id: str) -> str: + digest = hashlib.sha256(attachment_id.encode()).hexdigest() + return f"loop-items/external-attachments/{digest}" + + def _store_external_attachment( + self, attachment_id: str, source: BinaryIO, max_size_bytes: int + ) -> None: + length = 0 + with tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) as staged: + while chunk := source.read(1024 * 1024): + length += len(chunk) + if length > max_size_bytes: + raise HTTPException( + status.HTTP_413_CONTENT_TOO_LARGE, + "TODO attachment is too large", + ) + staged.write(chunk) + staged.seek(0) + delivery_storage.put_stream( + self._external_attachment_key(attachment_id), + staged, + length, + "application/octet-stream", + ) + + def _gitlab_attachments( + self, project: CloudProject, item_id: str, issue: dict[str, Any] + ) -> list[dict[str, object]]: + description = str(issue.get("description") or "") + created_at = str( + issue.get("updated_at") or issue.get("created_at") or self._now() + ) + creator_id = self._creator_id(self._labels(issue)) + attachments = [] + matches = list(WEGENT_ATTACHMENT_PATTERN.finditer(description)) + matches.extend(LEGACY_WEGENT_ATTACHMENT_PATTERN.finditer(description)) + for match in matches: + attachment_id = match.group("id") + encoded_item_id, url = self._decode_attachment_id(attachment_id) + if encoded_item_id != item_id: + continue + values = self._gitlab_attachment_values( + project, + item_id, + match.group("name"), + url, + "image/*" if match.group("image") else None, + 0, + creator_id, + created_at, + ) + values["markdown"] = match.group(0) + attachments.append(values) + return attachments + + def _gitlab_attachment_values( + self, + project: CloudProject, + item_id: str, + filename: str, + url: str, + content_type: str | None, + size_bytes: int, + user_id: int, + created_at: str, + ) -> dict[str, object]: + raw_id = ( + base64.urlsafe_b64encode(f"{item_id}\n{url}".encode()).decode().rstrip("=") + ) + return { + "id": f"gitlab-{raw_id}", + "loop_item_id": item_id, + "display_name": filename, + "content_type": content_type, + "size_bytes": size_bytes, + "sha256": hashlib.sha256(url.encode()).hexdigest(), + "created_by_user_id": user_id, + "created_at": created_at, + } + + @staticmethod + def _decode_attachment_id(attachment_id: str) -> tuple[str, str]: + if not attachment_id.startswith("gitlab-"): + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO attachment not found") + encoded = attachment_id.removeprefix("gitlab-") + try: + value = base64.urlsafe_b64decode( + encoded + "=" * (-len(encoded) % 4) + ).decode() + item_id, url = value.split("\n", 1) + except (ValueError, UnicodeError) as exc: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "TODO attachment not found" + ) from exc + return item_id, url + + def _absolute_gitlab_url(self, project: CloudProject, url: str) -> str: + if url.startswith(("https://", "http://")): + return url + config, _ = self._config(project) + domain = str(config.get("domain") or "gitlab.com").rstrip("/") + return f"https://{domain}/{url.lstrip('/')}" + def update( self, db: Session, @@ -293,6 +602,7 @@ def _request( *, json: object | None = None, params: dict[str, object] | None = None, + files: dict[str, object] | None = None, ) -> Any: config, token = self._config(project) domain = str( @@ -322,6 +632,7 @@ def _request( headers=headers, json=json, params=params, + files=files, timeout=30, ) response.raise_for_status() diff --git a/backend/app/services/loop_items/provider_router.py b/backend/app/services/loop_items/provider_router.py new file mode 100644 index 0000000000..132a04249b --- /dev/null +++ b/backend/app/services/loop_items/provider_router.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Route loop-item creation through the configured project provider.""" + +from dataclasses import dataclass +from typing import BinaryIO + +from sqlalchemy.orm import Session + +from app.models.cloud_project import CloudProject +from app.models.delivery import LoopItem +from app.models.user import User +from app.schemas.delivery import LoopItemCreate +from app.services.loop_items.external_provider import external_loop_item_provider +from app.services.loop_items.service import loop_item_service + + +@dataclass(frozen=True) +class RoutedLoopItem: + values: dict[str, object] + internal_item: LoopItem | None + + +class LoopItemProviderRouter: + def create( + self, + db: Session, + project: CloudProject, + user: User, + values: LoopItemCreate, + ) -> RoutedLoopItem: + if project.task_provider in {"github", "gitlab"}: + created = external_loop_item_provider.create( + db, project.id, user.id, user.user_name, values + ) + return RoutedLoopItem(values=created, internal_item=None) + + item = loop_item_service.create(db, project.id, user.id, values) + response = loop_item_service.response_values(db, item, user.id) + return RoutedLoopItem(values=response, internal_item=item) + + +loop_item_provider_router = LoopItemProviderRouter() + + +class LoopItemAttachmentProviderRouter: + def list(self, db: Session, item_id: str, user_id: int) -> list[object]: + if external_loop_item_provider.is_external_item(db, item_id): + return external_loop_item_provider.list_attachments(db, item_id, user_id) + return loop_item_service.list_attachments(db, item_id, user_id) + + def add( + self, + db: Session, + item_id: str, + user_id: int, + filename: str, + content_type: str, + source: BinaryIO, + max_size_bytes: int, + ) -> object: + if external_loop_item_provider.is_external_item(db, item_id): + return external_loop_item_provider.add_attachment( + db, + item_id, + user_id, + filename, + content_type, + source, + max_size_bytes, + ) + return loop_item_service.add_attachment( + db, item_id, user_id, filename, content_type, source + ) + + def content( + self, db: Session, attachment_id: str, user_id: int + ) -> tuple[bytes, str, str]: + if attachment_id.startswith("gitlab-"): + return external_loop_item_provider.attachment_content( + db, attachment_id, user_id + ) + return loop_item_service.attachment_content(db, attachment_id, user_id) + + def delete(self, db: Session, attachment_id: str, user_id: int) -> None: + if attachment_id.startswith("gitlab-"): + external_loop_item_provider.delete_attachment(db, attachment_id, user_id) + return + loop_item_service.delete_attachment(db, attachment_id, user_id) + + def require_access(self, db: Session, attachment_id: str, user_id: int) -> None: + if attachment_id.startswith("gitlab-"): + external_loop_item_provider.attachment_access_url( + db, attachment_id, user_id + ) + return + loop_item_service.require_attachment_access(db, attachment_id, user_id) + + +loop_item_attachment_provider_router = LoopItemAttachmentProviderRouter() diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index eaaa1ba030..978ba00e87 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -361,6 +361,68 @@ def add_attachment( if project is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found") + return self._store_attachment( + db, + item, + project, + user_id, + display_name, + content_type, + source, + settings.DELIVERY_MAX_ASSET_SIZE_MB, + ) + + def has_attachment(self, db: Session, item_id: str, display_name: str) -> bool: + return ( + db.query(LoopItemAttachment) + .filter( + LoopItemAttachment.loop_item_id == item_id, + LoopItemAttachment.display_name == display_name, + ) + .first() + is not None + ) + + def add_feedback_attachment( + self, + db: Session, + item: LoopItem, + user_id: int, + display_name: str, + content_type: str, + source: BinaryIO, + ) -> LoopItemAttachment: + metadata = item.metadata_json if isinstance(item.metadata_json, dict) else {} + if item.created_by_user_id != user_id or not metadata.get("feedback_report_id"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "Invalid feedback attachment" + ) + project = db.get(CloudProject, item.cloud_project_id) + if project is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found") + return self._store_attachment( + db, + item, + project, + user_id, + display_name, + content_type, + source, + settings.WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB, + ) + + @staticmethod + def _store_attachment( + db: Session, + item: LoopItem, + project: CloudProject, + user_id: int, + display_name: str, + content_type: str, + source: BinaryIO, + max_size_mb: int, + ) -> LoopItemAttachment: + attachment_id = str(uuid.uuid4()) object_key = ( f"projects/{project.public_id}/loop-items/{item.id}/attachments/" @@ -373,7 +435,7 @@ def add_attachment( digest.update(chunk) staged.write(chunk) length += len(chunk) - if length > settings.DELIVERY_MAX_ASSET_SIZE_MB * 1024 * 1024: + if length > max_size_mb * 1024 * 1024: raise HTTPException( status.HTTP_413_CONTENT_TOO_LARGE, "TODO attachment is too large", @@ -407,6 +469,21 @@ def attachment_access_url( attachment = self._get_attachment(db, attachment_id, user_id) return delivery_storage.download_url(attachment.object_key) + def attachment_content( + self, db: Session, attachment_id: str, user_id: int + ) -> tuple[bytes, str, str]: + attachment = self._get_attachment(db, attachment_id, user_id) + return ( + delivery_storage.get_bytes(attachment.object_key), + attachment.content_type or "application/octet-stream", + attachment.display_name, + ) + + def require_attachment_access( + self, db: Session, attachment_id: str, user_id: int + ) -> None: + self._get_attachment(db, attachment_id, user_id) + def delete_attachment(self, db: Session, attachment_id: str, user_id: int) -> None: attachment = self._get_attachment(db, attachment_id, user_id) item = self.get(db, attachment.loop_item_id, user_id) diff --git a/backend/app/services/runtime_work_service.py b/backend/app/services/runtime_work_service.py index 28789be3db..cb7a9fc4ca 100644 --- a/backend/app/services/runtime_work_service.py +++ b/backend/app/services/runtime_work_service.py @@ -3485,9 +3485,7 @@ def _build_runtime_execution_request( _apply_runtime_task_target(execution_request, target) _apply_runtime_model_options(db, execution_request, user, payload) _apply_runtime_attachments(db, execution_request, user_id, request.attachment_ids) - from app.core.config import settings from app.schemas.base_role import BaseRole - from app.services.auth import create_task_token from app.services.cloud_projects.access import require_cloud_project_role from app.services.delivery import delivery_service @@ -3497,23 +3495,6 @@ def _build_runtime_execution_request( require_cloud_project_role( db, request.cloud_project_id, user_id, BaseRole.Reporter ) - token = create_task_token( - task_id=task.id, - subtask_id=subtask.id, - user_id=user.id, - user_name=user.user_name, - ) - execution_request.mcp_servers.append( - { - "name": "wegent_delivery", - "url": ( - f"{settings.BACKEND_INTERNAL_URL.rstrip('/')}" - f"{settings.API_PREFIX}/mcp/delivery/sse" - ), - "type": "streamable-http", - "headers": {"Authorization": f"Bearer {token}"}, - } - ) return execution_request @@ -3532,19 +3513,13 @@ def _message_with_application_context( ): entries.append( "[projectSpaceCapability]\n" - "The user activated the Wegent project-space capability.\n" - "Project storage and task source are independent.\n" - "Use wegent_tasks for local project spaces and for GitHub or GitLab " - "Issues, even when the project space is stored in the Backend.\n" - "Use wegent_delivery for cloud project metadata, files, deliveries, " - "and Backend-native TODOs only.\n" - "wegent_delivery and wegent_tasks are server ids, not callable tools.\n" - "List both sources when resolving a project name.\n" - "Never create or copy a cloud project merely because a local project " - "is not returned by list_cloud_projects.\n" - "Use resolve_cloud_reference to resolve cloud:// references.\n" - "MCP resources describe addressable data; do not use " - "list_mcp_resources to discover tools." + "Use wework_space as the only interface for WeWork project spaces, " + "board items, files, attachments, and deliveries.\n" + "Storage and task providers are internal implementation details.\n" + "Do not use git commands or call GitHub, GitLab, or object-storage " + "APIs to inspect or modify project-space data.\n" + "Use list_spaces to discover spaces, get_board_item for item details, " + "and read_item_attachment for attachment contents." ) if not entries: return message diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 0badbd1eee..5c1838e351 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -142,6 +142,75 @@ def test_cloud_project_generates_key_when_omitted( assert 2 <= len(created.json()["project_key"]) <= 16 +def test_cloud_project_list_tolerates_unknown_task_provider( + test_client: TestClient, + test_db: Session, + test_token: str, +) -> None: + known = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "known", "name": "Known provider"}, + ).json() + newer = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "newer", "name": "Newer provider"}, + ).json() + stored = test_db.query(CloudProject).filter(CloudProject.id == newer["id"]).one() + stored.metadata_json = { + **stored.metadata_json, + "task_provider": "provider-from-newer-branch", + } + test_db.commit() + + listed = test_client.get("/api/v1/cloud-projects", headers=_auth(test_token)) + + assert listed.status_code == 200 + projects = {item["id"]: item for item in listed.json()["items"]} + assert projects[known["id"]]["task_provider"] == "local" + assert projects[newer["id"]]["task_provider"] == "provider-from-newer-branch" + + +def test_added_member_can_list_private_cloud_project( + test_client: TestClient, + test_db: Session, + test_token: str, +) -> None: + member = User( + user_name="cloud-project-member", + password_hash="unused", + email="cloud-project-member@example.com", + is_active=True, + git_info=None, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + member_token = create_access_token(data={"sub": member.user_name}) + + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "sharedprivate", + "name": "Shared private project", + "visibility": "private", + }, + ).json() + added = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/members", + headers=_auth(test_token), + json={"user_id": member.id, "role": "Developer"}, + ) + + assert added.status_code == 201 + listed = test_client.get("/api/v1/cloud-projects", headers=_auth(member_token)) + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()["items"]] == [project["id"]] + assert listed.json()["items"][0]["access_role"] == "Developer" + + def test_public_project_visitors_only_access_their_own_todo_details( test_client: TestClient, test_db: Session, diff --git a/backend/tests/api/test_deliveries_api.py b/backend/tests/api/test_deliveries_api.py index 81a1efe6e3..a051be0b4b 100644 --- a/backend/tests/api/test_deliveries_api.py +++ b/backend/tests/api/test_deliveries_api.py @@ -90,6 +90,7 @@ def test_todo_attachment_flow( attachment = uploaded.json() assert attachment["display_name"] == "brief.txt" assert attachment["size_bytes"] == 7 + assert attachment["markdown_url"] == f"wegent://attachments/{attachment['id']}" listed = test_client.get( f"/api/v1/loop-items/{item_id}/attachments", headers=_auth(test_token) @@ -101,7 +102,17 @@ def test_todo_attachment_flow( headers=_auth(test_token), ) assert accessed.status_code == 200 - assert accessed.json()["url"].startswith("https://storage.test/") + assert accessed.json() == { + "url": f"wegent://attachments/{attachment['id']}", + "expires_in_seconds": 0, + } + + content = test_client.get( + f"/api/v1/loop-item-attachments/{attachment['id']}/content", + headers=_auth(test_token), + ) + assert content.status_code == 200 + assert content.content == b"context" deleted = test_client.delete( f"/api/v1/loop-item-attachments/{attachment['id']}", diff --git a/backend/tests/api/test_feedback_api.py b/backend/tests/api/test_feedback_api.py new file mode 100644 index 0000000000..cb909d8d7c --- /dev/null +++ b/backend/tests/api/test_feedback_api.py @@ -0,0 +1,423 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""API tests for Wework feedback submission.""" + +import io +import json +import uuid +from typing import BinaryIO + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.delivery import CloudProject, LoopItem +from app.models.user import User +from app.services.loop_items.external_provider import external_loop_item_provider + + +class FeedbackStorage: + def __init__(self) -> None: + self.objects: dict[str, bytes] = {} + + def put_stream( + self, + object_key: str, + stream: BinaryIO, + length: int, + content_type: str, + ) -> None: + self.objects[object_key] = stream.read(length) + + def remove_objects(self, object_keys: list[str]) -> None: + for object_key in object_keys: + self.objects.pop(object_key, None) + + def get_bytes(self, object_key: str, max_bytes: int | None = None) -> bytes: + content = self.objects[object_key] + return content if max_bytes is None else content[:max_bytes] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def feedback_project(test_db: Session, test_user: User) -> CloudProject: + public_id = str(uuid.uuid4()) + project = CloudProject( + public_id=public_id, + project_key="FEEDBACK", + name="Wework feedback", + description="", + created_by_user_id=test_user.id, + storage_prefix=f"projects/{public_id}", + ) + test_db.add(project) + test_db.commit() + test_db.refresh(project) + return project + + +@pytest.fixture +def feedback_storage(monkeypatch: pytest.MonkeyPatch) -> FeedbackStorage: + storage = FeedbackStorage() + monkeypatch.setattr("app.services.loop_items.service.delivery_storage", storage) + return storage + + +def _feedback_form(report_id: str, title: str) -> dict[str, str]: + return { + "report_id": report_id, + "title": title, + "description": "The send button remained disabled.", + "context": json.dumps({"taskId": "task-1", "version": "1.2.3"}), + } + + +def test_submit_feedback_creates_board_item_for_current_user( + test_client: TestClient, + test_token: str, + test_db: Session, + test_user: User, + feedback_project: CloudProject, + feedback_storage: FeedbackStorage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, "WEWORK_FEEDBACK_PROJECT_ID", str(feedback_project.id) + ) + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=_feedback_form("WF-100", "Workbench stopped responding"), + files={"bundle": ("feedback.zip", b"diagnostics", "application/zip")}, + ) + + assert response.status_code == 201 + assert response.json() == { + "report_id": "WF-100", + "project_id": str(feedback_project.id), + "item_id": "FEEDBACK-1", + "created_by_user_id": test_user.id, + "duplicate": False, + } + item = test_db.get(LoopItem, "FEEDBACK-1") + assert item is not None + assert item.created_by_user_id == test_user.id + assert item.status == "inbox" + assert item.metadata_json == { + "tags": ["feedback", "wework"], + "feedback_report_id": "WF-100", + } + assert item.description == ( + "The send button remained disabled.\n\nFeedback report: WF-100" + ) + assert "taskId" not in item.description + assert list(feedback_storage.objects.values()) == [b"diagnostics"] + + +def test_submit_feedback_keeps_large_diagnostic_context_out_of_description( + test_client: TestClient, + test_token: str, + test_db: Session, + feedback_project: CloudProject, + feedback_storage: FeedbackStorage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, "WEWORK_FEEDBACK_PROJECT_ID", str(feedback_project.id) + ) + form = _feedback_form("WF-LARGE", "Large diagnostics") + form["context"] = json.dumps({"runtimeLogs": "x" * 100_000}) + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=form, + files={"bundle": ("feedback.zip", b"full diagnostics", "application/zip")}, + ) + + assert response.status_code == 201 + item = test_db.get(LoopItem, "FEEDBACK-1") + assert item is not None + assert item.description == ( + "The send button remained disabled.\n\nFeedback report: WF-LARGE" + ) + assert list(feedback_storage.objects.values()) == [b"full diagnostics"] + + +def test_submit_feedback_is_idempotent_per_user_and_report( + test_client: TestClient, + test_token: str, + test_db: Session, + feedback_project: CloudProject, + feedback_storage: FeedbackStorage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, "WEWORK_FEEDBACK_PROJECT_ID", str(feedback_project.id) + ) + request = _feedback_form("WF-RETRY", "Repeated feedback") + files = {"bundle": ("feedback.zip", b"diagnostics", "application/zip")} + + first = test_client.post( + "/api/v1/feedback", headers=_auth(test_token), data=request, files=files + ) + second = test_client.post( + "/api/v1/feedback", headers=_auth(test_token), data=request, files=files + ) + + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["item_id"] == second.json()["item_id"] + assert first.json()["duplicate"] is False + assert second.json()["duplicate"] is True + assert test_db.query(LoopItem).filter(LoopItem.id == "FEEDBACK-1").count() == 1 + assert len(feedback_storage.objects) == 1 + + +def test_submit_feedback_reports_unavailable_channel_when_not_configured( + test_client: TestClient, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "WEWORK_FEEDBACK_PROJECT_ID", "") + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=_feedback_form("WF-101", "Cannot submit"), + files={"bundle": ("feedback.zip", b"diagnostics", "application/zip")}, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "反馈通道异常,请联系开发者" + + +def test_submit_feedback_reports_unavailable_channel_when_project_is_missing( + test_client: TestClient, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "WEWORK_FEEDBACK_PROJECT_ID", "999999999") + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=_feedback_form("WF-MISSING", "Cannot submit"), + files={"bundle": ("feedback.zip", b"diagnostics", "application/zip")}, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "反馈通道异常,请联系开发者" + + +def test_submit_feedback_uses_gitlab_issue_provider_and_uploads_bundle( + test_client: TestClient, + test_token: str, + test_db: Session, + test_user: User, + monkeypatch: pytest.MonkeyPatch, +) -> None: + public_id = str(uuid.uuid4()) + project = CloudProject( + public_id=public_id, + project_key="GLFEEDBACK", + name="GitLab feedback", + description="", + created_by_user_id=test_user.id, + storage_prefix=f"projects/{public_id}", + metadata_json={"task_provider": "gitlab", "provider_config": {}}, + ) + test_db.add(project) + test_db.commit() + test_db.refresh(project) + monkeypatch.setattr(settings, "WEWORK_FEEDBACK_PROJECT_ID", str(project.id)) + monkeypatch.setattr( + external_loop_item_provider, + "create", + lambda _db, _project_id, _user_id, _user_name, _values: {"id": "GLFEEDBACK-7"}, + ) + monkeypatch.setattr(external_loop_item_provider, "list", lambda *_args: []) + uploaded: dict[str, object] = {} + + def attach( + _db: Session, + item_id: str, + user_id: int, + filename: str, + content_type: str, + source: BinaryIO, + max_size_bytes: int, + ) -> None: + uploaded.update( + item_id=item_id, + user_id=user_id, + filename=filename, + content_type=content_type, + content=source.read(), + max_size_bytes=max_size_bytes, + ) + + monkeypatch.setattr(external_loop_item_provider, "attach_gitlab_upload", attach) + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=_feedback_form("WF-GITLAB", "GitLab feedback"), + files={"bundle": ("feedback.zip", b"gitlab diagnostics", "application/zip")}, + ) + + assert response.status_code == 201 + assert response.json()["item_id"] == "GLFEEDBACK-7" + assert response.json()["created_by_user_id"] == test_user.id + assert uploaded == { + "item_id": "GLFEEDBACK-7", + "user_id": test_user.id, + "filename": "wework-feedback-WF-GITLAB.zip", + "content_type": "application/zip", + "content": b"gitlab diagnostics", + "max_size_bytes": settings.WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB * 1024 * 1024, + } + + +def test_submit_feedback_uses_github_issue_without_persisting_bundle( + test_client: TestClient, + test_token: str, + test_db: Session, + test_user: User, + feedback_storage: FeedbackStorage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + public_id = str(uuid.uuid4()) + project = CloudProject( + public_id=public_id, + project_key="GHFEEDBACK", + name="GitHub feedback", + description="", + created_by_user_id=test_user.id, + storage_prefix=f"projects/{public_id}", + metadata_json={"task_provider": "github", "provider_config": {}}, + ) + test_db.add(project) + test_db.commit() + test_db.refresh(project) + monkeypatch.setattr(settings, "WEWORK_FEEDBACK_PROJECT_ID", str(project.id)) + monkeypatch.setattr( + external_loop_item_provider, + "create", + lambda _db, _project_id, _user_id, _user_name, _values: {"id": "GHFEEDBACK-9"}, + ) + monkeypatch.setattr(external_loop_item_provider, "list", lambda *_args: []) + + def unexpected_gitlab_upload(*_args: object, **_kwargs: object) -> None: + raise AssertionError("GitHub feedback must not use the GitLab upload API") + + monkeypatch.setattr( + external_loop_item_provider, + "attach_gitlab_upload", + unexpected_gitlab_upload, + ) + + response = test_client.post( + "/api/v1/feedback", + headers=_auth(test_token), + data=_feedback_form("WF-GITHUB", "GitHub feedback"), + files={"bundle": ("feedback.zip", b"github diagnostics", "application/zip")}, + ) + + assert response.status_code == 201 + assert response.json()["item_id"] == "GHFEEDBACK-9" + assert response.json()["created_by_user_id"] == test_user.id + assert feedback_storage.objects == {} + assert test_db.query(LoopItem).filter(LoopItem.id == "GHFEEDBACK-9").count() == 0 + + +def test_gitlab_feedback_bundle_uses_project_upload_and_updates_issue( + test_db: Session, + test_user: User, + test_admin_user: User, + feedback_project: CloudProject, + feedback_storage: FeedbackStorage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + feedback_project.created_by_user_id = test_admin_user.id + feedback_project.metadata_json = { + "visibility": "public", + "task_provider": "gitlab", + "provider_config": {}, + } + monkeypatch.setattr( + external_loop_item_provider, + "_resolve_project", + lambda _db, _item_id: (feedback_project, 7), + ) + monkeypatch.setattr( + "app.services.loop_items.external_provider.delivery_storage", + feedback_storage, + ) + monkeypatch.setattr( + external_loop_item_provider, + "_get_issue", + lambda _project, _number: {"description": "Feedback report: WF-UPLOAD"}, + ) + monkeypatch.setattr( + external_loop_item_provider, "_repository", lambda _project: "group/project" + ) + request: dict[str, object] = {} + + def upload( + _project: CloudProject, method: str, path: str, **kwargs: object + ) -> dict: + files = kwargs["files"] + assert isinstance(files, dict) + filename, source, content_type = files["file"] + request.update( + method=method, + path=path, + filename=filename, + content=source.read(), + content_type=content_type, + ) + return {"markdown": "[wework-feedback-WF-UPLOAD.zip](/uploads/bundle.zip)"} + + monkeypatch.setattr(external_loop_item_provider, "_request", upload) + updates: list[tuple[int, dict[str, object]]] = [] + monkeypatch.setattr( + external_loop_item_provider, + "_update_issue", + lambda _project, number, payload: updates.append((number, payload)), + ) + attachment = external_loop_item_provider.attach_gitlab_upload( + test_db, + "GLFEEDBACK-7", + test_user.id, + "wework-feedback-WF-UPLOAD.zip", + "application/zip", + io.BytesIO(b"diagnostics"), + 1024, + ) + + assert request == { + "method": "POST", + "path": "/projects/group%2Fproject/uploads", + "filename": "wework-feedback-WF-UPLOAD.zip", + "content": b"diagnostics", + "content_type": "application/zip", + } + assert updates[0][0] == 7 + description = str(updates[0][1]["description"]) + assert description.startswith("Feedback report: WF-UPLOAD\n\n") + assert "[wework-feedback-WF-UPLOAD.zip](/uploads/bundle.zip)" in description + assert "', })), accessLoopItemAttachment: vi.fn(async () => ({ url: 'https://storage.test/attachment-1', expires_in_seconds: 900, })), + readLoopItemAttachment: vi.fn(async () => new Blob(['context'])), + downloadLoopItemAttachment: vi.fn(async () => undefined), deleteLoopItemAttachment: vi.fn(async () => undefined), listTaskBindings: vi.fn(async () => [ { @@ -381,6 +386,37 @@ describe('CloudTodoWorkspace', () => { expect(screen.getByText('自动加入')).toBeInTheDocument() }) + it('copies the cloud project ID before or after opening the project', async () => { + const writeText = vi.fn(async () => undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + render( + + ) + + await userEvent.click(await screen.findByTestId('cloud-project-copy-id-11')) + expect(writeText).toHaveBeenLastCalledWith('11') + + await userEvent.click(screen.getByTestId('cloud-sidebar-project-11')) + await userEvent.click(screen.getByTestId('cloud-sidebar-project-more-11')) + expect(screen.getByTestId('cloud-sidebar-project-menu-11')).toBeInTheDocument() + await userEvent.click(screen.getByTestId('cloud-project-header')) + expect(screen.queryByTestId('cloud-sidebar-project-menu-11')).not.toBeInTheDocument() + + await userEvent.click(screen.getByTestId('cloud-sidebar-project-more-11')) + await userEvent.click(screen.getByTestId('cloud-sidebar-copy-project-id-11')) + + expect(writeText).toHaveBeenLastCalledWith('11') + expect(screen.queryByTestId('cloud-sidebar-project-menu-11')).not.toBeInTheDocument() + }) + it('manually adds a project member as a TODO collaborator', async () => { const workbenchServices = services() render( @@ -446,6 +482,12 @@ describe('CloudTodoWorkspace', () => { ) ) expect((await screen.findAllByText('brief.txt')).length).toBeGreaterThan(0) + + await userEvent.click(screen.getByTestId('cloud-todo-attachment-download-attachment-1')) + expect(workbenchServices.deliveryApi?.downloadLoopItemAttachment).toHaveBeenCalledWith( + 'attachment-1', + 'brief.txt' + ) }) it('collapses and restores the sidebar chrome', async () => { @@ -596,6 +638,44 @@ describe('CloudTodoWorkspace', () => { ) }) + it('creates a cloud DingTalk AI Table project from a shared link', async () => { + const workbenchServices = services() + render( + + ) + + await userEvent.click(await screen.findByTestId('cloud-project-add')) + await userEvent.type(screen.getByTestId('cloud-project-name'), '钉钉需求池') + await userEvent.click(screen.getByTestId('cloud-project-task-provider-dingtalk_aitable')) + expect(screen.getByTestId('cloud-project-create-confirm')).toBeDisabled() + await userEvent.type( + screen.getByTestId('cloud-project-aitable-url'), + 'https://alidocs.dingtalk.com/i/nodes/pYLaezmVN63PAZGPTPKyr2X3VrMqPxX6?iframeQuery=entrance%3Ddata%26sheetId%3DhERWDMS%26viewId%3DqvGDAH2' + ) + expect(screen.queryByTestId('cloud-project-aitable-token')).not.toBeInTheDocument() + await userEvent.click(screen.getByTestId('cloud-project-create-confirm')) + + await waitFor(() => + expect(workbenchServices.deliveryApi?.createCloudProject).toHaveBeenCalledWith({ + name: '钉钉需求池', + description: '', + task_provider: 'dingtalk_aitable', + provider_config: { + base_id: 'pYLaezmVN63PAZGPTPKyr2X3VrMqPxX6', + table_id: 'hERWDMS', + source_url: + 'https://alidocs.dingtalk.com/i/nodes/pYLaezmVN63PAZGPTPKyr2X3VrMqPxX6?iframeQuery=entrance%3Ddata%26sheetId%3DhERWDMS%26viewId%3DqvGDAH2', + view_id: 'qvGDAH2', + }, + visibility: 'private', + }) + ) + }) + it('routes an explicitly local project to the local project-space API', async () => { const cloudServices = services() const localServices = services() @@ -740,6 +820,82 @@ describe('CloudTodoWorkspace', () => { expect(await screen.findByText('已保存')).toBeInTheDocument() }) + it('updates DingTalk table connection and board mappings from project management', async () => { + const workbenchServices = services() + const aitableProject = { + ...project, + task_provider: 'dingtalk_aitable' as const, + provider_config: { + base_id: 'base-1', + table_id: 'table-1', + source_url: 'https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1', + credential_configured: true, + board_mapping: { title_field_id: 'fld-title' }, + }, + } + workbenchServices.deliveryApi!.listCloudProjects = vi.fn(async () => ({ + items: [aitableProject], + })) + workbenchServices.deliveryApi!.updateCloudProject = vi.fn(async (_projectId, values) => ({ + ...aitableProject, + provider_config: values.provider_config ?? aitableProject.provider_config, + version: values.version + 1, + })) + workbenchServices.aitableApi = { + configureProject: vi.fn(async () => undefined), + describe: vi.fn(async () => ({ + base: {}, + tables: [], + active_table: {}, + fields: [ + { id: 'fld-title', name: '需求名称', type: 'text', config: {}, raw: {} }, + { id: 'fld-status', name: '状态', type: 'singleSelect', config: {}, raw: {} }, + ], + })), + listRecords: vi.fn(async () => ({ items: [], cursor: null, has_more: false })), + createRecord: vi.fn(), + updateRecord: vi.fn(), + deleteRecord: vi.fn(), + createField: vi.fn(), + updateField: vi.fn(), + deleteField: vi.fn(), + } + + render( + + ) + + await userEvent.click((await screen.findAllByText('Wegent V4'))[0]) + await userEvent.click(await screen.findByTestId('cloud-project-manage-view')) + await userEvent.selectOptions( + await screen.findByTestId('aitable-mapping-status_field_id'), + 'fld-status' + ) + await userEvent.click(screen.getByTestId('aitable-manage-save')) + + await waitFor(() => + expect(workbenchServices.deliveryApi!.updateCloudProject).toHaveBeenCalledWith(11, { + version: 1, + provider_config: { + base_id: 'base-1', + table_id: 'table-1', + source_url: 'https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1', + board_mapping: { + title_field_id: 'fld-title', + status_field_id: 'fld-status', + }, + status_mode: 'mapped', + status_mapping: {}, + custom_statuses: [], + }, + }) + ) + }) + it('keeps the project header above the macOS drag region and opens new TODO', async () => { render( = { completed: '已完成的任务会归档在这里', } -function boardStatusFromDropId(id: string | number | undefined): CloudLoopItem['status'] | null { +function boardStatusFromDropId(id: string | number | undefined): string | null { if (typeof id !== 'string' || !id.startsWith('todo-column:')) return null - const status = id.slice('todo-column:'.length) as CloudLoopItem['status'] - return columns.some(column => column.status === status) ? status : null + return id.slice('todo-column:'.length) || null } function boardCardIdFromDropId(id: string | number | undefined): string | null { @@ -230,13 +235,7 @@ function DraggableTodoCard({ ) } -function TodoColumnDropzone({ - status, - children, -}: { - status: CloudLoopItem['status'] - children: React.ReactNode -}) { +function TodoColumnDropzone({ status, children }: { status: string; children: React.ReactNode }) { const { isOver, setNodeRef } = useDroppable({ id: `todo-column:${status}` }) return (
('private') const [repositoryAddress, setRepositoryAddress] = useState('') const [token, setToken] = useState('') + const [aitableUrl, setAitableUrl] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - const externalProvider = taskProvider !== 'local' + const repositoryProvider = taskProvider === 'github' || taskProvider === 'gitlab' + const isAITableProvider = taskProvider === 'dingtalk_aitable' + const aitableLink = parseDingTalkAITableLink(aitableUrl) const canSubmit = Boolean( - name.trim() && (!externalProvider || repositoryAddress.trim()) && !saving + name.trim() && + (!repositoryProvider || repositoryAddress.trim()) && + (!isAITableProvider || aitableLink) && + !saving ) async function submit() { @@ -346,13 +351,19 @@ function ProjectDialog({ try { const selectedApi = availableApis.find(option => option.location === location)?.api if (!selectedApi) throw new Error('所选项目空间位置当前不可用') - const providerConfig = - taskProvider === 'local' - ? {} - : { + const providerConfig = isAITableProvider + ? { + base_id: aitableLink!.baseId, + table_id: aitableLink!.tableId, + source_url: aitableLink!.url, + ...(aitableLink!.viewId ? { view_id: aitableLink!.viewId } : {}), + } + : repositoryProvider + ? { ...repositoryProviderConfig(repositoryAddress, taskProvider), ...(token.trim() ? { token: token.trim() } : {}), } + : {} const project = await selectedApi.createCloudProject({ name: name.trim(), description: description.trim(), @@ -486,12 +497,13 @@ function ProjectDialog({

任务来源

-
+
{( [ ['local', ListTodo, '内置任务', location === 'local' ? '保存在本机' : '保存在云端'], ['github', GitBranch, 'GitHub', '读取 Issues'], ['gitlab', GitBranch, 'GitLab', '读取 Issues'], + ['dingtalk_aitable', Grid3X3, '钉钉多维表格', '同步表格记录'], ] as const ).map(([value, ProviderIcon, label, detail]) => (
- {externalProvider && ( + {repositoryProvider && (
)} + {isAITableProvider && ( +
+ +

+ 表格读写统一由本机 Executor 通过 DWS + 执行。创建后连接钉钉账号,并确保该账号已获得此表格权限。 +

+
+ )} +
) })} @@ -1257,6 +1465,21 @@ export function CloudTodoWorkspace({ > 事项 + {isAITableProject && aitableApi ? ( + + ) : null} {selectedProject.access_role !== 'RestrictedAnalyst' && ( + {canCreateBoardTask && ( + + )} - + {columnItems.map(item => ( + +