plane icon indicating copy to clipboard operation
plane copied to clipboard

[bug]: Issue deletion webhooks never fire (create/update work)

Open ilgaur opened this issue 1 month ago • 1 comments

Is there an existing issue for this?

  • [x] I have searched the existing issues

Current behavior

Issue deletion webhooks never fire (create/update work). External integrations such as Mattermost only receive the issue.created event; the delete action is missing. Confirmed on branch e8bdc47d6af6c39454a795a59098c0f2c34330a8 using the Docker local stack (docker-compose-local.yml). Database webhook_logs shows only action = 'created'; the delete event is absent.

Action Expected webhook? Actual
Create
Update
Delete

Verification query:

SELECT event_type, request_body->>'action' AS action
FROM webhook_logs
ORDER BY created_at DESC
LIMIT 5;

Output:

 event_type | action
------------+--------
 issue      | created
(1 row)

Steps to reproduce

  1. Checkout a fresh workspace

    git checkout e8bdc47d6af6c39454a795a59098c0f2c34330a8
    docker compose -f docker-compose-local.yml down -v
    docker compose -f docker-compose-local.yml up -d plane-db plane-redis plane-mq
    docker compose -f docker-compose-local.yml run --rm migrator
    docker compose -f docker-compose-local.yml up -d --build api worker beat-worker
    
  2. Seed a minimal workspace/project/webhook (inside api container)

    docker compose -f docker-compose-local.yml exec -T api python - <<'PY'
    import os, uuid
    from django.utils import timezone
    from django.contrib.auth.hashers import make_password
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.local")
    import django; django.setup()
    
    from plane.db.models.api import APIToken
    from plane.db.models.project import Project, ProjectIdentifier, ProjectMember
    from plane.db.models.state import State
    from plane.db.models.user import User
    from plane.db.models.workspace import Workspace, WorkspaceMember
    from plane.db.models.webhook import Webhook, ProjectWebhook
    from plane.db.models.issue_type import IssueType, ProjectIssueType
    from plane.license.models import Instance, InstanceAdmin
    
    email, password, slug, identifier = "[email protected]", "Passw0rd!", "test-workspace", "TST"
    
    instance, _ = Instance.objects.get_or_create(
        defaults={
            "instance_name": "Local Instance",
            "instance_id": uuid.uuid4().hex,
            "current_version": "0.0.0",
            "last_checked_at": timezone.now(),
        }
    )
    instance.is_setup_done = True
    instance.save()
    
    user, _ = User.objects.get_or_create(
        email=email,
        defaults={
            "username": uuid.uuid4().hex,
            "first_name": "Admin",
            "last_name": "User",
            "password": make_password(password),
            "is_active": True,
            "is_superuser": True,
        },
    )
    InstanceAdmin.objects.get_or_create(user=user, instance=instance)
    
    workspace, _ = Workspace.objects.get_or_create(
        slug=slug,
        defaults={"name": "Test Workspace", "owner": user},
    )
    WorkspaceMember.objects.get_or_create(workspace=workspace, member=user, defaults={"role": 20})
    
    project, _ = Project.objects.get_or_create(
        workspace=workspace,
        identifier=identifier,
        defaults={"name": "Test Project"},
    )
    ProjectIdentifier.objects.get_or_create(project=project, defaults={"workspace": workspace, "name": identifier})
    ProjectMember.objects.get_or_create(project=project, member=user, defaults={"role": 20})
    
    state, _ = State.objects.get_or_create(
        workspace=workspace,
        project=project,
        name="Todo",
        defaults={"color": "#FF0000", "group": "backlog", "default": True},
    )
    if project.default_state_id != state.id:
        project.default_state = state
        project.save(update_fields=["default_state"])
    
    issue_type, _ = IssueType.objects.get_or_create(
        workspace=workspace,
        name="Task",
        defaults={"is_default": True},
    )
    ProjectIssueType.objects.get_or_create(project=project, issue_type=issue_type, defaults={"is_default": True})
    
    webhook, _ = Webhook.objects.get_or_create(
        workspace=workspace,
        url="http://plane-webhook-receiver:8888/webhook",
        defaults={"is_active": True, "issue": True, "is_internal": True},
    )
    ProjectWebhook.objects.get_or_create(project=project, webhook=webhook)
    
    token, _ = APIToken.objects.get_or_create(
        user=user,
        workspace=workspace,
        defaults={"label": "local-test"},
    )
    print("API_TOKEN:", token.token)
    print("WORKSPACE_SLUG:", slug)
    print("PROJECT_ID:", project.id)
    PY
    

    Note the API_TOKEN, WORKSPACE_SLUG, and PROJECT_ID printed here.

  3. Run a simple webhook receiver (new terminal)

    docker rm -f plane-webhook-receiver >/dev/null 2>&1 || true
    docker run --rm --name plane-webhook-receiver --network plane_dev_env python:3.12-slim \
      python -u - <<'PY'
    import json, http.server, socketserver
    class Handler(http.server.BaseHTTPRequestHandler):
        def do_POST(self):
            payload = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
            print("\n" + "="*50)
            print("Webhook received")
            print("Action:", payload.get("action"))
            print("Body:", json.dumps(payload, indent=2))
            print("="*50 + "\n")
            self.send_response(200); self.end_headers()
            self.wfile.write(b'{"status": "ok"}')
        def do_GET(self):
            self.send_response(200); self.end_headers()
            self.wfile.write(b'{"status": "ok"}')
        def log_message(self, *_): pass
    socketserver.TCPServer(("", 8888), Handler).serve_forever()
    PY
    
  4. Create / update / delete an issue via API

    TOKEN=<API_TOKEN from step 2>
    WORKSPACE=test-workspace
    PROJECT=<PROJECT_ID from step 2>
    
    docker compose -f docker-compose-local.yml exec -T api python - <<PY
    import json, urllib.request
    TOKEN="${TOKEN}"
    WORKSPACE="${WORKSPACE}"
    PROJECT="${PROJECT}"
    BASE="http://localhost:8000/api/v1"
    
    def call(method, path, payload=None):
        data = json.dumps(payload).encode() if payload else None
        req = urllib.request.Request(
            f"{BASE}{path}",
            data=data,
            headers={"Content-Type": "application/json", "X-Api-Key": TOKEN},
            method=method,
        )
        with urllib.request.urlopen(req) as resp:
            body = resp.read().decode()
            print(method, path, resp.status, body)
            return json.loads(body) if body else None
    
    issue = call("POST", f"/workspaces/{WORKSPACE}/projects/{PROJECT}/issues/", {"name": "Webhook test"})
    issue_id = issue["id"]
    call("PATCH", f"/workspaces/{WORKSPACE}/projects/{PROJECT}/issues/{issue_id}/", {"name": "Webhook test updated"})
    call("DELETE", f"/workspaces/{WORKSPACE}/projects/{PROJECT}/issues/{issue_id}/")
    PY
    
  5. Inspect the webhook receiver
    It prints a payload for the create; nothing arrives for the delete.

  6. Double-check webhook_logs

    docker compose -f docker-compose-local.yml exec plane-db \
      psql -U plane -d plane \
      -c "SELECT event_type, request_body->>'action' AS action FROM webhook_logs ORDER BY created_at DESC LIMIT 5;"
    

    Output only shows action = 'created', no deleted row.

Environment

Production

Browser

Google Chrome

Variant

Self-hosted

Version

v1.0.0

ilgaur avatar Nov 01 '25 13:11 ilgaur

Hi team! I’ve opened https://github.com/makeplane/plane/pull/8055 to restore DELETE webhooks for issues. The PR reintroduces the missing model_activity.delay() call on the delete endpoint and ensures the webhook task emits a single verb="deleted" payload. After rebuilding on that branch, both the local receiver and webhook_logs table show the expected created + deleted events, so Mattermost/Slack-style integrations get the delete notifications.

ilgaur avatar Nov 01 '25 13:11 ilgaur