diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index dd8fb3326b..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, @@ -37,6 +36,20 @@ 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, + "current_user_name": current_user.user_name, + "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,24 +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) - - -@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) + return _project_response(db, project, current_user) @router.patch("/{project_id}", response_model=CloudProjectResponse) @@ -95,7 +91,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..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, @@ -33,12 +44,22 @@ 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 +from app.services.loop_items.external_provider import external_loop_item_provider 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 +138,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 +154,19 @@ 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, + "current_user_name": current_user.user_name, + "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 + ), } ) @@ -174,9 +206,27 @@ 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( - 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 + ] ) @@ -191,8 +241,15 @@ 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 LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.post( @@ -205,9 +262,19 @@ 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=[LoopItemResponse.model_validate(item) for item in items] + items=[_loop_item_response(db, item, current_user) for item in items] ) @@ -217,8 +284,12 @@ 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 LoopItemResponse.model_validate(item) + return _loop_item_response(db, item, current_user) @router.patch("/loop-items/{item_id}", response_model=LoopItemResponse) @@ -228,8 +299,30 @@ 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 LoopItemResponse.model_validate(item) + 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( @@ -241,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] @@ -256,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, @@ -302,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] @@ -316,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) @@ -330,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) @@ -345,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/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..e4086c53aa 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,11 @@ 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 + current_user_name: str = "" + access_role: BaseRole = BaseRole.RestrictedAnalyst status: str tags: list[str] = [] version: int @@ -137,6 +144,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 @@ -146,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 e65d7a3f22..a91a73fba8 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -71,6 +71,9 @@ 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 version: int created_at: datetime @@ -111,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/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..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 @@ -75,6 +72,7 @@ def create( "project_store": "backend", "task_provider": values.task_provider, "provider_config": provider_config, + "visibility": values.visibility, "tags": [], }, ) @@ -114,6 +112,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,28 +120,14 @@ 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 - - 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 + return require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst ).project - metadata = ( - project.metadata_json if isinstance(project.metadata_json, dict) else {} + + def access(self, db: Session, project_id: int, user_id: int): + return require_cloud_project_role( + db, project_id, user_id, BaseRole.RestrictedAnalyst ) - 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, @@ -158,6 +143,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 +175,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/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 10a7a0e42d..eaaa1ba030 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( @@ -248,20 +326,13 @@ 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: - 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 +356,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 +410,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 +432,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 +469,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 +479,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 +494,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 +548,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 +599,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 +652,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..0badbd1eee 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -8,10 +8,12 @@ from datetime import datetime from typing import BinaryIO +import httpx import pytest 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 @@ -19,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] = {} @@ -122,6 +142,107 @@ 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 + assert visible_project["current_user_name"] == visitor.user_name + + 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" + + 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, test_db: Session, @@ -178,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( @@ -220,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( @@ -245,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( @@ -274,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/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/lib.rs b/executor/src/lib.rs index bdd8ef1cb1..7536c286fc 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -38,6 +38,32 @@ pub(crate) mod test_env { static ENV_LOCK: Mutex<()> = Mutex::new(()); + // 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 HOST_STATE_VARS { + // 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/local/app_ipc.rs b/executor/src/local/app_ipc.rs index 377de31f11..72c004c30d 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.") @@ -701,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( @@ -741,6 +766,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 +986,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 +1007,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/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"); diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index 20d91765c7..a20469e5d2 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 +1548,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 +1587,40 @@ 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 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/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index 4a7350eb12..0d15a3bd10 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -2,14 +2,18 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::env; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; +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, TaskSearch}; const TASK_MCP_SERVER_NAME: &str = "wegent_tasks"; @@ -28,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> { @@ -101,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)), @@ -130,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"); @@ -186,6 +258,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( @@ -210,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) + .map_or(true, |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) + .map_or(true, |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) + .map_or(true, |value| task[key] == value) +} + +fn encode_segment(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + fn tools() -> Vec { vec![ tool( @@ -269,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", @@ -319,6 +710,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 +807,83 @@ 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 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()) } @@ -384,6 +906,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 +920,223 @@ mod tests { assert_eq!(request.mcp_servers[0]["type"], "stdio"); 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() + .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)); + } + } + + #[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(); + 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/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 538c092fd9..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, @@ -216,6 +231,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, @@ -241,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 2e00b5b6f4..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, @@ -105,6 +113,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)? { @@ -118,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, @@ -305,28 +442,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 +608,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 +842,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 +1202,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..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, @@ -820,6 +870,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 +1031,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()), @@ -1196,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/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..03e82480f1 --- /dev/null +++ b/wework/src/features/todo/CloudProjectsHome.tsx @@ -0,0 +1,465 @@ +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, memberNameById } 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 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) ?? + item.created_by_user_name ?? + 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..a8d9d83de8 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', }) ) }) @@ -627,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 83c0fd88ef..b7f484f184 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,14 @@ 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 { GlobalTodoSearch } from './GlobalTodoSearch' import { repositoryProviderConfig } from './projectProviderConfig' +import { TaskSearchPanel } from './TaskSearchPanel' import { TodoEditor } from './TodoEditor' -import { - columnDotClasses, - columns, - memberAvatarClasses, - priorityBadgeClasses, - reorderLaneItems, -} from './todoShared' +import { emptyTaskSearchFilters, type TaskSearchFilters } from './taskSearch' +import { columnDotClasses, columns, priorityBadgeClasses, reorderLaneItems } from './todoShared' type ProjectView = 'board' | 'files' | 'manage' type RootView = 'projects' | 'my-work' @@ -172,6 +171,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 +191,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 +708,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 @@ -676,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) @@ -706,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 @@ -742,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' @@ -770,10 +849,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 +864,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 +882,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 +915,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 +968,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) @@ -1005,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 ( - - ) - })} + })}
@@ -1116,7 +1180,9 @@ export function CloudTodoWorkspace({ setSelectedItem(item)} + onSelectItem={item => { + if (item.can_view_detail !== false) setSelectedItem(item) + }} /> ) : loading ? (
@@ -1138,89 +1204,24 @@ 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 => { + if (item.can_view_detail !== false) setSelectedItem(item) + }} + onOpenMyWork={() => setRootView('my-work')} + /> ) : ( <>
事项 - - + {selectedProject.access_role !== 'RestrictedAnalyst' && ( + + )} + {['Owner', 'Maintainer'].includes(selectedProject.access_role ?? 'Owner') && ( + + )} {projectView === 'board' && ( <> + + {projectSearchOpen && ( + { + if (item.can_view_detail === false) return + setSelectedItem(item) + setProjectSearchOpen(false) + }} + /> + )} {availableTags.length > 0 && ( @@ -1308,14 +1338,16 @@ export function CloudTodoWorkspace({ )} - + {canCreateBoardTask && ( + + )} )}
@@ -1403,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 (
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)} /> @@ -1462,18 +1491,20 @@ export function CloudTodoWorkspace({
)} -
- -
+ {canCreateBoardTask && ( +
+ +
+ )}
) })} @@ -1496,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 341434a79e..fe3f47fea3 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,22 @@ 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?.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) 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 +406,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 +725,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/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/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 diff --git a/wework/src/features/workbench/workbenchServices.test.ts b/wework/src/features/workbench/workbenchServices.test.ts index 3597acd911..edefe05389 100644 --- a/wework/src/features/workbench/workbenchServices.test.ts +++ b/wework/src/features/workbench/workbenchServices.test.ts @@ -23,12 +23,16 @@ 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(), + 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', @@ -79,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) }) }) diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 4209af6302..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", @@ -1706,6 +1713,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 +1849,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..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": "站点", @@ -1705,6 +1712,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 +1848,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": "更新项目权限失败" } }