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 new file mode 100644 index 0000000..e721b6c --- /dev/null +++ b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py @@ -0,0 +1,641 @@ +from html import escape + +from django.db import transaction +from django.http import JsonResponse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from plane.app.realtime.issue_events import publish_issue_event_on_commit +from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized +from plane.authentication.views.nodedc_workspace_adapter import parse_json_body +from plane.db.models import ( + Issue, + IssueAssignee, + IssueComment, + IssueLabel, + Label, + Project, + ProjectMember, + State, + User, + WorkspaceMember, +) + + +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"] + + +def unauthorized_response(): + return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401) + + +def invalid_json_response(): + return JsonResponse({"ok": False, "error": "invalid_json"}, status=400) + + +def validation_error(error, status=400): + return JsonResponse({"ok": False, "error": error}, status=status) + + +def serialize_project(project): + return { + "id": str(project.id), + "workspace_slug": project.workspace.slug, + "name": project.name, + "identifier": project.identifier, + } + + +def serialize_state(state): + return { + "id": str(state.id), + "name": state.name, + "color": state.color, + "group": state.group, + "sequence": state.sequence, + "default": state.default, + } + + +def serialize_label(label): + return { + "id": str(label.id), + "name": label.name, + "color": label.color, + } + + +def serialize_member(project_member): + return { + "id": str(project_member.member_id), + "project_member_id": str(project_member.id), + "display_name": project_member.member.display_name, + "email": project_member.member.email, + "role": project_member.role, + } + + +def serialize_issue(issue): + return { + "id": str(issue.id), + "project_id": str(issue.project_id), + "workspace_slug": issue.workspace.slug, + "title": issue.name, + "description": issue.description_html, + "priority": issue.priority, + "state_id": str(issue.state_id) if issue.state_id else None, + "sequence_id": issue.sequence_id, + "detail_layout": issue.detail_layout or {}, + "label_ids": [ + str(label_id) + for label_id in IssueLabel.objects.filter( + issue=issue, + deleted_at__isnull=True, + ).values_list("label_id", flat=True) + ], + "assignee_ids": [ + str(assignee_id) + for assignee_id in IssueAssignee.objects.filter( + issue=issue, + deleted_at__isnull=True, + ).values_list("assignee_id", flat=True) + ], + "created_by": str(issue.created_by_id) if issue.created_by_id else None, + "updated_by": str(issue.updated_by_id) if issue.updated_by_id else None, + "created_at": issue.created_at.isoformat(), + "updated_at": issue.updated_at.isoformat(), + } + + +def serialize_comment(comment): + return { + "id": str(comment.id), + "issue_id": str(comment.issue_id), + "body": comment.comment_html, + "actor_id": str(comment.actor_id) if comment.actor_id else None, + "created_at": comment.created_at.isoformat(), + } + + +def resolve_project(project_id, workspace_slug=None): + queryset = Project.objects.filter( + id=project_id, + deleted_at__isnull=True, + archived_at__isnull=True, + ).select_related("workspace") + + if workspace_slug: + queryset = queryset.filter(workspace__slug=workspace_slug) + + return queryset.first() + + +def resolve_issue(project_id, issue_id, workspace_slug=None): + project = resolve_project(project_id, workspace_slug) + if project is None: + return None, None + + issue = ( + Issue.issue_objects.filter(project=project, id=issue_id) + .select_related("workspace", "project", "state") + .first() + ) + return project, issue + + +def get_agent_header(request, key): + return request.headers.get(key, "").strip() + + +def get_agent_identity(request): + agent_id = get_agent_header(request, "X-NODEDC-Agent-Id") + owner_user_id = get_agent_header(request, "X-NODEDC-Agent-Owner-User-Id") + token_id = get_agent_header(request, "X-NODEDC-Agent-Token-Id") + + if not agent_id or not owner_user_id or not token_id: + return None + + return { + "agent_id": agent_id, + "owner_user_id": owner_user_id, + "token_id": token_id, + } + + +def ensure_agent_actor(request, workspace, project=None): + identity = get_agent_identity(request) + if identity is None: + return None + + agent_id = identity["agent_id"] + short_id = agent_id.replace("-", "")[:12] + email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}" + username = f"nodedc_agent_{short_id}" + display_name = f"Codex Agent {short_id}" + + user, _ = User.objects.get_or_create( + email=email, + defaults={ + "username": username, + "display_name": display_name, + "first_name": "Codex", + "last_name": f"Agent {short_id}", + "is_bot": True, + "bot_type": AGENT_BOT_TYPE, + "is_active": True, + }, + ) + + update_fields = [] + if not user.is_bot: + user.is_bot = True + update_fields.append("is_bot") + if user.bot_type != AGENT_BOT_TYPE: + user.bot_type = AGENT_BOT_TYPE + update_fields.append("bot_type") + if not user.is_active: + user.is_active = True + update_fields.append("is_active") + if user.display_name != display_name: + user.display_name = display_name + update_fields.append("display_name") + + if update_fields: + update_fields.append("updated_at") + user.save(update_fields=update_fields) + + WorkspaceMember.objects.get_or_create( + workspace=workspace, + member=user, + defaults={ + "role": 15, + "company_role": "codex-agent", + "is_active": True, + "is_banned": False, + }, + ) + + if project is not None: + ProjectMember.objects.get_or_create( + workspace=workspace, + project=project, + member=user, + defaults={ + "role": 15, + "is_active": True, + }, + ) + + return user + + +def validate_internal_request(request): + if not is_internal_logout_request_authorized(request): + return unauthorized_response() + + if get_agent_identity(request) is None: + return validation_error("missing_agent_headers", status=400) + + return None + + +def html_from_text(value): + text = value.strip() if isinstance(value, str) else "" + return f"

{escape(text)}

" if text else "

" + + +def merge_structured_blocks(detail_layout, structured_blocks): + if structured_blocks is None: + return detail_layout or {} + + if not isinstance(structured_blocks, list): + return None + + updated_layout = dict(detail_layout or {}) + updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks + return updated_layout + + +def update_issue_labels(issue, label_ids): + labels = list(Label.objects.filter(id__in=label_ids, project=issue.project, deleted_at__isnull=True)) + if len(labels) != len(set(label_ids)): + return False + + IssueLabel.objects.filter(issue=issue, deleted_at__isnull=True).delete() + for label in labels: + IssueLabel.objects.create(issue=issue, label=label, project=issue.project, workspace=issue.workspace) + return True + + +def update_issue_assignees(issue, member_ids): + project_members = list( + ProjectMember.objects.filter( + project=issue.project, + member_id__in=member_ids, + is_active=True, + deleted_at__isnull=True, + ).select_related("member") + ) + if len(project_members) != len(set(member_ids)): + return False + + IssueAssignee.objects.filter(issue=issue, deleted_at__isnull=True).delete() + for project_member in project_members: + IssueAssignee.objects.create( + issue=issue, + assignee=project_member.member, + project=issue.project, + workspace=issue.workspace, + ) + return True + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentProjectResolveEndpoint(View): + def post(self, request): + 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() + + grants = payload.get("grants") + if not isinstance(grants, list): + return validation_error("grants_required") + + project_filters = [] + workspace_slugs = [] + for grant in grants: + if not isinstance(grant, dict): + continue + workspace_slug = grant.get("workspace_slug") + project_id = grant.get("project_id") + if not isinstance(workspace_slug, str) or not workspace_slug: + continue + if isinstance(project_id, str) and project_id: + project_filters.append((workspace_slug, project_id)) + else: + workspace_slugs.append(workspace_slug) + + projects = [] + if workspace_slugs: + projects.extend( + Project.objects.filter( + workspace__slug__in=workspace_slugs, + deleted_at__isnull=True, + archived_at__isnull=True, + ).select_related("workspace") + ) + for workspace_slug, project_id in project_filters: + project = resolve_project(project_id, workspace_slug) + if project is not None: + projects.append(project) + + unique_projects = {str(project.id): project for project in projects} + return JsonResponse( + { + "ok": True, + "projects": [serialize_project(project) for project in unique_projects.values()], + } + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentProjectContextEndpoint(View): + def get(self, request, project_id): + error_response = validate_internal_request(request) + if error_response is not None: + return error_response + + project = resolve_project(project_id, request.GET.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + + states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence") + labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order") + members = ( + ProjectMember.objects.filter(project=project, is_active=True, deleted_at__isnull=True, member__is_bot=False) + .select_related("member") + .order_by("member__display_name") + ) + + return JsonResponse( + { + "ok": True, + "project": serialize_project(project), + "states": [serialize_state(state) for state in states], + "labels": [serialize_label(label) for label in labels], + "members": [serialize_member(member) for member in members], + } + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueListEndpoint(View): + def get(self, request): + error_response = validate_internal_request(request) + if error_response is not None: + return error_response + + project_id = request.GET.get("project_id") + if not project_id: + return validation_error("project_id_required") + + project = resolve_project(project_id, request.GET.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + + queryset = ( + Issue.issue_objects.filter(project=project) + .select_related("workspace", "project", "state") + .order_by("-updated_at") + ) + query = request.GET.get("query") + if query: + queryset = queryset.filter(name__icontains=query) + + return JsonResponse({"ok": True, "issues": [serialize_issue(issue) for issue in queryset[:100]]}) + + def post(self, request): + 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(payload.get("project_id"), payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + + title = payload.get("title") + if not isinstance(title, str) or not title.strip(): + return validation_error("title_required") + + priority = payload.get("priority") or "none" + if priority not in ALLOWED_PRIORITIES: + return validation_error("invalid_priority") + + detail_layout = merge_structured_blocks({}, payload.get("structured_blocks")) + if detail_layout is None: + return validation_error("invalid_structured_blocks") + + with transaction.atomic(): + actor = ensure_agent_actor(request, project.workspace, project) + if actor is None: + return validation_error("missing_agent_headers") + + issue = Issue( + project=project, + workspace=project.workspace, + name=title.strip(), + description_html=html_from_text(payload.get("description")), + priority=priority, + detail_layout=detail_layout, + external_source=AGENT_BOT_TYPE, + external_id=get_agent_header(request, "X-NODEDC-Agent-Id"), + ) + issue.save(created_by_id=actor.id) + + publish_issue_event_on_commit("issue.created", issue, actor_id=actor.id, changed_fields=payload.keys()) + return JsonResponse({"ok": True, "issue": serialize_issue(issue)}, status=201) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueUpdateEndpoint(View): + def patch(self, request, issue_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, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + if issue is None: + return validation_error("issue_not_found", status=404) + + detail_layout = ( + merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks")) + if "structured_blocks" in payload + else issue.detail_layout + ) + if detail_layout is None: + return validation_error("invalid_structured_blocks") + + update_fields = [] + if isinstance(payload.get("title"), str) and payload["title"].strip(): + issue.name = payload["title"].strip() + update_fields.append("name") + if "description" in payload: + issue.description_html = html_from_text(payload.get("description")) + update_fields.append("description_html") + if payload.get("priority") in ALLOWED_PRIORITIES: + issue.priority = payload["priority"] + update_fields.append("priority") + elif "priority" in payload: + return validation_error("invalid_priority") + if "structured_blocks" in payload: + issue.detail_layout = detail_layout + update_fields.append("detail_layout") + + if update_fields: + actor = ensure_agent_actor(request, project.workspace, project) + if actor is None: + return validation_error("missing_agent_headers") + issue.updated_by = actor + update_fields.extend(["updated_by", "updated_at"]) + issue.save(update_fields=update_fields, disable_auto_set_user=True) + publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=update_fields) + + return JsonResponse({"ok": True, "issue": serialize_issue(issue)}) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueMoveEndpoint(View): + def post(self, request, issue_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, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + if issue is None: + return validation_error("issue_not_found", status=404) + + state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first() + if state is None: + return validation_error("state_not_found", status=404) + + actor = ensure_agent_actor(request, project.workspace, project) + if actor is None: + return validation_error("missing_agent_headers") + + issue.state = state + issue.updated_by = actor + if state.group == "completed": + issue.completed_at = timezone.now() + else: + issue.completed_at = None + issue.save(update_fields=["state", "updated_by", "completed_at", "updated_at"], disable_auto_set_user=True) + publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["state"]) + return JsonResponse({"ok": True, "issue": serialize_issue(issue)}) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueCommentEndpoint(View): + def post(self, request, issue_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, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + if issue is None: + return validation_error("issue_not_found", status=404) + + body = payload.get("body") + if not isinstance(body, str) or not body.strip(): + return validation_error("body_required") + + actor = ensure_agent_actor(request, project.workspace, project) + if actor is None: + return validation_error("missing_agent_headers") + + comment = IssueComment( + project=project, + workspace=project.workspace, + issue=issue, + actor=actor, + comment_html=html_from_text(body), + comment_json={}, + ) + comment.save(created_by_id=actor.id) + return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueLabelsEndpoint(View): + def put(self, request, issue_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, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + if issue is None: + return validation_error("issue_not_found", status=404) + + label_ids = payload.get("label_ids") + if not isinstance(label_ids, list): + return validation_error("label_ids_required") + + if not update_issue_labels(issue, label_ids): + return validation_error("label_not_found", status=404) + + actor = ensure_agent_actor(request, project.workspace, project) + if actor is not None: + issue.updated_by = actor + issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) + publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["labels"]) + + return JsonResponse({"ok": True, "issue": serialize_issue(issue)}) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentIssueAssigneesEndpoint(View): + def put(self, request, issue_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, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + if issue is None: + return validation_error("issue_not_found", status=404) + + member_ids = payload.get("member_ids") + if not isinstance(member_ids, list): + return validation_error("member_ids_required") + + if not update_issue_assignees(issue, member_ids): + return validation_error("member_not_found", status=404) + + actor = ensure_agent_actor(request, project.workspace, project) + if actor is not None: + issue.updated_by = actor + issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) + publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["assignees"]) + + return JsonResponse({"ok": True, "issue": serialize_issue(issue)}) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 659ad8a..8cffc77 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -15,6 +15,16 @@ from plane.authentication.views.nodedc_logout import ( NodeDCFrontChannelLogoutEndpoint, NodeDCInternalSessionLogoutEndpoint, ) +from plane.authentication.views.nodedc_agent_adapter import ( + NodeDCAgentIssueAssigneesEndpoint, + NodeDCAgentIssueCommentEndpoint, + NodeDCAgentIssueLabelsEndpoint, + NodeDCAgentIssueListEndpoint, + NodeDCAgentIssueMoveEndpoint, + NodeDCAgentIssueUpdateEndpoint, + NodeDCAgentProjectContextEndpoint, + NodeDCAgentProjectResolveEndpoint, +) from plane.authentication.views.nodedc_workspace_adapter import ( NodeDCInternalProjectMembershipEnsureEndpoint, NodeDCInternalProjectMembershipRemoveEndpoint, @@ -74,6 +84,46 @@ urlpatterns = [ NodeDCInternalProjectMembershipRemoveEndpoint.as_view(), name="nodedc-internal-project-membership-remove", ), + path( + "api/internal/nodedc/agent/projects/resolve", + NodeDCAgentProjectResolveEndpoint.as_view(), + name="nodedc-agent-project-resolve", + ), + path( + "api/internal/nodedc/agent/projects//context", + NodeDCAgentProjectContextEndpoint.as_view(), + name="nodedc-agent-project-context", + ), + path( + "api/internal/nodedc/agent/issues", + NodeDCAgentIssueListEndpoint.as_view(), + name="nodedc-agent-issue-list", + ), + path( + "api/internal/nodedc/agent/issues/", + NodeDCAgentIssueUpdateEndpoint.as_view(), + name="nodedc-agent-issue-update", + ), + path( + "api/internal/nodedc/agent/issues//move", + NodeDCAgentIssueMoveEndpoint.as_view(), + name="nodedc-agent-issue-move", + ), + path( + "api/internal/nodedc/agent/issues//comments", + NodeDCAgentIssueCommentEndpoint.as_view(), + name="nodedc-agent-issue-comment", + ), + path( + "api/internal/nodedc/agent/issues//labels", + NodeDCAgentIssueLabelsEndpoint.as_view(), + name="nodedc-agent-issue-labels", + ), + path( + "api/internal/nodedc/agent/issues//assignees", + NodeDCAgentIssueAssigneesEndpoint.as_view(), + name="nodedc-agent-issue-assignees", + ), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")),