From 6bc5842982fc901276b7913d9cbe67ef500bca7b Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 10:00:32 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E7=9C=8B=E6=9D=BF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=AE=BE=E7=BD=AE=E7=A7=81=E6=9C=89=E5=92=8C=E5=85=AC?= =?UTF-8?q?=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/cloud_projects.py | 21 +- backend/app/api/endpoints/deliveries.py | 43 +- backend/app/models/delivery.py | 7 + backend/app/schemas/cloud_project.py | 9 + backend/app/schemas/delivery.py | 2 + backend/app/services/cloud_projects/access.py | 23 +- .../app/services/cloud_projects/service.py | 20 +- backend/app/services/delivery/access.py | 11 +- backend/app/services/loop_items/service.py | 175 ++++-- backend/tests/api/test_cloud_projects_api.py | 81 +++ executor/Cargo.lock | 17 + executor/Cargo.toml | 2 +- executor/src/local/app_ipc.rs | 57 +- executor/src/task_runtime/issue_provider.rs | 561 +++++++++++++++++- executor/src/task_runtime/mcp.rs | 279 ++++++++- executor/src/task_runtime/model.rs | 2 + executor/src/task_runtime/router.rs | 340 ++++++++++- executor/src/task_runtime/store.rs | 2 + executor/tests/local_app_ipc_contract.rs | 6 +- executor/tests/local_task_mcp_contract.rs | 12 + .../src/app/auth/wework/authorize/page.tsx | 22 +- frontend/src/i18n/locales/en/common.json | 1 + frontend/src/i18n/locales/zh-CN/common.json | 1 + wework/src/api/deliveries.ts | 7 + .../api/hybrid/cloudProjectSpaceApi.test.ts | 57 ++ wework/src/api/hybrid/cloudProjectSpaceApi.ts | 34 +- wework/src/api/local/localDelivery.test.ts | 49 ++ wework/src/api/local/localDelivery.ts | 33 +- .../features/todo/CloudProjectManageView.tsx | 256 +++++--- .../src/features/todo/CloudProjectsHome.tsx | 469 +++++++++++++++ .../features/todo/CloudTodoWorkspace.test.tsx | 2 + .../src/features/todo/CloudTodoWorkspace.tsx | 248 ++++---- wework/src/i18n/locales/en/common.json | 39 +- wework/src/i18n/locales/zh-CN/common.json | 39 +- 34 files changed, 2572 insertions(+), 355 deletions(-) create mode 100644 wework/src/features/todo/CloudProjectsHome.tsx diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index dd8fb3326b..2656ec8ffe 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -37,6 +37,19 @@ router = APIRouter() +def _project_response( + db: Session, project: object, current_user: User +) -> CloudProjectResponse: + access = cloud_project_service.access(db, int(project.id), current_user.id) + return CloudProjectResponse.model_validate( + { + **project.__dict__, + "current_user_id": current_user.id, + "access_role": access.role, + } + ) + + @router.post( "", response_model=CloudProjectResponse, status_code=status.HTTP_201_CREATED ) @@ -46,7 +59,7 @@ def create_cloud_project( current_user: User = Depends(get_current_user), ) -> CloudProjectResponse: project = cloud_project_service.create(db, current_user.id, values) - return CloudProjectResponse.model_validate(project) + return _project_response(db, project, current_user) @router.get("", response_model=CloudProjectListResponse) @@ -56,7 +69,7 @@ def list_cloud_projects( ) -> CloudProjectListResponse: projects = cloud_project_service.list_accessible(db, current_user.id) return CloudProjectListResponse( - items=[CloudProjectResponse.model_validate(project) for project in projects] + items=[_project_response(db, project, current_user) for project in projects] ) @@ -67,7 +80,7 @@ def get_cloud_project( current_user: User = Depends(get_current_user), ) -> CloudProjectResponse: project = cloud_project_service.get(db, project_id, current_user.id) - return CloudProjectResponse.model_validate(project) + return _project_response(db, project, current_user) @router.get( @@ -95,7 +108,7 @@ def update_cloud_project( current_user: User = Depends(get_current_user), ) -> CloudProjectResponse: project = cloud_project_service.update(db, project_id, current_user.id, values) - return CloudProjectResponse.model_validate(project) + return _project_response(db, project, current_user) @router.post( diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index 250204d031..a84ebbf77a 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -33,12 +33,21 @@ MyWorkItemResponse, MyWorkListResponse, ) +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 router = APIRouter() +def _loop_item_response( + db: Session, item: object, current_user: User +) -> LoopItemResponse: + return LoopItemResponse.model_validate( + loop_item_service.response_values(db, item, current_user.id) + ) + + def _delivery_response(db: Session, delivery: Delivery) -> DeliveryResponse: return DeliveryResponse.model_validate( { @@ -117,7 +126,7 @@ def find_runtime_task_loop_item( item = loop_item_service.find_for_runtime_task( db, current_user.id, device_id, task_id ) - return LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.get("/runtime-tasks/cloud-context", response_model=CloudTaskContextResponse) @@ -133,8 +142,18 @@ def find_runtime_task_cloud_context( return CloudTaskContextResponse.model_validate( { **binding.__dict__, - "project": project, - "loop_item": item, + "project": { + **project.__dict__, + "current_user_id": current_user.id, + "access_role": cloud_project_service.access( + db, project.id, current_user.id + ).role, + }, + "loop_item": ( + loop_item_service.response_values(db, item, current_user.id) + if item is not None + else None + ), } ) @@ -175,8 +194,16 @@ def list_loop_items( current_user: User = Depends(get_current_user), ) -> LoopItemListResponse: items = loop_item_service.list(db, project_id, current_user.id) + access = cloud_project_service.access(db, project_id, current_user.id) return LoopItemListResponse( - items=[LoopItemResponse.model_validate(item) for item in items] + items=[ + LoopItemResponse.model_validate( + loop_item_service.response_values( + db, item, current_user.id, access=access + ) + ) + for item in items + ] ) @@ -192,7 +219,7 @@ def create_loop_item( current_user: User = Depends(get_current_user), ) -> LoopItemResponse: item = loop_item_service.create(db, project_id, current_user.id, values) - return LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.post( @@ -207,7 +234,7 @@ def reorder_loop_items( ) -> LoopItemListResponse: items = loop_item_service.reorder(db, project_id, current_user.id, values) return LoopItemListResponse( - items=[LoopItemResponse.model_validate(item) for item in items] + items=[_loop_item_response(db, item, current_user) for item in items] ) @@ -218,7 +245,7 @@ def get_loop_item( current_user: User = Depends(get_current_user), ) -> LoopItemResponse: item = loop_item_service.get(db, item_id, current_user.id) - return LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.patch("/loop-items/{item_id}", response_model=LoopItemResponse) @@ -229,7 +256,7 @@ def update_loop_item( current_user: User = Depends(get_current_user), ) -> LoopItemResponse: item = loop_item_service.update(db, item_id, current_user.id, values) - return LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.get( diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index 46c31129cf..e11729b6b9 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -137,6 +137,13 @@ class LoopNode(Base): class CloudProject(LoopNode): __mapper_args__ = {"polymorphic_identity": "project"} + @property + def visibility(self) -> str: + metadata = self.metadata_json + if not isinstance(metadata, dict): + return "private" + return "public" if metadata.get("visibility") == "public" else "private" + @property def tags(self) -> list[str]: """Project-level tag registry stored inside the metadata JSON column.""" diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index 86f42a2014..fadd75e3c6 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -22,6 +22,7 @@ SnowflakeId = Annotated[str, BeforeValidator(str)] TaskProvider = Literal["local", "github", "gitlab"] +ProjectVisibility = Literal["private", "public"] def _normalize_repository(task_provider: str, repository: str) -> str: @@ -64,6 +65,7 @@ class CloudProjectCreate(BaseModel): description: str = "" task_provider: TaskProvider = "local" provider_config: dict[str, object] = Field(default_factory=dict) + visibility: ProjectVisibility = "private" @field_validator("project_key") @classmethod @@ -83,6 +85,7 @@ class CloudProjectUpdate(BaseModel): description: str | None = None tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM) provider_config: dict[str, object] | None = None + visibility: ProjectVisibility | None = None version: int = Field(ge=1) @field_validator("tags", mode="before") @@ -116,7 +119,10 @@ class CloudProjectResponse(BaseModel): project_store: Literal["backend"] = "backend" task_provider: TaskProvider = "local" provider_config: dict[str, object] = Field(default_factory=dict) + visibility: ProjectVisibility = "private" created_by_user_id: int + current_user_id: int = 0 + access_role: BaseRole = BaseRole.RestrictedAnalyst status: str tags: list[str] = [] version: int @@ -137,6 +143,9 @@ def populate_tags(cls, value: object) -> object: "provider_config": mask_provider_config( metadata.get("provider_config", {}) ), + "visibility": ( + "public" if metadata.get("visibility") == "public" else "private" + ), "tags": normalize_tags(metadata.get("tags")), } return value diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index e65d7a3f22..375e645065 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -71,6 +71,8 @@ class LoopItemResponse(BaseModel): sort_order: int tags: list[str] = [] created_by_user_id: int + can_view_detail: bool = True + can_edit: bool = True current_delivery_id: str | None version: int created_at: datetime diff --git a/backend/app/services/cloud_projects/access.py b/backend/app/services/cloud_projects/access.py index bb6c01da26..aecae36d71 100644 --- a/backend/app/services/cloud_projects/access.py +++ b/backend/app/services/cloud_projects/access.py @@ -20,6 +20,10 @@ class CloudProjectAccess: project: CloudProject role: BaseRole + @property + def is_public_visitor(self) -> bool: + return self.role == BaseRole.RestrictedAnalyst + def require_cloud_project_role( db: Session, @@ -53,13 +57,18 @@ def require_cloud_project_role( .first() ) if membership is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found") - try: - role = BaseRole(membership.role) - except ValueError as exc: - raise HTTPException( - status.HTTP_403_FORBIDDEN, "Invalid cloud project role" - ) from exc + if project.visibility != "public": + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Cloud project not found" + ) + role = BaseRole.RestrictedAnalyst + else: + try: + role = BaseRole(membership.role) + except ValueError as exc: + raise HTTPException( + status.HTTP_403_FORBIDDEN, "Invalid cloud project role" + ) from exc if not has_permission(role, required_role): raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index c14a276d3f..fb4c296300 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -75,6 +75,7 @@ def create( "project_store": "backend", "task_provider": values.task_provider, "provider_config": provider_config, + "visibility": values.visibility, "tags": [], }, ) @@ -114,6 +115,7 @@ def list_accessible(self, db: Session, user_id: int) -> list[CloudProject]: or_( CloudProject.created_by_user_id == user_id, CloudProject.id.in_(member_project_ids), + CloudProject.metadata_json["visibility"].as_string() == "public", ), ) .order_by(CloudProject.updated_at.desc()) @@ -121,13 +123,20 @@ def list_accessible(self, db: Session, user_id: int) -> list[CloudProject]: ) def get(self, db: Session, project_id: int, user_id: int) -> CloudProject: - return require_cloud_project_role(db, project_id, user_id).project + return require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst + ).project + + def access(self, db: Session, project_id: int, user_id: int): + return require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst + ) def get_provider_credential( self, db: Session, project_id: int, user_id: int ) -> str: project = require_cloud_project_role( - db, project_id, user_id, BaseRole.Developer + db, project_id, user_id, BaseRole.RestrictedAnalyst ).project metadata = ( project.metadata_json if isinstance(project.metadata_json, dict) else {} @@ -158,6 +167,7 @@ def update( if ( "tags" in values.model_fields_set or "provider_config" in values.model_fields_set + or "visibility" in values.model_fields_set ): metadata = dict(project.metadata_json or {}) if "tags" in values.model_fields_set and values.tags is not None: @@ -189,6 +199,12 @@ def update( status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc) ) from exc updates.pop("provider_config", None) + if ( + "visibility" in values.model_fields_set + and values.visibility is not None + ): + metadata["visibility"] = values.visibility + updates.pop("visibility", None) updates["metadata_json"] = metadata updated = ( db.query(CloudProject) diff --git a/backend/app/services/delivery/access.py b/backend/app/services/delivery/access.py index f024f63a71..5fcf917fd3 100644 --- a/backend/app/services/delivery/access.py +++ b/backend/app/services/delivery/access.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.models.delivery import LoopItem -from app.schemas.base_role import BaseRole +from app.schemas.base_role import BaseRole, has_permission from app.services.cloud_projects.access import require_cloud_project_role @@ -21,5 +21,12 @@ def require_loop_item_access( item = db.query(LoopItem).filter(LoopItem.id == item_id).first() if item is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") - require_cloud_project_role(db, item.cloud_project_id, user_id, required_role) + access = require_cloud_project_role( + db, item.cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) + if access.is_public_visitor: + if item.created_by_user_id != user_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + elif not has_permission(access.role, required_role): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") return item diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 10a7a0e42d..55070745e2 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -33,14 +33,17 @@ from app.models.share_link import ResourceType from app.models.task import TaskResource from app.models.user import User -from app.schemas.base_role import BaseRole +from app.schemas.base_role import BaseRole, has_permission from app.schemas.delivery import ( LoopItemCreate, LoopItemReorder, LoopItemTaskBind, LoopItemUpdate, ) -from app.services.cloud_projects.access import require_cloud_project_role +from app.services.cloud_projects.access import ( + CloudProjectAccess, + require_cloud_project_role, +) from app.services.delivery.storage import delivery_storage from app.stores.tasks import task_store @@ -52,10 +55,20 @@ def _require_internal_task_project( cloud_project_id: int, user_id: int, required_role: BaseRole = BaseRole.Reporter, - ) -> CloudProject: - project = require_cloud_project_role( - db, cloud_project_id, user_id, required_role - ).project + *, + allow_public_visitor: bool = False, + ) -> CloudProjectAccess: + access = require_cloud_project_role( + db, cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) + if access.is_public_visitor: + if not allow_public_visitor: + raise HTTPException( + status.HTTP_403_FORBIDDEN, "Insufficient permission" + ) + elif not has_permission(access.role, required_role): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") + project = access.project if project.task_provider != "local": raise HTTPException( status.HTTP_409_CONFLICT, @@ -64,7 +77,65 @@ def _require_internal_task_project( "use the local Issue provider" ), ) - return project + return access + + @staticmethod + def _item_permissions( + access: CloudProjectAccess, item: LoopItem, user_id: int + ) -> tuple[bool, bool]: + if access.is_public_visitor: + owns_item = item.created_by_user_id == user_id + return owns_item, owns_item + return True, has_permission(access.role, BaseRole.Developer) + + def response_values( + self, + db: Session, + item: LoopItem, + user_id: int, + access: CloudProjectAccess | None = None, + ) -> dict[str, object]: + access = access or require_cloud_project_role( + db, item.cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) + can_view_detail, can_edit = self._item_permissions(access, item, user_id) + values = { + **item.__dict__, + "can_view_detail": can_view_detail, + "can_edit": can_edit, + } + if not can_view_detail: + values["description"] = "" + return values + + def _get_item_row( + self, db: Session, item_id: str, *, include_deleted: bool = False + ) -> LoopItem: + query = db.query(LoopItem).filter(LoopItem.id == item_id) + if not include_deleted: + query = query.filter(loop_datetime_is_unset(LoopItem.deleted_at)) + item = query.first() + if item is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return item + + def _require_item_access( + self, + db: Session, + item: LoopItem, + user_id: int, + *, + edit: bool = False, + ) -> CloudProjectAccess: + access = require_cloud_project_role( + db, item.cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) + can_view_detail, can_edit = self._item_permissions(access, item, user_id) + if edit and not can_edit: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") + if not edit and not can_view_detail: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return access def ensure_collaborator( self, @@ -78,7 +149,12 @@ def ensure_collaborator( ) -> LoopItemCollaborator: """Ensure one project member participates in a TODO.""" - require_cloud_project_role(db, item.cloud_project_id, collaborator_user_id) + require_cloud_project_role( + db, + item.cloud_project_id, + collaborator_user_id, + BaseRole.RestrictedAnalyst, + ) collaborator = ( db.query(LoopItemCollaborator) .filter( @@ -124,9 +200,7 @@ def add_collaborator( self, db: Session, item_id: str, collaborator_user_id: int, user_id: int ) -> dict[str, object]: item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) self.ensure_collaborator(db, item, collaborator_user_id, user_id, "manual") return next( row @@ -138,9 +212,7 @@ def remove_collaborator( self, db: Session, item_id: str, collaborator_user_id: int, user_id: int ) -> None: item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) collaborator = ( db.query(LoopItemCollaborator) .filter( @@ -162,7 +234,11 @@ def create( values: LoopItemCreate, ) -> LoopItem: self._require_internal_task_project( - db, cloud_project_id, user_id, BaseRole.Developer + db, + cloud_project_id, + user_id, + BaseRole.Developer, + allow_public_visitor=True, ) if values.parent_id is not None: self._require_parent(db, values.parent_id, cloud_project_id) @@ -193,7 +269,9 @@ def create( return item def list(self, db: Session, cloud_project_id: int, user_id: int) -> list[LoopItem]: - self._require_internal_task_project(db, cloud_project_id, user_id) + self._require_internal_task_project( + db, cloud_project_id, user_id, allow_public_visitor=True + ) return ( db.query(LoopItem) .filter( @@ -251,17 +329,8 @@ def reorder( return ordered def get(self, db: Session, item_id: str, user_id: int) -> LoopItem: - item = ( - db.query(LoopItem) - .filter( - LoopItem.id == item_id, - loop_datetime_is_unset(LoopItem.deleted_at), - ) - .first() - ) - if item is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") - require_cloud_project_role(db, item.cloud_project_id, user_id) + item = self._get_item_row(db, item_id) + self._require_item_access(db, item, user_id) return item def list_attachments( @@ -285,9 +354,7 @@ def add_attachment( source: BinaryIO, ) -> LoopItemAttachment: item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) project = db.get(CloudProject, item.cloud_project_id) if project is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found") @@ -341,9 +408,7 @@ def attachment_access_url( 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) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) delivery_storage.remove_objects([attachment.object_key]) db.delete(attachment) db.commit() @@ -365,9 +430,7 @@ def update( values: LoopItemUpdate, ) -> LoopItem: item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) updates = values.model_dump(exclude={"version"}, exclude_unset=True) if "parent_id" in values.model_fields_set: self._validate_parent_change(db, item, values.parent_id) @@ -404,9 +467,7 @@ def delete(self, db: Session, item_id: str, user_id: int) -> LoopItem: """Soft delete a TODO; the row is kept for the recycle bin.""" item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) item.deleted_at = self._now() item.version += 1 db.commit() @@ -416,12 +477,8 @@ def delete(self, db: Session, item_id: str, user_id: int) -> LoopItem: def restore(self, db: Session, item_id: str, user_id: int) -> LoopItem: """Restore a soft-deleted TODO from the recycle bin.""" - item = db.query(LoopItem).filter(LoopItem.id == item_id).first() - if item is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + item = self._get_item_row(db, item_id, include_deleted=True) + self._require_item_access(db, item, user_id, edit=True) if loop_datetime_value_is_unset(item.deleted_at): raise HTTPException(status.HTTP_409_CONFLICT, "TODO is not deleted") item.deleted_at = None @@ -435,16 +492,16 @@ def list_deleted( ) -> list[LoopItem]: """List soft-deleted TODOs of a project, most recently deleted first.""" - require_cloud_project_role(db, cloud_project_id, user_id) - return ( - db.query(LoopItem) - .filter( - LoopItem.cloud_project_id == cloud_project_id, - ~loop_datetime_is_unset(LoopItem.deleted_at), - ) - .order_by(LoopItem.deleted_at.desc()) - .all() + access = require_cloud_project_role( + db, cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) + query = db.query(LoopItem).filter( + LoopItem.cloud_project_id == cloud_project_id, + ~loop_datetime_is_unset(LoopItem.deleted_at), ) + if access.is_public_visitor: + query = query.filter(LoopItem.created_by_user_id == user_id) + return query.order_by(LoopItem.deleted_at.desc()).all() def _require_parent( self, db: Session, parent_id: str, cloud_project_id: int @@ -489,9 +546,7 @@ def bind_task( user_id: int, ) -> LoopItemTaskBinding: item = self.get(db, item_id, user_id) - require_cloud_project_role( - db, item.cloud_project_id, user_id, BaseRole.Developer - ) + self._require_item_access(db, item, user_id, edit=True) self._validate_backend_task(db, values.backend_task_id, user_id) active = ( db.query(LoopItemTaskBinding) @@ -542,7 +597,9 @@ def bind_project_task( ) -> LoopItemTaskBinding: """Associate a runtime Task with a cloud project without choosing a TODO.""" - require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) + require_cloud_project_role( + db, cloud_project_id, user_id, BaseRole.RestrictedAnalyst + ) self._validate_backend_task(db, values.backend_task_id, user_id) active = self._active_task_binding(db, values, user_id, lock=True) if active is not None: @@ -593,7 +650,7 @@ def find_cloud_context( project = db.get(CloudProject, binding.cloud_project_id) if project is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found") - require_cloud_project_role(db, project.id, user_id) + require_cloud_project_role(db, project.id, user_id, BaseRole.RestrictedAnalyst) item = db.get(LoopItem, binding.loop_item_id) if binding.loop_item_id else None return binding, project, item diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index b303936415..7266cc07cf 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -12,6 +12,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session +from app.core.security import create_access_token from app.models.delivery import CloudProject, Delivery, DeliveryAsset from app.models.project import Project from app.models.user import User @@ -122,6 +123,86 @@ def test_cloud_project_generates_key_when_omitted( assert 2 <= len(created.json()["project_key"]) <= 16 +def test_public_project_visitors_only_access_their_own_todo_details( + test_client: TestClient, + test_db: Session, + test_token: str, +) -> None: + visitor = User( + user_name="public-project-visitor", + password_hash="unused", + email="visitor@example.com", + is_active=True, + git_info=None, + ) + test_db.add(visitor) + test_db.commit() + test_db.refresh(visitor) + visitor_token = create_access_token(data={"sub": visitor.user_name}) + + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "public", + "name": "Public collaboration", + "visibility": "public", + }, + ).json() + owner_item = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + json={"title": "Owner task", "description": "private task details"}, + ).json() + + listed_projects = test_client.get( + "/api/v1/cloud-projects", headers=_auth(visitor_token) + ) + assert listed_projects.status_code == 200 + visible_project = next( + item for item in listed_projects.json()["items"] if item["id"] == project["id"] + ) + assert visible_project["visibility"] == "public" + assert visible_project["access_role"] == "RestrictedAnalyst" + assert visible_project["current_user_id"] == visitor.id + + listed_items = test_client.get( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(visitor_token), + ) + assert listed_items.status_code == 200 + owner_summary = listed_items.json()["items"][0] + assert owner_summary["id"] == owner_item["id"] + assert owner_summary["description"] == "" + assert owner_summary["can_view_detail"] is False + assert owner_summary["can_edit"] is False + + hidden_detail = test_client.get( + f"/api/v1/loop-items/{owner_item['id']}", + headers=_auth(visitor_token), + ) + assert hidden_detail.status_code == 404 + + visitor_item = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(visitor_token), + json={"title": "Visitor task", "description": "visitor details"}, + ) + assert visitor_item.status_code == 201 + visitor_item_body = visitor_item.json() + assert visitor_item_body["created_by_user_id"] == visitor.id + assert visitor_item_body["can_view_detail"] is True + assert visitor_item_body["can_edit"] is True + + updated = test_client.patch( + f"/api/v1/loop-items/{visitor_item_body['id']}", + headers=_auth(visitor_token), + json={"version": visitor_item_body["version"], "title": "Visitor task updated"}, + ) + assert updated.status_code == 200 + assert updated.json()["title"] == "Visitor task updated" + + def test_cloud_project_persists_external_task_provider_and_encrypted_token( test_client: TestClient, test_db: Session, diff --git a/executor/Cargo.lock b/executor/Cargo.lock index 2cf918d698..2f0a6d16dc 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -1237,6 +1237,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1743,6 +1753,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2492,6 +2503,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 943bda29c0..e376e6269a 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -31,7 +31,7 @@ cbc = "0.1" chrono = { version = "0.4", default-features = false, features = ["clock"] } ctrlc = { version = "3.4", features = ["termination"], optional = true } dirs = "5" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } regex = "1.11" futures-util = "0.3" flate2 = "1.0" diff --git a/executor/src/local/app_ipc.rs b/executor/src/local/app_ipc.rs index 377de31f11..8d2d887998 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -381,6 +381,7 @@ impl AppIpcServer { || method.starts_with("external_projects.") || method.starts_with("todos.") || method.starts_with("external_todos.") + || method.starts_with("external_attachments.") || method.starts_with("runtime_tasks.") || method.starts_with("files.") || method.starts_with("attachments.") @@ -741,6 +742,48 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + serialize_task_value( + runtime + .list_external_task_attachments(project, task_id) + .await + .map_err(task_runtime_error)?, + ) + } + "external_attachments.add" => { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + let input = task_input::(¶ms, "file")?; + serialize_task_value( + runtime + .upload_external_task_attachment(project, task_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "external_attachments.access" => { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + let attachment_id = required_task_string(¶ms, "attachment_id")?; + Ok(json!({ + "path": runtime + .download_external_task_attachment(project, task_id, attachment_id) + .await + .map_err(task_runtime_error)? + })) + } + "external_attachments.delete" => { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + let attachment_id = required_task_string(¶ms, "attachment_id")?; + runtime + .delete_external_task_attachment(project, task_id, attachment_id) + .await + .map_err(task_runtime_error)?; + Ok(json!({"deleted": true})) + } "todos.list" => { let project_id = required_task_string(¶ms, "project_id")?; serialize_task_value( @@ -919,10 +962,12 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; let item_id = required_task_string(¶ms, "item_id")?; serialize_task_value( runtime - .list_task_attachments(item_id) + .list_task_attachments(project_id, item_id) + .await .map_err(task_runtime_error)?, ) } @@ -938,17 +983,23 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; + let item_id = required_task_string(¶ms, "item_id")?; let attachment_id = required_task_string(¶ms, "attachment_id")?; Ok(json!({ "path": runtime - .task_attachment_path(attachment_id) + .task_attachment_path(project_id, item_id, attachment_id) + .await .map_err(task_runtime_error)? })) } "attachments.delete" => { + let project_id = required_task_string(¶ms, "project_id")?; + let item_id = required_task_string(¶ms, "item_id")?; let attachment_id = required_task_string(¶ms, "attachment_id")?; runtime - .delete_task_attachment(attachment_id) + .delete_task_attachment(project_id, item_id, attachment_id) + .await .map_err(task_runtime_error)?; Ok(json!({"deleted": true})) } diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index 20d91765c7..f905bb8a4a 100644 --- a/executor/src/task_runtime/issue_provider.rs +++ b/executor/src/task_runtime/issue_provider.rs @@ -2,23 +2,39 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::path::PathBuf; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; -use reqwest::{Client, RequestBuilder}; -use serde::Deserialize; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine as _, +}; +use chrono::Utc; +use reqwest::{ + multipart::{Form, Part}, + Client, RequestBuilder, +}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use url::{form_urlencoded::byte_serialize, Url}; use crate::logging::{format_executor_log, write_executor_log_line}; use super::{ - credentials::decrypt_provider_credential, IssueComment, LoopItem, TaskCreate, TaskProviderKind, - TaskRuntimeError, TaskUpdate, + credentials::decrypt_provider_credential, BinaryInput, IssueComment, LoopItem, TaskAttachment, + TaskCreate, TaskProviderKind, TaskRuntimeError, TaskUpdate, }; const PARENT_MARKER: &str = "Wegent-Parent:"; const PRIORITY_LABEL_PREFIX: &str = "wegent:priority:"; const STATUS_LABEL_PREFIX: &str = "wegent:status:"; +const CREATOR_LABEL_PREFIX: &str = "wegent:creator:"; +const ATTACHMENT_BLOCK_START: &str = ""; +const ATTACHMENT_BLOCK_END: &str = ""; +const ATTACHMENT_DATA_PREFIX: &str = "")?; + Some(tail[..end].trim()) +} + +fn render_gitlab_attachment_manifest( + description: &str, + attachments: &[GitlabAttachmentRecord], +) -> Result { + let (description, _) = split_gitlab_attachment_manifest(description); + if attachments.is_empty() { + return Ok(description.trim_end().to_owned()); + } + let encoded = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(attachments) + .map_err(|error| invalid(format!("cannot encode attachment metadata: {error}")))?, + ); + let links = attachments + .iter() + .map(|attachment| { + format!( + "- [{}]({})", + escape_markdown_label(&attachment.display_name), + attachment.url + ) + }) + .collect::>() + .join("\n"); + let block = format!( + "{ATTACHMENT_BLOCK_START}\n### Attachments\n\n{links}\n\ + {ATTACHMENT_DATA_PREFIX}{encoded} -->\n{ATTACHMENT_BLOCK_END}" + ); + let description = description.trim_end(); + if description.is_empty() { + Ok(block) + } else { + Ok(format!("{description}\n\n{block}")) + } +} + +fn gitlab_upload_secret(url: &str) -> Result { + let secret = url + .split('/') + .collect::>() + .windows(2) + .find_map(|parts| (parts[0] == "uploads").then_some(parts[1])) + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid("GitLab upload response does not include an upload secret"))?; + Ok(secret.to_owned()) +} + +fn escape_markdown_label(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn safe_path_component(value: &str) -> Result { + let mut components = Path::new(value).components(); + if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() { + return Ok(value.to_owned()); + } + Err(invalid("attachment cache key is invalid")) +} + +fn safe_file_name(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| match character { + '/' | '\\' | '\0' => '_', + _ => character, + }) + .collect::(); + if sanitized.trim().is_empty() { + "attachment".to_owned() + } else { + sanitized + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + fn insert_optional( body: &mut serde_json::Map, key: &str, @@ -1022,8 +1535,8 @@ fn provider_error(error: impl std::fmt::Display) -> TaskRuntimeError { #[cfg(test)] mod tests { use super::{ - labels_for_write, normalize_repository, parent_number, priority_from_labels, - status_from_labels, + creator_from_labels, labels_for_write, normalize_repository, parent_number, + priority_from_labels, status_from_labels, }; use crate::task_runtime::TaskProviderKind; @@ -1061,6 +1574,24 @@ mod tests { assert_eq!(status_from_labels("opened", &labels), "in_review"); } + #[test] + fn reads_wegent_creator_from_reserved_issue_label() { + let labels = vec![ + "bug".to_owned(), + "wegent:creator:42".to_owned(), + "wegent:status:pending".to_owned(), + ]; + assert_eq!(creator_from_labels(&labels), 42); + assert_eq!( + labels_for_write(labels, "none", "in_progress"), + vec![ + "bug", + "wegent:creator:42", + "wegent:status:in_progress" + ] + ); + } + #[test] fn closed_provider_state_wins_and_unlabeled_open_issues_default_to_pending() { assert_eq!( diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index 4a7350eb12..8b0586bd32 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -2,14 +2,15 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::env; +use std::{env, fs, path::Path}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::protocol::ExecutionRequest; -use super::{ProjectCreate, TaskRuntime}; +use super::{BinaryInput, ProjectCreate, TaskRuntime}; const TASK_MCP_SERVER_NAME: &str = "wegent_tasks"; @@ -186,6 +187,60 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), } } + "list_todo_attachments" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + match (project_id, task_id) { + (Ok(project_id), Ok(task_id)) => runtime + .list_task_attachments(project_id, task_id) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "upload_todo_attachment" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + let file_path = string_argument(&arguments, "file_path"); + match (project_id, task_id, file_path) { + (Ok(project_id), Ok(task_id), Ok(file_path)) => { + let input = binary_input_from_path(&arguments, file_path); + match input { + Ok(input) => runtime + .add_task_attachment(project_id, task_id, input) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + Err(error) => Err(error), + } + } + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "download_todo_attachment" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + let attachment_id = string_argument(&arguments, "attachment_id"); + match (project_id, task_id, attachment_id) { + (Ok(project_id), Ok(task_id), Ok(attachment_id)) => runtime + .task_attachment_path(project_id, task_id, attachment_id) + .await + .and_then(|path| copy_attachment_if_requested(&arguments, &path)) + .map(|path| json!({"path": path})), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "delete_todo_attachment" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + let attachment_id = string_argument(&arguments, "attachment_id"); + match (project_id, task_id, attachment_id) { + (Ok(project_id), Ok(task_id), Ok(attachment_id)) => runtime + .delete_task_attachment(project_id, task_id, attachment_id) + .await + .map(|_| json!({"deleted": true})), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } "reorder_todos" => { let project_id = string_argument(&arguments, "project_id"); let input = parse( @@ -319,6 +374,60 @@ fn tools() -> Vec { "required": ["project_id", "task_id", "body"] }), ), + tool( + "list_todo_attachments", + "List attachments stored for a task. GitLab Issue attachments are read from GitLab.", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"} + }, + "required": ["project_id", "task_id"] + }), + ), + tool( + "upload_todo_attachment", + "Upload a local file as a task attachment. GitLab Issue files are stored in GitLab Project Uploads.", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"}, + "file_path": {"type": "string"}, + "display_name": {"type": "string"}, + "content_type": {"type": "string"} + }, + "required": ["project_id", "task_id", "file_path"] + }), + ), + tool( + "download_todo_attachment", + "Download a task attachment and return its local path for inspection.", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"}, + "attachment_id": {"type": "string"}, + "output_path": {"type": "string"} + }, + "required": ["project_id", "task_id", "attachment_id"] + }), + ), + tool( + "delete_todo_attachment", + "Delete a task attachment from its authoritative task storage.", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"}, + "attachment_id": {"type": "string"} + }, + "required": ["project_id", "task_id", "attachment_id"] + }), + ), tool( "reorder_todos", "Persist the order of tasks in one board lane", @@ -362,6 +471,62 @@ fn string_argument<'a>(value: &'a Value, key: &str) -> Result<&'a str, super::Ta .ok_or_else(|| super::TaskRuntimeError::Invalid(format!("{key} is required"))) } +fn binary_input_from_path( + arguments: &Value, + file_path: &str, +) -> Result { + let bytes = fs::read(file_path).map_err(|error| { + super::TaskRuntimeError::Invalid(format!("cannot read attachment file: {error}")) + })?; + let display_name = arguments + .get("display_name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + Path::new(file_path) + .file_name() + .and_then(|value| value.to_str()) + .map(ToOwned::to_owned) + }) + .ok_or_else(|| { + super::TaskRuntimeError::Invalid("attachment display_name is required".to_owned()) + })?; + Ok(BinaryInput { + display_name, + content_type: arguments + .get("content_type") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned), + base64: STANDARD.encode(bytes), + }) +} + +fn copy_attachment_if_requested( + arguments: &Value, + source_path: &str, +) -> Result { + let Some(output_path) = arguments + .get("output_path") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + else { + return Ok(source_path.to_owned()); + }; + if let Some(parent) = Path::new(output_path).parent() { + fs::create_dir_all(parent).map_err(|error| { + super::TaskRuntimeError::Invalid(format!( + "cannot create attachment output directory: {error}" + )) + })?; + } + fs::copy(source_path, output_path).map_err(|error| { + super::TaskRuntimeError::Invalid(format!("cannot copy attachment file: {error}")) + })?; + Ok(output_path.to_owned()) +} + fn invalid_json(error: serde_json::Error) -> super::TaskRuntimeError { super::TaskRuntimeError::Invalid(error.to_string()) } @@ -384,6 +549,7 @@ fn error_response(id: Value, code: i64, message: &str) -> Value { #[cfg(test)] mod tests { use super::*; + use crate::task_runtime::{LocalTaskStore, TaskCreate, TaskProviderKind}; #[test] fn injects_the_local_task_mcp_once() { @@ -397,4 +563,113 @@ mod tests { assert_eq!(request.mcp_servers[0]["type"], "stdio"); assert_eq!(request.mcp_servers[0]["args"], json!(["task-mcp-server"])); } + + #[test] + fn exposes_task_attachment_tools() { + let names = tools() + .into_iter() + .filter_map(|tool| tool["name"].as_str().map(ToOwned::to_owned)) + .collect::>(); + + for name in [ + "list_todo_attachments", + "upload_todo_attachment", + "download_todo_attachment", + "delete_todo_attachment", + ] { + assert!(names.iter().any(|candidate| candidate == name)); + } + } + + #[tokio::test] + async fn task_attachment_tools_upload_list_download_and_delete() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "Local attachments".to_owned(), + project_key: Some("LOCAL".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Local, + provider_config: json!({}), + }) + .unwrap(); + let task = store + .create_task( + &project.id, + TaskCreate { + title: "Inspect attachment".to_owned(), + description: String::new(), + status: "pending".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + let runtime = TaskRuntime::new(store).unwrap(); + let source = directory.path().join("source.txt"); + fs::write(&source, "attachment content").unwrap(); + + let upload = call_tool( + &runtime, + "upload_todo_attachment", + json!({ + "project_id": project.id, + "task_id": task.id, + "file_path": source, + "content_type": "text/plain" + }), + ) + .await; + assert_eq!(upload["isError"], false); + let attachment: Value = + serde_json::from_str(upload["content"][0]["text"].as_str().unwrap()).unwrap(); + let attachment_id = attachment["id"].as_str().unwrap(); + + let list = call_tool( + &runtime, + "list_todo_attachments", + json!({"project_id": project.id, "task_id": task.id}), + ) + .await; + let listed: Value = + serde_json::from_str(list["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(listed.as_array().unwrap().len(), 1); + + let output = directory.path().join("downloaded.txt"); + let download = call_tool( + &runtime, + "download_todo_attachment", + json!({ + "project_id": project.id, + "task_id": task.id, + "attachment_id": attachment_id, + "output_path": output + }), + ) + .await; + assert_eq!(download["isError"], false); + assert_eq!( + fs::read_to_string(directory.path().join("downloaded.txt")).unwrap(), + "attachment content" + ); + + let deleted = call_tool( + &runtime, + "delete_todo_attachment", + json!({ + "project_id": project.id, + "task_id": task.id, + "attachment_id": attachment_id + }), + ) + .await; + assert_eq!(deleted["isError"], false); + assert!(runtime + .list_task_attachments(&project.id, &task.id) + .await + .unwrap() + .is_empty()); + } } diff --git a/executor/src/task_runtime/model.rs b/executor/src/task_runtime/model.rs index 538c092fd9..6406208c23 100644 --- a/executor/src/task_runtime/model.rs +++ b/executor/src/task_runtime/model.rs @@ -216,6 +216,8 @@ pub struct LoopItem { pub name: Option, pub title: Option, pub description: String, + #[serde(default)] + pub created_by_user_id: i64, pub sequence_number: Option, pub next_item_number: Option, pub status: Option, diff --git a/executor/src/task_runtime/router.rs b/executor/src/task_runtime/router.rs index 2e00b5b6f4..9aa0d5bdcf 100644 --- a/executor/src/task_runtime/router.rs +++ b/executor/src/task_runtime/router.rs @@ -105,6 +105,60 @@ impl TaskRuntime { .await } + pub async fn list_external_task_attachments( + &self, + project: ProjectDescriptor, + task_id: &str, + ) -> Result, TaskRuntimeError> { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider + .list_attachments(&project, provider, task_id) + .await + } + + pub async fn upload_external_task_attachment( + &self, + project: ProjectDescriptor, + task_id: &str, + input: BinaryInput, + ) -> Result { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider + .upload_attachment(&project, provider, task_id, input) + .await + } + + pub async fn download_external_task_attachment( + &self, + project: ProjectDescriptor, + task_id: &str, + attachment_id: &str, + ) -> Result { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + Ok(self + .issue_provider + .download_attachment(&project, provider, task_id, attachment_id) + .await? + .display() + .to_string()) + } + + pub async fn delete_external_task_attachment( + &self, + project: ProjectDescriptor, + task_id: &str, + attachment_id: &str, + ) -> Result<(), TaskRuntimeError> { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider + .delete_attachment(&project, provider, task_id, attachment_id) + .await + } + pub async fn list_tasks(&self, project_id: &str) -> Result, TaskRuntimeError> { let project = self.local_store.get_project(project_id)?; match task_provider(&project)? { @@ -305,28 +359,90 @@ impl TaskRuntime { item_id: &str, input: BinaryInput, ) -> Result { - let persisted = self.content_target(project_id, item_id).await?; - self.local_store - .add_task_attachment(project_id, item_id, persisted, input) + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Gitlab => { + self.issue_provider + .upload_attachment(&project, TaskProviderKind::Gitlab, item_id, input) + .await + } + TaskProviderKind::Local | TaskProviderKind::Github => { + let persisted = self.content_target(project_id, item_id).await?; + self.local_store + .add_task_attachment(project_id, item_id, persisted, input) + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} attachments" + ))), + } } - pub fn list_task_attachments( + pub async fn list_task_attachments( &self, + project_id: &str, item_id: &str, ) -> Result, TaskRuntimeError> { - self.local_store.list_task_attachments(item_id) + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Gitlab => { + self.issue_provider + .list_attachments(&project, TaskProviderKind::Gitlab, item_id) + .await + } + TaskProviderKind::Local | TaskProviderKind::Github => { + self.local_store.list_task_attachments(item_id) + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} attachments" + ))), + } } - pub fn task_attachment_path(&self, attachment_id: &str) -> Result { - Ok(self - .local_store - .task_attachment_path(attachment_id)? - .display() - .to_string()) + pub async fn task_attachment_path( + &self, + project_id: &str, + item_id: &str, + attachment_id: &str, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Gitlab => Ok(self + .issue_provider + .download_attachment(&project, TaskProviderKind::Gitlab, item_id, attachment_id) + .await? + .display() + .to_string()), + TaskProviderKind::Local | TaskProviderKind::Github => Ok(self + .local_store + .task_attachment_path(attachment_id)? + .display() + .to_string()), + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} attachments" + ))), + } } - pub fn delete_task_attachment(&self, attachment_id: &str) -> Result<(), TaskRuntimeError> { - self.local_store.delete_task_attachment(attachment_id) + pub async fn delete_task_attachment( + &self, + project_id: &str, + item_id: &str, + attachment_id: &str, + ) -> Result<(), TaskRuntimeError> { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Gitlab => { + self.issue_provider + .delete_attachment(&project, TaskProviderKind::Gitlab, item_id, attachment_id) + .await + } + TaskProviderKind::Local | TaskProviderKind::Github => { + self.local_store.delete_task_attachment(attachment_id) + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} attachments" + ))), + } } pub async fn create_delivery( @@ -409,7 +525,15 @@ fn mask_project(mut project: LoopItem) -> LoopItem { #[cfg(test)] mod tests { - use axum::{http::HeaderMap, routing::get, Json, Router}; + use std::sync::{Arc, Mutex}; + + use axum::{ + extract::{Multipart, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::get, + Json, Router, + }; use serde_json::json; use crate::task_runtime::ProjectStoreKind; @@ -635,6 +759,76 @@ mod tests { })) } + #[derive(Clone, Default)] + struct GitlabAttachmentState { + description: Arc>, + upload_deleted: Arc>, + } + + fn gitlab_attachment_issue(description: &str) -> serde_json::Value { + json!({ + "iid": 11, + "title": "GitLab attachment issue", + "description": description, + "state": "opened", + "web_url": "https://gitlab.test/acme/repo/-/issues/11", + "author": {"username": "fox"}, + "labels": ["wegent:status:pending"], + "user_notes_count": 0, + "created_at": "2026-07-27T00:00:00Z", + "updated_at": "2026-07-27T00:00:00Z", + "closed_at": null + }) + } + + async fn get_gitlab_attachment_issue( + State(state): State, + ) -> Json { + Json(gitlab_attachment_issue(&state.description.lock().unwrap())) + } + + async fn update_gitlab_attachment_issue( + State(state): State, + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + let description = body["description"].as_str().unwrap().to_owned(); + *state.description.lock().unwrap() = description.clone(); + Json(gitlab_attachment_issue(&description)) + } + + async fn upload_gitlab_attachment( + headers: HeaderMap, + mut multipart: Multipart, + ) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + let field = multipart.next_field().await.unwrap().unwrap(); + assert_eq!(field.name(), Some("file")); + assert_eq!(field.file_name(), Some("notes.txt")); + assert_eq!(field.bytes().await.unwrap().as_ref(), b"hello gitlab"); + Json(json!({ + "id": 55, + "url": "/uploads/upload-secret/notes.txt", + "full_path": "/-/project/12/uploads/upload-secret/notes.txt", + "markdown": "[notes.txt](/uploads/upload-secret/notes.txt)" + })) + } + + async fn download_gitlab_attachment(headers: HeaderMap) -> impl IntoResponse { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + (StatusCode::OK, b"hello gitlab".to_vec()) + } + + async fn delete_gitlab_attachment( + State(state): State, + headers: HeaderMap, + ) -> StatusCode { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + *state.upload_deleted.lock().unwrap() = true; + StatusCode::NO_CONTENT + } + #[tokio::test] async fn routes_github_issues_without_persisting_task_rows() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -925,4 +1119,122 @@ mod tests { )); server.abort(); } + + #[tokio::test] + async fn stores_gitlab_task_attachments_in_project_uploads() { + let state = GitlabAttachmentState { + description: Arc::new(Mutex::new("Issue details".to_owned())), + upload_deleted: Arc::new(Mutex::new(false)), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route( + "/projects/12/issues/11", + get(get_gitlab_attachment_issue).put(update_gitlab_attachment_issue), + ) + .route( + "/projects/12/uploads", + axum::routing::post(upload_gitlab_attachment), + ) + .route( + "/projects/12/uploads/upload-secret/notes.txt", + get(download_gitlab_attachment), + ) + .route( + "/projects/12/uploads/upload-secret", + axum::routing::delete(delete_gitlab_attachment), + ) + .with_state(server_state), + ) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "GitLab attachments".to_owned(), + project_key: Some("GL".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Gitlab, + provider_config: json!({ + "repository": "12", + "domain": "127.0.0.1", + "api_base": format!("http://{address}"), + "token": "test-token" + }), + }) + .unwrap(); + let runtime = TaskRuntime::new(store).unwrap(); + + let uploaded = runtime + .add_task_attachment( + &project.id, + "GL-11", + BinaryInput { + display_name: "notes.txt".to_owned(), + content_type: Some("text/plain".to_owned()), + base64: "aGVsbG8gZ2l0bGFi".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!(uploaded.id, "gitlab-11-upload-secret"); + assert_eq!(uploaded.loop_item_id, "GL-11"); + assert!(state + .description + .lock() + .unwrap() + .contains("")); + let attachments = runtime + .list_task_attachments(&project.id, "GL-11") + .await + .unwrap(); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].display_name, "notes.txt"); + + let path = runtime + .task_attachment_path(&project.id, "GL-11", &uploaded.id) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "hello gitlab"); + + runtime + .update_task( + &project.id, + "GL-11", + TaskUpdate { + version: 1, + description: Some("Updated issue details".to_owned()), + ..TaskUpdate::default() + }, + ) + .await + .unwrap(); + let updated_description = state.description.lock().unwrap().clone(); + assert!(updated_description.starts_with("Updated issue details")); + assert!(updated_description.contains("notes.txt")); + + runtime + .delete_task_attachment(&project.id, "GL-11", &uploaded.id) + .await + .unwrap(); + assert!(*state.upload_deleted.lock().unwrap()); + assert_eq!( + state.description.lock().unwrap().as_str(), + "Updated issue details" + ); + assert!(runtime + .list_task_attachments(&project.id, "GL-11") + .await + .unwrap() + .is_empty()); + server.abort(); + } } diff --git a/executor/src/task_runtime/store.rs b/executor/src/task_runtime/store.rs index 14549cd239..7b0918b87c 100644 --- a/executor/src/task_runtime/store.rs +++ b/executor/src/task_runtime/store.rs @@ -820,6 +820,7 @@ fn map_loop_item(row: &Row<'_>) -> rusqlite::Result { name: row.get(7)?, title: row.get(8)?, description: row.get(9)?, + created_by_user_id: 0, sequence_number: row.get(10)?, next_item_number: row.get(11)?, status: row.get(12)?, @@ -980,6 +981,7 @@ fn descriptor_loop_item( name: Some(project.name), title: None, description: project.description, + created_by_user_id: 0, sequence_number: None, next_item_number: Some(1), status: Some("active".to_owned()), diff --git a/executor/tests/local_app_ipc_contract.rs b/executor/tests/local_app_ipc_contract.rs index 768bdd9a56..0c7bf058d8 100644 --- a/executor/tests/local_app_ipc_contract.rs +++ b/executor/tests/local_app_ipc_contract.rs @@ -328,7 +328,11 @@ async fn app_ipc_stores_project_files_attachments_and_deliveries_locally() { let attachment_access = server .dispatch( "attachments.access", - json!({"attachment_id": attachment_id}), + json!({ + "project_id": project_id, + "item_id": task_id, + "attachment_id": attachment_id + }), ) .await .unwrap(); diff --git a/executor/tests/local_task_mcp_contract.rs b/executor/tests/local_task_mcp_contract.rs index 2fa32a723b..52120057b4 100644 --- a/executor/tests/local_task_mcp_contract.rs +++ b/executor/tests/local_task_mcp_contract.rs @@ -69,6 +69,18 @@ fn task_mcp_runs_over_stdio_without_listening_on_a_port() { assert!(tools.iter().any(|tool| tool["name"] == "create_todo")); assert!(tools.iter().any(|tool| tool["name"] == "update_todo")); assert!(tools.iter().any(|tool| tool["name"] == "add_todo_comment")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "list_todo_attachments")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "upload_todo_attachment")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "download_todo_attachment")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "delete_todo_attachment")); assert!(executor_home.path().join("data/tasks.sqlite").is_file()); let project = responses[2] .pointer("/result/content/0/text") diff --git a/frontend/src/app/auth/wework/authorize/page.tsx b/frontend/src/app/auth/wework/authorize/page.tsx index 78d20de165..3ffa2da967 100644 --- a/frontend/src/app/auth/wework/authorize/page.tsx +++ b/frontend/src/app/auth/wework/authorize/page.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { CheckCircle2, Cloud, XCircle } from 'lucide-react' -import { userApis } from '@/apis/user' +import { removeToken, userApis } from '@/apis/user' import { Button } from '@/components/ui/button' import { paths } from '@/config/paths' import { POST_LOGIN_REDIRECT_KEY } from '@/features/login/constants' @@ -73,6 +73,16 @@ function WeworkAuthorizeContent() { } } + function handleSwitchAccount() { + // Drop the current Web session and bounce through the login page so the + // authorization can be granted by a different account. The login page + // reads POST_LOGIN_REDIRECT_KEY and returns here after a successful login. + removeToken() + const redirectTarget = currentRedirectTarget() + sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, redirectTarget) + router.replace(`${paths.auth.login.getHref()}?redirect=${encodeURIComponent(redirectTarget)}`) + } + if (!sessionId) { return ( @@ -158,6 +168,16 @@ function WeworkAuthorizeContent() { > {t('auth.wework_authorize.cancel')} + + ))} + + +
+

项目成员

+

+ 成员只能访问被授权的云项目、任务、共享文件和交付。 +

+ +
+ {members.map((member, index) => ( +
+ + {member.user_name.slice(0, 1)} + + + {member.user_name} + {member.email} + + {member.role === 'Owner' ? ( + Owner + ) : ( + <> + + + )} +
+ ))} +
+ +
+

添加成员

+
+ + - void updateMember( - member, - event.target.value as Exclude - ) - } - className="h-8 w-24 rounded-lg border border-border bg-background px-1.5 text-xs outline-none focus:border-text-muted" - > - - - - + + + + +
+ {visibleResults.length > 0 && ( +
+ {visibleResults.map(user => ( - - )} -
- ))} -
- -
-

添加成员

-
- - + ))} +
+ )} + {error &&

{error}

}
- {visibleResults.length > 0 && ( -
- {visibleResults.map(user => ( - - ))} -
- )} - {error &&

{error}

}
diff --git a/wework/src/features/todo/CloudProjectsHome.tsx b/wework/src/features/todo/CloudProjectsHome.tsx new file mode 100644 index 0000000000..5fb548ce4a --- /dev/null +++ b/wework/src/features/todo/CloudProjectsHome.tsx @@ -0,0 +1,469 @@ +import { Cloud, HardDrive, Plus, Search, Settings2 } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { CloudLoopItem, CloudMyWorkItem, CloudProjectMember } from '@/api/deliveries' +import { formatRelativeSidebarTime } from '@/components/layout/runtimeSidebarTime' +import { useTranslation } from '@/hooks/useTranslation' +import { cn } from '@/lib/utils' +import { CloudTodoModal as Modal } from './CloudTodoModal' +import { memberAvatarClasses } from './todoShared' + +export interface ProjectsHomeProject { + id: string + name: string + location: 'local' | 'cloud' + updated_at: string +} + +interface CloudProjectsHomeProps { + projects: ProjectsHomeProject[] + projectCounts: Record + projectMembers: Record + projectItems: Record + myWork: CloudMyWorkItem[] + searchQuery: string + onCreateProject: () => void + onSelectProject: (projectId: string) => void + onManageProject: (projectId: string) => void + onSelectItem: (item: CloudMyWorkItem) => void + onOpenMyWork: () => void +} + +const MAX_RECENT_ACTIVITY = 5 +const MAX_MY_TODOS = 5 +// The home "all spaces" preview shows this many rows and scrolls for the rest. +const HOME_VISIBLE_SPACE_ROWS = 5 +const SPACE_ROW_HEIGHT_PX = 45 +const WEEK_MS = 7 * 86_400_000 + +function isWithinLastWeek(timestamp: string | null | undefined, nowMs: number): boolean { + if (!timestamp) return false + const valueMs = new Date(timestamp).getTime() + return !Number.isNaN(valueMs) && valueMs >= nowMs - WEEK_MS +} + +function memberNameById(members: CloudProjectMember[], userId: number | null): string | null { + if (userId === null) return null + return members.find(member => member.user_id === userId)?.user_name ?? null +} + +function matchesQuery(project: ProjectsHomeProject, query: string): boolean { + if (!query) return true + return project.name.toLowerCase().includes(query) +} + +interface ProjectSpaceRowProps { + project: ProjectsHomeProject + members: CloudProjectMember[] + openLabel: string + manageLabel: string + localLabel: string + taskCountLabel: string + memberCountLabel: string + onOpen: () => void + onManage?: () => void +} + +function ProjectSpaceRow({ + project, + members, + openLabel, + manageLabel, + localLabel, + taskCountLabel, + memberCountLabel, + onOpen, + onManage, +}: ProjectSpaceRowProps) { + const LocationIcon = project.location === 'local' ? HardDrive : Cloud + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpen() + } + }} + className="group grid h-11 w-full cursor-pointer grid-cols-[minmax(0,1fr)_64px_84px_108px] items-center border-b border-border px-2 text-left transition hover:bg-muted/60" + > + + + {project.name} + + {taskCountLabel} + {project.updated_at.slice(5, 10)} + + {onManage ? ( + + + + + ) : null} + + {project.location === 'local' ? ( + {localLabel} + ) : ( + <> + {members.slice(0, 2).map((member, memberIndex) => ( + 0 && '-ml-1' + )} + > + {member.user_name.slice(0, 1).toUpperCase()} + + ))} + {memberCountLabel} + + )} + + +
+ ) +} + +export function CloudProjectsHome({ + projects, + projectCounts, + projectMembers, + projectItems, + myWork, + searchQuery, + onCreateProject, + onSelectProject, + onManageProject, + onSelectItem, + onOpenMyWork, +}: CloudProjectsHomeProps) { + const { t } = useTranslation('common') + const query = searchQuery.trim().toLowerCase() + const visibleProjects = useMemo( + () => projects.filter(project => matchesQuery(project, query)), + [projects, query] + ) + + const allItems = useMemo(() => Object.values(projectItems).flat(), [projectItems]) + const completedCount = useMemo( + () => allItems.filter(item => item.status === 'completed').length, + [allItems] + ) + const inProgressCount = useMemo( + () => allItems.filter(item => item.status === 'in_progress').length, + [allItems] + ) + // "This week" stats are computed against a single timestamp captured at + // mount; Date.now() is not allowed during render by react-hooks/purity. + const [nowMs] = useState(() => Date.now()) + const weeklyNewCount = useMemo(() => { + return allItems.filter(item => isWithinLastWeek(item.created_at, nowMs)).length + }, [allItems, nowMs]) + const weeklyCompletedCount = useMemo(() => { + return allItems.filter( + item => item.status === 'completed' && isWithinLastWeek(item.completed_at, nowMs) + ).length + }, [allItems, nowMs]) + + const recentActivity = useMemo( + () => + [...allItems] + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + .slice(0, MAX_RECENT_ACTIVITY), + [allItems] + ) + const myTodos = useMemo( + () => myWork.filter(item => item.status !== 'completed').slice(0, MAX_MY_TODOS), + [myWork] + ) + const sortedProjects = useMemo( + () => [...visibleProjects].sort((a, b) => b.updated_at.localeCompare(a.updated_at)), + [visibleProjects] + ) + + const [manageOpen, setManageOpen] = useState(false) + const [manageQuery, setManageQuery] = useState('') + const manageProjects = useMemo( + () => sortedProjects.filter(project => matchesQuery(project, manageQuery.trim().toLowerCase())), + [sortedProjects, manageQuery] + ) + + const stats = [ + { label: t('todo.home_stat_projects', '项目空间总数'), value: projects.length }, + { label: t('todo.home_stat_total_items', '总任务数'), value: allItems.length }, + { label: t('todo.home_stat_completed', '已完成任务'), value: completedCount }, + { label: t('todo.home_stat_week_new', '本周新增'), value: weeklyNewCount }, + { label: t('todo.home_stat_week_completed', '本周完成'), value: weeklyCompletedCount }, + { label: t('todo.home_stat_in_progress', '进行中'), value: inProgressCount }, + ] + + const statusLabels: Record = { + inbox: t('todo.status_inbox', '收集箱'), + pending: t('todo.status_pending', '待开始'), + in_progress: t('todo.status_in_progress', '进行中'), + in_review: t('todo.status_in_review', '待确认'), + completed: t('todo.status_completed', '已完成'), + } + + const projectNameById = useMemo( + () => new Map(projects.map(project => [String(project.id), project.name])), + [projects] + ) + + return ( +
+
+
+
+

{t('todo.projects_home', '项目空间')}

+

+ {t('todo.projects_home_subtitle', '跨项目的个人工作台与项目空间概览')} +

+
+ + +
+ +
+ {stats.map(stat => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ +
+
+
+ {t('todo.home_recent_activity', '最近动态')} + +
+ {recentActivity.length === 0 ? ( +

+ {t('todo.home_no_activity', '暂无动态')} +

+ ) : ( + recentActivity.map((item, itemIndex) => { + const projectId = String(item.cloud_project_id) + const actorName = + memberNameById(projectMembers[projectId] ?? [], item.assignee_user_id) ?? + memberNameById(projectMembers[projectId] ?? [], item.created_by_user_id) ?? + t('todo.home_activity_someone', '有人') + const actionLabel = + item.status === 'completed' + ? t('todo.home_activity_completed', '完成了任务') + : t('todo.home_activity_updated', '更新了任务') + return ( + + ) + }) + )} +
+ +
+
+ {t('todo.home_my_todos', '待我处理')} + +
+ {myTodos.length === 0 ? ( +

+ {t('todo.home_no_todos', '暂无待处理事项')} +

+ ) : ( + myTodos.map(item => ( + + )) + )} +
+
+ +
+
+ {t('todo.home_all_spaces', '全部空间')} + +
+
+ {sortedProjects.map(project => ( + onSelectProject(project.id)} + /> + ))} +
+
+ +

+ {t( + 'todo.projects_home_footnote', + '本地空间保存在当前设备;云端空间可与项目成员共享任务、文件和交付。' + )} +

+
+ + {manageOpen && ( + setManageOpen(false)} + > +
+
+ + setManageQuery(event.target.value)} + placeholder={t('todo.home_manage_search', '搜索项目空间')} + className="h-9 w-full rounded-lg border border-border bg-background pl-9 pr-3 text-sm outline-none transition focus:border-text-muted" + /> +
+
+ {manageProjects.length === 0 ? ( +

+ {t('todo.home_manage_empty', '没有匹配的项目空间')} +

+ ) : ( + manageProjects.map(project => ( + { + setManageOpen(false) + onSelectProject(project.id) + }} + onManage={() => { + setManageOpen(false) + onManageProject(project.id) + }} + /> + )) + )} +
+
+
+ )} +
+ ) +} diff --git a/wework/src/features/todo/CloudTodoWorkspace.test.tsx b/wework/src/features/todo/CloudTodoWorkspace.test.tsx index b5321affa9..8277b808e9 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.test.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.test.tsx @@ -506,6 +506,7 @@ describe('CloudTodoWorkspace', () => { description: '', task_provider: 'local', provider_config: {}, + visibility: 'private', }) ) expect(screen.queryByTestId('cloud-project-name')).not.toBeInTheDocument() @@ -590,6 +591,7 @@ describe('CloudTodoWorkspace', () => { provider_config: { repository: 'group/project', }, + visibility: 'private', }) ) }) diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index 83c0fd88ef..969c677cc9 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -40,6 +40,7 @@ import type { ProjectSpaceLocation, WorkbenchServices, } from '@/features/workbench/workbenchServices' +import { useTranslation } from '@/hooks/useTranslation' import { navigateTo } from '@/lib/navigation' import { cn } from '@/lib/utils' import type { @@ -51,16 +52,11 @@ import type { import { CloudTodoModal as Modal } from './CloudTodoModal' import { CloudMyWorkView } from './CloudMyWorkView' import { CloudProjectManageView } from './CloudProjectManageView' +import { CloudProjectsHome } from './CloudProjectsHome' import { CloudFilesView } from './CloudFilesView' import { repositoryProviderConfig } from './projectProviderConfig' import { TodoEditor } from './TodoEditor' -import { - columnDotClasses, - columns, - memberAvatarClasses, - priorityBadgeClasses, - reorderLaneItems, -} from './todoShared' +import { columnDotClasses, columns, priorityBadgeClasses, reorderLaneItems } from './todoShared' type ProjectView = 'board' | 'files' | 'manage' type RootView = 'projects' | 'my-work' @@ -172,6 +168,7 @@ function DraggableTodoCard({ isDragging, } = useDraggable({ id: item.id, + disabled: item.can_edit === false, }) const { isOver, setNodeRef: setDropRef } = useDroppable({ id: `todo-card:${item.id}` }) return ( @@ -191,8 +188,9 @@ function DraggableTodoCard({
+ {location === 'cloud' && ( +
+

+ {t('todo.project_visibility')} +

+
+ {( + [ + [ + 'private', + LockKeyhole, + t('todo.project_visibility_private'), + t('todo.project_visibility_private_description'), + ], + [ + 'public', + Cloud, + t('todo.project_visibility_public'), + t('todo.project_visibility_public_description'), + ], + ] as const + ).map(([value, VisibilityIcon, label, detail]) => ( + + ))} +
+ {visibility === 'public' && ( +

+ {t('todo.project_visibility_public_notice')} +

+ )} +
+ )} +

任务来源

@@ -654,6 +705,9 @@ export function CloudTodoWorkspace({ const [projects, setProjects] = useState([]) const [projectCounts, setProjectCounts] = useState>({}) const [projectMembers, setProjectMembers] = useState>({}) + // Every project's loop items, cached for the projects-home overview + // (stats, recent activity). Keyed by project id. + const [projectItems, setProjectItems] = useState>({}) const [selectedProjectId, setSelectedProjectId] = useState(null) const [items, setItems] = useState([]) // Which project's items are currently in `items`. Anything else rendered on @@ -770,10 +824,10 @@ export function CloudTodoWorkspace({ api.listLoopItems(project.id), api.listCloudProjectMembers(project.id), ]) - const issueCount = - loopItemsResult.status === 'fulfilled' ? loopItemsResult.value.items.length : 0 + const loopItems = + loopItemsResult.status === 'fulfilled' ? loopItemsResult.value.items : [] const members = membersResult.status === 'fulfilled' ? membersResult.value : [] - return [project.id, issueCount, members] as const + return [project.id, loopItems.length, members, loopItems] as const }) ) return { details, projects } @@ -785,6 +839,7 @@ export function CloudTodoWorkspace({ setProjects(results.flatMap(result => result.projects)) setProjectCounts(Object.fromEntries(details.map(([id, count]) => [id, count]))) setProjectMembers(Object.fromEntries(details.map(([id, , members]) => [id, members]))) + setProjectItems(Object.fromEntries(details.map(([id, , , loopItems]) => [id, loopItems]))) }) .finally(() => { if (active) setLoading(false) @@ -802,6 +857,8 @@ export function CloudTodoWorkspace({ .then(response => { if (!active) return applyBoardItems(selectedProjectId, response.items, null) + // Keep the projects-home cache in sync with the board fetch. + setProjectItems(current => ({ ...current, [selectedProjectId]: response.items })) // Only sync the open drawer when it belongs to this project; a drawer // opened from another view (e.g. my work) must not be closed here. setSelectedItem(current => @@ -833,11 +890,11 @@ export function CloudTodoWorkspace({ } }, [applyBoardItems, selectedProjectApi, selectedProjectId]) useEffect(() => { - if (rootView !== 'my-work') return + if (rootView !== 'my-work' && !(rootView === 'projects' && !selectedProjectId)) return void Promise.all(availableProjectSpaceApis.map(({ api }) => api.listMyWork())).then(responses => setMyWork(responses.flatMap(response => response.items)) ) - }, [availableProjectSpaceApis, rootView]) + }, [availableProjectSpaceApis, rootView, selectedProjectId]) // Load the drawer project's items when the drawer shows a todo from a project // other than the one on the board, so subtasks and parent options stay correct. useEffect(() => { @@ -886,7 +943,7 @@ export function CloudTodoWorkspace({ ) { const item = items.find(candidate => candidate.id === itemId) const reordered = reorderLaneItems(items, itemId, status, beforeItemId) - if (!item || !reordered) return + if (!item || item.can_edit === false || !reordered) return const previousItems = items setItems(reordered.items) setBoardError(null) @@ -1116,7 +1173,9 @@ export function CloudTodoWorkspace({ setSelectedItem(item)} + onSelectItem={item => { + if (item.can_view_detail !== false) setSelectedItem(item) + }} /> ) : loading ? (
@@ -1138,89 +1197,22 @@ export function CloudTodoWorkspace({
) : !selectedProject ? ( -
-
-
-

项目空间

-

- 多人和 AI 在各自本地工作区协作,共享任务、文件与交付。 -

-
- - -
-
-
- 项目 - 任务 - 更新时间 - 成员 -
-
- {projects - .filter(project => - `${project.name} ${project.project_key} ${project.description}` - .toLowerCase() - .includes(searchQuery.trim().toLowerCase()) - ) - .map(project => { - const ProjectLocationIcon = project.location === 'local' ? HardDrive : Cloud - return ( - - ) - })} -
-
-

- 本地空间保存在当前设备;云端空间可与项目成员共享任务、文件和交付。 -

-
+ setCreateProjectOpen(true)} + onSelectProject={projectId => selectProject(projectId)} + onManageProject={projectId => { + selectProject(projectId) + setProjectView('manage') + }} + onSelectItem={item => setSelectedItem(item)} + onOpenMyWork={() => setRootView('my-work')} + /> ) : ( <>
事项 - - + {selectedProject.access_role !== 'RestrictedAnalyst' && ( + + )} + {['Owner', 'Maintainer'].includes(selectedProject.access_role ?? 'Owner') && ( + + )} {projectView === 'board' && ( @@ -1451,7 +1447,9 @@ export function CloudTodoWorkspace({ childCount={ items.filter(child => child.parent_id === item.id).length } - onClick={() => setSelectedItem(item)} + onClick={() => { + if (item.can_view_detail !== false) setSelectedItem(item) + }} onAddChild={() => openTodoCreation(item)} onOpenChildren={() => setBoardParentId(item.id)} /> diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 4209af6302..b5673e6b88 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -1706,6 +1706,33 @@ "creation_principle": "Describe what needs to be done; AI can help break it into steps later", "items_scope": "Items", "my_work": "My work", + "projects_home": "Projects", + "projects_home_subtitle": "People and AI collaborate in their own local workspaces, sharing tasks, files, and deliveries.", + "projects_home_footnote": "Local spaces stay on this device; cloud spaces can share tasks, files, and deliveries with project members.", + "new_project_space": "New project space", + "home_stat_projects": "Project spaces", + "home_stat_in_progress": "In progress", + "home_stat_my_action": "Needs my action", + "home_stat_week_completed": "Completed this week", + "home_recent_activity": "Recent activity", + "home_view_all": "All", + "home_no_activity": "No activity yet", + "home_my_todos": "Needs my action", + "home_no_todos": "Nothing to handle", + "home_all_spaces": "All spaces", + "home_task_count": "{{count}} tasks", + "home_member_count": "{{count}} members", + "location_local": "Local", + "status_inbox": "Inbox", + "status_pending": "Pending", + "status_in_progress": "In progress", + "status_in_review": "In review", + "status_completed": "Completed", + "home_manage": "Manage", + "home_manage_title": "Manage project spaces", + "home_manage_search": "Search project spaces", + "home_manage_empty": "No matching project spaces", + "home_open": "Open", "my_work_calendar_note": "The calendar only shows tasks with a due date.", "my_work_col_due": "Due", "my_work_col_project": "Project", @@ -1815,6 +1842,16 @@ "attachment_remove_failed": "Failed to remove attachment", "uploading": "Uploading…", "running": "Running…", - "executor_required": "Select an employee or AI agent before running" + "executor_required": "Select an employee or AI agent before running", + "project_visibility": "Project access", + "project_visibility_private": "Private", + "project_visibility_public": "Public", + "project_visibility_private_description": "Visible to invited members only", + "project_visibility_public_description": "Visible to all Backend users", + "project_visibility_public_notice": "Users who have not joined the project can view the task list and create tasks, but can only open and edit tasks they created.", + "project_visibility_manage_description": "Private projects are visible to invited members only; public projects are visible to everyone connected to this Backend.", + "project_visibility_private_manage_description": "Only invited members can view the project and its tasks", + "project_visibility_public_manage_description": "Everyone can see the task list; visitors can only open and edit their own tasks", + "project_visibility_update_failed": "Failed to update project access" } } diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index d9f4b875db..bb5ad19f7d 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -1705,6 +1705,33 @@ "creation_principle": "先描述要完成的事情,稍后可由 AI 帮你拆分步骤", "items_scope": "事项", "my_work": "我的工作", + "projects_home": "项目空间", + "projects_home_subtitle": "多人和 AI 在各自本地工作区协作,共享任务、文件与交付。", + "projects_home_footnote": "本地空间保存在当前设备;云端空间可与项目成员共享任务、文件和交付。", + "new_project_space": "新建项目空间", + "home_stat_projects": "项目空间", + "home_stat_in_progress": "进行中任务", + "home_stat_my_action": "待我处理", + "home_stat_week_completed": "本周完成", + "home_recent_activity": "最近动态", + "home_view_all": "全部", + "home_no_activity": "暂无动态", + "home_my_todos": "待我处理", + "home_no_todos": "暂无待处理事项", + "home_all_spaces": "全部空间", + "home_task_count": "{{count}} 任务", + "home_member_count": "{{count}} 人", + "location_local": "本地", + "status_inbox": "收集箱", + "status_pending": "待开始", + "status_in_progress": "进行中", + "status_in_review": "待确认", + "status_completed": "已完成", + "home_manage": "管理", + "home_manage_title": "管理项目空间", + "home_manage_search": "搜索项目空间", + "home_manage_empty": "没有匹配的项目空间", + "home_open": "打开", "my_work_calendar_note": "日历仅展示设置了截止日期的任务。", "my_work_col_due": "截止日期", "my_work_col_project": "项目", @@ -1814,6 +1841,16 @@ "attachment_remove_failed": "附件移除失败", "uploading": "上传中…", "running": "运行中…", - "executor_required": "请先选择员工或 AI 智能体作为执行者" + "executor_required": "请先选择员工或 AI 智能体作为执行者", + "project_visibility": "项目权限", + "project_visibility_private": "私有", + "project_visibility_public": "公开", + "project_visibility_private_description": "仅邀请成员可见", + "project_visibility_public_description": "所有 Backend 用户可见", + "project_visibility_public_notice": "未加入项目的用户可查看任务列表并创建任务,但只能打开和编辑自己创建的任务。", + "project_visibility_manage_description": "私有项目仅邀请成员可见;公开项目对所有连接当前 Backend 的用户可见。", + "project_visibility_private_manage_description": "仅邀请成员可查看项目和任务", + "project_visibility_public_manage_description": "所有用户可见任务列表,访客仅能打开和编辑自己的任务", + "project_visibility_update_failed": "更新项目权限失败" } } From 5dd69d1dd4adb3bb61832643d7e823fc5c8433ba Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 10:05:49 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E7=9C=8B=E6=9D=BF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=AE=BE=E7=BD=AE=E7=A7=81=E6=9C=89=E5=92=8C=E5=85=AC?= =?UTF-8?q?=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/task_runtime/issue_provider.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index f905bb8a4a..eb915cdc08 100644 --- a/executor/src/task_runtime/issue_provider.rs +++ b/executor/src/task_runtime/issue_provider.rs @@ -1584,11 +1584,7 @@ mod tests { assert_eq!(creator_from_labels(&labels), 42); assert_eq!( labels_for_write(labels, "none", "in_progress"), - vec![ - "bug", - "wegent:creator:42", - "wegent:status:in_progress" - ] + vec!["bug", "wegent:creator:42", "wegent:status:in_progress"] ); } From 711aaf3e810fdd6b84f6c05b8d64dc362460da22 Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 10:46:31 +0800 Subject: [PATCH 3/7] fix(todo): show creator names for external issues --- backend/app/api/endpoints/cloud_projects.py | 1 + backend/app/api/endpoints/deliveries.py | 1 + backend/app/schemas/cloud_project.py | 1 + backend/tests/api/test_cloud_projects_api.py | 1 + executor/src/task_runtime/issue_provider.rs | 23 ++++++++++++++ wework/src/api/deliveries.ts | 1 + wework/src/api/local/localDelivery.test.ts | 3 +- wework/src/api/local/localDelivery.ts | 18 +++++++---- .../src/features/todo/CloudProjectsHome.tsx | 7 +---- wework/src/features/todo/TodoEditor.tsx | 30 ++++++++++++++++++- wework/src/features/todo/todoShared.ts | 12 +++++++- 11 files changed, 83 insertions(+), 15 deletions(-) diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index 2656ec8ffe..78574f9f97 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -45,6 +45,7 @@ def _project_response( { **project.__dict__, "current_user_id": current_user.id, + "current_user_name": current_user.user_name, "access_role": access.role, } ) diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index a84ebbf77a..49a4003954 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -145,6 +145,7 @@ def find_runtime_task_cloud_context( "project": { **project.__dict__, "current_user_id": current_user.id, + "current_user_name": current_user.user_name, "access_role": cloud_project_service.access( db, project.id, current_user.id ).role, diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index fadd75e3c6..a6573ee912 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -122,6 +122,7 @@ class CloudProjectResponse(BaseModel): visibility: ProjectVisibility = "private" created_by_user_id: int current_user_id: int = 0 + current_user_name: str = "" access_role: BaseRole = BaseRole.RestrictedAnalyst status: str tags: list[str] = [] diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 7266cc07cf..63517e402d 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -165,6 +165,7 @@ def test_public_project_visitors_only_access_their_own_todo_details( assert visible_project["visibility"] == "public" assert visible_project["access_role"] == "RestrictedAnalyst" assert visible_project["current_user_id"] == visitor.id + assert visible_project["current_user_name"] == visitor.user_name listed_items = test_client.get( f"/api/v1/cloud-projects/{project['id']}/loop-items", diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index eb915cdc08..3f8d337677 100644 --- a/executor/src/task_runtime/issue_provider.rs +++ b/executor/src/task_runtime/issue_provider.rs @@ -1245,9 +1245,12 @@ fn labels_for_write(mut tags: Vec, priority: &str, status: &str) -> Vec< } fn creator_from_labels(labels: &[String]) -> i64 { + // Labels are `wegent:creator:` or `wegent:creator::`; the + // numeric id is authoritative, the optional name is display-only. labels .iter() .find_map(|label| label.strip_prefix(CREATOR_LABEL_PREFIX)) + .map(|value| value.split(':').next().unwrap_or(value)) .and_then(|value| value.parse().ok()) .filter(|value| *value > 0) .unwrap_or(0) @@ -1588,6 +1591,26 @@ mod tests { ); } + #[test] + fn reads_wegent_creator_id_from_label_with_display_name() { + // The id segment stays authoritative when a display name is appended + // for humans browsing the provider UI. + let labels = vec![ + "wegent:creator:42:Micro66".to_owned(), + "wegent:status:pending".to_owned(), + ]; + assert_eq!(creator_from_labels(&labels), 42); + assert_eq!( + labels_for_write(labels, "none", "in_progress"), + vec!["wegent:creator:42:Micro66", "wegent:status:in_progress"] + ); + // A label whose id segment is not numeric contributes no creator. + assert_eq!( + creator_from_labels(&["wegent:creator:unknown".to_owned()]), + 0 + ); + } + #[test] fn closed_provider_state_wins_and_unlabeled_open_issues_default_to_pending() { assert_eq!( diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index a593b601e1..e9b1f4a827 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -87,6 +87,7 @@ export interface CloudProject { } created_by_user_id: number current_user_id?: number + current_user_name?: string access_role?: 'Owner' | 'Maintainer' | 'Developer' | 'Reporter' | 'RestrictedAnalyst' visibility?: 'private' | 'public' status: string diff --git a/wework/src/api/local/localDelivery.test.ts b/wework/src/api/local/localDelivery.test.ts index 95542656bf..91896db4d3 100644 --- a/wework/src/api/local/localDelivery.test.ts +++ b/wework/src/api/local/localDelivery.test.ts @@ -118,6 +118,7 @@ describe('local delivery API', () => { await api.createLoopItem(cloudProject, { title: 'Created by visitor', tags: ['customer'], + creator_name: 'Micro66', }) expect(request).toHaveBeenCalledWith('external_todos.create', { @@ -131,7 +132,7 @@ describe('local delivery API', () => { status: 'inbox', priority: 'none', parent_id: null, - tags: ['customer', 'wegent:creator:7'], + tags: ['customer', 'wegent:creator:7:Micro66'], }, }) }) diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 539f3ff602..c0d8cf1354 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -102,6 +102,7 @@ function localProject(record: LocalLoopItemRecord): CloudProject { : {}, created_by_user_id: 0, current_user_id: 0, + current_user_name: '', access_role: 'Owner', visibility: 'private', status: record.status ?? 'active', @@ -158,8 +159,18 @@ export function createExternalIssueApi(request: LocalRequest) { priority?: CloudLoopItem['priority'] parent_id?: string | null tags?: string[] + creator_name?: string } ) { + // The creator label keeps the numeric id as the authoritative identity + // (`wegent:creator:`); a best-effort display name is appended for + // humans browsing the provider UI (`wegent:creator::`). + // Provider label separators (comma for GitLab) cannot appear in names. + const creatorName = data.creator_name?.replace(/[,:]/g, ' ').trim() + const creatorLabel = + (project.current_user_id ?? 0) > 0 + ? [`wegent:creator:${project.current_user_id}${creatorName ? `:${creatorName}` : ''}`] + : [] const record = await request('external_todos.create', { project: externalProjectDescriptor(project), todo: { @@ -168,12 +179,7 @@ export function createExternalIssueApi(request: LocalRequest) { status: data.status ?? 'inbox', priority: data.priority ?? 'none', parent_id: data.parent_id ?? null, - tags: [ - ...(data.tags ?? []), - ...((project.current_user_id ?? 0) > 0 - ? [`wegent:creator:${project.current_user_id}`] - : []), - ], + tags: [...(data.tags ?? []), ...creatorLabel], }, }) return localTask(record, project) diff --git a/wework/src/features/todo/CloudProjectsHome.tsx b/wework/src/features/todo/CloudProjectsHome.tsx index 5fb548ce4a..2a45e514c4 100644 --- a/wework/src/features/todo/CloudProjectsHome.tsx +++ b/wework/src/features/todo/CloudProjectsHome.tsx @@ -5,7 +5,7 @@ import { formatRelativeSidebarTime } from '@/components/layout/runtimeSidebarTim import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' import { CloudTodoModal as Modal } from './CloudTodoModal' -import { memberAvatarClasses } from './todoShared' +import { memberAvatarClasses, memberNameById } from './todoShared' export interface ProjectsHomeProject { id: string @@ -41,11 +41,6 @@ function isWithinLastWeek(timestamp: string | null | undefined, nowMs: number): return !Number.isNaN(valueMs) && valueMs >= nowMs - WEEK_MS } -function memberNameById(members: CloudProjectMember[], userId: number | null): string | null { - if (userId === null) return null - return members.find(member => member.user_id === userId)?.user_name ?? null -} - function matchesQuery(project: ProjectsHomeProject, query: string): boolean { if (!query) return true return project.name.toLowerCase().includes(query) diff --git a/wework/src/features/todo/TodoEditor.tsx b/wework/src/features/todo/TodoEditor.tsx index 341434a79e..3af182279f 100644 --- a/wework/src/features/todo/TodoEditor.tsx +++ b/wework/src/features/todo/TodoEditor.tsx @@ -37,7 +37,13 @@ import { cn } from '@/lib/utils' import { TaskDescriptionEditor } from './TaskDescriptionEditor' import { TagEditor } from './TagEditor' import { normalizeTaskDescription } from './taskDescription' -import { columnDotClasses, columns, memberAvatarClasses, priorityBadgeClasses } from './todoShared' +import { + columnDotClasses, + columns, + memberAvatarClasses, + memberNameById, + priorityBadgeClasses, +} from './todoShared' type DeliveryApi = NonNullable @@ -376,12 +382,21 @@ export function TodoEditor(props: TodoEditorProps) { const statusLabel = columns.find(column => column.status === status)?.label ?? '' const parentItem = allItems.find(candidate => candidate.id === parentId) const assignee = projectMembers.find(member => String(member.user_id) === assigneeId) + const creator = + item && item.created_by_user_id === editProps?.project?.current_user_id + ? editProps.project.current_user_name + : item + ? memberNameById(projectMembers, item.created_by_user_id) + : null async function submitCreate() { if (props.mode !== 'create' || !title.trim() || saving) return setSaving(true) setSaveError(null) try { + const creatorName = + props.project.current_user_name || + memberNameById(projectMembers, props.project.current_user_id ?? null) let created = await api.createLoopItem(props.project.id, { title: title.trim(), description, @@ -390,6 +405,7 @@ export function TodoEditor(props: TodoEditorProps) { tags, ...(parentId ? { parent_id: parentId } : {}), ...(dueDate ? { due_at: dueDate } : {}), + ...(creatorName ? { creator_name: creatorName } : {}), }) // createLoopItem does not accept an assignee, so apply it right after. if (assigneeId) { @@ -708,6 +724,18 @@ export function TodoEditor(props: TodoEditorProps) { ))} + {item && ( + + + 创建人 + + {creator ?? (item.created_by_user_id > 0 ? `#${item.created_by_user_id}` : '—')} + + + )} 父任务 diff --git a/wework/src/features/todo/todoShared.ts b/wework/src/features/todo/todoShared.ts index 5fa8dd47c8..0d121fbb74 100644 --- a/wework/src/features/todo/todoShared.ts +++ b/wework/src/features/todo/todoShared.ts @@ -1,4 +1,4 @@ -import type { CloudLoopItem } from '@/api/deliveries' +import type { CloudLoopItem, CloudProjectMember } from '@/api/deliveries' export const columns: Array<{ status: CloudLoopItem['status']; label: string }> = [ { status: 'inbox', label: '收集箱' }, @@ -30,6 +30,16 @@ export const priorityBadgeClasses: Record = { urgent: 'bg-red-500/10 text-red-600 dark:text-red-400', } +// Resolves a user id to the project member display name; returns null when +// the user is not (or no longer) a member of the project. +export function memberNameById( + members: CloudProjectMember[], + userId: number | null +): string | null { + if (userId === null) return null + return members.find(member => member.user_id === userId)?.user_name ?? null +} + // Computes the flat item list and lane id order after moving `itemId` into // the lane identified by its own parent layer and `status`, inserted before // `beforeItemId` (appended at the end when null). Returns null when the drop From f69362296255a3758ff5494839ec811daf904422 Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 14:25:22 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/cloud_projects.py | 20 +- backend/app/api/endpoints/deliveries.py | 73 ++- backend/app/schemas/cloud_project.py | 4 - backend/app/schemas/delivery.py | 14 + .../app/services/cloud_projects/service.py | 26 +- .../services/loop_items/external_provider.py | 579 ++++++++++++++++++ backend/app/services/loop_items/service.py | 2 + backend/tests/api/test_cloud_projects_api.py | 271 +++++++- executor/src/local/app_ipc.rs | 24 + executor/src/task_runtime/issue_provider.rs | 22 +- executor/src/task_runtime/mcp.rs | 481 ++++++++++++++- executor/src/task_runtime/mod.rs | 2 +- executor/src/task_runtime/model.rs | 19 + executor/src/task_runtime/router.rs | 83 +++ executor/src/task_runtime/store.rs | 125 ++++ wework/src/api/deliveries.ts | 4 +- .../api/hybrid/cloudProjectSpaceApi.test.ts | 306 +-------- wework/src/api/hybrid/cloudProjectSpaceApi.ts | 181 +----- wework/src/api/local/localDelivery.ts | 10 + .../src/features/todo/CloudProjectsHome.tsx | 1 + .../features/todo/CloudTodoWorkspace.test.tsx | 26 +- .../src/features/todo/CloudTodoWorkspace.tsx | 210 ++++--- wework/src/features/todo/GlobalTodoSearch.tsx | 163 +++++ wework/src/features/todo/TaskSearchPanel.tsx | 239 ++++++++ .../todo/TaskSearchPermissions.test.tsx | 82 +++ wework/src/features/todo/TodoEditor.tsx | 5 +- wework/src/features/todo/taskSearch.test.ts | 81 +++ wework/src/features/todo/taskSearch.ts | 123 ++++ .../workbench/workbenchServices.test.ts | 1 - wework/src/i18n/locales/en/common.json | 7 + wework/src/i18n/locales/zh-CN/common.json | 7 + 31 files changed, 2572 insertions(+), 619 deletions(-) create mode 100644 backend/app/services/loop_items/external_provider.py create mode 100644 wework/src/features/todo/GlobalTodoSearch.tsx create mode 100644 wework/src/features/todo/TaskSearchPanel.tsx create mode 100644 wework/src/features/todo/TaskSearchPermissions.test.tsx create mode 100644 wework/src/features/todo/taskSearch.test.ts create mode 100644 wework/src/features/todo/taskSearch.ts diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index 78574f9f97..eb540d1118 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -4,7 +4,7 @@ """Shared cloud project endpoints.""" -from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status from sqlalchemy.orm import Session from app.api.dependencies import get_db @@ -25,7 +25,6 @@ CloudProjectMemberCreate, CloudProjectMemberResponse, CloudProjectMemberUpdate, - CloudProjectProviderCredentialResponse, CloudProjectResponse, CloudProjectUpdate, LocalBindingCreate, @@ -84,23 +83,6 @@ def get_cloud_project( return _project_response(db, project, current_user) -@router.get( - "/{project_id}/provider-credential", - response_model=CloudProjectProviderCredentialResponse, -) -def get_cloud_project_provider_credential( - project_id: int, - response: Response, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> CloudProjectProviderCredentialResponse: - response.headers["Cache-Control"] = "no-store" - token = cloud_project_service.get_provider_credential( - db, project_id, current_user.id - ) - return CloudProjectProviderCredentialResponse(token=token) - - @router.patch("/{project_id}", response_model=CloudProjectResponse) def update_cloud_project( project_id: int, diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index 49a4003954..ace07c218e 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -4,7 +4,16 @@ """Authenticated project TODO and delivery endpoints.""" -from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + UploadFile, + status, +) from sqlalchemy.orm import Session from app.api.dependencies import get_db @@ -23,6 +32,8 @@ LoopItemAttachmentResponse, LoopItemCollaboratorCreate, LoopItemCollaboratorResponse, + LoopItemCommentCreate, + LoopItemCommentResponse, LoopItemCreate, LoopItemListResponse, LoopItemReorder, @@ -36,6 +47,7 @@ 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 +from app.services.loop_items.external_provider import external_loop_item_provider router = APIRouter() @@ -194,6 +206,16 @@ def list_loop_items( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemListResponse: + project = cloud_project_service.get(db, project_id, current_user.id) + if project.task_provider in {"github", "gitlab"}: + return LoopItemListResponse( + items=[ + LoopItemResponse.model_validate(item) + for item in external_loop_item_provider.list( + db, project_id, current_user.id + ) + ] + ) items = loop_item_service.list(db, project_id, current_user.id) access = cloud_project_service.access(db, project_id, current_user.id) return LoopItemListResponse( @@ -219,6 +241,13 @@ def create_loop_item( db: Session = Depends(get_db), 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) @@ -233,6 +262,16 @@ def reorder_loop_items( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemListResponse: + project = cloud_project_service.get(db, project_id, current_user.id) + if project.task_provider in {"github", "gitlab"}: + return LoopItemListResponse( + items=[ + LoopItemResponse.model_validate(item) + for item in external_loop_item_provider.list( + db, project_id, current_user.id + ) + ] + ) items = loop_item_service.reorder(db, project_id, current_user.id, values) return LoopItemListResponse( items=[_loop_item_response(db, item, current_user) for item in items] @@ -245,6 +284,10 @@ def get_loop_item( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemResponse: + if external_loop_item_provider.is_external_item(db, item_id): + return LoopItemResponse.model_validate( + external_loop_item_provider.get(db, item_id, current_user.id) + ) item = loop_item_service.get(db, item_id, current_user.id) return _loop_item_response(db, item, current_user) @@ -256,10 +299,32 @@ def update_loop_item( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemResponse: + if external_loop_item_provider.is_external_item(db, item_id): + return LoopItemResponse.model_validate( + external_loop_item_provider.update(db, item_id, current_user.id, values) + ) item = loop_item_service.update(db, item_id, current_user.id, values) return _loop_item_response(db, item, current_user) +@router.post( + "/loop-items/{item_id}/comments", + response_model=LoopItemCommentResponse, + status_code=status.HTTP_201_CREATED, +) +def add_loop_item_comment( + item_id: str, + values: LoopItemCommentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> LoopItemCommentResponse: + return LoopItemCommentResponse.model_validate( + external_loop_item_provider.add_comment( + db, item_id, current_user.id, values.body + ) + ) + + @router.get( "/loop-items/{item_id}/attachments", response_model=list[LoopItemAttachmentResponse], @@ -269,6 +334,7 @@ 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) return [LoopItemAttachmentResponse.model_validate(item) for item in attachments] @@ -284,6 +350,7 @@ 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( db, item_id, @@ -330,6 +397,7 @@ def list_loop_item_tasks( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> list[LoopItemTaskBindingResponse]: + external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) bindings = loop_item_service.list_task_bindings(db, item_id, current_user.id) return [LoopItemTaskBindingResponse.model_validate(binding) for binding in bindings] @@ -344,6 +412,7 @@ def unbind_loop_item_task( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> None: + external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) loop_item_service.unbind_task(db, item_id, values, current_user.id) @@ -358,6 +427,7 @@ def bind_loop_item_task( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> LoopItemTaskBindingResponse: + external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) binding = loop_item_service.bind_task(db, item_id, values, current_user.id) return LoopItemTaskBindingResponse.model_validate(binding) @@ -373,6 +443,7 @@ def create_delivery( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> DeliveryResponse: + external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) delivery = delivery_service.create_delivery(db, item_id, current_user.id, values) return _delivery_response(db, delivery) diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index a6573ee912..e4086c53aa 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -156,10 +156,6 @@ class CloudProjectListResponse(BaseModel): items: list[CloudProjectResponse] -class CloudProjectProviderCredentialResponse(BaseModel): - token: str - - class LocalBindingCreate(BaseModel): local_project_id: int device_id: str | None = Field(default=None, max_length=100) diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index 375e645065..a91a73fba8 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -71,6 +71,7 @@ class LoopItemResponse(BaseModel): sort_order: int tags: list[str] = [] created_by_user_id: int + created_by_user_name: str | None = None can_view_detail: bool = True can_edit: bool = True current_delivery_id: str | None @@ -113,6 +114,19 @@ class LoopItemListResponse(BaseModel): items: list[LoopItemResponse] +class LoopItemCommentCreate(BaseModel): + body: str = Field(min_length=1) + + +class LoopItemCommentResponse(BaseModel): + id: str + body: str + author: str + web_url: str | None = None + created_at: datetime + updated_at: datetime + + class LoopItemAttachmentResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index fb4c296300..6db36339b1 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -13,10 +13,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.core.provider_credentials import ( - decrypt_provider_token, - store_provider_config, -) +from app.core.provider_credentials import store_provider_config from app.models.cloud_project import CloudProject, CloudProjectLocalBinding from app.models.project import Project from app.models.resource_member import MemberStatus, ResourceMember @@ -132,27 +129,6 @@ def access(self, db: Session, project_id: int, user_id: int): db, project_id, user_id, BaseRole.RestrictedAnalyst ) - def get_provider_credential( - self, db: Session, project_id: int, user_id: int - ) -> str: - project = require_cloud_project_role( - db, project_id, user_id, BaseRole.RestrictedAnalyst - ).project - metadata = ( - project.metadata_json if isinstance(project.metadata_json, dict) else {} - ) - try: - token = decrypt_provider_token( - project.task_provider, metadata.get("provider_config") - ) - except ValueError as exc: - raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc - if not token: - raise HTTPException( - status.HTTP_409_CONFLICT, "Provider credential is not configured" - ) - return token - def update( self, db: Session, diff --git a/backend/app/services/loop_items/external_provider.py b/backend/app/services/loop_items/external_provider.py new file mode 100644 index 0000000000..34ca0bbe63 --- /dev/null +++ b/backend/app/services/loop_items/external_provider.py @@ -0,0 +1,579 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""GitHub and GitLab Issue providers for backend-owned project spaces.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from urllib.parse import quote + +import httpx +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.core.provider_credentials import decrypt_provider_token +from app.models.cloud_project import CloudProject +from app.models.delivery import LoopItem +from app.schemas.base_role import BaseRole, has_permission +from app.schemas.delivery import LoopItemCreate, LoopItemUpdate +from app.services.cloud_projects.access import ( + CloudProjectAccess, + require_cloud_project_role, +) + +PRIORITY_PREFIX = "wegent:priority:" +STATUS_PREFIX = "wegent:status:" +CREATOR_PREFIX = "wegent:creator:" +PARENT_MARKER = "Wegent-Parent:" + + +class ExternalLoopItemProvider: + def is_external_item(self, db: Session, item_id: str) -> bool: + return self._find_project(db, item_id) is not None + + def list( + self, db: Session, project_id: int, user_id: int + ) -> list[dict[str, object]]: + access = require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst + ) + project = access.project + self._require_external(project) + issues = self._list_issues(project) + return [self._response(project, issue, access, user_id) for issue in issues] + + def get(self, db: Session, item_id: str, user_id: int) -> dict[str, object]: + 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_view_detail"]: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return response + + def create( + self, + db: Session, + project_id: int, + user_id: int, + user_name: str, + values: LoopItemCreate, + ) -> dict[str, object]: + access = require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst + ) + project = access.project + self._require_external(project) + if not access.is_public_visitor and not has_permission( + access.role, BaseRole.Reporter + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission") + labels = self._labels_for_write( + values.tags + [f"{CREATOR_PREFIX}{user_id}:{self._safe_name(user_name)}"], + values.priority, + values.status, + ) + issue = self._create_issue( + project, + values.title, + self._with_parent(values.description, values.parent_id), + labels, + ) + if values.status == "completed": + issue = self._update_issue( + project, self._number(issue), {"state": "closed"} + ) + return self._response(project, issue, access, user_id) + + def update( + self, + db: Session, + item_id: str, + user_id: int, + values: LoopItemUpdate, + ) -> dict[str, object]: + project, number = self._resolve_project(db, item_id) + access = require_cloud_project_role( + db, project.id, user_id, BaseRole.RestrictedAnalyst + ) + current = self._get_issue(project, number) + current_response = self._response(project, current, access, user_id) + if not current_response["can_edit"]: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + payload: dict[str, object] = {} + dumped = values.model_dump(exclude_unset=True) + if "title" in dumped: + payload["title"] = values.title + if "description" in dumped or "parent_id" in dumped: + description = ( + values.description + if "description" in dumped + else str(current_response["description"]) + ) + parent_id = ( + values.parent_id + if "parent_id" in dumped + else current_response["parent_id"] + ) + payload[self._body_key(project)] = self._with_parent( + description or "", parent_id + ) + if {"tags", "priority", "status"} & dumped.keys(): + tags = ( + list(values.tags) + if values.tags is not None + else list(current_response["tags"]) + ) + creator = self._creator_label(self._labels(current)) + if creator: + tags.append(creator) + payload["labels"] = self._labels_for_write( + tags, + values.priority or str(current_response["priority"]), + values.status or str(current_response["status"]), + ) + if "status" in dumped: + payload["state"] = ( + "closed" if values.status == "completed" else self._open_state(project) + ) + issue = self._update_issue(project, number, payload) + return self._response(project, issue, access, user_id) + + def add_comment( + self, db: Session, item_id: str, user_id: int, body: str + ) -> dict[str, object]: + 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) + if not self._permissions( + access, self._creator_id(self._labels(issue)), user_id + )[1]: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return self._create_comment(project, number, body) + + def ensure_shadow(self, db: Session, item_id: str, user_id: int) -> LoopItem: + if not self.is_external_item(db, item_id): + existing = db.get(LoopItem, item_id) + if existing is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return existing + values = self.get(db, item_id, user_id) + existing = db.get(LoopItem, item_id) + if existing is not None: + return existing + item = LoopItem( + id=str(values["id"]), + cloud_project_id=str(values["cloud_project_id"]), + parent_id=values["parent_id"], + title=str(values["title"]), + description=str(values["description"]), + sequence_number=int(values["sequence_number"]), + created_by_user_id=int(values["created_by_user_id"]), + assignee_user_id=values["assignee_user_id"], + status=str(values["status"]), + priority=str(values["priority"]), + sort_order=int(values["sort_order"]), + metadata_json={"tags": values["tags"], "external_shadow": True}, + version=int(values["version"]), + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + def _response( + self, + project: CloudProject, + issue: dict[str, Any], + access: CloudProjectAccess, + user_id: int, + ) -> dict[str, object]: + labels = self._labels(issue) + creator_id = self._creator_id(labels) + creator_name = self._creator_name(labels) + can_view, can_edit = self._permissions(access, creator_id, user_id) + number = self._number(issue) + description = str(issue.get(self._body_key(project)) or "") + parent_id = self._parent_id(project, description) + description = "\n".join( + line + for line in description.splitlines() + if not line.strip().startswith(PARENT_MARKER) + ).strip() + state = str(issue.get("state") or "") + item_status = self._status(labels, state) + created_at = str(issue.get("created_at") or self._now()) + updated_at = str(issue.get("updated_at") or created_at) + return { + "id": f"{project.project_key}-{number}", + "cloud_project_id": str(project.id), + "sequence_number": number, + "parent_id": parent_id, + "title": str(issue.get("title") or ""), + "description": description if can_view else "", + "status": item_status, + "assignee_user_id": None, + "priority": self._priority(labels), + "due_at": None, + "sort_order": number, + "tags": self._public_tags(labels), + "created_by_user_id": creator_id, + "created_by_user_name": creator_name, + "can_view_detail": can_view, + "can_edit": can_edit, + "current_delivery_id": None, + "version": 1, + "created_at": created_at, + "updated_at": updated_at, + "completed_at": ( + str(issue.get("closed_at") or updated_at) + if item_status == "completed" + else None + ), + } + + @staticmethod + def _permissions( + access: CloudProjectAccess, creator_id: int, user_id: int + ) -> tuple[bool, bool]: + if access.is_public_visitor: + owns = creator_id > 0 and creator_id == user_id + return owns, owns + return True, has_permission(access.role, BaseRole.Developer) + + def _resolve_project(self, db: Session, item_id: str) -> tuple[CloudProject, int]: + resolved = self._find_project(db, item_id) + if resolved is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found") + return resolved + + @staticmethod + def _find_project(db: Session, item_id: str) -> tuple[CloudProject, int] | None: + key, separator, raw_number = item_id.rpartition("-") + if not separator or not raw_number.isdigit(): + return None + project = db.query(CloudProject).filter(CloudProject.project_key == key).first() + if project is None or project.task_provider not in {"github", "gitlab"}: + return None + return project, int(raw_number) + + @staticmethod + def _require_external(project: CloudProject) -> None: + if project.task_provider not in {"github", "gitlab"}: + raise HTTPException(status.HTTP_409_CONFLICT, "Project is not external") + + def _config(self, project: CloudProject) -> tuple[dict[str, object], str]: + metadata = ( + project.metadata_json if isinstance(project.metadata_json, dict) else {} + ) + config = metadata.get("provider_config") + config = config if isinstance(config, dict) else {} + try: + token = decrypt_provider_token(project.task_provider, config) + except ValueError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + if not token: + raise HTTPException( + status.HTTP_409_CONFLICT, "Provider credential is not configured" + ) + return config, token + + def _request( + self, + project: CloudProject, + method: str, + path: str, + *, + json: object | None = None, + params: dict[str, object] | None = None, + ) -> Any: + config, token = self._config(project) + domain = str( + config.get("domain") + or ("github.com" if project.task_provider == "github" else "gitlab.com") + ) + api_base = str( + config.get("api_base") + or ( + "https://api.github.com" + if project.task_provider == "github" + else f"https://{domain}/api/v4" + ) + ).rstrip("/") + headers = ( + { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + if project.task_provider == "github" + else {"PRIVATE-TOKEN": token} + ) + try: + response = httpx.request( + method, + f"{api_base}{path}", + headers=headers, + json=json, + params=params, + timeout=30, + ) + response.raise_for_status() + return response.json() if response.content else {} + except httpx.HTTPStatusError as exc: + if exc.response.status_code == status.HTTP_404_NOT_FOUND: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "TODO not found" + ) from exc + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, f"Provider request failed: {exc}" + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, f"Provider request failed: {exc}" + ) from exc + + def _repository(self, project: CloudProject) -> str: + config, _ = self._config(project) + repository = str(config.get("repository") or "").strip().strip("/") + if not repository: + raise HTTPException( + status.HTTP_409_CONFLICT, "Provider repository is required" + ) + return repository + + def _list_issues(self, project: CloudProject) -> list[dict[str, Any]]: + repository = self._repository(project) + results: list[dict[str, Any]] = [] + for page in range(1, 101): + path = ( + f"/repos/{repository}/issues" + if project.task_provider == "github" + else f"/projects/{quote(repository, safe='')}/issues" + ) + batch = self._request( + project, + "GET", + path, + params={"state": "all", "per_page": 100, "page": page}, + ) + if project.task_provider == "github": + batch = [issue for issue in batch if "pull_request" not in issue] + results.extend(batch) + if len(batch) < 100: + break + return results + + def _get_issue(self, project: CloudProject, number: int) -> dict[str, Any]: + repository = self._repository(project) + path = ( + f"/repos/{repository}/issues/{number}" + if project.task_provider == "github" + else f"/projects/{quote(repository, safe='')}/issues/{number}" + ) + return self._request(project, "GET", path) + + def _create_issue( + self, project: CloudProject, title: str, body: str, labels: list[str] + ) -> dict[str, Any]: + repository = self._repository(project) + path = ( + f"/repos/{repository}/issues" + if project.task_provider == "github" + else f"/projects/{quote(repository, safe='')}/issues" + ) + payload: dict[str, object] = { + "title": title, + self._body_key(project): body, + "labels": labels if project.task_provider == "github" else ",".join(labels), + } + return self._request(project, "POST", path, json=payload) + + def _update_issue( + self, project: CloudProject, number: int, payload: dict[str, object] + ) -> dict[str, Any]: + repository = self._repository(project) + if project.task_provider == "gitlab": + if isinstance(payload.get("labels"), list): + payload["labels"] = ",".join(payload["labels"]) + if "state" in payload: + payload["state_event"] = ( + "close" if payload.pop("state") == "closed" else "reopen" + ) + path = ( + f"/repos/{repository}/issues/{number}" + if project.task_provider == "github" + else f"/projects/{quote(repository, safe='')}/issues/{number}" + ) + return self._request( + project, + "PATCH" if project.task_provider == "github" else "PUT", + path, + json=payload, + ) + + def _create_comment( + self, project: CloudProject, number: int, body: str + ) -> dict[str, object]: + repository = self._repository(project) + path = ( + f"/repos/{repository}/issues/{number}/comments" + if project.task_provider == "github" + else f"/projects/{quote(repository, safe='')}/issues/{number}/notes" + ) + response = self._request( + project, + "POST", + path, + json={"body": body}, + ) + return { + "id": str(response.get("id") or ""), + "body": str(response.get("body") or ""), + "author": str( + (response.get("user") or response.get("author") or {}).get( + "login" if project.task_provider == "github" else "username" + ) + or "" + ), + "web_url": response.get("html_url") or response.get("web_url"), + "created_at": str(response.get("created_at") or self._now()), + "updated_at": str( + response.get("updated_at") or response.get("created_at") or self._now() + ), + } + + @staticmethod + def _number(issue: dict[str, Any]) -> int: + return int(issue.get("number") or issue.get("iid") or 0) + + @staticmethod + def _labels(issue: dict[str, Any]) -> list[str]: + labels = issue.get("labels") or [] + return [ + str(label.get("name") if isinstance(label, dict) else label) + for label in labels + ] + + @staticmethod + def _creator_label(labels: list[str]) -> str | None: + return next( + (label for label in labels if label.startswith(CREATOR_PREFIX)), None + ) + + @classmethod + def _creator_id(cls, labels: list[str]) -> int: + label = cls._creator_label(labels) + if not label: + return 0 + try: + return int(label.removeprefix(CREATOR_PREFIX).split(":", 1)[0]) + except ValueError: + return 0 + + @classmethod + def _creator_name(cls, labels: list[str]) -> str | None: + label = cls._creator_label(labels) + if not label: + return None + parts = label.removeprefix(CREATOR_PREFIX).split(":", 1) + return parts[1].strip() if len(parts) == 2 and parts[1].strip() else None + + @staticmethod + def _public_tags(labels: list[str]) -> list[str]: + return [ + label + for label in labels + if not label.startswith((PRIORITY_PREFIX, STATUS_PREFIX, CREATOR_PREFIX)) + ] + + @staticmethod + def _labels_for_write( + tags: list[str], priority: str, item_status: str + ) -> list[str]: + labels = [ + tag for tag in tags if not tag.startswith((PRIORITY_PREFIX, STATUS_PREFIX)) + ] + if priority != "none": + labels.append(f"{PRIORITY_PREFIX}{priority}") + labels.append(f"{STATUS_PREFIX}{item_status}") + return list(dict.fromkeys(labels)) + + @staticmethod + def _status(labels: list[str], provider_state: str) -> str: + if provider_state in {"closed"}: + return "completed" + value = next( + ( + label.removeprefix(STATUS_PREFIX) + for label in labels + if label.startswith(STATUS_PREFIX) + ), + "pending", + ) + return ( + value + if value in {"inbox", "pending", "in_progress", "in_review"} + else "pending" + ) + + @staticmethod + def _priority(labels: list[str]) -> str: + value = next( + ( + label.removeprefix(PRIORITY_PREFIX) + for label in labels + if label.startswith(PRIORITY_PREFIX) + ), + "none", + ) + return value if value in {"low", "medium", "high", "urgent"} else "none" + + @staticmethod + def _body_key(project: CloudProject) -> str: + return "body" if project.task_provider == "github" else "description" + + @staticmethod + def _open_state(project: CloudProject) -> str: + return "open" if project.task_provider == "github" else "opened" + + @staticmethod + def _with_parent(description: str, parent_id: str | None) -> str: + content = "\n".join( + line + for line in description.splitlines() + if not line.strip().startswith(PARENT_MARKER) + ).rstrip() + return ( + f"{content}\n\n{PARENT_MARKER} {parent_id}".strip() + if parent_id + else content + ) + + @staticmethod + def _parent_id(project: CloudProject, description: str) -> str | None: + for line in description.splitlines(): + if line.strip().startswith(PARENT_MARKER): + raw = line.strip().removeprefix(PARENT_MARKER).strip() + if raw.isdigit(): + return f"{project.project_key}-{raw}" + if raw: + return raw + return None + + @staticmethod + def _safe_name(name: str) -> str: + return name.replace(":", " ").replace(",", " ").strip() + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +external_loop_item_provider = ExternalLoopItemProvider() diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 55070745e2..eaaa1ba030 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -326,6 +326,8 @@ def reorder( item.sort_order = position item.version += 1 db.commit() + for item in ordered: + db.refresh(item) return ordered def get(self, db: Session, item_id: str, user_id: int) -> LoopItem: diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 63517e402d..0badbd1eee 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import BinaryIO +import httpx import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session @@ -20,6 +21,24 @@ from app.services.delivery import delivery_service +class FakeProviderResponse: + def __init__(self, payload: object, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.content = b"{}" + + def json(self) -> object: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise httpx.HTTPStatusError( + "provider error", + request=httpx.Request("GET", "https://provider.invalid"), + response=httpx.Response(self.status_code), + ) + + class FakeCloudFileStorage: def __init__(self) -> None: self.objects: dict[str, bytes] = {} @@ -203,6 +222,26 @@ def test_public_project_visitors_only_access_their_own_todo_details( assert updated.status_code == 200 assert updated.json()["title"] == "Visitor task updated" + external_project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "publicext", + "name": "Public external collaboration", + "visibility": "public", + "task_provider": "github", + "provider_config": { + "repository": "acme/public", + "token": "shared-provider-secret", + }, + }, + ).json() + hidden_credential = test_client.get( + f"/api/v1/cloud-projects/{external_project['id']}/provider-credential", + headers=_auth(visitor_token), + ) + assert hidden_credential.status_code == 404 + def test_cloud_project_persists_external_task_provider_and_encrypted_token( test_client: TestClient, @@ -260,9 +299,7 @@ def test_cloud_project_persists_external_task_provider_and_encrypted_token( f"/api/v1/cloud-projects/{created.json()['id']}/provider-credential", headers=_auth(test_token), ) - assert credential.status_code == 200 - assert credential.json() == {"token": "github-secret"} - assert credential.headers["cache-control"] == "no-store" + assert credential.status_code == 404 def test_cloud_project_can_add_missing_provider_token( @@ -302,7 +339,7 @@ def test_cloud_project_can_add_missing_provider_token( f"/api/v1/cloud-projects/{created['id']}/provider-credential", headers=_auth(test_token), ) - assert credential.json() == {"token": "gitlab-secret"} + assert credential.status_code == 404 def test_cloud_project_normalizes_gitlab_web_page_repository( @@ -327,7 +364,7 @@ def test_cloud_project_normalizes_gitlab_web_page_repository( assert created.json()["provider_config"]["repository"] == "hongyu91/tab-prompt" -def test_external_cloud_project_rejects_internal_loop_item_routes( +def test_external_cloud_project_requires_provider_credential( test_client: TestClient, test_token: str ) -> None: project = test_client.post( @@ -356,7 +393,229 @@ def test_external_cloud_project_rejects_internal_loop_item_routes( assert listed.status_code == 409 assert created.status_code == 409 - assert "use the local Issue provider" in created.json()["detail"] + assert "Provider credential is not configured" in created.json()["detail"] + + +def test_backend_routes_cloud_github_issues_without_exposing_token( + test_client: TestClient, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_issue = { + "number": 7, + "title": "Backend issue", + "body": "private details", + "state": "open", + "labels": [ + {"name": "wegent:creator:1:admin"}, + {"name": "wegent:status:in_progress"}, + {"name": "bug"}, + ], + "created_at": "2026-07-28T00:00:00Z", + "updated_at": "2026-07-28T00:00:00Z", + "closed_at": None, + } + requests: list[tuple[str, str, object]] = [] + + def provider_request( + method: str, url: str, **kwargs: object + ) -> FakeProviderResponse: + requests.append((method, url, kwargs.get("json"))) + if method == "GET": + return FakeProviderResponse([created_issue]) + return FakeProviderResponse(created_issue) + + monkeypatch.setattr(httpx, "request", provider_request) + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "cloudgh", + "name": "Cloud GitHub", + "task_provider": "github", + "provider_config": { + "repository": "acme/repo", + "token": "server-only-secret", + }, + }, + ).json() + + listed = test_client.get( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + ) + created = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + json={"title": "Backend issue", "status": "in_progress", "tags": ["bug"]}, + ) + + assert listed.status_code == 200 + assert listed.json()["items"][0]["id"] == "CLOUDGH-7" + assert created.status_code == 201 + assert any(method == "POST" for method, _, _ in requests) + assert all("server-only-secret" not in str(payload) for _, _, payload in requests) + + +def test_public_github_project_enforces_issue_ownership( + test_client: TestClient, + test_db: Session, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + visitor = User( + user_name="external-visitor", + password_hash="unused", + email="external-visitor@example.com", + is_active=True, + git_info=None, + ) + test_db.add(visitor) + test_db.commit() + test_db.refresh(visitor) + visitor_token = create_access_token(data={"sub": visitor.user_name}) + issues = { + 1: { + "number": 1, + "title": "Owner issue", + "body": "owner-only details", + "state": "open", + "labels": [{"name": "wegent:creator:1:admin"}], + "created_at": "2026-07-28T00:00:00Z", + "updated_at": "2026-07-28T00:00:00Z", + }, + 2: { + "number": 2, + "title": "Visitor issue", + "body": "visitor details", + "state": "open", + "labels": [{"name": f"wegent:creator:{visitor.id}:{visitor.user_name}"}], + "created_at": "2026-07-28T00:00:00Z", + "updated_at": "2026-07-28T00:00:00Z", + }, + } + + def provider_request( + method: str, url: str, **kwargs: object + ) -> FakeProviderResponse: + if method == "GET" and url.endswith("/issues"): + return FakeProviderResponse(list(issues.values())) + number = int(url.rsplit("/", 1)[-1]) + return FakeProviderResponse(issues[number]) + + monkeypatch.setattr(httpx, "request", provider_request) + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "publicgh", + "name": "Public GitHub", + "visibility": "public", + "task_provider": "github", + "provider_config": { + "repository": "acme/public", + "token": "server-only-secret", + }, + }, + ).json() + + listed = test_client.get( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(visitor_token), + ) + assert listed.status_code == 200 + by_id = {item["id"]: item for item in listed.json()["items"]} + assert by_id["PUBLICGH-1"]["description"] == "" + assert by_id["PUBLICGH-1"]["can_view_detail"] is False + assert by_id["PUBLICGH-2"]["description"] == "visitor details" + assert by_id["PUBLICGH-2"]["created_by_user_name"] == visitor.user_name + assert by_id["PUBLICGH-2"]["can_edit"] is True + + hidden = test_client.get( + "/api/v1/loop-items/PUBLICGH-1", headers=_auth(visitor_token) + ) + visible = test_client.get( + "/api/v1/loop-items/PUBLICGH-2", headers=_auth(visitor_token) + ) + assert hidden.status_code == 404 + assert visible.status_code == 200 + + +def test_backend_routes_gitlab_updates_and_comments( + test_client: TestClient, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + issue = { + "iid": 9, + "title": "GitLab issue", + "description": "details", + "state": "opened", + "labels": ["wegent:creator:1:admin", "wegent:status:pending"], + "created_at": "2026-07-28T00:00:00Z", + "updated_at": "2026-07-28T00:00:00Z", + } + requests: list[tuple[str, str, object]] = [] + + def provider_request( + method: str, url: str, **kwargs: object + ) -> FakeProviderResponse: + payload = kwargs.get("json") + requests.append((method, url, payload)) + if url.endswith("/notes"): + return FakeProviderResponse( + { + "id": 10, + "body": "ship it", + "author": {"username": "admin"}, + "web_url": "https://gitlab.example.com/note/10", + "created_at": "2026-07-28T00:00:00Z", + } + ) + if method == "PUT" and isinstance(payload, dict): + issue.update(payload) + if isinstance(issue.get("labels"), str): + issue["labels"] = str(issue["labels"]).split(",") + issue["state"] = ( + "closed" if payload.get("state_event") == "close" else issue["state"] + ) + return FakeProviderResponse(issue) + + monkeypatch.setattr(httpx, "request", provider_request) + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "project_key": "cloudgl", + "name": "Cloud GitLab", + "task_provider": "gitlab", + "provider_config": { + "repository": "group/project", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + "token": "server-only-secret", + }, + }, + ).json() + + updated = test_client.patch( + "/api/v1/loop-items/CLOUDGL-9", + headers=_auth(test_token), + json={"version": 1, "status": "completed"}, + ) + commented = test_client.post( + "/api/v1/loop-items/CLOUDGL-9/comments", + headers=_auth(test_token), + json={"body": "ship it"}, + ) + + assert updated.status_code == 200 + assert updated.json()["status"] == "completed" + assert commented.status_code == 201 + assert commented.json()["web_url"] == "https://gitlab.example.com/note/10" + assert any(method == "PUT" for method, _, _ in requests) + assert any(url.endswith("/notes") for _, url, _ in requests) + assert all("server-only-secret" not in str(payload) for _, _, payload in requests) def test_cloud_project_can_link_local_workspace( diff --git a/executor/src/local/app_ipc.rs b/executor/src/local/app_ipc.rs index 8d2d887998..72c004c30d 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -702,6 +702,30 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; + runtime + .remove_external_project(project_id) + .map_err(task_runtime_error)?; + Ok(json!({})) + } + "external_projects.retain" => { + let project_ids = params + .get("project_ids") + .and_then(Value::as_array) + .ok_or_else(|| AppIpcError::new("bad_request", "project_ids must be an array"))? + .iter() + .map(|value| { + value.as_str().map(ToOwned::to_owned).ok_or_else(|| { + AppIpcError::new("bad_request", "project_ids must contain strings") + }) + }) + .collect::, _>>()?; + runtime + .retain_external_projects(&project_ids) + .map_err(task_runtime_error)?; + Ok(json!({})) + } "external_todos.list" => { let project = task_input::(¶ms, "project")?; serialize_task_value( diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index 3f8d337677..a20469e5d2 100644 --- a/executor/src/task_runtime/issue_provider.rs +++ b/executor/src/task_runtime/issue_provider.rs @@ -240,12 +240,17 @@ impl IssueProvider { .unwrap_or_default() }); tags.retain(|label| !label.starts_with(CREATOR_LABEL_PREFIX)); - if let Some(created_by_user_id) = current - .as_ref() - .map(|item| item.created_by_user_id) - .filter(|value| *value > 0) - { - tags.push(format!("{CREATOR_LABEL_PREFIX}{created_by_user_id}")); + if let Some(creator_label) = current.as_ref().and_then(|item| { + item.metadata + .get("creator_label") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + (item.created_by_user_id > 0) + .then(|| format!("{CREATOR_LABEL_PREFIX}{}", item.created_by_user_id)) + }) + }) { + tags.push(creator_label); } let priority = input .priority @@ -1168,6 +1173,10 @@ fn issue_loop_item( let status = status_from_labels(&provider_state, &labels); let priority = priority_from_labels(&labels); let created_by_user_id = creator_from_labels(&labels); + let creator_label = labels + .iter() + .find(|label| label.starts_with(CREATOR_LABEL_PREFIX)) + .cloned(); let labels = labels .into_iter() .filter(|label| { @@ -1200,6 +1209,7 @@ fn issue_loop_item( "provider_state": provider_state, "web_url": web_url, "author": author, + "creator_label": creator_label, "labels": labels, "comments": comments, }), diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index 8b0586bd32..41f41a9d0a 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -2,7 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::{env, fs, path::Path}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde_json::{json, Value}; @@ -10,7 +13,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::protocol::ExecutionRequest; -use super::{BinaryInput, ProjectCreate, TaskRuntime}; +use super::{BinaryInput, ProjectCreate, TaskRuntime, TaskSearch}; const TASK_MCP_SERVER_NAME: &str = "wegent_tasks"; @@ -29,12 +32,36 @@ pub fn ensure_task_mcp_server(request: &mut ExecutionRequest) { let Ok(executable) = env::current_exe() else { return; }; - request.mcp_servers.push(json!({ + let mut server = json!({ "name": TASK_MCP_SERVER_NAME, "type": "stdio", "command": executable, "args": ["task-mcp-server"], - })); + }); + if let Some(project_id) = request + .extra + .get("cloudProjectId") + .or_else(|| request.extra.get("cloud_project_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + server["env"] = json!({"WEGENT_TASK_PROJECT_ID": project_id}); + } + if let Some(backend_url) = request + .backend_url + .as_deref() + .filter(|value| !value.is_empty()) + { + server["env"]["WEGENT_TASK_BACKEND_URL"] = json!(backend_url); + } + if let Some(auth_token) = request + .auth_token + .as_deref() + .filter(|value| !value.is_empty()) + { + server["env"]["WEGENT_TASK_AUTH_TOKEN"] = json!(auth_token); + } + request.mcp_servers.push(server); } pub async fn run() -> Result<(), String> { @@ -102,10 +129,42 @@ async fn handle_request(runtime: &TaskRuntime, request: &Value) -> Option } async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value { + let scoped_project_id = env::var("WEGENT_TASK_PROJECT_ID").ok(); + if let (Some(scoped), Some(requested)) = ( + scoped_project_id.as_deref(), + arguments.get("project_id").and_then(Value::as_str), + ) { + if requested != scoped { + return text_result( + "Task MCP access is limited to the current project".to_owned(), + true, + ); + } + } + let is_local_project = scoped_project_id + .as_deref() + .is_some_and(|project_id| is_local_scoped_project(runtime, project_id)); + if let (Ok(backend_url), Ok(auth_token), Some(project_id)) = ( + env::var("WEGENT_TASK_BACKEND_URL"), + env::var("WEGENT_TASK_AUTH_TOKEN"), + scoped_project_id.as_deref(), + ) { + if !is_local_project { + return match call_backend_tool(&backend_url, &auth_token, project_id, name, &arguments) + .await + { + Ok(value) => text_result(value.to_string(), false), + Err(error) => text_result(error, true), + }; + } + } let result = match name { - "list_projects" => runtime - .list_projects() - .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + "list_projects" => runtime.list_projects().and_then(|mut value| { + if let Some(project_id) = scoped_project_id.as_deref() { + value.retain(|project| project.id == project_id); + } + serde_json::to_value(value).map_err(invalid_json) + }), "create_project" => parse(arguments) .and_then(|input: ProjectCreate| runtime.create_project(input)) .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), @@ -131,6 +190,18 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), Err(error) => Err(error), }, + "search_todos" => match parse::(arguments) { + Ok(mut input) => { + if input.project_id.is_none() { + input.project_id = scoped_project_id; + } + runtime + .search_tasks(input) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)) + } + Err(error) => Err(error), + }, "get_todo" => { let project_id = string_argument(&arguments, "project_id"); let task_id = string_argument(&arguments, "task_id"); @@ -265,6 +336,253 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value } } +fn is_local_scoped_project(runtime: &TaskRuntime, project_id: &str) -> bool { + runtime + .list_projects() + .unwrap_or_default() + .into_iter() + .find(|project| project.id == project_id) + .and_then(|project| { + project.metadata["project_store"] + .as_str() + .map(|store| store == "local") + }) + .unwrap_or(false) +} + +async fn call_backend_tool( + backend_url: &str, + auth_token: &str, + project_id: &str, + name: &str, + arguments: &Value, +) -> Result { + let client = reqwest::Client::new(); + let base = format!("{}/api/v1", backend_url.trim_end_matches('/')); + let task_id = || { + arguments + .get("task_id") + .and_then(Value::as_str) + .ok_or_else(|| "task_id is required".to_owned()) + }; + let request = match name { + "list_projects" => client.get(format!("{base}/cloud-projects")), + "list_todos" => client.get(format!("{base}/cloud-projects/{project_id}/loop-items")), + "search_todos" => { + let response = backend_json( + client + .get(format!("{base}/cloud-projects/{project_id}/loop-items")) + .bearer_auth(auth_token) + .send() + .await + .map_err(|error| error.to_string())?, + ) + .await?; + return Ok(filter_backend_tasks(response, arguments)); + } + "get_todo" => client.get(format!("{base}/loop-items/{}", encode_segment(task_id()?))), + "create_todo" => client + .post(format!("{base}/cloud-projects/{project_id}/loop-items")) + .json(arguments.get("todo").unwrap_or(arguments)), + "update_todo" => client + .patch(format!("{base}/loop-items/{}", encode_segment(task_id()?))) + .json(arguments.get("todo").unwrap_or(arguments)), + "add_todo_comment" => client + .post(format!( + "{base}/loop-items/{}/comments", + encode_segment(task_id()?) + )) + .json(&json!({ + "body": arguments.get("body").and_then(Value::as_str).unwrap_or_default() + })), + "list_todo_attachments" => client.get(format!( + "{base}/loop-items/{}/attachments", + encode_segment(task_id()?) + )), + "reorder_todos" => client + .post(format!( + "{base}/cloud-projects/{project_id}/loop-items/reorder" + )) + .json(arguments.get("reorder").unwrap_or(arguments)), + "create_project" | "update_project" => { + return Err("Cloud project management is not available through task MCP".to_owned()) + } + "upload_todo_attachment" => { + let file_path = + string_argument(arguments, "file_path").map_err(|error| error.to_string())?; + let bytes = fs::read(&file_path).map_err(|error| error.to_string())?; + let display_name = arguments + .get("display_name") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + Path::new(&file_path) + .file_name() + .and_then(|value| value.to_str()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "attachment".to_owned()); + let response = client + .post(format!( + "{base}/loop-items/{}/attachments", + encode_segment(task_id()?) + )) + .bearer_auth(auth_token) + .multipart(reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::bytes(bytes).file_name(display_name), + )) + .send() + .await + .map_err(|error| error.to_string())?; + return backend_json(response).await; + } + "download_todo_attachment" => { + let attachment_id = + string_argument(arguments, "attachment_id").map_err(|error| error.to_string())?; + let output_path = attachment_output_path(arguments, &attachment_id) + .map_err(|error| error.to_string())?; + let access = backend_json( + client + .get(format!( + "{base}/loop-item-attachments/{}/access", + encode_segment(&attachment_id) + )) + .bearer_auth(auth_token) + .send() + .await + .map_err(|error| error.to_string())?, + ) + .await?; + let url = access["url"] + .as_str() + .ok_or_else(|| "attachment access URL is missing".to_owned())?; + let bytes = client + .get(url) + .send() + .await + .map_err(|error| error.to_string())? + .bytes() + .await + .map_err(|error| error.to_string())?; + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + fs::write(&output_path, bytes).map_err(|error| error.to_string())?; + return Ok(json!({"path": output_path})); + } + "delete_todo_attachment" => { + let attachment_id = + string_argument(arguments, "attachment_id").map_err(|error| error.to_string())?; + let response = client + .delete(format!( + "{base}/loop-item-attachments/{}", + encode_segment(&attachment_id) + )) + .bearer_auth(auth_token) + .send() + .await + .map_err(|error| error.to_string())?; + return backend_json(response).await; + } + _ => return Err(format!("Unknown task tool: {name}")), + }; + let response = request + .bearer_auth(auth_token) + .send() + .await + .map_err(|error| error.to_string())?; + let value = backend_json(response).await?; + if name == "list_projects" { + return Ok(Value::Array( + value + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|project| project["id"].as_str() == Some(project_id)) + .cloned() + .collect(), + )); + } + if name == "list_todos" { + return Ok(value.get("items").cloned().unwrap_or_else(|| json!([]))); + } + Ok(value) +} + +async fn backend_json(response: reqwest::Response) -> Result { + let status = response.status(); + let text = response.text().await.map_err(|error| error.to_string())?; + if !status.is_success() { + return Err(format!("Backend request failed ({status}): {text}")); + } + if text.is_empty() { + return Ok(json!({})); + } + serde_json::from_str(&text).map_err(|error| error.to_string()) +} + +fn filter_backend_tasks(response: Value, arguments: &Value) -> Value { + let tasks = response + .get("items") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let query = arguments + .get("query") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + let limit = arguments + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(50) + .clamp(1, 200) as usize; + Value::Array( + tasks + .into_iter() + .filter(|task| { + let text = format!( + "{} {} {} {}", + task["id"].as_str().unwrap_or_default(), + task["title"].as_str().unwrap_or_default(), + task["description"].as_str().unwrap_or_default(), + task["tags"] + ) + .to_lowercase(); + (query.is_empty() || text.contains(&query)) + && matches_filter(task, arguments, "status") + && matches_filter(task, arguments, "priority") + && arguments + .get("tag") + .and_then(Value::as_str) + .is_none_or(|tag| { + task["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|value| value == tag)) + }) + && arguments + .get("creator_user_id") + .and_then(Value::as_i64) + .is_none_or(|id| task["created_by_user_id"] == id) + }) + .take(limit) + .collect(), + ) +} + +fn matches_filter(task: &Value, arguments: &Value, key: &str) -> bool { + arguments + .get(key) + .and_then(Value::as_str) + .is_none_or(|value| task[key] == value) +} + +fn encode_segment(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + fn tools() -> Vec { vec![ tool( @@ -324,6 +642,24 @@ fn tools() -> Vec { "required": ["project_id"] }), ), + tool( + "search_todos", + "Search tasks across one project or all configured projects using text and structured filters", + json!({ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Matches task id, title, description, or tags"}, + "project_id": {"type": "string"}, + "status": {"enum": ["inbox", "pending", "in_progress", "in_review", "completed"]}, + "priority": {"enum": ["none", "low", "medium", "high", "urgent"]}, + "tag": {"type": "string"}, + "creator_user_id": {"type": "integer"}, + "parent_id": {"type": "string"}, + "has_children": {"type": "boolean"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200} + } + }), + ), tool( "create_todo", "Create a local task or external Issue", @@ -527,6 +863,27 @@ fn copy_attachment_if_requested( Ok(output_path.to_owned()) } +fn attachment_output_path( + arguments: &Value, + attachment_id: &str, +) -> Result { + if let Some(output_path) = arguments + .get("output_path") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + return Ok(PathBuf::from(output_path)); + } + let file_name = Path::new(attachment_id) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("attachment"); + Ok(env::temp_dir() + .join("wegent-task-attachments") + .join(file_name)) +} + fn invalid_json(error: serde_json::Error) -> super::TaskRuntimeError { super::TaskRuntimeError::Invalid(error.to_string()) } @@ -564,6 +921,40 @@ mod tests { assert_eq!(request.mcp_servers[0]["args"], json!(["task-mcp-server"])); } + #[test] + fn scopes_task_mcp_to_the_bound_cloud_project() { + let mut request = ExecutionRequest::default(); + request + .extra + .insert("cloudProjectId".to_owned(), json!("cloud-42")); + + ensure_task_mcp_server(&mut request); + + assert_eq!( + request.mcp_servers[0]["env"]["WEGENT_TASK_PROJECT_ID"], + "cloud-42" + ); + } + + #[test] + fn identifies_local_projects_before_selecting_the_backend_route() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "Offline project".to_owned(), + project_key: Some("OFFLINE".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Local, + provider_config: json!({}), + }) + .unwrap(); + let runtime = TaskRuntime::new(store).unwrap(); + + assert!(is_local_scoped_project(&runtime, &project.id)); + assert!(!is_local_scoped_project(&runtime, "cloud-project")); + } + #[test] fn exposes_task_attachment_tools() { let names = tools() @@ -581,6 +972,82 @@ mod tests { } } + #[test] + fn exposes_task_search_tool() { + let search = tools() + .into_iter() + .find(|tool| tool["name"] == "search_todos") + .expect("search_todos tool"); + + assert_eq!( + search["inputSchema"]["properties"]["status"]["enum"], + json!(["inbox", "pending", "in_progress", "in_review", "completed"]) + ); + assert_eq!( + search["inputSchema"]["properties"]["creator_user_id"]["type"], + "integer" + ); + } + + #[tokio::test] + async fn searches_todos_by_text_and_structured_filters() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "Search project".to_owned(), + project_key: Some("SEARCH".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Local, + provider_config: json!({}), + }) + .unwrap(); + store + .create_task( + &project.id, + TaskCreate { + title: "Fix OAuth callback".to_owned(), + description: "Login fails after redirect".to_owned(), + status: "in_progress".to_owned(), + priority: "high".to_owned(), + parent_id: None, + tags: vec!["bug".to_owned()], + }, + ) + .unwrap(); + store + .create_task( + &project.id, + TaskCreate { + title: "Write deployment guide".to_owned(), + description: String::new(), + status: "pending".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec!["docs".to_owned()], + }, + ) + .unwrap(); + let runtime = TaskRuntime::new(store).unwrap(); + + let result = call_tool( + &runtime, + "search_todos", + json!({ + "project_id": project.id, + "query": "oauth", + "status": "in_progress", + "tag": "bug" + }), + ) + .await; + assert_eq!(result["isError"], false); + let tasks: Value = + serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(tasks.as_array().unwrap().len(), 1); + assert_eq!(tasks[0]["title"], "Fix OAuth callback"); + } + #[tokio::test] async fn task_attachment_tools_upload_list_download_and_delete() { let directory = tempfile::tempdir().unwrap(); diff --git a/executor/src/task_runtime/mod.rs b/executor/src/task_runtime/mod.rs index b37fa187de..235f148695 100644 --- a/executor/src/task_runtime/mod.rs +++ b/executor/src/task_runtime/mod.rs @@ -14,7 +14,7 @@ pub use model::{ BinaryInput, Delivery, DeliveryAsset, DeliveryCreate, DeliveryDetail, IssueComment, LoopItem, ProjectCreate, ProjectDescriptor, ProjectFile, ProjectStoreKind, ProjectUpdate, RuntimeTaskAddress, TaskAttachment, TaskBinding, TaskCreate, TaskProviderKind, TaskReorder, - TaskUpdate, + TaskSearch, TaskUpdate, }; pub use router::TaskRuntime; pub use store::{LocalTaskStore, TaskRuntimeError}; diff --git a/executor/src/task_runtime/model.rs b/executor/src/task_runtime/model.rs index 6406208c23..252239d05a 100644 --- a/executor/src/task_runtime/model.rs +++ b/executor/src/task_runtime/model.rs @@ -89,6 +89,21 @@ pub struct TaskReorder { pub item_ids: Vec, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct TaskSearch { + #[serde(default)] + pub query: String, + pub project_id: Option, + pub status: Option, + pub priority: Option, + pub tag: Option, + pub creator_user_id: Option, + pub parent_id: Option, + pub has_children: Option, + #[serde(default = "default_search_limit")] + pub limit: usize, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct IssueComment { pub id: String, @@ -243,6 +258,10 @@ fn default_version() -> i64 { 1 } +fn default_search_limit() -> usize { + 50 +} + fn default_provider_config() -> Value { Value::Object(Default::default()) } diff --git a/executor/src/task_runtime/router.rs b/executor/src/task_runtime/router.rs index 9aa0d5bdcf..a331be4c00 100644 --- a/executor/src/task_runtime/router.rs +++ b/executor/src/task_runtime/router.rs @@ -63,6 +63,14 @@ impl TaskRuntime { .map(mask_project) } + pub fn remove_external_project(&self, project_id: &str) -> Result<(), TaskRuntimeError> { + self.local_store.remove_external_project(project_id) + } + + pub fn retain_external_projects(&self, project_ids: &[String]) -> Result<(), TaskRuntimeError> { + self.local_store.retain_external_projects(project_ids) + } + pub async fn list_external_tasks( &self, project: ProjectDescriptor, @@ -172,6 +180,81 @@ impl TaskRuntime { } } + pub async fn search_tasks( + &self, + input: super::TaskSearch, + ) -> Result, TaskRuntimeError> { + let projects = if let Some(project_id) = input.project_id.as_deref() { + vec![self.local_store.get_project(project_id)?] + } else { + self.local_store.list_projects()? + }; + let query = input.query.trim().to_lowercase(); + let mut matches = Vec::new(); + for project in projects { + let tasks = self.list_tasks(&project.id).await?; + let child_ids = tasks + .iter() + .filter_map(|task| task.parent_id.clone()) + .collect::>(); + for task in tasks { + let tags = task + .metadata + .get("tags") + .or_else(|| task.metadata.get("labels")) + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + let text_matches = query.is_empty() + || task.id.to_lowercase().contains(&query) + || task + .title + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&query) + || task.description.to_lowercase().contains(&query) + || tags.iter().any(|tag| tag.to_lowercase().contains(&query)); + if !text_matches + || input + .status + .as_deref() + .is_some_and(|value| task.status.as_deref() != Some(value)) + || input + .priority + .as_deref() + .is_some_and(|value| task.priority.as_deref() != Some(value)) + || input + .tag + .as_deref() + .is_some_and(|value| !tags.contains(&value)) + || input + .creator_user_id + .is_some_and(|value| task.created_by_user_id != value) + || input + .parent_id + .as_deref() + .is_some_and(|value| task.parent_id.as_deref() != Some(value)) + || input + .has_children + .is_some_and(|value| child_ids.contains(&task.id) != value) + { + continue; + } + matches.push(task); + if matches.len() >= input.limit.clamp(1, 200) { + return Ok(matches); + } + } + } + Ok(matches) + } + pub async fn get_task( &self, project_id: &str, diff --git a/executor/src/task_runtime/store.rs b/executor/src/task_runtime/store.rs index 7b0918b87c..92faff1a9a 100644 --- a/executor/src/task_runtime/store.rs +++ b/executor/src/task_runtime/store.rs @@ -194,6 +194,56 @@ impl LocalTaskStore { Ok(descriptor_loop_item(project, provider_config)) } + pub fn remove_external_project(&self, project_id: &str) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + let transaction = connection.unchecked_transaction()?; + transaction.execute( + "DELETE FROM project_provider_credentials + WHERE project_store = 'backend' AND project_id = ?1", + [project_id], + )?; + transaction.execute( + "DELETE FROM external_project_catalog + WHERE project_store = 'backend' AND project_id = ?1", + [project_id], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn retain_external_projects(&self, project_ids: &[String]) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + let transaction = connection.unchecked_transaction()?; + let retained = project_ids + .iter() + .cloned() + .collect::>(); + let mut statement = transaction.prepare( + "SELECT project_id FROM external_project_catalog WHERE project_store = 'backend'", + )?; + let stored = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + drop(statement); + for project_id in stored { + if retained.contains(&project_id) { + continue; + } + transaction.execute( + "DELETE FROM project_provider_credentials + WHERE project_store = 'backend' AND project_id = ?1", + [&project_id], + )?; + transaction.execute( + "DELETE FROM external_project_catalog + WHERE project_store = 'backend' AND project_id = ?1", + [&project_id], + )?; + } + transaction.commit()?; + Ok(()) + } + pub fn external_project( &self, project: ProjectDescriptor, @@ -1198,6 +1248,81 @@ mod tests { ); } + #[test] + fn removes_backend_external_project_credentials_and_catalog_entry() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = ProjectDescriptor { + id: "cloud-public".to_owned(), + public_id: Some("public-id".to_owned()), + project_key: "PUBLIC".to_owned(), + name: "Public GitHub".to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::Github, + provider_config: json!({ + "repository": "acme/public", + "token": "sensitive-token" + }), + version: 1, + }; + store.configure_external_project(project).unwrap(); + + store.remove_external_project("cloud-public").unwrap(); + + assert!(store + .list_projects() + .unwrap() + .iter() + .all(|candidate| candidate.id != "cloud-public")); + let connection = store.connection().unwrap(); + let credential_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM project_provider_credentials + WHERE project_store = 'backend' AND project_id = 'cloud-public'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(credential_count, 0); + } + + #[test] + fn retains_only_current_accounts_backend_external_projects() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + for id in ["account-a", "account-b"] { + store + .configure_external_project(ProjectDescriptor { + id: id.to_owned(), + public_id: None, + project_key: id.to_uppercase(), + name: id.to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::Github, + provider_config: json!({ + "repository": format!("acme/{id}"), + "token": format!("{id}-token") + }), + version: 1, + }) + .unwrap(); + } + + store + .retain_external_projects(&["account-b".to_owned()]) + .unwrap(); + + let project_ids = store + .list_projects() + .unwrap() + .into_iter() + .map(|project| project.id) + .collect::>(); + assert_eq!(project_ids, vec!["account-b"]); + } + #[test] fn creates_nested_tasks_and_rejects_cycles() { let (_directory, store) = store(); diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index e9b1f4a827..763b52a0f4 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -43,6 +43,7 @@ export interface CloudLoopItem { sequence_number: number parent_id: string | null created_by_user_id: number + created_by_user_name?: string | null can_view_detail?: boolean can_edit?: boolean assignee_user_id: number | null @@ -202,9 +203,6 @@ export function createDeliveryApi(client: HttpClient) { }): Promise { return client.post('/v1/cloud-projects', data) }, - getCloudProjectProviderCredential(projectId: CloudProjectIdInput): Promise<{ token: string }> { - return client.get(`/v1/cloud-projects/${projectId}/provider-credential`) - }, updateCloudProject( projectId: CloudProjectIdInput, data: { diff --git a/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts b/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts index c16efbe053..5f41bd7ff3 100644 --- a/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts +++ b/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts @@ -1,312 +1,34 @@ import { describe, expect, test, vi } from 'vitest' -import type { CloudProject } from '@/api/deliveries' import type { DeliveryApi, ExternalIssueApi } from '@/features/workbench/workbenchServices' import { createCloudProjectSpaceApi } from './cloudProjectSpaceApi' -const project: CloudProject = { - id: 'cloud-1', - public_id: 'public-1', - project_key: 'CLOUD', - name: 'Cloud GitHub board', - description: '', - project_store: 'backend', - task_provider: 'github', - provider_config: { repository: 'acme/repo' }, - created_by_user_id: 1, - status: 'active', - tags: [], - version: 1, - created_at: '2026-07-27T00:00:00Z', - updated_at: '2026-07-27T00:00:00Z', -} - describe('cloud project-space API', () => { - test('stores the token in backend and configures the local executor', async () => { - const storeApi = { - createCloudProject: vi.fn(async () => project), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(async () => undefined), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await api.createCloudProject({ - name: project.name, - task_provider: 'github', - provider_config: { - repository: 'acme/repo', - token: 'local-secret', - }, - }) - - expect(storeApi.createCloudProject).toHaveBeenCalledWith({ - name: project.name, - task_provider: 'github', - provider_config: { - repository: 'acme/repo', - token: 'local-secret', - }, - }) - expect(externalIssueApi.configureProject).toHaveBeenCalledWith(project, 'local-secret') - }) - - test('uses submitted provider routing without waiting for the backend response to echo it', async () => { - const incompatibleProject = { - ...project, - task_provider: 'local' as const, - provider_config: {}, - } - const storeApi = { - createCloudProject: vi.fn(async () => incompatibleProject), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - const created = await api.createCloudProject({ - name: project.name, - task_provider: 'github', - provider_config: { - repository: 'acme/repo', - token: 'local-secret', - }, - }) - - expect(created.task_provider).toBe('github') - expect(created.provider_config).toEqual({ repository: 'acme/repo' }) - expect(externalIssueApi.configureProject).toHaveBeenCalledWith( - { - ...incompatibleProject, - project_store: 'backend', - task_provider: 'github', - provider_config: { repository: 'acme/repo' }, - }, - 'local-secret' - ) - }) - - test('stores the project in backend and routes GitHub Issues through local executor', async () => { - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [project] })), - getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'cloud-secret' })), - createCloudProject: vi.fn(async () => project), - listLoopItems: vi.fn(), - } as unknown as DeliveryApi - const issue = { - id: 'CLOUD-7', - cloud_project_id: project.id, - title: 'Issue', - } - const externalIssueApi = { - configureProject: vi.fn(async () => undefined), - listLoopItems: vi.fn(async () => ({ items: [issue] })), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await api.listCloudProjects() - const result = await api.listLoopItems(project.id) - const [deliveries, bindings, attachments, collaborators] = await Promise.all([ - api.listDeliveries(issue.id), - api.listTaskBindings(issue.id), - api.listLoopItemAttachments(issue.id), - api.listLoopItemCollaborators(issue.id), - ]) - - expect(result.items).toEqual([issue]) - expect(deliveries.items).toEqual([]) - expect(bindings).toEqual([]) - expect(attachments).toEqual([]) - expect(collaborators).toEqual([]) - expect(externalIssueApi.configureProject).toHaveBeenCalledWith(project, 'cloud-secret') - expect(externalIssueApi.listLoopItems).toHaveBeenCalledWith(project) - expect(storeApi.listLoopItems).not.toHaveBeenCalled() - }) - - test('creates cloud external tasks through the local Issue provider', async () => { - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [project] })), - getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'cloud-secret' })), - createLoopItem: vi.fn(), - } as unknown as DeliveryApi - const createdIssue = { - id: 'CLOUD-8', - cloud_project_id: project.id, - title: 'Created in GitHub', - } - const externalIssueApi = { - configureProject: vi.fn(async () => undefined), - createLoopItem: vi.fn(async () => createdIssue), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await api.listCloudProjects() - const result = await api.createLoopItem(project.id, { - title: createdIssue.title, - status: 'pending', - }) - - expect(result).toEqual(createdIssue) - expect(externalIssueApi.createLoopItem).toHaveBeenCalledWith(project, { - title: createdIssue.title, - status: 'pending', - }) - expect(storeApi.createLoopItem).not.toHaveBeenCalled() - }) - - test('blocks public visitors from opening or editing external tasks created by others', async () => { - const publicProject = { - ...project, - visibility: 'public' as const, - access_role: 'RestrictedAnalyst' as const, - current_user_id: 2, - } - const otherUsersIssue = { - id: 'CLOUD-9', - cloud_project_id: project.id, - title: 'Created by another user', - can_view_detail: false, - can_edit: false, - } - const ownedIssue = { - id: 'CLOUD-10', - cloud_project_id: project.id, - title: 'Created by the visitor', - can_view_detail: true, - can_edit: true, - } - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [publicProject] })), - getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'cloud-secret' })), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(async () => undefined), - listLoopItems: vi.fn(async () => ({ items: [otherUsersIssue, ownedIssue] })), - getLoopItem: vi.fn(async () => ownedIssue), - updateLoopItem: vi.fn(async () => ownedIssue), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await api.listCloudProjects() - await api.listLoopItems(publicProject.id) - - await expect(api.getLoopItem(otherUsersIssue.id)).rejects.toThrow( - 'You can only view tasks that you created in this public project' - ) - await expect( - api.updateLoopItem(otherUsersIssue.id, { - version: 1, - title: 'Forbidden update', - }) - ).rejects.toThrow('You can only edit tasks that you created in this public project') - - await expect(api.getLoopItem(ownedIssue.id)).resolves.toEqual(ownedIssue) - await expect( - api.updateLoopItem(ownedIssue.id, { - version: 1, - title: 'Allowed update', - }) - ).resolves.toEqual(ownedIssue) - expect(externalIssueApi.getLoopItem).toHaveBeenCalledTimes(1) - expect(externalIssueApi.updateLoopItem).toHaveBeenCalledTimes(1) - }) - - test('updates backend credentials and refreshes the local executor configuration', async () => { - const updatedProject = { - ...project, - provider_config: { - repository: 'acme/repo', - credential_configured: true, - }, - version: 2, - } - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [project] })), - getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'rotated-secret' })), - updateCloudProject: vi.fn(async () => updatedProject), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(async () => undefined), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await api.listCloudProjects() - vi.clearAllMocks() - const updated = await api.updateCloudProject(project.id, { - version: 1, - provider_config: { - repository: 'acme/repo', - token: 'rotated-secret', - }, - }) - - expect(updated).toEqual(updatedProject) - expect(storeApi.updateCloudProject).toHaveBeenCalledWith(project.id, { - version: 1, - provider_config: { - repository: 'acme/repo', - token: 'rotated-secret', - }, - }) - expect(externalIssueApi.configureProject).toHaveBeenCalledWith(updatedProject, 'rotated-secret') - }) - - test('keeps backend internal tasks on the backend store', async () => { - const internalProject = { ...project, task_provider: 'local' as const } + test('routes every backend-owned project operation directly to Backend', async () => { const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [internalProject] })), + listCloudProjects: vi.fn(async () => ({ items: [] })), listLoopItems: vi.fn(async () => ({ items: [] })), + createLoopItem: vi.fn(async () => ({ id: 'CLOUD-1' })), } as unknown as DeliveryApi const externalIssueApi = { + retainProjects: vi.fn(async () => undefined), configureProject: vi.fn(), listLoopItems: vi.fn(), + createLoopItem: vi.fn(), } as unknown as ExternalIssueApi const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) await api.listCloudProjects() - await api.listLoopItems(internalProject.id) - - expect(storeApi.listLoopItems).toHaveBeenCalledWith(internalProject.id) - expect(externalIssueApi.listLoopItems).not.toHaveBeenCalled() - }) - - test('loads an old external project without credentials so it can be repaired', async () => { - const projectWithoutCredential = { - ...project, - provider_config: { - repository: 'acme/repo', - credential_configured: false, - }, - } - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [projectWithoutCredential] })), - getCloudProjectProviderCredential: vi.fn(), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + await api.listLoopItems('cloud-1') + await api.createLoopItem('cloud-1', { title: 'Backend routed' }) - await expect(api.listCloudProjects()).resolves.toEqual({ - items: [projectWithoutCredential], + expect(storeApi.listCloudProjects).toHaveBeenCalled() + expect(externalIssueApi.retainProjects).toHaveBeenCalledWith([]) + expect(storeApi.listLoopItems).toHaveBeenCalledWith('cloud-1') + expect(storeApi.createLoopItem).toHaveBeenCalledWith('cloud-1', { + title: 'Backend routed', }) - expect(storeApi.getCloudProjectProviderCredential).not.toHaveBeenCalled() - expect(externalIssueApi.configureProject).not.toHaveBeenCalled() - }) - - test('keeps cloud projects visible when one provider credential cannot be restored', async () => { - const storeApi = { - listCloudProjects: vi.fn(async () => ({ items: [project] })), - getCloudProjectProviderCredential: vi.fn(async () => { - throw new Error('Provider credential is not configured') - }), - } as unknown as DeliveryApi - const externalIssueApi = { - configureProject: vi.fn(), - } as unknown as ExternalIssueApi - const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) - - await expect(api.listCloudProjects()).resolves.toEqual({ items: [project] }) expect(externalIssueApi.configureProject).not.toHaveBeenCalled() + expect(externalIssueApi.listLoopItems).not.toHaveBeenCalled() + expect(externalIssueApi.createLoopItem).not.toHaveBeenCalled() }) }) diff --git a/wework/src/api/hybrid/cloudProjectSpaceApi.ts b/wework/src/api/hybrid/cloudProjectSpaceApi.ts index fe1b6bc054..31b962bafd 100644 --- a/wework/src/api/hybrid/cloudProjectSpaceApi.ts +++ b/wework/src/api/hybrid/cloudProjectSpaceApi.ts @@ -1,186 +1,23 @@ -import type { - CloudLoopItem, - CloudProject, - CloudProjectId, - createDeliveryApi, -} from '@/api/deliveries' +import type { createDeliveryApi } from '@/api/deliveries' import type { ExternalIssueApi } from '@/features/workbench/workbenchServices' type DeliveryApi = ReturnType -function isExternalProject(project: CloudProject): boolean { - return project.task_provider === 'github' || project.task_provider === 'gitlab' -} - +// Backend-owned project spaces always execute through Backend. The Backend +// selects MySQL, GitHub, or GitLab from task_provider and remains the final +// authorization boundary. externalIssueApi is retained in the signature while +// callers migrate, but cloud credentials and requests never enter Executor. export function createCloudProjectSpaceApi( storeApi: DeliveryApi, externalIssueApi: ExternalIssueApi ): DeliveryApi { - const projects = new Map() - const taskProjects = new Map() - const tasks = new Map() - - function rememberProject(project: CloudProject): CloudProject { - projects.set(project.id, project) - return project - } - - function rememberTasks(projectId: CloudProjectId, items: CloudLoopItem[]): void { - for (const item of items) { - taskProjects.set(item.id, projectId) - tasks.set(item.id, item) - } - } - - function requireProject(projectId: CloudProjectId | number): CloudProject { - const project = projects.get(String(projectId)) - if (!project) throw new Error('Project space must be loaded before its tasks') - return project - } - - function requireTaskProject(itemId: string): CloudProject { - const projectId = taskProjects.get(itemId) - if (!projectId) throw new Error('Task project must be loaded before the task') - return requireProject(projectId) - } - - function requireTaskPermission(itemId: string, permission: 'view' | 'edit'): void { - const item = tasks.get(itemId) - if (!item) throw new Error('Task must be loaded before it can be accessed') - if (permission === 'view' && item.can_view_detail === false) { - throw new Error('You can only view tasks that you created in this public project') - } - if (permission === 'edit' && item.can_edit === false) { - throw new Error('You can only edit tasks that you created in this public project') - } - } - return { ...storeApi, async listCloudProjects() { - const response = await storeApi.listCloudProjects() - await Promise.all( - response.items.map(async project => { - rememberProject(project) - if ( - isExternalProject(project) && - project.provider_config?.credential_configured !== false - ) { - let credential - try { - credential = await storeApi.getCloudProjectProviderCredential(project.id) - } catch { - return - } - try { - await externalIssueApi.configureProject(project, credential.token) - } catch { - return - } - } - }) - ) - return response - }, - async createCloudProject(data) { - const { token, ...providerConfig } = data.provider_config ?? {} - const storedProject = await storeApi.createCloudProject({ - ...data, - provider_config: data.provider_config, - }) - const project = rememberProject({ - ...storedProject, - project_store: 'backend', - task_provider: data.task_provider ?? 'local', - provider_config: providerConfig, - }) - if (isExternalProject(project)) { - await externalIssueApi.configureProject(project, token) - } - return project - }, - async updateCloudProject(projectId, data) { - const current = requireProject(projectId) - const updated = await storeApi.updateCloudProject(projectId, data) - const project = rememberProject({ - ...updated, - project_store: current.project_store, - task_provider: current.task_provider, - provider_config: updated.provider_config ?? current.provider_config, - }) - if (isExternalProject(project)) { - const credential = await storeApi.getCloudProjectProviderCredential(project.id) - await externalIssueApi.configureProject(project, credential.token) - } - return project - }, - async listLoopItems(projectId) { - const project = requireProject(projectId) - const response = isExternalProject(project) - ? await externalIssueApi.listLoopItems(project) - : await storeApi.listLoopItems(projectId) - rememberTasks(project.id, response.items) - return response - }, - async getLoopItem(itemId) { - const project = requireTaskProject(itemId) - if (isExternalProject(project)) requireTaskPermission(itemId, 'view') - const item = isExternalProject(project) - ? await externalIssueApi.getLoopItem(project, itemId) - : await storeApi.getLoopItem(itemId) - rememberTasks(project.id, [item]) - return item - }, - async createLoopItem(projectId, data) { - const project = requireProject(projectId) - const item = isExternalProject(project) - ? await externalIssueApi.createLoopItem(project, data) - : await storeApi.createLoopItem(projectId, data) - rememberTasks(project.id, [item]) - return item - }, - async updateLoopItem(itemId, data) { - const project = requireTaskProject(itemId) - if (isExternalProject(project)) requireTaskPermission(itemId, 'edit') - const item = isExternalProject(project) - ? await externalIssueApi.updateLoopItem(project, itemId, data) - : await storeApi.updateLoopItem(itemId, data) - rememberTasks(project.id, [item]) - return item - }, - async reorderLoopItems(projectId, data) { - const project = requireProject(projectId) - if (!isExternalProject(project)) { - return storeApi.reorderLoopItems(projectId, data) - } - if (project.access_role === 'RestrictedAnalyst') { - throw new Error('Public project visitors cannot reorder tasks') - } - const response = await externalIssueApi.listLoopItems(project) - rememberTasks(project.id, response.items) - return response - }, - async listDeliveries(itemId) { - if (isExternalProject(requireTaskProject(itemId))) requireTaskPermission(itemId, 'view') - return isExternalProject(requireTaskProject(itemId)) - ? { items: [] } - : storeApi.listDeliveries(itemId) - }, - async listTaskBindings(itemId) { - if (isExternalProject(requireTaskProject(itemId))) requireTaskPermission(itemId, 'view') - return isExternalProject(requireTaskProject(itemId)) ? [] : storeApi.listTaskBindings(itemId) - }, - async listLoopItemAttachments(itemId) { - if (isExternalProject(requireTaskProject(itemId))) requireTaskPermission(itemId, 'view') - return isExternalProject(requireTaskProject(itemId)) - ? [] - : storeApi.listLoopItemAttachments(itemId) - }, - async listLoopItemCollaborators(itemId) { - if (isExternalProject(requireTaskProject(itemId))) requireTaskPermission(itemId, 'view') - return isExternalProject(requireTaskProject(itemId)) - ? [] - : storeApi.listLoopItemCollaborators(itemId) + // Remove credentials/catalog entries written by older Wework versions. + // Backend-owned projects never execute through the local task runtime. + await externalIssueApi.retainProjects?.([]) + return storeApi.listCloudProjects() }, } } diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index c0d8cf1354..366230b1e3 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -137,6 +137,12 @@ export function createExternalIssueApi(request: LocalRequest) { project: externalProjectDescriptor(project, token), }) }, + async removeProject(projectId: CloudProject['id']) { + await request('external_projects.remove', { project_id: projectId }) + }, + async retainProjects(projectIds: CloudProject['id'][]) { + await request('external_projects.retain', { project_ids: projectIds }) + }, async listLoopItems(project: CloudProject) { const records = await request('external_todos.list', { project: externalProjectDescriptor(project), @@ -210,6 +216,10 @@ function localTask(record: LocalLoopItemRecord, project?: CloudProject): CloudLo sequence_number: record.sequence_number ?? 0, parent_id: record.parent_id, created_by_user_id: record.created_by_user_id, + created_by_user_name: + typeof record.metadata.creator_label === 'string' + ? record.metadata.creator_label.split(':').slice(3).join(':').trim() || null + : null, can_view_detail: !isPublicVisitor || ownsTask, can_edit: ['Owner', 'Maintainer', 'Developer'].includes(role) || ownsTask, assignee_user_id: null, diff --git a/wework/src/features/todo/CloudProjectsHome.tsx b/wework/src/features/todo/CloudProjectsHome.tsx index 2a45e514c4..03e82480f1 100644 --- a/wework/src/features/todo/CloudProjectsHome.tsx +++ b/wework/src/features/todo/CloudProjectsHome.tsx @@ -277,6 +277,7 @@ export function CloudProjectsHome({ const projectId = String(item.cloud_project_id) const actorName = memberNameById(projectMembers[projectId] ?? [], item.assignee_user_id) ?? + item.created_by_user_name ?? memberNameById(projectMembers[projectId] ?? [], item.created_by_user_id) ?? t('todo.home_activity_someone', '有人') const actionLabel = diff --git a/wework/src/features/todo/CloudTodoWorkspace.test.tsx b/wework/src/features/todo/CloudTodoWorkspace.test.tsx index 8277b808e9..a8d9d83de8 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.test.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.test.tsx @@ -629,7 +629,7 @@ describe('CloudTodoWorkspace', () => { expect(cloudServices.deliveryApi?.createCloudProject).not.toHaveBeenCalled() }) - it('opens project member management and filters the board', async () => { + it('opens project member management and searches tasks without hiding the board', async () => { render( { expect(await screen.findByTestId('cloud-project-member-1')).toBeInTheDocument() await userEvent.click(screen.getByTestId('cloud-project-board-view')) - await userEvent.click(screen.getByTestId('cloud-search-toggle')) - await userEvent.type(screen.getByTestId('cloud-search-input'), 'missing') - expect(screen.queryByTestId('cloud-todo-card-WEG-1')).not.toBeInTheDocument() + await userEvent.click(screen.getByTestId('cloud-project-task-search-toggle')) + await userEvent.type(screen.getByTestId('cloud-project-task-search-input'), 'missing') + expect(screen.getByText('没有匹配的任务')).toBeInTheDocument() + expect(screen.getByTestId('cloud-todo-card-WEG-1')).toBeInTheDocument() + }) + + it('opens the global search with Command+K and opens a task result', async () => { + render( + + ) + + await screen.findAllByText('Wegent V4') + await userEvent.keyboard('{Meta>}k{/Meta}') + await userEvent.type(screen.getByTestId('cloud-global-search-input'), 'WEG-1') + await userEvent.click(await screen.findByTestId('cloud-global-search-result-WEG-1')) + + expect(await screen.findByTestId('cloud-todo-detail')).toBeInTheDocument() }) it('restores a missing cloud GitLab credential from project management', async () => { diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index 969c677cc9..b7f484f184 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -54,8 +54,11 @@ import { CloudMyWorkView } from './CloudMyWorkView' import { CloudProjectManageView } from './CloudProjectManageView' import { CloudProjectsHome } from './CloudProjectsHome' import { CloudFilesView } from './CloudFilesView' +import { GlobalTodoSearch } from './GlobalTodoSearch' import { repositoryProviderConfig } from './projectProviderConfig' +import { TaskSearchPanel } from './TaskSearchPanel' import { TodoEditor } from './TodoEditor' +import { emptyTaskSearchFilters, type TaskSearchFilters } from './taskSearch' import { columnDotClasses, columns, priorityBadgeClasses, reorderLaneItems } from './todoShared' type ProjectView = 'board' | 'files' | 'manage' @@ -730,8 +733,12 @@ export function CloudTodoWorkspace({ const boardSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }) ) - const [searchOpen, setSearchOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') + const [globalSearchOpen, setGlobalSearchOpen] = useState(false) + const [globalSearchQuery, setGlobalSearchQuery] = useState('') + const [projectSearchOpen, setProjectSearchOpen] = useState(false) + const [projectSearchQuery, setProjectSearchQuery] = useState('') + const [projectSearchFilters, setProjectSearchFilters] = + useState(emptyTaskSearchFilters) const [tagFilter, setTagFilter] = useState(null) const [loading, setLoading] = useState(true) const [boardError, setBoardError] = useState(null) @@ -760,6 +767,7 @@ export function CloudTodoWorkspace({ [availableProjectSpaceApis, projectSpaceApis, projects, services.deliveryApi] ) const selectedProjectApi = selectedProject ? apiForProjectId(selectedProject.id) : undefined + const canCreateBoardTask = selectedProject !== null // Only render board items that belong to the selected project. On a project // switch this flips to the skeleton in the same render, before the fetch. // `boardError` distinguishes a failed fetch (skeleton stays) from a @@ -796,8 +804,25 @@ export function CloudTodoWorkspace({ setSelectedProjectId(projectId) setBoardParentId(null) setTagFilter(null) + setProjectSearchOpen(false) + setProjectSearchQuery('') + setProjectSearchFilters(emptyTaskSearchFilters) } + useEffect(() => { + const handleGlobalSearchShortcut = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + setGlobalSearchOpen(true) + } else if (event.key === 'Escape') { + setGlobalSearchOpen(false) + setProjectSearchOpen(false) + } + } + window.addEventListener('keydown', handleGlobalSearchShortcut) + return () => window.removeEventListener('keydown', handleGlobalSearchShortcut) + }, []) + function openTodoCreation( parent: CloudLoopItem | null, status: CloudLoopItem['status'] = 'inbox' @@ -1062,7 +1087,7 @@ export function CloudTodoWorkspace({ - {searchOpen && ( -
- setSearchQuery(event.target.value)} - placeholder="搜索项目空间或任务" - className="h-8 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus:border-text-muted" - /> -
- )}
项目空间
- {projects - .filter(project => - `${project.name} ${project.project_key} ${project.description}` - .toLowerCase() - .includes(searchQuery.trim().toLowerCase()) + {projects.map(project => { + const ProjectLocationIcon = project.location === 'local' ? HardDrive : Cloud + return ( + ) - .map(project => { - const ProjectLocationIcon = project.location === 'local' ? HardDrive : Cloud - return ( - - ) - })} + })}
@@ -1203,14 +1210,16 @@ export function CloudTodoWorkspace({ projectMembers={projectMembers} projectItems={projectItems} myWork={myWork} - searchQuery={searchQuery} + searchQuery="" onCreateProject={() => setCreateProjectOpen(true)} onSelectProject={projectId => selectProject(projectId)} onManageProject={projectId => { selectProject(projectId) setProjectView('manage') }} - onSelectItem={item => setSelectedItem(item)} + onSelectItem={item => { + if (item.can_view_detail !== false) setSelectedItem(item) + }} onOpenMyWork={() => setRootView('my-work')} /> ) : ( @@ -1281,6 +1290,31 @@ export function CloudTodoWorkspace({ {projectView === 'board' && ( <> + + {projectSearchOpen && ( + { + if (item.can_view_detail === false) return + setSelectedItem(item) + setProjectSearchOpen(false) + }} + /> + )} {availableTags.length > 0 && ( @@ -1304,14 +1338,16 @@ export function CloudTodoWorkspace({ )} - + {canCreateBoardTask && ( + + )} )} @@ -1399,16 +1435,11 @@ export function CloudTodoWorkspace({ >
{columns.map(column => { - const normalizedSearch = searchQuery.trim().toLowerCase() const columnItems = items.filter( item => item.parent_id === boardParentId && item.status === column.status && - (!tagFilter || (item.tags ?? []).includes(tagFilter)) && - (!normalizedSearch || - `${item.id} ${item.title} ${item.description}` - .toLowerCase() - .includes(normalizedSearch)) + (!tagFilter || (item.tags ?? []).includes(tagFilter)) ) return (
)} -
- -
+ {canCreateBoardTask && ( +
+ +
+ )}
) })} @@ -1494,7 +1527,32 @@ export function CloudTodoWorkspace({ )} - {selectedItem && selectedItemApi && ( + {globalSearchOpen && ( + setGlobalSearchOpen(false)} + onSelectProject={projectId => { + selectProject(projectId) + setRootView('projects') + setProjectView('board') + setSelectedItem(null) + setGlobalSearchOpen(false) + }} + onSelectItem={(projectId, item) => { + if (item.can_view_detail === false) return + selectProject(projectId) + setRootView('projects') + setProjectView('board') + setSelectedItem(item) + setGlobalSearchOpen(false) + }} + /> + )} + {selectedItem && selectedItem.can_view_detail !== false && selectedItemApi && ( + projectMembers: Record + query: string + onQueryChange: (query: string) => void + onClose: () => void + onSelectProject: (projectId: string) => void + onSelectItem: (projectId: string, item: CloudLoopItem) => void +} + +export function GlobalTodoSearch({ + projects, + projectItems, + projectMembers, + query, + onQueryChange, + onClose, + onSelectProject, + onSelectItem, +}: GlobalTodoSearchProps) { + const { t } = useTranslation('common') + const normalizedQuery = query.trim().toLocaleLowerCase() + const matchingProjects = normalizedQuery + ? projects.filter(project => + `${project.name} ${project.project_key} ${project.description}` + .toLocaleLowerCase() + .includes(normalizedQuery) + ) + : [] + const taskResults = normalizedQuery + ? projects.flatMap(project => + searchTasks( + projectItems[project.id] ?? [], + normalizedQuery, + emptyTaskSearchFilters, + projectMembers[project.id] ?? [] + ) + .slice(0, 20) + .map(result => ({ ...result, project })) + ) + : [] + + return ( +
event.currentTarget === event.target && onClose()} + > +
+
+ + onQueryChange(event.target.value)} + placeholder={t('workbench.global_search_placeholder')} + className="h-full min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-text-muted" + /> + +
+
+ {!normalizedQuery ? ( +

+ {t('workbench.global_search_hint')} +

+ ) : matchingProjects.length === 0 && taskResults.length === 0 ? ( +

+ {t('workbench.global_search_no_results')} +

+ ) : ( + <> + {matchingProjects.length > 0 && ( +
+

+ {t('workbench.global_search_projects')} +

+ {matchingProjects.map(project => ( + + ))} +
+ )} + {taskResults.length > 0 && ( +
+

+ {t('workbench.global_search_tasks')} +

+ {taskResults.slice(0, 50).map(({ item, parentPath, project }) => ( + + ))} +
+ )} + + )} +
+
+ + esc + + {t('workbench.global_search_esc_to_close')} +
+
+
+ ) +} diff --git a/wework/src/features/todo/TaskSearchPanel.tsx b/wework/src/features/todo/TaskSearchPanel.tsx new file mode 100644 index 0000000000..fb13ede3f7 --- /dev/null +++ b/wework/src/features/todo/TaskSearchPanel.tsx @@ -0,0 +1,239 @@ +import { Search, X } from 'lucide-react' +import type { CloudLoopItem, CloudProjectMember } from '@/api/deliveries' +import { cn } from '@/lib/utils' +import { columns, priorityBadgeClasses } from './todoShared' +import { + emptyTaskSearchFilters, + hasTaskSearchFilters, + searchTasks, + type TaskSearchFilters, +} from './taskSearch' + +interface TaskSearchPanelProps { + items: CloudLoopItem[] + members: CloudProjectMember[] + query: string + filters: TaskSearchFilters + tags: string[] + onQueryChange: (query: string) => void + onFiltersChange: (filters: TaskSearchFilters) => void + onSelect: (item: CloudLoopItem) => void +} + +const selectClass = + 'h-8 rounded-lg border border-border bg-background px-2 text-xs text-text-secondary outline-none focus:border-text-muted' + +export function TaskSearchPanel({ + items, + members, + query, + filters, + tags, + onQueryChange, + onFiltersChange, + onSelect, +}: TaskSearchPanelProps) { + const results = searchTasks(items, query, filters, members) + const active = Boolean(query.trim()) || hasTaskSearchFilters(filters) + + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder="搜索任务编号、标题、内容、标签或成员" + className="h-9 w-full rounded-lg border border-border bg-background pl-9 pr-9 text-sm outline-none focus:border-text-muted" + /> + {active && ( + + )} +
+
+ + + + + + + +
+
+ {!active ? ( +

输入关键词或选择筛选条件

+ ) : results.length === 0 ? ( +

没有匹配的任务

+ ) : ( + <> +

{results.length} 个结果

+ {results.map(({ item, parentPath }) => ( + + ))} + + )} +
+
+ ) +} diff --git a/wework/src/features/todo/TaskSearchPermissions.test.tsx b/wework/src/features/todo/TaskSearchPermissions.test.tsx new file mode 100644 index 0000000000..4948b065b2 --- /dev/null +++ b/wework/src/features/todo/TaskSearchPermissions.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' +import type { CloudLoopItem } from '@/api/deliveries' +import '@/i18n' +import { GlobalTodoSearch } from './GlobalTodoSearch' +import { TaskSearchPanel } from './TaskSearchPanel' +import { emptyTaskSearchFilters } from './taskSearch' + +const restrictedItem: CloudLoopItem = { + id: 'PUBLIC-1', + cloud_project_id: 'project-1', + sequence_number: 1, + parent_id: null, + created_by_user_id: 1, + can_view_detail: false, + can_edit: false, + assignee_user_id: null, + title: 'Other user task', + description: '', + status: 'pending', + priority: 'none', + due_at: null, + tags: [], + sort_order: 1, + current_delivery_id: null, + version: 1, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + completed_at: null, +} + +describe('task search permissions', () => { + test('project search cannot open a restricted task', async () => { + const onSelect = vi.fn() + render( + undefined} + onFiltersChange={() => undefined} + onSelect={onSelect} + /> + ) + + const result = screen.getByTestId('cloud-task-search-result-PUBLIC-1') + expect(result).toBeDisabled() + await userEvent.click(result) + expect(onSelect).not.toHaveBeenCalled() + }) + + test('global search cannot open a restricted task', async () => { + const onSelectItem = vi.fn() + render( + undefined} + onClose={() => undefined} + onSelectProject={() => undefined} + onSelectItem={onSelectItem} + /> + ) + + const result = screen.getByTestId('cloud-global-search-result-PUBLIC-1') + expect(result).toBeDisabled() + await userEvent.click(result) + expect(onSelectItem).not.toHaveBeenCalled() + }) +}) diff --git a/wework/src/features/todo/TodoEditor.tsx b/wework/src/features/todo/TodoEditor.tsx index 3af182279f..fe3f47fea3 100644 --- a/wework/src/features/todo/TodoEditor.tsx +++ b/wework/src/features/todo/TodoEditor.tsx @@ -383,11 +383,12 @@ export function TodoEditor(props: TodoEditorProps) { const parentItem = allItems.find(candidate => candidate.id === parentId) const assignee = projectMembers.find(member => String(member.user_id) === assigneeId) const creator = - item && item.created_by_user_id === editProps?.project?.current_user_id + item?.created_by_user_name || + (item && item.created_by_user_id === editProps?.project?.current_user_id ? editProps.project.current_user_name : item ? memberNameById(projectMembers, item.created_by_user_id) - : null + : null) async function submitCreate() { if (props.mode !== 'create' || !title.trim() || saving) return diff --git a/wework/src/features/todo/taskSearch.test.ts b/wework/src/features/todo/taskSearch.test.ts new file mode 100644 index 0000000000..ff89bf4672 --- /dev/null +++ b/wework/src/features/todo/taskSearch.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' +import type { CloudLoopItem, CloudProjectMember } from '@/api/deliveries' +import { emptyTaskSearchFilters, searchTasks } from './taskSearch' + +const items: CloudLoopItem[] = [ + { + id: 'PROJ-1', + cloud_project_id: 'project-1', + sequence_number: 1, + parent_id: null, + created_by_user_id: 1, + assignee_user_id: 2, + title: 'Fix login', + description: 'OAuth callback fails', + status: 'in_progress', + priority: 'high', + due_at: '2026-07-20T00:00:00Z', + tags: ['bug'], + sort_order: 1, + current_delivery_id: null, + version: 1, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-02T00:00:00Z', + completed_at: null, + }, + { + id: 'PROJ-2', + cloud_project_id: 'project-1', + sequence_number: 2, + parent_id: 'PROJ-1', + created_by_user_id: 2, + assignee_user_id: null, + title: 'Document deployment', + description: '', + status: 'pending', + priority: 'none', + due_at: null, + tags: ['docs'], + sort_order: 2, + current_delivery_id: null, + version: 1, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + completed_at: null, + }, +] + +const members: CloudProjectMember[] = [ + { id: 1, user_id: 1, user_name: 'Micro66', email: null, role: 'Owner' }, + { id: 2, user_id: 2, user_name: 'Admin', email: null, role: 'Developer' }, +] + +describe('searchTasks', () => { + test('ranks exact ids and searches member names and nested tasks', () => { + expect(searchTasks(items, 'PROJ-1', emptyTaskSearchFilters, members)[0].item.id).toBe('PROJ-1') + expect(searchTasks(items, 'Micro66', emptyTaskSearchFilters, members)[0].item.id).toBe('PROJ-1') + expect(searchTasks(items, 'deployment', emptyTaskSearchFilters, members)[0]).toMatchObject({ + item: { id: 'PROJ-2' }, + parentPath: ['Fix login'], + }) + }) + + test('combines structured filters', () => { + expect( + searchTasks( + items, + '', + { + ...emptyTaskSearchFilters, + status: 'in_progress', + tag: 'bug', + creatorUserId: 1, + due: 'overdue', + children: 'with_children', + }, + members, + new Date('2026-07-28T00:00:00Z') + ).map(result => result.item.id) + ).toEqual(['PROJ-1']) + }) +}) diff --git a/wework/src/features/todo/taskSearch.ts b/wework/src/features/todo/taskSearch.ts new file mode 100644 index 0000000000..1fe75267cf --- /dev/null +++ b/wework/src/features/todo/taskSearch.ts @@ -0,0 +1,123 @@ +import type { CloudLoopItem, CloudProjectMember } from '@/api/deliveries' + +export type TaskSearchFilters = { + status: CloudLoopItem['status'] | null + priority: CloudLoopItem['priority'] | null + tag: string | null + assigneeUserId: number | null + creatorUserId: number | null + due: 'any' | 'with_due_date' | 'overdue' | 'no_due_date' + children: 'any' | 'with_children' | 'without_children' +} + +export const emptyTaskSearchFilters: TaskSearchFilters = { + status: null, + priority: null, + tag: null, + assigneeUserId: null, + creatorUserId: null, + due: 'any', + children: 'any', +} + +export type TaskSearchResult = { + item: CloudLoopItem + score: number + parentPath: string[] +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase() +} + +function textScore(item: CloudLoopItem, query: string, memberNames: Map): number { + if (!query) return 1 + const id = normalize(item.id) + const title = normalize(item.title) + const tags = (item.tags ?? []).map(normalize) + const creator = normalize( + item.created_by_user_name ?? memberNames.get(item.created_by_user_id) ?? '' + ) + const assignee = normalize(memberNames.get(item.assignee_user_id ?? 0) ?? '') + const description = normalize(item.description) + if (id === query) return 100 + if (title === query) return 90 + if (title.startsWith(query)) return 80 + if (title.includes(query)) return 70 + if (id.includes(query)) return 65 + if (tags.some(tag => tag.includes(query))) return 60 + if (creator.includes(query) || assignee.includes(query)) return 55 + if (description.includes(query)) return 40 + return 0 +} + +function buildParentPath(item: CloudLoopItem, byId: Map): string[] { + const path: string[] = [] + const visited = new Set() + let parentId = item.parent_id + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = byId.get(parentId) + if (!parent) break + path.unshift(parent.title) + parentId = parent.parent_id + } + return path +} + +export function searchTasks( + items: CloudLoopItem[], + query: string, + filters: TaskSearchFilters, + members: CloudProjectMember[] = [], + now = new Date() +): TaskSearchResult[] { + const normalizedQuery = normalize(query) + const byId = new Map(items.map(item => [item.id, item])) + const childCounts = new Map() + for (const item of items) { + if (item.parent_id) childCounts.set(item.parent_id, (childCounts.get(item.parent_id) ?? 0) + 1) + } + const memberNames = new Map(members.map(member => [member.user_id, member.user_name])) + const today = now.toISOString().slice(0, 10) + + return items + .map(item => ({ item, score: textScore(item, normalizedQuery, memberNames) })) + .filter(({ item, score }) => { + if (score === 0) return false + if (filters.status && item.status !== filters.status) return false + if (filters.priority && item.priority !== filters.priority) return false + if (filters.tag && !(item.tags ?? []).includes(filters.tag)) return false + if (filters.assigneeUserId && item.assignee_user_id !== filters.assigneeUserId) return false + if (filters.creatorUserId && item.created_by_user_id !== filters.creatorUserId) return false + if (filters.due === 'with_due_date' && !item.due_at) return false + if (filters.due === 'no_due_date' && item.due_at) return false + if ( + filters.due === 'overdue' && + (!item.due_at || item.due_at.slice(0, 10) >= today || item.status === 'completed') + ) + return false + const hasChildren = (childCounts.get(item.id) ?? 0) > 0 + if (filters.children === 'with_children' && !hasChildren) return false + if (filters.children === 'without_children' && hasChildren) return false + return true + }) + .map(({ item, score }) => ({ item, score, parentPath: buildParentPath(item, byId) })) + .sort( + (left, right) => + right.score - left.score || + Date.parse(right.item.updated_at) - Date.parse(left.item.updated_at) + ) +} + +export function hasTaskSearchFilters(filters: TaskSearchFilters): boolean { + return ( + filters.status !== null || + filters.priority !== null || + filters.tag !== null || + filters.assigneeUserId !== null || + filters.creatorUserId !== null || + filters.due !== 'any' || + filters.children !== 'any' + ) +} diff --git a/wework/src/features/workbench/workbenchServices.test.ts b/wework/src/features/workbench/workbenchServices.test.ts index 3597acd911..73b8dc18f2 100644 --- a/wework/src/features/workbench/workbenchServices.test.ts +++ b/wework/src/features/workbench/workbenchServices.test.ts @@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => { } const backendDeliveryApi = { listCloudProjects: vi.fn(async () => ({ items: [project] })), - getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'gitlab-secret' })), listLoopItems: vi.fn(), createLoopItem: vi.fn(), } diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index b5673e6b88..36445e75d4 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -26,6 +26,13 @@ "search_failed": "Search failed", "recent_conversations": "Recent conversations", "search_no_results": "No matching conversations", + "global_search_placeholder": "Search project spaces or all tasks", + "global_search_hint": "Type a project name, task ID, title, content, label, or member", + "global_search_no_results": "No matching results", + "global_search_projects": "Project spaces", + "global_search_tasks": "Tasks", + "global_search_close": "Close search", + "global_search_esc_to_close": "to close", "project_search_no_results": "No matching projects", "plugins": "Plugins", "sites": "Sites", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index bb5ad19f7d..5ced724bf6 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -26,6 +26,13 @@ "search_failed": "搜索失败", "recent_conversations": "近期对话", "search_no_results": "没有匹配的对话", + "global_search_placeholder": "搜索项目空间或所有任务", + "global_search_hint": "输入项目名称、任务编号、标题、内容、标签或成员", + "global_search_no_results": "没有匹配结果", + "global_search_projects": "项目空间", + "global_search_tasks": "任务", + "global_search_close": "关闭搜索", + "global_search_esc_to_close": "关闭", "project_search_no_results": "没有匹配的项目", "plugins": "插件", "sites": "站点", From ea02383e9cbc127d3169b96f1b9b0c3eaa9d61b3 Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 14:44:55 +0800 Subject: [PATCH 5/7] fix(executor): satisfy clippy and sandbox unit tests from host WEGENT env - drop needless borrows flagged by clippy in task MCP attachment tools - replace Option::is_none_or (stable since 1.82) with map_or to respect the declared 1.77 MSRV - scrub inherited WEGENT_* variables in the unit test binary so tests stay hermetic when spawned from the desktop app --- executor/src/lib.rs | 19 +++++++++++++++++++ executor/src/task_runtime/mcp.rs | 14 +++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/executor/src/lib.rs b/executor/src/lib.rs index bdd8ef1cb1..6b90c9324a 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -38,6 +38,25 @@ pub(crate) mod test_env { static ENV_LOCK: Mutex<()> = Mutex::new(()); + // The desktop app exports WEGENT_* variables (for example + // WEGENT_BUNDLED_HOOKS_DIR) into every process it spawns, including + // `cargo test`. Unit tests must observe a hermetic environment, so scrub + // these variables before any test runs. Constructors execute + // single-threaded ahead of main, which makes the mutation safe. + #[used] + #[link_section = "__DATA,__mod_init_func"] + static SCRUB_WEGENT_ENV: extern "C" fn() = { + extern "C" fn scrub() { + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("WEGENT_") { + // SAFETY: constructors run single-threaded before main. + unsafe { std::env::remove_var(&key) }; + } + } + } + scrub + }; + pub(crate) fn lock() -> MutexGuard<'static, ()> { // Recover from poisoning so a single panicking test does not cascade // into unrelated PoisonError failures across the shared test binary. diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index 41f41a9d0a..0d15a3bd10 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -410,7 +410,7 @@ async fn call_backend_tool( "upload_todo_attachment" => { let file_path = string_argument(arguments, "file_path").map_err(|error| error.to_string())?; - let bytes = fs::read(&file_path).map_err(|error| error.to_string())?; + let bytes = fs::read(file_path).map_err(|error| error.to_string())?; let display_name = arguments .get("display_name") .and_then(Value::as_str) @@ -440,13 +440,13 @@ async fn call_backend_tool( "download_todo_attachment" => { let attachment_id = string_argument(arguments, "attachment_id").map_err(|error| error.to_string())?; - let output_path = attachment_output_path(arguments, &attachment_id) + let output_path = attachment_output_path(arguments, attachment_id) .map_err(|error| error.to_string())?; let access = backend_json( client .get(format!( "{base}/loop-item-attachments/{}/access", - encode_segment(&attachment_id) + encode_segment(attachment_id) )) .bearer_auth(auth_token) .send() @@ -477,7 +477,7 @@ async fn call_backend_tool( let response = client .delete(format!( "{base}/loop-item-attachments/{}", - encode_segment(&attachment_id) + encode_segment(attachment_id) )) .bearer_auth(auth_token) .send() @@ -557,7 +557,7 @@ fn filter_backend_tasks(response: Value, arguments: &Value) -> Value { && arguments .get("tag") .and_then(Value::as_str) - .is_none_or(|tag| { + .map_or(true, |tag| { task["tags"] .as_array() .is_some_and(|tags| tags.iter().any(|value| value == tag)) @@ -565,7 +565,7 @@ fn filter_backend_tasks(response: Value, arguments: &Value) -> Value { && arguments .get("creator_user_id") .and_then(Value::as_i64) - .is_none_or(|id| task["created_by_user_id"] == id) + .map_or(true, |id| task["created_by_user_id"] == id) }) .take(limit) .collect(), @@ -576,7 +576,7 @@ fn matches_filter(task: &Value, arguments: &Value, key: &str) -> bool { arguments .get(key) .and_then(Value::as_str) - .is_none_or(|value| task[key] == value) + .map_or(true, |value| task[key] == value) } fn encode_segment(value: &str) -> String { From 055170f81b41fead9ddae3a4a4550de4c75c3772 Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 14:48:22 +0800 Subject: [PATCH 6/7] test(wework): assert backend-owned project spaces stay on the backend The workbench services test still expected cloud GitLab tasks to route through the local executor, but createCloudProjectSpaceApi now delegates execution to the backend and only uses externalIssueApi to prune stale local credential entries. --- .../workbench/workbenchServices.test.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/wework/src/features/workbench/workbenchServices.test.ts b/wework/src/features/workbench/workbenchServices.test.ts index 73b8dc18f2..edefe05389 100644 --- a/wework/src/features/workbench/workbenchServices.test.ts +++ b/wework/src/features/workbench/workbenchServices.test.ts @@ -24,10 +24,15 @@ const mocks = vi.hoisted(() => { const backendDeliveryApi = { listCloudProjects: vi.fn(async () => ({ items: [project] })), listLoopItems: vi.fn(), - createLoopItem: vi.fn(), + createLoopItem: vi.fn(async () => ({ + id: 'CLOUD-1', + cloud_project_id: project.id, + title: 'GitLab Issue', + })), } const externalIssueApi = { configureProject: vi.fn(async () => undefined), + retainProjects: vi.fn(async () => undefined), listLoopItems: vi.fn(async () => ({ items: [] })), createLoopItem: vi.fn(async () => ({ id: 'CLOUD-1', @@ -78,26 +83,24 @@ describe('default workbench project-space services', () => { vi.clearAllMocks() }) - test('routes cloud GitLab tasks through the local executor in backend desktop mode', async () => { + test('keeps cloud GitLab tasks on the backend in backend desktop mode', async () => { const services = createDefaultWorkbenchServices() const api = services.projectSpaceApis!.cloud! await api.listCloudProjects() - const created = await api.createLoopItem(mocks.project.id, { + await api.createLoopItem(mocks.project.id, { title: 'GitLab Issue', status: 'pending', }) - expect(created.id).toBe('CLOUD-1') - expect(services.projectSpaceApis?.local).toBe(mocks.localDeliveryApi) - expect(mocks.externalIssueApi.configureProject).toHaveBeenCalledWith( - mocks.project, - 'gitlab-secret' - ) - expect(mocks.externalIssueApi.createLoopItem).toHaveBeenCalledWith(mocks.project, { + expect(mocks.externalIssueApi.retainProjects).toHaveBeenCalledWith([]) + expect(mocks.backendDeliveryApi.listCloudProjects).toHaveBeenCalled() + expect(mocks.backendDeliveryApi.createLoopItem).toHaveBeenCalledWith(mocks.project.id, { title: 'GitLab Issue', status: 'pending', }) - expect(mocks.backendDeliveryApi.createLoopItem).not.toHaveBeenCalled() + expect(mocks.externalIssueApi.configureProject).not.toHaveBeenCalled() + expect(mocks.externalIssueApi.createLoopItem).not.toHaveBeenCalled() + expect(services.projectSpaceApis?.local).toBe(mocks.localDeliveryApi) }) }) From cd65921ebc4bf2bce9582b8186f24fa459eaa0ad Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 14:57:36 +0800 Subject: [PATCH 7/7] fix(executor): keep unit tests hermetic under desktop app shell - narrow the WEGENT_* scrub to host-state variables that leak machine paths (bundled/managed hooks dirs, executor home) instead of wiping every WEGENT_* variable, so runner configuration like WEGENT_EXTRA_PATHS survives - raise the fallback shell test timeout from 1s to 30s; the 1s budget flakes when the shared test thread pool is saturated by the full suite --- executor/src/lib.rs | 27 +++++++++++++++++---------- executor/src/process_environment.rs | 4 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 6b90c9324a..7536c286fc 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -38,20 +38,27 @@ pub(crate) mod test_env { static ENV_LOCK: Mutex<()> = Mutex::new(()); - // The desktop app exports WEGENT_* variables (for example - // WEGENT_BUNDLED_HOOKS_DIR) into every process it spawns, including - // `cargo test`. Unit tests must observe a hermetic environment, so scrub - // these variables before any test runs. Constructors execute - // single-threaded ahead of main, which makes the mutation safe. + // The desktop app exports host-specific WEGENT_* variables (for example + // WEGENT_BUNDLED_HOOKS_DIR pointing inside the installed app bundle) into + // every process it spawns, including `cargo test`. Tests that rely on a + // hermetic environment must not observe them, so scrub the variables that + // leak host machine state before any test runs. Constructors execute + // single-threaded ahead of main, which makes the mutation safe. Keep this + // list narrow: variables like WEGENT_EXTRA_PATHS configure the runner and + // must survive. + const HOST_STATE_VARS: &[&str] = &[ + "WEGENT_BUNDLED_HOOKS_DIR", + "WEGENT_MANAGED_HOOKS_DIR", + "WEGENT_EXECUTOR_HOME", + ]; + #[used] #[link_section = "__DATA,__mod_init_func"] static SCRUB_WEGENT_ENV: extern "C" fn() = { extern "C" fn scrub() { - for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("WEGENT_") { - // SAFETY: constructors run single-threaded before main. - unsafe { std::env::remove_var(&key) }; - } + for key in HOST_STATE_VARS { + // SAFETY: constructors run single-threaded before main. + unsafe { std::env::remove_var(key) }; } } scrub diff --git a/executor/src/process_environment.rs b/executor/src/process_environment.rs index 1a940afaea..015b848d50 100644 --- a/executor/src/process_environment.rs +++ b/executor/src/process_environment.rs @@ -411,12 +411,14 @@ mod tests { std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o700)) .expect("script should be executable"); + // The whole test binary shares a thread pool, so a generous timeout + // keeps this stable while hundreds of other tests run in parallel. let environment = load_shell_environment_from_candidates( &[ "/missing/wework-shell".to_string(), script_path.display().to_string(), ], - Duration::from_secs(1), + Duration::from_secs(30), ) .expect("fallback shell should load");