FEAT - TASKER: internal adapter для Codex Agent Gateway
This commit is contained in:
parent
533f8c6356
commit
2ae353c8d5
|
|
@ -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"<p>{escape(text)}</p>" if text else "<p></p>"
|
||||||
|
|
||||||
|
|
||||||
|
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)})
|
||||||
|
|
@ -15,6 +15,16 @@ from plane.authentication.views.nodedc_logout import (
|
||||||
NodeDCFrontChannelLogoutEndpoint,
|
NodeDCFrontChannelLogoutEndpoint,
|
||||||
NodeDCInternalSessionLogoutEndpoint,
|
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 (
|
from plane.authentication.views.nodedc_workspace_adapter import (
|
||||||
NodeDCInternalProjectMembershipEnsureEndpoint,
|
NodeDCInternalProjectMembershipEnsureEndpoint,
|
||||||
NodeDCInternalProjectMembershipRemoveEndpoint,
|
NodeDCInternalProjectMembershipRemoveEndpoint,
|
||||||
|
|
@ -74,6 +84,46 @@ urlpatterns = [
|
||||||
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
|
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
|
||||||
name="nodedc-internal-project-membership-remove",
|
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/<uuid:project_id>/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/<uuid:issue_id>",
|
||||||
|
NodeDCAgentIssueUpdateEndpoint.as_view(),
|
||||||
|
name="nodedc-agent-issue-update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/internal/nodedc/agent/issues/<uuid:issue_id>/move",
|
||||||
|
NodeDCAgentIssueMoveEndpoint.as_view(),
|
||||||
|
name="nodedc-agent-issue-move",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments",
|
||||||
|
NodeDCAgentIssueCommentEndpoint.as_view(),
|
||||||
|
name="nodedc-agent-issue-comment",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
|
||||||
|
NodeDCAgentIssueLabelsEndpoint.as_view(),
|
||||||
|
name="nodedc-agent-issue-labels",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/internal/nodedc/agent/issues/<uuid:issue_id>/assignees",
|
||||||
|
NodeDCAgentIssueAssigneesEndpoint.as_view(),
|
||||||
|
name="nodedc-agent-issue-assignees",
|
||||||
|
),
|
||||||
path("api/", include("plane.app.urls")),
|
path("api/", include("plane.app.urls")),
|
||||||
path("api/public/", include("plane.space.urls")),
|
path("api/public/", include("plane.space.urls")),
|
||||||
path("api/instances/", include("plane.license.urls")),
|
path("api/instances/", include("plane.license.urls")),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue