diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py index 0f6c011..52f4711 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py @@ -1,7 +1,8 @@ +import re from html import escape from django.core.exceptions import ValidationError -from django.db import transaction +from django.db import IntegrityError, transaction from django.http import JsonResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -30,6 +31,11 @@ NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks" AGENT_EMAIL_DOMAIN = "agents.nodedc.local" AGENT_BOT_TYPE = "nodedc_codex_agent" ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"] +DEFAULT_AGENT_LABEL_COLOR = "#4E5355" +MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000 +MARKDOWN_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$") +HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$") +DATA_IMAGE_PATTERN = re.compile(r"^data:image/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$") def unauthorized_response(): @@ -169,24 +175,80 @@ def get_agent_identity(request): } -def ensure_agent_actor(request, workspace, project=None): +def get_agent_payload_metadata(payload): + if not isinstance(payload, dict): + return {} + + metadata = payload.get("_agent") + return metadata if isinstance(metadata, dict) else {} + + +def normalize_agent_display_name(value, fallback): + if isinstance(value, str): + display_name = value.strip() + if display_name: + return display_name[:120] + + return fallback + + +def normalize_agent_avatar_url(value): + if not isinstance(value, str): + return "" + + avatar_url = value.strip() + if not avatar_url or len(avatar_url) > MAX_AGENT_AVATAR_URL_LENGTH: + return "" + + if avatar_url.startswith("data:image/"): + return avatar_url if DATA_IMAGE_PATTERN.match(avatar_url) else "" + + if avatar_url.startswith("http://") or avatar_url.startswith("https://"): + return avatar_url + + return "" + + +def get_agent_profile(request, payload=None): + identity = get_agent_identity(request) + if identity is None: + return None + + short_id = identity["agent_id"].replace("-", "")[:12] + fallback_display_name = f"Codex Agent {short_id}" + metadata = get_agent_payload_metadata(payload) + + return { + "display_name": normalize_agent_display_name(metadata.get("display_name"), fallback_display_name), + "avatar_url": normalize_agent_avatar_url(metadata.get("avatar_url")), + "short_id": short_id, + } + + +def ensure_agent_actor(request, workspace, project=None, payload=None): identity = get_agent_identity(request) if identity is None: return None agent_id = identity["agent_id"] - short_id = agent_id.replace("-", "")[:12] + profile = get_agent_profile(request, payload) + if profile is None: + return None + + short_id = profile["short_id"] email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}" username = f"nodedc_agent_{short_id}" - display_name = f"Codex Agent {short_id}" + display_name = profile["display_name"] + avatar_url = profile["avatar_url"] user, _ = User.objects.get_or_create( email=email, defaults={ "username": username, "display_name": display_name, - "first_name": "Codex", - "last_name": f"Agent {short_id}", + "first_name": display_name, + "last_name": "", + "avatar": avatar_url, "is_bot": True, "bot_type": AGENT_BOT_TYPE, "is_active": True, @@ -206,6 +268,18 @@ def ensure_agent_actor(request, workspace, project=None): if user.display_name != display_name: user.display_name = display_name update_fields.append("display_name") + if user.first_name != display_name: + user.first_name = display_name + update_fields.append("first_name") + if user.last_name: + user.last_name = "" + update_fields.append("last_name") + if avatar_url and user.avatar != avatar_url: + user.avatar = avatar_url + update_fields.append("avatar") + if avatar_url and user.avatar_asset_id: + user.avatar_asset = None + update_fields.append("avatar_asset") if update_fields: update_fields.append("updated_at") @@ -278,6 +352,100 @@ def html_from_text(value): return f"
{escape(text)}
" if text else "" +def normalize_structured_title(value, fallback): + title = value.strip() if isinstance(value, str) else "" + if not title: + return fallback + + heading_match = MARKDOWN_HEADING_PATTERN.match(title) + if heading_match: + return heading_match.group(1).strip() or fallback + + return title + + +def split_leading_markdown_heading(value): + if not isinstance(value, str): + return None, "" + + lines = value.splitlines() + leading_blank_count = 0 + for line in lines: + if line.strip(): + break + leading_blank_count += 1 + + if leading_blank_count >= len(lines): + return None, "" + + first_content_line = lines[leading_blank_count] + heading_match = MARKDOWN_HEADING_PATTERN.match(first_content_line) + if not heading_match: + return None, value.strip() + + body_lines = lines[leading_blank_count + 1 :] + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + + return heading_match.group(1).strip(), "\n".join(body_lines).strip() + + +def normalize_structured_block(block): + if not isinstance(block, dict): + return None + + block_type = block.get("type") + if block_type == "text": + extracted_title, body = split_leading_markdown_heading(block.get("body")) + title = normalize_structured_title(block.get("title") or extracted_title, "Раздел") + + return { + "id": str(block.get("id") or f"text-{title.lower().replace(' ', '-')}"), + "type": "text", + "title": title, + "body": body, + } + + if block_type == "checker": + title = normalize_structured_title(block.get("title"), "Чеклист") + raw_items = block.get("items") + items = [] + if isinstance(raw_items, list): + for item in raw_items: + if not isinstance(item, dict): + continue + text = item.get("text") + if not isinstance(text, str) or not text.strip(): + continue + items.append( + { + "id": str(item.get("id") or f"item-{len(items) + 1}"), + "text": text.strip(), + "checked": item.get("checked") is True, + } + ) + + return { + "id": str(block.get("id") or f"checker-{title.lower().replace(' ', '-')}"), + "type": "checker", + "title": title, + "items": items, + } + + return None + + +def normalize_structured_blocks(structured_blocks): + normalized_blocks = [] + for block in structured_blocks: + normalized_block = normalize_structured_block(block) + if normalized_block is None: + return None + normalized_blocks.append(normalized_block) + + return normalized_blocks + + def merge_structured_blocks(detail_layout, structured_blocks): if structured_blocks is None: return detail_layout or {} @@ -285,8 +453,12 @@ def merge_structured_blocks(detail_layout, structured_blocks): if not isinstance(structured_blocks, list): return None + normalized_blocks = normalize_structured_blocks(structured_blocks) + if normalized_blocks is None: + return None + updated_layout = dict(detail_layout or {}) - updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks + updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = normalized_blocks return updated_layout @@ -301,6 +473,72 @@ def update_issue_labels(issue, label_ids): return True +def normalize_label_color(value): + if isinstance(value, str) and HEX_COLOR_PATTERN.match(value.strip()): + return value.strip() + return DEFAULT_AGENT_LABEL_COLOR + + +def normalize_label_input(raw_label): + if isinstance(raw_label, str): + name = raw_label.strip() + color = DEFAULT_AGENT_LABEL_COLOR + elif isinstance(raw_label, dict): + name = raw_label.get("name").strip() if isinstance(raw_label.get("name"), str) else "" + color = normalize_label_color(raw_label.get("color")) + else: + return None + + if not name: + return None + + return { + "name": name[:255], + "color": color, + } + + +def ensure_project_labels(project, raw_labels, actor, agent_id): + if not isinstance(raw_labels, list) or not raw_labels or len(raw_labels) > 50: + return None + + ensured_labels = [] + for raw_label in raw_labels: + label_input = normalize_label_input(raw_label) + if label_input is None: + return None + + label = Label.objects.filter( + project=project, + name__iexact=label_input["name"], + deleted_at__isnull=True, + ).first() + + if label is None: + try: + label = Label( + project=project, + workspace=project.workspace, + name=label_input["name"], + color=label_input["color"], + external_source=AGENT_BOT_TYPE, + external_id=agent_id, + ) + label.save(created_by_id=actor.id) + except IntegrityError: + label = Label.objects.filter( + project=project, + name__iexact=label_input["name"], + deleted_at__isnull=True, + ).first() + if label is None: + return None + + ensured_labels.append(label) + + return ensured_labels + + def update_issue_assignees(issue, member_ids): project_members = list( ProjectMember.objects.filter( @@ -413,6 +651,42 @@ class NodeDCAgentProjectContextEndpoint(View): ) +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentProjectLabelsEnsureEndpoint(View): + def post(self, request, project_id): + error_response = validate_internal_request(request) + if error_response is not None: + return error_response + + payload = parse_json_body(request) + if payload is None: + return invalid_json_response() + + project = resolve_project(project_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + + entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) + if entitlement_error is not None: + return entitlement_error + + actor = ensure_agent_actor(request, project.workspace, project, payload) + if actor is None: + return validation_error("missing_agent_headers") + + identity = get_agent_identity(request) + ensured_labels = ensure_project_labels( + project, + payload.get("labels"), + actor, + identity["agent_id"] if identity else "", + ) + if ensured_labels is None: + return validation_error("invalid_labels") + + return JsonResponse({"ok": True, "labels": [serialize_label(label) for label in ensured_labels]}, status=201) + + @method_decorator(csrf_exempt, name="dispatch") class NodeDCAgentIssueListEndpoint(View): def get(self, request): @@ -473,7 +747,7 @@ class NodeDCAgentIssueListEndpoint(View): return validation_error("invalid_structured_blocks") with transaction.atomic(): - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -539,7 +813,7 @@ class NodeDCAgentIssueUpdateEndpoint(View): update_fields.append("detail_layout") if update_fields: - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") issue.updated_by = actor @@ -575,7 +849,7 @@ class NodeDCAgentIssueMoveEndpoint(View): if state is None: return validation_error("state_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -615,7 +889,7 @@ class NodeDCAgentIssueCommentEndpoint(View): if not isinstance(body, str) or not body.strip(): return validation_error("body_required") - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -659,7 +933,7 @@ class NodeDCAgentIssueLabelsEndpoint(View): if not update_issue_labels(issue, label_ids): return validation_error("label_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is not None: issue.updated_by = actor issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) @@ -696,7 +970,7 @@ class NodeDCAgentIssueAssigneesEndpoint(View): if not update_issue_assignees(issue, member_ids): return validation_error("member_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is not None: issue.updated_by = actor issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 8cffc77..96d2b2b 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -23,6 +23,7 @@ from plane.authentication.views.nodedc_agent_adapter import ( NodeDCAgentIssueMoveEndpoint, NodeDCAgentIssueUpdateEndpoint, NodeDCAgentProjectContextEndpoint, + NodeDCAgentProjectLabelsEnsureEndpoint, NodeDCAgentProjectResolveEndpoint, ) from plane.authentication.views.nodedc_workspace_adapter import ( @@ -94,6 +95,11 @@ urlpatterns = [ NodeDCAgentProjectContextEndpoint.as_view(), name="nodedc-agent-project-context", ), + path( + "api/internal/nodedc/agent/projects/