FEAT - TASKER: support Codex agent labels and profiles

This commit is contained in:
DCCONSTRUCTIONS
2026-05-15 14:25:36 +03:00
parent b0a682b63b
commit 6962642614
3 changed files with 557 additions and 85 deletions
@@ -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"<p>{escape(text)}</p>" if text else "<p></p>"
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)
+6
View File
@@ -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/<uuid:project_id>/labels/ensure",
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
name="nodedc-agent-project-labels-ensure",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),